Compare commits
3 Commits
aa2bd80418
...
6076063be0
| Author | SHA1 | Date | |
|---|---|---|---|
| 6076063be0 | |||
| e4915f327c | |||
| b87aa3f990 |
@@ -11,10 +11,17 @@
|
|||||||
*/
|
*/
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { trpc } from '@/lib/trpc';
|
import { trpc } from '@/lib/trpc';
|
||||||
import { AlertTriangle, CheckCircle2, ChevronDown, ChevronRight, Cpu, Play, RefreshCw, Scan } from 'lucide-react';
|
import { AlertTriangle, CheckCircle2, ChevronDown, ChevronRight, Cpu, Play, RefreshCw, Scan, Square } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import TrainMeasurementPanel from './TrainMeasurementPanel';
|
import TrainMeasurementPanel from './TrainMeasurementPanel';
|
||||||
|
|
||||||
|
const DEMO_MEASUREMENT_PLUGIN_IDS = [
|
||||||
|
'train-measurement',
|
||||||
|
'coupler-panel',
|
||||||
|
'flatness-suite',
|
||||||
|
'underframe-deflection',
|
||||||
|
];
|
||||||
|
|
||||||
interface PluginInputField {
|
interface PluginInputField {
|
||||||
name: string;
|
name: string;
|
||||||
label: string;
|
label: string;
|
||||||
@@ -53,6 +60,7 @@ interface PointcloudFileSummary {
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
taskId: number | null;
|
taskId: number | null;
|
||||||
|
modelId: number | null;
|
||||||
/** 是否已加载点云(3D 视图,仅用于提示,不再作为执行前置条件) */
|
/** 是否已加载点云(3D 视图,仅用于提示,不再作为执行前置条件) */
|
||||||
pointCloudLoaded: boolean;
|
pointCloudLoaded: boolean;
|
||||||
/** 当前任务下的点云文件总数(>0 即可执行算法,无需先在 3D 视图加载) */
|
/** 当前任务下的点云文件总数(>0 即可执行算法,无需先在 3D 视图加载) */
|
||||||
@@ -86,7 +94,7 @@ function defaultParamsFor(plugin: PluginSummary): Record<string, unknown> {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AlgorithmRunPanel({ taskId, pointCloudLoaded: _pointCloudLoaded, taskPcFileCount, taskPointcloudFiles = [], selectedPcFileIds, hasRegisteredCloud, selectedCadFileIds, roiPickedPoint, onStartRoiPick, activeRoiPickTarget }: Props) {
|
export default function AlgorithmRunPanel({ taskId, modelId, pointCloudLoaded: _pointCloudLoaded, taskPcFileCount, taskPointcloudFiles = [], selectedPcFileIds, hasRegisteredCloud, selectedCadFileIds, roiPickedPoint, onStartRoiPick, activeRoiPickTarget }: Props) {
|
||||||
const utils = trpc.useUtils();
|
const utils = trpc.useUtils();
|
||||||
const { data, isLoading } = trpc.algorithm.list.useQuery(undefined, {
|
const { data, isLoading } = trpc.algorithm.list.useQuery(undefined, {
|
||||||
staleTime: 60_000,
|
staleTime: 60_000,
|
||||||
@@ -98,12 +106,51 @@ export default function AlgorithmRunPanel({ taskId, pointCloudLoaded: _pointClou
|
|||||||
void utils.analysisResult.list.invalidate({ taskId });
|
void utils.analysisResult.list.invalidate({ taskId });
|
||||||
void utils.pointcloudFile.list.invalidate({ taskId });
|
void utils.pointcloudFile.list.invalidate({ taskId });
|
||||||
}
|
}
|
||||||
|
if (modelId != null) {
|
||||||
|
void utils.inspectionTask.listByModel.invalidate({ modelId });
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onError: (e) => toast.error(e.message || '提交失败'),
|
onError: (e) => toast.error(e.message || '提交失败'),
|
||||||
});
|
});
|
||||||
|
const runDemoAll = trpc.algorithm.runDemoAll.useMutation({
|
||||||
|
onSuccess: ({ jobs, registrationJob, queuedMeasurementCount }) => {
|
||||||
|
const submittedCount = jobs.length + (registrationJob ? 1 : 0);
|
||||||
|
if (registrationJob) {
|
||||||
|
toast.success(`已提交整车配准;完成后将自动提交 ${queuedMeasurementCount} 个测量算法`);
|
||||||
|
} else {
|
||||||
|
toast.success(`已提交 ${submittedCount} 个算法到并发池`);
|
||||||
|
}
|
||||||
|
if (taskId != null) {
|
||||||
|
void utils.analysisResult.list.invalidate({ taskId });
|
||||||
|
void utils.pointcloudFile.list.invalidate({ taskId });
|
||||||
|
}
|
||||||
|
if (modelId != null) {
|
||||||
|
void utils.inspectionTask.listByModel.invalidate({ modelId });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (e) => toast.error(e.message || '一键全流程演示提交失败'),
|
||||||
|
});
|
||||||
|
const cancelDemoAll = trpc.algorithm.cancelDemoAll.useMutation({
|
||||||
|
onSuccess: ({ cancelledIds }) => {
|
||||||
|
toast.success(cancelledIds.length > 0 ? `已停止 ${cancelledIds.length} 个运行任务` : '当前没有运行中的全流程任务');
|
||||||
|
if (taskId != null) {
|
||||||
|
void utils.analysisResult.list.invalidate({ taskId });
|
||||||
|
void utils.pointcloudFile.list.invalidate({ taskId });
|
||||||
|
}
|
||||||
|
if (modelId != null) {
|
||||||
|
void utils.inspectionTask.listByModel.invalidate({ modelId });
|
||||||
|
}
|
||||||
|
void utils.algorithm.list.invalidate();
|
||||||
|
},
|
||||||
|
onError: (e) => toast.error(e.message || '停止全流程失败'),
|
||||||
|
});
|
||||||
|
|
||||||
const plugins = data?.plugins ?? [];
|
const plugins = data?.plugins ?? [];
|
||||||
const scheduler = data?.scheduler;
|
const scheduler = data?.scheduler;
|
||||||
|
const { data: analysisResults = [] } = trpc.analysisResult.list.useQuery(
|
||||||
|
{ taskId: taskId ?? 0 },
|
||||||
|
{ enabled: taskId != null, refetchInterval: 3000 },
|
||||||
|
);
|
||||||
|
|
||||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||||
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||||||
@@ -149,6 +196,18 @@ export default function AlgorithmRunPanel({ taskId, pointCloudLoaded: _pointClou
|
|||||||
const measPlugins = 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 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 selectedRegIds = regPlugins.filter((p) => selected.has(p.id)).map((p) => p.id);
|
||||||
|
const demoMeasurementPluginIds = DEMO_MEASUREMENT_PLUGIN_IDS.filter((id) => measPlugins.some((p) => p.id === id));
|
||||||
|
const hasDemoRegistrationPlugin = regPlugins.some((p) => p.id === 'vehicle-registration');
|
||||||
|
const demoAllPluginIds = [
|
||||||
|
...(hasDemoRegistrationPlugin ? ['vehicle-registration'] : []),
|
||||||
|
...demoMeasurementPluginIds,
|
||||||
|
];
|
||||||
|
const demoAlgorithmCount = demoAllPluginIds.length;
|
||||||
|
const demoMeasurementAlgorithmCount = demoMeasurementPluginIds.length;
|
||||||
|
const demoRunTitle = `先执行整车配准,完成后自动提交 ${demoMeasurementAlgorithmCount} 个测量算法`;
|
||||||
|
const demoPluginIds = new Set(demoAllPluginIds);
|
||||||
|
const runningDemoResults = analysisResults.filter((r) => (r.status === 'processing' || r.status === 'pending') && r.type === 'plugin' && r.pluginId && demoPluginIds.has(r.pluginId));
|
||||||
|
const demoFlowRunning = runningDemoResults.length > 0;
|
||||||
|
|
||||||
const selectedPcCount = selectedPcFileIds?.length ?? 0;
|
const selectedPcCount = selectedPcFileIds?.length ?? 0;
|
||||||
const selectedCadCount = selectedCadFileIds?.length ?? 0;
|
const selectedCadCount = selectedCadFileIds?.length ?? 0;
|
||||||
@@ -171,7 +230,8 @@ export default function AlgorithmRunPanel({ taskId, pointCloudLoaded: _pointClou
|
|||||||
const baseRunBlockReason =
|
const baseRunBlockReason =
|
||||||
taskId == null ? '请先选择检测任务'
|
taskId == null ? '请先选择检测任务'
|
||||||
: (taskPcFileCount ?? 0) === 0 ? '请先上传点云文件'
|
: (taskPcFileCount ?? 0) === 0 ? '请先上传点云文件'
|
||||||
: runBatch.isPending ? '算法提交中,请稍候'
|
: demoFlowRunning ? '一键全流程运行中,请先停止或等待完成'
|
||||||
|
: (runBatch.isPending || runDemoAll.isPending || cancelDemoAll.isPending) ? '算法提交中,请稍候'
|
||||||
: null;
|
: null;
|
||||||
const registrationBlockReason =
|
const registrationBlockReason =
|
||||||
baseRunBlockReason
|
baseRunBlockReason
|
||||||
@@ -187,11 +247,36 @@ export default function AlgorithmRunPanel({ taskId, pointCloudLoaded: _pointClou
|
|||||||
?? (selectedMeasIds.length === 0 ? '请至少勾选一个测量算法' : null);
|
?? (selectedMeasIds.length === 0 ? '请至少勾选一个测量算法' : null);
|
||||||
const canRunRegistration = registrationBlockReason == null;
|
const canRunRegistration = registrationBlockReason == null;
|
||||||
const canRunMeasurement = measurementBlockReason == null;
|
const canRunMeasurement = measurementBlockReason == null;
|
||||||
|
const demoBlockReason =
|
||||||
|
baseRunBlockReason
|
||||||
|
?? (selectedPcCount === 0 ? '一键全流程演示必须选择 1 个原始点云'
|
||||||
|
: selectedPcCount > 1 ? '一键全流程演示一次只能选择 1 个原始点云'
|
||||||
|
: selectedRegistrationPointcloud?.source === 'registered' ? '请选择原始点云,系统会自动找到对应配准产物'
|
||||||
|
: selectedCadCount === 0 ? '一键全流程演示必须选择 1 个 CAD 文件'
|
||||||
|
: selectedCadCount > 1 ? '一键全流程演示一次只能选择 1 个 CAD 文件'
|
||||||
|
: !hasDemoRegistrationPlugin ? '暂无可执行的整车配准算法'
|
||||||
|
: demoMeasurementPluginIds.length === 0 ? '暂无可执行的测量算法'
|
||||||
|
: null);
|
||||||
|
const canRunDemoAll = demoBlockReason == null;
|
||||||
|
const demoRouteHint =
|
||||||
|
selectedPcCount === 1 && selectedRegistrationPointcloud?.source === 'registered'
|
||||||
|
? '请选择原始点云,系统会自动找到对应配准产物。'
|
||||||
|
: selectedPcCount === 1 && selectedRegistrationPointcloud && selectedRegistrationPointcloud.source !== 'registered' && selectedCadCount === 1
|
||||||
|
? `将先执行整车配准,完成后自动提交 ${demoMeasurementAlgorithmCount} 个测量算法`
|
||||||
|
: null;
|
||||||
const duplicateRegistrationWarning =
|
const duplicateRegistrationWarning =
|
||||||
latestRegistrationChild && selectedRegistrationPointcloud
|
latestRegistrationChild && selectedRegistrationPointcloud
|
||||||
? `点云「${selectedRegistrationPointcloud.name}」已有配准产物「${latestRegistrationChild.name}」(${formatDateTime(latestRegistrationChild.createdAt)}),再次执行会生成新产物。`
|
? `点云「${selectedRegistrationPointcloud.name}」已有配准产物「${latestRegistrationChild.name}」(${formatDateTime(latestRegistrationChild.createdAt)}),再次执行会生成新产物。`
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
const handleSelectAllMeasurement = () => {
|
||||||
|
setSelected((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
for (const plugin of measPlugins) next.add(plugin.id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const handleRun = (ids: string[], mode: 'registration' | 'measurement') => {
|
const handleRun = (ids: string[], mode: 'registration' | 'measurement') => {
|
||||||
const blockReason = mode === 'registration' ? registrationBlockReason : measurementBlockReason;
|
const blockReason = mode === 'registration' ? registrationBlockReason : measurementBlockReason;
|
||||||
if (blockReason) {
|
if (blockReason) {
|
||||||
@@ -232,10 +317,84 @@ export default function AlgorithmRunPanel({ taskId, pointCloudLoaded: _pointClou
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleRunDemoAll = () => {
|
||||||
|
if (demoBlockReason) {
|
||||||
|
toast.error(demoBlockReason);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (taskId == null || !selectedPcFileIds?.[0] || !selectedCadFileIds?.[0]) return;
|
||||||
|
setSelected((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
for (const pluginId of demoAllPluginIds) next.add(pluginId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
runDemoAll.mutate({
|
||||||
|
taskId,
|
||||||
|
sourcePointCloudFileId: selectedPcFileIds[0],
|
||||||
|
cadFileId: selectedCadFileIds[0],
|
||||||
|
measurementPluginIds: demoMeasurementPluginIds,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancelDemoAll = () => {
|
||||||
|
if (taskId == null || cancelDemoAll.isPending) return;
|
||||||
|
cancelDemoAll.mutate({ taskId });
|
||||||
|
};
|
||||||
|
|
||||||
// 不再需要 showRegWarning 变量,已直接内联到 JSX 里
|
// 不再需要 showRegWarning 变量,已直接内联到 JSX 里
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
{/* ── 一键演示:整车配准 → 测量算法 ───────────────────────────── */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<div className="flex items-center justify-between gap-1">
|
||||||
|
<span className="industrial-label flex items-center gap-1.5 text-emerald-300">
|
||||||
|
<Cpu className="w-3.5 h-3.5" />
|
||||||
|
一键全流程演示
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{demoFlowRunning && (
|
||||||
|
<span className="text-[9px] px-1.5 py-0.5 rounded bg-blue-900/40 text-blue-300 animate-pulse">
|
||||||
|
运行中 {runningDemoResults.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{demoFlowRunning ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`industrial-btn py-0.5 text-[10px] flex items-center gap-0.5 text-amber-300 ${
|
||||||
|
cancelDemoAll.isPending ? 'opacity-60 cursor-wait' : ''
|
||||||
|
}`}
|
||||||
|
disabled={cancelDemoAll.isPending}
|
||||||
|
onClick={handleCancelDemoAll}
|
||||||
|
title="停止整条一键全流程链路"
|
||||||
|
>
|
||||||
|
{cancelDemoAll.isPending ? <RefreshCw className="w-3 h-3 animate-spin" /> : <Square className="w-3 h-3" />}
|
||||||
|
停止全流程
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`industrial-btn industrial-btn-primary py-0.5 text-[10px] flex items-center gap-0.5 ${
|
||||||
|
canRunDemoAll ? '' : 'opacity-50 cursor-not-allowed'
|
||||||
|
}`}
|
||||||
|
disabled={!canRunDemoAll}
|
||||||
|
onClick={handleRunDemoAll}
|
||||||
|
title={demoBlockReason ?? demoRunTitle}
|
||||||
|
>
|
||||||
|
{runDemoAll.isPending ? <RefreshCw className="w-3 h-3 animate-spin" /> : <Play className="w-3 h-3" />}
|
||||||
|
运行全部 {demoAlgorithmCount} 个算法
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{demoRouteHint && (
|
||||||
|
<div className="flex items-start gap-1.5 bg-blue-900/20 border border-blue-700/40 rounded p-2">
|
||||||
|
<CheckCircle2 className="w-3 h-3 text-blue-300 flex-shrink-0 mt-0.5" />
|
||||||
|
<span className="text-[10px] text-blue-200">{demoRouteHint}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* ── 步骤 1:整车配准 ────────────────────────────────── */}
|
{/* ── 步骤 1:整车配准 ────────────────────────────────── */}
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<div className="flex items-center justify-between gap-1">
|
<div className="flex items-center justify-between gap-1">
|
||||||
@@ -275,7 +434,7 @@ export default function AlgorithmRunPanel({ taskId, pointCloudLoaded: _pointClou
|
|||||||
<PluginCard
|
<PluginCard
|
||||||
key={p.id}
|
key={p.id}
|
||||||
p={p}
|
p={p}
|
||||||
isSelected={selected.has(p.id)}
|
isSelected={selected.has(p.id) || (demoFlowRunning && demoPluginIds.has(p.id))}
|
||||||
isExpanded={expanded.has(p.id)}
|
isExpanded={expanded.has(p.id)}
|
||||||
params={paramsById[p.id] ?? {}}
|
params={paramsById[p.id] ?? {}}
|
||||||
onToggleSelect={toggleSelect}
|
onToggleSelect={toggleSelect}
|
||||||
@@ -296,7 +455,7 @@ export default function AlgorithmRunPanel({ taskId, pointCloudLoaded: _pointClou
|
|||||||
<Cpu className="w-3.5 h-3.5" />
|
<Cpu className="w-3.5 h-3.5" />
|
||||||
第 2 步 · 测量分析(多选并发)
|
第 2 步 · 测量分析(多选并发)
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1 flex-wrap justify-end">
|
||||||
{scheduler && (
|
{scheduler && (
|
||||||
<span
|
<span
|
||||||
className="text-[9px] text-muted-foreground tabular-nums"
|
className="text-[9px] text-muted-foreground tabular-nums"
|
||||||
@@ -305,6 +464,16 @@ export default function AlgorithmRunPanel({ taskId, pointCloudLoaded: _pointClou
|
|||||||
{scheduler.running}/{scheduler.limit} · 排队 {scheduler.queued}
|
{scheduler.running}/{scheduler.limit} · 排队 {scheduler.queued}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="industrial-btn py-0.5 text-[10px] flex items-center gap-0.5"
|
||||||
|
onClick={handleSelectAllMeasurement}
|
||||||
|
disabled={measPlugins.length === 0}
|
||||||
|
title="全选测量算法,不包含整车配准"
|
||||||
|
>
|
||||||
|
<CheckCircle2 className="w-3 h-3" />
|
||||||
|
全选测量算法
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`industrial-btn industrial-btn-primary py-0.5 text-[10px] flex items-center gap-0.5 ${
|
className={`industrial-btn industrial-btn-primary py-0.5 text-[10px] flex items-center gap-0.5 ${
|
||||||
@@ -340,7 +509,7 @@ export default function AlgorithmRunPanel({ taskId, pointCloudLoaded: _pointClou
|
|||||||
<PluginCard
|
<PluginCard
|
||||||
key={p.id}
|
key={p.id}
|
||||||
p={p}
|
p={p}
|
||||||
isSelected={selected.has(p.id)}
|
isSelected={selected.has(p.id) || (demoFlowRunning && demoPluginIds.has(p.id))}
|
||||||
isExpanded={expanded.has(p.id)}
|
isExpanded={expanded.has(p.id)}
|
||||||
params={paramsById[p.id] ?? {}}
|
params={paramsById[p.id] ?? {}}
|
||||||
onToggleSelect={toggleSelect}
|
onToggleSelect={toggleSelect}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useMemo, useRef } from 'react';
|
||||||
import { trpc } from '@/lib/trpc';
|
import { trpc } from '@/lib/trpc';
|
||||||
import { BarChart2, Ruler, Zap, Play, Trash2, ChevronDown, ChevronRight, RefreshCw, Flame, Box, Layers, Crosshair, Cpu, Timer } from 'lucide-react';
|
import { BarChart2, Ruler, Zap, Play, Trash2, ChevronDown, ChevronRight, RefreshCw, Flame, Box, Layers, Crosshair, Cpu, Timer, Square, Maximize2 } from 'lucide-react';
|
||||||
import GenericResultView, { type Overlay3D } from './GenericResultView';
|
import GenericResultView, { type Overlay3D } from './GenericResultView';
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from './ui/dialog';
|
||||||
|
|
||||||
/** 将毫秒数格式化为可读时长,如 2m 34s / 47s / 1h 5m */
|
/** 将毫秒数格式化为可读时长,如 2m 34s / 47s / 1h 5m */
|
||||||
function fmtDuration(ms: number): string {
|
function fmtDuration(ms: number): string {
|
||||||
@@ -163,30 +164,180 @@ type AssemblyMeta = SmoothHeatmapMeta & {
|
|||||||
parts?: AssemblyPartRow[];
|
parts?: AssemblyPartRow[];
|
||||||
};
|
};
|
||||||
|
|
||||||
function getResultTitle(r: { type: string; pluginId?: string }) {
|
function getPluginDisplayName(pluginId: string | null | undefined, pluginNameById: Map<string, string>, fallback: string) {
|
||||||
|
if (!pluginId) return fallback;
|
||||||
|
return pluginNameById.get(pluginId) ?? pluginId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getResultTitle(r: { type: string; pluginId?: string | null }, pluginNameById: Map<string, string>) {
|
||||||
if (r.type === 'error_analysis') return '误差分析';
|
if (r.type === 'error_analysis') return '误差分析';
|
||||||
if (r.type === 'measurement') return '尺寸测量';
|
if (r.type === 'measurement') return '尺寸测量';
|
||||||
if (r.type === 'smooth_heatmap') return '平滑热力';
|
if (r.type === 'smooth_heatmap') return '平滑热力';
|
||||||
if (r.type === 'assembly_inspection') return '装配检测';
|
if (r.type === 'assembly_inspection') return '装配检测';
|
||||||
if (r.type === 'obj_deviation') return '偏差热力';
|
if (r.type === 'obj_deviation') return '偏差热力';
|
||||||
if (r.type === 'plugin') return `算法 · ${r.pluginId ?? '插件'}`;
|
if (r.type === 'plugin') return `算法 · ${getPluginDisplayName(r.pluginId, pluginNameById, '插件')}`;
|
||||||
return '压缩';
|
return '压缩';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getStatusLabel(status: string) {
|
||||||
|
if (status === 'completed') return '已完成';
|
||||||
|
if (status === 'processing') return '运行中';
|
||||||
|
if (status === 'pending') return '排队中';
|
||||||
|
if (status === 'cancelled') return '已停止';
|
||||||
|
if (status === 'error') return '失败';
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusClass(status: string) {
|
||||||
|
if (status === 'completed') return 'bg-green-900/40 text-green-400';
|
||||||
|
if (status === 'processing') return 'bg-blue-900/40 text-blue-400 animate-pulse';
|
||||||
|
if (status === 'pending') return 'bg-sky-900/40 text-sky-300';
|
||||||
|
if (status === 'cancelled') return 'bg-amber-900/40 text-amber-300';
|
||||||
|
if (status === 'error') return 'bg-red-900/40 text-red-400';
|
||||||
|
return 'bg-secondary text-muted-foreground';
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatStartTime(value: string | Date) {
|
||||||
|
return new Date(value).toLocaleString('zh-CN', { hour12: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCancellableStatus(status: string) {
|
||||||
|
return status === 'processing' || status === 'pending';
|
||||||
|
}
|
||||||
|
|
||||||
|
type AnalysisResultRow = {
|
||||||
|
id: number;
|
||||||
|
taskId?: number;
|
||||||
|
type: string;
|
||||||
|
pluginId?: string | null;
|
||||||
|
schemaVersion?: number | null;
|
||||||
|
status: string;
|
||||||
|
createdAt: string | Date;
|
||||||
|
updatedAt: string | Date;
|
||||||
|
errorStats: unknown;
|
||||||
|
measurements: unknown;
|
||||||
|
resultData: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DemoFlowMeta = {
|
||||||
|
runId: string;
|
||||||
|
role?: string;
|
||||||
|
index?: number;
|
||||||
|
total?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AnalysisListItem =
|
||||||
|
| { kind: 'result'; result: AnalysisResultRow }
|
||||||
|
| { kind: 'demoFlow'; runId: string; results: AnalysisResultRow[] };
|
||||||
|
|
||||||
|
type ResultListVariant = 'panel' | 'maximized';
|
||||||
|
|
||||||
|
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||||
|
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
||||||
|
return value as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function numberFromUnknown(value: unknown): number | undefined {
|
||||||
|
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readDemoFlowMeta(result: AnalysisResultRow): DemoFlowMeta | null {
|
||||||
|
const data = asRecord(result.resultData);
|
||||||
|
if (!data) return null;
|
||||||
|
const runId = data?.demoFlowRunId;
|
||||||
|
if (typeof runId !== 'string' || runId.length === 0) return null;
|
||||||
|
return {
|
||||||
|
runId,
|
||||||
|
role: typeof data.demoFlowRole === 'string' ? data.demoFlowRole : undefined,
|
||||||
|
index: numberFromUnknown(data.demoFlowIndex),
|
||||||
|
total: numberFromUnknown(data.demoFlowTotal),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resultTimeMs(result: AnalysisResultRow, field: 'createdAt' | 'updatedAt' = 'createdAt') {
|
||||||
|
return new Date(result[field]).getTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAnalysisListItems(results: AnalysisResultRow[]): AnalysisListItem[] {
|
||||||
|
const items: AnalysisListItem[] = [];
|
||||||
|
const flowItems = new Map<string, Extract<AnalysisListItem, { kind: 'demoFlow' }>>();
|
||||||
|
|
||||||
|
for (const result of results) {
|
||||||
|
const meta = readDemoFlowMeta(result);
|
||||||
|
if (!meta) {
|
||||||
|
items.push({ kind: 'result', result });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let group = flowItems.get(meta.runId);
|
||||||
|
if (!group) {
|
||||||
|
group = { kind: 'demoFlow', runId: meta.runId, results: [] };
|
||||||
|
flowItems.set(meta.runId, group);
|
||||||
|
items.push(group);
|
||||||
|
}
|
||||||
|
group.results.push(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
Array.from(flowItems.values()).forEach((group) => {
|
||||||
|
group.results.sort((a, b) => {
|
||||||
|
const am = readDemoFlowMeta(a);
|
||||||
|
const bm = readDemoFlowMeta(b);
|
||||||
|
const ai = am?.index ?? Number.MAX_SAFE_INTEGER;
|
||||||
|
const bi = bm?.index ?? Number.MAX_SAFE_INTEGER;
|
||||||
|
if (ai !== bi) return ai - bi;
|
||||||
|
return resultTimeMs(a) - resultTimeMs(b);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDemoFlowStatus(results: AnalysisResultRow[]) {
|
||||||
|
if (results.some((r) => r.status === 'processing')) return 'processing';
|
||||||
|
if (results.some((r) => r.status === 'pending')) return 'pending';
|
||||||
|
if (results.length > 0 && results.every((r) => r.status === 'completed')) return 'completed';
|
||||||
|
if (results.some((r) => r.status === 'error')) return 'error';
|
||||||
|
if (results.length > 0 && results.every((r) => r.status === 'cancelled')) return 'cancelled';
|
||||||
|
if (results.some((r) => r.status === 'cancelled')) return 'cancelled';
|
||||||
|
return results[0]?.status ?? 'pending';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDemoFlowTotal(results: AnalysisResultRow[]) {
|
||||||
|
const totals = results.map((r) => readDemoFlowMeta(r)?.total ?? 0);
|
||||||
|
return Math.max(results.length, ...totals);
|
||||||
|
}
|
||||||
|
|
||||||
export default function AnalysisPanel({ taskId, onReplay, onViewSmoothHeatmap, onRunAssembly, onRunDeviation, externalPending, onViewPluginOverlay }: Props) {
|
export default function AnalysisPanel({ taskId, onReplay, onViewSmoothHeatmap, onRunAssembly, onRunDeviation, externalPending, onViewPluginOverlay }: Props) {
|
||||||
const utils = trpc.useUtils();
|
const utils = trpc.useUtils();
|
||||||
const [expandedIds, setExpandedIds] = useState<Set<number>>(new Set());
|
const [expandedIds, setExpandedIds] = useState<Set<number>>(new Set());
|
||||||
|
const [expandedFlowIds, setExpandedFlowIds] = useState<Set<string>>(new Set());
|
||||||
|
const [maximizedOpen, setMaximizedOpen] = useState(false);
|
||||||
|
const initializedDemoResultIds = useRef<Set<number>>(new Set());
|
||||||
|
const initializedDemoFlowIds = useRef<Set<string>>(new Set());
|
||||||
|
|
||||||
const { data: results = [], isLoading } = trpc.analysisResult.list.useQuery(
|
const { data: results = [], isLoading } = trpc.analysisResult.list.useQuery(
|
||||||
{ taskId },
|
{ taskId },
|
||||||
{ refetchInterval: 3000 },
|
{ refetchInterval: 3000 },
|
||||||
);
|
);
|
||||||
|
const { data: algorithmList } = trpc.algorithm.list.useQuery(undefined, {
|
||||||
|
staleTime: 60_000,
|
||||||
|
});
|
||||||
|
const pluginNameById = useMemo(() => {
|
||||||
|
const map = new Map<string, string>();
|
||||||
|
for (const plugin of algorithmList?.plugins ?? []) {
|
||||||
|
if (plugin.name) map.set(plugin.id, plugin.name);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [algorithmList?.plugins]);
|
||||||
const runAnalysis = trpc.analysisResult.runAnalysis.useMutation({
|
const runAnalysis = trpc.analysisResult.runAnalysis.useMutation({
|
||||||
onSuccess: () => utils.analysisResult.list.invalidate({ taskId }),
|
onSuccess: () => utils.analysisResult.list.invalidate({ taskId }),
|
||||||
});
|
});
|
||||||
const deleteResult = trpc.analysisResult.delete.useMutation({
|
const deleteResult = trpc.analysisResult.delete.useMutation({
|
||||||
onSuccess: () => utils.analysisResult.list.invalidate({ taskId }),
|
onSuccess: () => utils.analysisResult.list.invalidate({ taskId }),
|
||||||
});
|
});
|
||||||
|
const cancelResult = trpc.analysisResult.cancel.useMutation({
|
||||||
|
onSuccess: () => utils.analysisResult.list.invalidate({ taskId }),
|
||||||
|
});
|
||||||
|
|
||||||
function toggleExpand(id: number) {
|
function toggleExpand(id: number) {
|
||||||
setExpandedIds(prev => {
|
setExpandedIds(prev => {
|
||||||
@@ -197,6 +348,394 @@ export default function AnalysisPanel({ taskId, onReplay, onViewSmoothHeatmap, o
|
|||||||
}
|
}
|
||||||
|
|
||||||
const running = runAnalysis.isPending;
|
const running = runAnalysis.isPending;
|
||||||
|
const analysisResults = results as AnalysisResultRow[];
|
||||||
|
const listItems = buildAnalysisListItems(analysisResults);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const flowIdsToExpand: string[] = [];
|
||||||
|
const resultIdsToExpand: number[] = [];
|
||||||
|
|
||||||
|
for (const result of results as AnalysisResultRow[]) {
|
||||||
|
const meta = readDemoFlowMeta(result);
|
||||||
|
if (!meta) continue;
|
||||||
|
if (!initializedDemoFlowIds.current.has(meta.runId)) {
|
||||||
|
initializedDemoFlowIds.current.add(meta.runId);
|
||||||
|
flowIdsToExpand.push(meta.runId);
|
||||||
|
}
|
||||||
|
if (!initializedDemoResultIds.current.has(result.id)) {
|
||||||
|
initializedDemoResultIds.current.add(result.id);
|
||||||
|
resultIdsToExpand.push(result.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (flowIdsToExpand.length > 0) {
|
||||||
|
setExpandedFlowIds((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
for (const id of flowIdsToExpand) next.add(id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resultIdsToExpand.length > 0) {
|
||||||
|
setExpandedIds((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
for (const id of resultIdsToExpand) next.add(id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [results]);
|
||||||
|
|
||||||
|
function toggleFlowExpand(runId: string) {
|
||||||
|
setExpandedFlowIds(prev => {
|
||||||
|
const n = new Set(prev);
|
||||||
|
n.has(runId) ? n.delete(runId) : n.add(runId);
|
||||||
|
return n;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderResultCard(r: AnalysisResultRow, extraClassName = '') {
|
||||||
|
const expanded = expandedIds.has(r.id);
|
||||||
|
const errorStats = r.errorStats as ErrorStats | null;
|
||||||
|
const measurements = r.measurements as Measurement[] | null;
|
||||||
|
const resultTitle = getResultTitle(r, pluginNameById);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={r.id} className={`industrial-card rounded overflow-hidden min-w-0 ${extraClassName}`}>
|
||||||
|
{/* 标题行 */}
|
||||||
|
<div
|
||||||
|
className="px-2 py-1.5 cursor-pointer hover:bg-secondary/40 group min-w-0"
|
||||||
|
onClick={() => toggleExpand(r.id)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
{expanded ? <ChevronDown className="w-3 h-3 text-muted-foreground flex-shrink-0" /> : <ChevronRight className="w-3 h-3 text-muted-foreground flex-shrink-0" />}
|
||||||
|
{r.type === 'error_analysis' && <BarChart2 className="w-3.5 h-3.5 text-blue-400 flex-shrink-0" />}
|
||||||
|
{r.type === 'measurement' && <Ruler className="w-3.5 h-3.5 text-green-400 flex-shrink-0" />}
|
||||||
|
{r.type === 'compression' && <Zap className="w-3.5 h-3.5 text-amber-400 flex-shrink-0" />}
|
||||||
|
{r.type === 'smooth_heatmap' && <Flame className="w-3.5 h-3.5 text-orange-400 flex-shrink-0" />}
|
||||||
|
{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" />}
|
||||||
|
<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="hidden group-hover:flex items-center gap-0.5 flex-shrink-0">
|
||||||
|
{onReplay && r.status === 'completed' && (
|
||||||
|
<button
|
||||||
|
className="p-0.5 rounded hover:bg-secondary"
|
||||||
|
title="回放此结果"
|
||||||
|
onClick={e => { e.stopPropagation(); onReplay(r.id); }}
|
||||||
|
>
|
||||||
|
<Play className="w-3 h-3 text-primary" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{isCancellableStatus(r.status) && (
|
||||||
|
<button
|
||||||
|
className="p-0.5 rounded hover:bg-secondary text-amber-300 disabled:opacity-50"
|
||||||
|
title="停止"
|
||||||
|
disabled={cancelResult.isPending}
|
||||||
|
onClick={e => { e.stopPropagation(); cancelResult.mutate({ id: r.id }); }}
|
||||||
|
>
|
||||||
|
<Square className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
className="p-0.5 rounded hover:bg-secondary text-destructive"
|
||||||
|
title="删除"
|
||||||
|
onClick={e => { e.stopPropagation(); deleteResult.mutate({ id: r.id }); }}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="ml-[42px] mt-1 flex items-center gap-x-2 gap-y-1 min-w-0 flex-wrap">
|
||||||
|
<span className="text-[10px] text-muted-foreground tabular-nums whitespace-nowrap">
|
||||||
|
{formatStartTime(r.createdAt)}
|
||||||
|
</span>
|
||||||
|
<span className={`text-[9px] px-1.5 py-0.5 rounded flex-shrink-0 ${getStatusClass(r.status)}`}>{getStatusLabel(r.status)}</span>
|
||||||
|
<ElapsedTime status={r.status} createdAt={r.createdAt} updatedAt={r.updatedAt} />
|
||||||
|
{(r.status === 'processing' || r.status === 'pending') &&
|
||||||
|
(r.type === 'smooth_heatmap' || r.type === 'assembly_inspection' || r.type === 'obj_deviation' || r.type === 'plugin') &&
|
||||||
|
(() => {
|
||||||
|
const hm = r.resultData as SmoothHeatmapMeta | null;
|
||||||
|
const pct = hm?.progress != null ? Math.round(hm.progress * 100) : null;
|
||||||
|
return pct != null ? (
|
||||||
|
<span className="text-[9px] font-mono text-primary tabular-nums flex-shrink-0">{pct}%</span>
|
||||||
|
) : null;
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 展开内容 */}
|
||||||
|
{expanded && r.status === 'completed' && r.type !== 'smooth_heatmap' && r.type !== 'assembly_inspection' && r.type !== 'obj_deviation' && r.type !== 'plugin' && (
|
||||||
|
<div className="px-3 pb-3 pt-1 border-t border-border/40 space-y-3">
|
||||||
|
{errorStats && <ErrorStatCard stats={errorStats} />}
|
||||||
|
{measurements && measurements.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<div className="text-[10px] text-muted-foreground mb-1">尺寸测量</div>
|
||||||
|
<MeasurementTable items={measurements} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{expanded &&
|
||||||
|
(r.type === 'smooth_heatmap' || r.type === 'assembly_inspection' || r.type === 'obj_deviation' || r.type === 'plugin') &&
|
||||||
|
(r.status === 'processing' || r.status === 'pending') &&
|
||||||
|
(() => {
|
||||||
|
const hm = r.resultData as SmoothHeatmapMeta | null;
|
||||||
|
const p = hm?.progress ?? 0;
|
||||||
|
const stage = hm?.stage ?? '排队或启动中…';
|
||||||
|
return (
|
||||||
|
<div className="px-3 pb-3 pt-1 border-t border-border/40 space-y-2">
|
||||||
|
<div className="text-[10px] text-muted-foreground">{stage}</div>
|
||||||
|
<div className="industrial-progress h-1.5">
|
||||||
|
<div
|
||||||
|
className="industrial-progress-bar transition-[width] duration-300"
|
||||||
|
style={{
|
||||||
|
width: `${Math.min(100, Math.max(0, p * 100))}%`,
|
||||||
|
background: 'oklch(0.55 0.14 250)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-[9px] text-muted-foreground/80">
|
||||||
|
后台子进程执行,页面可继续操作;进度约每 1.2s 刷新。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
|
{expanded && r.type === 'smooth_heatmap' && r.status === 'completed' && (
|
||||||
|
<div className="px-3 pb-3 pt-1 border-t border-border/40 space-y-2">
|
||||||
|
{(() => {
|
||||||
|
const hm = r.resultData as SmoothHeatmapMeta | null;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="text-[10px] text-muted-foreground space-y-0.5">
|
||||||
|
{hm?.vertexCount != null && (
|
||||||
|
<div>网格顶点约 {hm.vertexCount.toLocaleString()}</div>
|
||||||
|
)}
|
||||||
|
{hm?.dispMin != null && hm?.dispMax != null && (
|
||||||
|
<div>
|
||||||
|
色标误差约 {hm.dispMin.toFixed(4)} ~ {hm.dispMax.toFixed(4)} mm
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="industrial-btn industrial-btn-primary py-1 text-[10px] flex items-center gap-1 w-full justify-center"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onViewSmoothHeatmap?.(r.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box className="w-3 h-3" />
|
||||||
|
在 3D 中查看
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{expanded && r.type === 'smooth_heatmap' && r.status === 'error' && (
|
||||||
|
<div className="px-3 pb-3 pt-1 border-t border-border/40">
|
||||||
|
<p className="text-[10px] text-red-400/90">
|
||||||
|
{(r.resultData as SmoothHeatmapMeta | null)?.error ?? '分析失败'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{expanded && r.type === 'assembly_inspection' && r.status === 'completed' && (
|
||||||
|
<div className="px-3 pb-3 pt-1 border-t border-border/40 space-y-2">
|
||||||
|
{(() => {
|
||||||
|
const am = r.resultData as AssemblyMeta | null;
|
||||||
|
const tol = am?.toleranceMm ?? 0.3;
|
||||||
|
const parts = am?.parts ?? [];
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="text-[10px] text-muted-foreground">
|
||||||
|
阈值 < {tol} mm 为合格;测点归属为「到各零件网格距离最小」的零件。
|
||||||
|
</div>
|
||||||
|
<div className="overflow-x-auto max-h-48 overflow-y-auto">
|
||||||
|
<table className="w-full text-[9px] border-collapse">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-muted-foreground text-left">
|
||||||
|
<th className="py-0.5 pr-1">零件</th>
|
||||||
|
<th className="py-0.5 pr-1 text-right">点数</th>
|
||||||
|
<th className="py-0.5 pr-1 text-right">min</th>
|
||||||
|
<th className="py-0.5 pr-1 text-right">max</th>
|
||||||
|
<th className="py-0.5 pr-1 text-right">mean</th>
|
||||||
|
<th className="py-0.5 text-right">合格率</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{parts.map((p) => (
|
||||||
|
<tr key={p.partId} className="border-t border-border/30">
|
||||||
|
<td className="py-0.5 pr-1 font-mono truncate max-w-[100px]" title={p.name}>
|
||||||
|
{p.name}
|
||||||
|
</td>
|
||||||
|
<td className="py-0.5 pr-1 text-right">{p.pointCount}</td>
|
||||||
|
<td className="py-0.5 pr-1 text-right">{p.min.toFixed(4)}</td>
|
||||||
|
<td className="py-0.5 pr-1 text-right">{p.max.toFixed(4)}</td>
|
||||||
|
<td className="py-0.5 pr-1 text-right">{p.mean.toFixed(4)}</td>
|
||||||
|
<td className="py-0.5 text-right">
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
p.passRate >= 0.95
|
||||||
|
? 'text-green-400'
|
||||||
|
: p.passRate >= 0.85
|
||||||
|
? 'text-amber-400'
|
||||||
|
: 'text-red-400'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{(p.passRate * 100).toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{expanded && r.type === 'assembly_inspection' && r.status === 'error' && (
|
||||||
|
<div className="px-3 pb-3 pt-1 border-t border-border/40">
|
||||||
|
<p className="text-[10px] text-red-400/90">
|
||||||
|
{(r.resultData as AssemblyMeta | null)?.error ?? '装配检测失败'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{expanded && r.type === 'obj_deviation' && r.status === 'completed' && (
|
||||||
|
<div className="px-3 pb-3 pt-1 border-t border-border/40 space-y-2">
|
||||||
|
{(() => {
|
||||||
|
const hm = r.resultData as SmoothHeatmapMeta | null;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="text-[10px] text-muted-foreground space-y-0.5">
|
||||||
|
<div>按顶点到最近点云点的距离着色(蓝=近 → 红=远)</div>
|
||||||
|
{hm?.vertexCount != null && (
|
||||||
|
<div>网格顶点约 {hm.vertexCount.toLocaleString()}</div>
|
||||||
|
)}
|
||||||
|
{hm?.dispMin != null && hm?.dispMax != null && (
|
||||||
|
<div>
|
||||||
|
偏差范围约 {hm.dispMin.toFixed(4)} ~ {hm.dispMax.toFixed(4)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="industrial-btn industrial-btn-primary py-1 text-[10px] flex items-center gap-1 w-full justify-center"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onViewSmoothHeatmap?.(r.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box className="w-3 h-3" />
|
||||||
|
在 3D 中查看
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{expanded && r.type === 'obj_deviation' && r.status === 'error' && (
|
||||||
|
<div className="px-3 pb-3 pt-1 border-t border-border/40">
|
||||||
|
<p className="text-[10px] text-red-400/90">
|
||||||
|
{(r.resultData as SmoothHeatmapMeta | null)?.error ?? '偏差分析失败'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{expanded && r.type === 'plugin' && (r.status === 'completed' || r.status === 'error' || r.status === 'cancelled') && (
|
||||||
|
<div className="px-3 pb-3 pt-1 border-t border-border/40">
|
||||||
|
<GenericResultView
|
||||||
|
taskId={taskId}
|
||||||
|
resultId={r.id}
|
||||||
|
pluginId={r.pluginId ?? undefined}
|
||||||
|
pluginName={getPluginDisplayName(r.pluginId, pluginNameById, '算法插件')}
|
||||||
|
data={(r.resultData as Parameters<typeof GenericResultView>[0]['data']) ?? {}}
|
||||||
|
onViewOverlay={onViewPluginOverlay}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDemoFlowCard(group: Extract<AnalysisListItem, { kind: 'demoFlow' }>, variant: ResultListVariant) {
|
||||||
|
const expanded = expandedFlowIds.has(group.runId);
|
||||||
|
const status = getDemoFlowStatus(group.results);
|
||||||
|
const total = getDemoFlowTotal(group.results);
|
||||||
|
const completed = group.results.filter((r) => r.status === 'completed').length;
|
||||||
|
const createdAt = group.results.reduce((min, r) => Math.min(min, resultTimeMs(r)), Number.POSITIVE_INFINITY);
|
||||||
|
const runningItems = group.results
|
||||||
|
.filter((r) => r.status === 'processing' || r.status === 'pending')
|
||||||
|
.slice(0, 2)
|
||||||
|
.map((r) => {
|
||||||
|
const data = asRecord(r.resultData);
|
||||||
|
const progress = numberFromUnknown(data?.progress);
|
||||||
|
return `${getResultTitle(r, pluginNameById)}${progress != null ? ` ${Math.round(progress * 100)}%` : ''}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={group.runId} className={`industrial-card rounded overflow-hidden min-w-0 ${variant === 'maximized' ? 'xl:col-span-2' : ''}`}>
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-2 px-2 py-1.5 cursor-pointer hover:bg-secondary/40 min-w-0"
|
||||||
|
onClick={() => toggleFlowExpand(group.runId)}
|
||||||
|
>
|
||||||
|
{expanded ? <ChevronDown className="w-3 h-3 text-muted-foreground" /> : <ChevronRight className="w-3 h-3 text-muted-foreground" />}
|
||||||
|
<Layers className="w-3.5 h-3.5 text-cyan-400 flex-shrink-0" />
|
||||||
|
<span className="text-xs flex-1 min-w-0 truncate">全流程算法结果</span>
|
||||||
|
<span className="text-[10px] text-muted-foreground flex-shrink-0">
|
||||||
|
{completed}/{total} 完成
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-muted-foreground flex-shrink-0">
|
||||||
|
{Number.isFinite(createdAt) ? new Date(createdAt).toLocaleDateString('zh-CN') : ''}
|
||||||
|
</span>
|
||||||
|
<span className={`text-[9px] px-1.5 py-0.5 rounded flex-shrink-0 ${getStatusClass(status)}`}>{getStatusLabel(status)}</span>
|
||||||
|
</div>
|
||||||
|
{expanded && (
|
||||||
|
<div className="px-2 pb-2 pt-1 border-t border-border/40 space-y-2">
|
||||||
|
{runningItems.length > 0 && (
|
||||||
|
<div className="text-[10px] text-muted-foreground truncate">
|
||||||
|
运行中:{runningItems.join('、')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className={variant === 'maximized' ? 'grid grid-cols-1 lg:grid-cols-2 gap-2' : 'space-y-1.5'}>
|
||||||
|
{group.results.map((result) => renderResultCard(result))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderResultList(variant: ResultListVariant) {
|
||||||
|
return (
|
||||||
|
<div className={variant === 'maximized' ? 'grid grid-cols-1 xl:grid-cols-2 gap-3' : 'space-y-1.5'}>
|
||||||
|
{listItems.map((item) => (
|
||||||
|
item.kind === 'demoFlow'
|
||||||
|
? renderDemoFlowCard(item, variant)
|
||||||
|
: renderResultCard(item.result)
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
@@ -205,6 +744,14 @@ export default function AnalysisPanel({ taskId, onReplay, onViewSmoothHeatmap, o
|
|||||||
<BarChart2 className="w-3.5 h-3.5" /> 分析结果
|
<BarChart2 className="w-3.5 h-3.5" /> 分析结果
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="industrial-btn py-0.5 px-1 text-[10px] flex items-center justify-center"
|
||||||
|
onClick={() => setMaximizedOpen(true)}
|
||||||
|
title="最大化查看分析结果"
|
||||||
|
>
|
||||||
|
<Maximize2 className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
className={`industrial-btn industrial-btn-primary py-0.5 text-[10px] flex items-center gap-0.5 ${running ? 'opacity-60 cursor-not-allowed' : ''}`}
|
className={`industrial-btn industrial-btn-primary py-0.5 text-[10px] flex items-center gap-0.5 ${running ? 'opacity-60 cursor-not-allowed' : ''}`}
|
||||||
onClick={() => !running && runAnalysis.mutate({ taskId, type: 'error_analysis' })}
|
onClick={() => !running && runAnalysis.mutate({ taskId, type: 'error_analysis' })}
|
||||||
@@ -249,283 +796,27 @@ export default function AnalysisPanel({ taskId, onReplay, onViewSmoothHeatmap, o
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
{!isLoading && results.length > 0 && renderResultList('panel')}
|
||||||
{results.map(r => {
|
|
||||||
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 (
|
<Dialog open={maximizedOpen} onOpenChange={setMaximizedOpen}>
|
||||||
<div key={r.id} className="industrial-card rounded overflow-hidden min-w-0">
|
<DialogContent className="w-[calc(100vw-2rem)] h-[calc(100vh-2rem)] max-w-[calc(100vw-2rem)] sm:max-w-[calc(100vw-2rem)] p-0 overflow-hidden gap-0">
|
||||||
{/* 标题行 */}
|
<DialogHeader className="px-4 py-3 border-b border-border/60 flex-shrink-0">
|
||||||
<div
|
<DialogTitle className="text-sm flex items-center gap-2">
|
||||||
className="flex items-center gap-2 px-2 py-1.5 cursor-pointer hover:bg-secondary/40 group min-w-0"
|
<BarChart2 className="w-4 h-4" />
|
||||||
onClick={() => toggleExpand(r.id)}
|
分析结果
|
||||||
>
|
</DialogTitle>
|
||||||
{expanded ? <ChevronDown className="w-3 h-3 text-muted-foreground" /> : <ChevronRight className="w-3 h-3 text-muted-foreground" />}
|
</DialogHeader>
|
||||||
{r.type === 'error_analysis' && <BarChart2 className="w-3.5 h-3.5 text-blue-400 flex-shrink-0" />}
|
<div className="min-h-0 overflow-y-auto p-4">
|
||||||
{r.type === 'measurement' && <Ruler className="w-3.5 h-3.5 text-green-400 flex-shrink-0" />}
|
{isLoading && (
|
||||||
{r.type === 'compression' && <Zap className="w-3.5 h-3.5 text-amber-400 flex-shrink-0" />}
|
<div className="text-xs text-muted-foreground text-center py-8">加载中…</div>
|
||||||
{r.type === 'smooth_heatmap' && <Flame className="w-3.5 h-3.5 text-orange-400 flex-shrink-0" />}
|
)}
|
||||||
{r.type === 'assembly_inspection' && <Layers className="w-3.5 h-3.5 text-cyan-400 flex-shrink-0" />}
|
{!isLoading && results.length === 0 && (
|
||||||
{r.type === 'obj_deviation' && <Crosshair className="w-3.5 h-3.5 text-rose-400 flex-shrink-0" />}
|
<div className="text-xs text-muted-foreground text-center py-8">暂无分析结果</div>
|
||||||
{r.type === 'plugin' && <Cpu className="w-3.5 h-3.5 text-violet-400 flex-shrink-0" />}
|
)}
|
||||||
<Tooltip>
|
{!isLoading && results.length > 0 && renderResultList('maximized')}
|
||||||
<TooltipTrigger asChild>
|
</div>
|
||||||
<span className="text-xs flex-1 min-w-0 truncate">{resultTitle}</span>
|
</DialogContent>
|
||||||
</TooltipTrigger>
|
</Dialog>
|
||||||
<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>
|
|
||||||
<span className={`text-[9px] px-1.5 py-0.5 rounded flex-shrink-0 ${
|
|
||||||
r.status === 'completed' ? 'bg-green-900/40 text-green-400' :
|
|
||||||
r.status === 'processing' ? 'bg-blue-900/40 text-blue-400 animate-pulse' :
|
|
||||||
r.status === 'error' ? 'bg-red-900/40 text-red-400' :
|
|
||||||
'bg-secondary text-muted-foreground'
|
|
||||||
}`}>{r.status}</span>
|
|
||||||
<ElapsedTime status={r.status} createdAt={r.createdAt} updatedAt={r.updatedAt} />
|
|
||||||
{r.status === 'processing' &&
|
|
||||||
(r.type === 'smooth_heatmap' || r.type === 'assembly_inspection' || r.type === 'obj_deviation' || r.type === 'plugin') &&
|
|
||||||
(() => {
|
|
||||||
const hm = r.resultData as SmoothHeatmapMeta | null;
|
|
||||||
const pct = hm?.progress != null ? Math.round(hm.progress * 100) : null;
|
|
||||||
return pct != null ? (
|
|
||||||
<span className="text-[9px] font-mono text-primary tabular-nums">{pct}%</span>
|
|
||||||
) : null;
|
|
||||||
})()}
|
|
||||||
|
|
||||||
<span className="hidden group-hover:flex items-center gap-0.5">
|
|
||||||
{onReplay && r.status === 'completed' && (
|
|
||||||
<button
|
|
||||||
className="p-0.5 rounded hover:bg-secondary"
|
|
||||||
title="回放此结果"
|
|
||||||
onClick={e => { e.stopPropagation(); onReplay(r.id); }}
|
|
||||||
>
|
|
||||||
<Play className="w-3 h-3 text-primary" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
className="p-0.5 rounded hover:bg-secondary text-destructive"
|
|
||||||
title="删除"
|
|
||||||
onClick={e => { e.stopPropagation(); deleteResult.mutate({ id: r.id }); }}
|
|
||||||
>
|
|
||||||
<Trash2 className="w-3 h-3" />
|
|
||||||
</button>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 展开内容 */}
|
|
||||||
{expanded && r.status === 'completed' && r.type !== 'smooth_heatmap' && r.type !== 'assembly_inspection' && r.type !== 'obj_deviation' && r.type !== 'plugin' && (
|
|
||||||
<div className="px-3 pb-3 pt-1 border-t border-border/40 space-y-3">
|
|
||||||
{errorStats && <ErrorStatCard stats={errorStats} />}
|
|
||||||
{measurements && measurements.length > 0 && (
|
|
||||||
<div>
|
|
||||||
<div className="text-[10px] text-muted-foreground mb-1">尺寸测量</div>
|
|
||||||
<MeasurementTable items={measurements} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{expanded &&
|
|
||||||
(r.type === 'smooth_heatmap' || r.type === 'assembly_inspection' || r.type === 'obj_deviation' || r.type === 'plugin') &&
|
|
||||||
r.status === 'processing' &&
|
|
||||||
(() => {
|
|
||||||
const hm = r.resultData as SmoothHeatmapMeta | null;
|
|
||||||
const p = hm?.progress ?? 0;
|
|
||||||
const stage = hm?.stage ?? '排队或启动中…';
|
|
||||||
return (
|
|
||||||
<div className="px-3 pb-3 pt-1 border-t border-border/40 space-y-2">
|
|
||||||
<div className="text-[10px] text-muted-foreground">{stage}</div>
|
|
||||||
<div className="industrial-progress h-1.5">
|
|
||||||
<div
|
|
||||||
className="industrial-progress-bar transition-[width] duration-300"
|
|
||||||
style={{
|
|
||||||
width: `${Math.min(100, Math.max(0, p * 100))}%`,
|
|
||||||
background: 'oklch(0.55 0.14 250)',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<p className="text-[9px] text-muted-foreground/80">
|
|
||||||
后台子进程执行,页面可继续操作;进度约每 1.2s 刷新。
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
|
|
||||||
{expanded && r.type === 'smooth_heatmap' && r.status === 'completed' && (
|
|
||||||
<div className="px-3 pb-3 pt-1 border-t border-border/40 space-y-2">
|
|
||||||
{(() => {
|
|
||||||
const hm = r.resultData as SmoothHeatmapMeta | null;
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="text-[10px] text-muted-foreground space-y-0.5">
|
|
||||||
{hm?.vertexCount != null && (
|
|
||||||
<div>网格顶点约 {hm.vertexCount.toLocaleString()}</div>
|
|
||||||
)}
|
|
||||||
{hm?.dispMin != null && hm?.dispMax != null && (
|
|
||||||
<div>
|
|
||||||
色标误差约 {hm.dispMin.toFixed(4)} ~ {hm.dispMax.toFixed(4)} mm
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="industrial-btn industrial-btn-primary py-1 text-[10px] flex items-center gap-1 w-full justify-center"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onViewSmoothHeatmap?.(r.id);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box className="w-3 h-3" />
|
|
||||||
在 3D 中查看
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{expanded && r.type === 'smooth_heatmap' && r.status === 'error' && (
|
|
||||||
<div className="px-3 pb-3 pt-1 border-t border-border/40">
|
|
||||||
<p className="text-[10px] text-red-400/90">
|
|
||||||
{(r.resultData as SmoothHeatmapMeta | null)?.error ?? '分析失败'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{expanded && r.type === 'assembly_inspection' && r.status === 'completed' && (
|
|
||||||
<div className="px-3 pb-3 pt-1 border-t border-border/40 space-y-2">
|
|
||||||
{(() => {
|
|
||||||
const am = r.resultData as AssemblyMeta | null;
|
|
||||||
const tol = am?.toleranceMm ?? 0.3;
|
|
||||||
const parts = am?.parts ?? [];
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="text-[10px] text-muted-foreground">
|
|
||||||
阈值 < {tol} mm 为合格;测点归属为「到各零件网格距离最小」的零件。
|
|
||||||
</div>
|
|
||||||
<div className="overflow-x-auto max-h-48 overflow-y-auto">
|
|
||||||
<table className="w-full text-[9px] border-collapse">
|
|
||||||
<thead>
|
|
||||||
<tr className="text-muted-foreground text-left">
|
|
||||||
<th className="py-0.5 pr-1">零件</th>
|
|
||||||
<th className="py-0.5 pr-1 text-right">点数</th>
|
|
||||||
<th className="py-0.5 pr-1 text-right">min</th>
|
|
||||||
<th className="py-0.5 pr-1 text-right">max</th>
|
|
||||||
<th className="py-0.5 pr-1 text-right">mean</th>
|
|
||||||
<th className="py-0.5 text-right">合格率</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{parts.map((p) => (
|
|
||||||
<tr key={p.partId} className="border-t border-border/30">
|
|
||||||
<td className="py-0.5 pr-1 font-mono truncate max-w-[100px]" title={p.name}>
|
|
||||||
{p.name}
|
|
||||||
</td>
|
|
||||||
<td className="py-0.5 pr-1 text-right">{p.pointCount}</td>
|
|
||||||
<td className="py-0.5 pr-1 text-right">{p.min.toFixed(4)}</td>
|
|
||||||
<td className="py-0.5 pr-1 text-right">{p.max.toFixed(4)}</td>
|
|
||||||
<td className="py-0.5 pr-1 text-right">{p.mean.toFixed(4)}</td>
|
|
||||||
<td className="py-0.5 text-right">
|
|
||||||
<span
|
|
||||||
className={
|
|
||||||
p.passRate >= 0.95
|
|
||||||
? 'text-green-400'
|
|
||||||
: p.passRate >= 0.85
|
|
||||||
? 'text-amber-400'
|
|
||||||
: 'text-red-400'
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{(p.passRate * 100).toFixed(1)}%
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{expanded && r.type === 'assembly_inspection' && r.status === 'error' && (
|
|
||||||
<div className="px-3 pb-3 pt-1 border-t border-border/40">
|
|
||||||
<p className="text-[10px] text-red-400/90">
|
|
||||||
{(r.resultData as AssemblyMeta | null)?.error ?? '装配检测失败'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{expanded && r.type === 'obj_deviation' && r.status === 'completed' && (
|
|
||||||
<div className="px-3 pb-3 pt-1 border-t border-border/40 space-y-2">
|
|
||||||
{(() => {
|
|
||||||
const hm = r.resultData as SmoothHeatmapMeta | null;
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="text-[10px] text-muted-foreground space-y-0.5">
|
|
||||||
<div>按顶点到最近点云点的距离着色(蓝=近 → 红=远)</div>
|
|
||||||
{hm?.vertexCount != null && (
|
|
||||||
<div>网格顶点约 {hm.vertexCount.toLocaleString()}</div>
|
|
||||||
)}
|
|
||||||
{hm?.dispMin != null && hm?.dispMax != null && (
|
|
||||||
<div>
|
|
||||||
偏差范围约 {hm.dispMin.toFixed(4)} ~ {hm.dispMax.toFixed(4)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="industrial-btn industrial-btn-primary py-1 text-[10px] flex items-center gap-1 w-full justify-center"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onViewSmoothHeatmap?.(r.id);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box className="w-3 h-3" />
|
|
||||||
在 3D 中查看
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{expanded && r.type === 'obj_deviation' && r.status === 'error' && (
|
|
||||||
<div className="px-3 pb-3 pt-1 border-t border-border/40">
|
|
||||||
<p className="text-[10px] text-red-400/90">
|
|
||||||
{(r.resultData as SmoothHeatmapMeta | null)?.error ?? '偏差分析失败'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{expanded && r.type === 'plugin' && (r.status === 'completed' || r.status === 'error') && (
|
|
||||||
<div className="px-3 pb-3 pt-1 border-t border-border/40">
|
|
||||||
<GenericResultView
|
|
||||||
taskId={taskId}
|
|
||||||
resultId={r.id}
|
|
||||||
pluginId={(r as { pluginId?: string }).pluginId ?? undefined}
|
|
||||||
pluginName={(r as { pluginId?: string }).pluginId ?? '算法插件'}
|
|
||||||
data={(r.resultData as Parameters<typeof GenericResultView>[0]['data']) ?? {}}
|
|
||||||
onViewOverlay={onViewPluginOverlay}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -100,6 +100,7 @@ function formatValue(v: number): string {
|
|||||||
export default function GenericResultView({
|
export default function GenericResultView({
|
||||||
taskId,
|
taskId,
|
||||||
resultId,
|
resultId,
|
||||||
|
pluginId,
|
||||||
pluginName,
|
pluginName,
|
||||||
data,
|
data,
|
||||||
onViewOverlay,
|
onViewOverlay,
|
||||||
@@ -112,13 +113,13 @@ export default function GenericResultView({
|
|||||||
typeof f === 'string' ? f : (f as { name?: string; path?: string }).name ?? String(f)
|
typeof f === 'string' ? f : (f as { name?: string; path?: string }).name ?? String(f)
|
||||||
);
|
);
|
||||||
const overlay = data.overlay3d ?? { kind: 'none' };
|
const overlay = data.overlay3d ?? { kind: 'none' };
|
||||||
|
const displayPluginName = pluginName ?? pluginId ?? data.pluginId;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{pluginName && (
|
{displayPluginName && (
|
||||||
<div className="text-[10px] text-muted-foreground">
|
<div className="text-[10px] text-muted-foreground">
|
||||||
{pluginName}
|
{displayPluginName}
|
||||||
{data.pluginId ? ` · ${data.pluginId}` : ''}
|
|
||||||
{data.schemaVersion != null ? ` · schema v${data.schemaVersion}` : ''}
|
{data.schemaVersion != null ? ` · schema v${data.schemaVersion}` : ''}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
Library,
|
Library,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
modelId: number;
|
modelId: number;
|
||||||
@@ -46,6 +47,31 @@ function formatDateTime(value?: string | Date) {
|
|||||||
return date.toLocaleString('zh-CN', { hour12: false });
|
return date.toLocaleString('zh-CN', { hour12: false });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type InspectionTaskStatus = 'created' | 'pending' | 'processing' | 'completed' | 'error';
|
||||||
|
|
||||||
|
const taskStatusLabels: Record<InspectionTaskStatus, string> = {
|
||||||
|
created: '未开始',
|
||||||
|
pending: '待处理',
|
||||||
|
processing: '处理中',
|
||||||
|
completed: '已完成',
|
||||||
|
error: '异常',
|
||||||
|
};
|
||||||
|
|
||||||
|
function taskStatusClass(status: InspectionTaskStatus) {
|
||||||
|
switch (status) {
|
||||||
|
case 'completed':
|
||||||
|
return 'bg-green-900/40 text-green-400';
|
||||||
|
case 'processing':
|
||||||
|
case 'pending':
|
||||||
|
return 'bg-blue-900/40 text-blue-400';
|
||||||
|
case 'error':
|
||||||
|
return 'bg-red-900/40 text-red-400';
|
||||||
|
case 'created':
|
||||||
|
default:
|
||||||
|
return 'bg-secondary text-muted-foreground';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export default function TaskManager({
|
export default function TaskManager({
|
||||||
modelId,
|
modelId,
|
||||||
activeTaskId,
|
activeTaskId,
|
||||||
@@ -82,9 +108,21 @@ export default function TaskManager({
|
|||||||
const [selectedModelCadFileIds, setSelectedModelCadFileIds] = useState<Set<number>>(new Set());
|
const [selectedModelCadFileIds, setSelectedModelCadFileIds] = useState<Set<number>>(new Set());
|
||||||
const [uploadingModelCad, setUploadingModelCad] = useState(false);
|
const [uploadingModelCad, setUploadingModelCad] = useState(false);
|
||||||
|
|
||||||
const { data: tasks = [] } = trpc.inspectionTask.listByModel.useQuery({ modelId });
|
const { data: tasks = [] } = trpc.inspectionTask.listByModel.useQuery(
|
||||||
|
{ modelId },
|
||||||
|
{
|
||||||
|
refetchInterval: 3000,
|
||||||
|
refetchIntervalInBackground: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
const createTask = trpc.inspectionTask.create.useMutation({
|
const createTask = trpc.inspectionTask.create.useMutation({
|
||||||
onSuccess: () => utils.inspectionTask.listByModel.invalidate({ modelId }),
|
onSuccess: () => {
|
||||||
|
utils.inspectionTask.listByModel.invalidate({ modelId });
|
||||||
|
setTaskName('');
|
||||||
|
setAdding(false);
|
||||||
|
onEndCreate?.();
|
||||||
|
},
|
||||||
|
onError: (e) => toast.error(e.message || '新建任务失败'),
|
||||||
});
|
});
|
||||||
const deleteTask = trpc.inspectionTask.delete.useMutation({
|
const deleteTask = trpc.inspectionTask.delete.useMutation({
|
||||||
onSuccess: () => utils.inspectionTask.listByModel.invalidate({ modelId }),
|
onSuccess: () => utils.inspectionTask.listByModel.invalidate({ modelId }),
|
||||||
@@ -187,6 +225,12 @@ export default function TaskManager({
|
|||||||
fileRef.current?.click();
|
fileRef.current?.click();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function submitCreateTask() {
|
||||||
|
const name = taskName.trim();
|
||||||
|
if (!name || createTask.isPending) return;
|
||||||
|
createTask.mutate({ modelId, name });
|
||||||
|
}
|
||||||
|
|
||||||
async function handleUpload(files: FileList) {
|
async function handleUpload(files: FileList) {
|
||||||
const taskId = pendingTaskId.current;
|
const taskId = pendingTaskId.current;
|
||||||
if (!taskId) return;
|
if (!taskId) return;
|
||||||
@@ -373,10 +417,7 @@ export default function TaskManager({
|
|||||||
onChange={(e) => setTaskName(e.target.value)}
|
onChange={(e) => setTaskName(e.target.value)}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Enter' && taskName.trim()) {
|
if (e.key === 'Enter' && taskName.trim()) {
|
||||||
createTask.mutate({ modelId, name: taskName.trim() });
|
submitCreateTask();
|
||||||
setTaskName('');
|
|
||||||
setAdding(false);
|
|
||||||
onEndCreate?.();
|
|
||||||
}
|
}
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
setAdding(false);
|
setAdding(false);
|
||||||
@@ -386,11 +427,8 @@ export default function TaskManager({
|
|||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
className="text-green-400"
|
className="text-green-400"
|
||||||
onClick={() => {
|
disabled={createTask.isPending}
|
||||||
if (taskName.trim()) createTask.mutate({ modelId, name: taskName.trim() });
|
onClick={submitCreateTask}
|
||||||
setAdding(false);
|
|
||||||
onEndCreate?.();
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Check className="w-3.5 h-3.5" />
|
<Check className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
@@ -407,52 +445,47 @@ export default function TaskManager({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{tasks.map((task) => (
|
{tasks.map((task) => {
|
||||||
<div
|
const status = task.status as InspectionTaskStatus;
|
||||||
key={task.id}
|
return (
|
||||||
className={`flex items-center gap-2 px-2 py-1.5 rounded text-xs group cursor-pointer
|
<div
|
||||||
${activeTaskId === task.id ? 'bg-primary/20 text-primary' : 'industrial-card hover:bg-secondary/60'}`}
|
key={task.id}
|
||||||
onClick={() => onSelectTask(task.id)}
|
className={`flex items-center gap-2 px-2 py-1.5 rounded text-xs group cursor-pointer
|
||||||
>
|
${activeTaskId === task.id ? 'bg-primary/20 text-primary' : 'industrial-card hover:bg-secondary/60'}`}
|
||||||
<ClipboardList
|
onClick={() => onSelectTask(task.id)}
|
||||||
className={`w-3.5 h-3.5 flex-shrink-0 ${activeTaskId === task.id ? 'text-primary' : 'text-amber-400'}`}
|
|
||||||
/>
|
|
||||||
<span className="flex-1 truncate">{task.name}</span>
|
|
||||||
<span
|
|
||||||
className={`text-[9px] px-1 py-0.5 rounded flex-shrink-0 ${
|
|
||||||
task.status === 'completed'
|
|
||||||
? 'bg-green-900/40 text-green-400'
|
|
||||||
: task.status === 'processing'
|
|
||||||
? 'bg-blue-900/40 text-blue-400'
|
|
||||||
: task.status === 'error'
|
|
||||||
? 'bg-red-900/40 text-red-400'
|
|
||||||
: 'bg-secondary text-muted-foreground'
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{task.status}
|
<ClipboardList
|
||||||
</span>
|
className={`w-3.5 h-3.5 flex-shrink-0 ${activeTaskId === task.id ? 'text-primary' : 'text-amber-400'}`}
|
||||||
<button
|
/>
|
||||||
className="hidden group-hover:block p-0.5 rounded hover:bg-secondary"
|
<span className="flex-1 truncate">{task.name}</span>
|
||||||
title="上传点云文件"
|
<span
|
||||||
onClick={(e) => {
|
className={`text-[9px] px-1 py-0.5 rounded flex-shrink-0 ${taskStatusClass(status)}`}
|
||||||
e.stopPropagation();
|
>
|
||||||
openUpload(task.id);
|
{taskStatusLabels[status] ?? status}
|
||||||
}}
|
</span>
|
||||||
>
|
<button
|
||||||
<Upload className="w-3 h-3" />
|
className="hidden group-hover:block p-0.5 rounded hover:bg-secondary"
|
||||||
</button>
|
title="上传点云文件"
|
||||||
<button
|
onClick={(e) => {
|
||||||
className="hidden group-hover:block p-0.5 rounded hover:bg-secondary text-destructive"
|
e.stopPropagation();
|
||||||
title="删除任务"
|
openUpload(task.id);
|
||||||
onClick={(e) => {
|
}}
|
||||||
e.stopPropagation();
|
>
|
||||||
if (confirm(`删除任务「${task.name}」?`)) deleteTask.mutate({ id: task.id });
|
<Upload className="w-3 h-3" />
|
||||||
}}
|
</button>
|
||||||
>
|
<button
|
||||||
<Trash2 className="w-3 h-3" />
|
className="hidden group-hover:block p-0.5 rounded hover:bg-secondary text-destructive"
|
||||||
</button>
|
title="删除任务"
|
||||||
</div>
|
onClick={(e) => {
|
||||||
))}
|
e.stopPropagation();
|
||||||
|
if (confirm(`删除任务「${task.name}」?`)) deleteTask.mutate({ id: task.id });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{tasks.length === 0 && (
|
{tasks.length === 0 && (
|
||||||
|
|||||||
@@ -1,111 +0,0 @@
|
|||||||
import { type ElementType } from 'react';
|
|
||||||
import { CheckCircle2, Loader2, FolderOpen, Cloud, Box, BarChart3, Save } from 'lucide-react';
|
|
||||||
|
|
||||||
export type WorkflowStepId = 'cad' | 'pointcloud' | 'render' | 'analysis' | 'save';
|
|
||||||
|
|
||||||
export type StepStatus = 'done' | 'active' | 'pending';
|
|
||||||
|
|
||||||
export interface WorkflowState {
|
|
||||||
cad: StepStatus;
|
|
||||||
pointcloud: StepStatus;
|
|
||||||
render: StepStatus;
|
|
||||||
analysis: StepStatus;
|
|
||||||
save: StepStatus;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface StepDef {
|
|
||||||
id: WorkflowStepId;
|
|
||||||
label: string;
|
|
||||||
icon: ElementType;
|
|
||||||
}
|
|
||||||
|
|
||||||
const STEPS: StepDef[] = [
|
|
||||||
{ id: 'cad', label: 'CAD 模型', icon: Box },
|
|
||||||
{ id: 'pointcloud', label: '点云采集', icon: Cloud },
|
|
||||||
{ id: 'render', label: '渲染完成', icon: FolderOpen },
|
|
||||||
{ id: 'analysis', label: '分析完成', icon: BarChart3 },
|
|
||||||
{ id: 'save', label: '保存完成', icon: Save },
|
|
||||||
];
|
|
||||||
|
|
||||||
function StepNode({
|
|
||||||
step,
|
|
||||||
status,
|
|
||||||
onClick,
|
|
||||||
}: {
|
|
||||||
step: StepDef;
|
|
||||||
status: StepStatus;
|
|
||||||
onClick?: () => void;
|
|
||||||
}) {
|
|
||||||
const Icon = step.icon;
|
|
||||||
|
|
||||||
const circleClass =
|
|
||||||
status === 'done'
|
|
||||||
? 'bg-[oklch(0.55_0.18_145)] border-[oklch(0.55_0.18_145)] text-white'
|
|
||||||
: status === 'active'
|
|
||||||
? 'bg-primary/20 border-primary text-primary'
|
|
||||||
: 'bg-transparent border-border text-muted-foreground/40';
|
|
||||||
|
|
||||||
const labelClass =
|
|
||||||
status === 'done'
|
|
||||||
? 'text-[oklch(0.75_0.14_145)]'
|
|
||||||
: status === 'active'
|
|
||||||
? 'text-primary'
|
|
||||||
: 'text-muted-foreground/40';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
onClick={onClick}
|
|
||||||
disabled={!onClick || status === 'pending'}
|
|
||||||
className={`flex flex-col items-center gap-1 transition-opacity
|
|
||||||
${status === 'pending' ? 'cursor-default' : 'cursor-pointer hover:opacity-80'}`}
|
|
||||||
>
|
|
||||||
<div className={`w-8 h-8 rounded-full border-2 flex items-center justify-center transition-all ${circleClass}`}>
|
|
||||||
{status === 'done' ? (
|
|
||||||
<CheckCircle2 className="w-4 h-4" />
|
|
||||||
) : status === 'active' ? (
|
|
||||||
<Loader2 className="w-4 h-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Icon className="w-4 h-4" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<span className={`text-[10px] leading-tight whitespace-nowrap transition-colors ${labelClass}`}>
|
|
||||||
{step.label}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Connector({ done }: { done: boolean }) {
|
|
||||||
return (
|
|
||||||
<div className="flex-1 flex items-center pb-4 px-1 min-w-[24px]">
|
|
||||||
<div className={`h-px w-full transition-colors duration-500 ${done ? 'bg-[oklch(0.55_0.18_145)]' : 'bg-border'}`} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
state: WorkflowState;
|
|
||||||
onStepClick?: (id: WorkflowStepId) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function WorkflowProgress({ state, onStepClick }: Props) {
|
|
||||||
return (
|
|
||||||
<div className="h-14 border-b border-border bg-card/60 flex items-center px-6 flex-shrink-0">
|
|
||||||
<span className="text-[10px] text-muted-foreground uppercase tracking-wider mr-4 flex-shrink-0">
|
|
||||||
工作流程
|
|
||||||
</span>
|
|
||||||
<div className="flex items-center flex-1 min-w-0">
|
|
||||||
{STEPS.map((step, i) => (
|
|
||||||
<div key={step.id} className="flex items-center flex-1 min-w-0">
|
|
||||||
<StepNode
|
|
||||||
step={step}
|
|
||||||
status={state[step.id]}
|
|
||||||
onClick={onStepClick ? () => onStepClick(step.id) : undefined}
|
|
||||||
/>
|
|
||||||
{i < STEPS.length - 1 && <Connector done={state[step.id] === 'done'} />}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -69,7 +69,12 @@ const trpcLinkOptions = {
|
|||||||
const sep = input.includes('?') ? '&' : '?';
|
const sep = input.includes('?') ? '&' : '?';
|
||||||
url = `${input}${sep}_t=${Date.now()}`;
|
url = `${input}${sep}_t=${Date.now()}`;
|
||||||
} else {
|
} else {
|
||||||
url = input as string;
|
const requestUrl = new URL(input.url);
|
||||||
|
requestUrl.searchParams.set('_t', Date.now().toString());
|
||||||
|
return globalThis.fetch(new Request(requestUrl.toString(), input), {
|
||||||
|
...(init ?? {}),
|
||||||
|
credentials: "include",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return globalThis.fetch(url, {
|
return globalThis.fetch(url, {
|
||||||
...(init ?? {}),
|
...(init ?? {}),
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useCallback, useEffect, useRef, useMemo } from 'react';
|
import { useState, useCallback, useEffect, useMemo } from 'react';
|
||||||
import { useAuth } from '@/_core/hooks/useAuth';
|
import { useAuth } from '@/_core/hooks/useAuth';
|
||||||
import { getLoginUrl } from '@/const';
|
import { getLoginUrl } from '@/const';
|
||||||
import PointCloudViewer from '@/components/PointCloudViewer';
|
import PointCloudViewer from '@/components/PointCloudViewer';
|
||||||
@@ -7,7 +7,6 @@ import TaskManager from '@/components/TaskManager';
|
|||||||
import AnalysisPanel from '@/components/AnalysisPanel';
|
import AnalysisPanel from '@/components/AnalysisPanel';
|
||||||
import AlgorithmRunPanel from '@/components/AlgorithmRunPanel';
|
import AlgorithmRunPanel from '@/components/AlgorithmRunPanel';
|
||||||
import type { Overlay3D } from '@/components/GenericResultView';
|
import type { Overlay3D } from '@/components/GenericResultView';
|
||||||
import WorkflowProgress, { type WorkflowState, type WorkflowStepId, type StepStatus } from '@/components/WorkflowProgress';
|
|
||||||
import { trpc } from '@/lib/trpc';
|
import { trpc } from '@/lib/trpc';
|
||||||
import {
|
import {
|
||||||
LogIn,
|
LogIn,
|
||||||
@@ -90,11 +89,6 @@ export default function Home() {
|
|||||||
overlay: Overlay3D;
|
overlay: Overlay3D;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [errorPickEnabled, setErrorPickEnabled] = useState(false);
|
const [errorPickEnabled, setErrorPickEnabled] = useState(false);
|
||||||
const [isCreatingTask, setIsCreatingTask] = useState(false);
|
|
||||||
// 触发分析时的 completed 结果基线数(key=taskId, value=触发时已有的 completed 数)
|
|
||||||
const [analysisBaselines, setAnalysisBaselines] = useState<Map<number, number>>(new Map());
|
|
||||||
// 用 ref 在 onSuccess 闭包中读取最新 analysisResults,避免过时闭包
|
|
||||||
const analysisResultsRef = useRef<any[]>([]);
|
|
||||||
const [showLeftPanel, setShowLeftPanel] = useState(true);
|
const [showLeftPanel, setShowLeftPanel] = useState(true);
|
||||||
const [showRightPanel, setShowRightPanel] = useState(true);
|
const [showRightPanel, setShowRightPanel] = useState(true);
|
||||||
|
|
||||||
@@ -122,13 +116,6 @@ export default function Home() {
|
|||||||
toast.success('已提交偏差热力分析(STL/OBJ + 点云距离),完成后点击「在 3D 中查看」');
|
toast.success('已提交偏差热力分析(STL/OBJ + 点云距离),完成后点击「在 3D 中查看」');
|
||||||
}
|
}
|
||||||
void utils.analysisResult.list.invalidate({ taskId: variables.taskId });
|
void utils.analysisResult.list.invalidate({ taskId: variables.taskId });
|
||||||
// 记录此时已有的 completed 数作为基线,只有新增结果才算本次分析完成
|
|
||||||
const baselineCount = analysisResultsRef.current.filter((r: any) => r.status === 'completed').length;
|
|
||||||
setAnalysisBaselines(prev => {
|
|
||||||
const next = new Map(prev);
|
|
||||||
if (!next.has(variables.taskId)) next.set(variables.taskId, baselineCount);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
onError: (e) => toast.error(e.message || '提交失败'),
|
onError: (e) => toast.error(e.message || '提交失败'),
|
||||||
});
|
});
|
||||||
@@ -146,7 +133,6 @@ export default function Home() {
|
|||||||
setHeatmapViewResultId(null);
|
setHeatmapViewResultId(null);
|
||||||
setPluginOverlay(null);
|
setPluginOverlay(null);
|
||||||
setPointCloudRenderMode('points');
|
setPointCloudRenderMode('points');
|
||||||
setIsCreatingTask(false);
|
|
||||||
setSelectedPcFileIds([]);
|
setSelectedPcFileIds([]);
|
||||||
setSelectedTaskCadFileIds([]);
|
setSelectedTaskCadFileIds([]);
|
||||||
setRoiPickMode(false);
|
setRoiPickMode(false);
|
||||||
@@ -253,57 +239,6 @@ export default function Home() {
|
|||||||
// ── 把 __task:N 格式的 URL 转成 batch 请求参数 ──────────────────────────────
|
// ── 把 __task:N 格式的 URL 转成 batch 请求参数 ──────────────────────────────
|
||||||
const batchTaskId = pointCloudTaskId;
|
const batchTaskId = pointCloudTaskId;
|
||||||
|
|
||||||
// ── 工作流进度条状态推导 ───────────────────────────────────────────────────
|
|
||||||
const { data: analysisResults = [] } = trpc.analysisResult.list.useQuery(
|
|
||||||
{ taskId: selection.taskId! },
|
|
||||||
{ enabled: selection.taskId != null, refetchInterval: 5000 },
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => { analysisResultsRef.current = analysisResults; }, [analysisResults]);
|
|
||||||
|
|
||||||
const workflowState = useMemo<WorkflowState>(() => {
|
|
||||||
const hasCad = !!cadModelUrl;
|
|
||||||
const hasPc = pointCloudTaskId != null;
|
|
||||||
const rendered = hasPc && !pcLoading && pcProgress >= 100;
|
|
||||||
const baseline = (!isCreatingTask && selection.taskId != null)
|
|
||||||
? (analysisBaselines.get(selection.taskId) ?? -1)
|
|
||||||
: -1;
|
|
||||||
const triggered = baseline >= 0;
|
|
||||||
const currentCompleted = (analysisResults as any[]).filter((r: any) => r.status === 'completed').length;
|
|
||||||
const analyzed = triggered && currentCompleted > baseline;
|
|
||||||
|
|
||||||
function step(cond: boolean, prev: boolean): 'done' | 'active' | 'pending' {
|
|
||||||
if (cond) return 'done';
|
|
||||||
if (prev) return 'active';
|
|
||||||
return 'pending';
|
|
||||||
}
|
|
||||||
|
|
||||||
// 分析步骤:只在 触发后未完成 时转圈;保存步骤:分析完成即完成,无中间态
|
|
||||||
const analysisStatus: StepStatus = analyzed ? 'done' : triggered ? 'active' : 'pending';
|
|
||||||
const saveStatus: StepStatus = analyzed ? 'done' : 'pending';
|
|
||||||
|
|
||||||
return {
|
|
||||||
cad: step(hasCad, true),
|
|
||||||
pointcloud: step(hasPc, hasCad),
|
|
||||||
render: step(rendered, hasPc),
|
|
||||||
analysis: analysisStatus,
|
|
||||||
save: saveStatus,
|
|
||||||
};
|
|
||||||
}, [cadModelUrl, pointCloudTaskId, pcLoading, pcProgress, analysisResults, isCreatingTask, analysisBaselines, selection.taskId]);
|
|
||||||
|
|
||||||
const rightPanelRef = useRef<HTMLDivElement | null>(null);
|
|
||||||
|
|
||||||
const handleWorkflowStepClick = useCallback((id: WorkflowStepId) => {
|
|
||||||
if (id === 'cad' || id === 'pointcloud') {
|
|
||||||
setShowLeftPanel(true);
|
|
||||||
} else if (id === 'render') {
|
|
||||||
if (selection.taskId != null && pointCloudTaskId !== selection.taskId) {
|
|
||||||
handleLoadPointClouds(selection.taskId, []);
|
|
||||||
}
|
|
||||||
} else if (id === 'analysis' || id === 'save') {
|
|
||||||
setShowRightPanel(true);
|
|
||||||
}
|
|
||||||
}, [selection.taskId, pointCloudTaskId, handleLoadPointClouds]);
|
|
||||||
// 必须稳定数组引用:若每次 render 都 new [],PointCloudViewer 会以为点云源变了而反复全量请求
|
// 必须稳定数组引用:若每次 render 都 new [],PointCloudViewer 会以为点云源变了而反复全量请求
|
||||||
const pointCloudUrlsStable = useMemo(
|
const pointCloudUrlsStable = useMemo(
|
||||||
() => (pointCloudTaskId != null ? [`__task:${pointCloudTaskId}`] : undefined),
|
() => (pointCloudTaskId != null ? [`__task:${pointCloudTaskId}`] : undefined),
|
||||||
@@ -329,8 +264,6 @@ export default function Home() {
|
|||||||
onPcFilesSelectionChange={setSelectedPcFileIds}
|
onPcFilesSelectionChange={setSelectedPcFileIds}
|
||||||
onCadFilesSelectionChange={setSelectedTaskCadFileIds}
|
onCadFilesSelectionChange={setSelectedTaskCadFileIds}
|
||||||
onSelectTaskCad={handleSelectCad}
|
onSelectTaskCad={handleSelectCad}
|
||||||
onStartCreate={() => setIsCreatingTask(true)}
|
|
||||||
onEndCreate={() => setIsCreatingTask(false)}
|
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@@ -350,6 +283,7 @@ export default function Home() {
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<AlgorithmRunPanel
|
<AlgorithmRunPanel
|
||||||
taskId={selection.taskId}
|
taskId={selection.taskId}
|
||||||
|
modelId={selection.modelId}
|
||||||
pointCloudLoaded={pointCloudTaskId === selection.taskId && pointCloudUrls.length > 0}
|
pointCloudLoaded={pointCloudTaskId === selection.taskId && pointCloudUrls.length > 0}
|
||||||
taskPcFileCount={taskPcFiles.length}
|
taskPcFileCount={taskPcFiles.length}
|
||||||
taskPointcloudFiles={taskPcFiles}
|
taskPointcloudFiles={taskPcFiles}
|
||||||
@@ -481,13 +415,6 @@ export default function Home() {
|
|||||||
|
|
||||||
{/* 中央 3D 视图 */}
|
{/* 中央 3D 视图 */}
|
||||||
<main className="flex-1 flex flex-col min-w-0">
|
<main className="flex-1 flex flex-col min-w-0">
|
||||||
{/* 工作流进度条(仅在选中任务时显示) */}
|
|
||||||
{selection.taskId != null && (
|
|
||||||
<WorkflowProgress
|
|
||||||
state={workflowState}
|
|
||||||
onStepClick={handleWorkflowStepClick}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<div className="flex-1 relative">
|
<div className="flex-1 relative">
|
||||||
<div className="absolute inset-2">
|
<div className="absolute inset-2">
|
||||||
<div className="viewer-container w-full h-full relative">
|
<div className="viewer-container w-full h-full relative">
|
||||||
@@ -670,7 +597,7 @@ export default function Home() {
|
|||||||
|
|
||||||
{/* 右侧面板 - 分析结果 */}
|
{/* 右侧面板 - 分析结果 */}
|
||||||
{showRightPanel && (
|
{showRightPanel && (
|
||||||
<aside ref={rightPanelRef} className="w-80 border-l border-border bg-card/30 flex-shrink-0 overflow-y-auto p-3">
|
<aside className="w-80 border-l border-border bg-card/30 flex-shrink-0 overflow-y-auto p-3">
|
||||||
{renderRightPanel()}
|
{renderRightPanel()}
|
||||||
</aside>
|
</aside>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -130,10 +130,11 @@ CREATE TABLE IF NOT EXISTS `inspection_tasks` (
|
|||||||
`name` VARCHAR(255) NOT NULL,
|
`name` VARCHAR(255) NOT NULL,
|
||||||
`inspector` VARCHAR(100),
|
`inspector` VARCHAR(100),
|
||||||
`notes` TEXT,
|
`notes` TEXT,
|
||||||
`status` ENUM('pending','processing','completed','error') NOT NULL DEFAULT 'pending',
|
`status` ENUM('created','pending','processing','completed','error') NOT NULL DEFAULT 'created',
|
||||||
`createdAt` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
`createdAt` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
`updatedAt` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
`updatedAt` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
`completedAt` TIMESTAMP NULL
|
`completedAt` TIMESTAMP NULL,
|
||||||
|
UNIQUE KEY `uk_inspection_tasks_model_name` (`modelId`, `name`)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS `task_pointcloud_files` (
|
CREATE TABLE IF NOT EXISTS `task_pointcloud_files` (
|
||||||
@@ -148,8 +149,8 @@ CREATE TABLE IF NOT EXISTS `task_pointcloud_files` (
|
|||||||
CREATE TABLE IF NOT EXISTS `task_analysis_results` (
|
CREATE TABLE IF NOT EXISTS `task_analysis_results` (
|
||||||
`id` INT AUTO_INCREMENT PRIMARY KEY,
|
`id` INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
`taskId` INT NOT NULL,
|
`taskId` INT NOT NULL,
|
||||||
`type` ENUM('error_analysis','measurement','compression','smooth_heatmap','assembly_inspection','obj_deviation') NOT NULL,
|
`type` ENUM('error_analysis','measurement','compression','smooth_heatmap','assembly_inspection','obj_deviation','plugin') NOT NULL,
|
||||||
`status` ENUM('pending','processing','completed','error') NOT NULL DEFAULT 'pending',
|
`status` ENUM('pending','processing','completed','error','cancelled') NOT NULL DEFAULT 'pending',
|
||||||
`errorStats` JSON,
|
`errorStats` JSON,
|
||||||
`measurements` JSON,
|
`measurements` JSON,
|
||||||
`resultData` JSON,
|
`resultData` JSON,
|
||||||
|
|||||||
11
drizzle/0012_inspection_task_created_status.sql
Normal file
11
drizzle/0012_inspection_task_created_status.sql
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
-- 检测任务新增 created 初始状态;task_analysis_results.status 仍保留原有语义
|
||||||
|
|
||||||
|
ALTER TABLE `inspection_tasks`
|
||||||
|
MODIFY COLUMN `status` ENUM('created','pending','processing','completed','error') NOT NULL DEFAULT 'created';
|
||||||
|
|
||||||
|
UPDATE `inspection_tasks` AS t
|
||||||
|
LEFT JOIN `task_analysis_results` AS r
|
||||||
|
ON r.`taskId` = t.`id`
|
||||||
|
SET t.`status` = 'created'
|
||||||
|
WHERE t.`status` = 'pending'
|
||||||
|
AND r.`id` IS NULL;
|
||||||
3
drizzle/0013_task_analysis_cancelled_status.sql
Normal file
3
drizzle/0013_task_analysis_cancelled_status.sql
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
-- 任务分析结果新增 cancelled:停止后保留记录与进度,不按 error 展示。
|
||||||
|
ALTER TABLE task_analysis_results
|
||||||
|
MODIFY COLUMN status ENUM('pending','processing','completed','error','cancelled') NOT NULL DEFAULT 'pending';
|
||||||
2
drizzle/0014_inspection_task_unique_model_name.sql
Normal file
2
drizzle/0014_inspection_task_unique_model_name.sql
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE `inspection_tasks`
|
||||||
|
ADD UNIQUE KEY `uk_inspection_tasks_model_name` (`modelId`, `name`);
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { int, mysqlEnum, mysqlTable, text, timestamp, varchar, bigint, json } from "drizzle-orm/mysql-core";
|
import { int, mysqlEnum, mysqlTable, text, timestamp, varchar, bigint, json, uniqueIndex } from "drizzle-orm/mysql-core";
|
||||||
|
|
||||||
// ─── 用户表(保持不变) ────────────────────────────────────────────────────────
|
// ─── 用户表(保持不变) ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -106,10 +106,14 @@ export const inspectionTasks = mysqlTable("inspection_tasks", {
|
|||||||
name: varchar("name", { length: 255 }).notNull(),
|
name: varchar("name", { length: 255 }).notNull(),
|
||||||
inspector: varchar("inspector", { length: 100 }),
|
inspector: varchar("inspector", { length: 100 }),
|
||||||
notes: text("notes"),
|
notes: text("notes"),
|
||||||
status: mysqlEnum("status", ["pending", "processing", "completed", "error"]).default("pending").notNull(),
|
status: mysqlEnum("status", ["created", "pending", "processing", "completed", "error"]).default("created").notNull(),
|
||||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||||
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
|
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
|
||||||
completedAt: timestamp("completedAt"),
|
completedAt: timestamp("completedAt"),
|
||||||
|
}, (table) => {
|
||||||
|
return {
|
||||||
|
modelNameUnique: uniqueIndex("uk_inspection_tasks_model_name").on(table.modelId, table.name),
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
export type InspectionTask = typeof inspectionTasks.$inferSelect;
|
export type InspectionTask = typeof inspectionTasks.$inferSelect;
|
||||||
@@ -158,7 +162,7 @@ export const taskAnalysisResults = mysqlTable("task_analysis_results", {
|
|||||||
pluginId: varchar("pluginId", { length: 64 }),
|
pluginId: varchar("pluginId", { length: 64 }),
|
||||||
/** 算法 result.json 的 schema 版本,便于回放时严格校验 */
|
/** 算法 result.json 的 schema 版本,便于回放时严格校验 */
|
||||||
schemaVersion: int("schemaVersion"),
|
schemaVersion: int("schemaVersion"),
|
||||||
status: mysqlEnum("status", ["pending", "processing", "completed", "error"]).default("pending").notNull(),
|
status: mysqlEnum("status", ["pending", "processing", "completed", "error", "cancelled"]).default("pending").notNull(),
|
||||||
errorStats: json("errorStats"), // { min, max, mean, stdDev, totalPoints, withinTolerance, outOfTolerance }
|
errorStats: json("errorStats"), // { min, max, mean, stdDev, totalPoints, withinTolerance, outOfTolerance }
|
||||||
measurements: json("measurements"), // [{ name, value, unit, tolerance, status }]
|
measurements: json("measurements"), // [{ name, value, unit, tolerance, status }]
|
||||||
resultData: json("resultData"), // 其他分析数据(如压缩比等)
|
resultData: json("resultData"), // 其他分析数据(如压缩比等)
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ const SERVER_ROOT = path.dirname(fileURLToPath(import.meta.url));
|
|||||||
export async function setupVite(app: Express, server: Server) {
|
export async function setupVite(app: Express, server: Server) {
|
||||||
const serverOptions = {
|
const serverOptions = {
|
||||||
middlewareMode: true,
|
middlewareMode: true,
|
||||||
// Disable Vite client-side auto reload/HMR in dev.
|
// Reuse the app HTTP server for HMR so SSH forwarding only needs the app port.
|
||||||
hmr: false as const,
|
hmr: { server },
|
||||||
allowedHosts: true as const,
|
allowedHosts: true as const,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
* 主进程不直接 spawn Python,而是 fork Node runner —— 与现有
|
* 主进程不直接 spawn Python,而是 fork Node runner —— 与现有
|
||||||
* smoothHeatmapJob / deviationHeatmapJob 的架构保持一致。
|
* smoothHeatmapJob / deviationHeatmapJob 的架构保持一致。
|
||||||
*/
|
*/
|
||||||
import { fork, ChildProcess } from 'node:child_process';
|
import { fork, ChildProcess, execFileSync } from 'node:child_process';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
|
||||||
@@ -67,7 +67,7 @@ class ConcurrencyPool {
|
|||||||
child.kill('SIGKILL');
|
child.kill('SIGKILL');
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return false;
|
return killOrphanAlgorithmProcesses({ resultId });
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 取消某个 taskId 下所有 job(删除任务时用)。返回被取消的 resultId 数组。 */
|
/** 取消某个 taskId 下所有 job(删除任务时用)。返回被取消的 resultId 数组。 */
|
||||||
@@ -85,13 +85,17 @@ class ConcurrencyPool {
|
|||||||
}
|
}
|
||||||
this.queue = keep;
|
this.queue = keep;
|
||||||
// 运行中 kill
|
// 运行中 kill
|
||||||
for (const [resultId, child] of this.activeChildren) {
|
for (const [resultId, child] of Array.from(this.activeChildren.entries())) {
|
||||||
const jobTaskId = (child as ChildProcess & { _taskId?: number })._taskId;
|
const jobTaskId = (child as ChildProcess & { _taskId?: number })._taskId;
|
||||||
if (jobTaskId === taskId) {
|
if (jobTaskId === taskId) {
|
||||||
child.kill('SIGKILL');
|
child.kill('SIGKILL');
|
||||||
cancelled.push(resultId);
|
cancelled.push(resultId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const orphaned = killOrphanAlgorithmProcesses({ taskId });
|
||||||
|
if (orphaned.length > 0) {
|
||||||
|
cancelled.push(...orphaned);
|
||||||
|
}
|
||||||
return cancelled;
|
return cancelled;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,6 +159,113 @@ function resolveAlgorithmRunnerEntry(): string {
|
|||||||
|
|
||||||
const pool = new ConcurrencyPool(DEFAULT_CONCURRENCY);
|
const pool = new ConcurrencyPool(DEFAULT_CONCURRENCY);
|
||||||
|
|
||||||
|
interface ProcessRow {
|
||||||
|
pid: number;
|
||||||
|
ppid: number;
|
||||||
|
command: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function listProcesses(): ProcessRow[] {
|
||||||
|
try {
|
||||||
|
const output = execFileSync('ps', ['-eo', 'pid=,ppid=,command='], {
|
||||||
|
encoding: 'utf8',
|
||||||
|
maxBuffer: 1024 * 1024,
|
||||||
|
});
|
||||||
|
return output
|
||||||
|
.split('\n')
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((line) => {
|
||||||
|
const match = line.match(/^(\d+)\s+(\d+)\s+(.+)$/);
|
||||||
|
if (!match) return null;
|
||||||
|
return {
|
||||||
|
pid: Number(match[1]),
|
||||||
|
ppid: Number(match[2]),
|
||||||
|
command: match[3],
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((row): row is ProcessRow => row != null && Number.isFinite(row.pid) && Number.isFinite(row.ppid));
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[AlgorithmJob] 无法列出进程,跳过孤儿进程清理:', err);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectDescendants(processes: ProcessRow[], rootPids: Set<number>): Set<number> {
|
||||||
|
const all = new Set(rootPids);
|
||||||
|
let changed = true;
|
||||||
|
while (changed) {
|
||||||
|
changed = false;
|
||||||
|
for (const proc of processes) {
|
||||||
|
if (!all.has(proc.pid) && all.has(proc.ppid)) {
|
||||||
|
all.add(proc.pid);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return all;
|
||||||
|
}
|
||||||
|
|
||||||
|
function killPids(pids: number[]): number[] {
|
||||||
|
const killed: number[] = [];
|
||||||
|
for (const pid of pids) {
|
||||||
|
if (pid <= 1 || pid === process.pid) continue;
|
||||||
|
try {
|
||||||
|
process.kill(pid, 'SIGKILL');
|
||||||
|
killed.push(pid);
|
||||||
|
} catch {
|
||||||
|
// Process may already have exited.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return killed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function killOrphanAlgorithmProcesses(input: { resultId: number }): boolean;
|
||||||
|
function killOrphanAlgorithmProcesses(input: { taskId: number }): number[];
|
||||||
|
function killOrphanAlgorithmProcesses(input: { resultId?: number; taskId?: number }): boolean | number[] {
|
||||||
|
const processes = listProcesses();
|
||||||
|
const roots = new Set<number>();
|
||||||
|
const resultIds = new Set<number>();
|
||||||
|
|
||||||
|
for (const proc of processes) {
|
||||||
|
const cmd = proc.command;
|
||||||
|
if (input.resultId != null) {
|
||||||
|
const resultId = String(input.resultId);
|
||||||
|
if (
|
||||||
|
new RegExp(`algorithmJobRunner\\.(?:ts|js)\\s+${resultId}\\s+`).test(cmd) ||
|
||||||
|
cmd.includes(`/analysis/${resultId}/input.json`)
|
||||||
|
) {
|
||||||
|
roots.add(proc.pid);
|
||||||
|
resultIds.add(input.resultId);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.taskId != null) {
|
||||||
|
const runnerMatch = cmd.match(/algorithmJobRunner\.(?:ts|js)\s+(\d+)\s+(\d+)\s+/);
|
||||||
|
if (runnerMatch && Number(runnerMatch[2]) === input.taskId) {
|
||||||
|
roots.add(proc.pid);
|
||||||
|
resultIds.add(Number(runnerMatch[1]));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const inputJsonMatch = cmd.match(new RegExp(`/data/tasks/${input.taskId}/analysis/(\\d+)/input\\.json`));
|
||||||
|
if (inputJsonMatch) {
|
||||||
|
roots.add(proc.pid);
|
||||||
|
resultIds.add(Number(inputJsonMatch[1]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const targets = Array.from(collectDescendants(processes, roots)).sort((a, b) => b - a);
|
||||||
|
const killed = killPids(targets);
|
||||||
|
if (killed.length > 0) {
|
||||||
|
console.warn(`[AlgorithmJob] 已清理孤儿算法进程 pid=${killed.join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.resultId != null) return killed.length > 0;
|
||||||
|
return Array.from(resultIds);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 把一次插件运行加入并发池。返回的 Promise 在子进程退出时 resolve(无论成功/失败)。
|
* 把一次插件运行加入并发池。返回的 Promise 在子进程退出时 resolve(无论成功/失败)。
|
||||||
* 真实结果状态写在 task_analysis_results.resultData / status 中,由前端轮询消费。
|
* 真实结果状态写在 task_analysis_results.resultData / status 中,由前端轮询消费。
|
||||||
|
|||||||
@@ -176,6 +176,27 @@ function readResultJson(workDir: string): PluginResult | null {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DEMO_FLOW_RESULT_DATA_KEYS = [
|
||||||
|
'demoFlowRunId',
|
||||||
|
'demoFlowRole',
|
||||||
|
'demoFlowIndex',
|
||||||
|
'demoFlowTotal',
|
||||||
|
'demoFlowSourcePointCloudFileId',
|
||||||
|
'demoFlowCadFileId',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
async function readDemoFlowResultData(resultId: number): Promise<Record<string, unknown>> {
|
||||||
|
const row = await db.getTaskAnalysisResultById(resultId);
|
||||||
|
const existing = (row?.resultData as Record<string, unknown> | null) ?? {};
|
||||||
|
const picked: Record<string, unknown> = {};
|
||||||
|
for (const key of DEMO_FLOW_RESULT_DATA_KEYS) {
|
||||||
|
if (existing[key] !== undefined) {
|
||||||
|
picked[key] = existing[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return picked;
|
||||||
|
}
|
||||||
|
|
||||||
async function runProcessRuntime(
|
async function runProcessRuntime(
|
||||||
manifest: PluginManifest,
|
manifest: PluginManifest,
|
||||||
workDir: string,
|
workDir: string,
|
||||||
@@ -286,12 +307,14 @@ export async function runAlgorithmJob(
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const manifest = getPlugin(pluginId);
|
const manifest = getPlugin(pluginId);
|
||||||
if (!manifest) {
|
if (!manifest) {
|
||||||
|
const demoFlowResultData = await readDemoFlowResultData(resultId);
|
||||||
await db.updateTaskAnalysisResult(resultId, {
|
await db.updateTaskAnalysisResult(resultId, {
|
||||||
status: 'error',
|
status: 'error',
|
||||||
resultData: {
|
resultData: {
|
||||||
kind: 'plugin',
|
kind: 'plugin',
|
||||||
pluginId,
|
pluginId,
|
||||||
error: `未注册的插件:${pluginId}`,
|
error: `未注册的插件:${pluginId}`,
|
||||||
|
...demoFlowResultData,
|
||||||
} as PluginResultData,
|
} as PluginResultData,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
@@ -333,6 +356,7 @@ export async function runAlgorithmJob(
|
|||||||
? 'completed'
|
? 'completed'
|
||||||
: 'error';
|
: 'error';
|
||||||
|
|
||||||
|
const demoFlowResultData = await readDemoFlowResultData(resultId);
|
||||||
const resultData: PluginResultData = {
|
const resultData: PluginResultData = {
|
||||||
kind: 'plugin',
|
kind: 'plugin',
|
||||||
pluginId,
|
pluginId,
|
||||||
@@ -351,6 +375,7 @@ export async function runAlgorithmJob(
|
|||||||
error: finalStatus === 'error'
|
error: finalStatus === 'error'
|
||||||
? result?.message ?? `子进程退出码 ${exitCode}`
|
? result?.message ?? `子进程退出码 ${exitCode}`
|
||||||
: undefined,
|
: undefined,
|
||||||
|
...demoFlowResultData,
|
||||||
};
|
};
|
||||||
|
|
||||||
await db.updateTaskAnalysisResult(resultId, {
|
await db.updateTaskAnalysisResult(resultId, {
|
||||||
@@ -404,12 +429,14 @@ export async function runAlgorithmJob(
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[AlgorithmJobRunner]', err);
|
console.error('[AlgorithmJobRunner]', err);
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
const demoFlowResultData = await readDemoFlowResultData(resultId);
|
||||||
await db.updateTaskAnalysisResult(resultId, {
|
await db.updateTaskAnalysisResult(resultId, {
|
||||||
status: 'error',
|
status: 'error',
|
||||||
resultData: {
|
resultData: {
|
||||||
kind: 'plugin',
|
kind: 'plugin',
|
||||||
pluginId,
|
pluginId,
|
||||||
error: message,
|
error: message,
|
||||||
|
...demoFlowResultData,
|
||||||
} as PluginResultData,
|
} as PluginResultData,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
154
server/analysisJobControl.ts
Normal file
154
server/analysisJobControl.ts
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
import { ChildProcess, execFileSync } from 'node:child_process';
|
||||||
|
|
||||||
|
export type LegacyAnalysisKind = 'smooth_heatmap' | 'assembly_inspection' | 'obj_deviation';
|
||||||
|
|
||||||
|
interface ActiveLegacyJob {
|
||||||
|
resultId: number;
|
||||||
|
taskId: number;
|
||||||
|
kind: LegacyAnalysisKind;
|
||||||
|
child: ChildProcess;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProcessRow {
|
||||||
|
pid: number;
|
||||||
|
ppid: number;
|
||||||
|
command: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeLegacyJobs = new Map<number, ActiveLegacyJob>();
|
||||||
|
|
||||||
|
export function registerLegacyAnalysisChild(
|
||||||
|
resultId: number,
|
||||||
|
taskId: number,
|
||||||
|
kind: LegacyAnalysisKind,
|
||||||
|
child: ChildProcess,
|
||||||
|
): void {
|
||||||
|
activeLegacyJobs.set(resultId, { resultId, taskId, kind, child });
|
||||||
|
child.once('exit', () => {
|
||||||
|
activeLegacyJobs.delete(resultId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cancelLegacyAnalysisJob(resultId: number): boolean {
|
||||||
|
const job = activeLegacyJobs.get(resultId);
|
||||||
|
let cancelled = false;
|
||||||
|
if (job) {
|
||||||
|
activeLegacyJobs.delete(resultId);
|
||||||
|
try {
|
||||||
|
job.child.kill('SIGKILL');
|
||||||
|
cancelled = true;
|
||||||
|
} catch {
|
||||||
|
// Process may already have exited.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return killOrphanLegacyAnalysisProcesses({ resultId }) || cancelled;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cancelLegacyAnalysisJobsByTask(taskId: number): number[] {
|
||||||
|
const cancelled = new Set<number>();
|
||||||
|
for (const [resultId, job] of Array.from(activeLegacyJobs.entries())) {
|
||||||
|
if (job.taskId !== taskId) continue;
|
||||||
|
activeLegacyJobs.delete(resultId);
|
||||||
|
try {
|
||||||
|
job.child.kill('SIGKILL');
|
||||||
|
cancelled.add(resultId);
|
||||||
|
} catch {
|
||||||
|
// Process may already have exited.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const resultId of killOrphanLegacyAnalysisProcesses({ taskId })) {
|
||||||
|
cancelled.add(resultId);
|
||||||
|
}
|
||||||
|
return Array.from(cancelled);
|
||||||
|
}
|
||||||
|
|
||||||
|
function listProcesses(): ProcessRow[] {
|
||||||
|
try {
|
||||||
|
const output = execFileSync('ps', ['-eo', 'pid=,ppid=,command='], {
|
||||||
|
encoding: 'utf8',
|
||||||
|
maxBuffer: 1024 * 1024,
|
||||||
|
});
|
||||||
|
return output
|
||||||
|
.split('\n')
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((line) => {
|
||||||
|
const match = line.match(/^(\d+)\s+(\d+)\s+(.+)$/);
|
||||||
|
if (!match) return null;
|
||||||
|
return {
|
||||||
|
pid: Number(match[1]),
|
||||||
|
ppid: Number(match[2]),
|
||||||
|
command: match[3],
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((row): row is ProcessRow => row != null && Number.isFinite(row.pid) && Number.isFinite(row.ppid));
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[AnalysisJobControl] 无法列出进程,跳过 legacy 孤儿进程清理:', err);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectDescendants(processes: ProcessRow[], rootPids: Set<number>): Set<number> {
|
||||||
|
const all = new Set(rootPids);
|
||||||
|
let changed = true;
|
||||||
|
while (changed) {
|
||||||
|
changed = false;
|
||||||
|
for (const proc of processes) {
|
||||||
|
if (!all.has(proc.pid) && all.has(proc.ppid)) {
|
||||||
|
all.add(proc.pid);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return all;
|
||||||
|
}
|
||||||
|
|
||||||
|
function killPids(pids: number[]): number[] {
|
||||||
|
const killed: number[] = [];
|
||||||
|
for (const pid of pids) {
|
||||||
|
if (pid <= 1 || pid === process.pid) continue;
|
||||||
|
try {
|
||||||
|
process.kill(pid, 'SIGKILL');
|
||||||
|
killed.push(pid);
|
||||||
|
} catch {
|
||||||
|
// Process may already have exited.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return killed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function runnerRegex(): RegExp {
|
||||||
|
return /(smoothHeatmapJobRunner|assemblyInspectionJobRunner|deviationHeatmapJobRunner)\.(?:ts|js)\s+(\d+)\s+(\d+)\b/;
|
||||||
|
}
|
||||||
|
|
||||||
|
function killOrphanLegacyAnalysisProcesses(input: { resultId: number }): boolean;
|
||||||
|
function killOrphanLegacyAnalysisProcesses(input: { taskId: number }): number[];
|
||||||
|
function killOrphanLegacyAnalysisProcesses(input: { resultId?: number; taskId?: number }): boolean | number[] {
|
||||||
|
const processes = listProcesses();
|
||||||
|
const roots = new Set<number>();
|
||||||
|
const resultIds = new Set<number>();
|
||||||
|
|
||||||
|
for (const proc of processes) {
|
||||||
|
const match = proc.command.match(runnerRegex());
|
||||||
|
if (!match) continue;
|
||||||
|
const resultId = Number(match[2]);
|
||||||
|
const taskId = Number(match[3]);
|
||||||
|
if (input.resultId != null && resultId === input.resultId) {
|
||||||
|
roots.add(proc.pid);
|
||||||
|
resultIds.add(resultId);
|
||||||
|
}
|
||||||
|
if (input.taskId != null && taskId === input.taskId) {
|
||||||
|
roots.add(proc.pid);
|
||||||
|
resultIds.add(resultId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const targets = Array.from(collectDescendants(processes, roots)).sort((a, b) => b - a);
|
||||||
|
const killed = killPids(targets);
|
||||||
|
if (killed.length > 0) {
|
||||||
|
console.warn(`[AnalysisJobControl] 已清理 legacy 分析进程 pid=${killed.join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.resultId != null) return killed.length > 0;
|
||||||
|
return Array.from(resultIds);
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ import { sampleTaskPointclouds } from './sampleTaskPointclouds.js';
|
|||||||
import type { SampledPoint } from './pointcloudSample.js';
|
import type { SampledPoint } from './pointcloudSample.js';
|
||||||
import { runStepParseMvp, stepParseCacheDir } from './stepParse.js';
|
import { runStepParseMvp, stepParseCacheDir } from './stepParse.js';
|
||||||
import type { StepManifest } from './stepParse.js';
|
import type { StepManifest } from './stepParse.js';
|
||||||
|
import { registerLegacyAnalysisChild } from './analysisJobControl.js';
|
||||||
|
|
||||||
const DATA_DIR = process.env.DATA_DIR || '/data';
|
const DATA_DIR = process.env.DATA_DIR || '/data';
|
||||||
const TASKS_DIR = path.join(DATA_DIR, 'tasks');
|
const TASKS_DIR = path.join(DATA_DIR, 'tasks');
|
||||||
@@ -42,6 +43,7 @@ export function enqueueAssemblyInspectionJob(
|
|||||||
stdio: ['ignore', 'inherit', 'inherit', 'ipc'],
|
stdio: ['ignore', 'inherit', 'inherit', 'ipc'],
|
||||||
execArgv: isProd ? [] : ['--import', 'tsx'],
|
execArgv: isProd ? [] : ['--import', 'tsx'],
|
||||||
});
|
});
|
||||||
|
registerLegacyAnalysisChild(resultId, taskId, 'assembly_inspection', child);
|
||||||
child.on('error', (err) => console.error('[AssemblyInspection] fork 失败:', err));
|
child.on('error', (err) => console.error('[AssemblyInspection] fork 失败:', err));
|
||||||
child.on('exit', (code, signal) => {
|
child.on('exit', (code, signal) => {
|
||||||
if (code !== 0 && code != null) {
|
if (code !== 0 && code != null) {
|
||||||
|
|||||||
63
server/db.ts
63
server/db.ts
@@ -1,4 +1,4 @@
|
|||||||
import { eq, desc, sql } from "drizzle-orm";
|
import { eq, desc, inArray, sql, and } from "drizzle-orm";
|
||||||
import { drizzle } from "drizzle-orm/mysql2";
|
import { drizzle } from "drizzle-orm/mysql2";
|
||||||
import type { ResultSetHeader } from "mysql2";
|
import type { ResultSetHeader } from "mysql2";
|
||||||
import {
|
import {
|
||||||
@@ -342,19 +342,73 @@ export async function createInspectionTask(data: InsertInspectionTask): Promise<
|
|||||||
return row;
|
return row;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function findInspectionTaskByModelAndName(modelId: number, name: string): Promise<InspectionTask | undefined> {
|
||||||
|
const db = await getDb();
|
||||||
|
if (!db) return undefined;
|
||||||
|
const [row] = await db.select().from(inspectionTasks)
|
||||||
|
.where(and(eq(inspectionTasks.modelId, modelId), eq(inspectionTasks.name, name)))
|
||||||
|
.limit(1);
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
export async function listInspectionTasksByModel(modelId: number): Promise<InspectionTask[]> {
|
export async function listInspectionTasksByModel(modelId: number): Promise<InspectionTask[]> {
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
if (!db) return [];
|
if (!db) return [];
|
||||||
return db.select().from(inspectionTasks)
|
const tasks = await db.select().from(inspectionTasks)
|
||||||
.where(eq(inspectionTasks.modelId, modelId))
|
.where(eq(inspectionTasks.modelId, modelId))
|
||||||
.orderBy(desc(inspectionTasks.createdAt));
|
.orderBy(desc(inspectionTasks.createdAt));
|
||||||
|
return withComputedInspectionTaskStatuses(tasks);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getInspectionTaskById(id: number): Promise<InspectionTask | undefined> {
|
export async function getInspectionTaskById(id: number): Promise<InspectionTask | undefined> {
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
if (!db) return undefined;
|
if (!db) return undefined;
|
||||||
const [row] = await db.select().from(inspectionTasks).where(eq(inspectionTasks.id, id));
|
const [row] = await db.select().from(inspectionTasks).where(eq(inspectionTasks.id, id));
|
||||||
return row;
|
if (!row) return undefined;
|
||||||
|
const [task] = await withComputedInspectionTaskStatuses([row]);
|
||||||
|
return task;
|
||||||
|
}
|
||||||
|
|
||||||
|
type InspectionTaskStatus = InspectionTask["status"];
|
||||||
|
type AnalysisStatus = TaskAnalysisResult["status"];
|
||||||
|
|
||||||
|
function summarizeInspectionTaskStatus(
|
||||||
|
fallback: InspectionTaskStatus,
|
||||||
|
analysisStatuses: AnalysisStatus[],
|
||||||
|
): InspectionTaskStatus {
|
||||||
|
if (analysisStatuses.length === 0) return fallback;
|
||||||
|
if (analysisStatuses.some(status => status === "processing" || status === "pending")) {
|
||||||
|
return "processing";
|
||||||
|
}
|
||||||
|
if (analysisStatuses.some(status => status === "error")) return "error";
|
||||||
|
if (analysisStatuses.some(status => status === "completed")) return "completed";
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function withComputedInspectionTaskStatuses(tasks: InspectionTask[]): Promise<InspectionTask[]> {
|
||||||
|
if (tasks.length === 0) return tasks;
|
||||||
|
const db = await getDb();
|
||||||
|
if (!db) return tasks;
|
||||||
|
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
taskId: taskAnalysisResults.taskId,
|
||||||
|
status: taskAnalysisResults.status,
|
||||||
|
})
|
||||||
|
.from(taskAnalysisResults)
|
||||||
|
.where(inArray(taskAnalysisResults.taskId, tasks.map(task => task.id)));
|
||||||
|
|
||||||
|
const statusesByTask = new Map<number, AnalysisStatus[]>();
|
||||||
|
for (const row of rows) {
|
||||||
|
const statuses = statusesByTask.get(row.taskId) ?? [];
|
||||||
|
statuses.push(row.status);
|
||||||
|
statusesByTask.set(row.taskId, statuses);
|
||||||
|
}
|
||||||
|
|
||||||
|
return tasks.map(task => ({
|
||||||
|
...task,
|
||||||
|
status: summarizeInspectionTaskStatus(task.status, statusesByTask.get(task.id) ?? []),
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateInspectionTask(id: number, data: Partial<InsertInspectionTask>): Promise<void> {
|
export async function updateInspectionTask(id: number, data: Partial<InsertInspectionTask>): Promise<void> {
|
||||||
@@ -366,6 +420,9 @@ export async function updateInspectionTask(id: number, data: Partial<InsertInspe
|
|||||||
export async function deleteInspectionTask(id: number): Promise<void> {
|
export async function deleteInspectionTask(id: number): Promise<void> {
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
if (!db) return;
|
if (!db) return;
|
||||||
|
await db.delete(taskAnalysisResults).where(eq(taskAnalysisResults.taskId, id));
|
||||||
|
await db.delete(taskPointcloudFiles).where(eq(taskPointcloudFiles.taskId, id));
|
||||||
|
await db.delete(taskCadFiles).where(eq(taskCadFiles.taskId, id));
|
||||||
await db.delete(inspectionTasks).where(eq(inspectionTasks.id, id));
|
await db.delete(inspectionTasks).where(eq(inspectionTasks.id, id));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
import { pickCadFileForHeatmap } from './cadFilePick.js';
|
import { pickCadFileForHeatmap } from './cadFilePick.js';
|
||||||
import { sampleTaskPointclouds } from './sampleTaskPointclouds.js';
|
import { sampleTaskPointclouds } from './sampleTaskPointclouds.js';
|
||||||
import { deviationGpuEnabled, tryRunDeviationGpu } from './deviationGpuCompute.js';
|
import { deviationGpuEnabled, tryRunDeviationGpu } from './deviationGpuCompute.js';
|
||||||
|
import { registerLegacyAnalysisChild } from './analysisJobControl.js';
|
||||||
|
|
||||||
const DATA_DIR = process.env.DATA_DIR || '/data';
|
const DATA_DIR = process.env.DATA_DIR || '/data';
|
||||||
const TASKS_DIR = path.join(DATA_DIR, 'tasks');
|
const TASKS_DIR = path.join(DATA_DIR, 'tasks');
|
||||||
@@ -41,6 +42,7 @@ export function enqueueDeviationHeatmapJob(resultId: number, taskId: number, tot
|
|||||||
stdio: ['ignore', 'inherit', 'inherit', 'ipc'],
|
stdio: ['ignore', 'inherit', 'inherit', 'ipc'],
|
||||||
execArgv: isProd ? [] : ['--import', 'tsx'],
|
execArgv: isProd ? [] : ['--import', 'tsx'],
|
||||||
});
|
});
|
||||||
|
registerLegacyAnalysisChild(resultId, taskId, 'obj_deviation', child);
|
||||||
child.on('error', (err) => {
|
child.on('error', (err) => {
|
||||||
console.error('[DeviationHeatmapJob] fork 失败:', err);
|
console.error('[DeviationHeatmapJob] fork 失败:', err);
|
||||||
});
|
});
|
||||||
|
|||||||
119
server/inspectionTask.test.ts
Normal file
119
server/inspectionTask.test.ts
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { appRouter } from "./routers";
|
||||||
|
import * as db from "./db";
|
||||||
|
import type { TrpcContext } from "./_core/context";
|
||||||
|
|
||||||
|
vi.mock("./db", () => ({
|
||||||
|
findInspectionTaskByModelAndName: vi.fn(async () => undefined),
|
||||||
|
createInspectionTask: vi.fn(async (data) => ({
|
||||||
|
id: 1,
|
||||||
|
modelId: data.modelId,
|
||||||
|
name: data.name,
|
||||||
|
inspector: data.inspector ?? null,
|
||||||
|
notes: data.notes ?? null,
|
||||||
|
status: data.status,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
completedAt: null,
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
type AuthenticatedUser = NonNullable<TrpcContext["user"]>;
|
||||||
|
|
||||||
|
function createAuthContext(): TrpcContext {
|
||||||
|
const user: AuthenticatedUser = {
|
||||||
|
id: 1,
|
||||||
|
openId: "test-user",
|
||||||
|
email: "test@example.com",
|
||||||
|
name: "Test User",
|
||||||
|
loginMethod: "manus",
|
||||||
|
role: "user",
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
lastSignedIn: new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
user,
|
||||||
|
req: {
|
||||||
|
protocol: "https",
|
||||||
|
headers: {},
|
||||||
|
} as TrpcContext["req"],
|
||||||
|
res: {
|
||||||
|
clearCookie: vi.fn(),
|
||||||
|
} as unknown as TrpcContext["res"],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("inspection task router", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
vi.mocked(db.findInspectionTaskByModelAndName).mockResolvedValue(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("trims name and creates a task in created status", async () => {
|
||||||
|
const ctx = createAuthContext();
|
||||||
|
const caller = appRouter.createCaller(ctx);
|
||||||
|
|
||||||
|
const result = await caller.inspectionTask.create({
|
||||||
|
modelId: 1,
|
||||||
|
name: " 首检任务 ",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.status).toBe("created");
|
||||||
|
expect(db.findInspectionTaskByModelAndName).toHaveBeenCalledWith(1, "首检任务");
|
||||||
|
expect(db.createInspectionTask).toHaveBeenCalledWith({
|
||||||
|
modelId: 1,
|
||||||
|
name: "首检任务",
|
||||||
|
inspector: null,
|
||||||
|
notes: null,
|
||||||
|
status: "created",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns CONFLICT when the same model already has the task name", async () => {
|
||||||
|
vi.mocked(db.findInspectionTaskByModelAndName).mockResolvedValueOnce({
|
||||||
|
id: 1,
|
||||||
|
modelId: 1,
|
||||||
|
name: "首检任务",
|
||||||
|
inspector: null,
|
||||||
|
notes: null,
|
||||||
|
status: "created",
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
completedAt: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const ctx = createAuthContext();
|
||||||
|
const caller = appRouter.createCaller(ctx);
|
||||||
|
|
||||||
|
await expect(caller.inspectionTask.create({
|
||||||
|
modelId: 1,
|
||||||
|
name: "首检任务",
|
||||||
|
})).rejects.toMatchObject({
|
||||||
|
code: "CONFLICT",
|
||||||
|
message: "该车型下已存在同名检测任务。",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(db.createInspectionTask).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns CONFLICT when the database unique key rejects a concurrent duplicate", async () => {
|
||||||
|
vi.mocked(db.createInspectionTask).mockRejectedValueOnce({
|
||||||
|
code: "ER_DUP_ENTRY",
|
||||||
|
errno: 1062,
|
||||||
|
message: "Duplicate entry '1-首检任务' for key 'uk_inspection_tasks_model_name'",
|
||||||
|
});
|
||||||
|
|
||||||
|
const ctx = createAuthContext();
|
||||||
|
const caller = appRouter.createCaller(ctx);
|
||||||
|
|
||||||
|
await expect(caller.inspectionTask.create({
|
||||||
|
modelId: 1,
|
||||||
|
name: "首检任务",
|
||||||
|
})).rejects.toMatchObject({
|
||||||
|
code: "CONFLICT",
|
||||||
|
message: "该车型下已存在同名检测任务。",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -6,6 +6,7 @@ import { TRPCError } from "@trpc/server";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import * as fs from "fs";
|
import * as fs from "fs";
|
||||||
import * as path from "path";
|
import * as path from "path";
|
||||||
|
import { randomUUID } from "crypto";
|
||||||
import * as db from "./db";
|
import * as db from "./db";
|
||||||
import { enqueueAssemblyInspectionJob } from "./assemblyInspectionJob.js";
|
import { enqueueAssemblyInspectionJob } from "./assemblyInspectionJob.js";
|
||||||
import { enqueueDeviationHeatmapJob } from "./deviationHeatmapJob.js";
|
import { enqueueDeviationHeatmapJob } from "./deviationHeatmapJob.js";
|
||||||
@@ -13,6 +14,22 @@ import { enqueueSmoothHeatmapJob } from "./smoothHeatmapJob.js";
|
|||||||
import { stepParseCacheDir } from "./stepParse.js";
|
import { stepParseCacheDir } from "./stepParse.js";
|
||||||
import { listPlugins, getPlugin } from "./plugins/registry.js";
|
import { listPlugins, getPlugin } from "./plugins/registry.js";
|
||||||
import { enqueueAlgorithmJob, algorithmJobStats, cancelAlgorithmJob, cancelAlgorithmJobsByTask } from "./algorithmJob.js";
|
import { enqueueAlgorithmJob, algorithmJobStats, cancelAlgorithmJob, cancelAlgorithmJobsByTask } from "./algorithmJob.js";
|
||||||
|
import { cancelLegacyAnalysisJob, cancelLegacyAnalysisJobsByTask } from "./analysisJobControl.js";
|
||||||
|
|
||||||
|
const DATA_DIR = process.env.DATA_DIR || "/data";
|
||||||
|
const TASKS_DIR = path.join(DATA_DIR, "tasks");
|
||||||
|
|
||||||
|
const DEMO_MEASUREMENT_PLUGIN_IDS = [
|
||||||
|
"train-measurement",
|
||||||
|
"coupler-panel",
|
||||||
|
"flatness-suite",
|
||||||
|
"underframe-deflection",
|
||||||
|
] as const;
|
||||||
|
const DEMO_REGISTERED_POINTCLOUD_PLUGIN_IDS = new Set<string>([
|
||||||
|
"coupler-panel",
|
||||||
|
"flatness-suite",
|
||||||
|
"underframe-deflection",
|
||||||
|
]);
|
||||||
|
|
||||||
async function validateRegistrationRunInput(input: {
|
async function validateRegistrationRunInput(input: {
|
||||||
taskId: number;
|
taskId: number;
|
||||||
@@ -60,6 +77,231 @@ async function validateRegistrationRunInput(input: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function latestRegisteredPointcloud(
|
||||||
|
pointcloudFiles: Awaited<ReturnType<typeof db.listTaskPointcloudFiles>>,
|
||||||
|
sourcePointcloudFileId: number,
|
||||||
|
) {
|
||||||
|
return pointcloudFiles
|
||||||
|
.filter((row) => row.source === "registered" && row.sourcePointcloudFileId === sourcePointcloudFileId)
|
||||||
|
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDemoMeasurementPluginIds(input?: string[]) {
|
||||||
|
const ids = input && input.length > 0 ? input : [...DEMO_MEASUREMENT_PLUGIN_IDS];
|
||||||
|
const unique = Array.from(new Set(ids));
|
||||||
|
const supported = new Set<string>(DEMO_MEASUREMENT_PLUGIN_IDS);
|
||||||
|
const unsupported = unique.filter((id) => !supported.has(id));
|
||||||
|
if (unsupported.length > 0) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: `一键全流程演示不支持算法:${unsupported.join(", ")}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return unique;
|
||||||
|
}
|
||||||
|
|
||||||
|
function paramsForDemoPlugin(pluginId: string): Record<string, unknown> {
|
||||||
|
if (pluginId === "flatness-suite") {
|
||||||
|
return {
|
||||||
|
useBatchDatasets: true,
|
||||||
|
module: "contour",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultParamsForManifest(manifest: NonNullable<ReturnType<typeof getPlugin>>): Record<string, unknown> {
|
||||||
|
const defaults: Record<string, unknown> = {};
|
||||||
|
for (const field of manifest.inputs ?? []) {
|
||||||
|
if (field.default !== undefined) {
|
||||||
|
defaults[field.name] = field.default;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return defaults;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergePluginParams(
|
||||||
|
manifest: NonNullable<ReturnType<typeof getPlugin>>,
|
||||||
|
params?: Record<string, unknown>,
|
||||||
|
): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
...defaultParamsForManifest(manifest),
|
||||||
|
...(params ?? {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createAndEnqueuePluginJob(input: {
|
||||||
|
taskId: number;
|
||||||
|
pluginId: string;
|
||||||
|
params?: Record<string, unknown>;
|
||||||
|
pointCloudFileIds?: number[];
|
||||||
|
cadFileIds?: number[];
|
||||||
|
resultData?: Record<string, unknown>;
|
||||||
|
}) {
|
||||||
|
const manifest = getPlugin(input.pluginId);
|
||||||
|
if (!manifest) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
message: `未注册的算法插件:${input.pluginId}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const record = await db.createTaskAnalysisResult({
|
||||||
|
taskId: input.taskId,
|
||||||
|
type: "plugin",
|
||||||
|
pluginId: manifest.id,
|
||||||
|
schemaVersion: manifest.schemaVersion,
|
||||||
|
status: "processing",
|
||||||
|
resultData: input.resultData,
|
||||||
|
});
|
||||||
|
const promise = enqueueAlgorithmJob(
|
||||||
|
record.id,
|
||||||
|
input.taskId,
|
||||||
|
manifest.id,
|
||||||
|
mergePluginParams(manifest, input.params),
|
||||||
|
input.pointCloudFileIds,
|
||||||
|
input.cadFileIds,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
id: record.id,
|
||||||
|
pluginId: manifest.id,
|
||||||
|
promise,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const DUPLICATE_INSPECTION_TASK_NAME_MESSAGE = "该车型下已存在同名检测任务。";
|
||||||
|
|
||||||
|
function duplicateInspectionTaskNameError() {
|
||||||
|
return new TRPCError({
|
||||||
|
code: "CONFLICT",
|
||||||
|
message: DUPLICATE_INSPECTION_TASK_NAME_MESSAGE,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDuplicateKeyError(error: unknown) {
|
||||||
|
const err = error as { code?: string; errno?: number; message?: string };
|
||||||
|
return err?.code === "ER_DUP_ENTRY" || err?.errno === 1062 ||
|
||||||
|
(typeof err?.message === "string" && err.message.includes("uk_inspection_tasks_model_name"));
|
||||||
|
}
|
||||||
|
|
||||||
|
const CANCELLABLE_ANALYSIS_STATUSES = new Set(["pending", "processing"]);
|
||||||
|
const cancelledDemoTaskIds = new Set<number>();
|
||||||
|
|
||||||
|
function markDemoFlowCancelled(taskId: number): void {
|
||||||
|
cancelledDemoTaskIds.add(taskId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearDemoFlowCancelled(taskId: number): void {
|
||||||
|
cancelledDemoTaskIds.delete(taskId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDemoFlowCancelled(taskId: number): boolean {
|
||||||
|
return cancelledDemoTaskIds.has(taskId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelledResultData(
|
||||||
|
row: Awaited<ReturnType<typeof db.getTaskAnalysisResultById>>,
|
||||||
|
reason: string,
|
||||||
|
): Record<string, unknown> {
|
||||||
|
const previous = (row?.resultData as Record<string, unknown> | null) ?? {};
|
||||||
|
return {
|
||||||
|
...previous,
|
||||||
|
cancelled: true,
|
||||||
|
cancelReason: reason,
|
||||||
|
cancelledAt: new Date().toISOString(),
|
||||||
|
stage: "已停止",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cancelTaskAnalysisResultById(id: number, reason = "用户停止任务") {
|
||||||
|
const row = await db.getTaskAnalysisResultById(id);
|
||||||
|
if (!row) {
|
||||||
|
throw new TRPCError({ code: "NOT_FOUND", message: "分析结果不存在。" });
|
||||||
|
}
|
||||||
|
if (!CANCELLABLE_ANALYSIS_STATUSES.has(row.status)) {
|
||||||
|
return { success: true as const, id, cancelled: false as const, status: row.status };
|
||||||
|
}
|
||||||
|
|
||||||
|
const pluginCancelled = cancelAlgorithmJob(id);
|
||||||
|
const legacyCancelled = cancelLegacyAnalysisJob(id);
|
||||||
|
await db.updateTaskAnalysisResult(id, {
|
||||||
|
status: "cancelled",
|
||||||
|
resultData: cancelledResultData(row, reason),
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
success: true as const,
|
||||||
|
id,
|
||||||
|
cancelled: true as const,
|
||||||
|
pluginCancelled,
|
||||||
|
legacyCancelled,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cancelTaskAnalysisResultsByTask(taskId: number, reason = "用户停止任务") {
|
||||||
|
const rows = await db.listTaskAnalysisResults(taskId);
|
||||||
|
const cancellable = rows.filter((row) => CANCELLABLE_ANALYSIS_STATUSES.has(row.status));
|
||||||
|
const pluginCancelled = cancelAlgorithmJobsByTask(taskId);
|
||||||
|
const legacyCancelled = cancelLegacyAnalysisJobsByTask(taskId);
|
||||||
|
|
||||||
|
for (const row of cancellable) {
|
||||||
|
await db.updateTaskAnalysisResult(row.id, {
|
||||||
|
status: "cancelled",
|
||||||
|
resultData: cancelledResultData(row, reason),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true as const,
|
||||||
|
cancelledIds: cancellable.map((row) => row.id),
|
||||||
|
pluginCancelled,
|
||||||
|
legacyCancelled,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function enqueueDemoRegisteredMeasurementsAfterRegistration(input: {
|
||||||
|
taskId: number;
|
||||||
|
sourcePointCloudFileId: number;
|
||||||
|
cadFileId: number;
|
||||||
|
registrationResultId: number;
|
||||||
|
measurementPluginIds: string[];
|
||||||
|
demoFlowRunId: string;
|
||||||
|
}) {
|
||||||
|
const registrationResult = await db.getTaskAnalysisResultById(input.registrationResultId);
|
||||||
|
if (registrationResult?.status !== "completed") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pointcloudFiles = await db.listTaskPointcloudFiles(input.taskId);
|
||||||
|
const registered = latestRegisteredPointcloud(pointcloudFiles, input.sourcePointCloudFileId);
|
||||||
|
if (!registered) {
|
||||||
|
console.error(
|
||||||
|
`[runDemoAll] 配准完成但未找到 registered 点云 taskId=${input.taskId} sourcePointCloudFileId=${input.sourcePointCloudFileId}`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let idx = 0; idx < input.measurementPluginIds.length; idx += 1) {
|
||||||
|
const pluginId = input.measurementPluginIds[idx];
|
||||||
|
const manifest = getPlugin(pluginId);
|
||||||
|
await createAndEnqueuePluginJob({
|
||||||
|
taskId: input.taskId,
|
||||||
|
pluginId,
|
||||||
|
params: paramsForDemoPlugin(pluginId),
|
||||||
|
pointCloudFileIds: DEMO_REGISTERED_POINTCLOUD_PLUGIN_IDS.has(pluginId)
|
||||||
|
? [registered.id]
|
||||||
|
: [input.sourcePointCloudFileId],
|
||||||
|
cadFileIds: manifest?.requiresCad ? [input.cadFileId] : undefined,
|
||||||
|
resultData: {
|
||||||
|
demoFlowRunId: input.demoFlowRunId,
|
||||||
|
demoFlowRole: "measurement",
|
||||||
|
demoFlowIndex: idx + 2,
|
||||||
|
demoFlowTotal: input.measurementPluginIds.length + 1,
|
||||||
|
demoFlowSourcePointCloudFileId: input.sourcePointCloudFileId,
|
||||||
|
demoFlowCadFileId: input.cadFileId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const appRouter = router({
|
export const appRouter = router({
|
||||||
system: systemRouter,
|
system: systemRouter,
|
||||||
|
|
||||||
@@ -285,18 +527,30 @@ export const appRouter = router({
|
|||||||
create: protectedProcedure
|
create: protectedProcedure
|
||||||
.input(z.object({
|
.input(z.object({
|
||||||
modelId: z.number(),
|
modelId: z.number(),
|
||||||
name: z.string().min(1).max(255),
|
name: z.string().trim().min(1).max(255),
|
||||||
inspector: z.string().max(100).optional(),
|
inspector: z.string().max(100).optional(),
|
||||||
notes: z.string().optional(),
|
notes: z.string().optional(),
|
||||||
}))
|
}))
|
||||||
.mutation(async ({ input }) => {
|
.mutation(async ({ input }) => {
|
||||||
return db.createInspectionTask({
|
const existing = await db.findInspectionTaskByModelAndName(input.modelId, input.name);
|
||||||
modelId: input.modelId,
|
if (existing) {
|
||||||
name: input.name,
|
throw duplicateInspectionTaskNameError();
|
||||||
inspector: input.inspector ?? null,
|
}
|
||||||
notes: input.notes ?? null,
|
|
||||||
status: "pending",
|
try {
|
||||||
});
|
return await db.createInspectionTask({
|
||||||
|
modelId: input.modelId,
|
||||||
|
name: input.name,
|
||||||
|
inspector: input.inspector ?? null,
|
||||||
|
notes: input.notes ?? null,
|
||||||
|
status: "created",
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (isDuplicateKeyError(error)) {
|
||||||
|
throw duplicateInspectionTaskNameError();
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}),
|
}),
|
||||||
|
|
||||||
update: protectedProcedure
|
update: protectedProcedure
|
||||||
@@ -305,7 +559,7 @@ export const appRouter = router({
|
|||||||
name: z.string().max(255).optional(),
|
name: z.string().max(255).optional(),
|
||||||
inspector: z.string().max(100).optional(),
|
inspector: z.string().max(100).optional(),
|
||||||
notes: z.string().optional(),
|
notes: z.string().optional(),
|
||||||
status: z.enum(["pending", "processing", "completed", "error"]).optional(),
|
status: z.enum(["created", "pending", "processing", "completed", "error"]).optional(),
|
||||||
}))
|
}))
|
||||||
.mutation(async ({ input }) => {
|
.mutation(async ({ input }) => {
|
||||||
const { id, ...data } = input;
|
const { id, ...data } = input;
|
||||||
@@ -316,12 +570,19 @@ export const appRouter = router({
|
|||||||
delete: protectedProcedure
|
delete: protectedProcedure
|
||||||
.input(z.object({ id: z.number() }))
|
.input(z.object({ id: z.number() }))
|
||||||
.mutation(async ({ input }) => {
|
.mutation(async ({ input }) => {
|
||||||
await db.deleteInspectionTask(input.id);
|
// 取消该任务下所有正在运行或排队的算法 job,再删除任务记录。
|
||||||
// 取消该任务下所有正在运行或排队的算法 job
|
const cancelled = [
|
||||||
const cancelled = cancelAlgorithmJobsByTask(input.id);
|
...cancelAlgorithmJobsByTask(input.id),
|
||||||
|
...cancelLegacyAnalysisJobsByTask(input.id),
|
||||||
|
];
|
||||||
if (cancelled.length > 0) {
|
if (cancelled.length > 0) {
|
||||||
console.log(`[routers] 删除任务 ${input.id},取消 ${cancelled.length} 个算法 job:`, cancelled);
|
console.log(`[routers] 删除任务 ${input.id},取消 ${cancelled.length} 个分析 job:`, cancelled);
|
||||||
}
|
}
|
||||||
|
await db.deleteInspectionTask(input.id);
|
||||||
|
|
||||||
|
const taskDir = path.join(TASKS_DIR, String(input.id));
|
||||||
|
await fs.promises.rm(taskDir, { recursive: true, force: true });
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
@@ -524,11 +785,25 @@ export const appRouter = router({
|
|||||||
return { success: true, id: record.id, errorStats, measurements };
|
return { success: true, id: record.id, errorStats, measurements };
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
cancel: protectedProcedure
|
||||||
|
.input(z.object({ id: z.number() }))
|
||||||
|
.mutation(async ({ input }) => {
|
||||||
|
return cancelTaskAnalysisResultById(input.id, "用户停止分析任务");
|
||||||
|
}),
|
||||||
|
|
||||||
|
cancelByTask: protectedProcedure
|
||||||
|
.input(z.object({ taskId: z.number() }))
|
||||||
|
.mutation(async ({ input }) => {
|
||||||
|
markDemoFlowCancelled(input.taskId);
|
||||||
|
return cancelTaskAnalysisResultsByTask(input.taskId, "用户停止检测任务下的运行任务");
|
||||||
|
}),
|
||||||
|
|
||||||
delete: protectedProcedure
|
delete: protectedProcedure
|
||||||
.input(z.object({ id: z.number() }))
|
.input(z.object({ id: z.number() }))
|
||||||
.mutation(async ({ input }) => {
|
.mutation(async ({ input }) => {
|
||||||
// 先取消正在运行或排队的 job,再删数据库记录
|
// 先取消正在运行或排队的 job,再删数据库记录
|
||||||
cancelAlgorithmJob(input.id);
|
cancelAlgorithmJob(input.id);
|
||||||
|
cancelLegacyAnalysisJob(input.id);
|
||||||
await db.deleteTaskAnalysisResult(input.id);
|
await db.deleteTaskAnalysisResult(input.id);
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}),
|
}),
|
||||||
@@ -600,7 +875,7 @@ export const appRouter = router({
|
|||||||
record.id,
|
record.id,
|
||||||
input.taskId,
|
input.taskId,
|
||||||
manifest.id,
|
manifest.id,
|
||||||
input.params ?? {},
|
mergePluginParams(manifest, input.params),
|
||||||
input.pointCloudFileIds,
|
input.pointCloudFileIds,
|
||||||
input.cadFileIds,
|
input.cadFileIds,
|
||||||
);
|
);
|
||||||
@@ -672,7 +947,7 @@ export const appRouter = router({
|
|||||||
record.id,
|
record.id,
|
||||||
input.taskId,
|
input.taskId,
|
||||||
manifest.id,
|
manifest.id,
|
||||||
job.params ?? {},
|
mergePluginParams(manifest, job.params),
|
||||||
input.pointCloudFileIds,
|
input.pointCloudFileIds,
|
||||||
input.cadFileIds,
|
input.cadFileIds,
|
||||||
);
|
);
|
||||||
@@ -680,6 +955,109 @@ export const appRouter = router({
|
|||||||
}
|
}
|
||||||
return { success: true as const, jobs: created, scheduler: algorithmJobStats() };
|
return { success: true as const, jobs: created, scheduler: algorithmJobStats() };
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
/** 一键全流程演示:先整车配准,成功后再一起提交测量算法。 */
|
||||||
|
runDemoAll: protectedProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
taskId: z.number(),
|
||||||
|
sourcePointCloudFileId: z.number(),
|
||||||
|
cadFileId: z.number(),
|
||||||
|
measurementPluginIds: z.array(z.string().min(1)).optional(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.mutation(async ({ input }) => {
|
||||||
|
const measurementPluginIds = normalizeDemoMeasurementPluginIds(input.measurementPluginIds);
|
||||||
|
const demoFlowRunId = randomUUID();
|
||||||
|
clearDemoFlowCancelled(input.taskId);
|
||||||
|
for (const pluginId of measurementPluginIds) {
|
||||||
|
const manifest = getPlugin(pluginId);
|
||||||
|
if (!manifest) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
message: `未注册的算法插件:${pluginId}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (manifest.category === "配准") {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: "一键全流程演示的测量算法列表不能包含配准算法。",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const pointcloudFiles = await db.listTaskPointcloudFiles(input.taskId);
|
||||||
|
const sourcePointcloud = pointcloudFiles.find((row) => row.id === input.sourcePointCloudFileId);
|
||||||
|
if (!sourcePointcloud) {
|
||||||
|
throw new TRPCError({ code: "NOT_FOUND", message: "选中的原始点云文件不存在。" });
|
||||||
|
}
|
||||||
|
if (sourcePointcloud.source === "registered") {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: "请选择原始点云,系统会自动找到对应配准产物。",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const cadFiles = await db.listTaskCadFiles(input.taskId);
|
||||||
|
const cad = cadFiles.find((row) => row.id === input.cadFileId);
|
||||||
|
if (!cad) {
|
||||||
|
throw new TRPCError({ code: "NOT_FOUND", message: "选中的 CAD 文件不存在。" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const registration = await createAndEnqueuePluginJob({
|
||||||
|
taskId: input.taskId,
|
||||||
|
pluginId: "vehicle-registration",
|
||||||
|
params: {},
|
||||||
|
pointCloudFileIds: [input.sourcePointCloudFileId],
|
||||||
|
cadFileIds: [input.cadFileId],
|
||||||
|
resultData: {
|
||||||
|
demoFlowRunId,
|
||||||
|
demoFlowRole: "registration",
|
||||||
|
demoFlowIndex: 1,
|
||||||
|
demoFlowTotal: measurementPluginIds.length + 1,
|
||||||
|
demoFlowSourcePointCloudFileId: input.sourcePointCloudFileId,
|
||||||
|
demoFlowCadFileId: input.cadFileId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const registrationJob = { id: registration.id, pluginId: registration.pluginId };
|
||||||
|
void registration.promise
|
||||||
|
.then(() => {
|
||||||
|
if (isDemoFlowCancelled(input.taskId)) {
|
||||||
|
console.log(`[runDemoAll] 任务 ${input.taskId} 已停止全流程,跳过后续测量提交。`);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return enqueueDemoRegisteredMeasurementsAfterRegistration({
|
||||||
|
taskId: input.taskId,
|
||||||
|
sourcePointCloudFileId: input.sourcePointCloudFileId,
|
||||||
|
cadFileId: input.cadFileId,
|
||||||
|
registrationResultId: registration.id,
|
||||||
|
measurementPluginIds,
|
||||||
|
demoFlowRunId,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.error("[runDemoAll] 配准链式提交失败:", err);
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true as const,
|
||||||
|
jobs: [] as { id: number; pluginId: string }[],
|
||||||
|
registrationJob,
|
||||||
|
queuedMeasurementCount: measurementPluginIds.length,
|
||||||
|
scheduler: algorithmJobStats(),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
|
||||||
|
cancelDemoAll: protectedProcedure
|
||||||
|
.input(z.object({ taskId: z.number() }))
|
||||||
|
.mutation(async ({ input }) => {
|
||||||
|
markDemoFlowCancelled(input.taskId);
|
||||||
|
const result = await cancelTaskAnalysisResultsByTask(input.taskId, "用户停止一键全流程演示");
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
scheduler: algorithmJobStats(),
|
||||||
|
};
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
} from './cadHeatmapCompute.js';
|
} from './cadHeatmapCompute.js';
|
||||||
import { pickCadFileForHeatmap } from './cadFilePick.js';
|
import { pickCadFileForHeatmap } from './cadFilePick.js';
|
||||||
import { sampleTaskPointclouds } from './sampleTaskPointclouds.js';
|
import { sampleTaskPointclouds } from './sampleTaskPointclouds.js';
|
||||||
|
import { registerLegacyAnalysisChild } from './analysisJobControl.js';
|
||||||
|
|
||||||
const DATA_DIR = process.env.DATA_DIR || '/data';
|
const DATA_DIR = process.env.DATA_DIR || '/data';
|
||||||
const TASKS_DIR = path.join(DATA_DIR, 'tasks');
|
const TASKS_DIR = path.join(DATA_DIR, 'tasks');
|
||||||
@@ -49,6 +50,7 @@ export function enqueueSmoothHeatmapJob(resultId: number, taskId: number, totalP
|
|||||||
stdio: ['ignore', 'inherit', 'inherit', 'ipc'],
|
stdio: ['ignore', 'inherit', 'inherit', 'ipc'],
|
||||||
execArgv: isProd ? [] : ['--import', 'tsx'],
|
execArgv: isProd ? [] : ['--import', 'tsx'],
|
||||||
});
|
});
|
||||||
|
registerLegacyAnalysisChild(resultId, taskId, 'smooth_heatmap', child);
|
||||||
child.on('error', (err) => {
|
child.on('error', (err) => {
|
||||||
console.error('[SmoothHeatmapJob] fork 失败:', err);
|
console.error('[SmoothHeatmapJob] fork 失败:', err);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user