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:
2026-06-24 05:34:28 +08:00
parent f708d6947a
commit dab672621c
34 changed files with 1586 additions and 1842 deletions
-1
View File
@@ -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"
},
+1 -3
View File
@@ -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) }
];
-174
View File
@@ -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 &lt;token&gt; 传递,也兼容 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>
-625
View File
@@ -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>
+1 -1
View File
@@ -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(() => {
+135 -7
View File
@@ -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('请填写完整数据库配置');
-2
View File
@@ -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
View File
@@ -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
View File
@@ -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;
}
-5
View File
@@ -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;
}