From dc760de7a2b1fb032ed46bd20b779eb9cf25522b Mon Sep 17 00:00:00 2001 From: lipan Date: Tue, 7 Jul 2026 18:15:47 +0800 Subject: [PATCH] feat: update task file selection UI --- client/src/components/AlgorithmRunPanel.tsx | 152 +++++-- client/src/components/AnalysisPanel.tsx | 43 +- client/src/components/TaskManager.tsx | 438 ++++++++++++++++++-- client/src/pages/Home.tsx | 1 + 4 files changed, 539 insertions(+), 95 deletions(-) diff --git a/client/src/components/AlgorithmRunPanel.tsx b/client/src/components/AlgorithmRunPanel.tsx index d1a3625..6e1ac8c 100644 --- a/client/src/components/AlgorithmRunPanel.tsx +++ b/client/src/components/AlgorithmRunPanel.tsx @@ -9,7 +9,7 @@ * - 多选执行时调用 `algorithm.runBatch` 一次性把所有作业丢进后端并发池 * (默认 4 并发),20 分钟内出结果(工信部验收要求)。 */ -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useState } from 'react'; import { trpc } from '@/lib/trpc'; import { AlertTriangle, CheckCircle2, ChevronDown, ChevronRight, Cpu, Play, RefreshCw, Scan } from 'lucide-react'; import { toast } from 'sonner'; @@ -42,12 +42,23 @@ interface PluginSummary { runtimeKind: 'process' | 'mock'; } +interface PointcloudFileSummary { + id: number; + name: string; + source?: string; + sourceResultId?: number | null; + sourcePointcloudFileId?: number | null; + createdAt?: string | Date; +} + interface Props { taskId: number | null; /** 是否已加载点云(3D 视图,仅用于提示,不再作为执行前置条件) */ pointCloudLoaded: boolean; /** 当前任务下的点云文件总数(>0 即可执行算法,无需先在 3D 视图加载) */ taskPcFileCount?: number; + /** 当前任务下的点云文件元数据(用于配准输入校验与重复配准确认) */ + taskPointcloudFiles?: PointcloudFileSummary[]; /** 当前任务中用户选中的点云文件 ID(空/undefined = 全部) */ selectedPcFileIds?: number[]; /** 当前任务中选中的点云中是否包含配准后的点云 */ @@ -60,6 +71,13 @@ interface Props { activeRoiPickTarget?: { taskIdx: number; roiKey: string; corner: string } | null; } +function formatDateTime(value?: string | Date) { + if (!value) return '未知时间'; + const date = value instanceof Date ? value : new Date(value); + if (Number.isNaN(date.getTime())) return '未知时间'; + return date.toLocaleString('zh-CN', { hour12: false }); +} + function defaultParamsFor(plugin: PluginSummary): Record { const out: Record = {}; for (const field of plugin.inputs) { @@ -68,7 +86,7 @@ function defaultParamsFor(plugin: PluginSummary): Record { return out; } -export default function AlgorithmRunPanel({ taskId, pointCloudLoaded: _pointCloudLoaded, taskPcFileCount, selectedPcFileIds, hasRegisteredCloud, selectedCadFileIds, roiPickedPoint, onStartRoiPick, activeRoiPickTarget }: Props) { +export default function AlgorithmRunPanel({ taskId, pointCloudLoaded: _pointCloudLoaded, taskPcFileCount, taskPointcloudFiles = [], selectedPcFileIds, hasRegisteredCloud, selectedCadFileIds, roiPickedPoint, onStartRoiPick, activeRoiPickTarget }: Props) { const utils = trpc.useUtils(); const { data, isLoading } = trpc.algorithm.list.useQuery(undefined, { staleTime: 60_000, @@ -78,6 +96,7 @@ export default function AlgorithmRunPanel({ taskId, pointCloudLoaded: _pointClou toast.success(`已提交 ${jobs.length} 个算法到并发池,进度请查看下方「分析结果」`); if (taskId != null) { void utils.analysisResult.list.invalidate({ taskId }); + void utils.pointcloudFile.list.invalidate({ taskId }); } }, onError: (e) => toast.error(e.message || '提交失败'), @@ -125,14 +144,81 @@ export default function AlgorithmRunPanel({ taskId, pointCloudLoaded: _pointClou })); }; - const canRun = useMemo( - // 任务下有点云文件(taskPcFileCount > 0)即可执行,无需先在 3D 视图加载 - () => taskId != null && (taskPcFileCount ?? 0) > 0 && selected.size > 0 && !runBatch.isPending, - [taskId, taskPcFileCount, selected, runBatch.isPending], - ); + // 区分配准插件 vs 测量插件 + const regPlugins = plugins.filter((p) => p.category === '配准'); + const measPlugins = plugins.filter((p) => p.category !== '配准'); + const selectedMeasIds = measPlugins.filter((p) => selected.has(p.id)).map((p) => p.id); + const selectedRegIds = regPlugins.filter((p) => selected.has(p.id)).map((p) => p.id); - const handleRun = (ids: string[]) => { - if (!canRun || taskId == null) return; + const selectedPcCount = selectedPcFileIds?.length ?? 0; + const selectedCadCount = selectedCadFileIds?.length ?? 0; + const selectedRegistrationPointcloud = + selectedPcCount === 1 + ? taskPointcloudFiles.find((f) => f.id === selectedPcFileIds?.[0]) + : undefined; + const latestRegistrationChild = selectedRegistrationPointcloud?.source !== 'registered' + ? taskPointcloudFiles + .filter((f) => ( + f.source === 'registered' + && f.sourcePointcloudFileId === selectedRegistrationPointcloud?.id + )) + .sort((a, b) => { + const aTime = a.createdAt ? new Date(a.createdAt).getTime() : 0; + const bTime = b.createdAt ? new Date(b.createdAt).getTime() : 0; + return bTime - aTime; + })[0] + : undefined; + const baseRunBlockReason = + taskId == null ? '请先选择检测任务' + : (taskPcFileCount ?? 0) === 0 ? '请先上传点云文件' + : runBatch.isPending ? '算法提交中,请稍候' + : null; + const registrationBlockReason = + baseRunBlockReason + ?? (selectedRegIds.length === 0 ? '请勾选配准算法' + : selectedPcCount === 0 ? '整车配准必须选择 1 个点云文件' + : selectedPcCount > 1 ? '整车配准一次只能选择 1 个点云文件' + : selectedRegistrationPointcloud?.source === 'registered' ? '请选择原始点云进行配准,已配准点云不能再次配准' + : selectedCadCount === 0 ? '整车配准必须选择 1 个 CAD 文件' + : selectedCadCount > 1 ? '整车配准一次只能选择 1 个 CAD 文件' + : null); + const measurementBlockReason = + baseRunBlockReason + ?? (selectedMeasIds.length === 0 ? '请至少勾选一个测量算法' : null); + const canRunRegistration = registrationBlockReason == null; + const canRunMeasurement = measurementBlockReason == null; + const duplicateRegistrationWarning = + latestRegistrationChild && selectedRegistrationPointcloud + ? `点云「${selectedRegistrationPointcloud.name}」已有配准产物「${latestRegistrationChild.name}」(${formatDateTime(latestRegistrationChild.createdAt)}),再次执行会生成新产物。` + : null; + + const handleRun = (ids: string[], mode: 'registration' | 'measurement') => { + const blockReason = mode === 'registration' ? registrationBlockReason : measurementBlockReason; + if (blockReason) { + toast.error(blockReason); + return; + } + if (taskId == null) return; + const allowDuplicateRegistration = + mode === 'registration' && duplicateRegistrationWarning + ? window.confirm(`${duplicateRegistrationWarning}\n\n是否确认重新配准?`) + : false; + if (mode === 'registration' && duplicateRegistrationWarning && !allowDuplicateRegistration) { + return; + } + + const pointCloudFileIds = + mode === 'registration' + ? selectedPcFileIds + : selectedPcFileIds && selectedPcFileIds.length > 0 + ? selectedPcFileIds + : undefined; + const cadFileIds = + mode === 'registration' + ? selectedCadFileIds + : selectedCadFileIds && selectedCadFileIds.length > 0 + ? selectedCadFileIds + : undefined; const jobs = ids.map((pluginId) => ({ pluginId, params: paramsById[pluginId] ?? {}, @@ -140,21 +226,12 @@ export default function AlgorithmRunPanel({ taskId, pointCloudLoaded: _pointClou runBatch.mutate({ taskId, jobs, - ...(selectedPcFileIds && selectedPcFileIds.length > 0 - ? { pointCloudFileIds: selectedPcFileIds } - : {}), - ...(selectedCadFileIds && selectedCadFileIds.length > 0 - ? { cadFileIds: selectedCadFileIds } - : {}), + ...(pointCloudFileIds ? { pointCloudFileIds } : {}), + ...(cadFileIds ? { cadFileIds } : {}), + ...(allowDuplicateRegistration ? { allowDuplicateRegistration } : {}), }); }; - // 区分配准插件 vs 测量插件 - const regPlugins = plugins.filter((p) => p.category === '配准'); - const measPlugins = plugins.filter((p) => p.category !== '配准'); - const selectedMeasIds = measPlugins.filter((p) => selected.has(p.id)).map((p) => p.id); - const selectedRegIds = regPlugins.filter((p) => selected.has(p.id)).map((p) => p.id); - // 不再需要 showRegWarning 变量,已直接内联到 JSX 里 return ( @@ -169,11 +246,11 @@ export default function AlgorithmRunPanel({ taskId, pointCloudLoaded: _pointClou - )} - - 点云文件 - - ({someSelected ? `${selectedFileIds.size}/` : ''}{pcFiles.length}) +
+
+
+ {/* 全选复选框 */} + {pcFiles.length > 0 && ( + + )} + + 点云文件 + + ({someSelected ? `${selectedFileIds.size}/` : ''}{pcFiles.length}) + - +
+ {pcFiles.length > 0 && ( +
+ +
+ {modelPcFiles.map((f) => { + const isChecked = selectedModelPcFileIds.has(f.id); + return ( + + ); + })} + {modelPcFiles.length === 0 && ( +
+ 车型点云库为空 +
+ )} +
+ + +
+ )} +
{pcFiles.map((f) => { const isChecked = selectedFileIds.has(f.id); + const meta = f as { + source?: string; + sourceResultId?: number | null; + sourcePointcloudFileId?: number | null; + createdAt?: string | Date; + }; + const source = meta.source; + const sourcePointcloud = meta.sourcePointcloudFileId + ? pcFiles.find((row) => row.id === meta.sourcePointcloudFileId) + : undefined; return (
- - {f.name} - - {(f as { source?: string }).source === 'registered' && ( + + + {f.name} + + +
+
{f.name}
+ {source === 'registered' && ( + <> +
类型:已配准点云
+ {meta.sourceResultId != null &&
结果编号:R{meta.sourceResultId}
} +
生成时间:{formatDateTime(meta.createdAt)}
+
+ 来源: + {sourcePointcloud + ? sourcePointcloud.name + : meta.sourcePointcloudFileId != null + ? `点云 #${meta.sourcePointcloudFileId}` + : '旧记录未记录来源'} +
+ + )} +
+
+
+ {source === 'registered' && ( 已配准 )} + {source === 'registered' && meta.sourceResultId != null && ( + + R{meta.sourceResultId} + + )} + {source === 'model_library' && ( + + 车型库 + + )} {formatSize(f.fileSize)} @@ -483,17 +717,19 @@ export default function TaskManager({ {/* 任务级 CAD 文件 */} {activeTaskId != null && (
-
- - 任务 CAD - - ({taskCadFiles.length}) +
+
+ + 任务 CAD + + ({taskCadFiles.length}) + - +
{taskCadFiles.length > 0 && ( )} +
@@ -523,13 +767,80 @@ export default function TaskManager({
)} + {cadLibraryOpen && ( +
+
+ + 车型 CAD 库 ({modelCadFiles.length}) + + +
+ +
+ {modelCadFiles.map((f) => { + const isChecked = selectedModelCadFileIds.has(f.id); + return ( + + ); + })} + {modelCadFiles.length === 0 && ( +
+ 车型 CAD 库为空 +
+ )} +
+ + +
+ )} +
{taskCadFiles.map((f) => { const isChecked = selectedCadFileIds.has(f.id); + const source = (f as { source?: string }).source; + const canPreview = canPreviewCadIn3D(f); return (
toggleCadFile(f.id)} > @@ -545,7 +856,23 @@ export default function TaskManager({ )} - {f.name} + + + {f.name} + + + {f.name} + + + {source === 'model_library' && ( + + 车型库 + + )} {cadSampleCacheMap[f.id] && ( {formatSize(f.fileSize)} - {onSelectTaskCad && ( + {onSelectTaskCad && canPreview && (
); } diff --git a/client/src/pages/Home.tsx b/client/src/pages/Home.tsx index 4056a5e..00c2762 100644 --- a/client/src/pages/Home.tsx +++ b/client/src/pages/Home.tsx @@ -352,6 +352,7 @@ export default function Home() { taskId={selection.taskId} pointCloudLoaded={pointCloudTaskId === selection.taskId && pointCloudUrls.length > 0} taskPcFileCount={taskPcFiles.length} + taskPointcloudFiles={taskPcFiles} selectedPcFileIds={selectedPcFileIds} hasRegisteredCloud={hasRegisteredCloud} selectedCadFileIds={selectedTaskCadFileIds}