feat: update analysis workflow UI

This commit is contained in:
2026-07-08 17:44:58 +08:00
parent b87aa3f990
commit e4915f327c
6 changed files with 842 additions and 532 deletions

View File

@@ -11,10 +11,17 @@
*/
import { useEffect, useState } from 'react';
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 TrainMeasurementPanel from './TrainMeasurementPanel';
const DEMO_MEASUREMENT_PLUGIN_IDS = [
'train-measurement',
'coupler-panel',
'flatness-suite',
'underframe-deflection',
];
interface PluginInputField {
name: string;
label: string;
@@ -53,6 +60,7 @@ interface PointcloudFileSummary {
interface Props {
taskId: number | null;
modelId: number | null;
/** 是否已加载点云(3D 视图,仅用于提示,不再作为执行前置条件) */
pointCloudLoaded: boolean;
/** 当前任务下的点云文件总数(>0 即可执行算法,无需先在 3D 视图加载) */
@@ -86,7 +94,7 @@ function defaultParamsFor(plugin: PluginSummary): Record<string, unknown> {
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 { data, isLoading } = trpc.algorithm.list.useQuery(undefined, {
staleTime: 60_000,
@@ -98,12 +106,51 @@ export default function AlgorithmRunPanel({ taskId, pointCloudLoaded: _pointClou
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 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 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 [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 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 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 selectedCadCount = selectedCadFileIds?.length ?? 0;
@@ -171,7 +230,8 @@ export default function AlgorithmRunPanel({ taskId, pointCloudLoaded: _pointClou
const baseRunBlockReason =
taskId == null ? '请先选择检测任务'
: (taskPcFileCount ?? 0) === 0 ? '请先上传点云文件'
: runBatch.isPending ? '算法提交中,请稍候'
: demoFlowRunning ? '一键全流程运行中,请先停止或等待完成'
: (runBatch.isPending || runDemoAll.isPending || cancelDemoAll.isPending) ? '算法提交中,请稍候'
: null;
const registrationBlockReason =
baseRunBlockReason
@@ -187,11 +247,36 @@ export default function AlgorithmRunPanel({ taskId, pointCloudLoaded: _pointClou
?? (selectedMeasIds.length === 0 ? '请至少勾选一个测量算法' : null);
const canRunRegistration = registrationBlockReason == 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 =
latestRegistrationChild && selectedRegistrationPointcloud
? `点云「${selectedRegistrationPointcloud.name}」已有配准产物「${latestRegistrationChild.name}」(${formatDateTime(latestRegistrationChild.createdAt)}),再次执行会生成新产物。`
: 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 blockReason = mode === 'registration' ? registrationBlockReason : measurementBlockReason;
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 里
return (
<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:整车配准 ────────────────────────────────── */}
<div className="space-y-1.5">
<div className="flex items-center justify-between gap-1">
@@ -275,7 +434,7 @@ export default function AlgorithmRunPanel({ taskId, pointCloudLoaded: _pointClou
<PluginCard
key={p.id}
p={p}
isSelected={selected.has(p.id)}
isSelected={selected.has(p.id) || (demoFlowRunning && demoPluginIds.has(p.id))}
isExpanded={expanded.has(p.id)}
params={paramsById[p.id] ?? {}}
onToggleSelect={toggleSelect}
@@ -296,7 +455,7 @@ export default function AlgorithmRunPanel({ taskId, pointCloudLoaded: _pointClou
<Cpu className="w-3.5 h-3.5" />
第 2 步 · 测量分析(多选并发)
</span>
<div className="flex items-center gap-1">
<div className="flex items-center gap-1 flex-wrap justify-end">
{scheduler && (
<span
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}
</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
type="button"
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
key={p.id}
p={p}
isSelected={selected.has(p.id)}
isSelected={selected.has(p.id) || (demoFlowRunning && demoPluginIds.has(p.id))}
isExpanded={expanded.has(p.id)}
params={paramsById[p.id] ?? {}}
onToggleSelect={toggleSelect}

View File

@@ -1,8 +1,9 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useMemo, useRef } from 'react';
import { trpc } from '@/lib/trpc';
import { BarChart2, Ruler, Zap, Play, Trash2, ChevronDown, ChevronRight, RefreshCw, Flame, Box, Layers, Crosshair, Cpu, Timer } from 'lucide-react';
import { 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 { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from './ui/dialog';
/** 将毫秒数格式化为可读时长,如 2m 34s / 47s / 1h 5m */
function fmtDuration(ms: number): string {
@@ -163,30 +164,180 @@ type AssemblyMeta = SmoothHeatmapMeta & {
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 === 'measurement') return '尺寸测量';
if (r.type === 'smooth_heatmap') return '平滑热力';
if (r.type === 'assembly_inspection') return '装配检测';
if (r.type === 'obj_deviation') return '偏差热力';
if (r.type === 'plugin') return `算法 · ${r.pluginId ?? '插件'}`;
if (r.type === 'plugin') return `算法 · ${getPluginDisplayName(r.pluginId, pluginNameById, '插件')}`;
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) {
const utils = trpc.useUtils();
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(
{ taskId },
{ 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({
onSuccess: () => utils.analysisResult.list.invalidate({ taskId }),
});
const deleteResult = trpc.analysisResult.delete.useMutation({
onSuccess: () => utils.analysisResult.list.invalidate({ taskId }),
});
const cancelResult = trpc.analysisResult.cancel.useMutation({
onSuccess: () => utils.analysisResult.list.invalidate({ taskId }),
});
function toggleExpand(id: number) {
setExpandedIds(prev => {
@@ -197,6 +348,394 @@ export default function AnalysisPanel({ taskId, onReplay, onViewSmoothHeatmap, o
}
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">
阈值 &lt; {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 (
<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" /> 分析结果
</span>
<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
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' })}
@@ -249,283 +796,27 @@ export default function AnalysisPanel({ taskId, onReplay, onViewSmoothHeatmap, o
</div>
)}
<div className="space-y-1.5">
{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 });
{!isLoading && results.length > 0 && renderResultList('panel')}
return (
<div key={r.id} className="industrial-card rounded overflow-hidden min-w-0">
{/* 标题行 */}
<div
className="flex items-center gap-2 px-2 py-1.5 cursor-pointer hover:bg-secondary/40 group min-w-0"
onClick={() => toggleExpand(r.id)}
>
{expanded ? <ChevronDown className="w-3 h-3 text-muted-foreground" /> : <ChevronRight className="w-3 h-3 text-muted-foreground" />}
{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="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">
阈值 &lt; {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>
<Dialog open={maximizedOpen} onOpenChange={setMaximizedOpen}>
<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">
<DialogTitle className="text-sm flex items-center gap-2">
<BarChart2 className="w-4 h-4" />
分析结果
</DialogTitle>
</DialogHeader>
<div className="min-h-0 overflow-y-auto p-4">
{isLoading && (
<div className="text-xs text-muted-foreground text-center py-8">加载中…</div>
)}
{!isLoading && results.length === 0 && (
<div className="text-xs text-muted-foreground text-center py-8">暂无分析结果</div>
)}
{!isLoading && results.length > 0 && renderResultList('maximized')}
</div>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -100,6 +100,7 @@ function formatValue(v: number): string {
export default function GenericResultView({
taskId,
resultId,
pluginId,
pluginName,
data,
onViewOverlay,
@@ -112,13 +113,13 @@ export default function GenericResultView({
typeof f === 'string' ? f : (f as { name?: string; path?: string }).name ?? String(f)
);
const overlay = data.overlay3d ?? { kind: 'none' };
const displayPluginName = pluginName ?? pluginId ?? data.pluginId;
return (
<div className="space-y-3">
{pluginName && (
{displayPluginName && (
<div className="text-[10px] text-muted-foreground">
{pluginName}
{data.pluginId ? ` · ${data.pluginId}` : ''}
{displayPluginName}
{data.schemaVersion != null ? ` · schema v${data.schemaVersion}` : ''}
</div>
)}

View File

@@ -16,6 +16,7 @@ import {
Library,
} from 'lucide-react';
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
import { toast } from 'sonner';
interface Props {
modelId: number;
@@ -46,6 +47,31 @@ function formatDateTime(value?: string | Date) {
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({
modelId,
activeTaskId,
@@ -82,9 +108,21 @@ export default function TaskManager({
const [selectedModelCadFileIds, setSelectedModelCadFileIds] = useState<Set<number>>(new Set());
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({
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({
onSuccess: () => utils.inspectionTask.listByModel.invalidate({ modelId }),
@@ -187,6 +225,12 @@ export default function TaskManager({
fileRef.current?.click();
}
function submitCreateTask() {
const name = taskName.trim();
if (!name || createTask.isPending) return;
createTask.mutate({ modelId, name });
}
async function handleUpload(files: FileList) {
const taskId = pendingTaskId.current;
if (!taskId) return;
@@ -373,10 +417,7 @@ export default function TaskManager({
onChange={(e) => setTaskName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && taskName.trim()) {
createTask.mutate({ modelId, name: taskName.trim() });
setTaskName('');
setAdding(false);
onEndCreate?.();
submitCreateTask();
}
if (e.key === 'Escape') {
setAdding(false);
@@ -386,11 +427,8 @@ export default function TaskManager({
/>
<button
className="text-green-400"
onClick={() => {
if (taskName.trim()) createTask.mutate({ modelId, name: taskName.trim() });
setAdding(false);
onEndCreate?.();
}}
disabled={createTask.isPending}
onClick={submitCreateTask}
>
<Check className="w-3.5 h-3.5" />
</button>
@@ -407,52 +445,47 @@ export default function TaskManager({
)}
<div className="space-y-1">
{tasks.map((task) => (
<div
key={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'}`}
onClick={() => onSelectTask(task.id)}
>
<ClipboardList
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'
}`}
{tasks.map((task) => {
const status = task.status as InspectionTaskStatus;
return (
<div
key={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'}`}
onClick={() => onSelectTask(task.id)}
>
{task.status}
</span>
<button
className="hidden group-hover:block p-0.5 rounded hover:bg-secondary"
title="上传点云文件"
onClick={(e) => {
e.stopPropagation();
openUpload(task.id);
}}
>
<Upload className="w-3 h-3" />
</button>
<button
className="hidden group-hover:block p-0.5 rounded hover:bg-secondary text-destructive"
title="删除任务"
onClick={(e) => {
e.stopPropagation();
if (confirm(`删除任务「${task.name}」?`)) deleteTask.mutate({ id: task.id });
}}
>
<Trash2 className="w-3 h-3" />
</button>
</div>
))}
<ClipboardList
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 ${taskStatusClass(status)}`}
>
{taskStatusLabels[status] ?? status}
</span>
<button
className="hidden group-hover:block p-0.5 rounded hover:bg-secondary"
title="上传点云文件"
onClick={(e) => {
e.stopPropagation();
openUpload(task.id);
}}
>
<Upload className="w-3 h-3" />
</button>
<button
className="hidden group-hover:block p-0.5 rounded hover:bg-secondary text-destructive"
title="删除任务"
onClick={(e) => {
e.stopPropagation();
if (confirm(`删除任务「${task.name}」?`)) deleteTask.mutate({ id: task.id });
}}
>
<Trash2 className="w-3 h-3" />
</button>
</div>
);
})}
</div>
{tasks.length === 0 && (

View File

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

View File

@@ -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 { getLoginUrl } from '@/const';
import PointCloudViewer from '@/components/PointCloudViewer';
@@ -7,7 +7,6 @@ import TaskManager from '@/components/TaskManager';
import AnalysisPanel from '@/components/AnalysisPanel';
import AlgorithmRunPanel from '@/components/AlgorithmRunPanel';
import type { Overlay3D } from '@/components/GenericResultView';
import WorkflowProgress, { type WorkflowState, type WorkflowStepId, type StepStatus } from '@/components/WorkflowProgress';
import { trpc } from '@/lib/trpc';
import {
LogIn,
@@ -90,11 +89,6 @@ export default function Home() {
overlay: Overlay3D;
} | null>(null);
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 [showRightPanel, setShowRightPanel] = useState(true);
@@ -122,13 +116,6 @@ export default function Home() {
toast.success('已提交偏差热力分析(STL/OBJ + 点云距离),完成后点击「在 3D 中查看」');
}
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 || '提交失败'),
});
@@ -146,7 +133,6 @@ export default function Home() {
setHeatmapViewResultId(null);
setPluginOverlay(null);
setPointCloudRenderMode('points');
setIsCreatingTask(false);
setSelectedPcFileIds([]);
setSelectedTaskCadFileIds([]);
setRoiPickMode(false);
@@ -253,57 +239,6 @@ export default function Home() {
// ── 把 __task:N 格式的 URL 转成 batch 请求参数 ──────────────────────────────
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 会以为点云源变了而反复全量请求
const pointCloudUrlsStable = useMemo(
() => (pointCloudTaskId != null ? [`__task:${pointCloudTaskId}`] : undefined),
@@ -329,8 +264,6 @@ export default function Home() {
onPcFilesSelectionChange={setSelectedPcFileIds}
onCadFilesSelectionChange={setSelectedTaskCadFileIds}
onSelectTaskCad={handleSelectCad}
onStartCreate={() => setIsCreatingTask(true)}
onEndCreate={() => setIsCreatingTask(false)}
/>
</>
);
@@ -350,6 +283,7 @@ export default function Home() {
<div className="space-y-4">
<AlgorithmRunPanel
taskId={selection.taskId}
modelId={selection.modelId}
pointCloudLoaded={pointCloudTaskId === selection.taskId && pointCloudUrls.length > 0}
taskPcFileCount={taskPcFiles.length}
taskPointcloudFiles={taskPcFiles}
@@ -481,13 +415,6 @@ export default function Home() {
{/* 中央 3D 视图 */}
<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="absolute inset-2">
<div className="viewer-container w-full h-full relative">
@@ -670,7 +597,7 @@ export default function Home() {
{/* 右侧面板 - 分析结果 */}
{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()}
</aside>
)}