fix: 恢复前端脚本编辑器和字段配置交互
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user