From 7c121b8948faab53dd8e3e27725b687972de75a8 Mon Sep 17 00:00:00 2001 From: chengkml Date: Sun, 3 May 2026 15:23:47 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E9=AB=98=E7=A8=8B=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E7=AE=A1=E7=90=86=E4=BA=A4=E4=BA=92=E5=B9=B6=E8=A1=A5?= =?UTF-8?q?=E5=85=85=E5=88=86=E6=9E=90=E8=BF=9B=E5=BA=A6=E4=B8=8E=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E6=98=8E=E7=BB=86=E8=83=BD=E5=8A=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: multica-agent --- api/app/api/v1/elevation.py | 22 ++ api/app/core/database.py | 46 +++ api/app/models/elevation.py | 5 + api/app/schemas/elevation.py | 33 ++ api/app/services/elevation_service.py | 167 +++++++++- web/src/app/admin/elevation/page.tsx | 451 +++++++++++++++++++------- web/src/types/auth.ts | 41 +++ 7 files changed, 640 insertions(+), 125 deletions(-) diff --git a/api/app/api/v1/elevation.py b/api/app/api/v1/elevation.py index fd4816c..bb00489 100644 --- a/api/app/api/v1/elevation.py +++ b/api/app/api/v1/elevation.py @@ -10,10 +10,12 @@ from ...schemas.elevation import ( ElevationApplyJobCreateResponse, ElevationApplyJobListResponse, ElevationApplyJobSummary, + ElevationDatasetAnalysisTaskStatusResponse, ElevationDatasetAnalyzeResponse, ElevationDatasetBatchImportResponse, ElevationDatasetDataImportResponse, ElevationDatasetCreateRequest, + ElevationDatasetFileListResponse, ElevationDatasetListResponse, ElevationDatasetPreviewResponse, ElevationDatasetSummary, @@ -24,9 +26,11 @@ from ...services.elevation_service import ( create_apply_job, create_dataset, delete_dataset, + get_dataset_analysis_task_status, get_job_by_id, import_dataset_data_files, import_datasets_from_csv, + list_dataset_files, list_datasets, list_jobs, preview_dataset, @@ -139,6 +143,24 @@ def preview_elevation_dataset( ) +@router.get("/datasets/{dataset_id}/files", response_model=ElevationDatasetFileListResponse) +def get_elevation_dataset_files( + dataset_id: str, + _: CurrentUser = Depends(require_any_permission("elevation.read", "elevation.manage")), + db: Session = Depends(get_db), +) -> ElevationDatasetFileListResponse: + return list_dataset_files(db, dataset_id=dataset_id) + + +@router.get("/datasets/{dataset_id}/analysis-task", response_model=ElevationDatasetAnalysisTaskStatusResponse) +def get_elevation_dataset_analysis_task_status( + dataset_id: str, + _: CurrentUser = Depends(require_any_permission("elevation.read", "elevation.manage")), + db: Session = Depends(get_db), +) -> ElevationDatasetAnalysisTaskStatusResponse: + return get_dataset_analysis_task_status(db, dataset_id=dataset_id) + + @router.get("/jobs", response_model=ElevationApplyJobListResponse) def get_elevation_jobs( line_id: str | None = Query(default=None), diff --git a/api/app/core/database.py b/api/app/core/database.py index fac2c49..08c64f9 100644 --- a/api/app/core/database.py +++ b/api/app/core/database.py @@ -230,6 +230,52 @@ def _ensure_elevation_dataset_column_compatibility() -> None: "Detected missing elevation_dataset.usage_status; added with default 'idle'.", ) + if "analysis_task_id" not in column_names: + connection.execute( + text("ALTER TABLE elevation_dataset ADD COLUMN IF NOT EXISTS analysis_task_id VARCHAR(128)"), + ) + logger.warning( + "Detected missing elevation_dataset.analysis_task_id; added nullable analysis task id column.", + ) + + if "analysis_status" not in column_names: + connection.execute( + text("ALTER TABLE elevation_dataset ADD COLUMN IF NOT EXISTS analysis_status VARCHAR(32)"), + ) + connection.execute( + text("UPDATE elevation_dataset SET analysis_status = 'not_started' WHERE analysis_status IS NULL"), + ) + connection.execute( + text("ALTER TABLE elevation_dataset ALTER COLUMN analysis_status SET NOT NULL"), + ) + logger.warning( + "Detected missing elevation_dataset.analysis_status; added with default 'not_started'.", + ) + + if "analysis_error_message" not in column_names: + connection.execute( + text("ALTER TABLE elevation_dataset ADD COLUMN IF NOT EXISTS analysis_error_message TEXT"), + ) + logger.warning( + "Detected missing elevation_dataset.analysis_error_message; added nullable analysis error column.", + ) + + if "analysis_started_at" not in column_names: + connection.execute( + text("ALTER TABLE elevation_dataset ADD COLUMN IF NOT EXISTS analysis_started_at TIMESTAMPTZ"), + ) + logger.warning( + "Detected missing elevation_dataset.analysis_started_at; added nullable analysis start time column.", + ) + + if "analysis_finished_at" not in column_names: + connection.execute( + text("ALTER TABLE elevation_dataset ADD COLUMN IF NOT EXISTS analysis_finished_at TIMESTAMPTZ"), + ) + logger.warning( + "Detected missing elevation_dataset.analysis_finished_at; added nullable analysis finish time column.", + ) + def get_db() -> Generator[Session, None, None]: db = SessionLocal() diff --git a/api/app/models/elevation.py b/api/app/models/elevation.py index 4ae55d9..31a89d5 100644 --- a/api/app/models/elevation.py +++ b/api/app/models/elevation.py @@ -37,6 +37,11 @@ class ElevationDataset(Base): resolution_m: Mapped[float | None] = mapped_column(Float) status: Mapped[str] = mapped_column(String(32), default="active", index=True) usage_status: Mapped[str] = mapped_column(String(32), default="idle", index=True) + analysis_task_id: Mapped[str | None] = mapped_column(String(128), index=True) + analysis_status: Mapped[str] = mapped_column(String(32), default="not_started", index=True) + analysis_error_message: Mapped[str | None] = mapped_column(Text) + analysis_started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + analysis_finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) sample_count: Mapped[int] = mapped_column(Integer, default=0) bbox_min_lon: Mapped[float | None] = mapped_column(Float) bbox_max_lon: Mapped[float | None] = mapped_column(Float) diff --git a/api/app/schemas/elevation.py b/api/app/schemas/elevation.py index 0a1acb9..b9e021c 100644 --- a/api/app/schemas/elevation.py +++ b/api/app/schemas/elevation.py @@ -29,6 +29,11 @@ class ElevationDatasetSummary(BaseModel): bbox_max_lon: float | None = None bbox_min_lat: float | None = None bbox_max_lat: float | None = None + analysis_task_id: str | None = None + analysis_status: str = "not_started" + analysis_error_message: str | None = None + analysis_started_at: datetime | None = None + analysis_finished_at: datetime | None = None notes: str | None = None create_date: datetime create_user: str | None = None @@ -137,6 +142,34 @@ class ElevationDatasetDataImportResponse(BaseModel): imported_files: list[str] = Field(default_factory=list) +class ElevationDatasetFileItem(BaseModel): + path: str + name: str + size: int + modified_at: datetime | None = None + mime_type: str | None = None + + +class ElevationDatasetFileListResponse(BaseModel): + dataset_id: str + dataset_code: str + dataset_dir: str + mount_code: str + items: list[ElevationDatasetFileItem] = Field(default_factory=list) + total: int = 0 + + +class ElevationDatasetAnalysisTaskStatusResponse(BaseModel): + dataset_id: str + dataset_code: str + task_id: str | None = None + status: Literal["queued", "running", "success", "failed", "unknown", "not_found"] = "not_found" + detail: str | None = None + started_at: datetime | None = None + finished_at: datetime | None = None + update_date: datetime | None = None + + class ElevationApplyJobSummary(BaseModel): id: str line_id: str diff --git a/api/app/services/elevation_service.py b/api/app/services/elevation_service.py index 6d6540b..668924f 100644 --- a/api/app/services/elevation_service.py +++ b/api/app/services/elevation_service.py @@ -25,12 +25,15 @@ from ..schemas.elevation import ( ElevationApplyJobCreateResponse, ElevationApplyJobListResponse, ElevationApplyJobSummary, + ElevationDatasetAnalysisTaskStatusResponse, ElevationDatasetAnalyzeResponse, ElevationDatasetBatchImportResponse, ElevationDatasetDataImportResponse, ElevationDatasetPreviewCell, ElevationDatasetPreviewDiagnostics, ElevationDatasetCreateRequest, + ElevationDatasetFileItem, + ElevationDatasetFileListResponse, ElevationDatasetListResponse, ElevationDatasetPreviewPoint, ElevationDatasetPreviewResponse, @@ -119,6 +122,11 @@ def serialize_dataset(item: ElevationDataset) -> ElevationDatasetSummary: bbox_max_lon=item.bbox_max_lon, bbox_min_lat=item.bbox_min_lat, bbox_max_lat=item.bbox_max_lat, + analysis_task_id=item.analysis_task_id, + analysis_status=item.analysis_status, + analysis_error_message=item.analysis_error_message, + analysis_started_at=item.analysis_started_at, + analysis_finished_at=item.analysis_finished_at, notes=item.notes, create_date=item.create_date, create_user=item.create_user, @@ -207,6 +215,50 @@ def get_dataset_by_code(db: Session, code: str) -> ElevationDataset | None: ).scalar_one_or_none() +def list_dataset_files( + db: Session, + *, + dataset_id: str, +) -> ElevationDatasetFileListResponse: + dataset = get_dataset_by_id(db, dataset_id) + if not dataset: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="高程数据集不存在") + + mount = _require_mount(db, dataset.mount_code) + driver = _build_driver_or_400(mount) + dataset_dir = _resolve_dataset_dir(dataset.code) + try: + entries = driver.list_dir(dataset_dir) + except StoragePathNotFoundError: + entries = [] + except StorageInvalidPathError as exc: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc + except StorageDriverError as exc: + raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc + + files = [ + ElevationDatasetFileItem( + path=item.path, + name=item.name, + size=max(0, int(item.size)), + modified_at=item.modified_at, + mime_type=item.mime_type, + ) + for item in entries + if not item.is_dir + ] + files.sort(key=lambda item: item.name.lower()) + + return ElevationDatasetFileListResponse( + dataset_id=dataset.id, + dataset_code=dataset.code, + dataset_dir=dataset_dir, + mount_code=dataset.mount_code, + items=files, + total=len(files), + ) + + def create_dataset( db: Session, payload: ElevationDatasetCreateRequest, @@ -445,6 +497,14 @@ def import_dataset_data_files( task = _dispatch_elevation_dataset_analysis_task(dataset_id=dataset.id, actor_user_id=actor.id) analysis_task_queued = True analysis_task_id = str(task.id) + dataset.analysis_task_id = analysis_task_id + dataset.analysis_status = "queued" + dataset.analysis_error_message = None + dataset.analysis_started_at = None + dataset.analysis_finished_at = None + dataset.update_date = utcnow() + dataset.update_user = actor.id + db.commit() except Exception as exc: # pragma: no cover warnings.append(f"自动分析任务派发失败:{exc}") @@ -469,6 +529,46 @@ def import_dataset_data_files( ) +def get_dataset_analysis_task_status( + db: Session, + *, + dataset_id: str, +) -> ElevationDatasetAnalysisTaskStatusResponse: + dataset = get_dataset_by_id(db, dataset_id) + if not dataset: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="高程数据集不存在") + + status_value = dataset.analysis_status or "not_started" + status_map = { + "queued": "queued", + "running": "running", + "success": "success", + "failed": "failed", + "unknown": "unknown", + "not_started": "not_found", + } + mapped_status = status_map.get(status_value, "unknown") + detail = dataset.analysis_error_message + if detail is None: + if mapped_status == "queued": + detail = "分析任务已提交,等待执行。" + elif mapped_status == "running": + detail = "分析任务执行中。" + elif mapped_status == "success": + detail = "最近一次分析已完成。" + + return ElevationDatasetAnalysisTaskStatusResponse( + dataset_id=dataset.id, + dataset_code=dataset.code, + task_id=dataset.analysis_task_id, + status=mapped_status, # type: ignore[arg-type] + detail=detail, + started_at=dataset.analysis_started_at, + finished_at=dataset.analysis_finished_at, + update_date=dataset.update_date, + ) + + def update_dataset( db: Session, dataset_id: str, @@ -552,13 +652,32 @@ def analyze_dataset( if item.status != "active": raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="高程数据集未启用") - stats, warnings = _analyze_dataset_content(db, item) + item.analysis_status = "running" + item.analysis_error_message = None + if item.analysis_started_at is None: + item.analysis_started_at = utcnow() + item.analysis_finished_at = None + item.update_date = utcnow() + db.commit() + + try: + stats, warnings = _analyze_dataset_content(db, item) + except Exception as exc: + item.analysis_status = "failed" + item.analysis_error_message = str(exc) + item.analysis_finished_at = utcnow() + item.update_date = utcnow() + db.commit() + raise item.sample_count = stats["sample_count"] item.bbox_min_lon = stats["bbox_min_lon"] item.bbox_max_lon = stats["bbox_max_lon"] item.bbox_min_lat = stats["bbox_min_lat"] item.bbox_max_lat = stats["bbox_max_lat"] + item.analysis_status = "success" + item.analysis_error_message = None + item.analysis_finished_at = utcnow() item.update_user = actor.id item.update_date = utcnow() db.commit() @@ -858,13 +977,59 @@ def execute_dataset_analysis_job(*, dataset_id: str, actor_user_id: str | None) if not item: return + item.analysis_status = "running" + item.analysis_error_message = None + item.analysis_started_at = utcnow() + item.analysis_finished_at = None + item.update_date = utcnow() + db.commit() + _publish_elevation_change( + "elevation.dataset.analysis.running", + {"action": "dataset_analysis_running", "dataset_id": item.id}, + ) + actor = db.execute(select(User).where(User.id == actor_user_id)).scalar_one_or_none() if actor_user_id else None if actor is None: actor = db.execute(select(User).where(User.status == "active").order_by(User.id.asc())).scalars().first() if actor is None: + item.analysis_status = "failed" + item.analysis_error_message = "未找到可用用户执行分析" + item.analysis_finished_at = utcnow() + item.update_date = utcnow() + db.commit() + _publish_elevation_change( + "elevation.dataset.analysis.failed", + {"action": "dataset_analysis_failed", "dataset_id": item.id}, + ) return analyze_dataset(db, dataset_id=dataset_id, actor=actor) + saved = get_dataset_by_id(db, dataset_id) + if saved is None: + return + saved.analysis_status = "success" + saved.analysis_error_message = None + saved.analysis_finished_at = utcnow() + saved.update_date = utcnow() + saved.update_user = actor.id + db.commit() + _publish_elevation_change( + "elevation.dataset.analysis.success", + {"action": "dataset_analysis_success", "dataset_id": saved.id}, + ) + except Exception as exc: + failed = get_dataset_by_id(db, dataset_id) + if failed is not None: + failed.analysis_status = "failed" + failed.analysis_error_message = str(exc) + failed.analysis_finished_at = utcnow() + failed.update_date = utcnow() + db.commit() + _publish_elevation_change( + "elevation.dataset.analysis.failed", + {"action": "dataset_analysis_failed", "dataset_id": failed.id}, + ) + raise finally: db.close() diff --git a/web/src/app/admin/elevation/page.tsx b/web/src/app/admin/elevation/page.tsx index fcbfc46..be444b0 100644 --- a/web/src/app/admin/elevation/page.tsx +++ b/web/src/app/admin/elevation/page.tsx @@ -1,10 +1,9 @@ "use client"; import Link from "next/link"; -import { ChangeEvent, useCallback, useMemo, useRef, useState } from "react"; +import { useCallback, useMemo, useRef, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { - App, Alert, Descriptions, Empty, @@ -12,15 +11,17 @@ import { Input, InputNumber, Modal, + Progress, Select, Space, Spin, Table, Tag, Typography, - Progress, + Upload, message, } from "antd"; +import type { UploadFile } from "antd/es/upload/interface"; import type { ColumnsType } from "antd/es/table"; import { useAuth } from "@/components/auth-provider"; @@ -32,9 +33,11 @@ import type { ElevationApplyJobCreateResponse, ElevationApplyJobListResponse, ElevationApplyJobSummary, - ElevationDatasetAnalyzeResponse, + ElevationDatasetAnalysisTaskStatusResponse, ElevationDatasetBatchImportResponse, ElevationDatasetDataImportResponse, + ElevationDatasetFileItem, + ElevationDatasetFileListResponse, ElevationDatasetListResponse, ElevationDatasetPreviewResponse, ElevationDatasetSummary, @@ -84,7 +87,7 @@ function applyModeLabel(mode: string): string { return mode; } -function formatDate(value: string | null): string { +function formatDate(value: string | null | undefined): string { if (!value) return "-"; return new Date(value).toLocaleString(); } @@ -94,6 +97,14 @@ function formatNumber(value: number | null | undefined, digits = 6): string { return Number(value).toFixed(digits); } +function formatFileSize(size: number): string { + if (!Number.isFinite(size) || size < 0) return "-"; + if (size < 1024) return `${size} B`; + if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`; + if (size < 1024 * 1024 * 1024) return `${(size / (1024 * 1024)).toFixed(1)} MB`; + return `${(size / (1024 * 1024 * 1024)).toFixed(2)} GB`; +} + function readXhrError(xhr: XMLHttpRequest): string { const fallback = `HTTP ${xhr.status}`; const raw = xhr.responseText?.trim(); @@ -109,7 +120,6 @@ function readXhrError(xhr: XMLHttpRequest): string { } export default function AdminElevationPage() { - const { modal } = App.useApp(); const queryClient = useQueryClient(); const { user, @@ -120,20 +130,33 @@ export default function AdminElevationPage() { refreshAccessToken, } = useAuth(); const [messageApi, messageContextHolder] = message.useMessage(); + const [error, setError] = useState(""); - const [success, setSuccess] = useState(""); const [datasetModalOpen, setDatasetModalOpen] = useState(false); const [applyModalOpen, setApplyModalOpen] = useState(false); const [previewModalOpen, setPreviewModalOpen] = useState(false); const [previewDataset, setPreviewDataset] = useState(null); const [previewData, setPreviewData] = useState(null); const [previewLoading, setPreviewLoading] = useState(false); - const [analyzingDatasetId, setAnalyzingDatasetId] = useState(null); + + const [importModalOpen, setImportModalOpen] = useState(false); + const [importDataset, setImportDataset] = useState(null); + const [importFileList, setImportFileList] = useState([]); const [datasetDataUploadProgress, setDatasetDataUploadProgress] = useState(0); const [datasetDataUploadFileName, setDatasetDataUploadFileName] = useState(""); - const [datasetDataUploadingDatasetId, setDatasetDataUploadingDatasetId] = useState(null); + const [lastImportedFiles, setLastImportedFiles] = useState([]); + const [lastAnalysisTaskId, setLastAnalysisTaskId] = useState(null); + + const [datasetFilesModalOpen, setDatasetFilesModalOpen] = useState(false); + const [datasetFilesDataset, setDatasetFilesDataset] = useState(null); + const [datasetFiles, setDatasetFiles] = useState([]); + const [datasetFilesLoading, setDatasetFilesLoading] = useState(false); + + const [analysisModalOpen, setAnalysisModalOpen] = useState(false); + const [analysisDataset, setAnalysisDataset] = useState(null); + const importInputRef = useRef(null); - const datasetDataImportRefs = useRef>({}); + const [datasetForm] = Form.useForm(); const [applyForm] = Form.useForm(); @@ -214,38 +237,6 @@ export default function AdminElevationPage() { }, [refreshPowerLines]), ); - const analyzeMutation = useMutation({ - mutationFn: async (datasetId: string) => { - const response = await fetchWithAuth(`/api/v1/elevation/datasets/${datasetId}/analyze`, { - method: "POST", - }); - if (!response.ok) { - throw new Error(await readApiError(response)); - } - return (await response.json()) as ElevationDatasetAnalyzeResponse; - }, - onMutate: (datasetId) => { - setAnalyzingDatasetId(datasetId); - }, - onSuccess: async (payload) => { - const warnings = payload.warnings.length; - const msg = warnings > 0 ? `分析完成(${warnings} 条告警)` : "分析完成"; - setSuccess(msg); - setError(""); - messageApi.success(msg); - await refreshElevationData(); - }, - onError: (candidate) => { - const nextError = candidate instanceof Error ? candidate.message : "分析失败"; - setError(nextError); - setSuccess(""); - messageApi.error(nextError); - }, - onSettled: () => { - setAnalyzingDatasetId(null); - }, - }); - const datasetCreateMutation = useMutation({ mutationFn: async (values: DatasetFormValues) => { const payload = { @@ -268,7 +259,6 @@ export default function AdminElevationPage() { return (await response.json()) as ElevationDatasetSummary; }, onSuccess: async () => { - setSuccess("高程数据集已创建"); setError(""); messageApi.success("高程数据集已创建"); setDatasetModalOpen(false); @@ -278,7 +268,6 @@ export default function AdminElevationPage() { onError: (candidate) => { const nextError = candidate instanceof Error ? candidate.message : "创建高程数据集失败"; setError(nextError); - setSuccess(""); messageApi.error(nextError); }, }); @@ -289,7 +278,6 @@ export default function AdminElevationPage() { setDatasetDataUploadFileName( payload.files.length === 1 ? payload.files[0].name : `共 ${payload.files.length} 个文件`, ); - setDatasetDataUploadingDatasetId(payload.datasetId); const uploadWithXhr = (token: string | null) => new Promise((resolve, reject) => { @@ -349,28 +337,21 @@ export default function AdminElevationPage() { return result; }, onSuccess: async (payload) => { - const monitorHint = payload.analysis_task_id - ? `,分析任务已入队(Task ID: ${payload.analysis_task_id},可在“任务监控”查看进度)` - : ""; + const monitorHint = payload.analysis_task_id ? `,分析任务ID:${payload.analysis_task_id}` : ""; const msg = payload.warning_count > 0 ? `数据导入完成:上传 ${payload.uploaded_file_count} 个、解压 ${payload.extracted_file_count} 个、可用 ${payload.imported_file_count} 个,告警 ${payload.warning_count} 条${monitorHint}` : `数据导入完成:上传 ${payload.uploaded_file_count} 个、解压 ${payload.extracted_file_count} 个、可用 ${payload.imported_file_count} 个${monitorHint}`; - setSuccess(msg); setError(""); messageApi.success(msg); + setLastImportedFiles(payload.imported_files); + setLastAnalysisTaskId(payload.analysis_task_id); await refreshElevationData(); - setDatasetDataUploadProgress(0); - setDatasetDataUploadFileName(""); - setDatasetDataUploadingDatasetId(null); }, onError: (candidate) => { const nextError = candidate instanceof Error ? candidate.message : "导入高程数据失败"; setError(nextError); - setSuccess(""); messageApi.error(nextError); setDatasetDataUploadProgress(0); - setDatasetDataUploadFileName(""); - setDatasetDataUploadingDatasetId(null); }, }); @@ -391,7 +372,6 @@ export default function AdminElevationPage() { const msg = payload.warning_count > 0 ? `批量导入完成:新增 ${payload.imported_count} 条,分析 ${payload.analyzed_count} 条,跳过 ${payload.skipped_count} 条,告警 ${payload.warning_count} 条` : `批量导入完成:新增 ${payload.imported_count} 条,分析 ${payload.analyzed_count} 条,跳过 ${payload.skipped_count} 条`; - setSuccess(msg); setError(""); messageApi.success(msg); await refreshElevationData(); @@ -399,7 +379,6 @@ export default function AdminElevationPage() { onError: (candidate) => { const nextError = candidate instanceof Error ? candidate.message : "批量导入高程数据集失败"; setError(nextError); - setSuccess(""); messageApi.error(nextError); }, }); @@ -421,7 +400,6 @@ export default function AdminElevationPage() { return (await response.json()) as ElevationApplyJobCreateResponse; }, onSuccess: async () => { - setSuccess("高程回填任务已提交"); setError(""); messageApi.success("高程回填任务已提交"); setApplyModalOpen(false); @@ -431,7 +409,6 @@ export default function AdminElevationPage() { onError: (candidate) => { const nextError = candidate instanceof Error ? candidate.message : "提交回填任务失败"; setError(nextError); - setSuccess(""); messageApi.error(nextError); }, }); @@ -446,7 +423,6 @@ export default function AdminElevationPage() { } }, onSuccess: async () => { - setSuccess("高程数据集已删除"); setError(""); messageApi.success("高程数据集已删除"); setPreviewModalOpen(false); @@ -458,11 +434,46 @@ export default function AdminElevationPage() { onError: (candidate) => { const nextError = candidate instanceof Error ? candidate.message : "删除高程数据集失败"; setError(nextError); - setSuccess(""); messageApi.error(nextError); }, }); + const datasetFilesMutation = useMutation({ + mutationFn: async (datasetId: string) => { + const response = await fetchWithAuth(`/api/v1/elevation/datasets/${datasetId}/files`); + if (!response.ok) { + throw new Error(await readApiError(response)); + } + return (await response.json()) as ElevationDatasetFileListResponse; + }, + onSuccess: (payload) => { + setDatasetFiles(payload.items); + setDatasetFilesLoading(false); + setError(""); + }, + onError: (candidate) => { + const nextError = candidate instanceof Error ? candidate.message : "加载文件明细失败"; + setError(nextError); + messageApi.error(nextError); + setDatasetFiles([]); + setDatasetFilesLoading(false); + }, + }); + + const analysisStatusQuery = useQuery({ + queryKey: ["/api/v1/elevation/datasets/analysis-task", analysisDataset?.id], + enabled: !!analysisDataset, + queryFn: async () => { + const response = await fetchWithAuth(`/api/v1/elevation/datasets/${analysisDataset?.id}/analysis-task`); + if (!response.ok) { + throw new Error(await readApiError(response)); + } + return (await response.json()) as ElevationDatasetAnalysisTaskStatusResponse; + }, + refetchInterval: analysisModalOpen ? 3000 : false, + staleTime: 0, + }); + const datasets = datasetsQuery.data?.items ?? []; const jobs = jobsQuery.data?.items ?? []; const lines = linesQuery.data?.items ?? []; @@ -486,6 +497,32 @@ export default function AdminElevationPage() { [datasets], ); + const fileColumns = useMemo>( + () => [ + { title: "文件名", dataIndex: "name", width: 260 }, + { title: "路径", dataIndex: "path", width: 420 }, + { + title: "大小", + dataIndex: "size", + width: 120, + render: (value: number) => formatFileSize(value), + }, + { + title: "修改时间", + dataIndex: "modified_at", + width: 180, + render: (value: string | null) => formatDate(value), + }, + { + title: "类型", + dataIndex: "mime_type", + width: 160, + render: (value: string | null) => value || "-", + }, + ], + [], + ); + const datasetColumns = useMemo>( () => [ { title: "编码", dataIndex: "code", width: 140 }, @@ -502,6 +539,21 @@ export default function AdminElevationPage() { width: 90, render: (value: string) => {value}, }, + { + title: "分析状态", + dataIndex: "analysis_status", + width: 120, + render: (value: string) => { + const colorMap: Record = { + queued: "orange", + running: "processing", + success: "green", + failed: "red", + not_started: "default", + }; + return {value}; + }, + }, { title: "使用状态", dataIndex: "usage_status", @@ -528,9 +580,9 @@ export default function AdminElevationPage() { title: "操作", key: "actions", fixed: "right", - width: 190, + width: 280, render: (_, row) => ( - + { setPreviewDataset(row); @@ -551,7 +603,6 @@ export default function AdminElevationPage() { .catch((candidate) => { const nextError = candidate instanceof Error ? candidate.message : "加载预览失败"; setError(nextError); - setSuccess(""); messageApi.error(nextError); }) .finally(() => { @@ -562,44 +613,44 @@ export default function AdminElevationPage() { 预览 { - if (!canManage) return; - analyzeMutation.mutate(row.id); + setDatasetFilesDataset(row); + setDatasetFiles([]); + setDatasetFilesModalOpen(true); + setDatasetFilesLoading(true); + datasetFilesMutation.mutate(row.id); }} > - {analyzingDatasetId === row.id ? "分析中..." : "分析"} + 文件明细 + + { + setAnalysisDataset(row); + setAnalysisModalOpen(true); + }} + > + 分析进度 { if (!canManage || datasetDataImportMutation.isPending) return; - datasetDataImportRefs.current[row.id]?.click(); + setImportDataset(row); + setImportFileList([]); + setDatasetDataUploadProgress(0); + setDatasetDataUploadFileName(""); + setLastImportedFiles([]); + setLastAnalysisTaskId(null); + setImportModalOpen(true); }} > - {datasetDataImportMutation.isPending && datasetDataUploadingDatasetId === row.id ? "导入中..." : "导入数据"} + 导入数据 - { - datasetDataImportRefs.current[row.id] = element; - }} - type="file" - multiple - accept=".csv,.img,.tif,.tiff,.zip" - className="hidden" - onChange={(event: ChangeEvent) => { - const selected = event.target.files ? Array.from(event.target.files) : []; - if (selected.length > 0) { - datasetDataImportMutation.mutate({ datasetId: row.id, files: selected }); - } - event.target.value = ""; - }} - /> { if (!canManage || datasetDeleteMutation.isPending) return; - modal.confirm({ + Modal.confirm({ title: "删除高程数据集", content: `确认删除数据集「${row.code} - ${row.name}」?该操作会同时删除关联的回填任务记录,且不可恢复。`, okText: "确认删除", @@ -618,15 +669,12 @@ export default function AdminElevationPage() { }, ], [ - analyzeMutation, - analyzingDatasetId, canManage, datasetDataImportMutation, - datasetDataUploadingDatasetId, datasetDeleteMutation, + datasetFilesMutation, fetchWithAuth, messageApi, - modal, ], ); @@ -695,33 +743,23 @@ export default function AdminElevationPage() { ); } - const datasetTableScrollX = 1950; + const datasetTableScrollX = 2200; return (
{messageContextHolder} - {(error || success || datasetsQuery.error || jobsQuery.error || linesQuery.error) && ( + {(error || datasetsQuery.error || jobsQuery.error || linesQuery.error) && ( - )} - - {datasetDataImportMutation.isPending && ( - - {datasetDataUploadFileName || "正在上传文件..."} - = 100 ? "active" : "normal"} /> -
- )} + message={error || (datasetsQuery.error instanceof Error + ? datasetsQuery.error.message + : jobsQuery.error instanceof Error + ? jobsQuery.error.message + : linesQuery.error instanceof Error + ? linesQuery.error.message + : "加载失败")} /> )} @@ -747,7 +785,7 @@ export default function AdminElevationPage() { type="file" accept=".csv,text/csv" className="hidden" - onChange={(event: ChangeEvent) => { + onChange={(event) => { const file = event.target.files?.[0]; if (file) { datasetImportMutation.mutate(file); @@ -769,13 +807,6 @@ export default function AdminElevationPage() {
)} > - {datasets.length === 0 ? ( ) : ( @@ -917,6 +948,178 @@ export default function AdminElevationPage() { )} + { + if (datasetDataImportMutation.isPending) return; + setImportModalOpen(false); + setImportDataset(null); + setImportFileList([]); + setDatasetDataUploadProgress(0); + setDatasetDataUploadFileName(""); + setLastImportedFiles([]); + setLastAnalysisTaskId(null); + }} + onOk={() => { + if (!importDataset || datasetDataImportMutation.isPending) return; + const files = importFileList + .map((item) => item.originFileObj) + .filter((item): item is File => !!item); + if (files.length === 0) { + messageApi.warning("请先选择至少一个文件"); + return; + } + datasetDataImportMutation.mutate({ datasetId: importDataset.id, files }); + }} + confirmLoading={datasetDataImportMutation.isPending} + okText={datasetDataImportMutation.isPending ? "导入中" : "开始导入"} + cancelText="取消" + > +
+ + {importDataset && ( + + {`${importDataset.code} - ${importDataset.name}`} + {importDataset.dataset_dir} + + )} + false} + onChange={({ fileList: nextFileList }) => { + setImportFileList(nextFileList); + }} + accept=".csv,.img,.tif,.tiff,.zip" + disabled={datasetDataImportMutation.isPending} + > + 选择文件(支持多选) + + + {(datasetDataImportMutation.isPending || datasetDataUploadProgress > 0) && ( +
+ + {datasetDataUploadFileName || "正在上传文件..."} + + = 100 ? "active" : "normal"} + /> +
+ )} + + {lastImportedFiles.length > 0 && ( + + )} + + {lastAnalysisTaskId && ( + + )} +
+
+ + { + setDatasetFilesModalOpen(false); + setDatasetFilesDataset(null); + setDatasetFiles([]); + setDatasetFilesLoading(false); + }} + > + {datasetFilesDataset && ( +
+ + {datasetFilesLoading ? ( +
+ +
+ ) : datasetFiles.length === 0 ? ( + + ) : ( + + rowKey={(row) => row.path} + columns={fileColumns} + dataSource={datasetFiles} + pagination={false} + scroll={{ x: 1000 }} + /> + )} +
+ )} +
+ + { + setAnalysisModalOpen(false); + setAnalysisDataset(null); + }} + > + {analysisDataset && ( +
+ {analysisStatusQuery.isLoading ? ( +
+ +
+ ) : analysisStatusQuery.error ? ( + + ) : ( + <> + + + + {analysisStatusQuery.data?.status || "-"} + + + {analysisStatusQuery.data?.task_id || "-"} + {formatDate(analysisStatusQuery.data?.started_at)} + {formatDate(analysisStatusQuery.data?.finished_at)} + {formatDate(analysisStatusQuery.data?.update_date)} + + {analysisStatusQuery.data?.detail && ( + + )} + + )} +
+ )} +
+ form={datasetForm} layout="vertical" initialValues={DEFAULT_DATASET_FORM}> - + - + @@ -972,13 +1175,13 @@ export default function AdminElevationPage() { confirmLoading={applyMutation.isPending} > form={applyForm} layout="vertical" initialValues={DEFAULT_APPLY_FORM}> - + - +