Compare commits

..

3 Commits

Author SHA1 Message Date
aa2bd80418 docs: document dev server workflow 2026-07-07 18:15:53 +08:00
dc760de7a2 feat: update task file selection UI 2026-07-07 18:15:47 +08:00
b5440fa1c3 feat: add model file library links 2026-07-07 18:15:41 +08:00
12 changed files with 1007 additions and 101 deletions

View File

@@ -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})

View File

@@ -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>

View File

@@ -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,13 +466,14 @@ 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">
<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"
className="text-muted-foreground hover:text-foreground flex-shrink-0"
title={allSelected ? '取消全选' : '全选'}
onClick={toggleAllFiles}
>
@@ -370,23 +484,32 @@ export default function TaskManager({
)}
</button>
)}
<span className="industrial-label flex items-center gap-1">
<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>
</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">
<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>
</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>
);
}

View File

@@ -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}

View 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`;如果要清理,应先确认其中截图、部署包说明、历史交付内容是否还需要留档。
- 直接在服务器改代码时,务必先确认工作区状态,避免覆盖另一名开发人员正在修改的文件。

View 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`;

View File

@@ -0,0 +1,5 @@
-- 记录配准产物来自哪个任务点云文件,用于阻止链式配准并检测重复配准
ALTER TABLE `task_pointcloud_files`
ADD COLUMN `sourcePointcloudFileId` INT NULL
AFTER `sourceResultId`;

View File

@@ -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(),
});

View File

@@ -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);
}

View File

@@ -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> {

View File

@@ -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

View File

@@ -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;