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
+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>