feat: 完善 API Token 管理功能

This commit is contained in:
2026-05-25 12:23:49 +08:00
parent 190ffa34f9
commit 4971cc380f
8 changed files with 279 additions and 44 deletions
+156 -15
View File
@@ -3,6 +3,16 @@
<n-card size="small" class="work-card api-token-card" title="API Token">
<template #header-extra>
<n-space size="small">
<n-button
v-if="selectedTokenIds.length > 0"
size="small"
tertiary
type="error"
:loading="batchDeleting"
@click="confirmBatchDelete"
>
批量删除 {{ selectedTokenIds.length }}
</n-button>
<n-button size="small" type="primary" @click="openCreateDialog">
<template #icon><n-icon><AddOutline /></n-icon></template>
生成 Token
@@ -15,13 +25,18 @@
</template>
<div class="api-token-intro">
API Token 用于内网程序直接调用业务接口。完整 Token 只会在生成或重生成时显示一次,请立即复制保存。
API Token 用于内网程序直接调用业务接口。完整 Token 会保存到本地,可在列表中随时复制。
</div>
<n-spin v-if="loadingTokens" class="api-token-loading" />
<n-empty v-else-if="tokens.length === 0" description="暂无 API Token" />
<n-scrollbar v-else class="api-token-list">
<div v-for="token in tokens" :key="token.id" class="api-token-item">
<n-checkbox
class="api-token-select"
:checked="isTokenSelected(token.id)"
@update:checked="setTokenSelected(token.id, $event)"
/>
<div class="api-token-main">
<div class="api-token-title-row">
<span class="api-token-name">{{ token.name }}</span>
@@ -37,9 +52,16 @@
</div>
</div>
<div class="api-token-actions">
<n-button size="tiny" tertiary @click="openEditDialog(token)">编辑</n-button>
<n-button size="tiny" tertiary type="warning" @click="confirmRegenerate(token)">重生成</n-button>
<n-button size="tiny" tertiary type="error" @click="confirmDelete(token)">删除</n-button>
<n-dropdown
trigger="click"
:options="tokenActionOptions(token)"
@select="handleTokenAction(token, $event)"
>
<n-button size="tiny" tertiary>
<template #icon><n-icon><ChevronDownOutline /></n-icon></template>
操作
</n-button>
</n-dropdown>
</div>
</div>
</n-scrollbar>
@@ -57,7 +79,7 @@
<n-input v-model:value="tokenForm.name" placeholder="例如:外部系统接入" />
</n-form-item>
<n-form-item label="有效期">
<n-radio-group v-model:value="tokenForm.permanent">
<n-radio-group v-model:value="tokenForm.permanent" @update:value="handlePermanentChange">
<n-space>
<n-radio :value="true">永久有效</n-radio>
<n-radio :value="false">指定日期</n-radio>
@@ -93,8 +115,8 @@
title="Token 已生成"
:style="{ width: '520px', maxWidth: 'calc(100vw - 32px)' }"
>
<n-alert type="warning" :bordered="false">
这是唯一一次显示完整 Token,请立即复制并妥善保存。
<n-alert type="success" :bordered="false">
完整 Token 已保存,可在列表中随时复制。请注意只在可信内网环境中使用。
</n-alert>
<n-input
class="raw-token-input"
@@ -115,8 +137,8 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue';
import { useDialog, useMessage } from 'naive-ui';
import { AddOutline, RefreshOutline } from '@vicons/ionicons5';
import { useDialog, useMessage, type DropdownOption } from 'naive-ui';
import { AddOutline, ChevronDownOutline, RefreshOutline } from '@vicons/ionicons5';
import { apiGet, apiPost } from '../api/client';
import type { ApiMessage, ApiTokenListResponse, ApiTokenMutationResponse, ApiTokenRecord } from '../types';
@@ -130,13 +152,15 @@ const dialog = useDialog();
const tokens = ref<ApiTokenRecord[]>([]);
const loadingTokens = ref(false);
const savingToken = ref(false);
const batchDeleting = ref(false);
const tokenDialogVisible = ref(false);
const rawTokenVisible = ref(false);
const rawToken = ref('');
const editingToken = ref<ApiTokenRecord | null>(null);
const selectedTokenIds = ref<string[]>([]);
const tokenForm = reactive({
name: '',
permanent: true,
permanent: false,
expires_at: '',
enabled: true
});
@@ -150,6 +174,8 @@ async function loadTokens() {
try {
const result = await apiGet<ApiTokenListResponse>('/api/tokens');
tokens.value = result.tokens;
const tokenIds = new Set(tokens.value.map(token => token.id));
selectedTokenIds.value = selectedTokenIds.value.filter(id => tokenIds.has(id));
} catch (error) {
message.error(error instanceof Error ? error.message : '加载 Token 失败');
} finally {
@@ -160,8 +186,8 @@ async function loadTokens() {
function openCreateDialog() {
editingToken.value = null;
tokenForm.name = '';
tokenForm.permanent = true;
tokenForm.expires_at = '';
tokenForm.permanent = false;
tokenForm.expires_at = defaultExpirationDate();
tokenForm.enabled = true;
tokenDialogVisible.value = true;
}
@@ -170,17 +196,25 @@ function openEditDialog(token: ApiTokenRecord) {
editingToken.value = token;
tokenForm.name = token.name;
tokenForm.permanent = !token.expires_at;
tokenForm.expires_at = token.expires_at?.slice(0, 10) || '';
tokenForm.expires_at = token.expires_at?.slice(0, 10) || defaultExpirationDate();
tokenForm.enabled = token.enabled;
tokenDialogVisible.value = true;
}
function handlePermanentChange(value: boolean) {
tokenForm.permanent = value;
if (!value && !tokenForm.expires_at) {
tokenForm.expires_at = defaultExpirationDate();
}
}
async function saveToken() {
const expiresAt = tokenForm.expires_at || defaultExpirationDate();
const payload = {
id: editingToken.value?.id,
name: tokenForm.name.trim(),
permanent: tokenForm.permanent,
expires_at: tokenForm.permanent ? null : tokenForm.expires_at,
expires_at: tokenForm.permanent ? null : expiresAt,
enabled: tokenForm.enabled
};
if (!payload.name) {
@@ -233,6 +267,22 @@ async function regenerateToken(token: ApiTokenRecord) {
}
}
async function toggleTokenEnabled(token: ApiTokenRecord) {
try {
const result = await apiPost<ApiTokenMutationResponse>('/api/tokens/update', {
id: token.id,
name: token.name,
permanent: !token.expires_at,
expires_at: token.expires_at?.slice(0, 10) || null,
enabled: !token.enabled
});
message.success(result.message || (token.enabled ? 'Token 已停用' : 'Token 已启用'));
await loadTokens();
} catch (error) {
message.error(error instanceof Error ? error.message : '更新 Token 状态失败');
}
}
function confirmDelete(token: ApiTokenRecord) {
dialog.error({
title: '删除 Token',
@@ -253,11 +303,84 @@ async function deleteToken(token: ApiTokenRecord) {
}
}
function confirmBatchDelete() {
if (selectedTokenIds.value.length === 0) return;
dialog.error({
title: '批量删除 Token',
content: `确认删除选中的 ${selectedTokenIds.value.length} 个 Token 吗?此操作不可恢复。`,
positiveText: '删除',
negativeText: '取消',
onPositiveClick: batchDeleteTokens
});
}
async function batchDeleteTokens() {
batchDeleting.value = true;
try {
const result = await apiPost<ApiMessage>('/api/tokens/batch-delete', { ids: selectedTokenIds.value });
selectedTokenIds.value = [];
message.success(result.message || 'Token 已删除');
await loadTokens();
} catch (error) {
message.error(error instanceof Error ? error.message : '批量删除 Token 失败');
} finally {
batchDeleting.value = false;
}
}
async function copyToken(token: ApiTokenRecord) {
if (!token.token) {
message.warning('该 Token 是旧版本生成的,未保存完整值,请重生成后再复制');
return;
}
await writeClipboard(token.token);
message.success('Token 已复制');
}
async function copyRawToken() {
await writeClipboard(rawToken.value);
message.success('Token 已复制');
}
function tokenActionOptions(token: ApiTokenRecord): DropdownOption[] {
return [
{ label: '复制 Token', key: 'copy' },
{ label: '编辑', key: 'edit' },
{ label: token.enabled ? '停用' : '启用', key: 'toggle' },
{ label: '重生成', key: 'regenerate' },
{ label: '删除', key: 'delete' }
];
}
function handleTokenAction(token: ApiTokenRecord, rawKey: string | number) {
const key = String(rawKey);
if (key === 'copy') {
void copyToken(token);
} else if (key === 'edit') {
openEditDialog(token);
} else if (key === 'toggle') {
void toggleTokenEnabled(token);
} else if (key === 'regenerate') {
confirmRegenerate(token);
} else if (key === 'delete') {
confirmDelete(token);
}
}
function isTokenSelected(tokenId: string): boolean {
return selectedTokenIds.value.includes(tokenId);
}
function setTokenSelected(tokenId: string, checked: boolean | string | number) {
if (Boolean(checked)) {
if (!selectedTokenIds.value.includes(tokenId)) {
selectedTokenIds.value.push(tokenId);
}
return;
}
selectedTokenIds.value = selectedTokenIds.value.filter(id => id !== tokenId);
}
async function writeClipboard(text: string) {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
@@ -292,6 +415,19 @@ function formatDateTime(value?: string | null): string {
if (!value) return '-';
return value.replace('T', ' ').slice(0, 19);
}
function defaultExpirationDate(): string {
const date = new Date();
date.setMonth(date.getMonth() + 1);
return formatDateInput(date);
}
function formatDateInput(date: Date): string {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
</script>
<style scoped>
@@ -342,6 +478,11 @@ function formatDateTime(value?: string | null): string {
border-bottom: 1px solid var(--td-border-color-light);
}
.api-token-select {
flex: 0 0 auto;
margin-top: 2px;
}
.api-token-main {
min-width: 0;
flex: 1;
@@ -382,7 +523,7 @@ function formatDateTime(value?: string | null): string {
.api-token-actions {
display: flex;
flex: 0 0 auto;
flex-direction: column;
align-items: flex-start;
gap: 6px;
}
+2
View File
@@ -152,6 +152,8 @@ export interface ApiTokenRecord {
name: string;
prefix: string;
suffix: string;
token?: string | null;
token_available?: boolean;
created_at: string;
expires_at: string | null;
enabled: boolean;