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>
|