Files
datahub/frontend/src/components/ApiKeyPanel.vue
T
2026-04-14 13:45:28 +08:00

292 lines
8.8 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { useApiKeyStore, MAX_KEYS_PER_USER } from '@/stores/api-key'
import { useUserStore } from '@/stores/user'
import {
PlusOutlined,
CopyOutlined,
DeleteOutlined,
} from '@ant-design/icons-vue'
import type { ApiKeyRecord } from '@/types/api'
import dayjs from 'dayjs'
const apiKeyStore = useApiKeyStore()
const userStore = useUserStore()
const apiKeyEnabled = computed(() => userStore.user?.api_key_enabled ?? false)
const hasKeys = computed(() => apiKeyStore.keys.length > 0)
// Create modal
const createModalOpen = ref(false)
const createLoading = ref(false)
const createForm = reactive({
name: '',
expires_at: null as string | null,
})
// Plain key modal
const plainKeyModalOpen = ref(false)
const plainKey = ref('')
function isExpired(record: ApiKeyRecord): boolean {
return !!record.expires_at && dayjs(record.expires_at).isBefore(dayjs())
}
function formatTime(time: string | null): string {
if (!time) return '-'
return time.replace('T', ' ').substring(0, 19)
}
const columns = [
{ title: '名称', dataIndex: 'name', width: 150 },
{ title: '前缀', dataIndex: 'key_prefix', width: 120 },
{ title: '创建时间', dataIndex: 'created_at', width: 170 },
{ title: '过期时间', key: 'expires_at', width: 170 },
{ title: '最后使用', dataIndex: 'last_used_at', width: 170 },
{ title: '状态', key: 'enabled', width: 100 },
{ title: '操作', key: 'action', width: 80 },
]
const readonlyColumns = columns.filter((c) => c.key !== 'enabled' && c.key !== 'action')
function showCreateModal() {
createForm.name = ''
createForm.expires_at = null
createModalOpen.value = true
}
async function handleCreate() {
if (!createForm.name.trim()) {
message.warning('请输入 API Key 名称')
return
}
createLoading.value = true
try {
const result = await apiKeyStore.createKey({
name: createForm.name.trim(),
expires_at: createForm.expires_at || undefined,
})
if (result) {
createModalOpen.value = false
plainKey.value = result.plain_key
plainKeyModalOpen.value = true
}
} finally {
createLoading.value = false
}
}
async function handleToggle(record: ApiKeyRecord, checked: boolean) {
await apiKeyStore.toggleKey(record.id, checked)
}
async function handleDelete(record: ApiKeyRecord) {
await apiKeyStore.deleteKey(record.id)
}
function handleCopyKey() {
navigator.clipboard.writeText(plainKey.value).then(() => {
message.success('已复制')
})
}
function handlePlainKeyModalClose() {
plainKey.value = ''
plainKeyModalOpen.value = false
}
onMounted(() => {
apiKeyStore.fetchMyKeys()
})
</script>
<template>
<!-- Mode 3: api_key_enabled=false 且无 Key -->
<a-empty
v-if="!apiKeyEnabled && !hasKeys"
description="API Key 功能未启用,请联系管理员开启"
/>
<!-- Mode 4: api_key_enabled=false 且有 Key 警告 + 只读表格 -->
<div v-else-if="!apiKeyEnabled && hasKeys">
<a-alert
type="warning"
show-icon
class="mb-4"
message="API Key 功能已被管理员关闭。您的所有 API Key 当前无法用于认证。已有 Key 不会被删除,功能重新开启后将自动恢复。如需开启,请联系管理员。"
/>
<a-table
:columns="readonlyColumns"
:data-source="apiKeyStore.keys"
:loading="apiKeyStore.loading"
:pagination="false"
row-key="id"
:row-class-name="(record: ApiKeyRecord) => isExpired(record) ? 'expired-row' : ''"
size="small"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'expires_at'">
<span v-if="!record.expires_at">永不过期</span>
<span v-else>
<span :class="{ 'text-red-500': isExpired(record) }">
{{ formatTime(record.expires_at) }}
</span>
<a-tag v-if="isExpired(record)" color="red" class="ml-1">已过期</a-tag>
</span>
</template>
<template v-else-if="column.dataIndex === 'key_prefix'">
{{ record.key_prefix }}****
</template>
<template v-else-if="column.dataIndex === 'created_at'">
{{ formatTime(record.created_at) }}
</template>
<template v-else-if="column.dataIndex === 'last_used_at'">
{{ record.last_used_at ? formatTime(record.last_used_at) : '从未使用' }}
</template>
</template>
</a-table>
</div>
<!-- Mode 2: api_key_enabled=true 且无 Key — 引导空状态 -->
<div v-else-if="apiKeyEnabled && !hasKeys && !apiKeyStore.loading">
<a-empty description="还没有 API Key">
<a-button type="primary" @click="showCreateModal">
<template #icon><PlusOutlined /></template>
生成第一个 API Key
</a-button>
</a-empty>
</div>
<!-- Mode 1: api_key_enabled=true 且有 Key — 完整面板 -->
<div v-else-if="apiKeyEnabled">
<div class="flex justify-between items-center mb-3">
<a-tag>{{ apiKeyStore.keyCount }}/{{ MAX_KEYS_PER_USER }}</a-tag>
<a-tooltip
v-if="!apiKeyStore.canCreate"
title="已达上限请删除不需要的 Key 后再生成"
>
<a-button type="primary" disabled>
<template #icon><PlusOutlined /></template>
生成 API Key
</a-button>
</a-tooltip>
<a-button
v-else
type="primary"
@click="showCreateModal"
>
<template #icon><PlusOutlined /></template>
生成 API Key
</a-button>
</div>
<a-table
:columns="columns"
:data-source="apiKeyStore.keys"
:loading="apiKeyStore.loading"
:pagination="false"
row-key="id"
:row-class-name="(record: ApiKeyRecord) => isExpired(record) ? 'expired-row' : ''"
size="small"
>
<template #bodyCell="{ column, record }">
<template v-if="column.dataIndex === 'key_prefix'">
{{ record.key_prefix }}****
</template>
<template v-else-if="column.dataIndex === 'created_at'">
{{ formatTime(record.created_at) }}
</template>
<template v-else-if="column.key === 'expires_at'">
<span v-if="!record.expires_at">永不过期</span>
<span v-else>
<span :class="{ 'text-red-500': isExpired(record) }">
{{ formatTime(record.expires_at) }}
</span>
<a-tag v-if="isExpired(record)" color="red" class="ml-1">已过期</a-tag>
</span>
</template>
<template v-else-if="column.dataIndex === 'last_used_at'">
{{ record.last_used_at ? formatTime(record.last_used_at) : '从未使用' }}
</template>
<template v-else-if="column.key === 'enabled'">
<a-switch
:checked="record.enabled"
:disabled="isExpired(record)"
size="small"
@change="(checked: boolean) => handleToggle(record, checked)"
/>
</template>
<template v-else-if="column.key === 'action'">
<a-popconfirm
title="确定要删除此 API Key "
@confirm="handleDelete(record)"
>
<a-button type="link" size="small" danger>
<template #icon><DeleteOutlined /></template>
</a-button>
</a-popconfirm>
</template>
</template>
</a-table>
</div>
<!-- Loading state -->
<a-spin v-if="apiKeyStore.loading && !hasKeys && apiKeyEnabled" class="mt-4" />
<!-- Create Modal -->
<a-modal
v-model:open="createModalOpen"
title="生成 API Key"
:confirm-loading="createLoading"
@ok="handleCreate"
>
<a-form layout="vertical">
<a-form-item label="名称" required>
<a-input
v-model:value="createForm.name"
placeholder="例如数据同步脚本监控系统"
:maxlength="100"
@press-enter="handleCreate"
/>
</a-form-item>
<a-form-item label="过期时间">
<a-date-picker
v-model:value="createForm.expires_at"
value-format="YYYY-MM-DD HH:mm:ss"
show-time
placeholder="留空表示永不过期"
style="width: 100%"
/>
</a-form-item>
</a-form>
</a-modal>
<!-- Plain Key Modal -->
<a-modal
:open="plainKeyModalOpen"
title="API Key 已生成"
:footer="null"
:mask-closable="false"
@cancel="handlePlainKeyModalClose"
>
<a-alert
type="warning"
show-icon
class="mb-4"
message="此密钥仅显示一次关闭后无法再次查看请妥善保存"
/>
<div class="bg-gray-100 p-3 rounded font-mono text-sm break-all select-all mb-3">
{{ plainKey }}
</div>
<a-button type="primary" block @click="handleCopyKey">
<template #icon><CopyOutlined /></template>
复制 API Key
</a-button>
</a-modal>
</template>
<style scoped>
:deep(.expired-row) {
opacity: 0.5;
}
</style>