From 29a69793dbefabdedd030ceb4c37074dd44f69d1 Mon Sep 17 00:00:00 2001 From: lipan Date: Thu, 13 Aug 2026 16:51:48 +0800 Subject: [PATCH] feat: add inspection object resource workflow --- .../src/components/InspectionObjectConfig.tsx | 176 ++ client/src/components/TaskManager.tsx | 2377 ++++++++++------- client/src/pages/Home.tsx | 6 +- drizzle/0017_inspection_objects.sql | 33 + drizzle/schema.ts | 31 + scripts/migrate-inspection-objects.ts | 60 + server/db.ts | 312 ++- server/fileRoutes.ts | 22 +- server/inspectionObject.test.ts | 75 + server/inspectionTask.test.ts | 27 +- server/routers.ts | 128 +- 11 files changed, 2312 insertions(+), 935 deletions(-) create mode 100644 client/src/components/InspectionObjectConfig.tsx create mode 100644 drizzle/0017_inspection_objects.sql create mode 100644 scripts/migrate-inspection-objects.ts create mode 100644 server/inspectionObject.test.ts diff --git a/client/src/components/InspectionObjectConfig.tsx b/client/src/components/InspectionObjectConfig.tsx new file mode 100644 index 0000000..48340f4 --- /dev/null +++ b/client/src/components/InspectionObjectConfig.tsx @@ -0,0 +1,176 @@ +import { useEffect, useMemo, useState } from 'react'; +import { CheckCircle2, CircleAlert, Plus, Settings2, Trash2 } from 'lucide-react'; +import { trpc } from '@/lib/trpc'; +import { Button } from './ui/button'; +import { + AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, + AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, + AlertDialogTrigger, +} from './ui/alert-dialog'; +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from './ui/dialog'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from './ui/tabs'; +import { toast } from 'sonner'; + +type Props = { modelId: number }; + +const stateLabels = { + missing_pointcloud: '未配置点云', + missing_cad: '未配置 CAD', + ready_draft: '配置完整,未启用', + active: '已启用', +} as const; + +type ReferenceCounts = { + taskReferenceCount: number; + pointcloudReferenceCount: number; + cadReferenceCount: number; +}; + +function referenceReason(object: ReferenceCounts) { + return [ + object.taskReferenceCount > 0 ? `${object.taskReferenceCount} 个任务` : null, + object.pointcloudReferenceCount > 0 ? `${object.pointcloudReferenceCount} 个点云` : null, + object.cadReferenceCount > 0 ? `${object.cadReferenceCount} 个 CAD 关联` : null, + ].filter(Boolean).join('、'); +} + +export default function InspectionObjectConfig({ modelId }: Props) { + const utils = trpc.useUtils(); + const [open, setOpen] = useState(false); + const [name, setName] = useState(''); + const [selectedId, setSelectedId] = useState(null); + const [pointcloudId, setPointcloudId] = useState(null); + const [cadId, setCadId] = useState(null); + const { data: objects = [] } = trpc.inspectionObject.list.useQuery({ modelId }, { enabled: open }); + const { data: pointclouds = [] } = trpc.modelPointcloudFile.list.useQuery({ modelId }, { enabled: open }); + const { data: cadFiles = [] } = trpc.cadFile.list.useQuery({ modelId }, { enabled: open }); + const selected = useMemo(() => objects.find(object => object.id === selectedId), [objects, selectedId]); + + useEffect(() => { + if (!selected && objects.length > 0) setSelectedId(objects[0].id); + }, [objects, selected]); + useEffect(() => { + setPointcloudId(selected?.defaultModelPointcloudId ?? null); + setCadId(selected?.defaultModelCadFileId ?? null); + }, [selected]); + + const invalidate = async () => { + await Promise.all([ + utils.inspectionObject.list.invalidate({ modelId }), + utils.modelPointcloudFile.list.invalidate({ modelId }), + utils.cadFile.list.invalidate({ modelId }), + ]); + }; + const create = trpc.inspectionObject.create.useMutation({ + onSuccess: async object => { setName(''); setSelectedId(object.id); await invalidate(); toast.success('检测对象已创建'); }, + onError: error => toast.error(error.message), + }); + const assignPointcloud = trpc.modelPointcloudFile.assignInspectionObject.useMutation({ + onSuccess: async (_file, variables) => { + await invalidate(); + const objectName = objects.find(object => object.id === variables.inspectionObjectId)?.name; + toast.success(objectName ? `点云已归属到“${objectName}”` : '点云归属已更新'); + }, + onError: error => toast.error(error.message), + }); + const setCadObjects = trpc.cadFile.setInspectionObjects.useMutation({ + onSuccess: async () => { await invalidate(); toast.success('CAD 适用对象已更新'); }, + onError: error => toast.error(error.message), + }); + const configure = trpc.inspectionObject.configureDefaults.useMutation({ + onSuccess: async () => { await invalidate(); toast.success('默认输入方案已保存并启用'); }, + onError: error => toast.error(error.message), + }); + const setStatus = trpc.inspectionObject.setStatus.useMutation({ + onSuccess: async () => { await invalidate(); toast.success('检测对象已停用'); }, + onError: error => toast.error(error.message), + }); + const deleteObject = trpc.inspectionObject.delete.useMutation({ + onSuccess: async () => { + setSelectedId(null); + await invalidate(); + toast.success('检测对象已删除'); + }, + onError: error => toast.error(error.message), + }); + + const compatiblePointclouds = pointclouds.filter(file => file.inspectionObjectId === selectedId && file.status === 'available'); + const compatibleCadFiles = cadFiles.filter(file => file.inspectionObjectIds.includes(selectedId ?? -1) && file.status === 'available'); + + return + + + + + + 检测对象配置 + 资源归属是当前车型的统一设置;只有默认输入方案会随所选检测对象变化,并且仅影响以后创建的任务。 + + + + 对象配置 + 资源归属 + + + +
+ + + {selected ?
+
+
+

{selected.name}

{stateLabels[selected.configurationState]}

+ {selected.status === 'active' ? '已启用' : '草稿'} +
+

默认输入方案

新建“{selected.name}”任务时自动关联并勾选以下资源。

+ + +
+
+ +
+

删除检测对象

仅可删除没有任务、点云和 CAD 关联的空对象,不会自动解绑或迁移历史数据。

+ {selected.canDelete ? + + + 确认删除“{selected.name}”?删除后无法恢复。系统会在执行删除时再次检查任务、点云和 CAD 引用。 + 取消 deleteObject.mutate({ id: selected.id })}>确认删除 + + :

需先处理:{referenceReason(selected)}。

} +
+
:
请先创建检测对象
} +
+
+ + +
+

车型级资源归属

