Files
massive-cloud/client/src/components/AnalysisPanel.tsx

532 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect } from 'react';
import { trpc } from '@/lib/trpc';
import { BarChart2, Ruler, Zap, Play, Trash2, ChevronDown, ChevronRight, RefreshCw, Flame, Box, Layers, Crosshair, Cpu, Timer } from 'lucide-react';
import GenericResultView, { type Overlay3D } from './GenericResultView';
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
/** 将毫秒数格式化为可读时长,如 2m 34s / 47s / 1h 5m */
function fmtDuration(ms: number): string {
const s = Math.floor(ms / 1000);
if (s < 60) return `${s}s`;
const m = Math.floor(s / 60);
if (m < 60) return `${m}m ${s % 60}s`;
return `${Math.floor(m / 60)}h ${m % 60}m`;
}
/** 运行中的任务:实时更新计时;已结束:显示静态 updatedAt - createdAt */
function ElapsedTime({ status, createdAt, updatedAt }: {
status: string;
createdAt: string | Date;
updatedAt: string | Date;
}) {
const start = new Date(createdAt).getTime();
const end = new Date(updatedAt).getTime();
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
if (status !== 'processing' && status !== 'pending') return;
const id = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(id);
}, [status]);
const ms = (status === 'processing' || status === 'pending')
? now - start
: end - start;
if (ms < 500) return null;
return (
<span className="flex items-center gap-0.5 text-[9px] tabular-nums text-muted-foreground flex-shrink-0" title="执行时长">
<Timer className="w-2.5 h-2.5" />
{fmtDuration(ms)}
</span>
);
}
interface Props {
taskId: number;
onReplay?: (resultId: number) => void;
/** 在 3D 视口显示该条平滑热力分析结果 */
onViewSmoothHeatmap?: (resultId: number) => void;
/** 触发装配检测(来自 Home.tsx 的 runAnalysisJob) */
onRunAssembly?: () => void;
/** 触发偏差热力(来自 Home.tsx 的 runAnalysisJob) */
onRunDeviation?: () => void;
/** 外部分析任务是否正在提交中 */
externalPending?: boolean;
/** 插件叠加层:用户点击「在 3D 中查看叠加层」时回调 Home → PointCloudViewer */
onViewPluginOverlay?: (args: { taskId: number; resultId: number; overlay: Overlay3D }) => void;
}
type Measurement = { name: string; value: number; unit: string; tolerance: number; status: string };
type ErrorStats = { min: number; max: number; mean: number; stdDev: number; totalPoints: number; withinTolerance: number; outOfTolerance: number };
function ErrorStatCard({ stats }: { stats: ErrorStats }) {
const pct = stats.totalPoints > 0
? ((stats.withinTolerance / stats.totalPoints) * 100).toFixed(1)
: '0';
return (
<div className="space-y-2">
<div className="grid grid-cols-2 gap-1.5">
{[
{ label: '最小误差', value: stats.min.toFixed(4) + ' mm' },
{ label: '最大误差', value: stats.max.toFixed(4) + ' mm' },
{ label: '平均误差', value: stats.mean.toFixed(4) + ' mm' },
{ label: '标准差', value: stats.stdDev.toFixed(4) + ' mm' },
].map(({ label, value }) => (
<div key={label} className="industrial-card p-2">
<div className="text-[10px] text-muted-foreground">{label}</div>
<div className="text-xs font-mono mt-0.5">{value}</div>
</div>
))}
</div>
<div className="industrial-card p-2">
<div className="flex items-center justify-between text-[10px] mb-1">
<span className="text-muted-foreground">合格率</span>
<span className={parseFloat(pct) >= 95 ? 'text-green-400' : parseFloat(pct) >= 85 ? 'text-amber-400' : 'text-red-400'}>
{pct}%
</span>
</div>
<div className="industrial-progress">
<div
className="industrial-progress-bar"
style={{ width: `${pct}%`, background: parseFloat(pct) >= 95 ? 'oklch(0.62 0.16 145)' : parseFloat(pct) >= 85 ? 'oklch(0.72 0.16 85)' : 'oklch(0.55 0.20 28)' }}
/>
</div>
<div className="flex justify-between text-[10px] text-muted-foreground mt-1">
<span>合格 {stats.withinTolerance.toLocaleString()}</span>
<span>超差 {stats.outOfTolerance.toLocaleString()}</span>
</div>
</div>
</div>
);
}
function MeasurementTable({ items }: { items: Measurement[] }) {
return (
<table className="w-full text-[10px] border-collapse">
<thead>
<tr className="text-muted-foreground">
<th className="text-left py-1 font-normal">项目</th>
<th className="text-right py-1 font-normal">值</th>
<th className="text-right py-1 font-normal">公差</th>
<th className="text-right py-1 font-normal">结果</th>
</tr>
</thead>
<tbody>
{items.map((m, i) => (
<tr key={i} className="border-t border-border/40">
<td className="py-1">{m.name}</td>
<td className="text-right font-mono">{m.value.toFixed(3)} {m.unit}</td>
<td className="text-right text-muted-foreground">±{m.tolerance}</td>
<td className="text-right">
<span className={`px-1 py-0.5 rounded text-[9px] ${
m.status === 'pass' ? 'bg-green-900/40 text-green-400' :
m.status === 'warning' ? 'bg-amber-900/40 text-amber-400' :
'bg-red-900/40 text-red-400'
}`}>
{m.status === 'pass' ? '合格' : m.status === 'warning' ? '警告' : '超差'}
</span>
</td>
</tr>
))}
</tbody>
</table>
);
}
type SmoothHeatmapMeta = {
kind?: string;
/** 0–1,processing 时子进程写入 */
progress?: number;
stage?: string;
vertexCount?: number;
dispMin?: number;
dispMax?: number;
error?: string;
};
type AssemblyPartRow = {
partId: string;
name: string;
min: number;
max: number;
mean: number;
stdDev: number;
passRate: number;
pointCount: number;
vertexHeatmapBin?: string;
};
type AssemblyMeta = SmoothHeatmapMeta & {
toleranceMm?: number;
samplePoints?: number;
parts?: AssemblyPartRow[];
};
function getResultTitle(r: { type: string; pluginId?: string }) {
if (r.type === 'error_analysis') return '误差分析';
if (r.type === 'measurement') return '尺寸测量';
if (r.type === 'smooth_heatmap') return '平滑热力';
if (r.type === 'assembly_inspection') return '装配检测';
if (r.type === 'obj_deviation') return '偏差热力';
if (r.type === 'plugin') return `算法 · ${r.pluginId ?? '插件'}`;
return '压缩';
}
export default function AnalysisPanel({ taskId, onReplay, onViewSmoothHeatmap, onRunAssembly, onRunDeviation, externalPending, onViewPluginOverlay }: Props) {
const utils = trpc.useUtils();
const [expandedIds, setExpandedIds] = useState<Set<number>>(new Set());
const { data: results = [], isLoading } = trpc.analysisResult.list.useQuery(
{ taskId },
{ refetchInterval: 3000 },
);
const runAnalysis = trpc.analysisResult.runAnalysis.useMutation({
onSuccess: () => utils.analysisResult.list.invalidate({ taskId }),
});
const deleteResult = trpc.analysisResult.delete.useMutation({
onSuccess: () => utils.analysisResult.list.invalidate({ taskId }),
});
function toggleExpand(id: number) {
setExpandedIds(prev => {
const n = new Set(prev);
n.has(id) ? n.delete(id) : n.add(id);
return n;
});
}
const running = runAnalysis.isPending;
return (
<div className="space-y-3">
<div className="flex items-center justify-between gap-1 flex-wrap">
<span className="industrial-label flex items-center gap-1.5">
<BarChart2 className="w-3.5 h-3.5" /> 分析结果
</span>
<div className="flex items-center gap-1">
<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' })}
disabled={running}
title="误差统计分析"
>
{running ? <RefreshCw className="w-3 h-3 animate-spin" /> : <Play className="w-3 h-3" />}
{running ? '分析中…' : '误差分析'}
</button>
{onRunAssembly && (
<button
className={`industrial-btn py-0.5 text-[10px] flex items-center gap-0.5 ${externalPending ? 'opacity-60 cursor-wait' : ''}`}
onClick={onRunAssembly}
disabled={externalPending}
title="需车型下已上传 STEP;后台解析零件并做最近零件归属与误差统计"
>
<Layers className="w-3 h-3" />
装配检测
</button>
)}
{onRunDeviation && (
<button
className={`industrial-btn py-0.5 text-[10px] flex items-center gap-0.5 ${externalPending ? 'opacity-60 cursor-wait' : ''}`}
onClick={onRunDeviation}
disabled={externalPending}
title="在 STL/OBJ 网格上按顶点到最近点云点的距离着色"
>
<Crosshair className="w-3 h-3" />
偏差热力
</button>
)}
</div>
</div>
{isLoading && (
<div className="text-xs text-muted-foreground text-center py-4">加载中…</div>
)}
{!isLoading && results.length === 0 && (
<div className="text-xs text-muted-foreground text-center py-4">
暂无分析结果,点击运行分析
</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 });
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>
</div>
);
}