fix: 恢复前端脚本编辑器和字段配置交互
This commit is contained in:
@@ -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 启动脚本编码问题
|
## 2026-05-15:修复 Windows 启动脚本编码问题
|
||||||
|
|
||||||
- `run.bat` 改为纯 ASCII 输出,并规范为 CRLF 行尾,避免 Windows `cmd` 在 PowerShell 中执行 UTF-8 中文批处理时把提示文本解析成碎片命令。
|
- `run.bat` 改为纯 ASCII 输出,并规范为 CRLF 行尾,避免 Windows `cmd` 在 PowerShell 中执行 UTF-8 中文批处理时把提示文本解析成碎片命令。
|
||||||
|
|||||||
Generated
+39
@@ -9,6 +9,7 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vicons/ionicons5": "^0.13.0",
|
"@vicons/ionicons5": "^0.13.0",
|
||||||
|
"monaco-editor": "^0.55.1",
|
||||||
"naive-ui": "^2.42.0",
|
"naive-ui": "^2.42.0",
|
||||||
"vue": "^3.5.0"
|
"vue": "^3.5.0"
|
||||||
},
|
},
|
||||||
@@ -922,6 +923,13 @@
|
|||||||
"@types/lodash": "*"
|
"@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": {
|
"node_modules/@vicons/ionicons5": {
|
||||||
"version": "0.13.0",
|
"version": "0.13.0",
|
||||||
"resolved": "https://registry.npmjs.org/@vicons/ionicons5/-/ionicons5-0.13.0.tgz",
|
"resolved": "https://registry.npmjs.org/@vicons/ionicons5/-/ionicons5-0.13.0.tgz",
|
||||||
@@ -1144,6 +1152,15 @@
|
|||||||
"date-fns": "^3.0.0 || ^4.0.0"
|
"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": {
|
"node_modules/entities": {
|
||||||
"version": "7.0.1",
|
"version": "7.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
|
||||||
@@ -1273,6 +1290,28 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
"@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": {
|
"node_modules/muggle-string": {
|
||||||
"version": "0.4.1",
|
"version": "0.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz",
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vicons/ionicons5": "^0.13.0",
|
"@vicons/ionicons5": "^0.13.0",
|
||||||
|
"monaco-editor": "^0.55.1",
|
||||||
"naive-ui": "^2.42.0",
|
"naive-ui": "^2.42.0",
|
||||||
"vue": "^3.5.0"
|
"vue": "^3.5.0"
|
||||||
},
|
},
|
||||||
@@ -20,4 +21,3 @@
|
|||||||
"vue-tsc": "^3.0.0"
|
"vue-tsc": "^3.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<LoginView v-if="!token" :loading="loginLoading" @login="handleLogin" />
|
<LoginView v-if="!token" :loading="loginLoading" @login="handleLogin" />
|
||||||
<n-layout v-else has-sider class="app-layout">
|
<n-layout v-else has-sider class="app-layout">
|
||||||
<n-layout-sider
|
<n-layout-sider
|
||||||
bordered
|
class="app-sider"
|
||||||
collapse-mode="width"
|
collapse-mode="width"
|
||||||
:collapsed-width="64"
|
:collapsed-width="64"
|
||||||
:width="224"
|
:width="224"
|
||||||
@@ -14,17 +14,21 @@
|
|||||||
</div>
|
</div>
|
||||||
<n-menu
|
<n-menu
|
||||||
v-model:value="activeMenu"
|
v-model:value="activeMenu"
|
||||||
|
class="app-menu"
|
||||||
:collapsed-width="64"
|
:collapsed-width="64"
|
||||||
:collapsed-icon-size="22"
|
:collapsed-icon-size="22"
|
||||||
:options="menuOptions"
|
:options="menuOptions"
|
||||||
/>
|
/>
|
||||||
|
<div class="sider-footer">
|
||||||
|
<span>v2.0</span>
|
||||||
|
<span>CapacityReport</span>
|
||||||
|
</div>
|
||||||
</n-layout-sider>
|
</n-layout-sider>
|
||||||
|
|
||||||
<n-layout>
|
<n-layout>
|
||||||
<n-layout-header bordered class="topbar">
|
<n-layout-header bordered class="topbar">
|
||||||
<div>
|
<div class="topbar-title">
|
||||||
<div class="page-title">{{ currentTitle }}</div>
|
<div class="page-title">{{ currentTitle }}</div>
|
||||||
<div class="page-subtitle">容量报表处理与数据维护</div>
|
|
||||||
</div>
|
</div>
|
||||||
<n-space align="center">
|
<n-space align="center">
|
||||||
<n-tag size="small" type="success" round>已登录</n-tag>
|
<n-tag size="small" type="success" round>已登录</n-tag>
|
||||||
@@ -48,7 +52,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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 { useMessage, type MenuOption, NIcon } from 'naive-ui';
|
||||||
import {
|
import {
|
||||||
CloudUploadOutline,
|
CloudUploadOutline,
|
||||||
@@ -65,19 +69,19 @@ import LoginView from './components/LoginView.vue';
|
|||||||
import FileWorkflow from './components/FileWorkflow.vue';
|
import FileWorkflow from './components/FileWorkflow.vue';
|
||||||
import HistoryPanel from './components/HistoryPanel.vue';
|
import HistoryPanel from './components/HistoryPanel.vue';
|
||||||
import DatabasePanel from './components/DatabasePanel.vue';
|
import DatabasePanel from './components/DatabasePanel.vue';
|
||||||
import ScriptPanel from './components/ScriptPanel.vue';
|
|
||||||
import SettingsPanel from './components/SettingsPanel.vue';
|
import SettingsPanel from './components/SettingsPanel.vue';
|
||||||
|
|
||||||
const message = useMessage();
|
const message = useMessage();
|
||||||
|
const ScriptPanel = defineAsyncComponent(() => import('./components/ScriptPanel.vue'));
|
||||||
const token = ref(getToken());
|
const token = ref(getToken());
|
||||||
const activeMenu = ref('workflow');
|
const activeMenu = ref('workflow');
|
||||||
const loginLoading = ref(false);
|
const loginLoading = ref(false);
|
||||||
|
|
||||||
const menuOptions: MenuOption[] = [
|
const menuOptions: MenuOption[] = [
|
||||||
{ label: '处理任务', key: 'workflow', icon: renderIcon(CloudUploadOutline) },
|
{ label: '数据上传', key: 'workflow', icon: renderIcon(CloudUploadOutline) },
|
||||||
{ label: '处理历史', key: 'history', icon: renderIcon(FileTrayFullOutline) },
|
{ label: '处理历史', key: 'history', icon: renderIcon(FileTrayFullOutline) },
|
||||||
{ label: '数据表', key: 'database', icon: renderIcon(ServerOutline) },
|
{ label: '数据管理', key: 'database', icon: renderIcon(ServerOutline) },
|
||||||
{ label: 'SQL 脚本', key: 'script', icon: renderIcon(ConstructOutline) },
|
{ label: '脚本编辑', key: 'script', icon: renderIcon(ConstructOutline) },
|
||||||
{ label: '系统设置', key: 'settings', icon: renderIcon(SettingsOutline) }
|
{ label: '系统设置', key: 'settings', icon: renderIcon(SettingsOutline) }
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
<template>
|
<template>
|
||||||
<n-grid :cols="12" :x-gap="16" :y-gap="16" responsive="screen">
|
<div class="script-workspace">
|
||||||
<n-gi :span="12" :m="8">
|
<n-card size="small" class="work-card script-editor-pane">
|
||||||
<n-card title="SQL 脚本" size="small">
|
<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>
|
<template #header-extra>
|
||||||
<n-space>
|
<n-space>
|
||||||
<n-button size="small" tertiary :loading="loading" @click="loadScript">重新加载</n-button>
|
<n-button size="small" tertiary :loading="loading" @click="loadScript">重新加载</n-button>
|
||||||
@@ -10,54 +16,72 @@
|
|||||||
</n-space>
|
</n-space>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<n-space vertical>
|
<div class="script-editor-toolbar">
|
||||||
<n-alert v-if="scriptPath" type="default" :bordered="false">
|
<span class="script-path" :title="scriptPath">{{ scriptPath || 'ReportScript.sql' }}</span>
|
||||||
{{ scriptPath }} / 修改时间:{{ modified || '-' }}
|
<span class="script-modified">修改时间:{{ modified || '-' }}</span>
|
||||||
</n-alert>
|
</div>
|
||||||
<n-input
|
|
||||||
v-model:value="content"
|
|
||||||
type="textarea"
|
|
||||||
class="script-editor"
|
|
||||||
:autosize="{ minRows: 24, maxRows: 32 }"
|
|
||||||
spellcheck="false"
|
|
||||||
/>
|
|
||||||
</n-space>
|
|
||||||
</n-card>
|
|
||||||
</n-gi>
|
|
||||||
|
|
||||||
<n-gi :span="12" :m="4">
|
<div ref="editorHost" class="monaco-editor-host" />
|
||||||
<n-card title="执行状态" size="small">
|
|
||||||
|
<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>
|
<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>
|
<n-button size="small" tertiary :disabled="!taskId" @click="refreshStatus">刷新</n-button>
|
||||||
|
</n-space>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<n-space vertical>
|
|
||||||
<n-tag v-if="taskStatus" :type="statusType">{{ taskStatus.status }}</n-tag>
|
|
||||||
<n-empty v-if="!taskStatus" description="脚本尚未执行" />
|
<n-empty v-if="!taskStatus" description="脚本尚未执行" />
|
||||||
<n-log v-else :log="logText" language="text" trim class="log-panel" />
|
<n-log v-else :log="logText" language="text" trim class="script-log" />
|
||||||
</n-space>
|
|
||||||
</n-card>
|
</n-card>
|
||||||
</n-gi>
|
</div>
|
||||||
</n-grid>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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 { 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 { apiGet, apiPost } from '../api/client';
|
||||||
import type { ApiMessage, ScriptContent, TaskStatus } from '../types';
|
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 message = useMessage();
|
||||||
|
const editorHost = ref<HTMLDivElement | null>(null);
|
||||||
|
const editor = shallowRef<monaco.editor.IStandaloneCodeEditor | null>(null);
|
||||||
|
const disposables: monaco.IDisposable[] = [];
|
||||||
const content = ref('');
|
const content = ref('');
|
||||||
|
const originalContent = ref('');
|
||||||
const scriptPath = ref('');
|
const scriptPath = ref('');
|
||||||
const modified = ref<string | null>(null);
|
const modified = ref<string | null>(null);
|
||||||
|
const cursorText = ref('行 1,列 1');
|
||||||
|
const lineCount = ref(1);
|
||||||
|
const isModified = ref(false);
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const saving = ref(false);
|
const saving = ref(false);
|
||||||
const executing = ref(false);
|
const executing = ref(false);
|
||||||
const taskId = ref('');
|
const taskId = ref('');
|
||||||
const taskStatus = ref<TaskStatus | null>(null);
|
const taskStatus = ref<TaskStatus | null>(null);
|
||||||
let timer: number | undefined;
|
let timer: number | undefined;
|
||||||
|
let applyingRemoteContent = false;
|
||||||
|
|
||||||
const logText = computed(() => taskStatus.value?.logs?.join('\n') || '等待执行日志');
|
const logText = computed(() => taskStatus.value?.logs?.join('\n') || '等待执行日志');
|
||||||
const statusType = computed(() => {
|
const statusType = computed(() => {
|
||||||
@@ -65,15 +89,72 @@ const statusType = computed(() => {
|
|||||||
if (taskStatus.value?.status === 'failed') return 'error';
|
if (taskStatus.value?.status === 'failed') return 'error';
|
||||||
return 'info';
|
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(() => {
|
onMounted(async () => {
|
||||||
void loadScript();
|
await nextTick();
|
||||||
|
createEditor();
|
||||||
|
await loadScript();
|
||||||
});
|
});
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
stopPolling();
|
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() {
|
async function loadScript() {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
@@ -81,7 +162,7 @@ async function loadScript() {
|
|||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
throw new Error(result.error || '加载脚本失败');
|
throw new Error(result.error || '加载脚本失败');
|
||||||
}
|
}
|
||||||
content.value = result.content;
|
applyScriptContent(result.content);
|
||||||
scriptPath.value = result.path;
|
scriptPath.value = result.path;
|
||||||
modified.value = result.modified;
|
modified.value = result.modified;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -94,12 +175,15 @@ async function loadScript() {
|
|||||||
async function saveScript() {
|
async function saveScript() {
|
||||||
saving.value = true;
|
saving.value = true;
|
||||||
try {
|
try {
|
||||||
|
const value = getEditorContent();
|
||||||
const result = await apiPost<ApiMessage & { modified?: string }>('/api/script/save', {
|
const result = await apiPost<ApiMessage & { modified?: string }>('/api/script/save', {
|
||||||
content: content.value
|
content: value
|
||||||
});
|
});
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
throw new Error(result.error || '保存失败');
|
throw new Error(result.error || '保存失败');
|
||||||
}
|
}
|
||||||
|
originalContent.value = value;
|
||||||
|
isModified.value = false;
|
||||||
modified.value = result.modified || modified.value;
|
modified.value = result.modified || modified.value;
|
||||||
message.success(result.message || '保存成功');
|
message.success(result.message || '保存成功');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -112,6 +196,11 @@ async function saveScript() {
|
|||||||
async function executeScript() {
|
async function executeScript() {
|
||||||
executing.value = true;
|
executing.value = true;
|
||||||
try {
|
try {
|
||||||
|
if (isModified.value) {
|
||||||
|
await saveScript();
|
||||||
|
if (isModified.value) return;
|
||||||
|
}
|
||||||
|
|
||||||
const result = await apiPost<ApiMessage>('/api/script/execute');
|
const result = await apiPost<ApiMessage>('/api/script/execute');
|
||||||
if (!result.task_id) {
|
if (!result.task_id) {
|
||||||
throw new Error(result.message || '脚本任务启动失败');
|
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() {
|
function startPolling() {
|
||||||
stopPolling();
|
stopPolling();
|
||||||
void refreshStatus();
|
void refreshStatus();
|
||||||
|
|||||||
@@ -1,34 +1,79 @@
|
|||||||
<template>
|
<template>
|
||||||
<n-grid :cols="12" :x-gap="16" :y-gap="16" responsive="screen">
|
<div class="settings-workspace">
|
||||||
<n-gi :span="12" :m="6">
|
<div class="settings-column">
|
||||||
<n-space vertical size="large">
|
<n-card title="数据库配置" size="small" class="work-card">
|
||||||
<n-card title="数据库配置" size="small">
|
|
||||||
<template #header-extra>
|
<template #header-extra>
|
||||||
|
<n-space>
|
||||||
<n-button size="small" tertiary :loading="loading" @click="loadConfig">刷新</n-button>
|
<n-button size="small" tertiary :loading="loading" @click="loadConfig">刷新</n-button>
|
||||||
|
<n-button size="small" tertiary :loading="testingDb" @click="testDatabase">测试连接</n-button>
|
||||||
|
</n-space>
|
||||||
</template>
|
</template>
|
||||||
<n-form label-placement="left" label-width="90">
|
|
||||||
<n-form-item label="地址">
|
<n-form label-placement="top">
|
||||||
<n-input v-model:value="mysqlForm.host" />
|
<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-form-item>
|
||||||
|
</n-gi>
|
||||||
|
<n-gi :span="4">
|
||||||
<n-form-item label="端口">
|
<n-form-item label="端口">
|
||||||
<n-input-number v-model:value="mysqlForm.port" class="full-width" :min="1" :max="65535" />
|
<n-input-number v-model:value="mysqlForm.port" class="full-width" :min="1" :max="65535" />
|
||||||
</n-form-item>
|
</n-form-item>
|
||||||
<n-form-item label="账号">
|
</n-gi>
|
||||||
<n-input v-model:value="mysqlForm.user" />
|
</n-grid>
|
||||||
|
<n-form-item label="数据库名">
|
||||||
|
<n-input v-model:value="mysqlForm.dbname" placeholder="CapacityReport" />
|
||||||
</n-form-item>
|
</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-form-item label="密码">
|
||||||
<n-input v-model:value="mysqlForm.passwd" type="password" show-password-on="click" />
|
<n-input v-model:value="mysqlForm.passwd" type="password" show-password-on="click" />
|
||||||
</n-form-item>
|
</n-form-item>
|
||||||
<n-form-item label="库名">
|
</n-gi>
|
||||||
<n-input v-model:value="mysqlForm.dbname" />
|
</n-grid>
|
||||||
</n-form-item>
|
<div class="card-actions">
|
||||||
<n-button type="primary" :loading="savingMysql" @click="saveMysql">保存数据库配置</n-button>
|
<span class="muted-line">更新时间:{{ configUpdate || '-' }}</span>
|
||||||
|
<n-button type="primary" :loading="savingMysql" @click="saveMysql">保存配置</n-button>
|
||||||
|
</div>
|
||||||
</n-form>
|
</n-form>
|
||||||
<div class="muted-line">配置更新时间:{{ configUpdate || '-' }}</div>
|
|
||||||
</n-card>
|
</n-card>
|
||||||
|
|
||||||
<n-card title="登录密码" size="small">
|
<n-card title="Sheet 过滤规则" size="small" class="work-card">
|
||||||
<n-form label-placement="left" label-width="90">
|
<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-form-item label="当前密码">
|
||||||
<n-input v-model:value="passwordForm.current_password" type="password" show-password-on="click" />
|
<n-input v-model:value="passwordForm.current_password" type="password" show-password-on="click" />
|
||||||
</n-form-item>
|
</n-form-item>
|
||||||
@@ -38,90 +83,146 @@
|
|||||||
<n-form-item label="确认密码">
|
<n-form-item label="确认密码">
|
||||||
<n-input v-model:value="passwordForm.confirm_password" type="password" show-password-on="click" />
|
<n-input v-model:value="passwordForm.confirm_password" type="password" show-password-on="click" />
|
||||||
</n-form-item>
|
</n-form-item>
|
||||||
|
<div class="card-actions">
|
||||||
|
<span />
|
||||||
<n-button type="primary" :loading="changingPassword" @click="changePassword">修改密码</n-button>
|
<n-button type="primary" :loading="changingPassword" @click="changePassword">修改密码</n-button>
|
||||||
|
</div>
|
||||||
</n-form>
|
</n-form>
|
||||||
</n-card>
|
</n-card>
|
||||||
|
|
||||||
<n-card title="配置文件" size="small">
|
<n-card title="配置文件与服务" size="small" class="work-card">
|
||||||
|
<n-space vertical>
|
||||||
<n-space>
|
<n-space>
|
||||||
<n-button @click="downloadConfig">下载配置</n-button>
|
<n-button @click="downloadConfig">下载配置</n-button>
|
||||||
<n-button @click="configInput?.click()">上传配置</n-button>
|
<n-button @click="configInput?.click()">上传配置</n-button>
|
||||||
<input ref="configInput" class="hidden-input" type="file" accept=".json" @change="uploadConfigFile" />
|
<input ref="configInput" class="hidden-input" type="file" accept=".json" @change="uploadConfigFile" />
|
||||||
</n-space>
|
</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 v-if="serviceStatus" bordered size="small" :column="2">
|
||||||
<n-descriptions-item label="状态">{{ serviceStatus.status }}</n-descriptions-item>
|
<n-descriptions-item label="状态">{{ serviceStatus.status }}</n-descriptions-item>
|
||||||
<n-descriptions-item label="版本">{{ serviceStatus.version }}</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.platform }}</n-descriptions-item>
|
||||||
<n-descriptions-item label="进程">{{ serviceStatus.pid }}</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-descriptions>
|
||||||
<n-button class="restart-button" type="warning" tertiary @click="confirmRestart">重启服务</n-button>
|
|
||||||
</n-card>
|
<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-space>
|
||||||
</n-gi>
|
</n-card>
|
||||||
</n-grid>
|
</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>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, reactive, ref } from 'vue';
|
import { computed, onMounted, reactive, ref } from 'vue';
|
||||||
import { useDialog, useMessage } from 'naive-ui';
|
import { useDialog, useMessage, type SelectOption } from 'naive-ui';
|
||||||
|
|
||||||
import { apiGet, apiPost, downloadGet, upload } from '../api/client';
|
import { apiGet, apiPost, downloadGet, upload } from '../api/client';
|
||||||
import type { ApiMessage, AppConfig, ServiceStatus } from '../types';
|
import type { ApiMessage, AppConfig, ServiceStatus } from '../types';
|
||||||
|
|
||||||
|
interface ExtractFieldConfig {
|
||||||
|
Field: string;
|
||||||
|
Type: string;
|
||||||
|
Extract: string[];
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
const message = useMessage();
|
const message = useMessage();
|
||||||
const dialog = useDialog();
|
const dialog = useDialog();
|
||||||
const configInput = ref<HTMLInputElement | null>(null);
|
const configInput = ref<HTMLInputElement | null>(null);
|
||||||
const configUpdate = ref('');
|
const configUpdate = ref('');
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
|
const testingDb = ref(false);
|
||||||
const savingMysql = ref(false);
|
const savingMysql = ref(false);
|
||||||
const savingSheetFilter = ref(false);
|
const savingSheetFilter = ref(false);
|
||||||
const savingExtractFields = ref(false);
|
const savingExtractFields = ref(false);
|
||||||
const changingPassword = ref(false);
|
const changingPassword = ref(false);
|
||||||
const sheetFilterText = ref('');
|
|
||||||
const extractFieldsText = ref('[]');
|
|
||||||
const serviceStatus = ref<ServiceStatus | null>(null);
|
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({
|
const mysqlForm = reactive({
|
||||||
host: '',
|
host: '',
|
||||||
@@ -137,6 +238,25 @@ const passwordForm = reactive({
|
|||||||
confirm_password: ''
|
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(() => {
|
onMounted(() => {
|
||||||
void loadConfig();
|
void loadConfig();
|
||||||
void loadServiceStatus();
|
void loadServiceStatus();
|
||||||
@@ -152,8 +272,9 @@ async function loadConfig() {
|
|||||||
mysqlForm.user = config.mysql.user;
|
mysqlForm.user = config.mysql.user;
|
||||||
mysqlForm.passwd = config.mysql.passwd || '';
|
mysqlForm.passwd = config.mysql.passwd || '';
|
||||||
mysqlForm.dbname = config.mysql.dbname;
|
mysqlForm.dbname = config.mysql.dbname;
|
||||||
sheetFilterText.value = config.sheet_filter.join('\n');
|
sheetFilters.value = [...config.sheet_filter];
|
||||||
extractFieldsText.value = JSON.stringify(config.extract_fields, null, 2);
|
extractFields.value = normalizeExtractFields(config.extract_fields);
|
||||||
|
resetExtractInputs();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
message.error(error instanceof Error ? error.message : '加载配置失败');
|
message.error(error instanceof Error ? error.message : '加载配置失败');
|
||||||
} finally {
|
} 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() {
|
async function saveSheetFilter() {
|
||||||
savingSheetFilter.value = true;
|
savingSheetFilter.value = true;
|
||||||
try {
|
try {
|
||||||
const filters = sheetFilterText.value
|
const result = await apiPost<ApiMessage>('/api/config/sheet-filter', sheetFilters.value);
|
||||||
.split(/\r?\n/)
|
|
||||||
.map(item => item.trim())
|
|
||||||
.filter(Boolean);
|
|
||||||
const result = await apiPost<ApiMessage>('/api/config/sheet-filter', filters);
|
|
||||||
configUpdate.value = result.update || configUpdate.value;
|
configUpdate.value = result.update || configUpdate.value;
|
||||||
message.success(result.message || '过滤规则已保存');
|
message.success(result.message || '过滤规则已保存');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -199,30 +343,56 @@ async function saveSheetFilter() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatExtractFields() {
|
function addFieldMapping() {
|
||||||
try {
|
extractFields.value.push({ Field: '', Type: 'string', Extract: [] });
|
||||||
extractFieldsText.value = JSON.stringify(JSON.parse(extractFieldsText.value), null, 2);
|
resetExtractInputs();
|
||||||
} catch {
|
}
|
||||||
message.error('字段配置不是有效 JSON');
|
|
||||||
|
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() {
|
async function saveExtractFields() {
|
||||||
let fields: Array<Record<string, unknown>>;
|
const fields = extractFields.value
|
||||||
try {
|
.map(field => ({
|
||||||
const parsed = JSON.parse(extractFieldsText.value) as unknown;
|
...field,
|
||||||
if (!Array.isArray(parsed)) {
|
Field: field.Field.trim(),
|
||||||
throw new Error('字段配置必须是数组');
|
Type: field.Type || 'string',
|
||||||
}
|
Extract: uniqueStrings(field.Extract)
|
||||||
fields = parsed as Array<Record<string, unknown>>;
|
}))
|
||||||
} catch (error) {
|
.filter(field => field.Field);
|
||||||
message.error(error instanceof Error ? error.message : '字段配置不是有效 JSON');
|
|
||||||
|
if (fields.length === 0) {
|
||||||
|
message.warning('请至少保留一个有效字段');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
savingExtractFields.value = true;
|
savingExtractFields.value = true;
|
||||||
try {
|
try {
|
||||||
const result = await apiPost<ApiMessage>('/api/config/extract-fields', fields);
|
const result = await apiPost<ApiMessage>('/api/config/extract-fields', fields);
|
||||||
|
extractFields.value = fields;
|
||||||
|
resetExtractInputs();
|
||||||
configUpdate.value = result.update || configUpdate.value;
|
configUpdate.value = result.update || configUpdate.value;
|
||||||
message.success(result.message || '字段配置已保存');
|
message.success(result.message || '字段配置已保存');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -310,4 +480,35 @@ async function restartService() {
|
|||||||
message.error(error instanceof Error ? error.message : '重启服务失败');
|
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>
|
</script>
|
||||||
|
|||||||
+379
-31
@@ -12,7 +12,7 @@ body,
|
|||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
color: #20242a;
|
color: #1f2933;
|
||||||
background: #eef1f4;
|
background: #eef1f4;
|
||||||
font-family:
|
font-family:
|
||||||
Inter,
|
Inter,
|
||||||
@@ -24,27 +24,41 @@ body {
|
|||||||
|
|
||||||
.app-layout {
|
.app-layout {
|
||||||
min-height: 100vh;
|
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 {
|
.brand {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
flex-shrink: 0;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
height: 60px;
|
height: 64px;
|
||||||
padding: 0 16px;
|
padding: 0 18px;
|
||||||
border-bottom: 1px solid #e6e9ee;
|
border-bottom: 1px solid #e4e8ef;
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand-mark {
|
.brand-mark {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
flex: 0 0 auto;
|
||||||
width: 34px;
|
width: 34px;
|
||||||
height: 34px;
|
height: 34px;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
background: #2458a6;
|
background: #1f5ea8;
|
||||||
border-radius: 8px;
|
border-radius: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand-mark.large {
|
.brand-mark.large {
|
||||||
@@ -60,30 +74,75 @@ body {
|
|||||||
text-overflow: ellipsis;
|
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 {
|
.topbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
height: 60px;
|
height: 64px;
|
||||||
padding: 0 20px;
|
padding: 0 20px;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.topbar-title {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.page-title {
|
.page-title {
|
||||||
|
overflow: hidden;
|
||||||
|
max-width: 52vw;
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-subtitle,
|
.page-subtitle,
|
||||||
.muted-line {
|
.muted-line {
|
||||||
margin-top: 2px;
|
color: #6f7885;
|
||||||
color: #6a7280;
|
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.content {
|
.content {
|
||||||
min-width: 0;
|
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 {
|
.login-page {
|
||||||
@@ -91,15 +150,13 @@ body {
|
|||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
background:
|
background: #eef1f4;
|
||||||
linear-gradient(180deg, rgba(255, 255, 255, 0.72), rgba(238, 241, 244, 0.94)),
|
|
||||||
#eef1f4;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-card {
|
.login-card {
|
||||||
width: min(420px, 100%);
|
width: min(420px, 100%);
|
||||||
border-radius: 8px;
|
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 {
|
.login-brand {
|
||||||
@@ -133,22 +190,15 @@ body {
|
|||||||
max-height: 360px;
|
max-height: 360px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.log-panel {
|
.log-panel,
|
||||||
|
.script-log {
|
||||||
min-height: 320px;
|
min-height: 320px;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
background: #101418;
|
color: #d7dde7;
|
||||||
|
background: #111820;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.script-editor textarea,
|
|
||||||
.json-editor textarea {
|
|
||||||
font-family:
|
|
||||||
"Cascadia Code",
|
|
||||||
Consolas,
|
|
||||||
"Courier New",
|
|
||||||
monospace;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-label {
|
.section-label {
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
color: #333b46;
|
color: #333b46;
|
||||||
@@ -166,8 +216,294 @@ body {
|
|||||||
margin-top: 16px;
|
margin-top: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.n-card {
|
.settings-workspace {
|
||||||
border-radius: 8px;
|
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) {
|
@media (max-width: 720px) {
|
||||||
@@ -176,15 +512,27 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.content {
|
.content {
|
||||||
padding: 12px;
|
padding: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-subtitle {
|
.page-title {
|
||||||
display: none;
|
max-width: 46vw;
|
||||||
}
|
}
|
||||||
|
|
||||||
.table-footer {
|
.table-footer,
|
||||||
|
.card-actions,
|
||||||
|
.extract-list-header,
|
||||||
|
.script-editor-toolbar {
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.field-mapping-header {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-search,
|
||||||
|
.extract-add-group {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
Reference in New Issue
Block a user