feat: 实现 CellData 预处理与单独刷新
This commit is contained in:
@@ -59,6 +59,22 @@
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section v-if="!taskInProgress" class="cell-data-card">
|
||||
<div>
|
||||
<h3>CellData</h3>
|
||||
<p>刷新 CellData 数据。</p>
|
||||
</div>
|
||||
<n-button
|
||||
type="primary"
|
||||
secondary
|
||||
:loading="cellDataStarting"
|
||||
:disabled="working || remoteStarting || cellDataStarting"
|
||||
@click="startCellDataProcessing"
|
||||
>
|
||||
刷新 CellData
|
||||
</n-button>
|
||||
</section>
|
||||
|
||||
<div v-if="!taskInProgress && files.length > 0" class="file-list">
|
||||
<div class="file-list-header">
|
||||
<h4>已选择文件</h4>
|
||||
@@ -226,6 +242,7 @@ const files = ref<PickedFile[]>([]);
|
||||
const uploadProgress = ref(0);
|
||||
const working = ref(false);
|
||||
const remoteStarting = ref(false);
|
||||
const cellDataStarting = ref(false);
|
||||
const isDragging = ref(false);
|
||||
const taskStatus = ref<TaskStatus | null>(null);
|
||||
const activeTask = ref<ActiveTask | null>(null);
|
||||
@@ -236,17 +253,19 @@ const activationCode = ref('');
|
||||
const activationLoading = ref(false);
|
||||
const activationKeyLabel = ref('2026/12/30');
|
||||
const licenseErrorMessage = ref('当前数据日期已超过授权到期日期,请输入激活码延长 30 天。');
|
||||
const taskMode = ref<'local' | 'remote' | 'unknown'>('unknown');
|
||||
const taskMode = ref<'local' | 'remote' | 'cell_data' | 'unknown'>('unknown');
|
||||
let activationRetry: (() => Promise<void>) | null = null;
|
||||
let timer: number | undefined;
|
||||
|
||||
const stageLabels: Record<string, string> = {
|
||||
license: '授权校验中...',
|
||||
locating: '定位数据中...',
|
||||
downloading: '远程下载中...',
|
||||
uploading: '上传文件中...',
|
||||
processing: '处理中...',
|
||||
extracting: '解压数据中...',
|
||||
converting: '转换 Excel 中...',
|
||||
parsing: '解析数据中...',
|
||||
importing: '上传数据中...',
|
||||
scripting: '运行脚本中...',
|
||||
completed: '处理完成',
|
||||
@@ -542,6 +561,34 @@ async function startRemoteProcessing() {
|
||||
}
|
||||
}
|
||||
|
||||
async function startCellDataProcessing() {
|
||||
if (working.value || remoteStarting.value || cellDataStarting.value) return;
|
||||
|
||||
cellDataStarting.value = true;
|
||||
try {
|
||||
const result = await apiPost<UploadResponse>('/api/cell-data/process/start');
|
||||
message.success(result.message || 'CellData 处理已启动');
|
||||
activeTask.value = {
|
||||
has_active: true,
|
||||
task_id: result.task_id,
|
||||
stage: result.stage || 'locating',
|
||||
started_at: new Date().toISOString()
|
||||
};
|
||||
taskStatus.value = {
|
||||
task_id: result.task_id,
|
||||
status: 'processing',
|
||||
stage: result.stage || 'locating',
|
||||
logs: ['CellData 任务已提交,等待处理日志...']
|
||||
};
|
||||
taskMode.value = 'cell_data';
|
||||
startPolling(result.task_id);
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : 'CellData 处理启动失败');
|
||||
} finally {
|
||||
cellDataStarting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function startUploadedTask(taskId: string) {
|
||||
await apiPost('/api/process/start', { task_id: taskId });
|
||||
taskMode.value = 'local';
|
||||
@@ -566,7 +613,7 @@ async function checkActiveTask() {
|
||||
try {
|
||||
activeTask.value = await apiGet<ActiveTask>('/api/task/status');
|
||||
if (activeTask.value.has_active && activeTask.value.task_id) {
|
||||
taskMode.value = 'unknown';
|
||||
taskMode.value = activeTask.value.task_id.startsWith('cell_data_') ? 'cell_data' : 'unknown';
|
||||
startPolling(activeTask.value.task_id);
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -589,7 +636,10 @@ function stopPolling() {
|
||||
|
||||
async function poll(taskId: string) {
|
||||
try {
|
||||
taskStatus.value = await apiPost<TaskStatus>('/api/process/status', { task_id: taskId });
|
||||
const statusUrl = taskMode.value === 'cell_data'
|
||||
? '/api/cell-data/process/status'
|
||||
: '/api/process/status';
|
||||
taskStatus.value = await apiPost<TaskStatus>(statusUrl, { task_id: taskId });
|
||||
if (activeTask.value?.has_active && taskStatus.value.stage) {
|
||||
activeTask.value = { ...activeTask.value, stage: taskStatus.value.stage };
|
||||
}
|
||||
|
||||
@@ -291,8 +291,64 @@
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
<n-form-item label="远程目录">
|
||||
<n-input v-model:value="cellDataRemoteForm.remote_dir" placeholder="/CellData" />
|
||||
<n-input v-model:value="cellDataRemoteForm.remote_dir" placeholder="/" />
|
||||
</n-form-item>
|
||||
<n-form-item label="扫描路径">
|
||||
<div class="cell-data-path-editor">
|
||||
<div class="cell-data-path-header">
|
||||
<span class="form-hint">支持路径模板和多目录组合</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>
|
||||
<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-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.file_name_regex" />
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
<n-gi :span="4">
|
||||
<n-form-item label="时间戳正则">
|
||||
<n-input v-model:value="cellDataRegexForm.file_time_regex" />
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
<n-form-item label="映射规则 JSON">
|
||||
<n-input
|
||||
v-model:value="cellDataMappingText"
|
||||
type="textarea"
|
||||
:autosize="{ minRows: 8, maxRows: 16 }"
|
||||
spellcheck="false"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-space justify="end" size="small">
|
||||
<n-button :loading="validatingCellDataSettings" @click="validateCellDataSettings">校验 JSON</n-button>
|
||||
<n-button @click="restoreDefaultCellDataMapping">恢复默认映射</n-button>
|
||||
<n-button type="primary" :loading="savingCellDataSettings" @click="saveCellDataSettings">保存规则</n-button>
|
||||
</n-space>
|
||||
<n-form-item label="启用 CellData 数据源">
|
||||
<n-switch v-model:value="cellDataRemoteForm.enabled" />
|
||||
</n-form-item>
|
||||
@@ -611,13 +667,47 @@
|
||||
|
||||
</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>{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>
|
||||
</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 { CloseOutline, CloudDownloadOutline, CloudUploadOutline, InformationCircleOutline } from '@vicons/ionicons5';
|
||||
|
||||
import { apiGet, apiPost, downloadGet, upload } from '../api/client';
|
||||
import type {
|
||||
@@ -658,6 +748,8 @@ 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');
|
||||
@@ -672,6 +764,10 @@ const newSheetFilter = ref('');
|
||||
const extractFields = ref<ExtractFieldConfig[]>([]);
|
||||
const fieldSearch = ref('');
|
||||
const newSchedulerDirectory = ref('');
|
||||
const newCellDataScanPath = ref('');
|
||||
const cellDataHelpVisible = ref(false);
|
||||
const cellDataScanPaths = ref<string[]>([]);
|
||||
const cellDataMappingText = ref('');
|
||||
const schedulerStatus = ref<RemoteSchedulerStatus | null>(null);
|
||||
const newExtractValues = reactive<Record<number, string>>({});
|
||||
|
||||
@@ -709,7 +805,7 @@ const cellDataRemoteForm = reactive<RemoteDataConfig>({
|
||||
port: 22,
|
||||
user: '',
|
||||
passwd: '',
|
||||
remote_dir: '/CellData',
|
||||
remote_dir: '/',
|
||||
passive: true,
|
||||
timeout: 30,
|
||||
auto_delete_source: false,
|
||||
@@ -721,6 +817,12 @@ const cellDataRemoteForm = reactive<RemoteDataConfig>({
|
||||
}
|
||||
});
|
||||
|
||||
const cellDataRegexForm = reactive({
|
||||
year_dir_regex: '(?P<year>\\\\d{4})年',
|
||||
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: '',
|
||||
@@ -885,8 +987,13 @@ async function loadConfig() {
|
||||
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, '/CellData'));
|
||||
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.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$)';
|
||||
cellDataMappingText.value = JSON.stringify(config.cell_data?.mapping || {}, null, 2);
|
||||
Object.assign(historyRetentionForm, normalizeHistoryRetentionConfig(config.history_retention));
|
||||
sheetFilters.value = [...config.sheet_filter];
|
||||
extractFields.value = normalizeExtractFields(config.extract_fields);
|
||||
@@ -1021,7 +1128,7 @@ function getCellDataRemotePayload(): RemoteDataConfig {
|
||||
port: cellDataRemoteForm.port || (cellDataRemoteForm.protocol === 'sftp' ? 22 : 21),
|
||||
user: cellDataRemoteForm.user.trim(),
|
||||
passwd: cellDataRemoteForm.passwd || '',
|
||||
remote_dir: cellDataRemoteForm.remote_dir.trim() || '/CellData',
|
||||
remote_dir: cellDataRemoteForm.remote_dir.trim() || '/',
|
||||
passive: cellDataRemoteForm.passive,
|
||||
timeout: cellDataRemoteForm.timeout || 30,
|
||||
auto_delete_source: false,
|
||||
@@ -1081,6 +1188,75 @@ async function saveCellDataRemote() {
|
||||
}
|
||||
}
|
||||
|
||||
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() {
|
||||
let mapping: Record<string, unknown>;
|
||||
try {
|
||||
mapping = JSON.parse(cellDataMappingText.value || '{}') as Record<string, unknown>;
|
||||
} catch {
|
||||
throw new Error('映射规则不是有效 JSON');
|
||||
}
|
||||
return {
|
||||
scan_paths: normalizeCellDataScanPaths(cellDataScanPaths.value),
|
||||
year_dir_regex: cellDataRegexForm.year_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');
|
||||
cellDataMappingText.value = JSON.stringify(mapping, null, 2);
|
||||
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);
|
||||
cellDataMappingText.value = JSON.stringify(payload.mapping, null, 2);
|
||||
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 addDirectoryMapping() {
|
||||
const path = normalizeDirectoryText(newDirectoryMapping.path);
|
||||
const table = newDirectoryMapping.table.trim();
|
||||
@@ -1413,6 +1589,15 @@ function normalizeCellDataMysqlConfig(config: CellDataConfig['mysql'] | undefine
|
||||
};
|
||||
}
|
||||
|
||||
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>();
|
||||
|
||||
@@ -559,6 +559,35 @@ select {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.cell-data-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-top: 16px;
|
||||
padding: 16px 18px;
|
||||
background: var(--td-bg-color-container);
|
||||
border: 1px solid var(--td-border-color-light);
|
||||
border-radius: var(--td-radius-large);
|
||||
}
|
||||
|
||||
.cell-data-card h3,
|
||||
.cell-data-card p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.cell-data-card h3 {
|
||||
color: var(--td-text-color-primary);
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.cell-data-card p {
|
||||
margin-top: 4px;
|
||||
color: var(--td-text-color-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.license-dialog-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -1482,6 +1511,56 @@ select {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.cell-data-path-editor,
|
||||
.cell-data-path-list {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.cell-data-path-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.cell-data-path-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.cell-data-help-scroll {
|
||||
max-height: 360px;
|
||||
}
|
||||
|
||||
.cell-data-help-section {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
padding-bottom: 14px;
|
||||
}
|
||||
|
||||
.cell-data-help-section + .cell-data-help-section {
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--td-border-color-light);
|
||||
}
|
||||
|
||||
.cell-data-help-section h4,
|
||||
.cell-data-help-section p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.cell-data-help-section code {
|
||||
display: block;
|
||||
overflow-x: auto;
|
||||
padding: 6px 8px;
|
||||
color: var(--td-text-color-primary);
|
||||
background: var(--td-bg-color-secondarycontainer);
|
||||
border-radius: var(--td-radius-small);
|
||||
}
|
||||
|
||||
.settings-rules-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(280px, 0.72fr) minmax(0, 1.4fr);
|
||||
|
||||
@@ -149,6 +149,11 @@ export interface CellDataConfig {
|
||||
passwd?: string;
|
||||
dbname: string;
|
||||
};
|
||||
scan_paths: string[];
|
||||
year_dir_regex: string;
|
||||
file_name_regex: string;
|
||||
file_time_regex: string;
|
||||
mapping: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface RemoteAutoSchedulerConfig {
|
||||
|
||||
Reference in New Issue
Block a user