feat: 完善 API Token 管理功能
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user