Files
CapacityReport/frontend/src/components/SettingsPanel.vue
T

997 lines
38 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="settings-workspace">
<input ref="configInput" class="hidden-input" type="file" accept=".json" @change="uploadConfigFile" />
<n-card size="small" class="work-card settings-tabs-card">
<n-tabs type="line" animated class="settings-tabs">
<n-tab-pane name="connection" tab="连接配置">
<div class="settings-section-grid">
<div class="settings-connection-stack">
<n-card title="数据库配置" size="small" class="work-card">
<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>
</n-form>
<template #footer>
<n-space justify="end">
<n-button type="primary" :loading="savingMysql" @click="saveMysql">保存配置</n-button>
<n-button :loading="testingDb" @click="testDatabase">测试连接</n-button>
</n-space>
</template>
</n-card>
<n-card title="处理历史保留" size="small" class="work-card">
<n-form label-placement="top">
<n-grid :cols="12" :x-gap="12">
<n-gi :span="6">
<n-form-item label="自动清理处理历史">
<n-switch v-model:value="historyRetentionForm.enabled" />
</n-form-item>
</n-gi>
<n-gi :span="6">
<n-form-item label="保留最近次数">
<n-input-number
v-model:value="historyRetentionForm.keep_count"
class="full-width"
:min="0"
:precision="0"
:disabled="!historyRetentionForm.enabled"
/>
</n-form-item>
</n-gi>
</n-grid>
<p class="form-hint">关闭后不自动删除处理历史;启用后会在任务结束时自动清理,设置为 0 表示不保留历史,否则只保留最近指定次数的处理历史。</p>
</n-form>
<template #footer>
<n-space justify="end">
<n-button type="primary" :loading="savingHistoryRetention" @click="saveHistoryRetention">
保存配置
</n-button>
</n-space>
</template>
</n-card>
</div>
<n-card title="远程数据源" size="small" class="work-card">
<n-form label-placement="top">
<n-grid :cols="12" :x-gap="12">
<n-gi :span="6">
<n-form-item label="启用远程自动化">
<n-switch v-model:value="remoteForm.enabled" :disabled="remoteForm.auto_scheduler.enabled" />
</n-form-item>
</n-gi>
<n-gi :span="6">
<n-form-item label="处理成功后删除源文件">
<n-switch
v-model:value="remoteForm.auto_delete_source"
:disabled="remoteForm.auto_scheduler.enabled"
/>
</n-form-item>
</n-gi>
</n-grid>
<n-grid :cols="12" :x-gap="12">
<n-gi :span="4">
<n-form-item label="协议">
<n-select
v-model:value="remoteForm.protocol"
:options="remoteProtocolOptions"
@update:value="updateRemoteProtocol"
/>
</n-form-item>
</n-gi>
<n-gi :span="8">
<n-form-item label="服务器地址">
<n-input v-model:value="remoteForm.host" placeholder="192.168.1.10" />
</n-form-item>
</n-gi>
</n-grid>
<n-grid :cols="12" :x-gap="12">
<n-gi :span="4">
<n-form-item label="端口">
<n-input-number v-model:value="remoteForm.port" class="full-width" :min="1" :max="65535" />
</n-form-item>
</n-gi>
<n-gi :span="4">
<n-form-item label="超时秒数">
<n-input-number v-model:value="remoteForm.timeout" class="full-width" :min="1" :max="600" />
</n-form-item>
</n-gi>
<n-gi :span="4">
<n-form-item label="FTP 被动模式">
<n-switch v-model:value="remoteForm.passive" :disabled="remoteForm.protocol !== 'ftp'" />
</n-form-item>
</n-gi>
</n-grid>
<n-grid :cols="12" :x-gap="12">
<n-gi :span="6">
<n-form-item label="用户名">
<n-input v-model:value="remoteForm.user" placeholder="remote user" />
</n-form-item>
</n-gi>
<n-gi :span="6">
<n-form-item label="密码">
<n-input v-model:value="remoteForm.passwd" type="password" show-password-on="click" />
</n-form-item>
</n-gi>
</n-grid>
<n-form-item label="远程目录">
<n-input v-model:value="remoteForm.remote_dir" placeholder="/CapacityReportData" />
</n-form-item>
<p class="form-hint">自动化执行会递归下载该目录下的全部文件和文件夹到本地缓存,再按现有处理流程入库和执行脚本;自动删除源文件只会在处理成功后删除远程文件,保留目录结构。</p>
<n-divider class="settings-compact-divider">自动调度</n-divider>
<n-grid :cols="12" :x-gap="12">
<n-gi :span="4">
<n-form-item label="启用自动调度">
<n-switch v-model:value="remoteForm.auto_scheduler.enabled" />
</n-form-item>
</n-gi>
<n-gi :span="4">
<n-form-item label="检查间隔(小时)">
<n-input-number
v-model:value="remoteForm.auto_scheduler.check_interval_hours"
class="full-width"
:min="1"
:precision="0"
:disabled="!remoteForm.auto_scheduler.enabled"
/>
</n-form-item>
</n-gi>
<n-gi :span="4">
<n-form-item label="目标周期">
<n-select
v-model:value="remoteForm.auto_scheduler.week_offset"
:options="schedulerWeekOptions"
:disabled="!remoteForm.auto_scheduler.enabled"
/>
</n-form-item>
</n-gi>
</n-grid>
<n-form-item label="预期目录">
<div class="scheduler-dir-editor">
<n-input-group>
<n-input
v-model:value="newSchedulerDirectory"
placeholder="例如 4G/FDD"
:disabled="!remoteForm.auto_scheduler.enabled"
@keydown.enter.prevent="addSchedulerDirectory"
/>
<n-button :disabled="!remoteForm.auto_scheduler.enabled" @click="addSchedulerDirectory">
添加
</n-button>
</n-input-group>
<div class="scheduler-dir-tags">
<n-tag
v-for="(directory, index) in remoteForm.auto_scheduler.expected_directories"
:key="`${directory}-${index}`"
closable
:disabled="!remoteForm.auto_scheduler.enabled"
@close="removeSchedulerDirectory(index)"
>
{{ directory }}
</n-tag>
<n-tag v-if="remoteForm.auto_scheduler.expected_directories.length === 0" type="default">
按实际 ZIP 目录检测
</n-tag>
</div>
</div>
</n-form-item>
<p class="form-hint">自动调度会按文件名日期检查目标自然周 7 天;开启后会强制启用远程自动化和处理成功后删除源文件。</p>
<div class="scheduler-status-panel">
<div class="scheduler-status-header">
<span>调度状态</span>
<n-tag size="small" :type="schedulerStatusTagType">
{{ schedulerStatusLabel }}
</n-tag>
</div>
<div class="scheduler-status-grid">
<div>
<span>目标周</span>
<strong>{{ schedulerTargetWeekText }}</strong>
</div>
<div>
<span>下次检查</span>
<strong>{{ schedulerStatus?.next_check_at || '-' }}</strong>
</div>
<div>
<span>就绪标识</span>
<strong>{{ schedulerStatus?.ready_flag?.exists ? '已存在' : '无' }}</strong>
</div>
</div>
<p class="form-hint">{{ schedulerStatus?.last_message || '自动调度状态尚未加载' }}</p>
<div v-if="schedulerDirectoryRows.length > 0" class="scheduler-directory-list">
<div v-for="row in schedulerDirectoryRows" :key="row.name" class="scheduler-directory-row">
<span class="scheduler-directory-name">{{ row.name }}</span>
<n-tag size="small" :type="row.ready ? 'success' : 'warning'">
{{ row.ready ? '就绪' : `缺 ${row.missing_days.length} 天` }}
</n-tag>
</div>
</div>
<n-space justify="end" size="small">
<n-button size="small" :loading="loadingSchedulerStatus" @click="loadSchedulerStatus">
刷新状态
</n-button>
<n-button
size="small"
type="primary"
:loading="triggeringScheduler"
:disabled="!remoteForm.auto_scheduler.enabled"
@click="triggerSchedulerCheck"
>
立即检查
</n-button>
</n-space>
</div>
</n-form>
<template #footer>
<n-space justify="end">
<n-button type="primary" :loading="savingRemote" @click="saveRemote">保存配置</n-button>
<n-button :loading="testingRemote" @click="testRemote">测试连接</n-button>
</n-space>
</template>
</n-card>
</div>
</n-tab-pane>
<n-tab-pane name="rules" tab="规则映射">
<div class="settings-rules-grid">
<n-card title="Sheet 过滤规则" size="small" class="work-card">
<n-space vertical>
<p class="form-hint">匹配这些关键词的 Sheet 将被跳过处理</p>
<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>
</n-space>
<template #footer>
<n-space justify="end">
<n-button type="primary" :loading="savingSheetFilter" @click="saveSheetFilter">保存规则</n-button>
</n-space>
</template>
</n-card>
<n-card size="small" class="work-card field-mapping-card">
<template #header>
<div class="field-card-header">
<div class="field-card-title-row">
<div class="field-card-title">
<span>字段映射配置</span>
<n-tag size="small" round>{{ extractFields.length }} 个字段</n-tag>
</div>
<n-input
v-model:value="fieldSearch"
size="small"
clearable
class="field-search"
placeholder="搜索字段..."
/>
</div>
<p class="form-hint">定义 Excel 列名到数据库字段的映射规则,提取来源匹配任意一个即可</p>
</div>
</template>
<div 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)"
>
<template #icon>
<n-icon><CloseOutline /></n-icon>
</template>
</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>
</div>
<template #footer>
<n-space justify="end">
<n-button @click="addFieldMapping">添加字段</n-button>
<n-button type="primary" :loading="savingExtractFields" @click="saveExtractFields">
保存映射
</n-button>
</n-space>
</template>
</n-card>
</div>
</n-tab-pane>
<n-tab-pane name="security" tab="修改密码">
<div class="settings-security-panel">
<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>
</n-form>
<template #footer>
<n-space justify="end">
<n-button type="primary" :loading="changingPassword" @click="changePassword">修改密码</n-button>
</n-space>
</template>
</n-card>
</div>
</n-tab-pane>
<n-tab-pane name="api-token" tab="API Token">
<div class="settings-token-panel">
<ApiTokenManager embedded />
</div>
</n-tab-pane>
</n-tabs>
</n-card>
</div>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
import { useDialog, useMessage, type SelectOption } from 'naive-ui';
import { CloseOutline, CloudDownloadOutline, CloudUploadOutline } from '@vicons/ionicons5';
import { apiGet, apiPost, downloadGet, upload } from '../api/client';
import type {
ApiMessage,
AppConfig,
HistoryRetentionConfig,
RemoteAutoSchedulerConfig,
RemoteDataConfig,
RemoteSchedulerStatus
} from '../types';
import { showDownloadCompleteDialog } from '../composables/downloadFeedback';
import { resetPageHeader, setPageHeader } from '../composables/pageHeader';
import ApiTokenManager from './ApiTokenManager.vue';
interface ExtractFieldConfig {
Field: string;
Type: string;
Extract: string[];
[key: string]: unknown;
}
const message = useMessage();
const dialog = useDialog();
const configInput = ref<HTMLInputElement | null>(null);
const configUpdate = ref('');
const configUpdateText = computed(() => `更新时间:${configUpdate.value || '-'}`);
const loading = ref(false);
const testingDb = ref(false);
const testingRemote = ref(false);
const loadingSchedulerStatus = ref(false);
const triggeringScheduler = ref(false);
const savingMysql = ref(false);
const savingRemote = ref(false);
const savingHistoryRetention = ref(false);
const savingSheetFilter = ref(false);
const savingExtractFields = ref(false);
const changingPassword = ref(false);
const sheetFilters = ref<string[]>([]);
const newSheetFilter = ref('');
const extractFields = ref<ExtractFieldConfig[]>([]);
const fieldSearch = ref('');
const newSchedulerDirectory = ref('');
const schedulerStatus = ref<RemoteSchedulerStatus | null>(null);
const newExtractValues = reactive<Record<number, string>>({});
const mysqlForm = reactive({
host: '',
port: 3306 as number | null,
user: '',
passwd: '',
dbname: ''
});
const remoteForm = reactive<RemoteDataConfig>({
enabled: false,
protocol: 'sftp',
host: '',
port: 22,
user: '',
passwd: '',
remote_dir: '/',
passive: true,
timeout: 30,
auto_delete_source: false,
auto_scheduler: {
enabled: false,
check_interval_hours: 1,
expected_directories: [],
week_offset: 0
}
});
const historyRetentionForm = reactive<HistoryRetentionConfig>({
enabled: false,
keep_count: 20
});
const passwordForm = reactive({
current_password: '',
new_password: '',
confirm_password: ''
});
const fieldTypeOptions: SelectOption[] = [
{ label: '字符串', value: 'string' },
{ label: '整数', value: 'int' },
{ label: '小数', value: 'float' },
{ label: '日期时间', value: 'datetime' }
];
const remoteProtocolOptions: SelectOption[] = [
{ label: 'SFTP', value: 'sftp' },
{ label: 'FTP', value: 'ftp' }
];
const schedulerWeekOptions: SelectOption[] = [
{ label: '上周', value: 0 },
{ label: '上上周', value: -1 }
];
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);
});
});
const schedulerStatusLabel = computed(() => {
if (!schedulerStatus.value) return '未加载';
if (!schedulerStatus.value.enabled) return '未启用';
if (schedulerStatus.value.task_running) return '处理中';
if ((schedulerStatus.value.failure_count || 0) >= 3) return '连续失败';
if (schedulerStatus.value.ready_flag?.exists) return '已就绪';
if (schedulerStatus.value.last_result === 'scan_failed') return '检查失败';
if (schedulerStatus.value.last_result === 'trigger_failed') return '触发失败';
if (schedulerStatus.value.last_result === 'waiting') return '等待数据';
if (schedulerStatus.value.last_result === 'marked_ready') return '已标记';
if (schedulerStatus.value.last_result === 'completed') return '已完成';
return schedulerStatus.value.running ? '运行中' : '已停止';
});
const schedulerStatusTagType = computed(() => {
if (!schedulerStatus.value?.enabled) return 'default';
if ((schedulerStatus.value.failure_count || 0) >= 3) return 'error';
if (schedulerStatus.value.task_running || schedulerStatus.value.ready_flag?.exists) return 'success';
if (['scan_failed', 'trigger_failed'].includes(schedulerStatus.value.last_result || '')) return 'error';
if (schedulerStatus.value.last_result === 'waiting') return 'warning';
return 'info';
});
const schedulerTargetWeekText = computed(() => {
const week = schedulerStatus.value?.target_week;
if (!week) return '-';
return `${week.start} 至 ${week.end}`;
});
const schedulerDirectoryRows = computed(() => {
const directoryStatus = schedulerStatus.value?.directory_status || {};
return Object.entries(directoryStatus).map(([name, status]) => ({
name,
...status
}));
});
watch(
() => remoteForm.auto_scheduler.enabled,
enabled => {
if (enabled) {
remoteForm.enabled = true;
remoteForm.auto_delete_source = true;
}
}
);
onMounted(() => {
setPageHeader({
actions: [
{ key: 'config-update', kind: 'text', label: configUpdateText },
{ key: 'download-config', label: '下载配置', icon: CloudDownloadOutline, onClick: downloadConfig },
{ key: 'upload-config', label: '上传配置', icon: CloudUploadOutline, onClick: () => configInput.value?.click() }
]
});
void loadConfig();
void loadSchedulerStatus();
});
onBeforeUnmount(() => {
resetPageHeader();
});
async function loadConfig() {
loading.value = true;
try {
const config = await apiGet<AppConfig>('/api/config/full');
configUpdate.value = config.update;
mysqlForm.host = config.mysql.host;
mysqlForm.port = config.mysql.port;
mysqlForm.user = config.mysql.user;
mysqlForm.passwd = config.mysql.passwd || '';
mysqlForm.dbname = config.mysql.dbname;
Object.assign(remoteForm, normalizeRemoteConfig(config.remote_data));
Object.assign(historyRetentionForm, normalizeHistoryRetentionConfig(config.history_retention));
sheetFilters.value = [...config.sheet_filter];
extractFields.value = normalizeExtractFields(config.extract_fields);
resetExtractInputs();
enforceRemoteSchedulerRules();
} catch (error) {
message.error(error instanceof Error ? error.message : '加载配置失败');
} finally {
loading.value = false;
}
}
function updateRemoteProtocol(value: string) {
if (value === 'sftp' && (!remoteForm.port || remoteForm.port === 21)) {
remoteForm.port = 22;
}
if (value === 'ftp' && (!remoteForm.port || remoteForm.port === 22)) {
remoteForm.port = 21;
}
}
async function saveMysql() {
if (!mysqlForm.host || !mysqlForm.user || !mysqlForm.dbname) {
message.warning('请填写完整数据库配置');
return;
}
savingMysql.value = true;
try {
const result = await apiPost<ApiMessage>('/api/config/mysql', {
...mysqlForm,
port: mysqlForm.port || 3306
});
configUpdate.value = result.update || configUpdate.value;
message.success(result.message || '数据库配置已保存');
} catch (error) {
message.error(error instanceof Error ? error.message : '保存数据库配置失败');
} finally {
savingMysql.value = false;
}
}
function getRemotePayload(): RemoteDataConfig {
enforceRemoteSchedulerRules();
const autoScheduler = normalizeAutoSchedulerConfig(remoteForm.auto_scheduler);
return {
...remoteForm,
protocol: remoteForm.protocol,
host: remoteForm.host.trim(),
port: remoteForm.port || (remoteForm.protocol === 'sftp' ? 22 : 21),
user: remoteForm.user.trim(),
passwd: remoteForm.passwd || '',
remote_dir: remoteForm.remote_dir.trim() || '/',
passive: remoteForm.passive,
timeout: remoteForm.timeout || 30,
auto_delete_source: remoteForm.auto_delete_source,
auto_scheduler: autoScheduler
};
}
function validateRemoteForm(): boolean {
if (!remoteForm.host || !remoteForm.user || !remoteForm.remote_dir) {
message.warning('请填写完整远程数据源配置');
return false;
}
return true;
}
async function saveRemote() {
if (!validateRemoteForm()) return;
savingRemote.value = true;
try {
const result = await apiPost<ApiMessage>('/api/config/remote', getRemotePayload());
configUpdate.value = result.update || configUpdate.value;
message.success(result.message || '远程数据源配置已保存');
await loadSchedulerStatus();
} catch (error) {
message.error(error instanceof Error ? error.message : '保存远程数据源配置失败');
} finally {
savingRemote.value = false;
}
}
function addSchedulerDirectory() {
const value = normalizeDirectoryText(newSchedulerDirectory.value);
if (!value) return;
if (remoteForm.auto_scheduler.expected_directories.includes(value)) {
message.warning('该目录已存在');
return;
}
remoteForm.auto_scheduler.expected_directories.push(value);
newSchedulerDirectory.value = '';
}
function removeSchedulerDirectory(index: number) {
remoteForm.auto_scheduler.expected_directories.splice(index, 1);
}
async function loadSchedulerStatus() {
loadingSchedulerStatus.value = true;
try {
schedulerStatus.value = await apiGet<RemoteSchedulerStatus>('/api/remote/scheduler/status');
} catch (error) {
message.error(error instanceof Error ? error.message : '加载自动调度状态失败');
} finally {
loadingSchedulerStatus.value = false;
}
}
async function triggerSchedulerCheck() {
triggeringScheduler.value = true;
try {
const result = await apiPost<{ success: boolean; message?: string; status?: RemoteSchedulerStatus }>(
'/api/remote/scheduler/trigger'
);
if (result.status) {
schedulerStatus.value = result.status;
} else {
await loadSchedulerStatus();
}
message[result.success ? 'success' : 'warning'](result.message || '自动调度检查已执行');
} catch (error) {
message.error(error instanceof Error ? error.message : '自动调度检查失败');
} finally {
triggeringScheduler.value = false;
}
}
async function testRemote() {
if (!validateRemoteForm()) return;
testingRemote.value = true;
try {
const result = await apiPost<ApiMessage>('/api/remote/test', getRemotePayload());
message[result.success ? 'success' : 'error'](result.message || '远程连接测试完成');
} catch (error) {
message.error(error instanceof Error ? error.message : '远程连接测试失败');
} finally {
testingRemote.value = false;
}
}
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;
}
}
async function saveHistoryRetention() {
savingHistoryRetention.value = true;
try {
const result = await apiPost<ApiMessage>('/api/config/history-retention', {
enabled: historyRetentionForm.enabled,
keep_count: Math.max(Number(historyRetentionForm.keep_count) || 0, 0)
});
configUpdate.value = result.update || configUpdate.value;
message.success(result.message || '处理历史保留配置已保存');
} catch (error) {
message.error(error instanceof Error ? error.message : '保存处理历史保留配置失败');
} finally {
savingHistoryRetention.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 result = await apiPost<ApiMessage>('/api/config/sheet-filter', sheetFilters.value);
configUpdate.value = result.update || configUpdate.value;
message.success(result.message || '过滤规则已保存');
} catch (error) {
message.error(error instanceof Error ? error.message : '保存过滤规则失败');
} finally {
savingSheetFilter.value = false;
}
}
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() {
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) {
message.error(error instanceof Error ? error.message : '保存字段配置失败');
} finally {
savingExtractFields.value = false;
}
}
async function changePassword() {
if (passwordForm.new_password.length < 4) {
message.warning('新密码不能少于 4 位');
return;
}
if (passwordForm.new_password !== passwordForm.confirm_password) {
message.warning('两次输入的新密码不一致');
return;
}
changingPassword.value = true;
try {
const result = await apiPost<ApiMessage>('/api/change-password', {
current_password: passwordForm.current_password,
new_password: passwordForm.new_password
});
passwordForm.current_password = '';
passwordForm.new_password = '';
passwordForm.confirm_password = '';
message.success(result.message || '密码已修改');
} catch (error) {
message.error(error instanceof Error ? error.message : '修改密码失败');
} finally {
changingPassword.value = false;
}
}
async function downloadConfig() {
try {
const filename = 'Configure.json';
const result = await downloadGet('/api/config/download', filename);
if (result.saved) {
showDownloadCompleteDialog(dialog, filename, result.path);
}
} catch (error) {
message.error(error instanceof Error ? error.message : '下载配置失败');
}
}
async function uploadConfigFile(event: Event) {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
input.value = '';
if (!file) return;
const formData = new FormData();
formData.append('file', file);
try {
const result = await upload<ApiMessage>('/api/config/upload', formData);
message.success(result.message || '配置文件已上传');
await loadConfig();
} catch (error) {
message.error(error instanceof Error ? error.message : '上传配置失败');
}
}
function normalizeRemoteConfig(config: RemoteDataConfig | undefined): RemoteDataConfig {
const protocol = config?.protocol === 'ftp' ? 'ftp' : 'sftp';
const autoScheduler = normalizeAutoSchedulerConfig(config?.auto_scheduler);
return {
enabled: Boolean(config?.enabled),
protocol,
host: config?.host || '',
port: config?.port || (protocol === 'sftp' ? 22 : 21),
user: config?.user || '',
passwd: config?.passwd || '',
remote_dir: config?.remote_dir || '/',
passive: config?.passive ?? true,
timeout: config?.timeout || 30,
auto_delete_source: Boolean(config?.auto_delete_source) || autoScheduler.enabled,
auto_scheduler: autoScheduler
};
}
function normalizeAutoSchedulerConfig(config: RemoteAutoSchedulerConfig | undefined): RemoteAutoSchedulerConfig {
return {
enabled: Boolean(config?.enabled),
check_interval_hours: Math.max(Number(config?.check_interval_hours ?? 1) || 1, 1),
expected_directories: uniqueStrings(config?.expected_directories || []).map(normalizeDirectoryText).filter(Boolean),
week_offset: Number.isFinite(Number(config?.week_offset)) ? Number(config?.week_offset) : 0
};
}
function enforceRemoteSchedulerRules() {
if (!remoteForm.auto_scheduler.enabled) return;
remoteForm.enabled = true;
remoteForm.auto_delete_source = true;
remoteForm.auto_scheduler.check_interval_hours = Math.max(Number(remoteForm.auto_scheduler.check_interval_hours) || 1, 1);
remoteForm.auto_scheduler.expected_directories = uniqueStrings(remoteForm.auto_scheduler.expected_directories)
.map(normalizeDirectoryText)
.filter(Boolean);
}
function normalizeDirectoryText(value: string): string {
return value.replace(/\\/g, '/').trim().replace(/^\/+|\/+$/g, '');
}
function normalizeHistoryRetentionConfig(config: HistoryRetentionConfig | undefined): HistoryRetentionConfig {
return {
enabled: Boolean(config?.enabled),
keep_count: Math.max(Number(config?.keep_count ?? 20) || 0, 0)
};
}
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>