fix: 恢复前端脚本编辑器和字段配置交互

This commit is contained in:
2026-05-15 12:18:25 +08:00
parent bba68f78bf
commit a0da114c95
8 changed files with 904 additions and 192 deletions
+8
View File
@@ -1,5 +1,13 @@
# 项目上下文记录
## 2026-05-15:恢复前端工作台交互质量
- 前端工作台布局重新对齐旧版 `54773f547f6fcb853d73785f05ff5ac39ab2e5f5` 的信息架构:左侧导航、顶部标题栏、紧凑后台式内容区,导航项使用“数据上传 / 处理历史 / 数据管理 / 脚本编辑 / 系统设置”。
- `ScriptPanel.vue` 不再使用普通 textarea,改为 `monaco-editor` SQL 编辑器,保留脚本读取、保存、执行和状态轮询接口;支持 SQL 高亮、行号、缩略图、光标行列状态和未保存状态。
- Monaco 通过 Vue 异步组件按需加载,避免脚本编辑器依赖进入首屏主包;Vite worker 类型由 `frontend/src/vite-env.d.ts` 提供。
- `SettingsPanel.vue` 的字段提取配置恢复为结构化树状配置:字段名、字段类型、提取来源列表、搜索、增删和去重保存,不再要求用户直接编辑 JSON。
- 新增前端运行依赖 `monaco-editor`,构建时仍会生成 `frontend/dist/`,该目录继续作为构建产物忽略,不进入版本库。
## 2026-05-15:修复 Windows 启动脚本编码问题
- `run.bat` 改为纯 ASCII 输出,并规范为 CRLF 行尾,避免 Windows `cmd` 在 PowerShell 中执行 UTF-8 中文批处理时把提示文本解析成碎片命令。
+39
View File
@@ -9,6 +9,7 @@
"version": "1.0.0",
"dependencies": {
"@vicons/ionicons5": "^0.13.0",
"monaco-editor": "^0.55.1",
"naive-ui": "^2.42.0",
"vue": "^3.5.0"
},
@@ -922,6 +923,13 @@
"@types/lodash": "*"
}
},
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"license": "MIT",
"optional": true
},
"node_modules/@vicons/ionicons5": {
"version": "0.13.0",
"resolved": "https://registry.npmjs.org/@vicons/ionicons5/-/ionicons5-0.13.0.tgz",
@@ -1144,6 +1152,15 @@
"date-fns": "^3.0.0 || ^4.0.0"
}
},
"node_modules/dompurify": {
"version": "3.2.7",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz",
"integrity": "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
},
"node_modules/entities": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
@@ -1273,6 +1290,28 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/marked": {
"version": "14.0.0",
"resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz",
"integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==",
"license": "MIT",
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/monaco-editor": {
"version": "0.55.1",
"resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.55.1.tgz",
"integrity": "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==",
"license": "MIT",
"dependencies": {
"dompurify": "3.2.7",
"marked": "14.0.0"
}
},
"node_modules/muggle-string": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz",
+1 -1
View File
@@ -10,6 +10,7 @@
},
"dependencies": {
"@vicons/ionicons5": "^0.13.0",
"monaco-editor": "^0.55.1",
"naive-ui": "^2.42.0",
"vue": "^3.5.0"
},
@@ -20,4 +21,3 @@
"vue-tsc": "^3.0.0"
}
}
+12 -8
View File
@@ -2,7 +2,7 @@
<LoginView v-if="!token" :loading="loginLoading" @login="handleLogin" />
<n-layout v-else has-sider class="app-layout">
<n-layout-sider
bordered
class="app-sider"
collapse-mode="width"
:collapsed-width="64"
:width="224"
@@ -14,17 +14,21 @@
</div>
<n-menu
v-model:value="activeMenu"
class="app-menu"
:collapsed-width="64"
:collapsed-icon-size="22"
:options="menuOptions"
/>
<div class="sider-footer">
<span>v2.0</span>
<span>CapacityReport</span>
</div>
</n-layout-sider>
<n-layout>
<n-layout-header bordered class="topbar">
<div>
<div class="topbar-title">
<div class="page-title">{{ currentTitle }}</div>
<div class="page-subtitle">容量报表处理与数据维护</div>
</div>
<n-space align="center">
<n-tag size="small" type="success" round>已登录</n-tag>
@@ -48,7 +52,7 @@
</template>
<script setup lang="ts">
import { computed, h, ref, type Component } from 'vue';
import { computed, defineAsyncComponent, h, ref, type Component } from 'vue';
import { useMessage, type MenuOption, NIcon } from 'naive-ui';
import {
CloudUploadOutline,
@@ -65,19 +69,19 @@ import LoginView from './components/LoginView.vue';
import FileWorkflow from './components/FileWorkflow.vue';
import HistoryPanel from './components/HistoryPanel.vue';
import DatabasePanel from './components/DatabasePanel.vue';
import ScriptPanel from './components/ScriptPanel.vue';
import SettingsPanel from './components/SettingsPanel.vue';
const message = useMessage();
const ScriptPanel = defineAsyncComponent(() => import('./components/ScriptPanel.vue'));
const token = ref(getToken());
const activeMenu = ref('workflow');
const loginLoading = ref(false);
const menuOptions: MenuOption[] = [
{ label: '处理任务', key: 'workflow', icon: renderIcon(CloudUploadOutline) },
{ label: '数据上传', key: 'workflow', icon: renderIcon(CloudUploadOutline) },
{ label: '处理历史', key: 'history', icon: renderIcon(FileTrayFullOutline) },
{ label: '数据表', key: 'database', icon: renderIcon(ServerOutline) },
{ label: 'SQL 脚本', key: 'script', icon: renderIcon(ConstructOutline) },
{ label: '数据管理', key: 'database', icon: renderIcon(ServerOutline) },
{ label: '脚本编辑', key: 'script', icon: renderIcon(ConstructOutline) },
{ label: '系统设置', key: 'settings', icon: renderIcon(SettingsOutline) }
];
+152 -41
View File
@@ -1,63 +1,87 @@
<template>
<n-grid :cols="12" :x-gap="16" :y-gap="16" responsive="screen">
<n-gi :span="12" :m="8">
<n-card title="SQL 脚本" size="small">
<template #header-extra>
<n-space>
<n-button size="small" tertiary :loading="loading" @click="loadScript">重新加载</n-button>
<n-button size="small" type="primary" :loading="saving" @click="saveScript">保存</n-button>
<n-button size="small" type="warning" :loading="executing" @click="executeScript">执行</n-button>
</n-space>
</template>
<n-space vertical>
<n-alert v-if="scriptPath" type="default" :bordered="false">
{{ scriptPath }} / 修改时间:{{ modified || '-' }}
</n-alert>
<n-input
v-model:value="content"
type="textarea"
class="script-editor"
:autosize="{ minRows: 24, maxRows: 32 }"
spellcheck="false"
/>
<div class="script-workspace">
<n-card size="small" class="work-card script-editor-pane">
<template #header>
<div class="script-card-title">
<span>脚本编辑</span>
<n-tag v-if="isModified" size="small" type="warning">已修改</n-tag>
<n-tag v-else size="small" type="success">已保存</n-tag>
</div>
</template>
<template #header-extra>
<n-space>
<n-button size="small" tertiary :loading="loading" @click="loadScript">重新加载</n-button>
<n-button size="small" type="primary" :loading="saving" @click="saveScript">保存</n-button>
<n-button size="small" type="warning" :loading="executing" @click="executeScript">执行</n-button>
</n-space>
</n-card>
</n-gi>
</template>
<n-gi :span="12" :m="4">
<n-card title="执行状态" size="small">
<template #header-extra>
<div class="script-editor-toolbar">
<span class="script-path" :title="scriptPath">{{ scriptPath || 'ReportScript.sql' }}</span>
<span class="script-modified">修改时间:{{ modified || '-' }}</span>
</div>
<div ref="editorHost" class="monaco-editor-host" />
<div class="script-status-bar">
<span>{{ cursorText }}</span>
<span>{{ lineCount }} 行</span>
<span>{{ editorStatus }}</span>
</div>
</n-card>
<n-card title="执行状态" size="small" class="work-card script-log-pane">
<template #header-extra>
<n-space>
<n-tag v-if="taskStatus" :type="statusType">{{ statusText }}</n-tag>
<n-button size="small" tertiary :disabled="!taskId" @click="refreshStatus">刷新</n-button>
</template>
<n-space vertical>
<n-tag v-if="taskStatus" :type="statusType">{{ taskStatus.status }}</n-tag>
<n-empty v-if="!taskStatus" description="脚本尚未执行" />
<n-log v-else :log="logText" language="text" trim class="log-panel" />
</n-space>
</n-card>
</n-gi>
</n-grid>
</template>
<n-empty v-if="!taskStatus" description="脚本尚未执行" />
<n-log v-else :log="logText" language="text" trim class="script-log" />
</n-card>
</div>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
import { computed, nextTick, onBeforeUnmount, onMounted, ref, shallowRef } from 'vue';
import { useMessage } from 'naive-ui';
import * as monaco from 'monaco-editor/esm/vs/editor/editor.api.js';
import editorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker';
import 'monaco-editor/esm/vs/basic-languages/sql/sql.contribution';
import { apiGet, apiPost } from '../api/client';
import type { ApiMessage, ScriptContent, TaskStatus } from '../types';
type MonacoGlobal = typeof globalThis & {
MonacoEnvironment?: {
getWorker: (_moduleId: string, _label: string) => Worker;
};
};
(globalThis as MonacoGlobal).MonacoEnvironment = {
getWorker: () => new editorWorker()
};
const message = useMessage();
const editorHost = ref<HTMLDivElement | null>(null);
const editor = shallowRef<monaco.editor.IStandaloneCodeEditor | null>(null);
const disposables: monaco.IDisposable[] = [];
const content = ref('');
const originalContent = ref('');
const scriptPath = ref('');
const modified = ref<string | null>(null);
const cursorText = ref('行 1,列 1');
const lineCount = ref(1);
const isModified = ref(false);
const loading = ref(false);
const saving = ref(false);
const executing = ref(false);
const taskId = ref('');
const taskStatus = ref<TaskStatus | null>(null);
let timer: number | undefined;
let applyingRemoteContent = false;
const logText = computed(() => taskStatus.value?.logs?.join('\n') || '等待执行日志');
const statusType = computed(() => {
@@ -65,15 +89,72 @@ const statusType = computed(() => {
if (taskStatus.value?.status === 'failed') return 'error';
return 'info';
});
const statusText = computed(() => {
const status = taskStatus.value?.status;
if (status === 'completed') return '已完成';
if (status === 'failed') return '失败';
if (status === 'processing') return '执行中';
if (status === 'pending') return '等待中';
return status || '';
});
const editorStatus = computed(() => {
if (loading.value) return '加载中';
if (saving.value) return '保存中';
return isModified.value ? '未保存' : '就绪';
});
onMounted(() => {
void loadScript();
onMounted(async () => {
await nextTick();
createEditor();
await loadScript();
});
onBeforeUnmount(() => {
stopPolling();
for (const disposable of disposables) {
disposable.dispose();
}
editor.value?.dispose();
});
function createEditor() {
if (!editorHost.value || editor.value) return;
editor.value = monaco.editor.create(editorHost.value, {
value: '-- 正在加载 ReportScript.sql',
language: 'sql',
theme: 'vs',
fontSize: 14,
fontFamily: "'Cascadia Code', Consolas, 'Courier New', monospace",
lineNumbers: 'on',
minimap: { enabled: true },
automaticLayout: true,
scrollBeyondLastLine: false,
roundedSelection: false,
renderLineHighlight: 'all',
tabSize: 4,
wordWrap: 'off'
});
disposables.push(
editor.value.onDidChangeCursorPosition(updateCursor),
editor.value.onDidChangeModelContent(() => {
const value = getEditorContent();
content.value = value;
lineCount.value = editor.value?.getModel()?.getLineCount() || 1;
if (!applyingRemoteContent) {
isModified.value = value !== originalContent.value;
}
})
);
editor.value.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => {
void saveScript();
});
updateCursor();
}
async function loadScript() {
loading.value = true;
try {
@@ -81,7 +162,7 @@ async function loadScript() {
if (!result.success) {
throw new Error(result.error || '加载脚本失败');
}
content.value = result.content;
applyScriptContent(result.content);
scriptPath.value = result.path;
modified.value = result.modified;
} catch (error) {
@@ -94,12 +175,15 @@ async function loadScript() {
async function saveScript() {
saving.value = true;
try {
const value = getEditorContent();
const result = await apiPost<ApiMessage & { modified?: string }>('/api/script/save', {
content: content.value
content: value
});
if (!result.success) {
throw new Error(result.error || '保存失败');
}
originalContent.value = value;
isModified.value = false;
modified.value = result.modified || modified.value;
message.success(result.message || '保存成功');
} catch (error) {
@@ -112,6 +196,11 @@ async function saveScript() {
async function executeScript() {
executing.value = true;
try {
if (isModified.value) {
await saveScript();
if (isModified.value) return;
}
const result = await apiPost<ApiMessage>('/api/script/execute');
if (!result.task_id) {
throw new Error(result.message || '脚本任务启动失败');
@@ -127,6 +216,28 @@ async function executeScript() {
}
}
function applyScriptContent(value: string) {
applyingRemoteContent = true;
content.value = value;
originalContent.value = value;
if (editor.value && editor.value.getValue() !== value) {
editor.value.setValue(value);
}
lineCount.value = editor.value?.getModel()?.getLineCount() || value.split(/\r\n|\r|\n/).length || 1;
isModified.value = false;
applyingRemoteContent = false;
updateCursor();
}
function getEditorContent() {
return editor.value?.getValue() ?? content.value;
}
function updateCursor() {
const position = editor.value?.getPosition();
cursorText.value = position ? `行 ${position.lineNumber},列 ${position.column}` : '行 1,列 1';
}
function startPolling() {
stopPolling();
void refreshStatus();
+312 -111
View File
@@ -1,127 +1,228 @@
<template>
<n-grid :cols="12" :x-gap="16" :y-gap="16" responsive="screen">
<n-gi :span="12" :m="6">
<n-space vertical size="large">
<n-card title="数据库配置" size="small">
<template #header-extra>
<div class="settings-workspace">
<div class="settings-column">
<n-card title="数据库配置" size="small" class="work-card">
<template #header-extra>
<n-space>
<n-button size="small" tertiary :loading="loading" @click="loadConfig">刷新</n-button>
</template>
<n-form label-placement="left" label-width="90">
<n-form-item label="地址">
<n-input v-model:value="mysqlForm.host" />
</n-form-item>
<n-form-item label="端口">
<n-input-number v-model:value="mysqlForm.port" class="full-width" :min="1" :max="65535" />
</n-form-item>
<n-form-item label="账号">
<n-input v-model:value="mysqlForm.user" />
</n-form-item>
<n-form-item label="密码">
<n-input v-model:value="mysqlForm.passwd" type="password" show-password-on="click" />
</n-form-item>
<n-form-item label="库名">
<n-input v-model:value="mysqlForm.dbname" />
</n-form-item>
<n-button type="primary" :loading="savingMysql" @click="saveMysql">保存数据库配置</n-button>
</n-form>
<div class="muted-line">配置更新时间:{{ configUpdate || '-' }}</div>
</n-card>
<n-button size="small" tertiary :loading="testingDb" @click="testDatabase">测试连接</n-button>
</n-space>
</template>
<n-card title="登录密码" size="small">
<n-form label-placement="left" label-width="90">
<n-form-item label="当前密码">
<n-input v-model:value="passwordForm.current_password" type="password" show-password-on="click" />
</n-form-item>
<n-form-item label="新密码">
<n-input v-model:value="passwordForm.new_password" type="password" show-password-on="click" />
</n-form-item>
<n-form-item label="确认密码">
<n-input v-model:value="passwordForm.confirm_password" type="password" show-password-on="click" />
</n-form-item>
<n-form label-placement="top">
<n-grid :cols="12" :x-gap="12">
<n-gi :span="8">
<n-form-item label="主机地址">
<n-input v-model:value="mysqlForm.host" placeholder="localhost" />
</n-form-item>
</n-gi>
<n-gi :span="4">
<n-form-item label="端口">
<n-input-number v-model:value="mysqlForm.port" class="full-width" :min="1" :max="65535" />
</n-form-item>
</n-gi>
</n-grid>
<n-form-item label="数据库名">
<n-input v-model:value="mysqlForm.dbname" placeholder="CapacityReport" />
</n-form-item>
<n-grid :cols="12" :x-gap="12">
<n-gi :span="6">
<n-form-item label="用户名">
<n-input v-model:value="mysqlForm.user" placeholder="root" />
</n-form-item>
</n-gi>
<n-gi :span="6">
<n-form-item label="密码">
<n-input v-model:value="mysqlForm.passwd" type="password" show-password-on="click" />
</n-form-item>
</n-gi>
</n-grid>
<div class="card-actions">
<span class="muted-line">更新时间:{{ configUpdate || '-' }}</span>
<n-button type="primary" :loading="savingMysql" @click="saveMysql">保存配置</n-button>
</div>
</n-form>
</n-card>
<n-card title="Sheet 过滤规则" size="small" class="work-card">
<n-space vertical>
<div class="filter-tags">
<n-tag
v-for="(filter, index) in sheetFilters"
:key="`${filter}-${index}`"
closable
@close="removeSheetFilter(index)"
>
{{ filter }}
</n-tag>
<n-empty v-if="sheetFilters.length === 0" size="small" description="暂无过滤规则" />
</div>
<n-input-group>
<n-input
v-model:value="newSheetFilter"
placeholder="输入需要跳过的 Sheet 关键词"
@keydown.enter.prevent="addSheetFilter"
/>
<n-button @click="addSheetFilter">添加</n-button>
</n-input-group>
<div class="card-actions">
<span class="muted-line">匹配这些关键词的 Sheet 会跳过处理</span>
<n-button type="primary" :loading="savingSheetFilter" @click="saveSheetFilter">保存规则</n-button>
</div>
</n-space>
</n-card>
<n-card title="登录密码" size="small" class="work-card">
<n-form label-placement="top">
<n-form-item label="当前密码">
<n-input v-model:value="passwordForm.current_password" type="password" show-password-on="click" />
</n-form-item>
<n-form-item label="新密码">
<n-input v-model:value="passwordForm.new_password" type="password" show-password-on="click" />
</n-form-item>
<n-form-item label="确认密码">
<n-input v-model:value="passwordForm.confirm_password" type="password" show-password-on="click" />
</n-form-item>
<div class="card-actions">
<span />
<n-button type="primary" :loading="changingPassword" @click="changePassword">修改密码</n-button>
</n-form>
</n-card>
</div>
</n-form>
</n-card>
<n-card title="配置文件" size="small">
<n-card title="配置文件与服务" size="small" class="work-card">
<n-space vertical>
<n-space>
<n-button @click="downloadConfig">下载配置</n-button>
<n-button @click="configInput?.click()">上传配置</n-button>
<input ref="configInput" class="hidden-input" type="file" accept=".json" @change="uploadConfigFile" />
</n-space>
</n-card>
</n-space>
</n-gi>
<n-gi :span="12" :m="6">
<n-space vertical size="large">
<n-card title="Sheet 过滤规则" size="small">
<n-space vertical>
<n-input
v-model:value="sheetFilterText"
type="textarea"
:autosize="{ minRows: 6, maxRows: 10 }"
placeholder="每行一条规则"
/>
<n-button type="primary" :loading="savingSheetFilter" @click="saveSheetFilter">保存过滤规则</n-button>
</n-space>
</n-card>
<n-card title="字段提取配置" size="small">
<n-space vertical>
<n-input
v-model:value="extractFieldsText"
type="textarea"
class="json-editor"
:autosize="{ minRows: 12, maxRows: 18 }"
spellcheck="false"
/>
<n-space>
<n-button :disabled="!extractFieldsText" @click="formatExtractFields">格式化</n-button>
<n-button type="primary" :loading="savingExtractFields" @click="saveExtractFields">
保存字段配置
</n-button>
</n-space>
</n-space>
</n-card>
<n-card title="运行状态" size="small">
<template #header-extra>
<n-button size="small" tertiary @click="loadServiceStatus">刷新</n-button>
</template>
<n-descriptions v-if="serviceStatus" bordered size="small" :column="2">
<n-descriptions-item label="状态">{{ serviceStatus.status }}</n-descriptions-item>
<n-descriptions-item label="版本">{{ serviceStatus.version }}</n-descriptions-item>
<n-descriptions-item label="系统">{{ serviceStatus.platform }}</n-descriptions-item>
<n-descriptions-item label="进程">{{ serviceStatus.pid }}</n-descriptions-item>
<n-descriptions-item label="Python">{{ serviceStatus.python_version }}</n-descriptions-item>
<n-descriptions-item label="Supervisor">{{ serviceStatus.supervisor ? '是' : '否' }}</n-descriptions-item>
</n-descriptions>
<n-button class="restart-button" type="warning" tertiary @click="confirmRestart">重启服务</n-button>
</n-card>
</n-space>
</n-gi>
</n-grid>
<div class="card-actions">
<n-button size="small" tertiary @click="loadServiceStatus">刷新状态</n-button>
<n-button type="warning" tertiary @click="confirmRestart">重启服务</n-button>
</div>
</n-space>
</n-card>
</div>
<n-card size="small" class="work-card field-mapping-card">
<template #header>
<div class="field-card-title">
<span>字段提取配置</span>
<n-tag size="small" round>{{ extractFields.length }} 个字段</n-tag>
</div>
</template>
<template #header-extra>
<n-space align="center">
<n-input
v-model:value="fieldSearch"
size="small"
clearable
class="field-search"
placeholder="搜索字段或提取来源"
/>
<n-button size="small" @click="addFieldMapping">添加字段</n-button>
<n-button size="small" type="primary" :loading="savingExtractFields" @click="saveExtractFields">
保存映射
</n-button>
</n-space>
</template>
<n-scrollbar class="field-mapping-scroll">
<n-empty v-if="visibleFieldMappings.length === 0" description="没有匹配的字段配置" />
<div
v-for="{ field, index } in visibleFieldMappings"
:key="index"
class="field-mapping-item"
>
<div class="field-mapping-number">{{ index + 1 }}</div>
<n-button
quaternary
circle
size="small"
type="error"
class="field-remove-button"
@click="removeFieldMapping(index)"
>
×
</n-button>
<div class="field-mapping-header">
<n-form-item label="数据库字段名" class="field-name-control">
<n-input v-model:value="field.Field" placeholder="输入字段名" />
</n-form-item>
<n-form-item label="字段类型" class="field-type-control">
<n-select v-model:value="field.Type" :options="fieldTypeOptions" />
</n-form-item>
</div>
<div class="extract-list">
<div class="extract-list-header">
<span>提取来源({{ field.Extract.length }} 个)</span>
<n-input-group class="extract-add-group">
<n-input
v-model:value="newExtractValues[index]"
size="small"
placeholder="输入 Excel 列名"
@keydown.enter.prevent="addExtract(index)"
/>
<n-button size="small" @click="addExtract(index)">添加</n-button>
</n-input-group>
</div>
<div v-if="field.Extract.length > 0" class="extract-tree">
<div v-for="(source, sourceIndex) in field.Extract" :key="`${source}-${sourceIndex}`" class="extract-tree-item">
<span class="tree-text">{{ source }}</span>
<n-button text size="tiny" type="warning" @click="removeExtract(index, sourceIndex)">删除</n-button>
</div>
</div>
<div v-else class="extract-empty">暂无提取来源</div>
</div>
</div>
</n-scrollbar>
</n-card>
</div>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue';
import { useDialog, useMessage } from 'naive-ui';
import { computed, onMounted, reactive, ref } from 'vue';
import { useDialog, useMessage, type SelectOption } from 'naive-ui';
import { apiGet, apiPost, downloadGet, upload } from '../api/client';
import type { ApiMessage, AppConfig, ServiceStatus } from '../types';
interface ExtractFieldConfig {
Field: string;
Type: string;
Extract: string[];
[key: string]: unknown;
}
const message = useMessage();
const dialog = useDialog();
const configInput = ref<HTMLInputElement | null>(null);
const configUpdate = ref('');
const loading = ref(false);
const testingDb = ref(false);
const savingMysql = ref(false);
const savingSheetFilter = ref(false);
const savingExtractFields = ref(false);
const changingPassword = ref(false);
const sheetFilterText = ref('');
const extractFieldsText = ref('[]');
const serviceStatus = ref<ServiceStatus | null>(null);
const sheetFilters = ref<string[]>([]);
const newSheetFilter = ref('');
const extractFields = ref<ExtractFieldConfig[]>([]);
const fieldSearch = ref('');
const newExtractValues = reactive<Record<number, string>>({});
const mysqlForm = reactive({
host: '',
@@ -137,6 +238,25 @@ const passwordForm = reactive({
confirm_password: ''
});
const fieldTypeOptions: SelectOption[] = [
{ label: '字符串', value: 'string' },
{ label: '整数', value: 'int' },
{ label: '小数', value: 'float' },
{ label: '日期时间', value: 'datetime' }
];
const visibleFieldMappings = computed(() => {
const keyword = fieldSearch.value.trim().toLowerCase();
return extractFields.value
.map((field, index) => ({ field, index }))
.filter(({ field }) => {
if (!keyword) return true;
const fieldName = field.Field.toLowerCase();
const extractText = field.Extract.join(' ').toLowerCase();
return fieldName.includes(keyword) || extractText.includes(keyword);
});
});
onMounted(() => {
void loadConfig();
void loadServiceStatus();
@@ -152,8 +272,9 @@ async function loadConfig() {
mysqlForm.user = config.mysql.user;
mysqlForm.passwd = config.mysql.passwd || '';
mysqlForm.dbname = config.mysql.dbname;
sheetFilterText.value = config.sheet_filter.join('\n');
extractFieldsText.value = JSON.stringify(config.extract_fields, null, 2);
sheetFilters.value = [...config.sheet_filter];
extractFields.value = normalizeExtractFields(config.extract_fields);
resetExtractInputs();
} catch (error) {
message.error(error instanceof Error ? error.message : '加载配置失败');
} finally {
@@ -182,14 +303,37 @@ async function saveMysql() {
}
}
async function testDatabase() {
testingDb.value = true;
try {
const result = await apiPost<ApiMessage>('/api/database/test');
message[result.success ? 'success' : 'error'](result.message || '连接测试完成');
} catch (error) {
message.error(error instanceof Error ? error.message : '数据库连接测试失败');
} finally {
testingDb.value = false;
}
}
function addSheetFilter() {
const value = newSheetFilter.value.trim();
if (!value) return;
if (sheetFilters.value.includes(value)) {
message.warning('该规则已存在');
return;
}
sheetFilters.value.push(value);
newSheetFilter.value = '';
}
function removeSheetFilter(index: number) {
sheetFilters.value.splice(index, 1);
}
async function saveSheetFilter() {
savingSheetFilter.value = true;
try {
const filters = sheetFilterText.value
.split(/\r?\n/)
.map(item => item.trim())
.filter(Boolean);
const result = await apiPost<ApiMessage>('/api/config/sheet-filter', filters);
const result = await apiPost<ApiMessage>('/api/config/sheet-filter', sheetFilters.value);
configUpdate.value = result.update || configUpdate.value;
message.success(result.message || '过滤规则已保存');
} catch (error) {
@@ -199,30 +343,56 @@ async function saveSheetFilter() {
}
}
function formatExtractFields() {
try {
extractFieldsText.value = JSON.stringify(JSON.parse(extractFieldsText.value), null, 2);
} catch {
message.error('字段配置不是有效 JSON');
function addFieldMapping() {
extractFields.value.push({ Field: '', Type: 'string', Extract: [] });
resetExtractInputs();
}
function removeFieldMapping(index: number) {
extractFields.value.splice(index, 1);
resetExtractInputs();
}
function addExtract(index: number) {
const value = (newExtractValues[index] || '').trim();
if (!value) return;
const field = extractFields.value[index];
if (!field) return;
if (field.Extract.includes(value)) {
message.warning('该提取来源已存在');
return;
}
field.Extract.push(value);
newExtractValues[index] = '';
}
function removeExtract(fieldIndex: number, extractIndex: number) {
extractFields.value[fieldIndex]?.Extract.splice(extractIndex, 1);
}
async function saveExtractFields() {
let fields: Array<Record<string, unknown>>;
try {
const parsed = JSON.parse(extractFieldsText.value) as unknown;
if (!Array.isArray(parsed)) {
throw new Error('字段配置必须是数组');
}
fields = parsed as Array<Record<string, unknown>>;
} catch (error) {
message.error(error instanceof Error ? error.message : '字段配置不是有效 JSON');
const fields = extractFields.value
.map(field => ({
...field,
Field: field.Field.trim(),
Type: field.Type || 'string',
Extract: uniqueStrings(field.Extract)
}))
.filter(field => field.Field);
if (fields.length === 0) {
message.warning('请至少保留一个有效字段');
return;
}
savingExtractFields.value = true;
try {
const result = await apiPost<ApiMessage>('/api/config/extract-fields', fields);
extractFields.value = fields;
resetExtractInputs();
configUpdate.value = result.update || configUpdate.value;
message.success(result.message || '字段配置已保存');
} catch (error) {
@@ -310,4 +480,35 @@ async function restartService() {
message.error(error instanceof Error ? error.message : '重启服务失败');
}
}
function normalizeExtractFields(fields: Array<Record<string, unknown>>): ExtractFieldConfig[] {
return fields.map(field => {
const extract = Array.isArray(field.Extract) ? uniqueStrings(field.Extract) : [];
return {
...field,
Field: String(field.Field || ''),
Type: typeof field.Type === 'string' && field.Type ? field.Type : 'string',
Extract: extract
};
});
}
function uniqueStrings(values: unknown[]): string[] {
return Array.from(
new Set(
values
.map(value => String(value || '').trim())
.filter(Boolean)
)
);
}
function resetExtractInputs() {
for (const key of Object.keys(newExtractValues)) {
delete newExtractValues[Number(key)];
}
extractFields.value.forEach((_, index) => {
newExtractValues[index] = '';
});
}
</script>
+379 -31
View File
@@ -12,7 +12,7 @@ body,
}
body {
color: #20242a;
color: #1f2933;
background: #eef1f4;
font-family:
Inter,
@@ -24,27 +24,41 @@ body {
.app-layout {
min-height: 100vh;
background: #eef1f4;
}
.app-sider {
background: #ffffff;
border-right: 1px solid #d9dee7;
}
.app-sider .n-layout-sider-scroll-container {
display: flex;
flex-direction: column;
height: 100%;
}
.brand {
display: flex;
align-items: center;
flex-shrink: 0;
gap: 10px;
height: 60px;
padding: 0 16px;
border-bottom: 1px solid #e6e9ee;
height: 64px;
padding: 0 18px;
border-bottom: 1px solid #e4e8ef;
}
.brand-mark {
display: grid;
flex: 0 0 auto;
width: 34px;
height: 34px;
place-items: center;
color: #fff;
font-size: 13px;
font-weight: 700;
background: #2458a6;
border-radius: 8px;
background: #1f5ea8;
border-radius: 6px;
}
.brand-mark.large {
@@ -60,30 +74,75 @@ body {
text-overflow: ellipsis;
}
.app-menu {
flex: 1;
padding: 12px 8px;
}
.sider-footer {
display: flex;
flex-direction: column;
flex-shrink: 0;
gap: 3px;
padding: 12px 18px;
color: #7b8492;
font-size: 12px;
border-top: 1px solid #e4e8ef;
}
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
height: 60px;
height: 64px;
padding: 0 20px;
background: #fff;
}
.topbar-title {
min-width: 0;
}
.page-title {
overflow: hidden;
max-width: 52vw;
font-size: 18px;
font-weight: 700;
white-space: nowrap;
text-overflow: ellipsis;
}
.page-subtitle,
.muted-line {
margin-top: 2px;
color: #6a7280;
color: #6f7885;
font-size: 12px;
}
.content {
min-width: 0;
padding: 16px;
height: calc(100vh - 64px);
padding: 14px;
overflow: auto;
background: #eef1f4;
}
.work-card,
.n-card {
border-radius: 6px;
}
.work-card {
border-color: #dfe4ec;
box-shadow: 0 1px 2px rgba(31, 41, 55, 0.04);
}
.work-card .n-card-header {
min-height: 46px;
padding: 10px 14px;
}
.work-card .n-card__content {
padding: 14px;
}
.login-page {
@@ -91,15 +150,13 @@ body {
min-height: 100vh;
place-items: center;
padding: 24px;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.72), rgba(238, 241, 244, 0.94)),
#eef1f4;
background: #eef1f4;
}
.login-card {
width: min(420px, 100%);
border-radius: 8px;
box-shadow: 0 18px 48px rgba(21, 38, 62, 0.16);
box-shadow: 0 18px 48px rgba(21, 38, 62, 0.14);
}
.login-brand {
@@ -133,22 +190,15 @@ body {
max-height: 360px;
}
.log-panel {
.log-panel,
.script-log {
min-height: 320px;
padding: 12px;
background: #101418;
color: #d7dde7;
background: #111820;
border-radius: 6px;
}
.script-editor textarea,
.json-editor textarea {
font-family:
"Cascadia Code",
Consolas,
"Courier New",
monospace;
}
.section-label {
margin-bottom: 8px;
color: #333b46;
@@ -166,8 +216,294 @@ body {
margin-top: 16px;
}
.n-card {
border-radius: 8px;
.settings-workspace {
display: grid;
grid-template-columns: minmax(340px, 420px) minmax(0, 1fr);
align-items: start;
gap: 14px;
}
.settings-column {
display: flex;
flex-direction: column;
gap: 14px;
}
.card-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-top: 4px;
}
.filter-tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
min-height: 34px;
}
.field-mapping-card {
min-width: 0;
height: calc(100vh - 92px);
}
.field-mapping-card .n-card__content {
height: calc(100% - 48px);
padding: 0 14px 14px;
}
.field-card-title,
.script-card-title {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
font-weight: 700;
}
.field-search {
width: 240px;
}
.field-mapping-scroll {
height: 100%;
}
.field-mapping-scroll .n-scrollbar-content {
display: flex;
flex-direction: column;
gap: 12px;
padding: 2px 4px 4px 0;
}
.field-mapping-item {
position: relative;
min-width: 0;
padding: 32px 12px 12px;
background: #fff;
border: 1px solid #dfe4ec;
border-radius: 6px;
}
.field-mapping-item:hover {
box-shadow: 0 2px 8px rgba(31, 41, 55, 0.08);
}
.field-mapping-number {
position: absolute;
top: 8px;
left: 10px;
display: grid;
width: 22px;
height: 22px;
place-items: center;
color: #fff;
font-size: 12px;
font-weight: 700;
background: #1f5ea8;
border-radius: 4px;
}
.field-remove-button {
position: absolute;
top: 6px;
right: 8px;
}
.field-mapping-header {
display: grid;
grid-template-columns: minmax(180px, 1fr) 160px;
gap: 12px;
padding-bottom: 10px;
border-bottom: 1px solid #e8ecf2;
}
.field-name-control,
.field-type-control {
margin-bottom: 0;
}
.extract-list {
padding-top: 10px;
}
.extract-list-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 8px;
color: #4b5563;
font-size: 13px;
font-weight: 600;
}
.extract-add-group {
width: 300px;
}
.extract-tree {
max-height: 130px;
overflow: auto;
background: #f6f8fb;
border: 1px solid #e1e6ee;
border-radius: 5px;
}
.extract-tree-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-height: 32px;
padding: 5px 10px;
font-size: 13px;
border-bottom: 1px solid #e8edf3;
}
.extract-tree-item:last-child {
border-bottom: 0;
}
.extract-tree-item:hover {
background: #edf3fa;
}
.tree-text {
display: inline-flex;
align-items: center;
min-width: 0;
gap: 6px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.tree-text::before {
content: "└";
flex: 0 0 auto;
color: #8b95a4;
}
.extract-empty {
padding: 9px 10px;
color: #8b95a4;
font-size: 13px;
background: #f6f8fb;
border: 1px dashed #cfd7e4;
border-radius: 5px;
}
.script-workspace {
display: grid;
grid-template-columns: minmax(0, 1fr) 360px;
gap: 14px;
height: calc(100vh - 92px);
min-height: 620px;
}
.script-editor-pane,
.script-log-pane {
min-width: 0;
height: 100%;
}
.script-editor-pane .n-card__content {
display: flex;
flex-direction: column;
height: calc(100% - 48px);
padding: 0;
}
.script-editor-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
min-height: 38px;
padding: 0 12px;
color: #596473;
font-size: 12px;
background: #f6f8fb;
border-top: 1px solid #e4e8ef;
border-bottom: 1px solid #e4e8ef;
}
.script-path {
overflow: hidden;
min-width: 0;
font-family:
"Cascadia Code",
Consolas,
"Courier New",
monospace;
white-space: nowrap;
text-overflow: ellipsis;
}
.script-modified {
flex: 0 0 auto;
}
.monaco-editor-host {
flex: 1;
min-height: 480px;
overflow: hidden;
}
.script-status-bar {
display: flex;
align-items: center;
gap: 18px;
height: 28px;
padding: 0 12px;
color: #5f6b7a;
font-size: 12px;
background: #f6f8fb;
border-top: 1px solid #e4e8ef;
}
.script-log-pane .n-card__content {
height: calc(100% - 48px);
}
.script-log {
height: 100%;
min-height: 0;
}
@media (max-width: 1180px) {
.settings-workspace,
.script-workspace {
grid-template-columns: 1fr;
height: auto;
min-height: 0;
}
.field-mapping-card,
.script-editor-pane,
.script-log-pane {
height: auto;
}
.field-mapping-scroll {
height: min(680px, calc(100vh - 180px));
}
.script-workspace {
min-height: 0;
}
.monaco-editor-host {
height: 620px;
flex: 0 0 auto;
}
.script-log {
height: 360px;
}
}
@media (max-width: 720px) {
@@ -176,15 +512,27 @@ body {
}
.content {
padding: 12px;
padding: 10px;
}
.page-subtitle {
display: none;
.page-title {
max-width: 46vw;
}
.table-footer {
.table-footer,
.card-actions,
.extract-list-header,
.script-editor-toolbar {
align-items: flex-start;
flex-direction: column;
}
.field-mapping-header {
grid-template-columns: 1fr;
}
.field-search,
.extract-add-group {
width: 100%;
}
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />