优化高程数据管理交互并补充分析进度与文件明细能力

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
2026-05-03 15:23:47 +08:00
parent a09d12c3c3
commit 7c121b8948
7 changed files with 640 additions and 125 deletions
+22
View File
@@ -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),
+46
View File
@@ -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()
+5
View File
@@ -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)
+33
View File
@@ -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
+166 -1
View File
@@ -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()
+327 -124
View File
@@ -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<ElevationDatasetSummary | null>(null);
const [previewData, setPreviewData] = useState<ElevationDatasetPreviewResponse | null>(null);
const [previewLoading, setPreviewLoading] = useState(false);
const [analyzingDatasetId, setAnalyzingDatasetId] = useState<string | null>(null);
const [importModalOpen, setImportModalOpen] = useState(false);
const [importDataset, setImportDataset] = useState<ElevationDatasetSummary | null>(null);
const [importFileList, setImportFileList] = useState<UploadFile[]>([]);
const [datasetDataUploadProgress, setDatasetDataUploadProgress] = useState(0);
const [datasetDataUploadFileName, setDatasetDataUploadFileName] = useState("");
const [datasetDataUploadingDatasetId, setDatasetDataUploadingDatasetId] = useState<string | null>(null);
const [lastImportedFiles, setLastImportedFiles] = useState<string[]>([]);
const [lastAnalysisTaskId, setLastAnalysisTaskId] = useState<string | null>(null);
const [datasetFilesModalOpen, setDatasetFilesModalOpen] = useState(false);
const [datasetFilesDataset, setDatasetFilesDataset] = useState<ElevationDatasetSummary | null>(null);
const [datasetFiles, setDatasetFiles] = useState<ElevationDatasetFileItem[]>([]);
const [datasetFilesLoading, setDatasetFilesLoading] = useState(false);
const [analysisModalOpen, setAnalysisModalOpen] = useState(false);
const [analysisDataset, setAnalysisDataset] = useState<ElevationDatasetSummary | null>(null);
const importInputRef = useRef<HTMLInputElement | null>(null);
const datasetDataImportRefs = useRef<Record<string, HTMLInputElement | null>>({});
const [datasetForm] = Form.useForm<DatasetFormValues>();
const [applyForm] = Form.useForm<ApplyFormValues>();
@@ -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<ElevationDatasetDataImportResponse>((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<ColumnsType<ElevationDatasetFileItem>>(
() => [
{ 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<ColumnsType<ElevationDatasetSummary>>(
() => [
{ title: "编码", dataIndex: "code", width: 140 },
@@ -502,6 +539,21 @@ export default function AdminElevationPage() {
width: 90,
render: (value: string) => <Tag color={statusTagColor(value)}>{value}</Tag>,
},
{
title: "分析状态",
dataIndex: "analysis_status",
width: 120,
render: (value: string) => {
const colorMap: Record<string, string> = {
queued: "orange",
running: "processing",
success: "green",
failed: "red",
not_started: "default",
};
return <Tag color={colorMap[value] || "default"}>{value}</Tag>;
},
},
{
title: "使用状态",
dataIndex: "usage_status",
@@ -528,9 +580,9 @@ export default function AdminElevationPage() {
title: "操作",
key: "actions",
fixed: "right",
width: 190,
width: 280,
render: (_, row) => (
<Space size="small">
<Space size="small" wrap>
<Typography.Link
onClick={() => {
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() {
</Typography.Link>
<Typography.Link
disabled={!canManage}
onClick={() => {
if (!canManage) return;
analyzeMutation.mutate(row.id);
setDatasetFilesDataset(row);
setDatasetFiles([]);
setDatasetFilesModalOpen(true);
setDatasetFilesLoading(true);
datasetFilesMutation.mutate(row.id);
}}
>
{analyzingDatasetId === row.id ? "分析中..." : "分析"}
</Typography.Link>
<Typography.Link
onClick={() => {
setAnalysisDataset(row);
setAnalysisModalOpen(true);
}}
>
</Typography.Link>
<Typography.Link
disabled={!canManage || datasetDataImportMutation.isPending}
onClick={() => {
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 ? "导入中..." : "导入数据"}
</Typography.Link>
<input
ref={(element) => {
datasetDataImportRefs.current[row.id] = element;
}}
type="file"
multiple
accept=".csv,.img,.tif,.tiff,.zip"
className="hidden"
onChange={(event: ChangeEvent<HTMLInputElement>) => {
const selected = event.target.files ? Array.from(event.target.files) : [];
if (selected.length > 0) {
datasetDataImportMutation.mutate({ datasetId: row.id, files: selected });
}
event.target.value = "";
}}
/>
<Typography.Link
disabled={!canManage || datasetDeleteMutation.isPending}
onClick={() => {
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 (
<div className="space-y-6">
{messageContextHolder}
{(error || success || datasetsQuery.error || jobsQuery.error || linesQuery.error) && (
{(error || datasetsQuery.error || jobsQuery.error || linesQuery.error) && (
<Alert
type={error || datasetsQuery.error || jobsQuery.error || linesQuery.error ? "error" : "success"}
type="error"
showIcon
message={error || (datasetsQuery.error instanceof Error ? datasetsQuery.error.message : jobsQuery.error instanceof Error ? jobsQuery.error.message : linesQuery.error instanceof Error ? linesQuery.error.message : success)}
/>
)}
{datasetDataImportMutation.isPending && (
<Alert
type="info"
showIcon
message={datasetDataUploadingDatasetId
? `正在导入数据(数据集 ${datasetDataUploadingDatasetId}`
: "正在导入数据"}
description={(
<div className="space-y-2">
<Typography.Text type="secondary">{datasetDataUploadFileName || "正在上传文件..."}</Typography.Text>
<Progress percent={datasetDataUploadProgress} status={datasetDataUploadProgress >= 100 ? "active" : "normal"} />
</div>
)}
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<HTMLInputElement>) => {
onChange={(event) => {
const file = event.target.files?.[0];
if (file) {
datasetImportMutation.mutate(file);
@@ -769,13 +807,6 @@ export default function AdminElevationPage() {
</Space>
)}
>
<Alert
type="info"
showIcon
message="支持文件格式:CSV(点集)/ IMG / TIF / TIFF(栅格)/ ZIP(解压后 csv/img/tif"
description="先新建数据集,再使用“导入数据”上传多个高程文件。数据集目录自动固定为 /elevation/datasets/{数据集编码},导入完成后自动触发分析。"
className="mb-4"
/>
{datasets.length === 0 ? (
<Empty description="暂无高程数据集,请先上传 CSV/IMG/TIF 并创建数据集。" />
) : (
@@ -917,6 +948,178 @@ export default function AdminElevationPage() {
)}
</Modal>
<Modal
title="导入高程数据"
open={importModalOpen}
onCancel={() => {
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="取消"
>
<div className="space-y-3">
<Alert
type="info"
showIcon
message="支持文件格式:CSV(点集)/ IMG / TIF / TIFF(栅格)/ ZIP(解压后 csv/img/tif"
description="先新建数据集,再使用“导入数据”上传多个高程文件。数据集目录自动固定为 /elevation/datasets/{数据集编码},导入完成后自动触发分析。"
/>
{importDataset && (
<Descriptions bordered size="small" column={1}>
<Descriptions.Item label="数据集">{`${importDataset.code} - ${importDataset.name}`}</Descriptions.Item>
<Descriptions.Item label="目录">{importDataset.dataset_dir}</Descriptions.Item>
</Descriptions>
)}
<Upload
multiple
fileList={importFileList}
beforeUpload={() => false}
onChange={({ fileList: nextFileList }) => {
setImportFileList(nextFileList);
}}
accept=".csv,.img,.tif,.tiff,.zip"
disabled={datasetDataImportMutation.isPending}
>
<Typography.Link></Typography.Link>
</Upload>
{(datasetDataImportMutation.isPending || datasetDataUploadProgress > 0) && (
<div className="space-y-2">
<Typography.Text type="secondary">
{datasetDataUploadFileName || "正在上传文件..."}
</Typography.Text>
<Progress
percent={datasetDataUploadProgress}
status={datasetDataUploadProgress >= 100 ? "active" : "normal"}
/>
</div>
)}
{lastImportedFiles.length > 0 && (
<Alert
type="success"
showIcon
message={`已导入文件(${lastImportedFiles.length}`}
description={lastImportedFiles.slice(0, 5).join("")}
/>
)}
{lastAnalysisTaskId && (
<Alert
type="info"
showIcon
message="分析任务已触发"
description={`Task ID: ${lastAnalysisTaskId},可点击数据集行「分析进度」查看实时状态。`}
/>
)}
</div>
</Modal>
<Modal
title={datasetFilesDataset ? `文件明细:${datasetFilesDataset.code}` : "文件明细"}
open={datasetFilesModalOpen}
footer={null}
width={1040}
onCancel={() => {
setDatasetFilesModalOpen(false);
setDatasetFilesDataset(null);
setDatasetFiles([]);
setDatasetFilesLoading(false);
}}
>
{datasetFilesDataset && (
<div className="space-y-3">
<Alert
type="info"
showIcon
message={`目录:${datasetFilesDataset.dataset_dir}`}
description={`挂载:${datasetFilesDataset.mount_code}`}
/>
{datasetFilesLoading ? (
<div className="flex min-h-[180px] items-center justify-center">
<Spin tip="文件明细加载中..." />
</div>
) : datasetFiles.length === 0 ? (
<Empty description="当前目录暂无文件。" />
) : (
<Table<ElevationDatasetFileItem>
rowKey={(row) => row.path}
columns={fileColumns}
dataSource={datasetFiles}
pagination={false}
scroll={{ x: 1000 }}
/>
)}
</div>
)}
</Modal>
<Modal
title={analysisDataset ? `分析进度:${analysisDataset.code}` : "分析进度"}
open={analysisModalOpen}
footer={null}
onCancel={() => {
setAnalysisModalOpen(false);
setAnalysisDataset(null);
}}
>
{analysisDataset && (
<div className="space-y-3">
{analysisStatusQuery.isLoading ? (
<div className="flex min-h-[180px] items-center justify-center">
<Spin tip="分析状态加载中..." />
</div>
) : analysisStatusQuery.error ? (
<Alert
type="error"
showIcon
message={analysisStatusQuery.error instanceof Error ? analysisStatusQuery.error.message : "分析状态加载失败"}
/>
) : (
<>
<Descriptions bordered size="small" column={1}>
<Descriptions.Item label="任务状态">
<Tag color={statusTagColor(analysisStatusQuery.data?.status || "default")}>
{analysisStatusQuery.data?.status || "-"}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="Task ID">{analysisStatusQuery.data?.task_id || "-"}</Descriptions.Item>
<Descriptions.Item label="开始时间">{formatDate(analysisStatusQuery.data?.started_at)}</Descriptions.Item>
<Descriptions.Item label="结束时间">{formatDate(analysisStatusQuery.data?.finished_at)}</Descriptions.Item>
<Descriptions.Item label="更新时间">{formatDate(analysisStatusQuery.data?.update_date)}</Descriptions.Item>
</Descriptions>
{analysisStatusQuery.data?.detail && (
<Alert
type={analysisStatusQuery.data.status === "failed" ? "error" : "info"}
showIcon
message={analysisStatusQuery.data.detail}
/>
)}
</>
)}
</div>
)}
</Modal>
<Modal
title="新建高程数据集"
open={datasetModalOpen}
@@ -932,10 +1135,10 @@ export default function AdminElevationPage() {
confirmLoading={datasetCreateMutation.isPending}
>
<Form<DatasetFormValues> form={datasetForm} layout="vertical" initialValues={DEFAULT_DATASET_FORM}>
<Form.Item name="code" label="编码" rules={[{ required: true, message: "请输入编码" }]}>
<Form.Item name="code" label="编码" rules={[{ required: true, message: "请输入编码" }]}>
<Input placeholder="dem_china_90m_v1" />
</Form.Item>
<Form.Item name="name" label="名称" rules={[{ required: true, message: "请输入名称" }]}>
<Form.Item name="name" label="名称" rules={[{ required: true, message: "请输入名称" }]}>
<Input placeholder="中国90米DEMIMG" />
</Form.Item>
<Form.Item name="source" label="来源">
@@ -972,13 +1175,13 @@ export default function AdminElevationPage() {
confirmLoading={applyMutation.isPending}
>
<Form<ApplyFormValues> form={applyForm} layout="vertical" initialValues={DEFAULT_APPLY_FORM}>
<Form.Item name="line_id" label="线路" rules={[{ required: true, message: "请选择线路" }]}>
<Form.Item name="line_id" label="线路" rules={[{ required: true, message: "请选择线路" }]}>
<Select showSearch options={lineOptions} optionFilterProp="label" placeholder="选择线路" />
</Form.Item>
<Form.Item name="dataset_id" label="高程数据集" rules={[{ required: true, message: "请选择高程数据集" }]}>
<Form.Item name="dataset_id" label="高程数据集" rules={[{ required: true, message: "请选择高程数据集" }]}>
<Select showSearch options={datasetOptions} optionFilterProp="label" placeholder="选择高程数据集" />
</Form.Item>
<Form.Item name="mode" label="回填模式" rules={[{ required: true, message: "请选择回填模式" }]}>
<Form.Item name="mode" label="回填模式" rules={[{ required: true, message: "请选择回填模式" }]}>
<Select
options={[
{ value: "fill_null_only", label: "仅填空(推荐)" },
+41
View File
@@ -300,6 +300,11 @@ export type ElevationDatasetSummary = {
bbox_max_lon: number | null;
bbox_min_lat: number | null;
bbox_max_lat: number | null;
analysis_task_id: string | null;
analysis_status: string;
analysis_error_message: string | null;
analysis_started_at: string | null;
analysis_finished_at: string | null;
notes: string | null;
create_date: string;
create_user: string | null;
@@ -338,6 +343,42 @@ export type ElevationDatasetDataImportResponse = {
imported_files: string[];
};
export type ElevationDatasetFileItem = {
path: string;
name: string;
size: number;
modified_at: string | null;
mime_type: string | null;
};
export type ElevationDatasetFileListResponse = {
dataset_id: string;
dataset_code: string;
dataset_dir: string;
mount_code: string;
items: ElevationDatasetFileItem[];
total: number;
};
export type ElevationDatasetAnalysisTaskStatus =
| "queued"
| "running"
| "success"
| "failed"
| "unknown"
| "not_found";
export type ElevationDatasetAnalysisTaskStatusResponse = {
dataset_id: string;
dataset_code: string;
task_id: string | null;
status: ElevationDatasetAnalysisTaskStatus;
detail: string | null;
started_at: string | null;
finished_at: string | null;
update_date: string | null;
};
export type ElevationDatasetPreviewPoint = {
longitude: number;
latitude: number;