diff --git a/Configure.json b/Configure.json index 989c6c9..e8ba36d 100644 --- a/Configure.json +++ b/Configure.json @@ -1,5 +1,5 @@ { - "Update": "2026/06/25 03:58:45", + "Update": "2026/06/25 05:17:26", "SourceType": "sftp", "WarehouseType": "mysql", "MySQL_DBInfo": { @@ -206,11 +206,11 @@ }, "CellData": { "remote_data": { - "enabled": false, + "enabled": true, "protocol": "sftp", - "host": "", - "port": 22, - "user": "", + "host": "127.0.0.1", + "port": 2022, + "user": "nixevol", "remote_dir": "/", "passive": true, "timeout": 30, @@ -221,7 +221,7 @@ "expected_directories": [], "week_offset": 0 }, - "passwd": "" + "passwd": "242520" }, "mysql": { "host": "127.0.0.1", @@ -234,10 +234,111 @@ "/网优日常优化数据文档/日常性能报表/{maxyear}年/300表" ], "year_dir_regex": "(?P\\d{4})年", - "month_dir_regex": "(?P\\d{1,2})月", - "day_dir_regex": "(?P\\d{1,2})日", "file_name_regex": "^Result_300_.*\\.zip$", - "file_time_regex": "(?P\\d{14})(?=\\.zip$)" + "file_time_regex": "(?P\\d{14})(?=\\.zip$)", + "mapping": { + "target_table": "cellinfo", + "key": { + "field": "CGI", + "expr": "{PLMN}-{eNodeBID}-{CellID}" + }, + "sources": [ + { + "band": "2.6G", + "file_prefix": "LTE_ITBBU_CellInfo", + "fields": { + "eNodeBID": "eNBId", + "CellID": "cellLocalId", + "PLMN": "plmn", + "基站名称": "eNBName", + "小区名称": "CellName", + "频点": "frequency", + "带宽": "bandWidth", + "制式": "radioMode", + "功率": "cpSpeRefSigPwr", + "网络": { + "value": "4G" + } + } + }, + { + "band": "2.6G", + "file_prefix": "LTE_SDR_CellInfo", + "fields": { + "eNodeBID": "eNBId", + "CellID": "cellLocalId", + "PLMN": "plmn", + "基站名称": "eNBName", + "小区名称": "CellName", + "频点": "frequency", + "带宽": "bandWidth", + "制式": "radioMode", + "功率": "cpSpeRefSigPwr", + "网络": { + "value": "4G" + } + } + }, + { + "band": "2.6G", + "file_prefix": "NR_CellInfo", + "fields": { + "eNodeBID": "gNBId", + "CellID": "cellLocalId", + "PLMN": "plmn", + "基站名称": "gNBName", + "小区名称": "CellName", + "频点": "ssbFrequency", + "带宽": "carrierBandwidth", + "制式": { + "value": "2.6G" + }, + "功率": "powerPerRERef", + "网络": { + "value": "5G" + } + } + }, + { + "band": "700M", + "file_prefix": "LTE_ITBBU_CellInfo", + "fields": { + "eNodeBID": "eNBId", + "CellID": "cellLocalId", + "PLMN": "plmn", + "基站名称": "eNBName", + "小区名称": "CellName", + "频点": "frequency", + "带宽": "bandWidth", + "制式": "radioMode", + "功率": "cpSpeRefSigPwr", + "网络": { + "value": "4G" + } + } + }, + { + "band": "700M", + "file_prefix": "NR_CellInfo", + "fields": { + "eNodeBID": "gNBId", + "CellID": "cellLocalId", + "PLMN": "plmn", + "基站名称": "gNBName", + "小区名称": "CellName", + "频点": "ssbFrequency", + "带宽": "carrierBandwidth", + "制式": { + "value": "700M" + }, + "功率": "powerPerRERef", + "网络": { + "value": "5G" + } + } + } + ] + } }, "HistoryRetention": { "enabled": true, diff --git a/docs/project_context.md b/docs/project_context.md index 377e41f..5f5560b 100644 --- a/docs/project_context.md +++ b/docs/project_context.md @@ -802,3 +802,9 @@ - CellData 数据源卡片中的扫描路径区域改为全宽列表布局,说明文字、说明按钮、路径输入、删除按钮和添加输入保持对齐,减少左侧拥挤。 - 验证:`frontend` `npm run build` 通过;构建产物已清理。 + +## 2026-06-25:CellData 映射支持图形化编辑 + +- CellData「规则设置」弹窗中的映射规则增加“图形化 / JSON”切换。图形化模式可维护目标表、主键字段、主键表达式、来源目录、CSV 前缀和字段映射;字段映射支持“CSV 字段”和“固定值”两种模式。 +- JSON 模式仍使用 Monaco 编辑器,支持格式化、校验、恢复默认;两种模式共用同一份 `CellData.mapping` JSON,切换时自动互转。 +- 验证:`frontend` `npm run build` 通过;构建产物已清理。 diff --git a/frontend/src/components/SettingsPanel.vue b/frontend/src/components/SettingsPanel.vue index 40698fa..26b9d43 100644 --- a/frontend/src/components/SettingsPanel.vue +++ b/frontend/src/components/SettingsPanel.vue @@ -717,14 +717,65 @@
- 映射规则 JSON + 映射规则 - 格式化 + + 图形化 + JSON + + 格式化 校验 恢复默认
-
+
+
+
+ + + + + + + + + +
+
+
+
+ 来源 {{ sourceIndex + 1 }} + + + +
+
+ + + + + + +
+
+
+ + + + + + +
+
+ 添加字段 +
+
+ 添加来源 +
@@ -769,6 +820,29 @@ interface ExtractFieldConfig { [key: string]: unknown; } +type MappingFieldMode = 'field' | 'value'; + +interface MappingFieldForm { + target: string; + mode: MappingFieldMode; + value: string; +} + +interface MappingSourceForm { + band: string; + file_prefix: string; + 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; @@ -816,8 +890,17 @@ const cellDataHelpVisible = ref(false); const cellDataSettingsVisible = ref(false); const cellDataScanPaths = ref([]); const cellDataMappingText = ref(''); +const cellDataMappingMode = ref<'form' | 'json'>('form'); const cellDataEditorHost = ref(null); const cellDataEditor = shallowRef(null); +const cellDataMappingForm = reactive({ + target_table: 'cellinfo', + key: { + field: 'CGI', + expr: '{PLMN}-{eNodeBID}-{CellID}' + }, + sources: [] +}); const schedulerStatus = ref(null); const newExtractValues = reactive>({}); @@ -935,6 +1018,10 @@ 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(); @@ -1048,7 +1135,8 @@ async function loadConfig() { cellDataRegexForm.day_dir_regex = config.cell_data?.day_dir_regex || '(?P\\\\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\\\\d{14})(?=\\\\.zip$)'; - cellDataMappingText.value = JSON.stringify(config.cell_data?.mapping || {}, null, 2); + 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); @@ -1259,6 +1347,9 @@ function removeCellDataScanPath(index: number) { } function getCellDataSettingsPayload() { + if (cellDataMappingMode.value === 'form') { + syncCellDataMappingTextFromForm(); + } let mapping: Record; try { mapping = JSON.parse(getCellDataMappingText() || '{}') as Record; @@ -1292,6 +1383,7 @@ async function restoreDefaultCellDataMapping() { try { const mapping = await apiGet>('/api/config/cell-data/mapping/default'); setCellDataMappingText(JSON.stringify(mapping, null, 2)); + syncCellDataMappingFormFromText(); message.success('已恢复默认映射'); } catch (error) { message.error(error instanceof Error ? error.message : '恢复默认映射失败'); @@ -1305,6 +1397,7 @@ async function saveCellDataSettings() { const result = await apiPost('/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) { @@ -1316,7 +1409,9 @@ async function saveCellDataSettings() { function openCellDataSettingsModal() { cellDataSettingsVisible.value = true; - void nextTick(createCellDataEditor); + if (cellDataMappingMode.value === 'json') { + void nextTick(createCellDataEditor); + } } function createCellDataEditor() { @@ -1364,6 +1459,96 @@ function setCellDataMappingText(value: string) { } } +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); + } catch { + applyCellDataMappingForm({}); + } +} + +function syncCellDataMappingTextFromForm() { + setCellDataMappingText(JSON.stringify(mappingFormToJson(), null, 2)); +} + +function applyCellDataMappingForm(mapping: Record) { + cellDataMappingForm.target_table = String(mapping.target_table || 'cellinfo'); + const key = typeof mapping.key === 'object' && mapping.key !== null ? mapping.key as Record : {}; + 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; + const fields = typeof source.fields === 'object' && source.fields !== null ? source.fields as Record : {}; + cellDataMappingForm.sources.push({ + band: String(source.band || ''), + file_prefix: String(source.file_prefix || ''), + 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).value ?? '') }; + } + return { target, mode: 'field', value: String(rule ?? '') }; + }) + }); + } +} + +function mappingFormToJson(): Record { + 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 = {}; + 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: '', fields: [] }); +} + +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); diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 52eeb63..29caa05 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -1667,6 +1667,51 @@ select { border-radius: var(--td-radius-default); } +.cell-data-mapping-form, +.cell-data-source-list, +.cell-data-source-card { + display: grid; + gap: 10px; +} + +.cell-data-mapping-key { + display: grid; + grid-template-columns: minmax(140px, 0.8fr) minmax(120px, 0.6fr) minmax(240px, 1.4fr); + gap: 12px; +} + +.cell-data-source-card { + padding: 12px; + background: var(--td-bg-color-secondarycontainer); + border: 1px solid var(--td-border-color-light); + border-radius: var(--td-radius-default); +} + +.cell-data-source-header, +.cell-data-source-meta, +.cell-data-field-row { + display: grid; + align-items: center; + gap: 8px; +} + +.cell-data-source-header { + grid-template-columns: minmax(0, 1fr) auto; +} + +.cell-data-source-meta { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.cell-data-field-list { + display: grid; + gap: 6px; +} + +.cell-data-field-row { + grid-template-columns: minmax(120px, 0.9fr) 96px minmax(160px, 1fr) auto; +} + .settings-rules-grid { display: grid; grid-template-columns: minmax(280px, 0.72fr) minmax(0, 1.4fr);