feat: 支持 CellData 文件夹上传处理
This commit is contained in:
@@ -5,13 +5,13 @@ from datetime import datetime
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from threading import Thread
|
from threading import Thread
|
||||||
|
|
||||||
from fastapi import APIRouter, Body, HTTPException
|
from fastapi import APIRouter, Body, File, HTTPException, UploadFile
|
||||||
|
|
||||||
from app import state
|
from app import state
|
||||||
from app.api.routers.task_runtime import set_task_stage
|
from app.api.routers.task_runtime import set_task_stage
|
||||||
from app.config import AppConfig, CACHE_DIR
|
from app.config import AppConfig, CACHE_DIR
|
||||||
from app.processor import ProcessLogger
|
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"])
|
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}
|
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")
|
@router.post("/api/cell-data/process/status")
|
||||||
async def get_cell_data_processing_status(task_id: str = Body(..., embed=True)):
|
async def get_cell_data_processing_status(task_id: str = Body(..., embed=True)):
|
||||||
if task_id in state.processing_tasks:
|
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:
|
finally:
|
||||||
state.reset_task_lock()
|
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()
|
||||||
|
|||||||
@@ -84,6 +84,24 @@ class CellDataProcessor:
|
|||||||
self._log(f"CellData 导入完成,共 {result.imported_rows} 行")
|
self._log(f"CellData 导入完成,共 {result.imported_rows} 行")
|
||||||
return result
|
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]:
|
def _select_latest_zip_files(self) -> list[SelectedZip]:
|
||||||
selected: list[SelectedZip] = []
|
selected: list[SelectedZip] = []
|
||||||
for template in self.config.scan_paths:
|
for template in self.config.scan_paths:
|
||||||
@@ -182,6 +200,47 @@ class CellDataProcessor:
|
|||||||
ssh.close()
|
ssh.close()
|
||||||
return local_files
|
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
|
@staticmethod
|
||||||
def _download_result():
|
def _download_result():
|
||||||
from app.services.remote_download import RemoteDownloadResult
|
from app.services.remote_download import RemoteDownloadResult
|
||||||
@@ -194,15 +253,22 @@ class CellDataProcessor:
|
|||||||
rows_by_key: dict[str, dict[str, str]] = {}
|
rows_by_key: dict[str, dict[str, str]] = {}
|
||||||
for selected, local_path in local_files:
|
for selected, local_path in local_files:
|
||||||
with zipfile.ZipFile(local_path) as zf:
|
with zipfile.ZipFile(local_path) as zf:
|
||||||
for source in mapping["sources"]:
|
sources = list(mapping["sources"])
|
||||||
if source["band"] != selected.band:
|
for info in zf.infolist():
|
||||||
|
name = Path(info.filename).name
|
||||||
|
if not name.lower().endswith(".csv"):
|
||||||
continue
|
continue
|
||||||
for info in zf.infolist():
|
matching_sources = [
|
||||||
name = Path(info.filename).name
|
source
|
||||||
if not name.lower().endswith(".csv"):
|
for source in sources
|
||||||
continue
|
if name.startswith(source["file_prefix"]) and (not selected.band or source["band"] == selected.band)
|
||||||
if not name.startswith(source["file_prefix"]):
|
]
|
||||||
continue
|
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)
|
raw = zf.read(info.filename)
|
||||||
text = self._decode_csv(raw)
|
text = self._decode_csv(raw)
|
||||||
reader = csv.DictReader(text.splitlines())
|
reader = csv.DictReader(text.splitlines())
|
||||||
|
|||||||
@@ -753,3 +753,16 @@
|
|||||||
- 接口:新增 `app/api/routers/cell_data.py`,提供 `POST /api/cell-data/process/start` 与 `/status`,数据处理页新增独立 CellData 卡片,可只刷新 CellData,不跑容量处理。
|
- 接口:新增 `app/api/routers/cell_data.py`,提供 `POST /api/cell-data/process/start` 与 `/status`,数据处理页新增独立 CellData 卡片,可只刷新 CellData,不跑容量处理。
|
||||||
- 接入:本地上传、远程手动和自动调度入口都会在容量处理前调用 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`)。
|
- 验证:`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` 通过;构建产物已清理。
|
||||||
|
|||||||
@@ -15,6 +15,20 @@
|
|||||||
@dragleave="onDragLeave"
|
@dragleave="onDragLeave"
|
||||||
@drop.prevent.stop="onDrop"
|
@drop.prevent.stop="onDrop"
|
||||||
>
|
>
|
||||||
|
<span class="upload-card-label">容量数据</span>
|
||||||
|
<n-button
|
||||||
|
quaternary
|
||||||
|
circle
|
||||||
|
size="small"
|
||||||
|
class="upload-help-button"
|
||||||
|
aria-label="查看容量数据说明"
|
||||||
|
title="查看说明"
|
||||||
|
@click.stop="capacityHelpVisible = true"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<n-icon><InformationCircleOutline /></n-icon>
|
||||||
|
</template>
|
||||||
|
</n-button>
|
||||||
<div class="upload-icon">
|
<div class="upload-icon">
|
||||||
<n-icon size="24"><FolderOpenOutline /></n-icon>
|
<n-icon size="24"><FolderOpenOutline /></n-icon>
|
||||||
</div>
|
</div>
|
||||||
@@ -59,20 +73,66 @@
|
|||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section v-if="!taskInProgress" class="cell-data-card">
|
<section
|
||||||
<div>
|
v-if="!taskInProgress"
|
||||||
<h3>CellData</h3>
|
class="cell-data-upload-zone"
|
||||||
<p>刷新 CellData 数据。</p>
|
:class="{ dragover: isCellDataDragging, disabled: cellDataStarting }"
|
||||||
</div>
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
:aria-disabled="cellDataStarting"
|
||||||
|
@click="cellDataFolderInput?.click()"
|
||||||
|
@keydown.enter.prevent="cellDataFolderInput?.click()"
|
||||||
|
@keydown.space.prevent="cellDataFolderInput?.click()"
|
||||||
|
@dragenter.prevent.stop="onCellDataDragEnter"
|
||||||
|
@dragover.prevent.stop="onCellDataDragOver"
|
||||||
|
@dragleave="onCellDataDragLeave"
|
||||||
|
@drop.prevent.stop="onCellDataDrop"
|
||||||
|
>
|
||||||
|
<span class="upload-card-label">CellData</span>
|
||||||
<n-button
|
<n-button
|
||||||
type="primary"
|
quaternary
|
||||||
secondary
|
circle
|
||||||
:loading="cellDataStarting"
|
size="small"
|
||||||
:disabled="working || remoteStarting || cellDataStarting"
|
class="upload-help-button"
|
||||||
@click="startCellDataProcessing"
|
aria-label="查看 CellData 文件说明"
|
||||||
|
title="查看说明"
|
||||||
|
@click.stop="cellDataHelpVisible = true"
|
||||||
>
|
>
|
||||||
刷新 CellData
|
<template #icon>
|
||||||
|
<n-icon><InformationCircleOutline /></n-icon>
|
||||||
|
</template>
|
||||||
</n-button>
|
</n-button>
|
||||||
|
<div class="upload-icon">
|
||||||
|
<n-icon size="24"><FolderOpenOutline /></n-icon>
|
||||||
|
</div>
|
||||||
|
<div class="cell-data-upload-text">
|
||||||
|
<h3>拖拽文件夹到这里</h3>
|
||||||
|
<p>包含 700M、2.6G 等目录</p>
|
||||||
|
</div>
|
||||||
|
<div class="upload-zone-actions">
|
||||||
|
<span class="upload-hint">或者点击选择文件夹</span>
|
||||||
|
<n-space size="small" @click.stop @keydown.stop>
|
||||||
|
<n-button
|
||||||
|
type="primary"
|
||||||
|
secondary
|
||||||
|
:loading="cellDataStarting"
|
||||||
|
:disabled="working || remoteStarting || cellDataStarting"
|
||||||
|
@click="startCellDataProcessing"
|
||||||
|
>
|
||||||
|
<template #icon><n-icon><CloudDownloadOutline /></n-icon></template>
|
||||||
|
远程刷新
|
||||||
|
</n-button>
|
||||||
|
</n-space>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
ref="cellDataFolderInput"
|
||||||
|
class="hidden-input"
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
webkitdirectory
|
||||||
|
directory
|
||||||
|
@change="pickCellDataFiles"
|
||||||
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div v-if="!taskInProgress && files.length > 0" class="file-list">
|
<div v-if="!taskInProgress && files.length > 0" class="file-list">
|
||||||
@@ -193,6 +253,58 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</n-modal>
|
</n-modal>
|
||||||
|
|
||||||
|
<n-modal
|
||||||
|
v-model:show="capacityHelpVisible"
|
||||||
|
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>支持 ZIP、Excel 和 CSV 文件。</p>
|
||||||
|
</section>
|
||||||
|
<section class="cell-data-help-section">
|
||||||
|
<h4>上传方式</h4>
|
||||||
|
<p>可拖拽文件或文件夹,也可点击卡片选择文件。</p>
|
||||||
|
</section>
|
||||||
|
<section class="cell-data-help-section">
|
||||||
|
<h4>远程处理</h4>
|
||||||
|
<p>使用系统设置中的数据源配置下载并处理。</p>
|
||||||
|
</section>
|
||||||
|
</n-scrollbar>
|
||||||
|
</n-modal>
|
||||||
|
|
||||||
|
<n-modal
|
||||||
|
v-model:show="cellDataHelpVisible"
|
||||||
|
preset="card"
|
||||||
|
class="cell-data-help-modal"
|
||||||
|
title="CellData 文件说明"
|
||||||
|
:style="{ width: 'min(520px, calc(100vw - 32px))' }"
|
||||||
|
>
|
||||||
|
<n-scrollbar class="cell-data-help-scroll">
|
||||||
|
<section class="cell-data-help-section">
|
||||||
|
<h4>目录结构</h4>
|
||||||
|
<p>请选择包含频段目录的文件夹,例如:</p>
|
||||||
|
<code>300表/700M/Result_300_*.zip</code>
|
||||||
|
<code>300表/2.6G/Result_300_*.zip</code>
|
||||||
|
</section>
|
||||||
|
<section class="cell-data-help-section">
|
||||||
|
<h4>文件要求</h4>
|
||||||
|
<p>只处理文件名以 <strong>Result_300_</strong> 开头的 ZIP。</p>
|
||||||
|
<p>每个频段目录只取文件名末尾时间戳最新的 ZIP。</p>
|
||||||
|
</section>
|
||||||
|
<section class="cell-data-help-section">
|
||||||
|
<h4>ZIP 内容</h4>
|
||||||
|
<p>识别以下 CSV 文件名前缀:</p>
|
||||||
|
<code>LTE_ITBBU_CellInfo</code>
|
||||||
|
<code>LTE_SDR_CellInfo</code>
|
||||||
|
<code>NR_CellInfo</code>
|
||||||
|
</section>
|
||||||
|
</n-scrollbar>
|
||||||
|
</n-modal>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -204,6 +316,7 @@ import {
|
|||||||
CloudUploadOutline,
|
CloudUploadOutline,
|
||||||
DocumentTextOutline,
|
DocumentTextOutline,
|
||||||
FolderOpenOutline,
|
FolderOpenOutline,
|
||||||
|
InformationCircleOutline,
|
||||||
RefreshOutline,
|
RefreshOutline,
|
||||||
TrashOutline
|
TrashOutline
|
||||||
} from '@vicons/ionicons5';
|
} from '@vicons/ionicons5';
|
||||||
@@ -238,12 +351,16 @@ const validExtensions = new Set(['.zip', '.xlsx', '.xls', '.csv']);
|
|||||||
const message = useMessage();
|
const message = useMessage();
|
||||||
const fileInput = ref<HTMLInputElement | null>(null);
|
const fileInput = ref<HTMLInputElement | null>(null);
|
||||||
const folderInput = ref<HTMLInputElement | null>(null);
|
const folderInput = ref<HTMLInputElement | null>(null);
|
||||||
|
const cellDataFolderInput = ref<HTMLInputElement | null>(null);
|
||||||
const files = ref<PickedFile[]>([]);
|
const files = ref<PickedFile[]>([]);
|
||||||
const uploadProgress = ref(0);
|
const uploadProgress = ref(0);
|
||||||
const working = ref(false);
|
const working = ref(false);
|
||||||
const remoteStarting = ref(false);
|
const remoteStarting = ref(false);
|
||||||
const cellDataStarting = ref(false);
|
const cellDataStarting = ref(false);
|
||||||
const isDragging = ref(false);
|
const isDragging = ref(false);
|
||||||
|
const isCellDataDragging = ref(false);
|
||||||
|
const capacityHelpVisible = ref(false);
|
||||||
|
const cellDataHelpVisible = ref(false);
|
||||||
const taskStatus = ref<TaskStatus | null>(null);
|
const taskStatus = ref<TaskStatus | null>(null);
|
||||||
const activeTask = ref<ActiveTask | null>(null);
|
const activeTask = ref<ActiveTask | null>(null);
|
||||||
const keepLatestLog = ref(false);
|
const keepLatestLog = ref(false);
|
||||||
@@ -320,6 +437,10 @@ onMounted(() => {
|
|||||||
if (folder) {
|
if (folder) {
|
||||||
folder.webkitdirectory = true;
|
folder.webkitdirectory = true;
|
||||||
}
|
}
|
||||||
|
const cellDataFolder = cellDataFolderInput.value as (HTMLInputElement & { webkitdirectory?: boolean }) | null;
|
||||||
|
if (cellDataFolder) {
|
||||||
|
cellDataFolder.webkitdirectory = true;
|
||||||
|
}
|
||||||
void checkActiveTask();
|
void checkActiveTask();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -367,6 +488,43 @@ async function onDrop(event: DragEvent) {
|
|||||||
addFiles(dropped);
|
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<DroppedFile[]> {
|
async function readDroppedFiles(dataTransfer: DataTransfer): Promise<DroppedFile[]> {
|
||||||
const result: DroppedFile[] = [];
|
const result: DroppedFile[] = [];
|
||||||
const items = Array.from(dataTransfer.items || []);
|
const items = Array.from(dataTransfer.items || []);
|
||||||
@@ -386,6 +544,13 @@ async function readDroppedFiles(dataTransfer: DataTransfer): Promise<DroppedFile
|
|||||||
return Array.from(dataTransfer.files || []).map(toDroppedFile);
|
return Array.from(dataTransfer.files || []).map(toDroppedFile);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasDroppedDirectory(dataTransfer: DataTransfer): boolean {
|
||||||
|
return Array.from(dataTransfer.items || []).some(item => {
|
||||||
|
if (item.kind !== 'file' || typeof item.webkitGetAsEntry !== 'function') return false;
|
||||||
|
return Boolean(item.webkitGetAsEntry()?.isDirectory);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function traverseEntry(entry: FileSystemEntry, parentPath: string): Promise<DroppedFile[]> {
|
async function traverseEntry(entry: FileSystemEntry, parentPath: string): Promise<DroppedFile[]> {
|
||||||
if (entry.isFile) {
|
if (entry.isFile) {
|
||||||
const fileEntry = entry as FileSystemFileEntry;
|
const fileEntry = entry as FileSystemFileEntry;
|
||||||
@@ -481,6 +646,11 @@ function isSupportedFile(path: string): boolean {
|
|||||||
return validExtensions.has(path.slice(dotIndex).toLowerCase());
|
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 {
|
function normalizePath(path: string): string {
|
||||||
return path.replace(/\\/g, '/');
|
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<UploadResponse>('/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) {
|
async function startUploadedTask(taskId: string) {
|
||||||
await apiPost('/api/process/start', { task_id: taskId });
|
await apiPost('/api/process/start', { task_id: taskId });
|
||||||
taskMode.value = 'local';
|
taskMode.value = 'local';
|
||||||
|
|||||||
+69
-9
@@ -497,6 +497,21 @@ select {
|
|||||||
transition: all var(--td-transition);
|
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:hover,
|
||||||
.upload-zone.dragover {
|
.upload-zone.dragover {
|
||||||
background: var(--td-brand-color-light);
|
background: var(--td-brand-color-light);
|
||||||
@@ -559,35 +574,80 @@ select {
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cell-data-card {
|
.cell-data-upload-zone {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
position: relative;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: center;
|
||||||
gap: 16px;
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
margin-top: 16px;
|
margin-top: 16px;
|
||||||
padding: 16px 18px;
|
padding: 24px 18px;
|
||||||
|
text-align: center;
|
||||||
background: var(--td-bg-color-container);
|
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);
|
border-radius: var(--td-radius-large);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--td-transition);
|
||||||
}
|
}
|
||||||
|
|
||||||
.cell-data-card h3,
|
.cell-data-upload-zone:hover,
|
||||||
.cell-data-card p {
|
.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;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cell-data-card h3 {
|
.cell-data-upload-text h3 {
|
||||||
color: var(--td-text-color-primary);
|
color: var(--td-text-color-primary);
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cell-data-card p {
|
.cell-data-upload-text p {
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
color: var(--td-text-color-secondary);
|
color: var(--td-text-color-secondary);
|
||||||
font-size: 13px;
|
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 {
|
.license-dialog-body {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
Reference in New Issue
Block a user