292 lines
8.8 KiB
Vue
292 lines
8.8 KiB
Vue
<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>
|