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

2012 lines
78 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="backend" tab="数据源 / 仓库">
<div class="settings-database-panel">
<div class="settings-database-stack">
<n-card title="后端类型" size="small" class="work-card">
<n-form label-placement="top">
<n-form-item label="数据源">
<n-radio-group v-model:value="sourceType">
<n-radio-button value="sftp">SFTP</n-radio-button>
<n-radio-button value="ftp">FTP</n-radio-button>
<n-radio-button value="metrix">Metrix 存储平台</n-radio-button>
</n-radio-group>
</n-form-item>
<n-form-item label="数据仓库">
<n-radio-group v-model:value="warehouseType">
<n-radio-button value="mysql">MySQL</n-radio-button>
<n-radio-button value="metrix">Metrix 数据库平台</n-radio-button>
</n-radio-group>
</n-form-item>
<p class="form-hint">主数据源和数据仓库可独立选择;CellData 单独配置。</p>
</n-form>
<template #footer>
<n-space justify="end">
<n-button type="primary" :loading="savingBackend" @click="saveBackend">保存类型</n-button>
</n-space>
</template>
</n-card>
<n-card title="Metrix 连接(存储平台 / 数据库平台)" size="small" class="work-card">
<n-form label-placement="top">
<n-form-item label="平台地址 (api_base)">
<n-input v-model:value="metrixForm.base_url" placeholder="http://host.docker.internal:8000" />
</n-form-item>
<n-form-item label="API Token">
<n-input v-model:value="metrixForm.token" type="password" show-password-on="click" placeholder="mtx_..." />
</n-form-item>
<n-grid :cols="12" :x-gap="12">
<n-gi :span="6">
<n-form-item label="储存连接 ID">
<n-input v-model:value="metrixForm.storage_id" placeholder="stg_..." />
</n-form-item>
</n-gi>
<n-gi :span="6">
<n-form-item label="数据库连接 ID">
<n-input v-model:value="metrixForm.database_conn_id" placeholder="db_..." />
</n-form-item>
</n-gi>
</n-grid>
<n-grid :cols="12" :x-gap="12">
<n-gi :span="8">
<n-form-item label="目标库/Schema">
<n-input v-model:value="metrixForm.target_database" placeholder="连接已指定库时可空" />
</n-form-item>
</n-gi>
<n-gi :span="4">
<n-form-item label="最近 N 天回退">
<n-input-number v-model:value="metrixForm.recent_days" class="full-width" :min="1" :precision="0" />
</n-form-item>
</n-gi>
</n-grid>
<p class="form-hint">填写 Metrix 平台连接信息。</p>
</n-form>
<template #footer>
<n-space justify="end">
<n-button type="primary" :loading="savingMetrix" @click="saveMetrix">保存连接</n-button>
<n-button :loading="testingRemote" @click="testRemote">测试储存</n-button>
</n-space>
</template>
</n-card>
</div>
</div>
</n-tab-pane>
<n-tab-pane name="database" tab="数据库">
<div class="settings-database-panel">
<div class="settings-database-stack">
<n-card title="MySQL配置" 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="CellData 数据库配置" 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="cellDataMysqlForm.host" placeholder="localhost" />
</n-form-item>
</n-gi>
<n-gi :span="4">
<n-form-item label="端口">
<n-input-number v-model:value="cellDataMysqlForm.port" class="full-width" :min="1" :max="65535" />
</n-form-item>
</n-gi>
</n-grid>
<n-form-item label="数据库名">
<n-input v-model:value="cellDataMysqlForm.dbname" placeholder="celldata" />
</n-form-item>
<n-grid :cols="12" :x-gap="12">
<n-gi :span="6">
<n-form-item label="用户名">
<n-input v-model:value="cellDataMysqlForm.user" placeholder="root" />
</n-form-item>
</n-gi>
<n-gi :span="6">
<n-form-item label="密码">
<n-input v-model:value="cellDataMysqlForm.passwd" type="password" show-password-on="click" />
</n-form-item>
</n-gi>
</n-grid>
<p class="form-hint">填写 CellData 数据库连接;可与主数据库相同。</p>
</n-form>
<template #footer>
<n-space justify="end">
<n-button type="primary" :loading="savingCellDataMysql" @click="saveCellDataMysql">保存配置</n-button>
<n-button :loading="testingCellDataDb" @click="testCellDataDatabase">测试连接</n-button>
</n-space>
</template>
</n-card>
<n-card title="处理历史保留" size="small" class="work-card work-card-narrow">
<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>
</div>
</n-tab-pane>
<n-tab-pane name="remote" tab="远程数据源">
<div class="settings-remote-panel">
<n-card title="数据推送信息" size="small" class="work-card settings-remote-card">
<n-form label-placement="top">
<div class="remote-connection-row">
<n-form-item label="协议">
<n-select
v-model:value="remoteForm.protocol"
:options="remoteProtocolOptions"
@update:value="updateRemoteProtocol"
/>
</n-form-item>
<n-form-item label="服务器地址">
<n-input v-model:value="remoteForm.host" placeholder="192.168.1.10" />
</n-form-item>
<n-form-item label="端口">
<n-input-number v-model:value="remoteForm.port" class="full-width" :min="1" :max="65535" />
</n-form-item>
<n-form-item label="超时秒数">
<n-input-number v-model:value="remoteForm.timeout" class="full-width" :min="1" :max="600" />
</n-form-item>
<n-form-item label="FTP 被动模式" class="remote-passive-field">
<n-switch v-model:value="remoteForm.passive" :disabled="remoteForm.protocol !== 'ftp'" />
</n-form-item>
</div>
<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>
<div class="remote-toggle-row">
<n-form-item label="启用远程自动化">
<n-switch v-model:value="remoteForm.enabled" />
</n-form-item>
<n-form-item label="处理成功后删除源文件">
<n-switch
v-model:value="remoteForm.auto_delete_source"
:disabled="remoteForm.auto_scheduler.enabled"
/>
</n-form-item>
</div>
<p class="form-hint">填写容量数据来源;可选择处理成功后删除源文件。</p>
</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>
<n-card title="CellData 数据源" size="small" class="work-card settings-remote-card">
<n-form label-placement="top">
<div class="remote-connection-row">
<n-form-item label="协议">
<n-select
v-model:value="cellDataRemoteForm.protocol"
:options="remoteProtocolOptions"
@update:value="updateCellDataRemoteProtocol"
/>
</n-form-item>
<n-form-item label="服务器地址">
<n-input v-model:value="cellDataRemoteForm.host" placeholder="192.168.1.10" />
</n-form-item>
<n-form-item label="端口">
<n-input-number v-model:value="cellDataRemoteForm.port" class="full-width" :min="1" :max="65535" />
</n-form-item>
<n-form-item label="超时秒数">
<n-input-number v-model:value="cellDataRemoteForm.timeout" class="full-width" :min="1" :max="600" />
</n-form-item>
<n-form-item label="FTP 被动模式" class="remote-passive-field">
<n-switch v-model:value="cellDataRemoteForm.passive" :disabled="cellDataRemoteForm.protocol !== 'ftp'" />
</n-form-item>
</div>
<n-grid :cols="12" :x-gap="12">
<n-gi :span="6">
<n-form-item label="用户名">
<n-input v-model:value="cellDataRemoteForm.user" placeholder="remote user" />
</n-form-item>
</n-gi>
<n-gi :span="6">
<n-form-item label="密码">
<n-input v-model:value="cellDataRemoteForm.passwd" type="password" show-password-on="click" />
</n-form-item>
</n-gi>
</n-grid>
<n-form-item label="扫描路径">
<div class="cell-data-path-editor">
<div class="cell-data-path-header">
<span>支持路径模板和多目录组合</span>
<n-button quaternary circle size="small" title="查看说明" @click="cellDataHelpVisible = true">
<template #icon>
<n-icon><InformationCircleOutline /></n-icon>
</template>
</n-button>
</div>
<div class="cell-data-path-list">
<div v-for="(path, index) in cellDataScanPaths" :key="`${path}-${index}`" class="cell-data-path-row">
<n-input v-model:value="cellDataScanPaths[index]" size="small" placeholder="/.../{maxyear}年/300表" />
<n-button quaternary circle size="small" type="error" @click="removeCellDataScanPath(index)">
<template #icon>
<n-icon><CloseOutline /></n-icon>
</template>
</n-button>
</div>
</div>
<n-input-group class="cell-data-path-add">
<n-input v-model:value="newCellDataScanPath" placeholder="添加扫描路径模板" @keydown.enter.prevent="addCellDataScanPath" />
<n-button @click="addCellDataScanPath">添加</n-button>
</n-input-group>
</div>
</n-form-item>
<n-form-item label="启用 CellData 数据源">
<n-switch v-model:value="cellDataRemoteForm.enabled" />
</n-form-item>
<p class="form-hint">填写 CellData 文件来源。</p>
</n-form>
<template #footer>
<n-space justify="end">
<n-button type="primary" :loading="savingCellDataRemote" @click="saveCellDataRemote">保存配置</n-button>
<n-button :loading="testingCellDataRemote" @click="testCellDataRemote">测试连接</n-button>
<n-button @click="openCellDataSettingsModal">规则设置</n-button>
</n-space>
</template>
</n-card>
</div>
</n-tab-pane>
<n-tab-pane name="scheduler" tab="自动调度">
<div class="settings-scheduler-panel">
<n-card title="自动调度" size="small" class="work-card settings-scheduler-card">
<n-form label-placement="top">
<n-grid :cols="12" :x-gap="12">
<n-gi :span="4">
<n-form-item label="启用自动调度">
<n-switch v-model:value="remoteForm.auto_scheduler.enabled" :disabled="!remoteForm.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="schedulerControlsDisabled"
/>
</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="schedulerControlsDisabled"
/>
</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="schedulerControlsDisabled"
@keydown.enter.prevent="addSchedulerDirectory"
/>
<n-button :disabled="schedulerControlsDisabled" @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="schedulerControlsDisabled"
@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">启用后按目标周检查文件完整性,满足条件后自动处理。</p>
</n-form>
<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.displayName }}</span>
<n-tag size="small" :type="row.skipped ? 'info' : row.ready ? 'success' : 'warning'">
{{ row.skipped ? '已停推' : 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.enabled || !remoteForm.auto_scheduler.enabled"
@click="triggerSchedulerCheck"
>
立即检查
</n-button>
</n-space>
</div>
<template #footer>
<n-space justify="end">
<n-button type="primary" :loading="savingRemote" @click="saveRemote">保存调度</n-button>
</n-space>
</template>
</n-card>
</div>
</n-tab-pane>
<n-tab-pane name="rules" tab="规则映射">
<div class="settings-rules-grid">
<div class="settings-rules-side">
<n-card title="数据目录映射" size="small" class="work-card">
<n-space vertical>
<p class="form-hint">配置源目录对应的暂存表和检查规则。</p>
<div class="directory-mapping-list">
<div
v-for="(item, index) in dataMappingsForm.directories"
:key="`${item.path}-${item.table}-${item.ready_rule}-${index}`"
class="directory-mapping-row"
>
<n-input v-model:value="item.path" size="small" placeholder="目录,例如 4G 或 RJ/700M/700RJYD" />
<n-input v-model:value="item.table" size="small" placeholder="暂存表,例如 4G_UD" />
<n-select v-model:value="item.ready_rule" size="small" :options="readyRuleOptions" />
<n-button quaternary circle size="small" type="error" @click="removeDirectoryMapping(index)">
<template #icon>
<n-icon><CloseOutline /></n-icon>
</template>
</n-button>
</div>
<n-empty v-if="dataMappingsForm.directories.length === 0" size="small" description="暂无目录映射" />
</div>
<n-input-group>
<n-input v-model:value="newDirectoryMapping.path" placeholder="目录路径" @keydown.enter.prevent="addDirectoryMapping" />
<n-input v-model:value="newDirectoryMapping.table" placeholder="暂存表名" @keydown.enter.prevent="addDirectoryMapping" />
<n-select v-model:value="newDirectoryMapping.ready_rule" class="directory-ready-select" :options="readyRuleOptions" />
<n-button @click="addDirectoryMapping">添加</n-button>
</n-input-group>
</n-space>
<template #footer>
<n-space justify="end">
<n-button type="primary" :loading="savingDirectoryMappings" @click="saveDirectoryMappings">保存目录映射</n-button>
</n-space>
</template>
</n-card>
<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>
</div>
<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">把源文件列名映射为数据库字段。</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-tabs>
</n-card>
<n-modal
v-model:show="cellDataHelpVisible"
preset="card"
class="cell-data-help-modal"
title="扫描路径说明"
style="width: min(520px, calc(100vw - 32px))"
>
<n-scrollbar class="cell-data-help-scroll">
<section class="cell-data-help-section">
<h4>路径模板</h4>
<p>每行填写一个扫描目录。系统会在目录下继续识别 700M、2.6G 等子目录。</p>
<code>/网优日常优化数据文档/日常性能报表/{maxyear}年/300表</code>
</section>
<section class="cell-data-help-section">
<h4>占位符</h4>
<p><strong>{maxyear}</strong>:扫描同级年份目录并取最大年份。</p>
<p><strong>{maxmonth}</strong>:扫描同级月份目录并取最大月份。</p>
<p><strong>{maxday}</strong>:扫描同级日期目录并取最大日期。</p>
<p><strong>{yyyy}</strong>:当前年份。</p>
<p><strong>{yyyymm}</strong>:当前年月。</p>
<p><strong>{yyyymmdd}</strong>:当前日期。</p>
</section>
<section class="cell-data-help-section">
<h4>高级正则</h4>
<p><strong>年份目录正则</strong>:识别年份目录,默认匹配 2026年。</p>
<p><strong>文件名正则</strong>:筛选 ZIP 文件,默认匹配 Result_300_ 开头。</p>
<p><strong>时间戳正则</strong>:从 ZIP 文件名末尾提取文件时间。</p>
</section>
<section class="cell-data-help-section">
<h4>多目录示例</h4>
<code>/网优日常优化数据文档/日常性能报表/{maxyear}年/300表</code>
<code>/网优日常优化数据文档/日常性能报表/{maxyear}年/cellinfo</code>
</section>
</n-scrollbar>
</n-modal>
<n-modal
v-model:show="cellDataSettingsVisible"
preset="card"
class="cell-data-settings-modal"
title="CellData 规则设置"
style="width: min(860px, calc(100vw - 32px))"
@after-enter="layoutCellDataEditor"
@after-leave="disposeCellDataEditor"
>
<div class="cell-data-settings-body">
<section class="cell-data-settings-section">
<div class="cell-data-section-title">
<strong>高级匹配</strong>
</div>
<n-grid :cols="12" :x-gap="12">
<n-gi :span="4">
<n-form-item label="年份目录正则">
<n-input v-model:value="cellDataRegexForm.year_dir_regex" />
</n-form-item>
</n-gi>
<n-gi :span="4">
<n-form-item label="月份目录正则">
<n-input v-model:value="cellDataRegexForm.month_dir_regex" />
</n-form-item>
</n-gi>
<n-gi :span="4">
<n-form-item label="日期目录正则">
<n-input v-model:value="cellDataRegexForm.day_dir_regex" />
</n-form-item>
</n-gi>
<n-gi :span="6">
<n-form-item label="文件名正则">
<n-input v-model:value="cellDataRegexForm.file_name_regex" />
</n-form-item>
</n-gi>
<n-gi :span="6">
<n-form-item label="时间戳正则">
<n-input v-model:value="cellDataRegexForm.file_time_regex" />
</n-form-item>
</n-gi>
</n-grid>
</section>
<section class="cell-data-settings-section">
<div class="cell-data-section-title">
<strong>映射规则</strong>
<n-space size="small">
<n-button v-if="cellDataMappingMode === 'form'" size="small" @click="addCellDataMappingSource">添加来源</n-button>
<n-radio-group v-model:value="cellDataMappingMode" size="small" @update:value="switchCellDataMappingMode">
<n-radio-button value="form">图形化</n-radio-button>
<n-radio-button value="json">JSON</n-radio-button>
</n-radio-group>
<n-button v-if="cellDataMappingMode === 'json'" size="small" @click="formatCellDataMapping">格式化</n-button>
<n-button size="small" :loading="validatingCellDataSettings" @click="validateCellDataSettings">校验</n-button>
<n-button size="small" @click="restoreDefaultCellDataMapping">恢复默认</n-button>
</n-space>
</div>
<div v-if="cellDataMappingMode === 'json'" ref="cellDataEditorHost" class="cell-data-json-editor"></div>
<div v-else class="cell-data-mapping-form">
<div class="cell-data-mapping-key">
<n-form-item label="目标表">
<n-input v-model:value="cellDataMappingForm.target_table" />
</n-form-item>
<n-form-item label="主键字段">
<n-input v-model:value="cellDataMappingForm.key.field" />
</n-form-item>
<n-form-item label="主键表达式">
<n-input v-model:value="cellDataMappingForm.key.expr" />
</n-form-item>
</div>
<div class="cell-data-source-list">
<div v-for="(source, sourceIndex) in cellDataMappingForm.sources" :key="sourceIndex" class="cell-data-source-card">
<div class="cell-data-source-header" @click="toggleCellDataMappingSource(sourceIndex)">
<div class="cell-data-source-title">
<span class="cell-data-source-toggle">{{ source.collapsed ? '▶' : '▼' }}</span>
<strong>来源 {{ sourceIndex + 1 }}</strong>
<span>{{ source.band || '未填写目录' }} / {{ source.file_prefix || '未填写前缀' }}</span>
</div>
<n-button quaternary circle size="small" type="error" @click.stop="removeCellDataMappingSource(sourceIndex)">
<template #icon>
<n-icon><CloseOutline /></n-icon>
</template>
</n-button>
</div>
<template v-if="!source.collapsed">
<div class="cell-data-source-meta">
<n-form-item label="目录">
<n-input v-model:value="source.band" placeholder="如 2.6G" />
</n-form-item>
<n-form-item label="CSV 前缀">
<n-input v-model:value="source.file_prefix" placeholder="如 NR_CellInfo" />
</n-form-item>
</div>
<div class="cell-data-field-list">
<div v-for="(field, fieldIndex) in source.fields" :key="fieldIndex" class="cell-data-field-row">
<n-input v-model:value="field.target" size="small" placeholder="目标字段" />
<n-select v-model:value="field.mode" size="small" :options="mappingFieldModeOptions" />
<n-input v-model:value="field.value" size="small" :placeholder="field.mode === 'value' ? '固定值' : 'CSV 字段名'" />
<n-button quaternary circle size="small" type="error" @click="removeCellDataMappingField(sourceIndex, fieldIndex)">
<template #icon>
<n-icon><CloseOutline /></n-icon>
</template>
</n-button>
</div>
</div>
<n-button size="small" @click="addCellDataMappingField(sourceIndex)">添加字段</n-button>
</template>
</div>
</div>
</div>
</section>
</div>
<template #footer>
<n-space justify="end">
<n-button @click="cellDataSettingsVisible = false">取消</n-button>
<n-button type="primary" :loading="savingCellDataSettings" @click="saveCellDataSettings">保存规则</n-button>
</n-space>
</template>
</n-modal>
</div>
</template>
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, shallowRef, watch } from 'vue';
import { useDialog, useMessage, type SelectOption } from 'naive-ui';
import { CloseOutline, CloudDownloadOutline, CloudUploadOutline, InformationCircleOutline } from '@vicons/ionicons5';
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/language/json/monaco.contribution';
import { apiGet, apiPost, downloadGet, upload } from '../api/client';
import type {
ApiMessage,
AppConfig,
CellDataConfig,
DataDirectoryMapping,
DataMappingsConfig,
HistoryRetentionConfig,
MetrixConfig,
RemoteAutoSchedulerConfig,
RemoteDataConfig,
RemoteSchedulerStatus
} from '../types';
import { showDownloadCompleteDialog } from '../composables/downloadFeedback';
import { resetPageHeader, setPageHeader } from '../composables/pageHeader';
interface ExtractFieldConfig {
Field: string;
Type: string;
Extract: string[];
[key: string]: unknown;
}
type MappingFieldMode = 'field' | 'value';
interface MappingFieldForm {
target: string;
mode: MappingFieldMode;
value: string;
}
interface MappingSourceForm {
band: string;
file_prefix: string;
collapsed: boolean;
fields: MappingFieldForm[];
}
interface CellDataMappingForm {
target_table: string;
key: {
field: string;
expr: string;
};
sources: MappingSourceForm[];
}
type MonacoGlobal = typeof globalThis & {
MonacoEnvironment?: {
getWorker: (_moduleId: string, _label: string) => Worker;
};
};
(globalThis as MonacoGlobal).MonacoEnvironment = {
getWorker: () => new editorWorker()
};
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 testingCellDataDb = ref(false);
const testingCellDataRemote = ref(false);
const loadingSchedulerStatus = ref(false);
const triggeringScheduler = ref(false);
const savingMysql = ref(false);
const savingRemote = ref(false);
const savingCellDataMysql = ref(false);
const savingCellDataRemote = ref(false);
const savingCellDataSettings = ref(false);
const validatingCellDataSettings = ref(false);
const savingBackend = ref(false);
const savingMetrix = ref(false);
const sourceType = ref<'ftp' | 'sftp' | 'metrix'>('sftp');
const warehouseType = ref<'mysql' | 'metrix'>('mysql');
const savingDirectoryMappings = 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 newCellDataScanPath = ref('');
const cellDataHelpVisible = ref(false);
const cellDataSettingsVisible = ref(false);
const cellDataScanPaths = ref<string[]>([]);
const cellDataMappingText = ref('');
const cellDataMappingMode = ref<'form' | 'json'>('form');
const cellDataEditorHost = ref<HTMLDivElement | null>(null);
const cellDataEditor = shallowRef<monaco.editor.IStandaloneCodeEditor | null>(null);
const cellDataMappingForm = reactive<CellDataMappingForm>({
target_table: 'cellinfo',
key: {
field: 'CGI',
expr: '{PLMN}-{eNodeBID}-{CellID}'
},
sources: []
});
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 cellDataRemoteForm = 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 cellDataRegexForm = reactive({
year_dir_regex: '(?P<year>\\\\d{4})年',
month_dir_regex: '(?P<month>\\\\d{1,2})月',
day_dir_regex: '(?P<day>\\\\d{1,2})日',
file_name_regex: '^Result_300_.*\\\\.zip$',
file_time_regex: '(?P<timestamp>\\\\d{14})(?=\\\\.zip$)'
});
const metrixForm = reactive<MetrixConfig>({
base_url: 'http://host.docker.internal:8000',
token: '',
storage_id: '',
database_conn_id: '',
target_database: '',
recent_days: 7
});
const dataMappingsForm = reactive<DataMappingsConfig>({
directories: [
{ path: '4G', table: '4G_UD', ready_rule: 'daily' },
{ path: '5G', table: '5G_UD', ready_rule: 'daily' }
],
table_field_mappings: {}
});
const newDirectoryMapping = reactive<DataDirectoryMapping>({
path: '',
table: '',
ready_rule: 'daily'
});
const cellDataMysqlForm = reactive({
host: '',
port: 3306 as number | null,
user: '',
passwd: '',
dbname: 'celldata'
});
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 readyRuleOptions: SelectOption[] = [
{ label: '每日7天', value: 'daily' },
{ label: '自动日/周', value: 'auto' }
];
const mappingFieldModeOptions: SelectOption[] = [
{ label: '字段', value: 'field' },
{ label: '固定值', value: 'value' }
];
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,
displayName: name.replace(/^rj:/i, ''),
...status
}));
});
const schedulerControlsDisabled = computed(() => !remoteForm.enabled || !remoteForm.auto_scheduler.enabled);
watch(
() => remoteForm.enabled,
enabled => {
if (!enabled && remoteForm.auto_scheduler.enabled) {
remoteForm.auto_scheduler.enabled = false;
}
}
);
watch(
() => remoteForm.auto_scheduler.enabled,
enabled => {
if (enabled) {
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();
disposeCellDataEditor();
});
async function loadConfig() {
loading.value = true;
try {
const config = await apiGet<AppConfig>('/api/config/full');
configUpdate.value = config.update;
sourceType.value = config.source_type || 'sftp';
warehouseType.value = config.warehouse_type || 'mysql';
if (config.metrix) {
metrixForm.base_url = config.metrix.base_url || '';
metrixForm.token = config.metrix.token || '';
metrixForm.storage_id = config.metrix.storage_id || '';
metrixForm.database_conn_id = config.metrix.database_conn_id || '';
metrixForm.target_database = config.metrix.target_database || '';
metrixForm.recent_days = Number(config.metrix.recent_days) || 7;
}
Object.assign(dataMappingsForm, normalizeDataMappingsConfig(config.data_mappings));
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(cellDataRemoteForm, normalizeRemoteConfig(config.cell_data?.remote_data, '/'));
Object.assign(cellDataMysqlForm, normalizeCellDataMysqlConfig(config.cell_data?.mysql));
cellDataScanPaths.value = normalizeCellDataScanPaths(config.cell_data?.scan_paths);
cellDataRegexForm.year_dir_regex = config.cell_data?.year_dir_regex || '(?P<year>\\\\d{4})年';
cellDataRegexForm.month_dir_regex = config.cell_data?.month_dir_regex || '(?P<month>\\\\d{1,2})月';
cellDataRegexForm.day_dir_regex = config.cell_data?.day_dir_regex || '(?P<day>\\\\d{1,2})日';
cellDataRegexForm.file_name_regex = config.cell_data?.file_name_regex || '^Result_300_.*\\\\.zip$';
cellDataRegexForm.file_time_regex = config.cell_data?.file_time_regex || '(?P<timestamp>\\\\d{14})(?=\\\\.zip$)';
setCellDataMappingText(JSON.stringify(config.cell_data?.mapping || {}, null, 2));
syncCellDataMappingFormFromText();
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;
}
}
function updateCellDataRemoteProtocol(value: string) {
if (value === 'sftp' && (!cellDataRemoteForm.port || cellDataRemoteForm.port === 21)) {
cellDataRemoteForm.port = 22;
}
if (value === 'ftp' && (!cellDataRemoteForm.port || cellDataRemoteForm.port === 22)) {
cellDataRemoteForm.port = 21;
}
}
async function saveBackend() {
savingBackend.value = true;
try {
const result = await apiPost<ApiMessage>('/api/config/backend', {
source_type: sourceType.value,
warehouse_type: warehouseType.value
});
configUpdate.value = result.update || configUpdate.value;
message.success(result.message || '后端类型已保存');
} catch (error) {
message.error(error instanceof Error ? error.message : '保存后端类型失败');
} finally {
savingBackend.value = false;
}
}
async function saveMetrix() {
if (!metrixForm.base_url) {
message.warning('请填写平台地址');
return;
}
savingMetrix.value = true;
try {
const result = await apiPost<ApiMessage>('/api/config/metrix', {
...metrixForm,
recent_days: Math.max(Number(metrixForm.recent_days) || 7, 1)
});
configUpdate.value = result.update || configUpdate.value;
message.success(result.message || 'Metrix 连接已保存');
} catch (error) {
message.error(error instanceof Error ? error.message : '保存 Metrix 连接失败');
} finally {
savingMetrix.value = false;
}
}
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;
}
}
async function saveCellDataMysql() {
if (!cellDataMysqlForm.host || !cellDataMysqlForm.user || !cellDataMysqlForm.dbname) {
message.warning('请填写完整 CellData 数据库配置');
return;
}
savingCellDataMysql.value = true;
try {
const result = await apiPost<ApiMessage>('/api/config/cell-data/mysql', {
...cellDataMysqlForm,
port: cellDataMysqlForm.port || 3306
});
configUpdate.value = result.update || configUpdate.value;
message.success(result.message || 'CellData 数据库配置已保存');
} catch (error) {
message.error(error instanceof Error ? error.message : '保存 CellData 数据库配置失败');
} finally {
savingCellDataMysql.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 getCellDataRemotePayload(): RemoteDataConfig {
return {
...cellDataRemoteForm,
protocol: cellDataRemoteForm.protocol,
host: cellDataRemoteForm.host.trim(),
port: cellDataRemoteForm.port || (cellDataRemoteForm.protocol === 'sftp' ? 22 : 21),
user: cellDataRemoteForm.user.trim(),
passwd: cellDataRemoteForm.passwd || '',
remote_dir: cellDataRemoteForm.remote_dir.trim() || '/',
passive: cellDataRemoteForm.passive,
timeout: cellDataRemoteForm.timeout || 30,
auto_delete_source: false,
auto_scheduler: {
enabled: false,
check_interval_hours: 1,
expected_directories: [],
week_offset: 0
}
};
}
function validateRemoteForm(): boolean {
if (!remoteForm.host || !remoteForm.user || !remoteForm.remote_dir) {
message.warning('请填写完整远程数据源配置');
return false;
}
return true;
}
function validateCellDataRemoteForm(): boolean {
if (!cellDataRemoteForm.host || !cellDataRemoteForm.user || !cellDataRemoteForm.remote_dir) {
message.warning('请填写完整 CellData 远程数据源配置');
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;
}
}
async function saveCellDataRemote() {
if (!validateCellDataRemoteForm()) return;
savingCellDataRemote.value = true;
try {
const result = await apiPost<ApiMessage>('/api/config/cell-data/remote', getCellDataRemotePayload());
configUpdate.value = result.update || configUpdate.value;
message.success(result.message || 'CellData 远程数据源配置已保存');
} catch (error) {
message.error(error instanceof Error ? error.message : '保存 CellData 远程数据源配置失败');
} finally {
savingCellDataRemote.value = false;
}
}
function addCellDataScanPath() {
const value = normalizeDirectoryText(newCellDataScanPath.value);
if (!value) return;
if (cellDataScanPaths.value.includes(value)) {
message.warning('该扫描路径已存在');
return;
}
cellDataScanPaths.value.push(value);
newCellDataScanPath.value = '';
}
function removeCellDataScanPath(index: number) {
cellDataScanPaths.value.splice(index, 1);
}
function getCellDataSettingsPayload() {
if (cellDataMappingMode.value === 'form') {
syncCellDataMappingTextFromForm();
}
let mapping: Record<string, unknown>;
try {
mapping = JSON.parse(getCellDataMappingText() || '{}') as Record<string, unknown>;
} catch {
throw new Error('映射规则不是有效 JSON');
}
return {
scan_paths: normalizeCellDataScanPaths(cellDataScanPaths.value),
year_dir_regex: cellDataRegexForm.year_dir_regex,
month_dir_regex: cellDataRegexForm.month_dir_regex,
day_dir_regex: cellDataRegexForm.day_dir_regex,
file_name_regex: cellDataRegexForm.file_name_regex,
file_time_regex: cellDataRegexForm.file_time_regex,
mapping
};
}
async function validateCellDataSettings() {
validatingCellDataSettings.value = true;
try {
const result = await apiPost<ApiMessage>('/api/config/cell-data/settings/validate', getCellDataSettingsPayload());
message[result.success ? 'success' : 'error'](result.message || '校验完成');
} catch (error) {
message.error(error instanceof Error ? error.message : '校验失败');
} finally {
validatingCellDataSettings.value = false;
}
}
async function restoreDefaultCellDataMapping() {
try {
const mapping = await apiGet<Record<string, unknown>>('/api/config/cell-data/mapping/default');
setCellDataMappingText(JSON.stringify(mapping, null, 2));
syncCellDataMappingFormFromText();
message.success('已恢复默认映射');
} catch (error) {
message.error(error instanceof Error ? error.message : '恢复默认映射失败');
}
}
async function saveCellDataSettings() {
savingCellDataSettings.value = true;
try {
const payload = getCellDataSettingsPayload();
const result = await apiPost<ApiMessage>('/api/config/cell-data/settings', payload);
cellDataScanPaths.value = normalizeCellDataScanPaths(payload.scan_paths);
setCellDataMappingText(JSON.stringify(payload.mapping, null, 2));
syncCellDataMappingFormFromText();
configUpdate.value = result.update || configUpdate.value;
message.success(result.message || 'CellData 规则已保存');
} catch (error) {
message.error(error instanceof Error ? error.message : '保存 CellData 规则失败');
} finally {
savingCellDataSettings.value = false;
}
}
function openCellDataSettingsModal() {
cellDataSettingsVisible.value = true;
if (cellDataMappingMode.value === 'json') {
void nextTick(createCellDataEditor);
}
}
function createCellDataEditor() {
if (!cellDataEditorHost.value || cellDataEditor.value) {
layoutCellDataEditor();
return;
}
cellDataEditor.value = monaco.editor.create(cellDataEditorHost.value, {
value: cellDataMappingText.value || '{}',
language: 'json',
theme: currentEditorTheme(),
fontSize: 13,
fontFamily: "Consolas, 'Courier New', monospace",
minimap: { enabled: false },
automaticLayout: true,
scrollBeyondLastLine: false,
wordWrap: 'on',
tabSize: 2,
insertSpaces: true,
formatOnPaste: true,
formatOnType: true
});
}
function disposeCellDataEditor() {
if (cellDataEditor.value) {
cellDataMappingText.value = cellDataEditor.value.getValue();
cellDataEditor.value.dispose();
cellDataEditor.value = null;
}
}
function layoutCellDataEditor() {
void nextTick(() => cellDataEditor.value?.layout());
}
function getCellDataMappingText(): string {
return cellDataEditor.value?.getValue() ?? cellDataMappingText.value;
}
function setCellDataMappingText(value: string) {
cellDataMappingText.value = value;
if (cellDataEditor.value) {
cellDataEditor.value.setValue(value);
}
}
function switchCellDataMappingMode(value: string) {
if (value === 'json') {
syncCellDataMappingTextFromForm();
cellDataMappingMode.value = 'json';
void nextTick(createCellDataEditor);
return;
}
cellDataMappingText.value = getCellDataMappingText();
disposeCellDataEditor();
cellDataMappingMode.value = 'form';
syncCellDataMappingFormFromText();
}
function syncCellDataMappingFormFromText() {
try {
applyCellDataMappingForm(JSON.parse(cellDataMappingText.value || '{}') as Record<string, unknown>);
} catch {
applyCellDataMappingForm({});
}
}
function syncCellDataMappingTextFromForm() {
setCellDataMappingText(JSON.stringify(mappingFormToJson(), null, 2));
}
function applyCellDataMappingForm(mapping: Record<string, unknown>) {
cellDataMappingForm.target_table = String(mapping.target_table || 'cellinfo');
const key = typeof mapping.key === 'object' && mapping.key !== null ? mapping.key as Record<string, unknown> : {};
cellDataMappingForm.key.field = String(key.field || 'CGI');
cellDataMappingForm.key.expr = String(key.expr || '{PLMN}-{eNodeBID}-{CellID}');
cellDataMappingForm.sources.splice(0, cellDataMappingForm.sources.length);
const sources = Array.isArray(mapping.sources) ? mapping.sources : [];
for (const item of sources) {
if (typeof item !== 'object' || item === null) continue;
const source = item as Record<string, unknown>;
const fields = typeof source.fields === 'object' && source.fields !== null ? source.fields as Record<string, unknown> : {};
cellDataMappingForm.sources.push({
band: String(source.band || ''),
file_prefix: String(source.file_prefix || ''),
collapsed: false,
fields: Object.entries(fields).map(([target, rule]) => {
if (typeof rule === 'object' && rule !== null && 'value' in rule) {
return { target, mode: 'value', value: String((rule as Record<string, unknown>).value ?? '') };
}
return { target, mode: 'field', value: String(rule ?? '') };
})
});
}
}
function mappingFormToJson(): Record<string, unknown> {
return {
target_table: cellDataMappingForm.target_table.trim() || 'cellinfo',
key: {
field: cellDataMappingForm.key.field.trim() || 'CGI',
expr: cellDataMappingForm.key.expr.trim() || '{PLMN}-{eNodeBID}-{CellID}'
},
sources: cellDataMappingForm.sources
.map(source => {
const fields: Record<string, unknown> = {};
for (const field of source.fields) {
const target = field.target.trim();
if (!target) continue;
fields[target] = field.mode === 'value' ? { value: field.value } : field.value.trim();
}
return {
band: source.band.trim(),
file_prefix: source.file_prefix.trim(),
fields
};
})
.filter(source => source.band && source.file_prefix && Object.keys(source.fields).length)
};
}
function addCellDataMappingSource() {
cellDataMappingForm.sources.push({ band: '', file_prefix: '', collapsed: false, fields: [] });
}
function toggleCellDataMappingSource(index: number) {
const source = cellDataMappingForm.sources[index];
if (source) {
source.collapsed = !source.collapsed;
}
}
function removeCellDataMappingSource(index: number) {
cellDataMappingForm.sources.splice(index, 1);
}
function addCellDataMappingField(sourceIndex: number) {
cellDataMappingForm.sources[sourceIndex]?.fields.push({ target: '', mode: 'field', value: '' });
}
function removeCellDataMappingField(sourceIndex: number, fieldIndex: number) {
cellDataMappingForm.sources[sourceIndex]?.fields.splice(fieldIndex, 1);
}
async function formatCellDataMapping() {
try {
const formatted = JSON.stringify(JSON.parse(getCellDataMappingText() || '{}'), null, 2);
setCellDataMappingText(formatted);
await nextTick();
await cellDataEditor.value?.getAction('editor.action.formatDocument')?.run();
} catch {
message.error('映射规则不是有效 JSON');
}
}
function currentEditorTheme(): string {
return document.documentElement.dataset.theme === 'dark' ? 'vs-dark' : 'vs';
}
function addDirectoryMapping() {
const path = normalizeDirectoryText(newDirectoryMapping.path);
const table = newDirectoryMapping.table.trim();
const readyRule = newDirectoryMapping.ready_rule === 'auto' ? 'auto' : 'daily';
if (!path || !table) {
message.warning('请填写目录路径和暂存表名');
return;
}
const exists = dataMappingsForm.directories.some(
item => normalizeDirectoryText(item.path) === path && item.table.trim() === table && item.ready_rule === readyRule
);
if (exists) {
message.warning('该目录映射已存在');
return;
}
dataMappingsForm.directories.push({ path, table, ready_rule: readyRule });
newDirectoryMapping.path = '';
newDirectoryMapping.table = '';
newDirectoryMapping.ready_rule = 'daily';
}
function removeDirectoryMapping(index: number) {
dataMappingsForm.directories.splice(index, 1);
}
async function saveDirectoryMappings() {
const config = normalizeDataMappingsConfig(dataMappingsForm);
if (config.directories.length === 0) {
message.warning('请至少保留一个数据目录映射');
return;
}
savingDirectoryMappings.value = true;
try {
const result = await apiPost<ApiMessage>('/api/config/data-mappings', config);
Object.assign(dataMappingsForm, config);
configUpdate.value = result.update || configUpdate.value;
message.success('数据目录映射已保存');
} catch (error) {
message.error(error instanceof Error ? error.message : '保存数据目录映射失败');
} finally {
savingDirectoryMappings.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 testCellDataRemote() {
if (!validateCellDataRemoteForm()) return;
testingCellDataRemote.value = true;
try {
const result = await apiPost<ApiMessage>('/api/config/cell-data/remote/test', getCellDataRemotePayload());
message[result.success ? 'success' : 'error'](result.message || 'CellData 远程连接测试完成');
} catch (error) {
message.error(error instanceof Error ? error.message : 'CellData 远程连接测试失败');
} finally {
testingCellDataRemote.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 testCellDataDatabase() {
testingCellDataDb.value = true;
try {
const result = await apiPost<ApiMessage>('/api/config/cell-data/mysql/test', {
...cellDataMysqlForm,
port: cellDataMysqlForm.port || 3306
});
message[result.success ? 'success' : 'error'](result.message || 'CellData 数据库连接测试完成');
} catch (error) {
message.error(error instanceof Error ? error.message : 'CellData 数据库连接测试失败');
} finally {
testingCellDataDb.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, defaultRemoteDir = '/'): 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 || defaultRemoteDir,
passive: config?.passive ?? true,
timeout: config?.timeout || 30,
auto_delete_source: Boolean(config?.auto_delete_source) || autoScheduler.enabled,
auto_scheduler: autoScheduler
};
}
function normalizeCellDataMysqlConfig(config: CellDataConfig['mysql'] | undefined) {
return {
host: config?.host || 'localhost',
port: config?.port || 3306,
user: config?.user || 'root',
passwd: config?.passwd || '',
dbname: config?.dbname || 'celldata'
};
}
function normalizeCellDataScanPaths(paths: string[] | undefined): string[] {
const normalized = uniqueStrings(paths || [])
.map(path => path.replace(/\\/g, '/').trim())
.filter(Boolean);
return normalized.length
? normalized
: ['/网优日常优化数据文档/日常性能报表/{maxyear}年/300表'];
}
function normalizeDataMappingsConfig(config: DataMappingsConfig | undefined): DataMappingsConfig {
const directories: DataDirectoryMapping[] = [];
const seen = new Set<string>();
for (const item of config?.directories || []) {
const path = normalizeDirectoryText(item.path || '');
const table = String(item.table || '').trim();
const readyRule = item.ready_rule === 'auto' ? 'auto' : 'daily';
if (!path || !table) continue;
const key = `${path}\n${table}\n${readyRule}`;
if (seen.has(key)) continue;
seen.add(key);
directories.push({ path, table, ready_rule: readyRule });
}
if (directories.length === 0) {
directories.push(
{ path: '4G', table: '4G_UD', ready_rule: 'daily' },
{ path: '5G', table: '5G_UD', ready_rule: 'daily' }
);
}
return {
directories,
table_field_mappings: config?.table_field_mappings || {}
};
}
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.enabled) {
remoteForm.auto_scheduler.enabled = false;
return;
}
if (!remoteForm.auto_scheduler.enabled) return;
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>