feat: 双模式集成并精简 API 文档/Token、设置页卡片自适应
- 源/仓库各自可在直连(FTP/SFTP、MySQL)与 Metrix 存储/数据库平台间独立选择,两侧互不依赖 - Metrix 模式下源走平台储存 API、仓库走平台导入 + run-script(single_session)、查看导出代理到平台 - 去掉对外 API 文档与 API Token(前后端 + auth/config 解耦),业务接口仅登录态可访问 - 授权默认到期日改为 2026-12-30 - 设置页卡片改横向自适应(宽屏并排、窄屏换行),处理历史保留卡片收窄
This commit is contained in:
@@ -19,7 +19,6 @@
|
||||
"@vicons/ionicons5": "^0.13.0",
|
||||
"monaco-editor": "^0.55.1",
|
||||
"naive-ui": "^2.42.0",
|
||||
"swagger-ui-dist": "^5.32.6",
|
||||
"vue": "^3.5.0",
|
||||
"vue-router": "^5.0.7"
|
||||
},
|
||||
|
||||
@@ -122,7 +122,6 @@ import { useMessage, type DropdownOption, type MenuOption, NIcon } from 'naive-u
|
||||
import {
|
||||
ChevronDownOutline,
|
||||
CloudUploadOutline,
|
||||
CodeSlashOutline,
|
||||
ConstructOutline,
|
||||
FileTrayFullOutline,
|
||||
LogOutOutline,
|
||||
@@ -147,7 +146,7 @@ const loginLoading = ref(false);
|
||||
const sidebarCollapsed = ref(localStorage.getItem('sidebarCollapsed') === 'true');
|
||||
const licenseModalVisible = ref(false);
|
||||
const brandClickCount = ref(0);
|
||||
const menuKeys = ['workflow', 'history', 'database', 'script', 'api-center', 'settings'] as const;
|
||||
const menuKeys = ['workflow', 'history', 'database', 'script', 'settings'] as const;
|
||||
type MenuKey = (typeof menuKeys)[number];
|
||||
let brandClickResetTimer: number | undefined;
|
||||
|
||||
@@ -156,7 +155,6 @@ const menuOptions: MenuOption[] = [
|
||||
{ label: '处理历史', key: 'history', icon: renderIcon(FileTrayFullOutline) },
|
||||
{ label: '数据管理', key: 'database', icon: renderIcon(ServerOutline) },
|
||||
{ label: '脚本编辑', key: 'script', icon: renderIcon(ConstructOutline) },
|
||||
{ label: 'API 文档', key: 'api-center', icon: renderIcon(CodeSlashOutline) },
|
||||
{ label: '系统设置', key: 'settings', icon: renderIcon(SettingsOutline) }
|
||||
];
|
||||
|
||||
|
||||
@@ -1,174 +0,0 @@
|
||||
<template>
|
||||
<div class="api-docs-workspace">
|
||||
<n-card size="small" class="work-card api-docs-card">
|
||||
<template #header>
|
||||
<div class="api-docs-header">
|
||||
<div>
|
||||
<span class="api-docs-title">API 文档</span>
|
||||
<p class="api-docs-hint">Token 在系统设置的 API Token 分页生成,通过 Authorization: Bearer <token> 传递,也兼容 X-API-Token。</p>
|
||||
</div>
|
||||
<n-space size="small">
|
||||
<n-button size="small" tertiary @click="copyHeaderSample">复制传参示例</n-button>
|
||||
<n-button size="small" tertiary tag="a" :href="openApiUrl" target="_blank">OpenAPI JSON</n-button>
|
||||
</n-space>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div ref="swaggerHost" class="swagger-host" />
|
||||
</n-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { nextTick, onMounted, ref } from 'vue';
|
||||
import { useMessage } from 'naive-ui';
|
||||
import SwaggerUIBundle from 'swagger-ui-dist/swagger-ui-bundle.js';
|
||||
import 'swagger-ui-dist/swagger-ui.css';
|
||||
|
||||
import { apiUrl, getApiBaseUrl, getToken } from '../api/client';
|
||||
import { writeClipboardText } from '../composables/clipboard';
|
||||
|
||||
type SwaggerSystem = {
|
||||
getSystem?: () => {
|
||||
authActions?: {
|
||||
authorize?: (payload: Record<string, { name: string; schema: unknown; value: string }>) => void;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
interface SwaggerRequest {
|
||||
headers: Record<string, string>;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
const message = useMessage();
|
||||
const swaggerHost = ref<HTMLDivElement | null>(null);
|
||||
const openApiUrl = apiUrl('/api/openapi.json');
|
||||
let swaggerUi: SwaggerSystem | undefined;
|
||||
|
||||
onMounted(async () => {
|
||||
await nextTick();
|
||||
initSwagger();
|
||||
});
|
||||
|
||||
function initSwagger() {
|
||||
if (!swaggerHost.value) return;
|
||||
|
||||
swaggerHost.value.innerHTML = '';
|
||||
swaggerUi = SwaggerUIBundle({
|
||||
url: openApiUrl,
|
||||
domNode: swaggerHost.value,
|
||||
requestSnippetsEnabled: true,
|
||||
deepLinking: true,
|
||||
docExpansion: 'list',
|
||||
defaultModelsExpandDepth: -1,
|
||||
displayRequestDuration: true,
|
||||
persistAuthorization: true,
|
||||
filter: true,
|
||||
validatorUrl: null,
|
||||
showCommonExtensions: true,
|
||||
showExtensions: false,
|
||||
requestInterceptor: (request: SwaggerRequest) => {
|
||||
const token = getToken();
|
||||
if (token && !request.headers.Authorization && !request.headers.authorization) {
|
||||
request.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
if (request.url?.startsWith('/')) {
|
||||
request.url = `${getApiBaseUrl()}${request.url}`;
|
||||
}
|
||||
return request;
|
||||
},
|
||||
onComplete: () => {
|
||||
const token = getToken();
|
||||
if (!token) return;
|
||||
swaggerUi?.getSystem?.().authActions?.authorize?.({
|
||||
BearerAuth: {
|
||||
name: 'BearerAuth',
|
||||
schema: { type: 'http', scheme: 'bearer' },
|
||||
value: token
|
||||
}
|
||||
});
|
||||
}
|
||||
}) as SwaggerSystem;
|
||||
}
|
||||
|
||||
async function copyHeaderSample() {
|
||||
await writeClipboardText('Authorization: Bearer <token>\nX-API-Token: <token>');
|
||||
message.success('传参示例已复制');
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.api-docs-workspace {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
padding: 24px 32px;
|
||||
}
|
||||
|
||||
.api-docs-card {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.api-docs-card > :deep(.n-card__content),
|
||||
.api-docs-card > :deep(.n-card-content) {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.api-docs-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.api-docs-title {
|
||||
color: var(--td-text-color-primary);
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.api-docs-hint {
|
||||
margin: 4px 0 0;
|
||||
color: var(--td-text-color-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.swagger-host {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
border: 1px solid var(--td-border-color-light);
|
||||
border-radius: var(--td-radius-default);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
:deep(.swagger-ui) {
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
:deep(.swagger-ui .scheme-container) {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
:deep(.swagger-ui .models),
|
||||
:deep(.swagger-ui section.models) {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.api-docs-workspace {
|
||||
height: auto;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.swagger-host {
|
||||
min-height: 70vh;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,625 +0,0 @@
|
||||
<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>
|
||||
@@ -234,7 +234,7 @@ const logContainer = ref<HTMLElement | null>(null);
|
||||
const licenseModalVisible = ref(false);
|
||||
const activationCode = ref('');
|
||||
const activationLoading = ref(false);
|
||||
const activationKeyLabel = ref('2026/06/20');
|
||||
const activationKeyLabel = ref('2026/12/30');
|
||||
const licenseErrorMessage = ref('当前数据日期已超过授权到期日期,请输入激活码延长 30 天。');
|
||||
const taskMode = ref<'local' | 'remote' | 'unknown'>('unknown');
|
||||
let activationRetry: (() => Promise<void>) | null = null;
|
||||
|
||||
@@ -37,7 +37,7 @@ const visible = defineModel<boolean>('show', { required: true });
|
||||
const message = useMessage();
|
||||
const activationCode = ref('');
|
||||
const activationLoading = ref(false);
|
||||
const activationKeyLabel = ref('2026/06/20');
|
||||
const activationKeyLabel = ref('2026/12/30');
|
||||
const licenseMessage = ref('输入激活码可将授权到期日期延长 30 天。');
|
||||
|
||||
onMounted(() => {
|
||||
|
||||
@@ -4,6 +4,78 @@
|
||||
|
||||
<n-card size="small" class="work-card settings-tabs-card">
|
||||
<n-tabs type="line" animated class="settings-tabs">
|
||||
<n-tab-pane name="backend" tab="数据源 / 仓库">
|
||||
<div class="settings-database-panel">
|
||||
<div class="settings-database-stack">
|
||||
<n-card title="后端类型" size="small" class="work-card">
|
||||
<n-form label-placement="top">
|
||||
<n-form-item label="数据源">
|
||||
<n-radio-group v-model:value="sourceType">
|
||||
<n-radio-button value="sftp">SFTP</n-radio-button>
|
||||
<n-radio-button value="ftp">FTP</n-radio-button>
|
||||
<n-radio-button value="metrix">Metrix 存储平台</n-radio-button>
|
||||
</n-radio-group>
|
||||
</n-form-item>
|
||||
<n-form-item label="数据仓库">
|
||||
<n-radio-group v-model:value="warehouseType">
|
||||
<n-radio-button value="mysql">MySQL</n-radio-button>
|
||||
<n-radio-button value="metrix">Metrix 数据库平台</n-radio-button>
|
||||
</n-radio-group>
|
||||
</n-form-item>
|
||||
<p class="form-hint">两侧可独立选择:源用 FTP/SFTP 或 Metrix 存储平台;仓库用本地 MySQL 或 Metrix 数据库平台。选 Metrix 时在下方填写连接;选 FTP/SFTP 或 MySQL 时在对应标签页填写。源目录在「远程数据源」标签页的「远程目录」。</p>
|
||||
</n-form>
|
||||
<template #footer>
|
||||
<n-space justify="end">
|
||||
<n-button type="primary" :loading="savingBackend" @click="saveBackend">保存类型</n-button>
|
||||
</n-space>
|
||||
</template>
|
||||
</n-card>
|
||||
|
||||
<n-card title="Metrix 连接(存储平台 / 数据库平台)" size="small" class="work-card">
|
||||
<n-form label-placement="top">
|
||||
<n-form-item label="平台地址 (api_base)">
|
||||
<n-input v-model:value="metrixForm.base_url" placeholder="http://host.docker.internal:8000" />
|
||||
</n-form-item>
|
||||
<n-form-item label="API Token">
|
||||
<n-input v-model:value="metrixForm.token" type="password" show-password-on="click" placeholder="mtx_..." />
|
||||
</n-form-item>
|
||||
<n-grid :cols="12" :x-gap="12">
|
||||
<n-gi :span="6">
|
||||
<n-form-item label="储存连接 ID">
|
||||
<n-input v-model:value="metrixForm.storage_id" placeholder="stg_..." />
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
<n-gi :span="6">
|
||||
<n-form-item label="数据库连接 ID">
|
||||
<n-input v-model:value="metrixForm.database_conn_id" placeholder="db_..." />
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
<n-grid :cols="12" :x-gap="12">
|
||||
<n-gi :span="8">
|
||||
<n-form-item label="目标库/Schema">
|
||||
<n-input v-model:value="metrixForm.target_database" placeholder="连接已指定库时可空" />
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
<n-gi :span="4">
|
||||
<n-form-item label="最近 N 天回退">
|
||||
<n-input-number v-model:value="metrixForm.recent_days" class="full-width" :min="1" :precision="0" />
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
<p class="form-hint">仅当数据源或仓库选择 Metrix 时使用。存储平台用 storage_id,数据库平台用 database_conn_id,二者共用地址与 Token。</p>
|
||||
</n-form>
|
||||
<template #footer>
|
||||
<n-space justify="end">
|
||||
<n-button type="primary" :loading="savingMetrix" @click="saveMetrix">保存连接</n-button>
|
||||
<n-button :loading="testingRemote" @click="testRemote">测试储存</n-button>
|
||||
</n-space>
|
||||
</template>
|
||||
</n-card>
|
||||
</div>
|
||||
</div>
|
||||
</n-tab-pane>
|
||||
|
||||
<n-tab-pane name="database" tab="数据库">
|
||||
<div class="settings-database-panel">
|
||||
<div class="settings-database-stack">
|
||||
@@ -46,7 +118,7 @@
|
||||
</template>
|
||||
</n-card>
|
||||
|
||||
<n-card title="处理历史保留" size="small" class="work-card">
|
||||
<n-card title="处理历史保留" size="small" class="work-card work-card-narrow">
|
||||
<n-form label-placement="top">
|
||||
<n-grid :cols="12" :x-gap="12">
|
||||
<n-gi :span="6">
|
||||
@@ -408,11 +480,6 @@
|
||||
</div>
|
||||
</n-tab-pane>
|
||||
|
||||
<n-tab-pane name="api-token" tab="API Token">
|
||||
<div class="settings-token-panel">
|
||||
<ApiTokenManager embedded />
|
||||
</div>
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
</n-card>
|
||||
</div>
|
||||
@@ -428,13 +495,13 @@ import type {
|
||||
ApiMessage,
|
||||
AppConfig,
|
||||
HistoryRetentionConfig,
|
||||
MetrixConfig,
|
||||
RemoteAutoSchedulerConfig,
|
||||
RemoteDataConfig,
|
||||
RemoteSchedulerStatus
|
||||
} from '../types';
|
||||
import { showDownloadCompleteDialog } from '../composables/downloadFeedback';
|
||||
import { resetPageHeader, setPageHeader } from '../composables/pageHeader';
|
||||
import ApiTokenManager from './ApiTokenManager.vue';
|
||||
|
||||
interface ExtractFieldConfig {
|
||||
Field: string;
|
||||
@@ -455,6 +522,10 @@ const loadingSchedulerStatus = ref(false);
|
||||
const triggeringScheduler = ref(false);
|
||||
const savingMysql = ref(false);
|
||||
const savingRemote = ref(false);
|
||||
const savingBackend = ref(false);
|
||||
const savingMetrix = ref(false);
|
||||
const sourceType = ref<'ftp' | 'sftp' | 'metrix'>('sftp');
|
||||
const warehouseType = ref<'mysql' | 'metrix'>('mysql');
|
||||
const savingHistoryRetention = ref(false);
|
||||
const savingSheetFilter = ref(false);
|
||||
const savingExtractFields = ref(false);
|
||||
@@ -494,6 +565,16 @@ const remoteForm = reactive<RemoteDataConfig>({
|
||||
}
|
||||
});
|
||||
|
||||
const metrixForm = reactive<MetrixConfig>({
|
||||
base_url: 'http://host.docker.internal:8000',
|
||||
token: '',
|
||||
storage_id: '',
|
||||
database_conn_id: '',
|
||||
target_database: '',
|
||||
recent_days: 7,
|
||||
data_dir_to_table: { '4G': '4G_UD', '5G': '5G_UD' }
|
||||
});
|
||||
|
||||
const historyRetentionForm = reactive<HistoryRetentionConfig>({
|
||||
enabled: false,
|
||||
keep_count: 20
|
||||
@@ -606,6 +687,17 @@ async function loadConfig() {
|
||||
try {
|
||||
const config = await apiGet<AppConfig>('/api/config/full');
|
||||
configUpdate.value = config.update;
|
||||
sourceType.value = config.source_type || 'sftp';
|
||||
warehouseType.value = config.warehouse_type || 'mysql';
|
||||
if (config.metrix) {
|
||||
metrixForm.base_url = config.metrix.base_url || '';
|
||||
metrixForm.token = config.metrix.token || '';
|
||||
metrixForm.storage_id = config.metrix.storage_id || '';
|
||||
metrixForm.database_conn_id = config.metrix.database_conn_id || '';
|
||||
metrixForm.target_database = config.metrix.target_database || '';
|
||||
metrixForm.recent_days = Number(config.metrix.recent_days) || 7;
|
||||
metrixForm.data_dir_to_table = config.metrix.data_dir_to_table || { '4G': '4G_UD', '5G': '5G_UD' };
|
||||
}
|
||||
mysqlForm.host = config.mysql.host;
|
||||
mysqlForm.port = config.mysql.port;
|
||||
mysqlForm.user = config.mysql.user;
|
||||
@@ -633,6 +725,42 @@ function updateRemoteProtocol(value: string) {
|
||||
}
|
||||
}
|
||||
|
||||
async function saveBackend() {
|
||||
savingBackend.value = true;
|
||||
try {
|
||||
const result = await apiPost<ApiMessage>('/api/config/backend', {
|
||||
source_type: sourceType.value,
|
||||
warehouse_type: warehouseType.value
|
||||
});
|
||||
configUpdate.value = result.update || configUpdate.value;
|
||||
message.success(result.message || '后端类型已保存');
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '保存后端类型失败');
|
||||
} finally {
|
||||
savingBackend.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveMetrix() {
|
||||
if (!metrixForm.base_url) {
|
||||
message.warning('请填写平台地址');
|
||||
return;
|
||||
}
|
||||
savingMetrix.value = true;
|
||||
try {
|
||||
const result = await apiPost<ApiMessage>('/api/config/metrix', {
|
||||
...metrixForm,
|
||||
recent_days: Math.max(Number(metrixForm.recent_days) || 7, 1)
|
||||
});
|
||||
configUpdate.value = result.update || configUpdate.value;
|
||||
message.success(result.message || 'Metrix 连接已保存');
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '保存 Metrix 连接失败');
|
||||
} finally {
|
||||
savingMetrix.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveMysql() {
|
||||
if (!mysqlForm.host || !mysqlForm.user || !mysqlForm.dbname) {
|
||||
message.warning('请填写完整数据库配置');
|
||||
|
||||
@@ -5,7 +5,6 @@ const HistoryPanel = () => import('./components/HistoryPanel.vue');
|
||||
const DatabasePanel = () => import('./components/DatabasePanel.vue');
|
||||
const SettingsPanel = () => import('./components/SettingsPanel.vue');
|
||||
const ScriptPanel = () => import('./components/ScriptPanel.vue');
|
||||
const ApiDocs = () => import('./components/ApiDocs.vue');
|
||||
|
||||
export const routes: RouteRecordRaw[] = [
|
||||
{ path: '/', redirect: { name: 'workflow' } },
|
||||
@@ -13,7 +12,6 @@ export const routes: RouteRecordRaw[] = [
|
||||
{ path: '/history', name: 'history', component: HistoryPanel, meta: { title: '处理历史' } },
|
||||
{ path: '/database', name: 'database', component: DatabasePanel, meta: { title: '数据管理' } },
|
||||
{ path: '/script', name: 'script', component: ScriptPanel, meta: { title: '脚本编辑' } },
|
||||
{ path: '/api-docs', alias: '/api-center', name: 'api-center', component: ApiDocs, meta: { title: 'API 文档' } },
|
||||
{ path: '/settings', name: 'settings', component: SettingsPanel, meta: { title: '系统设置' } },
|
||||
{ path: '/:pathMatch(.*)*', redirect: { name: 'workflow' } }
|
||||
];
|
||||
|
||||
+17
-7
@@ -1404,13 +1404,29 @@ select {
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.settings-database-panel {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.settings-database-stack {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
flex-flow: row wrap;
|
||||
align-items: flex-start;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.settings-database-stack > .work-card {
|
||||
flex: 1 1 360px;
|
||||
min-width: 320px;
|
||||
}
|
||||
|
||||
.settings-database-stack > .work-card-narrow {
|
||||
flex: 0 1 360px;
|
||||
min-width: 300px;
|
||||
max-width: 460px;
|
||||
}
|
||||
|
||||
.settings-database-panel::-webkit-scrollbar,
|
||||
.settings-remote-panel::-webkit-scrollbar,
|
||||
.settings-scheduler-panel::-webkit-scrollbar {
|
||||
@@ -1501,12 +1517,6 @@ select {
|
||||
max-width: 560px;
|
||||
}
|
||||
|
||||
.settings-token-panel {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
+13
-26
@@ -88,6 +88,8 @@ export interface CacheSize {
|
||||
|
||||
export interface AppConfig {
|
||||
update: string;
|
||||
source_type: 'ftp' | 'sftp' | 'metrix';
|
||||
warehouse_type: 'mysql' | 'metrix';
|
||||
mysql: {
|
||||
host: string;
|
||||
port: number;
|
||||
@@ -95,6 +97,7 @@ export interface AppConfig {
|
||||
passwd?: string;
|
||||
dbname: string;
|
||||
};
|
||||
metrix: MetrixConfig;
|
||||
remote_data: RemoteDataConfig;
|
||||
history_retention: HistoryRetentionConfig;
|
||||
rj_data?: RJDataConfig;
|
||||
@@ -102,6 +105,16 @@ export interface AppConfig {
|
||||
extract_fields: Array<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
export interface MetrixConfig {
|
||||
base_url: string;
|
||||
token?: string;
|
||||
storage_id: string;
|
||||
database_conn_id: string;
|
||||
target_database: string;
|
||||
recent_days: number;
|
||||
data_dir_to_table: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface RJDataConfig {
|
||||
enabled: boolean;
|
||||
weekly_directories: string[];
|
||||
@@ -224,29 +237,3 @@ export interface ApiMessage {
|
||||
task_id?: string;
|
||||
}
|
||||
|
||||
export interface ApiTokenRecord {
|
||||
id: string;
|
||||
name: string;
|
||||
prefix: string;
|
||||
suffix: string;
|
||||
token?: string | null;
|
||||
token_available?: boolean;
|
||||
created_at: string;
|
||||
expires_at: string | null;
|
||||
enabled: boolean;
|
||||
expired: boolean;
|
||||
last_used_at?: string | null;
|
||||
last_used_from?: string | null;
|
||||
}
|
||||
|
||||
export interface ApiTokenListResponse {
|
||||
success: boolean;
|
||||
tokens: ApiTokenRecord[];
|
||||
}
|
||||
|
||||
export interface ApiTokenMutationResponse {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
token?: string;
|
||||
record: ApiTokenRecord;
|
||||
}
|
||||
|
||||
Vendored
-5
@@ -1,6 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module 'swagger-ui-dist/swagger-ui-bundle.js' {
|
||||
const SwaggerUIBundle: (options: Record<string, unknown>) => unknown;
|
||||
export default SwaggerUIBundle;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user