feat: initial commit - massive-cloud point cloud processing system
This commit is contained in:
231
client/src/components/GenericResultView.tsx
Normal file
231
client/src/components/GenericResultView.tsx
Normal file
@@ -0,0 +1,231 @@
|
||||
/**
|
||||
* 通用算法结果渲染(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 };
|
||||
Reference in New Issue
Block a user