Compare commits
3 Commits
7e2e56a50d
...
aa2bd80418
| Author | SHA1 | Date | |
|---|---|---|---|
| aa2bd80418 | |||
| dc760de7a2 | |||
| b5440fa1c3 |
@@ -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<string, unknown> {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const field of plugin.inputs) {
|
||||
@@ -68,7 +86,7 @@ function defaultParamsFor(plugin: PluginSummary): Record<string, unknown> {
|
||||
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
|
||||
<button
|
||||
type="button"
|
||||
className={`industrial-btn industrial-btn-primary py-0.5 text-[10px] flex items-center gap-0.5 ${
|
||||
canRun && selectedRegIds.length > 0 ? '' : 'opacity-50 cursor-not-allowed'
|
||||
canRunRegistration ? '' : 'opacity-50 cursor-not-allowed'
|
||||
}`}
|
||||
disabled={!canRun || selectedRegIds.length === 0}
|
||||
onClick={() => handleRun(selectedRegIds)}
|
||||
title={selectedRegIds.length === 0 ? '请勾选配准算法' : '执行配准'}
|
||||
disabled={!canRunRegistration}
|
||||
onClick={() => handleRun(selectedRegIds, 'registration')}
|
||||
title={registrationBlockReason ?? '执行配准'}
|
||||
>
|
||||
{runBatch.isPending ? <RefreshCw className="w-3 h-3 animate-spin" /> : <Play className="w-3 h-3" />}
|
||||
执行({selectedRegIds.length})
|
||||
@@ -182,6 +259,18 @@ export default function AlgorithmRunPanel({ taskId, pointCloudLoaded: _pointClou
|
||||
{regPlugins.length === 0 && !isLoading && (
|
||||
<div className="text-[10px] text-muted-foreground italic px-1">暂无配准插件</div>
|
||||
)}
|
||||
{selectedRegIds.length > 0 && registrationBlockReason && !baseRunBlockReason && (
|
||||
<div className="flex items-start gap-1.5 bg-amber-900/20 border border-amber-700/40 rounded p-2">
|
||||
<AlertTriangle className="w-3 h-3 text-amber-400 flex-shrink-0 mt-0.5" />
|
||||
<span className="text-[10px] text-amber-300">{registrationBlockReason}</span>
|
||||
</div>
|
||||
)}
|
||||
{selectedRegIds.length > 0 && !registrationBlockReason && duplicateRegistrationWarning && (
|
||||
<div className="flex items-start gap-1.5 bg-amber-900/20 border border-amber-700/40 rounded p-2">
|
||||
<AlertTriangle className="w-3 h-3 text-amber-400 flex-shrink-0 mt-0.5" />
|
||||
<span className="text-[10px] text-amber-300">{duplicateRegistrationWarning}</span>
|
||||
</div>
|
||||
)}
|
||||
{regPlugins.map((p) => (
|
||||
<PluginCard
|
||||
key={p.id}
|
||||
@@ -219,16 +308,11 @@ export default function AlgorithmRunPanel({ taskId, pointCloudLoaded: _pointClou
|
||||
<button
|
||||
type="button"
|
||||
className={`industrial-btn industrial-btn-primary py-0.5 text-[10px] flex items-center gap-0.5 ${
|
||||
canRun && selectedMeasIds.length > 0 ? '' : 'opacity-50 cursor-not-allowed'
|
||||
canRunMeasurement ? '' : 'opacity-50 cursor-not-allowed'
|
||||
}`}
|
||||
disabled={!canRun || selectedMeasIds.length === 0}
|
||||
onClick={() => handleRun(selectedMeasIds)}
|
||||
title={
|
||||
taskId == null ? '请先选择检测任务'
|
||||
: (taskPcFileCount ?? 0) === 0 ? '请先上传点云文件'
|
||||
: selectedMeasIds.length === 0 ? '请至少勾选一个测量算法'
|
||||
: '并发执行已勾选算法'
|
||||
}
|
||||
disabled={!canRunMeasurement}
|
||||
onClick={() => handleRun(selectedMeasIds, 'measurement')}
|
||||
title={measurementBlockReason ?? '并发执行已勾选算法'}
|
||||
>
|
||||
{runBatch.isPending ? <RefreshCw className="w-3 h-3 animate-spin" /> : <Play className="w-3 h-3" />}
|
||||
执行({selectedMeasIds.length})
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState, useEffect } from 'react';
|
||||
import { trpc } from '@/lib/trpc';
|
||||
import { BarChart2, Ruler, Zap, Play, Trash2, ChevronDown, ChevronRight, RefreshCw, Flame, Box, Layers, Crosshair, Cpu, Timer } from 'lucide-react';
|
||||
import GenericResultView, { type Overlay3D } from './GenericResultView';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
|
||||
|
||||
/** 将毫秒数格式化为可读时长,如 2m 34s / 47s / 1h 5m */
|
||||
function fmtDuration(ms: number): string {
|
||||
@@ -162,6 +163,16 @@ type AssemblyMeta = SmoothHeatmapMeta & {
|
||||
parts?: AssemblyPartRow[];
|
||||
};
|
||||
|
||||
function getResultTitle(r: { type: string; pluginId?: string }) {
|
||||
if (r.type === 'error_analysis') return '误差分析';
|
||||
if (r.type === 'measurement') return '尺寸测量';
|
||||
if (r.type === 'smooth_heatmap') return '平滑热力';
|
||||
if (r.type === 'assembly_inspection') return '装配检测';
|
||||
if (r.type === 'obj_deviation') return '偏差热力';
|
||||
if (r.type === 'plugin') return `算法 · ${r.pluginId ?? '插件'}`;
|
||||
return '压缩';
|
||||
}
|
||||
|
||||
export default function AnalysisPanel({ taskId, onReplay, onViewSmoothHeatmap, onRunAssembly, onRunDeviation, externalPending, onViewPluginOverlay }: Props) {
|
||||
const utils = trpc.useUtils();
|
||||
const [expandedIds, setExpandedIds] = useState<Set<number>>(new Set());
|
||||
@@ -243,12 +254,13 @@ export default function AnalysisPanel({ taskId, onReplay, onViewSmoothHeatmap, o
|
||||
const expanded = expandedIds.has(r.id);
|
||||
const errorStats = r.errorStats as ErrorStats | null;
|
||||
const measurements = r.measurements as Measurement[] | null;
|
||||
const resultTitle = getResultTitle(r as { type: string; pluginId?: string });
|
||||
|
||||
return (
|
||||
<div key={r.id} className="industrial-card rounded overflow-hidden">
|
||||
<div key={r.id} className="industrial-card rounded overflow-hidden min-w-0">
|
||||
{/* 标题行 */}
|
||||
<div
|
||||
className="flex items-center gap-2 px-2 py-1.5 cursor-pointer hover:bg-secondary/40 group"
|
||||
className="flex items-center gap-2 px-2 py-1.5 cursor-pointer hover:bg-secondary/40 group min-w-0"
|
||||
onClick={() => toggleExpand(r.id)}
|
||||
>
|
||||
{expanded ? <ChevronDown className="w-3 h-3 text-muted-foreground" /> : <ChevronRight className="w-3 h-3 text-muted-foreground" />}
|
||||
@@ -259,21 +271,18 @@ export default function AnalysisPanel({ taskId, onReplay, onViewSmoothHeatmap, o
|
||||
{r.type === 'assembly_inspection' && <Layers className="w-3.5 h-3.5 text-cyan-400 flex-shrink-0" />}
|
||||
{r.type === 'obj_deviation' && <Crosshair className="w-3.5 h-3.5 text-rose-400 flex-shrink-0" />}
|
||||
{r.type === 'plugin' && <Cpu className="w-3.5 h-3.5 text-violet-400 flex-shrink-0" />}
|
||||
<span className="text-xs flex-1 truncate">
|
||||
{r.type === 'error_analysis'
|
||||
? '误差分析'
|
||||
: r.type === 'measurement'
|
||||
? '尺寸测量'
|
||||
: r.type === 'smooth_heatmap'
|
||||
? '平滑热力'
|
||||
: r.type === 'assembly_inspection'
|
||||
? '装配检测'
|
||||
: r.type === 'obj_deviation'
|
||||
? '偏差热力'
|
||||
: r.type === 'plugin'
|
||||
? `算法 · ${(r as { pluginId?: string }).pluginId ?? '插件'}`
|
||||
: '压缩'}
|
||||
</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="text-xs flex-1 min-w-0 truncate">{resultTitle}</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="left"
|
||||
align="start"
|
||||
className="max-w-[min(28rem,calc(100vw-2rem))] break-all"
|
||||
>
|
||||
{resultTitle}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{new Date(r.createdAt).toLocaleDateString('zh-CN')}
|
||||
</span>
|
||||
|
||||
@@ -13,7 +13,9 @@ import {
|
||||
Square,
|
||||
FileBox,
|
||||
Eye,
|
||||
Library,
|
||||
} from 'lucide-react';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
|
||||
|
||||
interface Props {
|
||||
modelId: number;
|
||||
@@ -37,6 +39,13 @@ function formatSize(bytes: number) {
|
||||
return (bytes / 1e9).toFixed(2) + ' GB';
|
||||
}
|
||||
|
||||
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 });
|
||||
}
|
||||
|
||||
export default function TaskManager({
|
||||
modelId,
|
||||
activeTaskId,
|
||||
@@ -54,7 +63,11 @@ export default function TaskManager({
|
||||
const [uploadingFor, setUploadingFor] = useState<number | null>(null);
|
||||
const [uploadProgress, setUploadProgress] = useState(0);
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const modelPointcloudFileRef = useRef<HTMLInputElement>(null);
|
||||
const pendingTaskId = useRef<number | null>(null);
|
||||
const [pcLibraryOpen, setPcLibraryOpen] = useState(false);
|
||||
const [selectedModelPcFileIds, setSelectedModelPcFileIds] = useState<Set<number>>(new Set());
|
||||
const [uploadingModelPointcloud, setUploadingModelPointcloud] = useState(false);
|
||||
|
||||
// ── 点云文件多选状态 ──────────────────────────────────────────────────────
|
||||
const [selectedFileIds, setSelectedFileIds] = useState<Set<number>>(new Set());
|
||||
@@ -62,8 +75,12 @@ export default function TaskManager({
|
||||
// ── 任务级 CAD 文件多选状态 ───────────────────────────────────────────────
|
||||
const [selectedCadFileIds, setSelectedCadFileIds] = useState<Set<number>>(new Set());
|
||||
const cadFileRef = useRef<HTMLInputElement>(null);
|
||||
const modelCadFileRef = useRef<HTMLInputElement>(null);
|
||||
const [uploadingCadFor, setUploadingCadFor] = useState<number | null>(null);
|
||||
const [cadUploadProgress, setCadUploadProgress] = useState(0);
|
||||
const [cadLibraryOpen, setCadLibraryOpen] = useState(false);
|
||||
const [selectedModelCadFileIds, setSelectedModelCadFileIds] = useState<Set<number>>(new Set());
|
||||
const [uploadingModelCad, setUploadingModelCad] = useState(false);
|
||||
|
||||
const { data: tasks = [] } = trpc.inspectionTask.listByModel.useQuery({ modelId });
|
||||
const createTask = trpc.inspectionTask.create.useMutation({
|
||||
@@ -81,6 +98,16 @@ export default function TaskManager({
|
||||
const deletePcFile = trpc.pointcloudFile.delete.useMutation({
|
||||
onSuccess: () => utils.pointcloudFile.list.invalidate({ taskId: activeTaskId! }),
|
||||
});
|
||||
const { data: modelPcFiles = [] } = trpc.modelPointcloudFile.list.useQuery(
|
||||
{ modelId },
|
||||
{ enabled: activeTaskId != null },
|
||||
);
|
||||
const linkPcFilesFromModel = trpc.pointcloudFile.linkFromModel.useMutation({
|
||||
onSuccess: () => {
|
||||
if (activeTaskId != null) utils.pointcloudFile.list.invalidate({ taskId: activeTaskId });
|
||||
setSelectedModelPcFileIds(new Set());
|
||||
},
|
||||
});
|
||||
|
||||
// 当前选中任务下的 CAD 文件
|
||||
const { data: taskCadFiles = [] } = trpc.taskCadFile.list.useQuery(
|
||||
@@ -90,6 +117,16 @@ export default function TaskManager({
|
||||
const deleteTaskCadFile = trpc.taskCadFile.delete.useMutation({
|
||||
onSuccess: () => utils.taskCadFile.list.invalidate({ taskId: activeTaskId! }),
|
||||
});
|
||||
const { data: modelCadFiles = [] } = trpc.cadFile.list.useQuery(
|
||||
{ modelId },
|
||||
{ enabled: activeTaskId != null },
|
||||
);
|
||||
const linkCadFilesFromModel = trpc.taskCadFile.linkFromModel.useMutation({
|
||||
onSuccess: () => {
|
||||
if (activeTaskId != null) utils.taskCadFile.list.invalidate({ taskId: activeTaskId });
|
||||
setSelectedModelCadFileIds(new Set());
|
||||
},
|
||||
});
|
||||
|
||||
// CAD 三角化采样缓存状态(key: cadFileId, value: 是否已缓存)
|
||||
const cadFileIds = taskCadFiles.map((f) => f.id);
|
||||
@@ -102,6 +139,10 @@ export default function TaskManager({
|
||||
useEffect(() => {
|
||||
setSelectedFileIds(new Set());
|
||||
setSelectedCadFileIds(new Set());
|
||||
setSelectedModelPcFileIds(new Set());
|
||||
setSelectedModelCadFileIds(new Set());
|
||||
setPcLibraryOpen(false);
|
||||
setCadLibraryOpen(false);
|
||||
}, [activeTaskId]);
|
||||
|
||||
// 通知父组件点云选中变化
|
||||
@@ -133,6 +174,14 @@ export default function TaskManager({
|
||||
});
|
||||
}
|
||||
|
||||
function toggleModelPcFile(id: number) {
|
||||
setSelectedModelPcFileIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.has(id) ? next.delete(id) : next.add(id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function openUpload(taskId: number) {
|
||||
pendingTaskId.current = taskId;
|
||||
fileRef.current?.click();
|
||||
@@ -172,6 +221,27 @@ export default function TaskManager({
|
||||
}
|
||||
}
|
||||
|
||||
async function handleModelPointcloudUpload(files: FileList) {
|
||||
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,
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
}
|
||||
utils.modelPointcloudFile.list.invalidate({ modelId });
|
||||
} catch (e) {
|
||||
console.error('Model pointcloud upload error', e);
|
||||
alert('上传失败: ' + (e as Error).message);
|
||||
} finally {
|
||||
setUploadingModelPointcloud(false);
|
||||
}
|
||||
}
|
||||
|
||||
// 加载按钮:有勾选就只加载勾选的,否则加载全部
|
||||
function handleLoad() {
|
||||
if (activeTaskId == null || pcFiles.length === 0) return;
|
||||
@@ -198,6 +268,28 @@ export default function TaskManager({
|
||||
});
|
||||
}
|
||||
|
||||
function toggleModelCadFile(id: number) {
|
||||
setSelectedModelCadFileIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.has(id) ? next.delete(id) : next.add(id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function taskCadReadUrl(file: { name: string; source?: string }) {
|
||||
const encoded = encodeURIComponent(file.name);
|
||||
if (file.source === 'model_library') {
|
||||
return `/api/files/read/cad/${modelId}/${encoded}`;
|
||||
}
|
||||
return `/api/files/read/task-cad/${activeTaskId}/${encoded}`;
|
||||
}
|
||||
|
||||
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';
|
||||
}
|
||||
|
||||
async function handleCadUpload(files: FileList) {
|
||||
if (activeTaskId == null) return;
|
||||
setUploadingCadFor(activeTaskId);
|
||||
@@ -231,6 +323,27 @@ export default function TaskManager({
|
||||
}
|
||||
}
|
||||
|
||||
async function handleModelCadUpload(files: FileList) {
|
||||
setUploadingModelCad(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/cad/${modelId}`, {
|
||||
method: 'POST',
|
||||
body: fd,
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
}
|
||||
utils.cadFile.list.invalidate({ modelId });
|
||||
} catch (e) {
|
||||
console.error('Model CAD upload error', e);
|
||||
alert('上传失败: ' + (e as Error).message);
|
||||
} finally {
|
||||
setUploadingModelCad(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* 任务列表 */}
|
||||
@@ -353,40 +466,50 @@ export default function TaskManager({
|
||||
{activeTaskId != null && (
|
||||
<div>
|
||||
{/* 标题栏 */}
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{/* 全选复选框 */}
|
||||
{pcFiles.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
title={allSelected ? '取消全选' : '全选'}
|
||||
onClick={toggleAllFiles}
|
||||
>
|
||||
{allSelected ? (
|
||||
<CheckSquare className="w-3.5 h-3.5 text-primary" />
|
||||
) : (
|
||||
<Square className="w-3.5 h-3.5" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
<span className="industrial-label flex items-center gap-1">
|
||||
<Cloud className="w-3.5 h-3.5" /> 点云文件
|
||||
<span className="text-[10px] text-muted-foreground font-normal">
|
||||
({someSelected ? `${selectedFileIds.size}/` : ''}{pcFiles.length})
|
||||
<div className="mb-1.5 space-y-1.5">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
{/* 全选复选框 */}
|
||||
{pcFiles.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
className="text-muted-foreground hover:text-foreground flex-shrink-0"
|
||||
title={allSelected ? '取消全选' : '全选'}
|
||||
onClick={toggleAllFiles}
|
||||
>
|
||||
{allSelected ? (
|
||||
<CheckSquare className="w-3.5 h-3.5 text-primary" />
|
||||
) : (
|
||||
<Square className="w-3.5 h-3.5" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
<span className="industrial-label flex items-center gap-1 whitespace-nowrap">
|
||||
<Cloud className="w-3.5 h-3.5" /> 点云文件
|
||||
<span className="text-[10px] text-muted-foreground font-normal">
|
||||
({someSelected ? `${selectedFileIds.size}/` : ''}{pcFiles.length})
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
className="industrial-btn py-0.5 text-[10px] flex items-center gap-0.5"
|
||||
className="industrial-btn flex-1 min-w-0 justify-center whitespace-nowrap py-0.5 text-[10px] flex items-center gap-0.5"
|
||||
onClick={() => openUpload(activeTaskId)}
|
||||
>
|
||||
<Upload className="w-3 h-3" /> 上传
|
||||
<Upload className="w-3 h-3" /> 上传到任务
|
||||
</button>
|
||||
<button
|
||||
className={`industrial-btn flex-1 min-w-0 justify-center whitespace-nowrap py-0.5 text-[10px] flex items-center gap-0.5 ${
|
||||
pcLibraryOpen ? 'text-primary border-primary/50' : ''
|
||||
}`}
|
||||
onClick={() => setPcLibraryOpen((open) => !open)}
|
||||
>
|
||||
<Library className="w-3 h-3" /> 从车型库选择
|
||||
</button>
|
||||
{pcFiles.length > 0 && (
|
||||
<button
|
||||
className="industrial-btn industrial-btn-primary py-0.5 text-[10px] flex items-center gap-0.5"
|
||||
className="industrial-btn industrial-btn-primary flex-1 min-w-0 justify-center whitespace-nowrap py-0.5 text-[10px] flex items-center gap-0.5"
|
||||
title={
|
||||
someSelected
|
||||
? `加载选中的 ${selectedFileIds.size} 个文件`
|
||||
@@ -412,9 +535,84 @@ export default function TaskManager({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pcLibraryOpen && (
|
||||
<div className="industrial-card rounded p-2 mb-2 space-y-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
车型点云库 ({modelPcFiles.length})
|
||||
</span>
|
||||
<button
|
||||
className="industrial-btn py-0.5 text-[10px] flex items-center gap-0.5"
|
||||
disabled={uploadingModelPointcloud}
|
||||
onClick={() => modelPointcloudFileRef.current?.click()}
|
||||
>
|
||||
<Upload className="w-3 h-3" />
|
||||
{uploadingModelPointcloud ? '上传中' : '上传到车型库'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1 max-h-40 overflow-auto">
|
||||
{modelPcFiles.map((f) => {
|
||||
const isChecked = selectedModelPcFileIds.has(f.id);
|
||||
return (
|
||||
<button
|
||||
key={f.id}
|
||||
type="button"
|
||||
className={`w-full flex items-center gap-2 px-2 py-1.5 rounded text-xs text-left min-w-0 ${
|
||||
isChecked ? 'bg-primary/10 border border-primary/30' : 'bg-secondary/40 hover:bg-secondary/70'
|
||||
}`}
|
||||
onClick={() => toggleModelPcFile(f.id)}
|
||||
>
|
||||
{isChecked ? (
|
||||
<CheckSquare className="w-3.5 h-3.5 text-primary flex-shrink-0" />
|
||||
) : (
|
||||
<Square className="w-3.5 h-3.5 text-muted-foreground flex-shrink-0" />
|
||||
)}
|
||||
<Cloud className="w-3.5 h-3.5 text-cyan-400 flex-shrink-0" />
|
||||
<span className="flex-1 min-w-0 truncate" title={f.name}>{f.name}</span>
|
||||
<span className="text-muted-foreground text-[10px] flex-shrink-0">
|
||||
{formatSize(f.fileSize)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{modelPcFiles.length === 0 && (
|
||||
<div className="text-xs text-muted-foreground text-center py-3">
|
||||
车型点云库为空
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="industrial-btn industrial-btn-primary w-full py-1 text-[10px] flex items-center justify-center gap-1"
|
||||
disabled={activeTaskId == null || selectedModelPcFileIds.size === 0 || linkPcFilesFromModel.isPending}
|
||||
onClick={() => {
|
||||
if (activeTaskId == null || selectedModelPcFileIds.size === 0) return;
|
||||
linkPcFilesFromModel.mutate({
|
||||
taskId: activeTaskId,
|
||||
modelPointcloudFileIds: Array.from(selectedModelPcFileIds),
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Plus className="w-3 h-3" />
|
||||
添加到当前任务{selectedModelPcFileIds.size > 0 ? ` (${selectedModelPcFileIds.size})` : ''}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1">
|
||||
{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 (
|
||||
<div
|
||||
key={f.id}
|
||||
@@ -440,14 +638,50 @@ export default function TaskManager({
|
||||
<Cloud
|
||||
className={`w-3.5 h-3.5 flex-shrink-0 ${isChecked ? 'text-primary' : 'text-cyan-400'}`}
|
||||
/>
|
||||
<span className="flex-1 truncate" title={f.name}>
|
||||
{f.name}
|
||||
</span>
|
||||
{(f as { source?: string }).source === 'registered' && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="flex-1 min-w-0 truncate">{f.name}</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="right"
|
||||
align="start"
|
||||
className="max-w-[min(32rem,calc(100vw-2rem))]"
|
||||
>
|
||||
<div className="space-y-1 text-[11px]">
|
||||
<div className="break-all">{f.name}</div>
|
||||
{source === 'registered' && (
|
||||
<>
|
||||
<div>类型:已配准点云</div>
|
||||
{meta.sourceResultId != null && <div>结果编号:R{meta.sourceResultId}</div>}
|
||||
<div>生成时间:{formatDateTime(meta.createdAt)}</div>
|
||||
<div>
|
||||
来源:
|
||||
{sourcePointcloud
|
||||
? sourcePointcloud.name
|
||||
: meta.sourcePointcloudFileId != null
|
||||
? `点云 #${meta.sourcePointcloudFileId}`
|
||||
: '旧记录未记录来源'}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{source === 'registered' && (
|
||||
<span className="text-[9px] px-1 py-0.5 rounded bg-blue-900/40 text-blue-300 flex-shrink-0 whitespace-nowrap">
|
||||
已配准
|
||||
</span>
|
||||
)}
|
||||
{source === 'registered' && meta.sourceResultId != null && (
|
||||
<span className="text-[9px] px-1 py-0.5 rounded bg-sky-500/15 text-sky-300 border border-sky-500/30 flex-shrink-0 whitespace-nowrap">
|
||||
R{meta.sourceResultId}
|
||||
</span>
|
||||
)}
|
||||
{source === 'model_library' && (
|
||||
<span className="text-[9px] px-1 py-0.5 rounded bg-amber-500/15 text-amber-300 border border-amber-500/30 flex-shrink-0 whitespace-nowrap">
|
||||
车型库
|
||||
</span>
|
||||
)}
|
||||
<span className="text-muted-foreground text-[10px] flex-shrink-0">
|
||||
{formatSize(f.fileSize)}
|
||||
</span>
|
||||
@@ -483,17 +717,19 @@ export default function TaskManager({
|
||||
{/* 任务级 CAD 文件 */}
|
||||
{activeTaskId != null && (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<span className="industrial-label flex items-center gap-1">
|
||||
<FileBox className="w-3.5 h-3.5" /> 任务 CAD
|
||||
<span className="text-[10px] text-muted-foreground font-normal">
|
||||
({taskCadFiles.length})
|
||||
<div className="mb-1.5 space-y-1.5">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="industrial-label flex items-center gap-1 whitespace-nowrap">
|
||||
<FileBox className="w-3.5 h-3.5" /> 任务 CAD
|
||||
<span className="text-[10px] text-muted-foreground font-normal">
|
||||
({taskCadFiles.length})
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{taskCadFiles.length > 0 && (
|
||||
<button
|
||||
className="industrial-btn py-0.5 text-[10px] flex items-center gap-0.5"
|
||||
className="industrial-btn flex-1 min-w-0 justify-center whitespace-nowrap py-0.5 text-[10px] flex items-center gap-0.5"
|
||||
onClick={toggleAllCadFiles}
|
||||
title={allCadSelected ? '取消全选' : '全选'}
|
||||
>
|
||||
@@ -504,10 +740,18 @@ export default function TaskManager({
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="industrial-btn py-0.5 text-[10px] flex items-center gap-0.5"
|
||||
className="industrial-btn flex-1 min-w-0 justify-center whitespace-nowrap py-0.5 text-[10px] flex items-center gap-0.5"
|
||||
onClick={() => cadFileRef.current?.click()}
|
||||
>
|
||||
<Upload className="w-3 h-3" /> 上传
|
||||
<Upload className="w-3 h-3" /> 上传到任务
|
||||
</button>
|
||||
<button
|
||||
className={`industrial-btn flex-1 min-w-0 justify-center whitespace-nowrap py-0.5 text-[10px] flex items-center gap-0.5 ${
|
||||
cadLibraryOpen ? 'text-primary border-primary/50' : ''
|
||||
}`}
|
||||
onClick={() => setCadLibraryOpen((open) => !open)}
|
||||
>
|
||||
<Library className="w-3 h-3" /> 从车型库选择
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -523,13 +767,80 @@ export default function TaskManager({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{cadLibraryOpen && (
|
||||
<div className="industrial-card rounded p-2 mb-2 space-y-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
车型 CAD 库 ({modelCadFiles.length})
|
||||
</span>
|
||||
<button
|
||||
className="industrial-btn py-0.5 text-[10px] flex items-center gap-0.5"
|
||||
disabled={uploadingModelCad}
|
||||
onClick={() => modelCadFileRef.current?.click()}
|
||||
>
|
||||
<Upload className="w-3 h-3" />
|
||||
{uploadingModelCad ? '上传中' : '上传到车型库'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1 max-h-40 overflow-auto">
|
||||
{modelCadFiles.map((f) => {
|
||||
const isChecked = selectedModelCadFileIds.has(f.id);
|
||||
return (
|
||||
<button
|
||||
key={f.id}
|
||||
type="button"
|
||||
className={`w-full flex items-center gap-2 px-2 py-1.5 rounded text-xs text-left min-w-0 ${
|
||||
isChecked ? 'bg-primary/10 border border-primary/30' : 'bg-secondary/40 hover:bg-secondary/70'
|
||||
}`}
|
||||
onClick={() => toggleModelCadFile(f.id)}
|
||||
>
|
||||
{isChecked ? (
|
||||
<CheckSquare className="w-3.5 h-3.5 text-primary flex-shrink-0" />
|
||||
) : (
|
||||
<Square className="w-3.5 h-3.5 text-muted-foreground flex-shrink-0" />
|
||||
)}
|
||||
<FileBox className="w-3.5 h-3.5 text-blue-400 flex-shrink-0" />
|
||||
<span className="flex-1 min-w-0 truncate" title={f.name}>{f.name}</span>
|
||||
<span className="text-muted-foreground text-[10px] flex-shrink-0">
|
||||
{f.format.toUpperCase()} · {formatSize(f.fileSize)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{modelCadFiles.length === 0 && (
|
||||
<div className="text-xs text-muted-foreground text-center py-3">
|
||||
车型 CAD 库为空
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="industrial-btn industrial-btn-primary w-full py-1 text-[10px] flex items-center justify-center gap-1"
|
||||
disabled={activeTaskId == null || selectedModelCadFileIds.size === 0 || linkCadFilesFromModel.isPending}
|
||||
onClick={() => {
|
||||
if (activeTaskId == null || selectedModelCadFileIds.size === 0) return;
|
||||
linkCadFilesFromModel.mutate({
|
||||
taskId: activeTaskId,
|
||||
modelCadFileIds: Array.from(selectedModelCadFileIds),
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Plus className="w-3 h-3" />
|
||||
添加到当前任务{selectedModelCadFileIds.size > 0 ? ` (${selectedModelCadFileIds.size})` : ''}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1">
|
||||
{taskCadFiles.map((f) => {
|
||||
const isChecked = selectedCadFileIds.has(f.id);
|
||||
const source = (f as { source?: string }).source;
|
||||
const canPreview = canPreviewCadIn3D(f);
|
||||
return (
|
||||
<div
|
||||
key={f.id}
|
||||
className={`flex items-center gap-2 px-2 py-1.5 rounded text-xs group cursor-pointer
|
||||
className={`flex items-center gap-2 px-2 py-1.5 rounded text-xs group cursor-pointer min-w-0
|
||||
${isChecked ? 'bg-primary/10 border border-primary/30' : 'industrial-card'}`}
|
||||
onClick={() => toggleCadFile(f.id)}
|
||||
>
|
||||
@@ -545,7 +856,23 @@ export default function TaskManager({
|
||||
)}
|
||||
</button>
|
||||
<FileBox className={`w-3.5 h-3.5 flex-shrink-0 ${isChecked ? 'text-primary' : 'text-blue-400'}`} />
|
||||
<span className="flex-1 truncate" title={f.name}>{f.name}</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="flex-1 min-w-0 truncate">{f.name}</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="right"
|
||||
align="start"
|
||||
className="max-w-[min(32rem,calc(100vw-2rem))] break-all"
|
||||
>
|
||||
{f.name}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{source === 'model_library' && (
|
||||
<span className="text-[9px] px-1 py-0.5 rounded bg-amber-500/15 text-amber-300 border border-amber-500/30 flex-shrink-0 whitespace-nowrap">
|
||||
车型库
|
||||
</span>
|
||||
)}
|
||||
{cadSampleCacheMap[f.id] && (
|
||||
<span
|
||||
className="text-[9px] px-1 py-0.5 rounded bg-green-500/15 text-green-400 border border-green-500/30 flex-shrink-0"
|
||||
@@ -557,14 +884,14 @@ export default function TaskManager({
|
||||
<span className="text-muted-foreground text-[10px] flex-shrink-0">
|
||||
{formatSize(f.fileSize)}
|
||||
</span>
|
||||
{onSelectTaskCad && (
|
||||
{onSelectTaskCad && canPreview && (
|
||||
<button
|
||||
className="hidden group-hover:block p-0.5 rounded hover:bg-secondary flex-shrink-0"
|
||||
title="在 3D 视图中显示"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSelectTaskCad(
|
||||
`/api/files/read/task-cad/${activeTaskId}/${encodeURIComponent(f.name)}`,
|
||||
taskCadReadUrl({ name: f.name, source }),
|
||||
f.format,
|
||||
f.name,
|
||||
);
|
||||
@@ -600,11 +927,14 @@ export default function TaskManager({
|
||||
</p>
|
||||
{selectedCadFileIds.size === 1 && onSelectTaskCad && (() => {
|
||||
const f = taskCadFiles.find(f => selectedCadFileIds.has(f.id));
|
||||
return f ? (
|
||||
return f && canPreviewCadIn3D(f) ? (
|
||||
<button
|
||||
className="industrial-btn w-full py-0.5 text-[10px] flex items-center justify-center gap-1"
|
||||
onClick={() => onSelectTaskCad(
|
||||
`/api/files/read/task-cad/${activeTaskId}/${encodeURIComponent(f.name)}`,
|
||||
taskCadReadUrl({
|
||||
name: f.name,
|
||||
source: (f as { source?: string }).source,
|
||||
}),
|
||||
f.format,
|
||||
f.name,
|
||||
)}
|
||||
@@ -629,6 +959,16 @@ export default function TaskManager({
|
||||
e.target.files && e.target.files.length > 0 && handleUpload(e.target.files)
|
||||
}
|
||||
/>
|
||||
<input
|
||||
ref={modelPointcloudFileRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept=".txt,.xyz,.pts,.ply,.pcd,.las,.laz"
|
||||
className="hidden"
|
||||
onChange={(e) =>
|
||||
e.target.files && e.target.files.length > 0 && handleModelPointcloudUpload(e.target.files)
|
||||
}
|
||||
/>
|
||||
<input
|
||||
ref={cadFileRef}
|
||||
type="file"
|
||||
@@ -639,6 +979,16 @@ export default function TaskManager({
|
||||
e.target.files && e.target.files.length > 0 && handleCadUpload(e.target.files)
|
||||
}
|
||||
/>
|
||||
<input
|
||||
ref={modelCadFileRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept=".obj,.stl,.step,.stp,.iges,.igs"
|
||||
className="hidden"
|
||||
onChange={(e) =>
|
||||
e.target.files && e.target.files.length > 0 && handleModelCadUpload(e.target.files)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
110
docs/开发环境与协作方式.md
Normal file
110
docs/开发环境与协作方式.md
Normal file
@@ -0,0 +1,110 @@
|
||||
# 当前源码与开发环境说明
|
||||
|
||||
## 结论
|
||||
|
||||
当前最新版本源码不在本地 `massive-cloud-export` 目录中,而是在开发服务器 `2680v4`:
|
||||
|
||||
```bash
|
||||
ssh 2680v4
|
||||
cd /home/lipan/massive-cloud-v3
|
||||
```
|
||||
|
||||
本地 `massive-cloud-export` 更接近旧版离线导出包或历史交付包,可作为历史留档参考;日常开发、联调和问题排查应以 `2680v4:/home/lipan/massive-cloud-v3` 为准。
|
||||
|
||||
## 协作方式
|
||||
|
||||
- 当前项目由两名开发人员共同维护。
|
||||
- 日常习惯是直接 SSH 到 `2680v4` 上修改代码。
|
||||
- 修改目录为 `/home/lipan/massive-cloud-v3`。
|
||||
- 该目录是当前实际运行服务挂载的源码目录,代码改动会被开发模式服务读取。
|
||||
- 服务器工作区可能存在其他开发人员的未提交改动,修改前应先查看状态,避免覆盖他人工作:
|
||||
|
||||
```bash
|
||||
git status --short
|
||||
```
|
||||
|
||||
## 外网调试入口
|
||||
|
||||
`http://npsx.i-llusion.cn:3002` 不是正式生产环境地址,而是开发环境的公网穿透访问地址。
|
||||
|
||||
访问链路为:
|
||||
|
||||
```text
|
||||
外网浏览器
|
||||
-> http://npsx.i-llusion.cn:3002
|
||||
-> 内网穿透
|
||||
-> 2680v4:3002
|
||||
-> massive-point-cloud-app-dev
|
||||
```
|
||||
|
||||
服务器上存在 `npc` 穿透客户端进程,连接到 `47.106.245.28:8024`,用于把 `2680v4` 上的开发服务暴露到公网,方便外网访问和调试。
|
||||
|
||||
## 当前运行方式
|
||||
|
||||
当前服务使用开发模式 Docker 编排文件运行:
|
||||
|
||||
```bash
|
||||
cd /home/lipan/massive-cloud-v3
|
||||
sudo docker compose -f docker-compose.dev.yml ps
|
||||
```
|
||||
|
||||
关键运行信息:
|
||||
|
||||
- 应用容器:`massive-point-cloud-app-dev`
|
||||
- 编排文件:`/home/lipan/massive-cloud-v3/docker-compose.dev.yml`
|
||||
- 网络模式:`network_mode: host`
|
||||
- 应用端口:`3002`
|
||||
- 源码映射:`/home/lipan/massive-cloud-v3:/app`
|
||||
- 依赖卷:`pointcloud-app-node-modules-dev:/app/node_modules`
|
||||
- 数据目录映射:`/home/lipan/massive-cloud-v3/data:/data`
|
||||
- 算法目录映射:`/home/lipan/massive-cloud-v3/alg:/alg:ro`
|
||||
|
||||
容器启动后会在 `/app` 中执行:
|
||||
|
||||
```bash
|
||||
pnpm install && pnpm dev --host 0.0.0.0
|
||||
```
|
||||
|
||||
因此,开发人员在宿主机 `/home/lipan/massive-cloud-v3` 中修改的源码,会通过 volume 映射进入容器 `/app`,由容器内的 Node/tsx 开发服务运行。
|
||||
|
||||
## Docker 权限
|
||||
|
||||
`lipan` 用户当前直接访问 Docker daemon 权限不足,管理容器时需要使用 `sudo`:
|
||||
|
||||
```bash
|
||||
sudo docker ps
|
||||
sudo docker logs -f massive-point-cloud-app-dev
|
||||
sudo docker compose -f docker-compose.dev.yml ps
|
||||
sudo docker compose -f docker-compose.dev.yml restart app
|
||||
```
|
||||
|
||||
## 常用检查命令
|
||||
|
||||
```bash
|
||||
# 登录开发服务器
|
||||
ssh 2680v4
|
||||
|
||||
# 进入当前源码目录
|
||||
cd /home/lipan/massive-cloud-v3
|
||||
|
||||
# 查看代码状态,注意是否有他人改动
|
||||
git status --short
|
||||
|
||||
# 查看当前容器
|
||||
sudo docker ps
|
||||
|
||||
# 查看应用日志
|
||||
sudo docker logs -f massive-point-cloud-app-dev
|
||||
|
||||
# 查看 3002 端口监听
|
||||
sudo ss -lntp sport = :3002
|
||||
|
||||
# 查看穿透进程
|
||||
pgrep -af "npc|nps|frp"
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
- `massive-cloud-export` 不是当前最新开发源码目录,后续维护不要以它为准。
|
||||
- 如果需要保留旧版交付材料,可以继续保留 `massive-cloud-export`;如果要清理,应先确认其中截图、部署包说明、历史交付内容是否还需要留档。
|
||||
- 直接在服务器改代码时,务必先确认工作区状态,避免覆盖另一名开发人员正在修改的文件。
|
||||
22
drizzle/0010_model_library_task_links.sql
Normal file
22
drizzle/0010_model_library_task_links.sql
Normal file
@@ -0,0 +1,22 @@
|
||||
-- 车型点云库,以及任务输入对车型库文件的轻量关联
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `model_pointcloud_files` (
|
||||
`id` INT NOT NULL AUTO_INCREMENT,
|
||||
`modelId` INT NOT NULL,
|
||||
`name` VARCHAR(255) NOT NULL,
|
||||
`fileSize` BIGINT NOT NULL,
|
||||
`filePath` VARCHAR(512) NOT NULL,
|
||||
`createdAt` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
INDEX `idx_model_pointcloud_files_modelId` (`modelId`)
|
||||
);
|
||||
|
||||
ALTER TABLE `task_pointcloud_files`
|
||||
ADD COLUMN `sourceModelPointcloudId` INT NULL
|
||||
AFTER `sourceResultId`;
|
||||
|
||||
ALTER TABLE `task_cad_files`
|
||||
ADD COLUMN `source` VARCHAR(32) NOT NULL DEFAULT 'uploaded'
|
||||
AFTER `filePath`,
|
||||
ADD COLUMN `sourceModelCadFileId` INT NULL
|
||||
AFTER `source`;
|
||||
5
drizzle/0011_registration_source_pointcloud.sql
Normal file
5
drizzle/0011_registration_source_pointcloud.sql
Normal file
@@ -0,0 +1,5 @@
|
||||
-- 记录配准产物来自哪个任务点云文件,用于阻止链式配准并检测重复配准
|
||||
|
||||
ALTER TABLE `task_pointcloud_files`
|
||||
ADD COLUMN `sourcePointcloudFileId` INT NULL
|
||||
AFTER `sourceResultId`;
|
||||
@@ -62,6 +62,21 @@ export const modelCadFiles = mysqlTable("model_cad_files", {
|
||||
export type ModelCadFile = typeof modelCadFiles.$inferSelect;
|
||||
export type InsertModelCadFile = typeof modelCadFiles.$inferInsert;
|
||||
|
||||
// ─── 车型点云文件 ─────────────────────────────────────────────────────────────
|
||||
// 一个车型可维护多个通用点云文件,供任务按需关联复用
|
||||
|
||||
export const modelPointcloudFiles = mysqlTable("model_pointcloud_files", {
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
modelId: int("modelId").notNull(),
|
||||
name: varchar("name", { length: 255 }).notNull(),
|
||||
fileSize: bigint("fileSize", { mode: "number" }).notNull(),
|
||||
filePath: varchar("filePath", { length: 512 }).notNull(),
|
||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export type ModelPointcloudFile = typeof modelPointcloudFiles.$inferSelect;
|
||||
export type InsertModelPointcloudFile = typeof modelPointcloudFiles.$inferInsert;
|
||||
|
||||
// ─── 任务 CAD 文件 ─────────────────────────────────────────────────────────────
|
||||
// 每个任务可关联任意个 CAD 文件(与车型级 CAD 独立,用于本次检测的实测参考)
|
||||
|
||||
@@ -72,6 +87,10 @@ export const taskCadFiles = mysqlTable("task_cad_files", {
|
||||
format: varchar("format", { length: 32 }).notNull(),
|
||||
fileSize: bigint("fileSize", { mode: "number" }).notNull(),
|
||||
filePath: varchar("filePath", { length: 512 }).notNull(),
|
||||
/** 来源:uploaded = 用户上传;model_library = 车型 CAD 库关联 */
|
||||
source: varchar("source", { length: 32 }).default("uploaded").notNull(),
|
||||
/** 来源车型 CAD 文件 ID(仅 model_library 时有值) */
|
||||
sourceModelCadFileId: int("sourceModelCadFileId"),
|
||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||
});
|
||||
|
||||
@@ -105,10 +124,14 @@ export const taskPointcloudFiles = mysqlTable("task_pointcloud_files", {
|
||||
name: varchar("name", { length: 255 }).notNull(),
|
||||
fileSize: bigint("fileSize", { mode: "number" }).notNull(),
|
||||
filePath: varchar("filePath", { length: 512 }).notNull(),
|
||||
/** 来源:uploaded = 用户上传;registered = 配准算法产出 */
|
||||
/** 来源:uploaded = 用户上传;registered = 配准算法产出;model_library = 车型点云库关联 */
|
||||
source: varchar("source", { length: 32 }).default("uploaded").notNull(),
|
||||
/** 产出该点云的分析结果 ID(仅 registered 时有值) */
|
||||
sourceResultId: int("sourceResultId"),
|
||||
/** 生成该配准产物的原始任务点云 ID(仅 registered 时有值) */
|
||||
sourcePointcloudFileId: int("sourcePointcloudFileId"),
|
||||
/** 来源车型点云文件 ID(仅 model_library 时有值) */
|
||||
sourceModelPointcloudId: int("sourceModelPointcloudId"),
|
||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||
});
|
||||
|
||||
|
||||
@@ -43,6 +43,26 @@ function ensureDir(dir: string) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
function formatRegistrationTimestamp(date: Date): string {
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
return [
|
||||
date.getFullYear(),
|
||||
pad(date.getMonth() + 1),
|
||||
pad(date.getDate()),
|
||||
'-',
|
||||
pad(date.getHours()),
|
||||
pad(date.getMinutes()),
|
||||
pad(date.getSeconds()),
|
||||
].join('');
|
||||
}
|
||||
|
||||
function registeredPointcloudDisplayName(sourceName: string | undefined, derivedFile: string, resultId: number): string {
|
||||
const sourceParsed = path.parse(sourceName || 'pointcloud');
|
||||
const stem = sourceParsed.name || 'pointcloud';
|
||||
const ext = path.extname(derivedFile) || sourceParsed.ext || '.ply';
|
||||
return `${stem}_已配准_${formatRegistrationTimestamp(new Date())}_R${resultId}${ext}`;
|
||||
}
|
||||
|
||||
/** 把 process / mock cmd 模板里的占位符替换成实际路径。 */
|
||||
function resolvePlaceholders(value: string, ctx: Record<string, string>): string {
|
||||
return value.replace(/\{(\w+)\}/g, (_, key) => ctx[key] ?? `{${key}}`);
|
||||
@@ -91,7 +111,7 @@ async function gatherInputs(
|
||||
}
|
||||
}
|
||||
|
||||
return { task, pointCloudPaths, cadPath };
|
||||
return { task, pointCloudRecords, pointCloudPaths, cadPath };
|
||||
}
|
||||
|
||||
async function writeInputJson(
|
||||
@@ -282,7 +302,12 @@ export async function runAlgorithmJob(
|
||||
|
||||
try {
|
||||
await patchProgress(resultId, pluginId, 0.02, '准备输入');
|
||||
const { pointCloudPaths, cadPath } = await gatherInputs(taskId, manifest, pointCloudFileIds, cadFileIds);
|
||||
const { pointCloudRecords, pointCloudPaths, cadPath } = await gatherInputs(
|
||||
taskId,
|
||||
manifest,
|
||||
pointCloudFileIds,
|
||||
cadFileIds,
|
||||
);
|
||||
const inputJsonPath = await writeInputJson(
|
||||
workDir,
|
||||
taskId,
|
||||
@@ -350,13 +375,16 @@ export async function runAlgorithmJob(
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const sourcePointcloud = pointCloudRecords[0];
|
||||
const displayName = registeredPointcloudDisplayName(sourcePointcloud?.name, d.file, resultId);
|
||||
await db.createRegisteredPointcloudFile(
|
||||
taskId,
|
||||
d.name ?? d.file,
|
||||
displayName,
|
||||
absPath,
|
||||
resultId,
|
||||
sourcePointcloud?.id ?? null,
|
||||
);
|
||||
console.log(`[AlgorithmJobRunner] registered derived pointcloud: ${d.name ?? d.file}`);
|
||||
console.log(`[AlgorithmJobRunner] registered derived pointcloud: ${displayName}`);
|
||||
} catch (err) {
|
||||
console.error('[AlgorithmJobRunner] failed to register derived pointcloud:', err);
|
||||
}
|
||||
|
||||
116
server/db.ts
116
server/db.ts
@@ -6,6 +6,7 @@ import {
|
||||
vehicleSeries, InsertVehicleSeries, VehicleSeries,
|
||||
vehicleModels, InsertVehicleModel, VehicleModel,
|
||||
modelCadFiles, InsertModelCadFile, ModelCadFile,
|
||||
modelPointcloudFiles, InsertModelPointcloudFile, ModelPointcloudFile,
|
||||
taskCadFiles, InsertTaskCadFile, TaskCadFile,
|
||||
inspectionTasks, InsertInspectionTask, InspectionTask,
|
||||
taskPointcloudFiles, InsertTaskPointcloudFile, TaskPointcloudFile,
|
||||
@@ -229,6 +230,37 @@ export async function deleteModelCadFile(id: number): Promise<void> {
|
||||
await db.delete(modelCadFiles).where(eq(modelCadFiles.id, id));
|
||||
}
|
||||
|
||||
// ─── 车型点云文件 ─────────────────────────────────────────────────────────────
|
||||
|
||||
export async function createModelPointcloudFile(data: InsertModelPointcloudFile): Promise<ModelPointcloudFile> {
|
||||
const db = await getDb();
|
||||
if (!db) throw new Error("Database not available");
|
||||
const result = await db.insert(modelPointcloudFiles).values(data);
|
||||
const [row] = await db.select().from(modelPointcloudFiles).where(eq(modelPointcloudFiles.id, getMysqlInsertId(result)));
|
||||
return row;
|
||||
}
|
||||
|
||||
export async function listModelPointcloudFiles(modelId: number): Promise<ModelPointcloudFile[]> {
|
||||
const db = await getDb();
|
||||
if (!db) return [];
|
||||
return db.select().from(modelPointcloudFiles)
|
||||
.where(eq(modelPointcloudFiles.modelId, modelId))
|
||||
.orderBy(desc(modelPointcloudFiles.createdAt));
|
||||
}
|
||||
|
||||
export async function getModelPointcloudFileById(id: number): Promise<ModelPointcloudFile | undefined> {
|
||||
const db = await getDb();
|
||||
if (!db) return undefined;
|
||||
const [row] = await db.select().from(modelPointcloudFiles).where(eq(modelPointcloudFiles.id, id));
|
||||
return row;
|
||||
}
|
||||
|
||||
export async function deleteModelPointcloudFile(id: number): Promise<void> {
|
||||
const db = await getDb();
|
||||
if (!db) return;
|
||||
await db.delete(modelPointcloudFiles).where(eq(modelPointcloudFiles.id, id));
|
||||
}
|
||||
|
||||
// ─── 任务 CAD 文件 ─────────────────────────────────────────────────────────────
|
||||
|
||||
export async function createTaskCadFile(data: InsertTaskCadFile): Promise<TaskCadFile> {
|
||||
@@ -260,6 +292,46 @@ export async function deleteTaskCadFile(id: number): Promise<void> {
|
||||
await db.delete(taskCadFiles).where(eq(taskCadFiles.id, id));
|
||||
}
|
||||
|
||||
export async function linkTaskCadFilesFromModel(taskId: number, modelCadFileIds: number[]): Promise<TaskCadFile[]> {
|
||||
const db = await getDb();
|
||||
if (!db) throw new Error("Database not available");
|
||||
|
||||
const task = await getInspectionTaskById(taskId);
|
||||
if (!task) throw new Error("Task not found");
|
||||
|
||||
const existing = await listTaskCadFiles(taskId);
|
||||
const existingSourceIds = new Set(
|
||||
existing
|
||||
.map((row) => row.sourceModelCadFileId)
|
||||
.filter((id): id is number => typeof id === "number"),
|
||||
);
|
||||
|
||||
const inserted: TaskCadFile[] = [];
|
||||
for (const id of modelCadFileIds) {
|
||||
if (existingSourceIds.has(id)) continue;
|
||||
|
||||
const source = await getModelCadFileById(id);
|
||||
if (!source) throw new Error("Model CAD file not found");
|
||||
if (source.modelId !== task.modelId) {
|
||||
throw new Error("Cannot link CAD file from another model");
|
||||
}
|
||||
|
||||
const record = await createTaskCadFile({
|
||||
taskId,
|
||||
name: source.name,
|
||||
format: source.format,
|
||||
fileSize: source.fileSize,
|
||||
filePath: source.filePath,
|
||||
source: "model_library",
|
||||
sourceModelCadFileId: source.id,
|
||||
} as InsertTaskCadFile);
|
||||
inserted.push(record);
|
||||
existingSourceIds.add(id);
|
||||
}
|
||||
|
||||
return inserted;
|
||||
}
|
||||
|
||||
// ─── 检测任务 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function createInspectionTask(data: InsertInspectionTask): Promise<InspectionTask> {
|
||||
@@ -315,6 +387,7 @@ export async function createRegisteredPointcloudFile(
|
||||
name: string,
|
||||
filePath: string,
|
||||
sourceResultId: number,
|
||||
sourcePointcloudFileId?: number | null,
|
||||
): Promise<TaskPointcloudFile> {
|
||||
const fs = await import('node:fs');
|
||||
const fileSize = fs.existsSync(filePath) ? fs.statSync(filePath).size : 0;
|
||||
@@ -325,6 +398,7 @@ export async function createRegisteredPointcloudFile(
|
||||
fileSize,
|
||||
source: 'registered',
|
||||
sourceResultId,
|
||||
sourcePointcloudFileId: sourcePointcloudFileId ?? null,
|
||||
} as InsertTaskPointcloudFile);
|
||||
}
|
||||
|
||||
@@ -342,6 +416,48 @@ export async function deleteTaskPointcloudFile(id: number): Promise<void> {
|
||||
await db.delete(taskPointcloudFiles).where(eq(taskPointcloudFiles.id, id));
|
||||
}
|
||||
|
||||
export async function linkTaskPointcloudFilesFromModel(
|
||||
taskId: number,
|
||||
modelPointcloudFileIds: number[],
|
||||
): Promise<TaskPointcloudFile[]> {
|
||||
const db = await getDb();
|
||||
if (!db) throw new Error("Database not available");
|
||||
|
||||
const task = await getInspectionTaskById(taskId);
|
||||
if (!task) throw new Error("Task not found");
|
||||
|
||||
const existing = await listTaskPointcloudFiles(taskId);
|
||||
const existingSourceIds = new Set(
|
||||
existing
|
||||
.map((row) => row.sourceModelPointcloudId)
|
||||
.filter((id): id is number => typeof id === "number"),
|
||||
);
|
||||
|
||||
const inserted: TaskPointcloudFile[] = [];
|
||||
for (const id of modelPointcloudFileIds) {
|
||||
if (existingSourceIds.has(id)) continue;
|
||||
|
||||
const source = await getModelPointcloudFileById(id);
|
||||
if (!source) throw new Error("Model pointcloud file not found");
|
||||
if (source.modelId !== task.modelId) {
|
||||
throw new Error("Cannot link pointcloud file from another model");
|
||||
}
|
||||
|
||||
const record = await createTaskPointcloudFile({
|
||||
taskId,
|
||||
name: source.name,
|
||||
fileSize: source.fileSize,
|
||||
filePath: source.filePath,
|
||||
source: "model_library",
|
||||
sourceModelPointcloudId: source.id,
|
||||
} as InsertTaskPointcloudFile);
|
||||
inserted.push(record);
|
||||
existingSourceIds.add(id);
|
||||
}
|
||||
|
||||
return inserted;
|
||||
}
|
||||
|
||||
// ─── 任务分析结果 ──────────────────────────────────────────────────────────────
|
||||
|
||||
export async function createTaskAnalysisResult(data: InsertTaskAnalysisResult): Promise<TaskAnalysisResult> {
|
||||
|
||||
@@ -24,7 +24,9 @@ ensureDir(TASKS_DIR);
|
||||
const diskStorage = multer.diskStorage({
|
||||
destination: (req, _file, cb) => {
|
||||
let dir: string;
|
||||
if (req.params.modelId) {
|
||||
if (req.params.modelId && req.path.includes('/model-pointcloud/')) {
|
||||
dir = path.join(MODELS_DIR, String(parseInt(req.params.modelId, 10)), 'pointcloud');
|
||||
} else if (req.params.modelId) {
|
||||
dir = path.join(MODELS_DIR, String(parseInt(req.params.modelId, 10)), 'cad');
|
||||
} else if (req.params.taskId && req.path.includes('/task-cad/')) {
|
||||
dir = path.join(TASKS_DIR, String(parseInt(req.params.taskId, 10)), 'cad');
|
||||
@@ -92,6 +94,35 @@ router.post('/upload/cad/:modelId', diskUpload.single('file'), async (req, res)
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 车型点云文件上传 ────────────────────────────────────────────────────────
|
||||
// POST /api/files/upload/model-pointcloud/:modelId
|
||||
// multipart/form-data field: file
|
||||
|
||||
router.post('/upload/model-pointcloud/:modelId', diskUpload.single('file'), async (req, res) => {
|
||||
const modelId = parseInt(req.params.modelId, 10);
|
||||
if (!req.file || isNaN(modelId)) {
|
||||
return res.status(400).json({ success: false, error: 'Missing file or modelId' });
|
||||
}
|
||||
|
||||
const destPath = req.file.path;
|
||||
const record = await db.createModelPointcloudFile({
|
||||
modelId,
|
||||
name: req.file.originalname,
|
||||
fileSize: req.file.size,
|
||||
filePath: destPath,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
file: {
|
||||
id: record.id,
|
||||
name: record.name,
|
||||
size: formatFileSize(record.fileSize),
|
||||
filePath: record.filePath,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 任务 CAD 文件上传 ────────────────────────────────────────────────────────
|
||||
// POST /api/files/upload/task-cad/:taskId
|
||||
|
||||
@@ -173,6 +204,17 @@ router.get('/read/cad/:modelId/:filename', (req, res) => {
|
||||
res.sendFile(filePath);
|
||||
});
|
||||
|
||||
// ─── 读取车型点云文件 ─────────────────────────────────────────────────────────
|
||||
// GET /api/files/read/model-pointcloud/:modelId/:filename
|
||||
|
||||
router.get('/read/model-pointcloud/:modelId/:filename', (req, res) => {
|
||||
const pcDir = path.join(MODELS_DIR, req.params.modelId, 'pointcloud');
|
||||
const filePath = safeResolve(pcDir, req.params.filename);
|
||||
if (!filePath) return res.status(403).json({ error: 'Access denied' });
|
||||
if (!fs.existsSync(filePath)) return res.status(404).json({ error: 'File not found' });
|
||||
res.sendFile(filePath);
|
||||
});
|
||||
|
||||
// ─── 读取点云文件 ─────────────────────────────────────────────────────────────
|
||||
// GET /api/files/read/pointcloud/:taskId/:filename
|
||||
|
||||
|
||||
@@ -14,6 +14,52 @@ import { stepParseCacheDir } from "./stepParse.js";
|
||||
import { listPlugins, getPlugin } from "./plugins/registry.js";
|
||||
import { enqueueAlgorithmJob, algorithmJobStats, cancelAlgorithmJob, cancelAlgorithmJobsByTask } from "./algorithmJob.js";
|
||||
|
||||
async function validateRegistrationRunInput(input: {
|
||||
taskId: number;
|
||||
pointCloudFileIds?: number[];
|
||||
cadFileIds?: number[];
|
||||
allowDuplicateRegistration?: boolean;
|
||||
}) {
|
||||
const pointCloudFileIds = input.pointCloudFileIds ?? [];
|
||||
const cadFileIds = input.cadFileIds ?? [];
|
||||
|
||||
if (pointCloudFileIds.length !== 1 || cadFileIds.length !== 1) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "整车配准必须恰好选择 1 个点云文件和 1 个 CAD 文件。",
|
||||
});
|
||||
}
|
||||
|
||||
const pointcloudFiles = await db.listTaskPointcloudFiles(input.taskId);
|
||||
const pointcloud = pointcloudFiles.find((row) => row.id === pointCloudFileIds[0]);
|
||||
if (!pointcloud) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "选中的点云文件不存在。" });
|
||||
}
|
||||
if (pointcloud.source === "registered") {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "已配准点云不能再次作为配准输入,请选择原始点云进行配准。",
|
||||
});
|
||||
}
|
||||
|
||||
const cadFiles = await db.listTaskCadFiles(input.taskId);
|
||||
const cad = cadFiles.find((row) => row.id === cadFileIds[0]);
|
||||
if (!cad) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "选中的 CAD 文件不存在。" });
|
||||
}
|
||||
|
||||
const registeredChildren = pointcloudFiles
|
||||
.filter((row) => row.source === "registered" && row.sourcePointcloudFileId === pointcloud.id)
|
||||
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
|
||||
if (registeredChildren.length > 0 && !input.allowDuplicateRegistration) {
|
||||
const latest = registeredChildren[0];
|
||||
throw new TRPCError({
|
||||
code: "CONFLICT",
|
||||
message: `点云「${pointcloud.name}」已有配准产物「${latest.name}」,重复配准需要确认。`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const appRouter = router({
|
||||
system: systemRouter,
|
||||
|
||||
@@ -147,6 +193,23 @@ export const appRouter = router({
|
||||
}),
|
||||
}),
|
||||
|
||||
// ─── 车型点云文件 ─────────────────────────────────────────────────────────
|
||||
|
||||
modelPointcloudFile: router({
|
||||
list: protectedProcedure
|
||||
.input(z.object({ modelId: z.number() }))
|
||||
.query(async ({ input }) => {
|
||||
return db.listModelPointcloudFiles(input.modelId);
|
||||
}),
|
||||
|
||||
delete: protectedProcedure
|
||||
.input(z.object({ id: z.number() }))
|
||||
.mutation(async ({ input }) => {
|
||||
await db.deleteModelPointcloudFile(input.id);
|
||||
return { success: true };
|
||||
}),
|
||||
}),
|
||||
|
||||
// ─── 任务 CAD 文件 ────────────────────────────────────────────────────────────
|
||||
|
||||
taskCadFile: router({
|
||||
@@ -163,6 +226,27 @@ export const appRouter = router({
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
linkFromModel: protectedProcedure
|
||||
.input(z.object({
|
||||
taskId: z.number(),
|
||||
modelCadFileIds: z.array(z.number()).min(1),
|
||||
}))
|
||||
.mutation(async ({ input }) => {
|
||||
try {
|
||||
const files = await db.linkTaskCadFilesFromModel(input.taskId, input.modelCadFileIds);
|
||||
return { success: true, files };
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
if (message.includes("another model")) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "只能关联当前车型的 CAD 库文件" });
|
||||
}
|
||||
if (message.includes("not found")) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "任务或 CAD 库文件不存在" });
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}),
|
||||
|
||||
// 检查 CAD 文件是否已有三角化采样缓存(.cad_sample_cache/ 目录下存在对应 PLY)
|
||||
hasSampleCache: protectedProcedure
|
||||
.input(z.object({ cadFileIds: z.array(z.number()) }))
|
||||
@@ -257,6 +341,27 @@ export const appRouter = router({
|
||||
await db.deleteTaskPointcloudFile(input.id);
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
linkFromModel: protectedProcedure
|
||||
.input(z.object({
|
||||
taskId: z.number(),
|
||||
modelPointcloudFileIds: z.array(z.number()).min(1),
|
||||
}))
|
||||
.mutation(async ({ input }) => {
|
||||
try {
|
||||
const files = await db.linkTaskPointcloudFilesFromModel(input.taskId, input.modelPointcloudFileIds);
|
||||
return { success: true, files };
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
if (message.includes("another model")) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "只能关联当前车型的点云库文件" });
|
||||
}
|
||||
if (message.includes("not found")) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "任务或点云库文件不存在" });
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}),
|
||||
}),
|
||||
|
||||
// ─── 任务分析结果 ────────────────────────────────────────────────────────────
|
||||
@@ -467,6 +572,8 @@ export const appRouter = router({
|
||||
pointCloudFileIds: z.array(z.number()).optional(),
|
||||
/** 可选:任务级 CAD 文件 ID(空/缺省 = 不传 CAD) */
|
||||
cadFileIds: z.array(z.number()).optional(),
|
||||
/** 配准:允许同一原始点云再次生成配准产物 */
|
||||
allowDuplicateRegistration: z.boolean().optional(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ input }) => {
|
||||
@@ -477,6 +584,9 @@ export const appRouter = router({
|
||||
message: `未注册的算法插件:${input.pluginId}`,
|
||||
});
|
||||
}
|
||||
if (manifest.category === "配准") {
|
||||
await validateRegistrationRunInput(input);
|
||||
}
|
||||
|
||||
try {
|
||||
const record = await db.createTaskAnalysisResult({
|
||||
@@ -529,6 +639,8 @@ export const appRouter = router({
|
||||
pointCloudFileIds: z.array(z.number()).optional(),
|
||||
/** 可选:任务级 CAD 文件 ID(空/缺省 = 不传 CAD) */
|
||||
cadFileIds: z.array(z.number()).optional(),
|
||||
/** 配准:允许同一原始点云再次生成配准产物 */
|
||||
allowDuplicateRegistration: z.boolean().optional(),
|
||||
jobs: z
|
||||
.array(
|
||||
z.object({
|
||||
@@ -542,6 +654,10 @@ export const appRouter = router({
|
||||
)
|
||||
.mutation(async ({ input }) => {
|
||||
const created: { id: number; pluginId: string }[] = [];
|
||||
const hasRegistrationJob = input.jobs.some((job) => getPlugin(job.pluginId)?.category === "配准");
|
||||
if (hasRegistrationJob) {
|
||||
await validateRegistrationRunInput(input);
|
||||
}
|
||||
for (const job of input.jobs) {
|
||||
const manifest = getPlugin(job.pluginId);
|
||||
if (!manifest) continue;
|
||||
|
||||
Reference in New Issue
Block a user