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"
>
+ 容量数据
+
刷新 CellData 数据。
-包含 700M、2.6G 等目录
+支持 ZIP、Excel 和 CSV 文件。
+可拖拽文件或文件夹,也可点击卡片选择文件。
+使用系统设置中的数据源配置下载并处理。
+请选择包含频段目录的文件夹,例如:
+300表/700M/Result_300_*.zip
+ 300表/2.6G/Result_300_*.zip
+ 只处理文件名以 Result_300_ 开头的 ZIP。
+每个频段目录只取文件名末尾时间戳最新的 ZIP。
+识别以下 CSV 文件名前缀:
+LTE_ITBBU_CellInfo
+ LTE_SDR_CellInfo
+ NR_CellInfo
+