232 lines
7.6 KiB
TypeScript
232 lines
7.6 KiB
TypeScript
/**
|
||
* 通用算法结果渲染(Route B)
|
||
* ------------------------------------------------------------
|
||
* 输入:一条 task_analysis_results.resultData(PluginResultData)记录。
|
||
* 输出:统一的「误差统计 + 测量值表 + 图片缩略图 + 3D 叠加按钮 + 原始字段折叠」。
|
||
*
|
||
* 设计目标:新增算法不需要为它写新的 React 组件;只要 result.json 字段对齐
|
||
* PluginResult,就能自动渲染。
|
||
*/
|
||
import { useState } from 'react';
|
||
import { Box, ChevronDown, ChevronRight, Image as ImageIcon } from 'lucide-react';
|
||
|
||
interface Measurement {
|
||
name: string;
|
||
value: number;
|
||
unit?: string;
|
||
tolerance?: number;
|
||
status?: 'pass' | 'warning' | 'fail' | 'info';
|
||
}
|
||
|
||
interface ErrorStats {
|
||
min: number;
|
||
max: number;
|
||
mean: number;
|
||
stdDev: number;
|
||
totalPoints?: number;
|
||
withinTolerance?: number;
|
||
outOfTolerance?: number;
|
||
}
|
||
|
||
type Overlay3D =
|
||
| { kind: 'none' }
|
||
| { kind: 'vertexColors'; file: string; vertexCount: number; dispMin: number; dispMax: number }
|
||
| { kind: 'ply'; file: string };
|
||
|
||
interface PluginResultLike {
|
||
pluginId?: string;
|
||
schemaVersion?: number;
|
||
message?: string;
|
||
measurements?: Measurement[];
|
||
errorStats?: ErrorStats | null;
|
||
overlay3d?: Overlay3D;
|
||
images?: string[];
|
||
extra?: Record<string, unknown>;
|
||
error?: string;
|
||
}
|
||
|
||
interface Props {
|
||
taskId: number;
|
||
resultId: number;
|
||
pluginId?: string;
|
||
pluginName?: string;
|
||
data: PluginResultLike;
|
||
/** 用户点击「在 3D 中查看」时回调,把叠加层信息传给 Home → PointCloudViewer */
|
||
onViewOverlay?: (overlay: { taskId: number; resultId: number; overlay: Overlay3D }) => void;
|
||
}
|
||
|
||
function statusClass(s?: Measurement['status']) {
|
||
switch (s) {
|
||
case 'pass':
|
||
return 'bg-green-900/40 text-green-400';
|
||
case 'warning':
|
||
return 'bg-amber-900/40 text-amber-400';
|
||
case 'fail':
|
||
return 'bg-red-900/40 text-red-400';
|
||
default:
|
||
return 'bg-secondary text-muted-foreground';
|
||
}
|
||
}
|
||
|
||
function statusLabel(s?: Measurement['status']) {
|
||
switch (s) {
|
||
case 'pass':
|
||
return '合格';
|
||
case 'warning':
|
||
return '警告';
|
||
case 'fail':
|
||
return '超差';
|
||
default:
|
||
return '—';
|
||
}
|
||
}
|
||
|
||
function formatValue(v: number): string {
|
||
if (!Number.isFinite(v)) return '—';
|
||
const abs = Math.abs(v);
|
||
if (abs !== 0 && (abs < 0.001 || abs >= 1e6)) return v.toExponential(3);
|
||
return v.toFixed(3);
|
||
}
|
||
|
||
export default function GenericResultView({
|
||
taskId,
|
||
resultId,
|
||
pluginName,
|
||
data,
|
||
onViewOverlay,
|
||
}: Props) {
|
||
const [showExtra, setShowExtra] = useState(false);
|
||
const errorStats = data.errorStats ?? null;
|
||
const measurements = data.measurements ?? [];
|
||
const images = data.images ?? [];
|
||
const overlay = data.overlay3d ?? { kind: 'none' };
|
||
|
||
return (
|
||
<div className="space-y-3">
|
||
{pluginName && (
|
||
<div className="text-[10px] text-muted-foreground">
|
||
{pluginName}
|
||
{data.pluginId ? ` · ${data.pluginId}` : ''}
|
||
{data.schemaVersion != null ? ` · schema v${data.schemaVersion}` : ''}
|
||
</div>
|
||
)}
|
||
{data.message && (
|
||
<div className="text-[10px] text-muted-foreground italic">{data.message}</div>
|
||
)}
|
||
|
||
{errorStats && (
|
||
<div className="grid grid-cols-2 gap-1.5">
|
||
{[
|
||
{ label: '最小误差', value: formatValue(errorStats.min) + ' mm' },
|
||
{ label: '最大误差', value: formatValue(errorStats.max) + ' mm' },
|
||
{ label: '平均误差', value: formatValue(errorStats.mean) + ' mm' },
|
||
{ label: '标准差', value: formatValue(errorStats.stdDev) + ' 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>
|
||
)}
|
||
|
||
{measurements.length > 0 && (
|
||
<div>
|
||
<div className="text-[10px] text-muted-foreground mb-1">测量值</div>
|
||
<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>
|
||
{measurements.map((m, i) => (
|
||
<tr key={`${m.name}-${i}`} className="border-t border-border/40">
|
||
<td className="py-1 truncate max-w-[140px]" title={m.name}>
|
||
{m.name}
|
||
</td>
|
||
<td className="text-right font-mono">
|
||
{formatValue(m.value)} {m.unit ?? ''}
|
||
</td>
|
||
<td className="text-right text-muted-foreground">
|
||
{m.tolerance != null ? `±${m.tolerance}` : '—'}
|
||
</td>
|
||
<td className="text-right">
|
||
<span className={`px-1 py-0.5 rounded text-[9px] ${statusClass(m.status)}`}>
|
||
{m.status === 'info' ? '参考' : statusLabel(m.status)}
|
||
</span>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
|
||
{overlay.kind !== 'none' && onViewOverlay && (
|
||
<button
|
||
type="button"
|
||
className="industrial-btn industrial-btn-primary py-1 text-[10px] flex items-center gap-1 w-full justify-center"
|
||
onClick={() => onViewOverlay({ taskId, resultId, overlay })}
|
||
>
|
||
<Box className="w-3 h-3" />
|
||
在 3D 中查看叠加层
|
||
</button>
|
||
)}
|
||
|
||
{images.length > 0 && (
|
||
<div>
|
||
<div className="text-[10px] text-muted-foreground mb-1">产物图片</div>
|
||
<div className="grid grid-cols-2 gap-1.5">
|
||
{images.map((rel) => {
|
||
const url = `/api/files/read/analysis/${taskId}/${resultId}/${encodeURI(rel)}`;
|
||
return (
|
||
<a
|
||
key={rel}
|
||
href={url}
|
||
target="_blank"
|
||
rel="noreferrer"
|
||
className="industrial-card p-1 flex items-center gap-1 hover:bg-secondary/40 text-[10px]"
|
||
title={rel}
|
||
>
|
||
<ImageIcon className="w-3 h-3 flex-shrink-0" />
|
||
<span className="truncate">{rel}</span>
|
||
</a>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{data.extra && Object.keys(data.extra).length > 0 && (
|
||
<div>
|
||
<button
|
||
type="button"
|
||
className="flex items-center gap-1 text-[10px] text-muted-foreground hover:text-foreground"
|
||
onClick={() => setShowExtra((v) => !v)}
|
||
>
|
||
{showExtra ? <ChevronDown className="w-3 h-3" /> : <ChevronRight className="w-3 h-3" />}
|
||
原始字段 (extra)
|
||
</button>
|
||
{showExtra && (
|
||
<pre className="industrial-card p-2 text-[9px] font-mono whitespace-pre-wrap break-all max-h-48 overflow-y-auto mt-1">
|
||
{JSON.stringify(data.extra, null, 2)}
|
||
</pre>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{data.error && (
|
||
<div className="industrial-card p-2 border border-red-900/40">
|
||
<div className="text-[10px] text-red-400/90">{data.error}</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export type { PluginResultLike, Overlay3D, Measurement, ErrorStats };
|