diff --git a/app/api/routers/cell_data.py b/app/api/routers/cell_data.py index 8491bc8..b571222 100644 --- a/app/api/routers/cell_data.py +++ b/app/api/routers/cell_data.py @@ -5,13 +5,13 @@ from datetime import datetime from pathlib import Path from threading import Thread -from fastapi import APIRouter, Body, HTTPException +from fastapi import APIRouter, Body, File, HTTPException, UploadFile from app import state from app.api.routers.task_runtime import set_task_stage from app.config import AppConfig, CACHE_DIR from app.processor import ProcessLogger -from app.services.cell_data import refresh_cell_data +from app.services.cell_data import CellDataProcessor, refresh_cell_data router = APIRouter(tags=["cell-data"]) @@ -57,6 +57,67 @@ async def start_cell_data_processing(): return {"success": True, "message": "CellData 处理已启动", "task_id": task_id, "stage": current_stage} +@router.post("/api/cell-data/process/upload") +async def upload_and_start_cell_data_processing(files: list[UploadFile] = File(...)): + if not files: + raise HTTPException(status_code=400, detail="没有上传文件") + if state.global_task_lock["locked"]: + raise HTTPException(status_code=409, detail="已有任务在运行,请等待当前任务完成") + + task_id = "cell_data_" + datetime.now().strftime("%Y%m%d_%H%M%S") + work_dir = CACHE_DIR / task_id + upload_dir = work_dir / "uploads" + upload_dir.mkdir(parents=True, exist_ok=True) + saved_count = 0 + for file in files: + if not file.filename: + continue + target = upload_dir / file.filename.replace("\\", "/") + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(await file.read()) + saved_count += 1 + if saved_count == 0: + raise HTTPException(status_code=400, detail="没有有效上传文件") + + logs: list[str] = [] + current_stage = "parsing" + + def log_callback(message: str) -> None: + logs.append(message) + set_task_stage(task_id, current_stage, logs) + + def stage_callback(stage: str) -> None: + nonlocal current_stage + current_stage = stage + set_task_stage(task_id, current_stage, logs) + + logger = ProcessLogger( + log_file=work_dir / "log.txt", + callback=log_callback, + stage_callback=stage_callback, + ) + app_config = state.current_config() + state.processing_tasks[task_id] = {"logs": [], "status": "processing", "stage": current_stage} + state.global_task_lock.update( + { + "locked": True, + "task_id": task_id, + "stage": current_stage, + "started_at": datetime.now().isoformat(), + } + ) + + thread = Thread(target=_run_uploaded_cell_data_processing, args=(task_id, upload_dir, work_dir, logger, app_config), daemon=True) + thread.start() + return { + "success": True, + "message": "CellData 处理已启动", + "task_id": task_id, + "stage": current_stage, + "file_count": saved_count, + } + + @router.post("/api/cell-data/process/status") async def get_cell_data_processing_status(task_id: str = Body(..., embed=True)): if task_id in state.processing_tasks: @@ -101,3 +162,33 @@ def _run_cell_data_processing(task_id: str, work_dir: Path, logger: ProcessLogge } finally: state.reset_task_lock() + + +def _run_uploaded_cell_data_processing(task_id: str, upload_dir: Path, work_dir: Path, logger: ProcessLogger, app_config: AppConfig) -> None: + started = time.time() + try: + result = CellDataProcessor(app_config, work_dir, logger).run_local(upload_dir) + elapsed = round(time.time() - started, 2) + state.processing_tasks[task_id] = { + "logs": logger.get_logs(), + "status": "completed", + "stage": "completed", + "elapsed_time": elapsed, + "result": { + "selected_files": result.selected_files, + "parsed_rows": result.parsed_rows, + "imported_rows": result.imported_rows, + "skipped_rows": result.skipped_rows, + }, + } + except Exception as exc: + logger.error(str(exc)) + state.processing_tasks[task_id] = { + "logs": logger.get_logs(), + "status": "failed", + "stage": "failed", + "error": str(exc), + "elapsed_time": round(time.time() - started, 2), + } + finally: + state.reset_task_lock() diff --git a/app/services/cell_data.py b/app/services/cell_data.py index 0aecd0d..b162cef 100644 --- a/app/services/cell_data.py +++ b/app/services/cell_data.py @@ -84,6 +84,24 @@ class CellDataProcessor: self._log(f"CellData 导入完成,共 {result.imported_rows} 行") return result + def run_local(self, upload_root: Path) -> CellDataResult: + self._set_stage("locating") + local_files = self._select_local_zip_files(upload_root) + if not local_files: + raise RuntimeError("未找到可处理的 CellData ZIP 文件") + self._log(f"已选择 {len(local_files)} 个 CellData ZIP 文件") + + self._set_stage("parsing") + result = CellDataResult(selected_files=len(local_files)) + rows = self._parse_zip_files(local_files, result) + if not rows: + raise RuntimeError("CellData ZIP 中未解析到有效数据") + + self._set_stage("importing") + result.imported_rows = self._replace_cellinfo(rows) + self._log(f"CellData 导入完成,共 {result.imported_rows} 行") + return result + def _select_latest_zip_files(self) -> list[SelectedZip]: selected: list[SelectedZip] = [] for template in self.config.scan_paths: @@ -182,6 +200,47 @@ class CellDataProcessor: ssh.close() return local_files + def _select_local_zip_files(self, upload_root: Path) -> list[tuple[SelectedZip, Path]]: + grouped: dict[str, list[Path]] = {} + for path in upload_root.rglob("*.zip"): + if not self.file_name_re.search(path.name): + continue + try: + parent = str(path.parent.relative_to(upload_root)).replace("\\", "/") + except ValueError: + parent = "" + grouped.setdefault("" if parent == "." else parent, []).append(path) + + selected: list[tuple[SelectedZip, Path]] = [] + for parent, paths in sorted(grouped.items(), key=lambda item: item[0]): + candidates = [] + for path in paths: + match = self.file_time_re.search(path.name) + if match: + candidates.append((match.group("timestamp"), path)) + if not candidates: + continue + timestamp, path = max(candidates, key=lambda item: item[0]) + band = Path(parent).name if parent else "" + selected_zip = SelectedZip( + scan_path=str(upload_root), + band=band, + remote_file=RemoteFileInfo( + path=str(path), + relative_path=str(path.relative_to(upload_root)).replace("\\", "/"), + parent=parent, + name=path.name, + size=path.stat().st_size, + ), + timestamp=timestamp, + ) + if not band: + self._log(f"未从目录名识别频段: {path.name}") + else: + self._log(f"{band}: {path.name}") + selected.append((selected_zip, path)) + return selected + @staticmethod def _download_result(): from app.services.remote_download import RemoteDownloadResult @@ -194,15 +253,22 @@ class CellDataProcessor: rows_by_key: dict[str, dict[str, str]] = {} for selected, local_path in local_files: with zipfile.ZipFile(local_path) as zf: - for source in mapping["sources"]: - if source["band"] != selected.band: + sources = list(mapping["sources"]) + for info in zf.infolist(): + name = Path(info.filename).name + if not name.lower().endswith(".csv"): continue - for info in zf.infolist(): - name = Path(info.filename).name - if not name.lower().endswith(".csv"): - continue - if not name.startswith(source["file_prefix"]): - continue + matching_sources = [ + source + for source in sources + if name.startswith(source["file_prefix"]) and (not selected.band or source["band"] == selected.band) + ] + if not matching_sources: + continue + if not selected.band and len(matching_sources) > 1: + self._log(f"跳过无法识别频段的文件: {name}") + continue + for source in matching_sources: raw = zf.read(info.filename) text = self._decode_csv(raw) reader = csv.DictReader(text.splitlines()) diff --git a/docs/project_context.md b/docs/project_context.md index 53ebe2e..44aefaf 100644 --- a/docs/project_context.md +++ b/docs/project_context.md @@ -753,3 +753,16 @@ - 接口:新增 `app/api/routers/cell_data.py`,提供 `POST /api/cell-data/process/start` 与 `/status`,数据处理页新增独立 CellData 卡片,可只刷新 CellData,不跑容量处理。 - 接入:本地上传、远程手动和自动调度入口都会在容量处理前调用 CellData 预处理;CellData 单独处理和容量处理共用现有全局任务锁,避免并发写库。 - 验证:`python -m compileall -q app` 通过;`frontend` `npm run build` 通过;远程定位可从最新年份 `2026年/300表` 选出 `2.6G` 与 `700M` 各自最新 `Result_300` ZIP;用 SFTP MCP 读取的小样本 ZIP 验证解析和入库,`celldata.cellinfo` 写入 1 行且中文字段正常(样本 `CGI=460-00-12683845-1`)。 + +## 2026-06-25:CellData 卡片支持本地上传处理 + +- 数据处理页 CellData 卡片改为与主上传区一致的拖拽/点击上传样式,支持拖入或选择多个 `Result_300_*.zip`,也支持选择文件夹上传。 +- 新增 `/api/cell-data/process/upload`,上传后复用 CellData 解析入库逻辑;若 ZIP 不在 `700M/2.6G` 等目录下且无法识别频段,会跳过无法唯一匹配的 CSV。 +- CellData 卡片仍保留「远程刷新」操作,用于按系统设置中的 CellData SFTP/FTP 配置拉取处理。 +- 验证:`frontend` `npm run build` 通过;构建产物已清理。 + +## 2026-06-25:数据处理卡片补说明按钮并规范 CellData 上传 + +- 数据处理页的容量数据卡片与 CellData 卡片左上角均显示数据类型,右上角均提供说明图标按钮,点击后以小弹窗展示所需文件格式和目录结构。 +- CellData 卡片改为点击/拖拽文件夹上传,不再提供单文件选择;直接拖入单个 ZIP 会提示选择包含 `Result_300` ZIP 的文件夹。 +- 验证:`frontend` `npm run build` 通过;构建产物已清理。 diff --git a/frontend/src/components/FileWorkflow.vue b/frontend/src/components/FileWorkflow.vue index 68a986a..a8c90ad 100644 --- a/frontend/src/components/FileWorkflow.vue +++ b/frontend/src/components/FileWorkflow.vue @@ -15,6 +15,20 @@ @dragleave="onDragLeave" @drop.prevent.stop="onDrop" > + 容量数据 + + +
@@ -59,20 +73,66 @@ /> -
-
-