每个点云必须归属一个检测对象;每个 CAD 可适用于多个检测对象,但至少保留一个。

+ {objects.length === 0 &&
请先在“对象配置”中创建检测对象
} + {objects.length > 0 &&
+

车型库点云归属

+ {pointclouds.map(file =>
{file.name}
)} + {pointclouds.length === 0 &&

车型库暂无点云

} +
+

车型库 CAD · 适用对象

+ {cadFiles.map(file =>
{file.name}
{objects.map(object => { const checked = file.inspectionObjectIds.includes(object.id); return ; })}
)} + {cadFiles.length === 0 &&

车型库暂无 CAD

} +
+
} +
+
+
+
+
; +} diff --git a/client/src/components/TaskManager.tsx b/client/src/components/TaskManager.tsx index bd1743f..130f03d 100644 --- a/client/src/components/TaskManager.tsx +++ b/client/src/components/TaskManager.tsx @@ -1,5 +1,5 @@ -import { useRef, useState, useEffect } from 'react'; -import { trpc } from '@/lib/trpc'; +import { useRef, useState, useEffect } from "react"; +import { trpc } from "@/lib/trpc"; import { Plus, Trash2, @@ -16,17 +16,21 @@ import { Library, Pencil, Info, -} from 'lucide-react'; -import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip'; + RotateCcw, +} from "lucide-react"; +import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip"; import { Dialog, DialogContent, DialogDescription, + DialogFooter, DialogHeader, DialogTitle, -} from './ui/dialog'; -import { toast } from 'sonner'; -import { useAuth } from '@/_core/hooks/useAuth'; +} from "./ui/dialog"; +import { toast } from "sonner"; +import { useAuth } from "@/_core/hooks/useAuth"; +import InspectionObjectConfig from "./InspectionObjectConfig"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "./ui/tabs"; interface Props { modelId: number; @@ -45,101 +49,115 @@ interface Props { } function formatSize(bytes: number) { - if (bytes < 1e6) return (bytes / 1024).toFixed(1) + ' KB'; - if (bytes < 1e9) return (bytes / 1e6).toFixed(1) + ' MB'; - return (bytes / 1e9).toFixed(2) + ' GB'; + if (bytes < 1e6) return (bytes / 1024).toFixed(1) + " KB"; + if (bytes < 1e9) return (bytes / 1e6).toFixed(1) + " MB"; + return (bytes / 1e9).toFixed(2) + " GB"; } function compactNumber(value: number, unit: number) { const scaled = value / unit; - return Number.isInteger(scaled) ? String(scaled) : scaled.toFixed(1).replace(/\.0$/, ''); + return Number.isInteger(scaled) + ? String(scaled) + : scaled.toFixed(1).replace(/\.0$/, ""); } function formatCompactPointCount(file: PointcloudMetadataFile) { - if (file.metadataStatus === 'error') return '解析失败'; - if (file.metadataStatus !== 'ready' || file.pointCount == null) return '点数未知'; - if (file.pointCount >= 1e8) return `${compactNumber(file.pointCount, 1e8)}亿点`; - if (file.pointCount >= 1e4) return `${compactNumber(file.pointCount, 1e4)}万点`; - return `${file.pointCount.toLocaleString('zh-CN')}点`; + if (file.metadataStatus === "error") return "解析失败"; + if (file.metadataStatus !== "ready" || file.pointCount == null) + return "点数未知"; + if (file.pointCount >= 1e8) + return `${compactNumber(file.pointCount, 1e8)}亿点`; + if (file.pointCount >= 1e4) + return `${compactNumber(file.pointCount, 1e4)}万点`; + return `${file.pointCount.toLocaleString("zh-CN")}点`; } const pointDataFormatLabels: Record = { - ascii: 'ASCII', - binary_little_endian: '二进制(小端)', - binary_big_endian: '二进制(大端)', + ascii: "ASCII", + binary_little_endian: "二进制(小端)", + binary_big_endian: "二进制(大端)", }; type PointcloudMetadataFile = { name: string; fileSize: number; - metadataStatus: 'pending' | 'ready' | 'unsupported' | 'error'; + metadataStatus: "pending" | "ready" | "unsupported" | "error"; pointCount: number | null; pointAttributes: Array<{ name: string; type: string; bytes: number }> | null; pointDataFormat: string | null; bytesPerPoint: number | null; }; -type PointcloudMetadataDetail = PointcloudMetadataFile & { sourceLabel: string }; +type PointcloudMetadataDetail = PointcloudMetadataFile & { + id: number; + sourceLabel: string; +}; function pointcloudFormat(name: string) { - const extension = name.split('.').pop(); - return extension ? extension.toUpperCase() : '未知'; + const extension = name.split(".").pop(); + return extension ? extension.toUpperCase() : "未知"; } function unsupportedMetadataMessage(name: string) { - const extension = name.split('.').pop()?.toLowerCase(); - if (extension === 'txt' || extension === 'xyz' || extension === 'pts') { - return '文本点云没有标准文件头,当前版本不执行完整扫描。'; + const extension = name.split(".").pop()?.toLowerCase(); + if (extension === "txt" || extension === "xyz" || extension === "pts") { + return "文本点云没有标准文件头,当前版本不执行完整扫描。"; } - return '当前版本暂不支持解析此格式的点云属性。'; + return "当前版本暂不支持解析此格式的点云属性。"; } function formatDateTime(value?: string | Date) { - if (!value) return '未知时间'; + 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 }); + if (Number.isNaN(date.getTime())) return "未知时间"; + return date.toLocaleString("zh-CN", { hour12: false }); } function pointcloudNameParts(name: string, filePath: string) { const physicalName = filePath.split(/[\\/]/).pop() ?? filePath; - const dotIndex = physicalName.lastIndexOf('.'); - const extension = dotIndex > 0 ? physicalName.slice(dotIndex) : ''; - const baseName = extension && name.toLowerCase().endsWith(extension.toLowerCase()) - ? name.slice(0, -extension.length) - : name; + const dotIndex = physicalName.lastIndexOf("."); + const extension = dotIndex > 0 ? physicalName.slice(dotIndex) : ""; + const baseName = + extension && name.toLowerCase().endsWith(extension.toLowerCase()) + ? name.slice(0, -extension.length) + : name; return { baseName, extension }; } type PointcloudRenameTarget = { - scope: 'task' | 'model'; + scope: "task" | "model"; id: number; baseName: string; extension: string; }; -type InspectionTaskStatus = 'created' | 'pending' | 'processing' | 'completed' | 'error'; +type InspectionTaskStatus = + | "created" + | "pending" + | "processing" + | "completed" + | "error"; const taskStatusLabels: Record = { - created: '未开始', - pending: '待处理', - processing: '处理中', - completed: '已完成', - error: '异常', + created: "未开始", + pending: "待处理", + processing: "处理中", + completed: "已完成", + error: "异常", }; function taskStatusClass(status: InspectionTaskStatus) { switch (status) { - case 'completed': - return 'bg-green-900/40 text-green-400'; - case 'processing': - case 'pending': - return 'bg-blue-900/40 text-blue-400'; - case 'error': - return 'bg-red-900/40 text-red-400'; - case 'created': + case "completed": + return "bg-green-900/40 text-green-400"; + case "processing": + case "pending": + return "bg-blue-900/40 text-blue-400"; + case "error": + return "bg-red-900/40 text-red-400"; + case "created": default: - return 'bg-secondary text-muted-foreground'; + return "bg-secondary text-muted-foreground"; } } @@ -157,30 +175,54 @@ export default function TaskManager({ const { canWrite } = useAuth(); const utils = trpc.useUtils(); const [adding, setAdding] = useState(false); - const [taskName, setTaskName] = useState(''); + const [taskName, setTaskName] = useState(""); + const [newTaskInspectionObjectId, setNewTaskInspectionObjectId] = useState< + number | null + >(null); const [uploadingFor, setUploadingFor] = useState(null); const [uploadProgress, setUploadProgress] = useState(0); const fileRef = useRef(null); const modelPointcloudFileRef = useRef(null); + const pcLibraryTriggerRef = useRef(null); const pendingTaskId = useRef(null); + const pendingDefaultSelection = useRef<{ + taskId: number; + pointcloudId: number; + cadId: number; + } | null>(null); const [pcLibraryOpen, setPcLibraryOpen] = useState(false); - const [selectedModelPcFileIds, setSelectedModelPcFileIds] = useState>(new Set()); - const [uploadingModelPointcloud, setUploadingModelPointcloud] = useState(false); - const [renameTarget, setRenameTarget] = useState(null); + const [selectedModelPcFileIds, setSelectedModelPcFileIds] = useState< + Set + >(new Set()); + const [uploadingModelPointcloud, setUploadingModelPointcloud] = + useState(false); + const [renameTarget, setRenameTarget] = + useState(null); const [renameError, setRenameError] = useState(null); - const [metadataDetail, setMetadataDetail] = useState(null); + const [metadataDetail, setMetadataDetail] = + useState(null); + const [libraryMetadataDetail, setLibraryMetadataDetail] = + useState(null); + const activeTaskRowRef = useRef(null); // ── 点云文件多选状态 ────────────────────────────────────────────────────── - const [selectedFileIds, setSelectedFileIds] = useState>(new Set()); + const [selectedFileIds, setSelectedFileIds] = useState>( + new Set() + ); // ── 任务级 CAD 文件多选状态 ─────────────────────────────────────────────── - const [selectedCadFileIds, setSelectedCadFileIds] = useState>(new Set()); + const [selectedCadFileIds, setSelectedCadFileIds] = useState>( + new Set() + ); const cadFileRef = useRef(null); const modelCadFileRef = useRef(null); + const cadLibraryTriggerRef = useRef(null); const [uploadingCadFor, setUploadingCadFor] = useState(null); const [cadUploadProgress, setCadUploadProgress] = useState(0); const [cadLibraryOpen, setCadLibraryOpen] = useState(false); - const [selectedModelCadFileIds, setSelectedModelCadFileIds] = useState>(new Set()); + const [selectedModelCadFileIds, setSelectedModelCadFileIds] = useState< + Set + >(new Set()); const [uploadingModelCad, setUploadingModelCad] = useState(false); const { data: tasks = [] } = trpc.inspectionTask.listByModel.useQuery( @@ -188,96 +230,149 @@ export default function TaskManager({ { refetchInterval: 3000, refetchIntervalInBackground: false, - }, + } + ); + const { data: inspectionObjects = [] } = trpc.inspectionObject.list.useQuery({ + modelId, + activeOnly: true, + }); + const activeTask = tasks.find(task => task.id === activeTaskId); + const activeInspectionObject = inspectionObjects.find( + object => object.id === activeTask?.inspectionObjectId ); const createTask = trpc.inspectionTask.create.useMutation({ - onSuccess: () => { + onSuccess: result => { + pendingDefaultSelection.current = { + taskId: result.task.id, + pointcloudId: result.pointcloudFile.id, + cadId: result.cadFile.id, + }; utils.inspectionTask.listByModel.invalidate({ modelId }); - setTaskName(''); + onSelectTask(result.task.id); + setSelectedFileIds(new Set([result.pointcloudFile.id])); + setSelectedCadFileIds(new Set([result.cadFile.id])); + void utils.pointcloudFile.list.invalidate({ taskId: result.task.id }); + void utils.taskCadFile.list.invalidate({ taskId: result.task.id }); + toast.success("任务已创建,并自动应用默认输入方案"); + setTaskName(""); setAdding(false); onEndCreate?.(); }, - onError: (e) => toast.error(e.message || '新建任务失败'), + onError: e => toast.error(e.message || "新建任务失败"), }); const deleteTask = trpc.inspectionTask.delete.useMutation({ onSuccess: () => utils.inspectionTask.listByModel.invalidate({ modelId }), }); + const classifyTask = trpc.inspectionTask.classify.useMutation({ + onSuccess: () => utils.inspectionTask.listByModel.invalidate({ modelId }), + onError: error => toast.error(error.message), + }); // 当前选中任务下的点云文件 const { data: pcFiles = [] } = trpc.pointcloudFile.list.useQuery( { taskId: activeTaskId! }, - { enabled: activeTaskId != null }, + { enabled: activeTaskId != null } ); const deletePcFile = trpc.pointcloudFile.delete.useMutation({ - onSuccess: () => utils.pointcloudFile.list.invalidate({ taskId: activeTaskId! }), + onSuccess: () => + utils.pointcloudFile.list.invalidate({ taskId: activeTaskId! }), }); const renamePcFile = trpc.pointcloudFile.rename.useMutation({ - onSuccess: (updated) => { + onSuccess: updated => { if (activeTaskId != null) { - utils.pointcloudFile.list.setData({ taskId: activeTaskId }, (current) => - current?.map((file) => (file.id === updated.id ? updated : file)), + utils.pointcloudFile.list.setData({ taskId: activeTaskId }, current => + current?.map(file => (file.id === updated.id ? updated : file)) ); void utils.pointcloudFile.list.invalidate({ taskId: activeTaskId }); } + setLibraryMetadataDetail(null); setRenameTarget(null); setRenameError(null); - toast.success('名称已修改'); + toast.success("名称已修改"); }, - onError: (error) => setRenameError(error.message || '修改名称失败'), + onError: error => setRenameError(error.message || "修改名称失败"), }); - const { data: modelPcFiles = [] } = trpc.modelPointcloudFile.list.useQuery( - { modelId }, - { enabled: activeTaskId != null }, - ); + const { data: modelPcFiles = [] } = + trpc.modelPointcloudFile.listForInspectionObject.useQuery( + { modelId, inspectionObjectId: activeTask?.inspectionObjectId ?? 0 }, + { + enabled: activeTaskId != null && activeTask?.inspectionObjectId != null, + } + ); const renameModelPcFile = trpc.modelPointcloudFile.rename.useMutation({ - onSuccess: (updated) => { - utils.modelPointcloudFile.list.setData({ modelId }, (current) => - current?.map((file) => (file.id === updated.id ? updated : file)), - ); - void utils.modelPointcloudFile.list.invalidate({ modelId }); + onSuccess: updated => { + if (activeTask?.inspectionObjectId) { + void utils.modelPointcloudFile.listForInspectionObject.invalidate({ + modelId, + inspectionObjectId: activeTask.inspectionObjectId, + }); + } setRenameTarget(null); setRenameError(null); - toast.success('名称已修改'); + toast.success("名称已修改"); }, - onError: (error) => setRenameError(error.message || '修改名称失败'), + onError: error => setRenameError(error.message || "修改名称失败"), }); const linkPcFilesFromModel = trpc.pointcloudFile.linkFromModel.useMutation({ onSuccess: () => { - if (activeTaskId != null) utils.pointcloudFile.list.invalidate({ taskId: activeTaskId }); + if (activeTaskId != null) + utils.pointcloudFile.list.invalidate({ taskId: activeTaskId }); setSelectedModelPcFileIds(new Set()); + setLibraryMetadataDetail(null); + setRenameTarget(null); + setRenameError(null); + setPcLibraryOpen(false); + toast.success("点云资源已添加到当前任务"); }, + onError: error => toast.error(error.message || "添加点云资源失败"), }); // 当前选中任务下的 CAD 文件 const { data: taskCadFiles = [] } = trpc.taskCadFile.list.useQuery( { taskId: activeTaskId! }, - { enabled: activeTaskId != null }, + { enabled: activeTaskId != null } ); const deleteTaskCadFile = trpc.taskCadFile.delete.useMutation({ - onSuccess: () => utils.taskCadFile.list.invalidate({ taskId: activeTaskId! }), + onSuccess: () => + utils.taskCadFile.list.invalidate({ taskId: activeTaskId! }), }); - const { data: modelCadFiles = [] } = trpc.cadFile.list.useQuery( - { modelId }, - { enabled: activeTaskId != null }, - ); + const { data: modelCadFiles = [] } = + trpc.cadFile.listForInspectionObject.useQuery( + { modelId, inspectionObjectId: activeTask?.inspectionObjectId ?? 0 }, + { + enabled: activeTaskId != null && activeTask?.inspectionObjectId != null, + } + ); const linkCadFilesFromModel = trpc.taskCadFile.linkFromModel.useMutation({ onSuccess: () => { - if (activeTaskId != null) utils.taskCadFile.list.invalidate({ taskId: activeTaskId }); + if (activeTaskId != null) + utils.taskCadFile.list.invalidate({ taskId: activeTaskId }); setSelectedModelCadFileIds(new Set()); + setCadLibraryOpen(false); + toast.success("CAD 资源已添加到当前任务"); }, + onError: error => toast.error(error.message || "添加 CAD 资源失败"), }); // CAD 三角化采样缓存状态(key: cadFileId, value: 是否已缓存) - const cadFileIds = taskCadFiles.map((f) => f.id); - const { data: cadSampleCacheMap = {} } = trpc.taskCadFile.hasSampleCache.useQuery( - { cadFileIds }, - { enabled: cadFileIds.length > 0, staleTime: 30_000 }, - ); + const cadFileIds = taskCadFiles.map(f => f.id); + const { data: cadSampleCacheMap = {} } = + trpc.taskCadFile.hasSampleCache.useQuery( + { cadFileIds }, + { enabled: cadFileIds.length > 0, staleTime: 30_000 } + ); // 切换任务时清空文件选中状态 useEffect(() => { - setSelectedFileIds(new Set()); - setSelectedCadFileIds(new Set()); + const defaults = pendingDefaultSelection.current; + if (defaults?.taskId === activeTaskId) { + setSelectedFileIds(new Set([defaults.pointcloudId])); + setSelectedCadFileIds(new Set([defaults.cadId])); + pendingDefaultSelection.current = null; + } else { + setSelectedFileIds(new Set()); + setSelectedCadFileIds(new Set()); + } setSelectedModelPcFileIds(new Set()); setSelectedModelCadFileIds(new Set()); setPcLibraryOpen(false); @@ -285,8 +380,18 @@ export default function TaskManager({ setRenameTarget(null); setRenameError(null); setMetadataDetail(null); + setLibraryMetadataDetail(null); }, [activeTaskId]); + // 任务列表独立滚动时,确保新建或刚选中的任务始终留在可视区域内。 + useEffect(() => { + if (activeTaskId == null) return; + const frame = window.requestAnimationFrame(() => { + activeTaskRowRef.current?.scrollIntoView({ block: "nearest" }); + }); + return () => window.cancelAnimationFrame(frame); + }, [activeTaskId, tasks.length]); + // 通知父组件点云选中变化 useEffect(() => { onPcFilesSelectionChange?.(Array.from(selectedFileIds)); @@ -297,19 +402,20 @@ export default function TaskManager({ onCadFilesSelectionChange?.(Array.from(selectedCadFileIds)); }, [selectedCadFileIds, onCadFilesSelectionChange]); - const allSelected = pcFiles.length > 0 && pcFiles.every((f) => selectedFileIds.has(f.id)); - const someSelected = pcFiles.some((f) => selectedFileIds.has(f.id)); + const allSelected = + pcFiles.length > 0 && pcFiles.every(f => selectedFileIds.has(f.id)); + const someSelected = pcFiles.some(f => selectedFileIds.has(f.id)); function toggleAllFiles() { if (allSelected) { setSelectedFileIds(new Set()); } else { - setSelectedFileIds(new Set(pcFiles.map((f) => f.id))); + setSelectedFileIds(new Set(pcFiles.map(f => f.id))); } } function toggleFile(id: number) { - setSelectedFileIds((prev) => { + setSelectedFileIds(prev => { const next = new Set(prev); next.has(id) ? next.delete(id) : next.add(id); return next; @@ -317,7 +423,7 @@ export default function TaskManager({ } function toggleModelPcFile(id: number) { - setSelectedModelPcFileIds((prev) => { + setSelectedModelPcFileIds(prev => { const next = new Set(prev); next.has(id) ? next.delete(id) : next.add(id); return next; @@ -327,11 +433,15 @@ export default function TaskManager({ const renamePending = renamePcFile.isPending || renameModelPcFile.isPending; function startRename( - scope: PointcloudRenameTarget['scope'], - file: { id: number; name: string; filePath: string }, + scope: PointcloudRenameTarget["scope"], + file: { id: number; name: string; filePath: string } ) { if (renamePending) return; - setRenameTarget({ scope, id: file.id, ...pointcloudNameParts(file.name, file.filePath) }); + setRenameTarget({ + scope, + id: file.id, + ...pointcloudNameParts(file.name, file.filePath), + }); setRenameError(null); } @@ -345,7 +455,7 @@ export default function TaskManager({ if (!renameTarget || renamePending) return; setRenameError(null); const input = { id: renameTarget.id, baseName: renameTarget.baseName }; - if (renameTarget.scope === 'task') { + if (renameTarget.scope === "task") { renamePcFile.mutate(input); } else { renameModelPcFile.mutate(input); @@ -360,7 +470,15 @@ export default function TaskManager({ function submitCreateTask() { const name = taskName.trim(); if (!name || createTask.isPending) return; - createTask.mutate({ modelId, name }); + if (!newTaskInspectionObjectId) { + toast.error("请选择检测对象"); + return; + } + createTask.mutate({ + modelId, + inspectionObjectId: newTaskInspectionObjectId, + name, + }); } async function handleUpload(files: FileList) { @@ -372,25 +490,28 @@ export default function TaskManager({ for (let i = 0; i < files.length; i++) { const file = files[i]; const fd = new FormData(); - fd.append('file', file); + fd.append("file", file); const xhr = new XMLHttpRequest(); - xhr.upload.onprogress = (e) => { + xhr.upload.onprogress = e => { if (e.lengthComputable) { const base = (i / files.length) * 100; - setUploadProgress(Math.round(base + (e.loaded / e.total) * (100 / files.length))); + setUploadProgress( + Math.round(base + (e.loaded / e.total) * (100 / files.length)) + ); } }; await new Promise((resolve, reject) => { - xhr.onload = () => (xhr.status < 300 ? resolve() : reject(new Error(xhr.responseText))); - xhr.onerror = () => reject(new Error('Upload failed')); - xhr.open('POST', `/api/files/upload/pointcloud/${taskId}`); + xhr.onload = () => + xhr.status < 300 ? resolve() : reject(new Error(xhr.responseText)); + xhr.onerror = () => reject(new Error("Upload failed")); + xhr.open("POST", `/api/files/upload/pointcloud/${taskId}`); xhr.send(fd); }); } utils.pointcloudFile.list.invalidate({ taskId }); } catch (e) { - console.error('PC upload error', e); - alert('上传失败: ' + (e as Error).message); + console.error("PC upload error", e); + alert("上传失败: " + (e as Error).message); } finally { setUploadingFor(null); setUploadProgress(0); @@ -398,21 +519,30 @@ export default function TaskManager({ } async function handleModelPointcloudUpload(files: FileList) { + if (!activeTask?.inspectionObjectId) return; + const inspectionObjectId = activeTask.inspectionObjectId; setUploadingModelPointcloud(true); try { for (let i = 0; i < files.length; i++) { const fd = new FormData(); - fd.append('file', files[i]); - const res = await fetch(`/api/files/upload/model-pointcloud/${modelId}`, { - method: 'POST', - body: fd, - }); + fd.append("file", files[i]); + fd.append("inspectionObjectId", String(inspectionObjectId)); + const res = await fetch( + `/api/files/upload/model-pointcloud/${modelId}`, + { + method: "POST", + body: fd, + } + ); if (!res.ok) throw new Error(await res.text()); } - utils.modelPointcloudFile.list.invalidate({ modelId }); + void utils.modelPointcloudFile.listForInspectionObject.invalidate({ + modelId, + inspectionObjectId, + }); } catch (e) { - console.error('Model pointcloud upload error', e); - alert('上传失败: ' + (e as Error).message); + console.error("Model pointcloud upload error", e); + alert("上传失败: " + (e as Error).message); } finally { setUploadingModelPointcloud(false); } @@ -425,19 +555,21 @@ export default function TaskManager({ onLoadPointClouds(activeTaskId, ids); } - const allCadSelected = taskCadFiles.length > 0 && taskCadFiles.every((f) => selectedCadFileIds.has(f.id)); - const someCadSelected = taskCadFiles.some((f) => selectedCadFileIds.has(f.id)); + const allCadSelected = + taskCadFiles.length > 0 && + taskCadFiles.every(f => selectedCadFileIds.has(f.id)); + const someCadSelected = taskCadFiles.some(f => selectedCadFileIds.has(f.id)); function toggleAllCadFiles() { if (allCadSelected) { setSelectedCadFileIds(new Set()); } else { - setSelectedCadFileIds(new Set(taskCadFiles.map((f) => f.id))); + setSelectedCadFileIds(new Set(taskCadFiles.map(f => f.id))); } } function toggleCadFile(id: number) { - setSelectedCadFileIds((prev) => { + setSelectedCadFileIds(prev => { const next = new Set(prev); next.has(id) ? next.delete(id) : next.add(id); return next; @@ -445,16 +577,33 @@ export default function TaskManager({ } function toggleModelCadFile(id: number) { - setSelectedModelCadFileIds((prev) => { + setSelectedModelCadFileIds(prev => { const next = new Set(prev); next.has(id) ? next.delete(id) : next.add(id); return next; }); } + function handlePcLibraryOpenChange(open: boolean) { + setPcLibraryOpen(open); + if (!open) { + setSelectedModelPcFileIds(new Set()); + setLibraryMetadataDetail(null); + if (renameTarget?.scope === "model") { + setRenameTarget(null); + setRenameError(null); + } + } + } + + function handleCadLibraryOpenChange(open: boolean) { + setCadLibraryOpen(open); + if (!open) setSelectedModelCadFileIds(new Set()); + } + function taskCadReadUrl(file: { name: string; source?: string }) { const encoded = encodeURIComponent(file.name); - if (file.source === 'model_library') { + if (file.source === "model_library") { return `/api/files/read/cad/${modelId}/${encoded}`; } return `/api/files/read/task-cad/${activeTaskId}/${encoded}`; @@ -462,8 +611,10 @@ export default function TaskManager({ function canPreviewCadIn3D(file: { name: string; format?: string }) { const format = file.format?.toLowerCase(); - const ext = file.name.split('.').pop()?.toLowerCase(); - return format === 'stl' || format === 'obj' || ext === 'stl' || ext === 'obj'; + const ext = file.name.split(".").pop()?.toLowerCase(); + return ( + format === "stl" || format === "obj" || ext === "stl" || ext === "obj" + ); } async function handleCadUpload(files: FileList) { @@ -474,25 +625,28 @@ export default function TaskManager({ for (let i = 0; i < files.length; i++) { const file = files[i]; const fd = new FormData(); - fd.append('file', file); + fd.append("file", file); const xhr = new XMLHttpRequest(); - xhr.upload.onprogress = (e) => { + xhr.upload.onprogress = e => { if (e.lengthComputable) { const base = (i / files.length) * 100; - setCadUploadProgress(Math.round(base + (e.loaded / e.total) * (100 / files.length))); + setCadUploadProgress( + Math.round(base + (e.loaded / e.total) * (100 / files.length)) + ); } }; await new Promise((resolve, reject) => { - xhr.onload = () => (xhr.status < 300 ? resolve() : reject(new Error(xhr.responseText))); - xhr.onerror = () => reject(new Error('Upload failed')); - xhr.open('POST', `/api/files/upload/task-cad/${activeTaskId}`); + xhr.onload = () => + xhr.status < 300 ? resolve() : reject(new Error(xhr.responseText)); + xhr.onerror = () => reject(new Error("Upload failed")); + xhr.open("POST", `/api/files/upload/task-cad/${activeTaskId}`); xhr.send(fd); }); } utils.taskCadFile.list.invalidate({ taskId: activeTaskId }); } catch (e) { - console.error('Task CAD upload error', e); - alert('上传失败: ' + (e as Error).message); + console.error("Task CAD upload error", e); + alert("上传失败: " + (e as Error).message); } finally { setUploadingCadFor(null); setCadUploadProgress(0); @@ -500,121 +654,192 @@ export default function TaskManager({ } async function handleModelCadUpload(files: FileList) { + if (!activeTask?.inspectionObjectId) return; + const inspectionObjectId = activeTask.inspectionObjectId; setUploadingModelCad(true); try { for (let i = 0; i < files.length; i++) { const fd = new FormData(); - fd.append('file', files[i]); + fd.append("file", files[i]); + fd.append("inspectionObjectIds", String(inspectionObjectId)); const res = await fetch(`/api/files/upload/cad/${modelId}`, { - method: 'POST', + method: "POST", body: fd, }); if (!res.ok) throw new Error(await res.text()); } - utils.cadFile.list.invalidate({ modelId }); + void utils.cadFile.listForInspectionObject.invalidate({ + modelId, + inspectionObjectId, + }); } catch (e) { - console.error('Model CAD upload error', e); - alert('上传失败: ' + (e as Error).message); + console.error("Model CAD upload error", e); + alert("上传失败: " + (e as Error).message); } finally { setUploadingModelCad(false); } } return ( -
+
{/* 任务列表 */} -
-
- +
+
+ 检测任务 - {canWrite && } + {canWrite && ( + + )} + {canWrite && }
{canWrite && adding && ( -
- setTaskName(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter' && taskName.trim()) { - submitCreateTask(); - } - if (e.key === 'Escape') { +
+ + {inspectionObjects.length === 0 && ( +

+ 当前车型没有已启用且配置完整的检测对象 +

+ )} +
+ setTaskName(e.target.value)} + onKeyDown={e => { + if ( + e.key === "Enter" && + taskName.trim() && + newTaskInspectionObjectId + ) { + submitCreateTask(); + } + if (e.key === "Escape") { + setAdding(false); + onEndCreate?.(); + } + }} + /> + + - + }} + > + + +
)} -
- {tasks.map((task) => { +
+ {tasks.map(task => { const status = task.status as InspectionTaskStatus; return (
onSelectTask(task.id)} > {task.name} + {task.inspectionObjectId == null && canWrite ? ( + + ) : ( + + {inspectionObjects.find( + object => object.id === task.inspectionObjectId + )?.name ?? "未分类"} + + )} {taskStatusLabels[status] ?? status} - {canWrite && } - {canWrite && } + {canWrite && ( + + )} + {canWrite && ( + + )}
); })} @@ -622,200 +847,323 @@ export default function TaskManager({ {tasks.length === 0 && (
- {canWrite ? '暂无任务,点击新建任务' : '暂无任务'} + {canWrite ? "暂无任务,点击新建任务" : "暂无任务"}
)} -
+
- {/* 选中任务的点云文件 */} + {/* 当前任务资源 */} {activeTaskId != null && ( -
- {/* 标题栏 */} -
-
-
- {/* 全选复选框 */} - {pcFiles.length > 0 && ( + +
+ + 检测对象: + + {activeInspectionObject?.name ?? "未分类"} + + + {activeInspectionObject && ( + + )} +
+ + + 点云文件({pcFiles.length}) + + + 任务 CAD({taskCadFiles.length}) + + + + + {/* 标题栏 */} +
+
+
+ {/* 全选复选框 */} + {pcFiles.length > 0 && ( + + )} + + 点云文件 + + ({someSelected ? `${selectedFileIds.size}/` : ""} + {pcFiles.length}) + + +
+
+
+ {canWrite && ( + )} + {canWrite && ( + + )} + {pcFiles.length > 0 && ( + )} - - 点云文件 - - ({someSelected ? `${selectedFileIds.size}/` : ''}{pcFiles.length}) - -
-
- {canWrite && } - {canWrite && } - {pcFiles.length > 0 && ( - + +
+ {uploadingFor === activeTaskId && ( +
+
+
+
+
+ {uploadProgress}% +
+
)} -
-
- {uploadingFor === activeTaskId && ( -
-
-
-
-
- {uploadProgress}% -
-
- )} - - {canWrite && pcLibraryOpen && ( -
-
- - 车型点云库 ({modelPcFiles.length}) - - -
- -
- {modelPcFiles.map((f) => { - const isChecked = selectedModelPcFileIds.has(f.id); - const isEditing = renameTarget?.scope === 'model' && renameTarget.id === f.id; +
+ {pcFiles.map(f => { + const isChecked = selectedFileIds.has(f.id); + const isEditing = + renameTarget?.scope === "task" && renameTarget.id === 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 (
toggleModelPcFile(f.id)} - onKeyDown={(event) => { - if (isEditing) return; - if (event.key === 'Enter' || event.key === ' ') { - event.preventDefault(); - toggleModelPcFile(f.id); - } - }} + className={`group relative overflow-hidden rounded-lg border text-xs cursor-pointer transition-all + ${ + isChecked + ? "border-cyan-400/55 bg-gradient-to-br from-cyan-500/15 via-cyan-500/5 to-blue-500/10 shadow-[0_0_18px_rgba(34,211,238,0.1)]" + : "border-border/60 bg-gradient-to-br from-cyan-500/[0.06] via-background/50 to-blue-500/[0.05] hover:border-cyan-400/35 hover:from-cyan-500/10" + }`} + onClick={() => toggleFile(f.id)} >
- {isChecked ? ( - - ) : ( - - )} - + + {isEditing && renameTarget ? ( -
event.stopPropagation()}> -
- { - setRenameTarget({ ...renameTarget, baseName: event.target.value }); - setRenameError(null); - }} - onKeyDown={(event) => { - if (event.key === 'Enter') { - event.preventDefault(); +
event.stopPropagation()} + > +
+ { + setRenameTarget({ + ...renameTarget, + baseName: event.target.value, + }); + setRenameError(null); + }} + onKeyDown={event => { + if (event.key === "Enter") { + event.preventDefault(); + submitRename(); + } else if (event.key === "Escape") { + event.preventDefault(); + cancelRename(); + } + }} + /> + + {renameTarget.extension} + + + - -
- {renameError &&
{renameError}
} + }} + > + + +
+ {renameError && ( +
+ {renameError} +
+ )}
) : (
- - - - {f.name} +
+ + + + {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" && ( + + {meta.sourceResultId != null + ? `R${meta.sourceResultId}` + : "配准"} - - {f.name} - + )} + {source === "model_library" && ( + + 车型库 + + )} +
{formatCompactPointCount(f)} @@ -827,27 +1175,52 @@ export default function TaskManager({ - + {canWrite && ( + + )} + {canWrite && ( + + )}
@@ -856,518 +1229,675 @@ export default function TaskManager({
); })} - {modelPcFiles.length === 0 && ( + {pcFiles.length === 0 && (
- 车型点云库为空 + 暂无点云文件
)}
-
- - + {/* 选中提示 */} + {someSelected && ( +

+ 已选 {selectedFileIds.size} 个文件 · + 将作为「加载渲染」及算法输入 +

+ )} +
+ + + {/* 任务级 CAD 文件 */} + +
+
+ + 任务 CAD + + ({taskCadFiles.length}) + + +
+
+ {taskCadFiles.length > 0 && ( + + )} + {canWrite && ( + + )} + {canWrite && ( + + )}
- )} -
- {pcFiles.map((f) => { - const isChecked = selectedFileIds.has(f.id); - const isEditing = renameTarget?.scope === 'task' && renameTarget.id === 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 ( -
toggleFile(f.id)} - > -
-
- - + {uploadingCadFor === activeTaskId && ( +
+
+
- {isEditing && renameTarget ? ( -
event.stopPropagation()}> -
- { - setRenameTarget({ ...renameTarget, baseName: event.target.value }); - setRenameError(null); - }} - onKeyDown={(event) => { - if (event.key === 'Enter') { - event.preventDefault(); - submitRename(); - } else if (event.key === 'Escape') { - event.preventDefault(); - cancelRename(); - } - }} - /> - {renameTarget.extension} - - -
- {renameError &&
{renameError}
} -
- ) : ( -
-
- - - - {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' && ( - - {meta.sourceResultId != null ? `R${meta.sourceResultId}` : '配准'} - - )} - {source === 'model_library' && ( - - 车型库 - - )} -
-
-
- - {formatCompactPointCount(f)} - - - {formatSize(f.fileSize)} - -
-
- - {canWrite && ( - +
+
+ {cadUploadProgress}% +
+
+ )} + +
+ {taskCadFiles.map(f => { + const isChecked = selectedCadFileIds.has(f.id); + const source = (f as { source?: string }).source; + const canPreview = canPreviewCadIn3D(f); + return ( +
toggleCadFile(f.id)} + > + + + + + + {f.name} + + + + {f.name} + + + {source === "model_library" && ( + + 车型库 + + )} + {cadSampleCacheMap[f.id] && ( + + 已预处理 + + )} + + {formatSize(f.fileSize)} + + {onSelectTaskCad && canPreview && ( + + )} + {canWrite && ( + } -
-
-
- )} -
-
- ); - })} - {pcFiles.length === 0 && ( -
- 暂无点云文件 -
- )} -
- - {/* 选中提示 */} - {someSelected && ( -

- 已选 {selectedFileIds.size} 个文件 · 将作为「加载渲染」及算法输入 -

- )} -
- )} - - {/* 任务级 CAD 文件 */} - {activeTaskId != null && ( -
-
-
- - 任务 CAD - - ({taskCadFiles.length}) - - -
-
- {taskCadFiles.length > 0 && ( - - )} - {canWrite && } - {canWrite && } -
-
- - {uploadingCadFor === activeTaskId && ( -
-
-
-
-
- {cadUploadProgress}% -
-
- )} - - {canWrite && cadLibraryOpen && ( -
-
- - 车型 CAD 库 ({modelCadFiles.length}) - - -
- -
- {modelCadFiles.map((f) => { - const isChecked = selectedModelCadFileIds.has(f.id); - return ( - )} - - {f.name} - - {f.format.toUpperCase()} · {formatSize(f.fileSize)} - - +
); })} - {modelCadFiles.length === 0 && ( + {taskCadFiles.length === 0 && (
- 车型 CAD 库为空 + 暂无任务 CAD 文件
)}
-
- - -
+ {selectedCadFileIds.size > 0 && ( +
+

+ 已选 {selectedCadFileIds.size} 个 CAD · 将作为算法输入 +

+ {selectedCadFileIds.size === 1 && + onSelectTaskCad && + (() => { + const f = taskCadFiles.find(f => + selectedCadFileIds.has(f.id) + ); + return f && canPreviewCadIn3D(f) ? ( + + ) : null; + })()} +
+ )}
- )} + + + )} -
- {taskCadFiles.map((f) => { - const isChecked = selectedCadFileIds.has(f.id); - const source = (f as { source?: string }).source; - const canPreview = canPreviewCadIn3D(f); - return ( -
toggleCadFile(f.id)} - > - - - - - {f.name} - - - {f.name} - - - {source === 'model_library' && ( - - 车型库 - - )} - {cadSampleCacheMap[f.id] && ( - - 已预处理 - - )} - - {formatSize(f.fileSize)} - - {onSelectTaskCad && canPreview && ( - - )} - {canWrite && +
+ + +
+
+ {modelPcFiles.map(f => { + const isChecked = selectedModelPcFileIds.has(f.id); + const isEditing = + renameTarget?.scope === "model" && renameTarget.id === f.id; + return ( +
{ + if (!isEditing) toggleModelPcFile(f.id); + }} + onKeyDown={event => { + if ( + !isEditing && + (event.key === "Enter" || event.key === " ") + ) { + event.preventDefault(); + toggleModelPcFile(f.id); + } }} > - - } +
+
+ {isChecked ? ( + + ) : ( + + )} + + {isEditing && renameTarget ? ( +
event.stopPropagation()} + > +
+ { + setRenameTarget({ + ...renameTarget, + baseName: event.target.value, + }); + setRenameError(null); + }} + onKeyDown={event => { + if (event.key === "Enter") { + event.preventDefault(); + submitRename(); + } + if (event.key === "Escape") { + event.preventDefault(); + event.stopPropagation(); + cancelRename(); + } + }} + /> + + {renameTarget.extension} + + + +
+ {renameError && ( +
+ {renameError} +
+ )} +
+ ) : ( +
+
+ {f.name} +
+
+
+ + {formatCompactPointCount(f)} + + + {formatSize(f.fileSize)} + +
+
+ + +
+
+
+ )} +
+
+ ); + })} + {modelPcFiles.length === 0 && ( +
+ 当前检测对象的点云资源库为空
+ )} +
+ + {libraryMetadataDetail && ( + + )} +
+ + + + 已选择 {selectedModelPcFileIds.size} 项 + + + + + + + + + { + event.preventDefault(); + cadLibraryTriggerRef.current?.focus(); + }} + > + +
+
+ + + {activeInspectionObject?.name ?? "当前检测对象"} · CAD 资源 + + + 选择要关联到任务“{activeTask?.name ?? ""}”的 CAD 资源。 + +
+ +
+
+ +
+ {modelCadFiles.map(f => { + const isChecked = selectedModelCadFileIds.has(f.id); + return ( + ); })} - {taskCadFiles.length === 0 && ( -
- 暂无任务 CAD 文件 + {modelCadFiles.length === 0 && ( +
+ 当前检测对象的 CAD 资源库为空
)}
- {selectedCadFileIds.size > 0 && ( -
-

- 已选 {selectedCadFileIds.size} 个 CAD · 将作为算法输入 -

- {selectedCadFileIds.size === 1 && onSelectTaskCad && (() => { - const f = taskCadFiles.find(f => selectedCadFileIds.has(f.id)); - return f && canPreviewCadIn3D(f) ? ( - - ) : null; - })()} -
- )} -
- )} + + + 已选择 {selectedModelCadFileIds.size} 项 + + + + +
+
{ if (!open) setMetadataDetail(null); }} + onOpenChange={open => { + if (!open) setMetadataDetail(null); + }} > 点云属性信息 - 文件头中记录的点数、编码与每点字段。 + + 文件头中记录的点数、编码与每点字段。 + {metadataDetail && (
@@ -1382,60 +1912,83 @@ export default function TaskManager({
文件大小
-
{formatSize(metadataDetail.fileSize)}
+
+ {formatSize(metadataDetail.fileSize)} +
格式
-
{pointcloudFormat(metadataDetail.name)}
+
+ {pointcloudFormat(metadataDetail.name)} +
编码
{metadataDetail.pointDataFormat - ? pointDataFormatLabels[metadataDetail.pointDataFormat] ?? metadataDetail.pointDataFormat - : '未知'} + ? (pointDataFormatLabels[ + metadataDetail.pointDataFormat + ] ?? metadataDetail.pointDataFormat) + : "未知"}
精确点数
- {metadataDetail.metadataStatus === 'ready' && metadataDetail.pointCount != null - ? metadataDetail.pointCount.toLocaleString('en-US') - : '点数未知'} + {metadataDetail.metadataStatus === "ready" && + metadataDetail.pointCount != null + ? metadataDetail.pointCount.toLocaleString("en-US") + : "点数未知"}
每点字节数
- {metadataDetail.bytesPerPoint == null ? '未知' : `${metadataDetail.bytesPerPoint} 字节`} + {metadataDetail.bytesPerPoint == null + ? "未知" + : `${metadataDetail.bytesPerPoint} 字节`}
- {metadataDetail.metadataStatus === 'ready' && metadataDetail.pointAttributes && ( -
- - - - - - - - - - {metadataDetail.pointAttributes.map((attribute, index) => ( - - - - + {metadataDetail.metadataStatus === "ready" && + metadataDetail.pointAttributes && ( +
+
属性名称数据类型字节数
{attribute.name}{attribute.type}{attribute.bytes}
+ + + + + - ))} - -
属性名称数据类型 + 字节数 +
-
- )} + + + {metadataDetail.pointAttributes.map( + (attribute, index) => ( + + + {attribute.name} + + + {attribute.type} + + + {attribute.bytes} + + + ) + )} + + +
+ )} - {metadataDetail.metadataStatus === 'unsupported' && ( + {metadataDetail.metadataStatus === "unsupported" && (
点数未知
@@ -1443,13 +1996,15 @@ export default function TaskManager({
)} - {metadataDetail.metadataStatus === 'error' && ( + {metadataDetail.metadataStatus === "error" && (
属性解析失败
-
无法从该文件的 PLY 文件头读取属性信息。
+
+ 无法从该文件的 PLY 文件头读取属性信息。 +
)} - {metadataDetail.metadataStatus === 'pending' && ( + {metadataDetail.metadataStatus === "pending" && (
属性信息正在解析,请稍后刷新。
@@ -1466,8 +2021,10 @@ export default function TaskManager({ multiple accept=".txt,.xyz,.pts,.ply,.pcd,.las,.laz" className="hidden" - onChange={(e) => - e.target.files && e.target.files.length > 0 && handleUpload(e.target.files) + onChange={e => + e.target.files && + e.target.files.length > 0 && + handleUpload(e.target.files) } /> - e.target.files && e.target.files.length > 0 && handleModelPointcloudUpload(e.target.files) + onChange={e => + e.target.files && + e.target.files.length > 0 && + handleModelPointcloudUpload(e.target.files) } /> - e.target.files && e.target.files.length > 0 && handleCadUpload(e.target.files) + onChange={e => + e.target.files && + e.target.files.length > 0 && + handleCadUpload(e.target.files) } /> - e.target.files && e.target.files.length > 0 && handleModelCadUpload(e.target.files) + onChange={e => + e.target.files && + e.target.files.length > 0 && + handleModelCadUpload(e.target.files) } />
diff --git a/client/src/pages/Home.tsx b/client/src/pages/Home.tsx index 66acdd0..ae1e346 100644 --- a/client/src/pages/Home.tsx +++ b/client/src/pages/Home.tsx @@ -284,7 +284,7 @@ export default function Home() { ); } return ( - <> +
- +
); }; @@ -475,7 +475,7 @@ export default function Home() {
{/* 左侧面板 */} {showLeftPanel && ( -