Files
CapacityReport/frontend/src/components/ApiTokenManager.vue
T

626 lines
18 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.
<template>
<div class="api-token-manager" :class="{ embedded }">
<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
</n-button>
<n-button size="small" tertiary :loading="loadingTokens" @click="loadTokens">
<template #icon><n-icon><RefreshOutline /></n-icon></template>
刷新
</n-button>
</n-space>
</template>
<div class="api-token-intro">
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>
<n-tag size="small" :type="token.enabled && !token.expired ? 'success' : 'warning'">
{{ tokenStatusText(token) }}
</n-tag>
</div>
<div class="api-token-value-row">
<div class="api-token-value monospace">{{ tokenDisplayValue(token) }}</div>
<div class="api-token-value-actions">
<n-button
circle
quaternary
size="tiny"
:title="token.token ? (isTokenRevealed(token.id) ? '隐藏 Token' : '显示 Token') : '旧 Token 未保存完整值'"
:disabled="!token.token"
@click="toggleTokenVisibility(token)"
>
<template #icon>
<n-icon>
<EyeOffOutline v-if="isTokenRevealed(token.id)" />
<EyeOutline v-else />
</n-icon>
</template>
</n-button>
<n-button
circle
quaternary
size="tiny"
title="复制 Token"
:disabled="!token.token"
@click="copyToken(token)"
>
<template #icon><n-icon><CopyOutline /></n-icon></template>
</n-button>
</div>
</div>
<div class="api-token-meta">
<span>创建:{{ formatDateTime(token.created_at) }}</span>
<span>到期:{{ formatExpiration(token.expires_at) }}</span>
<span>最近使用:{{ formatDateTime(token.last_used_at) }}</span>
<span v-if="token.last_used_from">来源:{{ token.last_used_from }}</span>
</div>
</div>
<div class="api-token-actions">
<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>
</n-card>
<n-modal
v-model:show="tokenDialogVisible"
preset="card"
:title="editingToken ? '编辑 Token' : '生成 Token'"
:style="{ width: '440px', maxWidth: 'calc(100vw - 32px)' }"
:mask-closable="!savingToken"
>
<n-form label-placement="top">
<n-form-item label="名称">
<n-input v-model:value="tokenForm.name" placeholder="例如:外部系统接入" />
</n-form-item>
<n-form-item label="有效期">
<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>
</n-space>
</n-radio-group>
</n-form-item>
<n-form-item v-if="!tokenForm.permanent" label="到期日期">
<input
v-model="tokenForm.expires_at"
class="api-token-date-input"
type="date"
:disabled="savingToken"
/>
</n-form-item>
<n-form-item label="启用">
<n-switch v-model:value="tokenForm.enabled" />
</n-form-item>
</n-form>
<template #footer>
<div class="api-token-dialog-footer">
<n-button :disabled="savingToken" @click="tokenDialogVisible = false">取消</n-button>
<n-button type="primary" :loading="savingToken" @click="saveToken">
{{ editingToken ? '保存' : '生成' }}
</n-button>
</div>
</template>
</n-modal>
<n-modal
v-model:show="rawTokenVisible"
preset="card"
title="Token 已生成"
:style="{ width: '520px', maxWidth: 'calc(100vw - 32px)' }"
>
<n-alert type="success" :bordered="false">
完整 Token 已保存,可在列表中随时复制。请注意只在可信内网环境中使用。
</n-alert>
<n-input
class="raw-token-input"
:value="rawToken"
type="textarea"
readonly
:autosize="{ minRows: 3, maxRows: 6 }"
/>
<template #footer>
<div class="api-token-dialog-footer">
<n-button @click="rawTokenVisible = false">关闭</n-button>
<n-button type="primary" @click="copyRawToken">复制 Token</n-button>
</div>
</template>
</n-modal>
</div>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue';
import { useDialog, useMessage, type DropdownOption } from 'naive-ui';
import { AddOutline, ChevronDownOutline, CopyOutline, EyeOffOutline, EyeOutline, RefreshOutline } from '@vicons/ionicons5';
import { apiGet, apiPost } from '../api/client';
import { writeClipboardText } from '../composables/clipboard';
import type { ApiMessage, ApiTokenListResponse, ApiTokenMutationResponse, ApiTokenRecord } from '../types';
defineProps<{
embedded?: boolean;
}>();
const message = useMessage();
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 visibleTokenIds = ref<string[]>([]);
const tokenForm = reactive({
name: '',
permanent: false,
expires_at: '',
enabled: true
});
onMounted(() => {
void loadTokens();
});
async function loadTokens() {
loadingTokens.value = true;
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));
visibleTokenIds.value = visibleTokenIds.value.filter(id => tokenIds.has(id));
} catch (error) {
message.error(error instanceof Error ? error.message : '加载 Token 失败');
} finally {
loadingTokens.value = false;
}
}
function openCreateDialog() {
editingToken.value = null;
tokenForm.name = '';
tokenForm.permanent = false;
tokenForm.expires_at = defaultExpirationDate();
tokenForm.enabled = true;
tokenDialogVisible.value = true;
}
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) || 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 : expiresAt,
enabled: tokenForm.enabled
};
if (!payload.name) {
message.warning('请输入 Token 名称');
return;
}
if (!payload.permanent && !payload.expires_at) {
message.warning('请选择到期日期');
return;
}
savingToken.value = true;
try {
const result = editingToken.value
? await apiPost<ApiTokenMutationResponse>('/api/tokens/update', payload)
: await apiPost<ApiTokenMutationResponse>('/api/tokens/create', payload);
message.success(result.message || (editingToken.value ? 'Token 已更新' : 'Token 已生成'));
tokenDialogVisible.value = false;
if (result.token) {
rawToken.value = result.token;
rawTokenVisible.value = true;
}
await loadTokens();
} catch (error) {
message.error(error instanceof Error ? error.message : '保存 Token 失败');
} finally {
savingToken.value = false;
}
}
function confirmRegenerate(token: ApiTokenRecord) {
dialog.warning({
title: '重新生成 Token',
content: `确认重新生成「${token.name}」吗?旧 Token 会立即失效。`,
positiveText: '重新生成',
negativeText: '取消',
onPositiveClick: () => regenerateToken(token)
});
}
async function regenerateToken(token: ApiTokenRecord) {
try {
const result = await apiPost<ApiTokenMutationResponse>('/api/tokens/regenerate', { id: token.id });
rawToken.value = result.token || '';
rawTokenVisible.value = Boolean(rawToken.value);
message.success(result.message || 'Token 已重新生成');
await loadTokens();
} catch (error) {
message.error(error instanceof Error ? error.message : '重新生成 Token 失败');
}
}
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',
content: `确认删除「${token.name}」吗?此操作不可恢复。`,
positiveText: '删除',
negativeText: '取消',
onPositiveClick: () => deleteToken(token)
});
}
async function deleteToken(token: ApiTokenRecord) {
try {
const result = await apiPost<ApiMessage>('/api/tokens/delete', { id: token.id });
message.success(result.message || 'Token 已删除');
await loadTokens();
} catch (error) {
message.error(error instanceof Error ? error.message : '删除 Token 失败');
}
}
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 writeClipboardText(token.token);
message.success('Token 已复制');
}
async function copyRawToken() {
await writeClipboardText(rawToken.value);
message.success('Token 已复制');
}
function tokenActionOptions(token: ApiTokenRecord): DropdownOption[] {
return [
{ 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 === '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);
}
function isTokenRevealed(tokenId: string): boolean {
return visibleTokenIds.value.includes(tokenId);
}
function toggleTokenVisibility(token: ApiTokenRecord) {
if (!token.token) {
message.warning('该 Token 是旧版本生成的,未保存完整值,请重生成后再查看');
return;
}
if (isTokenRevealed(token.id)) {
visibleTokenIds.value = visibleTokenIds.value.filter(id => id !== token.id);
return;
}
visibleTokenIds.value.push(token.id);
}
function tokenDisplayValue(token: ApiTokenRecord): string {
if (token.token && isTokenRevealed(token.id)) {
return token.token;
}
return `${token.prefix}...${token.suffix}`;
}
function tokenStatusText(token: ApiTokenRecord): string {
if (!token.enabled) return '已停用';
if (token.expired) return '已过期';
return '可用';
}
function formatExpiration(value: string | null): string {
if (!value) return '永久有效';
return value.replace('T', ' ').slice(0, 10);
}
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>
.api-token-manager {
min-height: 0;
}
.api-token-manager.embedded {
height: 100%;
min-height: 0;
}
.api-token-card {
display: flex;
height: 100%;
min-height: 0;
flex-direction: column;
}
.api-token-card > :deep(.n-card__content),
.api-token-card > :deep(.n-card-content) {
display: flex;
flex: 1;
min-height: 0;
flex-direction: column;
}
.api-token-intro {
margin-bottom: 12px;
color: var(--td-text-color-secondary);
font-size: 13px;
line-height: 1.6;
}
.api-token-loading {
margin: 48px auto;
}
.api-token-list {
flex: 1;
min-height: 220px;
}
.api-token-item {
display: flex;
gap: 12px;
padding: 12px 0;
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;
}
.api-token-title-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.api-token-name {
min-width: 0;
overflow: hidden;
color: var(--td-text-color-primary);
font-weight: 600;
white-space: nowrap;
text-overflow: ellipsis;
}
.api-token-value-row {
display: flex;
align-items: center;
gap: 8px;
margin-top: 6px;
}
.api-token-value {
min-width: 0;
max-width: min(72ch, 100%);
flex: 0 1 auto;
overflow: hidden;
color: var(--td-text-color-secondary);
font-family: var(--td-font-family-mono);
font-size: 12px;
white-space: nowrap;
text-overflow: ellipsis;
}
.api-token-value-actions {
display: flex;
flex: 0 0 auto;
align-items: center;
gap: 2px;
}
.api-token-meta {
display: flex;
flex-direction: column;
gap: 3px;
margin-top: 8px;
color: var(--td-text-color-placeholder);
font-size: 12px;
}
.api-token-actions {
display: flex;
flex: 0 0 auto;
align-items: flex-start;
gap: 6px;
}
.api-token-date-input {
width: 100%;
height: 34px;
box-sizing: border-box;
padding: 0 12px;
border: 1px solid var(--td-border-color);
border-radius: var(--td-radius-default);
background: var(--td-card-bg);
color: var(--td-text-color-primary);
font: inherit;
outline: none;
}
.api-token-date-input:focus {
border-color: var(--n-primary-color, #18a058);
box-shadow: 0 0 0 2px rgba(24, 160, 88, 0.12);
}
.api-token-date-input:disabled {
cursor: not-allowed;
opacity: 0.6;
}
.raw-token-input {
margin-top: 12px;
}
.api-token-dialog-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
}
@media (max-width: 720px) {
.api-token-item {
flex-direction: column;
}
.api-token-actions {
flex-direction: row;
}
}
</style>