CellData

-

刷新 CellData 数据。

-
+
+ CellData - 刷新 CellData + +
+ +
+
+

拖拽文件夹到这里

+

包含 700M、2.6G 等目录

+
+
+ 或者点击选择文件夹 + + + + 远程刷新 + + +
+
@@ -193,6 +253,58 @@
+ + + +
+

支持格式

+

支持 ZIP、Excel 和 CSV 文件。

+
+
+

上传方式

+

可拖拽文件或文件夹,也可点击卡片选择文件。

+
+
+

远程处理

+

使用系统设置中的数据源配置下载并处理。

+
+
+
+ + + +
+

目录结构

+

请选择包含频段目录的文件夹,例如:

+ 300表/700M/Result_300_*.zip + 300表/2.6G/Result_300_*.zip +
+
+

文件要求

+

只处理文件名以 Result_300_ 开头的 ZIP。

+

每个频段目录只取文件名末尾时间戳最新的 ZIP。

+
+
+

ZIP 内容

+

识别以下 CSV 文件名前缀:

+ LTE_ITBBU_CellInfo + LTE_SDR_CellInfo + NR_CellInfo +
+
+
@@ -204,6 +316,7 @@ import { CloudUploadOutline, DocumentTextOutline, FolderOpenOutline, + InformationCircleOutline, RefreshOutline, TrashOutline } from '@vicons/ionicons5'; @@ -238,12 +351,16 @@ const validExtensions = new Set(['.zip', '.xlsx', '.xls', '.csv']); const message = useMessage(); const fileInput = ref(null); const folderInput = ref(null); +const cellDataFolderInput = ref(null); const files = ref([]); const uploadProgress = ref(0); const working = ref(false); const remoteStarting = ref(false); const cellDataStarting = ref(false); const isDragging = ref(false); +const isCellDataDragging = ref(false); +const capacityHelpVisible = ref(false); +const cellDataHelpVisible = ref(false); const taskStatus = ref(null); const activeTask = ref(null); const keepLatestLog = ref(false); @@ -320,6 +437,10 @@ onMounted(() => { if (folder) { folder.webkitdirectory = true; } + const cellDataFolder = cellDataFolderInput.value as (HTMLInputElement & { webkitdirectory?: boolean }) | null; + if (cellDataFolder) { + cellDataFolder.webkitdirectory = true; + } void checkActiveTask(); }); @@ -367,6 +488,43 @@ async function onDrop(event: DragEvent) { addFiles(dropped); } +function onCellDataDragEnter() { + if (!cellDataStarting.value && !taskInProgress.value) { + isCellDataDragging.value = true; + } +} + +function onCellDataDragOver() { + if (!cellDataStarting.value && !taskInProgress.value) { + isCellDataDragging.value = true; + } +} + +function onCellDataDragLeave(event: DragEvent) { + const target = event.currentTarget as HTMLElement; + const related = event.relatedTarget as Node | null; + if (!related || !target.contains(related)) { + isCellDataDragging.value = false; + } +} + +async function onCellDataDrop(event: DragEvent) { + isCellDataDragging.value = false; + if (cellDataStarting.value || taskInProgress.value || !event.dataTransfer) return; + if (!hasDroppedDirectory(event.dataTransfer)) { + message.warning('请拖入包含 Result_300 ZIP 的文件夹'); + return; + } + await startCellDataUpload(await readDroppedFiles(event.dataTransfer)); +} + +async function pickCellDataFiles(event: Event) { + const input = event.target as HTMLInputElement; + const selected = Array.from(input.files || []).map(toDroppedFile); + input.value = ''; + await startCellDataUpload(selected); +} + async function readDroppedFiles(dataTransfer: DataTransfer): Promise { const result: DroppedFile[] = []; const items = Array.from(dataTransfer.items || []); @@ -386,6 +544,13 @@ async function readDroppedFiles(dataTransfer: DataTransfer): Promise { + if (item.kind !== 'file' || typeof item.webkitGetAsEntry !== 'function') return false; + return Boolean(item.webkitGetAsEntry()?.isDirectory); + }); +} + async function traverseEntry(entry: FileSystemEntry, parentPath: string): Promise { if (entry.isFile) { const fileEntry = entry as FileSystemFileEntry; @@ -481,6 +646,11 @@ function isSupportedFile(path: string): boolean { return validExtensions.has(path.slice(dotIndex).toLowerCase()); } +function isCellDataZip(path: string): boolean { + const name = normalizePath(path).split('/').pop() || ''; + return /^Result_300_.*\.zip$/i.test(name); +} + function normalizePath(path: string): string { return path.replace(/\\/g, '/'); } @@ -589,6 +759,46 @@ async function startCellDataProcessing() { } } +async function startCellDataUpload(items: DroppedFile[]) { + if (cellDataStarting.value || taskInProgress.value) return; + const accepted = items + .map(item => ({ ...item, path: normalizePath(item.path) })) + .filter(item => isCellDataZip(item.path)); + if (accepted.length === 0) { + message.warning('请选择 Result_300 ZIP 文件'); + return; + } + + const formData = new FormData(); + for (const item of accepted) { + formData.append('files', item.file, item.path); + } + + cellDataStarting.value = true; + try { + const result = await upload('/api/cell-data/process/upload', formData); + message.success(result.message || `已上传 ${accepted.length} 个文件`); + activeTask.value = { + has_active: true, + task_id: result.task_id, + stage: result.stage || 'parsing', + started_at: new Date().toISOString() + }; + taskStatus.value = { + task_id: result.task_id, + status: 'processing', + stage: result.stage || 'parsing', + 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'; diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 56f988b..bbb3874 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -497,6 +497,21 @@ select { transition: all var(--td-transition); } +.upload-card-label { + position: absolute; + top: 12px; + left: 16px; + color: var(--td-text-color-primary); + font-size: 13px; + font-weight: 600; +} + +.upload-help-button { + position: absolute; + top: 10px; + right: 10px; +} + .upload-zone:hover, .upload-zone.dragover { background: var(--td-brand-color-light); @@ -559,35 +574,80 @@ select { display: inline-flex; } -.cell-data-card { +.cell-data-upload-zone { display: flex; + position: relative; align-items: center; - justify-content: space-between; - gap: 16px; + justify-content: center; + flex-direction: column; + gap: 10px; margin-top: 16px; - padding: 16px 18px; + padding: 24px 18px; + text-align: center; background: var(--td-bg-color-container); - border: 1px solid var(--td-border-color-light); + border: 1px dashed var(--td-border-color); border-radius: var(--td-radius-large); + cursor: pointer; + transition: all var(--td-transition); } -.cell-data-card h3, -.cell-data-card p { +.cell-data-upload-zone:hover, +.cell-data-upload-zone.dragover { + background: var(--td-brand-color-light); + border-color: var(--td-brand-color); +} + +.cell-data-upload-zone.disabled { + cursor: not-allowed; + opacity: 0.72; +} + +.cell-data-upload-text h3, +.cell-data-upload-text p { margin: 0; } -.cell-data-card h3 { +.cell-data-upload-text h3 { color: var(--td-text-color-primary); font-size: 15px; font-weight: 600; } -.cell-data-card p { +.cell-data-upload-text p { margin-top: 4px; color: var(--td-text-color-secondary); font-size: 13px; } +.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); +} + .license-dialog-body { display: flex; flex-direction: column;