feat: refine task and demo workflow UI
This commit is contained in:
@@ -1,7 +1,9 @@
|
|||||||
import { useState, useEffect, useMemo, useRef } 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, Square, Maximize2 } from 'lucide-react';
|
import { useAuth } from '@/_core/hooks/useAuth';
|
||||||
import GenericResultView, { type Overlay3D } from './GenericResultView';
|
import { BarChart2, Ruler, Zap, Play, Trash2, ChevronDown, ChevronRight, RefreshCw, Flame, Box, Layers, Crosshair, Cpu, Timer, Square, Maximize2, FileSpreadsheet, Image as ImageIcon } from 'lucide-react';
|
||||||
|
import GenericResultView, { filterVisibleMeasurements, formatValue, normalizeOverlay3D, numberArrayFromUnknown, statusClass, statusLabel, type Overlay3D } from './GenericResultView';
|
||||||
|
import { aggregateDemoFlowResults, type DemoFlowArtifactGroup, type DemoFlowStatusNotice } from './demoFlowAggregation';
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from './ui/dialog';
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from './ui/dialog';
|
||||||
|
|
||||||
@@ -108,14 +110,15 @@ function ErrorStatCard({ stats }: { stats: ErrorStats }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function MeasurementTable({ items }: { items: Measurement[] }) {
|
function MeasurementTable({ items, displayMode }: { items: Measurement[]; displayMode: ResultListVariant }) {
|
||||||
|
const showDetails = displayMode === 'maximized';
|
||||||
return (
|
return (
|
||||||
<table className="w-full text-[10px] border-collapse">
|
<table className="w-full text-[10px] border-collapse">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="text-muted-foreground">
|
<tr className="text-muted-foreground">
|
||||||
<th className="text-left py-1 font-normal">项目</th>
|
<th className="text-left py-1 font-normal">项目</th>
|
||||||
<th className="text-right py-1 font-normal">值</th>
|
{showDetails && <th className="text-right py-1 font-normal">值</th>}
|
||||||
<th className="text-right py-1 font-normal">公差</th>
|
{showDetails && <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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -123,8 +126,8 @@ function MeasurementTable({ items }: { items: Measurement[] }) {
|
|||||||
{items.map((m, i) => (
|
{items.map((m, i) => (
|
||||||
<tr key={i} className="border-t border-border/40">
|
<tr key={i} className="border-t border-border/40">
|
||||||
<td className="py-1">{m.name}</td>
|
<td className="py-1">{m.name}</td>
|
||||||
<td className="text-right font-mono">{m.value.toFixed(3)} {m.unit}</td>
|
{showDetails && <td className="text-right font-mono">{m.value.toFixed(3)} {m.unit}</td>}
|
||||||
<td className="text-right text-muted-foreground">±{m.tolerance}</td>
|
{showDetails && <td className="text-right text-muted-foreground">±{m.tolerance}</td>}
|
||||||
<td className="text-right">
|
<td className="text-right">
|
||||||
<span className={`px-1 py-0.5 rounded text-[9px] ${
|
<span className={`px-1 py-0.5 rounded text-[9px] ${
|
||||||
m.status === 'pass' ? 'bg-green-900/40 text-green-400' :
|
m.status === 'pass' ? 'bg-green-900/40 text-green-400' :
|
||||||
@@ -233,6 +236,7 @@ type DemoFlowMeta = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const SHOW_LEGACY_ANALYSIS_ACTIONS = false;
|
const SHOW_LEGACY_ANALYSIS_ACTIONS = false;
|
||||||
|
const SHOW_DEMO_FLOW_ALGORITHM_GROUP_ROWS = false;
|
||||||
|
|
||||||
type AnalysisListItem =
|
type AnalysisListItem =
|
||||||
| { kind: 'result'; result: AnalysisResultRow }
|
| { kind: 'result'; result: AnalysisResultRow }
|
||||||
@@ -316,9 +320,14 @@ function getDemoFlowTotal(results: AnalysisResultRow[]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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 { canWrite } = useAuth();
|
||||||
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 [expandedFlowIds, setExpandedFlowIds] = useState<Set<string>>(new Set());
|
||||||
|
const [collapsedFlowResultCards, setCollapsedFlowResultCards] = useState<Set<string>>(new Set());
|
||||||
|
const [expandedFlowArtifactCards, setExpandedFlowArtifactCards] = useState<Set<string>>(new Set());
|
||||||
|
const [expandedFlowArtifactGroups, setExpandedFlowArtifactGroups] = useState<Set<string>>(new Set());
|
||||||
|
const [expandedFlowArtifactExtras, setExpandedFlowArtifactExtras] = useState<Set<string>>(new Set());
|
||||||
const [maximizedOpen, setMaximizedOpen] = useState(false);
|
const [maximizedOpen, setMaximizedOpen] = useState(false);
|
||||||
const initializedDemoResultIds = useRef<Set<number>>(new Set());
|
const initializedDemoResultIds = useRef<Set<number>>(new Set());
|
||||||
const initializedDemoFlowIds = useRef<Set<string>>(new Set());
|
const initializedDemoFlowIds = useRef<Set<string>>(new Set());
|
||||||
@@ -401,10 +410,42 @@ export default function AnalysisPanel({ taskId, onReplay, onViewSmoothHeatmap, o
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderResultCard(r: AnalysisResultRow, extraClassName = '') {
|
function toggleFlowResultCard(key: string) {
|
||||||
|
setCollapsedFlowResultCards((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.has(key) ? next.delete(key) : next.add(key);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleFlowArtifactCard(key: string) {
|
||||||
|
setExpandedFlowArtifactCards((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.has(key) ? next.delete(key) : next.add(key);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleFlowArtifactGroup(key: string) {
|
||||||
|
setExpandedFlowArtifactGroups((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.has(key) ? next.delete(key) : next.add(key);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleFlowArtifactExtra(key: string) {
|
||||||
|
setExpandedFlowArtifactExtras((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.has(key) ? next.delete(key) : next.add(key);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderResultCard(r: AnalysisResultRow, variant: ResultListVariant, extraClassName = '') {
|
||||||
const expanded = expandedIds.has(r.id);
|
const expanded = expandedIds.has(r.id);
|
||||||
const errorStats = r.errorStats as ErrorStats | null;
|
const errorStats = r.errorStats as ErrorStats | null;
|
||||||
const measurements = r.measurements as Measurement[] | null;
|
const measurements = filterVisibleMeasurements((r.measurements as Measurement[] | null) ?? []);
|
||||||
const resultTitle = getResultTitle(r, pluginNameById);
|
const resultTitle = getResultTitle(r, pluginNameById);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -445,7 +486,7 @@ export default function AnalysisPanel({ taskId, onReplay, onViewSmoothHeatmap, o
|
|||||||
<Play className="w-3 h-3 text-primary" />
|
<Play className="w-3 h-3 text-primary" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{isCancellableStatus(r.status) && (
|
{canWrite && isCancellableStatus(r.status) && (
|
||||||
<button
|
<button
|
||||||
className="p-0.5 rounded hover:bg-secondary text-amber-300 disabled:opacity-50"
|
className="p-0.5 rounded hover:bg-secondary text-amber-300 disabled:opacity-50"
|
||||||
title="停止"
|
title="停止"
|
||||||
@@ -455,13 +496,13 @@ export default function AnalysisPanel({ taskId, onReplay, onViewSmoothHeatmap, o
|
|||||||
<Square className="w-3 h-3" />
|
<Square className="w-3 h-3" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button
|
{canWrite && <button
|
||||||
className="p-0.5 rounded hover:bg-secondary text-destructive"
|
className="p-0.5 rounded hover:bg-secondary text-destructive"
|
||||||
title="删除"
|
title="删除"
|
||||||
onClick={e => { e.stopPropagation(); deleteResult.mutate({ id: r.id }); }}
|
onClick={e => { e.stopPropagation(); deleteResult.mutate({ id: r.id }); }}
|
||||||
>
|
>
|
||||||
<Trash2 className="w-3 h-3" />
|
<Trash2 className="w-3 h-3" />
|
||||||
</button>
|
</button>}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="ml-[42px] mt-1 flex items-center gap-x-2 gap-y-1 min-w-0 flex-wrap">
|
<div className="ml-[42px] mt-1 flex items-center gap-x-2 gap-y-1 min-w-0 flex-wrap">
|
||||||
@@ -486,10 +527,10 @@ export default function AnalysisPanel({ taskId, onReplay, onViewSmoothHeatmap, o
|
|||||||
{expanded && r.status === 'completed' && r.type !== 'smooth_heatmap' && r.type !== 'assembly_inspection' && r.type !== 'obj_deviation' && r.type !== 'plugin' && (
|
{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">
|
<div className="px-3 pb-3 pt-1 border-t border-border/40 space-y-3">
|
||||||
{errorStats && <ErrorStatCard stats={errorStats} />}
|
{errorStats && <ErrorStatCard stats={errorStats} />}
|
||||||
{measurements && measurements.length > 0 && (
|
{measurements.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<div className="text-[10px] text-muted-foreground mb-1">尺寸测量</div>
|
<div className="text-[10px] text-muted-foreground mb-1">尺寸测量</div>
|
||||||
<MeasurementTable items={measurements} />
|
<MeasurementTable items={measurements} displayMode={variant} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -677,6 +718,7 @@ export default function AnalysisPanel({ taskId, onReplay, onViewSmoothHeatmap, o
|
|||||||
pluginId={r.pluginId ?? undefined}
|
pluginId={r.pluginId ?? undefined}
|
||||||
pluginName={getPluginDisplayName(r.pluginId, pluginNameById, '算法插件')}
|
pluginName={getPluginDisplayName(r.pluginId, pluginNameById, '算法插件')}
|
||||||
data={(r.resultData as Parameters<typeof GenericResultView>[0]['data']) ?? {}}
|
data={(r.resultData as Parameters<typeof GenericResultView>[0]['data']) ?? {}}
|
||||||
|
displayMode={variant}
|
||||||
onViewOverlay={onViewPluginOverlay}
|
onViewOverlay={onViewPluginOverlay}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -685,20 +727,141 @@ export default function AnalysisPanel({ taskId, onReplay, onViewSmoothHeatmap, o
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderFlowStatusNotice(notice: DemoFlowStatusNotice) {
|
||||||
|
const progress = notice.progress != null ? ` ${Math.round(notice.progress * 100)}%` : '';
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={notice.resultId}
|
||||||
|
className={`rounded px-2 py-1 text-[10px] ${notice.status === 'error' ? 'bg-red-950/30 text-red-300' : notice.status === 'cancelled' ? 'bg-amber-950/30 text-amber-300' : 'bg-secondary/50 text-muted-foreground'}`}
|
||||||
|
>
|
||||||
|
<span className="font-medium">{notice.algorithmName}</span>
|
||||||
|
<span> · {getStatusLabel(notice.status)}{progress}</span>
|
||||||
|
{notice.detail && <span> · {notice.detail}</span>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderFlowArtifactGroup(artifact: DemoFlowArtifactGroup, cardKey: string) {
|
||||||
|
const overlay = normalizeOverlay3D(artifact.overlay);
|
||||||
|
const inputPointCloudFileIds = numberArrayFromUnknown(artifact.inputPointCloudFileIds);
|
||||||
|
const inputCadFileIds = numberArrayFromUnknown(artifact.inputCadFileIds);
|
||||||
|
const groupKey = `${cardKey}:${artifact.resultId}`;
|
||||||
|
const groupExpanded = expandedFlowArtifactGroups.has(groupKey);
|
||||||
|
const extraExpanded = expandedFlowArtifactExtras.has(groupKey);
|
||||||
|
const artifactCount = artifact.files.length + artifact.images.length
|
||||||
|
+ (overlay.kind !== 'none' ? 1 : 0)
|
||||||
|
+ (artifact.extra && Object.keys(artifact.extra).length > 0 ? 1 : 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section key={artifact.resultId} className="industrial-card rounded overflow-hidden">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="w-full flex items-center gap-2 px-2 py-1.5 hover:bg-secondary/40 text-left"
|
||||||
|
onClick={() => toggleFlowArtifactGroup(groupKey)}
|
||||||
|
>
|
||||||
|
{groupExpanded ? <ChevronDown className="w-3 h-3 text-muted-foreground" /> : <ChevronRight className="w-3 h-3 text-muted-foreground" />}
|
||||||
|
<Cpu className="w-3.5 h-3.5 text-violet-400" />
|
||||||
|
<span className="text-[11px] font-medium text-foreground flex-1">{artifact.algorithmName}</span>
|
||||||
|
<span className="text-[9px] text-muted-foreground">{artifactCount} 项</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{groupExpanded && <div className="border-t border-border/40 p-2 space-y-2">
|
||||||
|
|
||||||
|
{artifact.files.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<div className="text-[10px] text-muted-foreground mb-1">报表下载</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{artifact.files.map((file) => (
|
||||||
|
<a
|
||||||
|
key={file.path}
|
||||||
|
href={`/api/files/read/analysis/${taskId}/${artifact.resultId}/${encodeURI(file.path)}`}
|
||||||
|
download
|
||||||
|
className="industrial-card p-1.5 flex items-center gap-1.5 hover:bg-secondary/40 text-[10px]"
|
||||||
|
title={file.path}
|
||||||
|
>
|
||||||
|
<FileSpreadsheet className="w-3 h-3 flex-shrink-0 text-green-500" />
|
||||||
|
<span className="truncate">{file.label}</span>
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{artifact.images.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<div className="text-[10px] text-muted-foreground mb-1">产物图片</div>
|
||||||
|
<div className="grid grid-cols-2 gap-1.5">
|
||||||
|
{artifact.images.map((image) => (
|
||||||
|
<a
|
||||||
|
key={image}
|
||||||
|
href={`/api/files/read/analysis/${taskId}/${artifact.resultId}/${encodeURI(image)}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="industrial-card p-1 flex items-center gap-1 hover:bg-secondary/40 text-[10px]"
|
||||||
|
title={image}
|
||||||
|
>
|
||||||
|
<ImageIcon className="w-3 h-3 flex-shrink-0" />
|
||||||
|
<span className="truncate">{image}</span>
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{overlay.kind !== 'none' && onViewPluginOverlay && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="industrial-btn industrial-btn-primary py-1 text-[10px] flex items-center gap-1 w-full justify-center"
|
||||||
|
onClick={() => onViewPluginOverlay({
|
||||||
|
taskId,
|
||||||
|
resultId: artifact.resultId,
|
||||||
|
overlay,
|
||||||
|
inputPointCloudFileIds,
|
||||||
|
inputCadFileIds,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Box className="w-3 h-3" />
|
||||||
|
在 3D 中查看叠加层
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{artifact.extra && Object.keys(artifact.extra).length > 0 && (
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex items-center gap-1 text-[10px] text-muted-foreground hover:text-foreground"
|
||||||
|
onClick={() => toggleFlowArtifactExtra(groupKey)}
|
||||||
|
>
|
||||||
|
{extraExpanded ? <ChevronDown className="w-3 h-3" /> : <ChevronRight className="w-3 h-3" />}
|
||||||
|
原始字段
|
||||||
|
</button>
|
||||||
|
{extraExpanded && (
|
||||||
|
<pre className="industrial-card p-2 mt-1 text-[9px] font-mono whitespace-pre-wrap break-all">
|
||||||
|
{JSON.stringify(artifact.extra, null, 2)}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function renderDemoFlowCard(group: Extract<AnalysisListItem, { kind: 'demoFlow' }>, variant: ResultListVariant) {
|
function renderDemoFlowCard(group: Extract<AnalysisListItem, { kind: 'demoFlow' }>, variant: ResultListVariant) {
|
||||||
const expanded = expandedFlowIds.has(group.runId);
|
const expanded = expandedFlowIds.has(group.runId);
|
||||||
const status = getDemoFlowStatus(group.results);
|
const status = getDemoFlowStatus(group.results);
|
||||||
const total = getDemoFlowTotal(group.results);
|
const total = getDemoFlowTotal(group.results);
|
||||||
const completed = group.results.filter((r) => r.status === 'completed').length;
|
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 createdAt = group.results.reduce((min, r) => Math.min(min, resultTimeMs(r)), Number.POSITIVE_INFINITY);
|
||||||
const runningItems = group.results
|
const cardKey = `${variant}:${group.runId}`;
|
||||||
.filter((r) => r.status === 'processing' || r.status === 'pending')
|
const resultsExpanded = !collapsedFlowResultCards.has(cardKey);
|
||||||
.slice(0, 2)
|
const artifactsExpanded = expandedFlowArtifactCards.has(cardKey);
|
||||||
.map((r) => {
|
const aggregated = aggregateDemoFlowResults(
|
||||||
const data = asRecord(r.resultData);
|
group.results,
|
||||||
const progress = numberFromUnknown(data?.progress);
|
(result) => getPluginDisplayName(result.pluginId, pluginNameById, getResultTitle(result, pluginNameById)),
|
||||||
return `${getResultTitle(r, pluginNameById)}${progress != null ? ` ${Math.round(progress * 100)}%` : ''}`;
|
{ includeAlgorithmGroups: SHOW_DEMO_FLOW_ALGORITHM_GROUP_ROWS },
|
||||||
});
|
);
|
||||||
|
const showMeasurementDetails = variant === 'maximized';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={group.runId} className={`industrial-card rounded overflow-hidden min-w-0 ${variant === 'maximized' ? 'xl:col-span-2' : ''}`}>
|
<div key={group.runId} className={`industrial-card rounded overflow-hidden min-w-0 ${variant === 'maximized' ? 'xl:col-span-2' : ''}`}>
|
||||||
@@ -719,13 +882,92 @@ export default function AnalysisPanel({ taskId, onReplay, onViewSmoothHeatmap, o
|
|||||||
</div>
|
</div>
|
||||||
{expanded && (
|
{expanded && (
|
||||||
<div className="px-2 pb-2 pt-1 border-t border-border/40 space-y-2">
|
<div className="px-2 pb-2 pt-1 border-t border-border/40 space-y-2">
|
||||||
{runningItems.length > 0 && (
|
<div className="industrial-card rounded overflow-hidden">
|
||||||
<div className="text-[10px] text-muted-foreground truncate">
|
<button
|
||||||
运行中:{runningItems.join('、')}
|
type="button"
|
||||||
</div>
|
className="w-full flex items-center gap-2 px-2 py-1.5 hover:bg-secondary/40 text-left"
|
||||||
)}
|
onClick={() => toggleFlowResultCard(cardKey)}
|
||||||
<div className={variant === 'maximized' ? 'grid grid-cols-1 lg:grid-cols-2 gap-2' : 'space-y-1.5'}>
|
>
|
||||||
{group.results.map((result) => renderResultCard(result))}
|
{resultsExpanded ? <ChevronDown className="w-3 h-3 text-muted-foreground" /> : <ChevronRight className="w-3 h-3 text-muted-foreground" />}
|
||||||
|
<Ruler className="w-3.5 h-3.5 text-green-400" />
|
||||||
|
<span className="text-xs flex-1">算法结果</span>
|
||||||
|
<span className="text-[10px] text-muted-foreground">{aggregated.measurementRows.length} 项</span>
|
||||||
|
</button>
|
||||||
|
{resultsExpanded && (
|
||||||
|
<div className="border-t border-border/40">
|
||||||
|
{aggregated.statusNotices.length > 0 && (
|
||||||
|
<div className="space-y-1 p-2 pb-1">
|
||||||
|
{aggregated.statusNotices.map(renderFlowStatusNotice)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{aggregated.tableRows.length === 0 ? (
|
||||||
|
<div className="text-[10px] text-muted-foreground text-center py-4">暂无测量结果</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-[10px] border-collapse">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-muted-foreground border-b border-border/60">
|
||||||
|
<th className="text-left px-2 py-1.5 font-normal">项目</th>
|
||||||
|
{showMeasurementDetails && <th className="text-right px-2 py-1.5 font-normal">值</th>}
|
||||||
|
{showMeasurementDetails && <th className="text-right px-2 py-1.5 font-normal">公差</th>}
|
||||||
|
<th className="text-right px-2 py-1.5 font-normal">结果</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{aggregated.tableRows.map((row) => row.kind === 'algorithm' ? (
|
||||||
|
<tr key={`group-${row.resultId}`} className="bg-secondary/40">
|
||||||
|
<td colSpan={showMeasurementDetails ? 4 : 2} className="px-2 py-1 font-medium">{row.algorithmName}</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
<tr key={`${row.resultId}-${row.measurementIndex}`} className="border-t border-border/40">
|
||||||
|
<td className="px-2 py-1 truncate max-w-[140px]" title={row.measurement.name}>{row.measurement.name}</td>
|
||||||
|
{showMeasurementDetails && <td className="px-2 text-right font-mono whitespace-nowrap">
|
||||||
|
{row.measurement.baseline != null
|
||||||
|
? `${formatValue(row.measurement.value)}/${row.measurement.baseline}mm`
|
||||||
|
: `${formatValue(row.measurement.value)} ${row.measurement.unit ?? ''}`}
|
||||||
|
</td>}
|
||||||
|
{showMeasurementDetails && <td className="px-2 text-right text-muted-foreground whitespace-nowrap">
|
||||||
|
{row.measurement.toleranceLabel != null
|
||||||
|
? row.measurement.toleranceLabel
|
||||||
|
: row.measurement.tolerance != null
|
||||||
|
? row.measurement.baseline != null
|
||||||
|
? `${row.measurement.tolerance}/${row.measurement.baseline}mm`
|
||||||
|
: `±${row.measurement.tolerance}`
|
||||||
|
: '—'}
|
||||||
|
</td>}
|
||||||
|
<td className="px-2 text-right whitespace-nowrap">
|
||||||
|
<span className={`px-1 py-0.5 rounded text-[9px] ${statusClass(row.measurement.status)}`}>
|
||||||
|
{row.measurement.status === 'info' ? '参考' : statusLabel(row.measurement)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="industrial-card rounded overflow-hidden">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="w-full flex items-center gap-2 px-2 py-1.5 hover:bg-secondary/40 text-left"
|
||||||
|
onClick={() => toggleFlowArtifactCard(cardKey)}
|
||||||
|
>
|
||||||
|
{artifactsExpanded ? <ChevronDown className="w-3 h-3 text-muted-foreground" /> : <ChevronRight className="w-3 h-3 text-muted-foreground" />}
|
||||||
|
<FileSpreadsheet className="w-3.5 h-3.5 text-cyan-400" />
|
||||||
|
<span className="text-xs flex-1">算法产物</span>
|
||||||
|
<span className="text-[10px] text-muted-foreground">{aggregated.artifactGroups.length} 组</span>
|
||||||
|
</button>
|
||||||
|
{artifactsExpanded && (
|
||||||
|
<div className="border-t border-border/40 p-2 space-y-3">
|
||||||
|
{aggregated.artifactGroups.length > 0
|
||||||
|
? aggregated.artifactGroups.map((artifact) => renderFlowArtifactGroup(artifact, cardKey))
|
||||||
|
: <div className="text-[10px] text-muted-foreground text-center py-3">暂无算法产物</div>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -739,7 +981,7 @@ export default function AnalysisPanel({ taskId, onReplay, onViewSmoothHeatmap, o
|
|||||||
{listItems.map((item) => (
|
{listItems.map((item) => (
|
||||||
item.kind === 'demoFlow'
|
item.kind === 'demoFlow'
|
||||||
? renderDemoFlowCard(item, variant)
|
? renderDemoFlowCard(item, variant)
|
||||||
: renderResultCard(item.result)
|
: renderResultCard(item.result, variant)
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -764,7 +1006,7 @@ export default function AnalysisPanel({ taskId, onReplay, onViewSmoothHeatmap, o
|
|||||||
误差分析当前后端仍是模拟统计结果,不作为正式入口展示。
|
误差分析当前后端仍是模拟统计结果,不作为正式入口展示。
|
||||||
装配检测仍有 legacy 后台任务,但暂不作为主流程按钮展示。
|
装配检测仍有 legacy 后台任务,但暂不作为主流程按钮展示。
|
||||||
偏差热力依赖 STL/OBJ,不适合当前以 STEP 为主的流程,暂时隐藏。 */}
|
偏差热力依赖 STL/OBJ,不适合当前以 STEP 为主的流程,暂时隐藏。 */}
|
||||||
{SHOW_LEGACY_ANALYSIS_ACTIONS && (
|
{canWrite && SHOW_LEGACY_ANALYSIS_ACTIONS && (
|
||||||
<>
|
<>
|
||||||
<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' : ''}`}
|
||||||
@@ -808,7 +1050,7 @@ export default function AnalysisPanel({ taskId, onReplay, onViewSmoothHeatmap, o
|
|||||||
|
|
||||||
{!isLoading && results.length === 0 && (
|
{!isLoading && results.length === 0 && (
|
||||||
<div className="text-xs text-muted-foreground text-center py-4">
|
<div className="text-xs text-muted-foreground text-center py-4">
|
||||||
暂无分析结果,点击运行分析
|
{canWrite ? '暂无分析结果,点击运行分析' : '暂无分析结果'}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Skeleton } from './ui/skeleton';
|
import { Skeleton } from "./ui/skeleton";
|
||||||
|
|
||||||
export function DashboardLayoutSkeleton() {
|
export function DashboardLayoutSkeleton() {
|
||||||
return (
|
return (
|
||||||
@@ -7,24 +7,24 @@ export function DashboardLayoutSkeleton() {
|
|||||||
<div className="w-[280px] border-r border-border bg-background p-4 space-y-6">
|
<div className="w-[280px] border-r border-border bg-background p-4 space-y-6">
|
||||||
{/* Logo area */}
|
{/* Logo area */}
|
||||||
<div className="flex items-center gap-3 px-2">
|
<div className="flex items-center gap-3 px-2">
|
||||||
<Skeleton className="h-8 w-8 rounded-md" />
|
<Skeleton className="bg-muted h-8 w-8 rounded-md" />
|
||||||
<Skeleton className="h-4 w-24" />
|
<Skeleton className="bg-muted h-4 w-24" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Menu items */}
|
{/* Menu items */}
|
||||||
<div className="space-y-2 px-2">
|
<div className="space-y-2 px-2">
|
||||||
<Skeleton className="h-10 w-full rounded-lg" />
|
<Skeleton className="bg-muted h-10 w-full rounded-lg" />
|
||||||
<Skeleton className="h-10 w-full rounded-lg" />
|
<Skeleton className="bg-muted h-10 w-full rounded-lg" />
|
||||||
<Skeleton className="h-10 w-full rounded-lg" />
|
<Skeleton className="bg-muted h-10 w-full rounded-lg" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* User profile area at bottom */}
|
{/* User profile area at bottom */}
|
||||||
<div className="absolute bottom-4 left-4 right-4">
|
<div className="absolute bottom-4 left-4 right-4">
|
||||||
<div className="flex items-center gap-3 px-1">
|
<div className="flex items-center gap-3 px-1">
|
||||||
<Skeleton className="h-9 w-9 rounded-full" />
|
<Skeleton className="bg-muted h-9 w-9 rounded-full" />
|
||||||
<div className="flex-1 space-y-2">
|
<div className="flex-1 space-y-2">
|
||||||
<Skeleton className="h-3 w-20" />
|
<Skeleton className="bg-muted h-3 w-20" />
|
||||||
<Skeleton className="h-2 w-32" />
|
<Skeleton className="bg-muted h-2 w-32" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -33,13 +33,13 @@ export function DashboardLayoutSkeleton() {
|
|||||||
{/* Main content skeleton */}
|
{/* Main content skeleton */}
|
||||||
<div className="flex-1 p-4 space-y-4">
|
<div className="flex-1 p-4 space-y-4">
|
||||||
{/* Content blocks */}
|
{/* Content blocks */}
|
||||||
<Skeleton className="h-12 w-48 rounded-lg" />
|
<Skeleton className="bg-muted h-12 w-48 rounded-lg" />
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
<Skeleton className="h-32 rounded-xl" />
|
<Skeleton className="bg-muted h-32 rounded-xl" />
|
||||||
<Skeleton className="h-32 rounded-xl" />
|
<Skeleton className="bg-muted h-32 rounded-xl" />
|
||||||
<Skeleton className="h-32 rounded-xl" />
|
<Skeleton className="bg-muted h-32 rounded-xl" />
|
||||||
</div>
|
</div>
|
||||||
<Skeleton className="h-64 rounded-xl" />
|
<Skeleton className="bg-muted h-64 rounded-xl" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
208
client/src/components/DemoFlowOverlay.tsx
Normal file
208
client/src/components/DemoFlowOverlay.tsx
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import { Check, Loader2, Circle, X, AlertTriangle, Cpu, Scan, Minimize2, Maximize2 } from 'lucide-react';
|
||||||
|
import type { DemoFlowState, DemoFlowStep, DemoFlowStepStatus } from '@/hooks/useDemoFlowState';
|
||||||
|
|
||||||
|
interface DemoFlowOverlayProps {
|
||||||
|
state: DemoFlowState;
|
||||||
|
pluginNameById?: Map<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusIcon(status: DemoFlowStepStatus) {
|
||||||
|
switch (status) {
|
||||||
|
case 'completed':
|
||||||
|
return (<div className="w-5 h-5 rounded-full bg-green-500/20 text-green-400 flex items-center justify-center">
|
||||||
|
<Check className="w-3 h-3" />
|
||||||
|
</div>);
|
||||||
|
case 'processing':
|
||||||
|
return (<div className="w-5 h-5 rounded-full bg-blue-500/20 text-blue-400 flex items-center justify-center">
|
||||||
|
<Loader2 className="w-3 h-3 animate-spin" />
|
||||||
|
</div>);
|
||||||
|
case 'pending':
|
||||||
|
return (<div className="w-5 h-5 rounded-full bg-slate-500/20 text-slate-400 flex items-center justify-center">
|
||||||
|
<Circle className="w-3 h-3" />
|
||||||
|
</div>);
|
||||||
|
case 'error':
|
||||||
|
return (<div className="w-5 h-5 rounded-full bg-red-500/20 text-red-400 flex items-center justify-center">
|
||||||
|
<X className="w-3 h-3" />
|
||||||
|
</div>);
|
||||||
|
case 'cancelled':
|
||||||
|
return (<div className="w-5 h-5 rounded-full bg-amber-500/20 text-amber-400 flex items-center justify-center">
|
||||||
|
<AlertTriangle className="w-3 h-3" />
|
||||||
|
</div>);
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusBorder(status: DemoFlowStepStatus) {
|
||||||
|
switch (status) {
|
||||||
|
case 'completed': return 'border-green-500/30 bg-green-500/5';
|
||||||
|
case 'processing': return 'border-blue-500/40 bg-blue-500/10 animate-pulse';
|
||||||
|
case 'pending': return 'border-slate-500/20 bg-slate-500/5';
|
||||||
|
case 'error': return 'border-red-500/30 bg-red-500/5';
|
||||||
|
case 'cancelled': return 'border-amber-500/30 bg-amber-500/5';
|
||||||
|
default: return 'border-border/40 bg-card/30';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stepLabel(step: DemoFlowStep, pluginNameById?: Map<string, string>): string {
|
||||||
|
if (step.role === 'registration') return '整车配准';
|
||||||
|
const name = step.pluginId ? (pluginNameById?.get(step.pluginId) ?? step.pluginId) : '测量算法';
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
function overallStatusLabel(status: DemoFlowState['overallStatus']): string {
|
||||||
|
switch (status) {
|
||||||
|
case 'processing': return '运行中';
|
||||||
|
case 'pending': return '排队中';
|
||||||
|
case 'completed': return '已完成';
|
||||||
|
case 'error': return '失败';
|
||||||
|
case 'cancelled': return '已停止';
|
||||||
|
default: return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DemoFlowOverlay({ state, pluginNameById }: DemoFlowOverlayProps) {
|
||||||
|
const [collapsedRunIds, setCollapsedRunIds] = useState<Set<string>>(() => new Set());
|
||||||
|
const title = useMemo(() => {
|
||||||
|
switch (state.overallStatus) {
|
||||||
|
case 'processing': return '一键全流程演示运行中';
|
||||||
|
case 'pending': return '一键全流程演示排队中';
|
||||||
|
case 'completed': return '一键全流程演示已完成';
|
||||||
|
case 'error': return '一键全流程演示失败';
|
||||||
|
case 'cancelled': return '一键全流程演示已停止';
|
||||||
|
default: return '一键全流程演示';
|
||||||
|
}
|
||||||
|
}, [state.overallStatus]);
|
||||||
|
|
||||||
|
if (!state.active && state.overallStatus === 'idle') return null;
|
||||||
|
|
||||||
|
const isCollapsed = state.runId != null && collapsedRunIds.has(state.runId);
|
||||||
|
const setCurrentRunCollapsed = (collapsed: boolean) => {
|
||||||
|
if (state.runId == null) return;
|
||||||
|
setCollapsedRunIds((previous) => {
|
||||||
|
const next = new Set(previous);
|
||||||
|
if (collapsed) next.add(state.runId!);
|
||||||
|
else next.delete(state.runId!);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isCollapsed) {
|
||||||
|
return (
|
||||||
|
<div className="absolute inset-0 z-30 pointer-events-none">
|
||||||
|
<div className="industrial-panel pointer-events-auto absolute top-9 right-3 flex w-64 max-w-[calc(100%-1.5rem)] items-center gap-3 border border-border/60 bg-[oklch(0.1_0.01_240)]/90 px-3 py-2.5 shadow-2xl backdrop-blur-sm">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="mb-1.5 flex items-center justify-between gap-3 text-[10px]">
|
||||||
|
<span className="truncate font-medium">一键全流程 · {overallStatusLabel(state.overallStatus)}</span>
|
||||||
|
<span className="flex-shrink-0 font-mono text-muted-foreground">{state.overallProgress}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-1 w-full overflow-hidden rounded-full bg-secondary">
|
||||||
|
<div
|
||||||
|
className="h-full bg-gradient-to-r from-blue-500 to-cyan-400 transition-all duration-500"
|
||||||
|
style={{ width: `${state.overallProgress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title="展开进度"
|
||||||
|
aria-label="展开进度"
|
||||||
|
className="flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-md text-muted-foreground outline-none transition-colors hover:bg-primary/15 hover:text-primary focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
onClick={() => setCurrentRunCollapsed(false)}
|
||||||
|
>
|
||||||
|
<Maximize2 className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="absolute inset-0 z-30 flex items-center justify-center pointer-events-none">
|
||||||
|
<div className="industrial-panel pointer-events-auto px-6 py-5 max-w-md w-full mx-4 shadow-2xl border border-border/60 bg-[oklch(0.1_0.01_240)]/90 backdrop-blur-sm">
|
||||||
|
<div className="flex items-center gap-3 mb-4">
|
||||||
|
<div className="w-8 h-8 rounded-lg bg-primary/20 flex items-center justify-center">
|
||||||
|
{state.overallStatus === 'processing' ? (
|
||||||
|
<Scan className="w-4 h-4 text-primary animate-pulse" />
|
||||||
|
) : (
|
||||||
|
<Cpu className="w-4 h-4 text-primary" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h3 className="text-sm font-medium truncate">{title}</h3>
|
||||||
|
<p className="text-[10px] text-muted-foreground">
|
||||||
|
{state.completed}/{state.total} 步骤已完成 · 总进度 {state.overallProgress}%
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title="收起进度"
|
||||||
|
aria-label="收起进度"
|
||||||
|
className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-md text-muted-foreground outline-none transition-colors hover:bg-primary/15 hover:text-primary focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
onClick={() => setCurrentRunCollapsed(true)}
|
||||||
|
>
|
||||||
|
<Minimize2 className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 总进度条 */}
|
||||||
|
<div className="h-1.5 w-full bg-secondary rounded-full overflow-hidden mb-5">
|
||||||
|
<div
|
||||||
|
className="h-full bg-gradient-to-r from-blue-500 to-cyan-400 transition-all duration-500"
|
||||||
|
style={{ width: `${state.overallProgress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 步骤条 */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
{state.steps.map((step, idx) => {
|
||||||
|
const isCurrent = state.currentStep?.id === step.id;
|
||||||
|
const label = stepLabel(step, pluginNameById);
|
||||||
|
const showProgress = step.status === 'processing' && step.progress != null;
|
||||||
|
return (
|
||||||
|
<div key={step.id} className="relative flex items-start gap-3">
|
||||||
|
{idx !== state.steps.length - 1 && (
|
||||||
|
<div className="absolute left-[9px] top-5 bottom-[-16px] w-px bg-border/40" />
|
||||||
|
)}
|
||||||
|
<div className="relative z-10 mt-0.5">{statusIcon(step.status)}</div>
|
||||||
|
<div className={`flex-1 rounded border p-2.5 ${statusBorder(step.status)} ${isCurrent ? 'ring-1 ring-primary/20' : ''}`}>
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="text-xs font-medium truncate">{label}</span>
|
||||||
|
<span className="text-[9px] text-muted-foreground flex-shrink-0">
|
||||||
|
{step.status === 'processing' ? '运行中'
|
||||||
|
: step.status === 'pending' ? '排队中'
|
||||||
|
: step.status === 'completed' ? '已完成'
|
||||||
|
: step.status === 'error' ? '失败'
|
||||||
|
: step.status === 'cancelled' ? '已停止'
|
||||||
|
: step.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{showProgress && (
|
||||||
|
<div className="mt-1.5">
|
||||||
|
<div className="h-1 w-full bg-secondary rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-blue-500 transition-all duration-500"
|
||||||
|
style={{ width: `${Math.round((step.progress ?? 0) * 100)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="text-[9px] text-muted-foreground mt-0.5 text-right">
|
||||||
|
{Math.round((step.progress ?? 0) * 100)}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{state.overallStatus === 'processing' && state.currentStep && (
|
||||||
|
<div className="mt-4 text-[10px] text-muted-foreground text-center">
|
||||||
|
当前正在执行:<span className="text-foreground/90 ml-1">{stepLabel(state.currentStep, pluginNameById)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -12,7 +12,7 @@ import { Box, ChevronDown, ChevronRight, FileSpreadsheet, Image as ImageIcon } f
|
|||||||
|
|
||||||
interface Measurement {
|
interface Measurement {
|
||||||
name: string;
|
name: string;
|
||||||
value: number;
|
value: number | null;
|
||||||
unit?: string;
|
unit?: string;
|
||||||
tolerance?: number;
|
tolerance?: number;
|
||||||
/** 原始公差文本,例如 +8.5/-3;存在时优先展示 */
|
/** 原始公差文本,例如 +8.5/-3;存在时优先展示 */
|
||||||
@@ -22,6 +22,7 @@ interface Measurement {
|
|||||||
status?: 'pass' | 'warning' | 'fail' | 'info';
|
status?: 'pass' | 'warning' | 'fail' | 'info';
|
||||||
/** 自定义通过/不通过文字(覆盖默认的"合格"/"超差") */
|
/** 自定义通过/不通过文字(覆盖默认的"合格"/"超差") */
|
||||||
passLabel?: string;
|
passLabel?: string;
|
||||||
|
warningLabel?: string;
|
||||||
failLabel?: string;
|
failLabel?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,6 +67,8 @@ interface Props {
|
|||||||
pluginId?: string;
|
pluginId?: string;
|
||||||
pluginName?: string;
|
pluginName?: string;
|
||||||
data: PluginResultLike;
|
data: PluginResultLike;
|
||||||
|
/** 右侧普通面板隐藏数值详情,最大化弹窗展示完整四列。 */
|
||||||
|
displayMode?: 'panel' | 'maximized';
|
||||||
/** 用户点击「在 3D 中查看」时回调,把叠加层信息传给 Home → PointCloudViewer */
|
/** 用户点击「在 3D 中查看」时回调,把叠加层信息传给 Home → PointCloudViewer */
|
||||||
onViewOverlay?: (overlay: {
|
onViewOverlay?: (overlay: {
|
||||||
taskId: number;
|
taskId: number;
|
||||||
@@ -76,7 +79,7 @@ interface Props {
|
|||||||
}) => void;
|
}) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function statusClass(s?: Measurement['status']) {
|
export function statusClass(s?: Measurement['status']) {
|
||||||
switch (s) {
|
switch (s) {
|
||||||
case 'pass':
|
case 'pass':
|
||||||
return 'bg-green-900/40 text-green-400';
|
return 'bg-green-900/40 text-green-400';
|
||||||
@@ -89,12 +92,12 @@ function statusClass(s?: Measurement['status']) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function statusLabel(m: Measurement) {
|
export function statusLabel(m: Measurement) {
|
||||||
switch (m.status) {
|
switch (m.status) {
|
||||||
case 'pass':
|
case 'pass':
|
||||||
return m.passLabel ?? '合格';
|
return m.passLabel ?? '合格';
|
||||||
case 'warning':
|
case 'warning':
|
||||||
return '警告';
|
return m.warningLabel ?? '警告';
|
||||||
case 'fail':
|
case 'fail':
|
||||||
return m.failLabel ?? '超差';
|
return m.failLabel ?? '超差';
|
||||||
default:
|
default:
|
||||||
@@ -102,20 +105,29 @@ function statusLabel(m: Measurement) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatValue(v: number): string {
|
export function isReferenceMeasurement(measurement: { status?: unknown }): boolean {
|
||||||
|
return measurement.status === 'info';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterVisibleMeasurements<T extends { status?: unknown }>(measurements: readonly T[]): T[] {
|
||||||
|
return measurements.filter((measurement) => !isReferenceMeasurement(measurement));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatValue(v: number | null): string {
|
||||||
|
if (v == null) return '—';
|
||||||
if (!Number.isFinite(v)) return '—';
|
if (!Number.isFinite(v)) return '—';
|
||||||
const abs = Math.abs(v);
|
const abs = Math.abs(v);
|
||||||
if (abs !== 0 && (abs < 0.001 || abs >= 1e6)) return v.toExponential(3);
|
if (abs !== 0 && (abs < 0.001 || abs >= 1e6)) return v.toExponential(3);
|
||||||
return v.toFixed(3);
|
return v.toFixed(3);
|
||||||
}
|
}
|
||||||
|
|
||||||
function numberArrayFromUnknown(value: unknown): number[] | undefined {
|
export function numberArrayFromUnknown(value: unknown): number[] | undefined {
|
||||||
if (!Array.isArray(value)) return undefined;
|
if (!Array.isArray(value)) return undefined;
|
||||||
const ids = value.filter((id): id is number => typeof id === 'number' && Number.isInteger(id));
|
const ids = value.filter((id): id is number => typeof id === 'number' && Number.isInteger(id));
|
||||||
return ids.length > 0 ? ids : undefined;
|
return ids.length > 0 ? ids : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeOverlay3D(overlay?: RawOverlay3D): Overlay3D {
|
export function normalizeOverlay3D(overlay?: RawOverlay3D): Overlay3D {
|
||||||
if (!overlay || overlay.kind === 'none') return { kind: 'none' };
|
if (!overlay || overlay.kind === 'none') return { kind: 'none' };
|
||||||
if (overlay.kind === 'ply') {
|
if (overlay.kind === 'ply') {
|
||||||
const files = Array.isArray(overlay.files)
|
const files = Array.isArray(overlay.files)
|
||||||
@@ -157,11 +169,13 @@ export default function GenericResultView({
|
|||||||
pluginId,
|
pluginId,
|
||||||
pluginName,
|
pluginName,
|
||||||
data,
|
data,
|
||||||
|
displayMode = 'panel',
|
||||||
onViewOverlay,
|
onViewOverlay,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const [showExtra, setShowExtra] = useState(false);
|
const [showExtra, setShowExtra] = useState(false);
|
||||||
const errorStats = data.errorStats ?? null;
|
const errorStats = data.errorStats ?? null;
|
||||||
const measurements = data.measurements ?? [];
|
const measurements = filterVisibleMeasurements(data.measurements ?? []);
|
||||||
|
const showMeasurementDetails = displayMode === 'maximized';
|
||||||
const images = data.images ?? [];
|
const images = data.images ?? [];
|
||||||
const files = (data.files ?? []).map((f) =>
|
const files = (data.files ?? []).map((f) =>
|
||||||
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)
|
||||||
@@ -206,8 +220,8 @@ export default function GenericResultView({
|
|||||||
<thead>
|
<thead>
|
||||||
<tr className="text-muted-foreground">
|
<tr className="text-muted-foreground">
|
||||||
<th className="text-left py-1 font-normal">项目</th>
|
<th className="text-left py-1 font-normal">项目</th>
|
||||||
<th className="text-right py-1 font-normal">值</th>
|
{showMeasurementDetails && <th className="text-right py-1 font-normal">值</th>}
|
||||||
<th className="text-right py-1 font-normal">公差</th>
|
{showMeasurementDetails && <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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -217,12 +231,12 @@ export default function GenericResultView({
|
|||||||
<td className="py-1 truncate max-w-[140px]" title={m.name}>
|
<td className="py-1 truncate max-w-[140px]" title={m.name}>
|
||||||
{m.name}
|
{m.name}
|
||||||
</td>
|
</td>
|
||||||
<td className="text-right font-mono">
|
{showMeasurementDetails && <td className="text-right font-mono">
|
||||||
{m.baseline != null
|
{m.baseline != null
|
||||||
? `${formatValue(m.value)}/${m.baseline}mm`
|
? `${formatValue(m.value)}/${m.baseline}mm`
|
||||||
: `${formatValue(m.value)} ${m.unit ?? ''}`}
|
: `${formatValue(m.value)} ${m.unit ?? ''}`}
|
||||||
</td>
|
</td>}
|
||||||
<td className="text-right text-muted-foreground">
|
{showMeasurementDetails && <td className="text-right text-muted-foreground">
|
||||||
{m.toleranceLabel != null
|
{m.toleranceLabel != null
|
||||||
? m.toleranceLabel
|
? m.toleranceLabel
|
||||||
: m.tolerance != null
|
: m.tolerance != null
|
||||||
@@ -230,7 +244,7 @@ export default function GenericResultView({
|
|||||||
? `${m.tolerance}/${m.baseline}mm`
|
? `${m.tolerance}/${m.baseline}mm`
|
||||||
: `±${m.tolerance}`
|
: `±${m.tolerance}`
|
||||||
: '—'}
|
: '—'}
|
||||||
</td>
|
</td>}
|
||||||
<td className="text-right">
|
<td className="text-right">
|
||||||
<span className={`px-1 py-0.5 rounded text-[9px] ${statusClass(m.status)}`}>
|
<span className={`px-1 py-0.5 rounded text-[9px] ${statusClass(m.status)}`}>
|
||||||
{m.status === 'info' ? '参考' : statusLabel(m)}
|
{m.status === 'info' ? '参考' : statusLabel(m)}
|
||||||
|
|||||||
@@ -44,9 +44,11 @@ function errorToColor(error: number, minError: number, maxError: number): THREE.
|
|||||||
function getHeatmapLegendBounds(
|
function getHeatmapLegendBounds(
|
||||||
points: Point[],
|
points: Point[],
|
||||||
mode: 'points' | 'smoothHeatmap',
|
mode: 'points' | 'smoothHeatmap',
|
||||||
): { min: number; max: number; midTick: string } {
|
): { min: number; max: number; midTick: string } | null {
|
||||||
if (mode === 'smoothHeatmap') {
|
if (mode === 'smoothHeatmap') {
|
||||||
const { minError, maxError } = displayErrorRangeSmoothHeatmap(points);
|
const pointsWithError = points.filter((point) => Number.isFinite(point.error));
|
||||||
|
if (pointsWithError.length === 0) return null;
|
||||||
|
const { minError, maxError } = displayErrorRangeSmoothHeatmap(pointsWithError);
|
||||||
const midTick =
|
const midTick =
|
||||||
minError < 0 && maxError > 0
|
minError < 0 && maxError > 0
|
||||||
? '0'
|
? '0'
|
||||||
@@ -56,10 +58,12 @@ function getHeatmapLegendBounds(
|
|||||||
let minE = Infinity;
|
let minE = Infinity;
|
||||||
let maxE = -Infinity;
|
let maxE = -Infinity;
|
||||||
for (let i = 0; i < points.length; i++) {
|
for (let i = 0; i < points.length; i++) {
|
||||||
const e = points[i].error ?? 0;
|
const e = points[i].error;
|
||||||
|
if (typeof e !== 'number' || !Number.isFinite(e)) continue;
|
||||||
if (e < minE) minE = e;
|
if (e < minE) minE = e;
|
||||||
if (e > maxE) maxE = e;
|
if (e > maxE) maxE = e;
|
||||||
}
|
}
|
||||||
|
if (!Number.isFinite(minE) || !Number.isFinite(maxE)) return null;
|
||||||
if (!(maxE > minE)) {
|
if (!(maxE > minE)) {
|
||||||
return { min: minE, max: maxE, midTick: minE.toFixed(3) };
|
return { min: minE, max: maxE, midTick: minE.toFixed(3) };
|
||||||
}
|
}
|
||||||
@@ -389,18 +393,21 @@ const PointCloudMesh = memo(function PointCloudMesh({
|
|||||||
|
|
||||||
const positions = new Float32Array(data.points.length * 3);
|
const positions = new Float32Array(data.points.length * 3);
|
||||||
const colors = new Float32Array(data.points.length * 3);
|
const colors = new Float32Array(data.points.length * 3);
|
||||||
|
const pointsWithError = data.points.filter((point) => Number.isFinite(point.error));
|
||||||
|
const hasErrorValues = pointsWithError.length > 0;
|
||||||
|
|
||||||
let minError: number;
|
let minError: number;
|
||||||
let maxError: number;
|
let maxError: number;
|
||||||
if (errorRangeMode === 'percentile') {
|
if (errorRangeMode === 'percentile' && hasErrorValues) {
|
||||||
const r = displayErrorRangeSmoothHeatmap(data.points);
|
const r = displayErrorRangeSmoothHeatmap(pointsWithError);
|
||||||
minError = r.minError;
|
minError = r.minError;
|
||||||
maxError = r.maxError;
|
maxError = r.maxError;
|
||||||
} else {
|
} else {
|
||||||
minError = Infinity;
|
minError = Infinity;
|
||||||
maxError = -Infinity;
|
maxError = -Infinity;
|
||||||
for (let i = 0; i < data.points.length; i++) {
|
for (let i = 0; i < data.points.length; i++) {
|
||||||
const err = data.points[i].error ?? 0;
|
const err = data.points[i].error;
|
||||||
|
if (typeof err !== 'number' || !Number.isFinite(err)) continue;
|
||||||
if (err < minError) minError = err;
|
if (err < minError) minError = err;
|
||||||
if (err > maxError) maxError = err;
|
if (err > maxError) maxError = err;
|
||||||
}
|
}
|
||||||
@@ -420,8 +427,8 @@ const PointCloudMesh = memo(function PointCloudMesh({
|
|||||||
colors[i3] = point.r;
|
colors[i3] = point.r;
|
||||||
colors[i3 + 1] = point.g;
|
colors[i3 + 1] = point.g;
|
||||||
colors[i3 + 2] = point.b;
|
colors[i3 + 2] = point.b;
|
||||||
} else if (showHeatmap) {
|
} else if (showHeatmap && hasErrorValues && Number.isFinite(point.error)) {
|
||||||
const color = errorToColor(point.error ?? 0, minError, maxError);
|
const color = errorToColor(point.error!, minError, maxError);
|
||||||
colors[i3] = color.r;
|
colors[i3] = color.r;
|
||||||
colors[i3 + 1] = color.g;
|
colors[i3 + 1] = color.g;
|
||||||
colors[i3 + 2] = color.b;
|
colors[i3 + 2] = color.b;
|
||||||
@@ -1165,6 +1172,82 @@ const AmbientParticles = memo(function AmbientParticles() {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 一键全流程演示扫描动画
|
||||||
|
interface DemoFlowScannerProps {
|
||||||
|
active: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DemoFlowScanner = memo(function DemoFlowScanner({ active }: DemoFlowScannerProps) {
|
||||||
|
const ringRef = useRef<THREE.Mesh>(null);
|
||||||
|
const sweepRef = useRef<THREE.Mesh>(null);
|
||||||
|
const particlesRef = useRef<THREE.Points>(null);
|
||||||
|
|
||||||
|
const ringGeometry = useMemo(() => new THREE.RingGeometry(3.5, 3.7, 64), []);
|
||||||
|
const sweepGeometry = useMemo(() => new THREE.PlaneGeometry(7.4, 7.4, 32, 32), []);
|
||||||
|
|
||||||
|
const { positions, colors } = useMemo(() => {
|
||||||
|
const count = 120;
|
||||||
|
const positions = new Float32Array(count * 3);
|
||||||
|
const colors = new Float32Array(count * 3);
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const angle = (i / count) * Math.PI * 2;
|
||||||
|
const radius = 3.6 + (Math.random() - 0.5) * 0.4;
|
||||||
|
positions[i * 3] = Math.cos(angle) * radius;
|
||||||
|
positions[i * 3 + 1] = (Math.random() - 0.5) * 0.3;
|
||||||
|
positions[i * 3 + 2] = Math.sin(angle) * radius;
|
||||||
|
|
||||||
|
colors[i * 3] = 0.2;
|
||||||
|
colors[i * 3 + 1] = 0.75;
|
||||||
|
colors[i * 3 + 2] = 1.0;
|
||||||
|
}
|
||||||
|
return { positions, colors };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useFrame((state) => {
|
||||||
|
if (!active) return;
|
||||||
|
const t = state.clock.elapsedTime;
|
||||||
|
if (ringRef.current) {
|
||||||
|
ringRef.current.rotation.z = -t * 0.3;
|
||||||
|
ringRef.current.rotation.x = Math.sin(t * 0.5) * 0.1;
|
||||||
|
}
|
||||||
|
if (sweepRef.current) {
|
||||||
|
sweepRef.current.position.y = Math.sin(t * 0.8) * 1.8;
|
||||||
|
const material = sweepRef.current.material as THREE.MeshBasicMaterial;
|
||||||
|
material.opacity = 0.12 + Math.sin(t * 1.6) * 0.06;
|
||||||
|
}
|
||||||
|
if (particlesRef.current) {
|
||||||
|
particlesRef.current.rotation.y = t * 0.15;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!active) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group>
|
||||||
|
{/* 旋转圆环 */}
|
||||||
|
<mesh ref={ringRef} rotation={[Math.PI / 2, 0, 0]} position={[0, 0, 0]}>
|
||||||
|
<ringGeometry args={[3.5, 3.7, 96]} />
|
||||||
|
<meshBasicMaterial color="#22d3ee" transparent opacity={0.35} side={THREE.DoubleSide} depthWrite={false} />
|
||||||
|
</mesh>
|
||||||
|
|
||||||
|
{/* 扫描平面 */}
|
||||||
|
<mesh ref={sweepRef} rotation={[-Math.PI / 2, 0, 0]} position={[0, 0, 0]}>
|
||||||
|
<planeGeometry args={[7.6, 7.6, 48, 48]} />
|
||||||
|
<meshBasicMaterial color="#38bdf8" transparent opacity={0.15} side={THREE.DoubleSide} depthWrite={false} />
|
||||||
|
</mesh>
|
||||||
|
|
||||||
|
{/* 粒子环 */}
|
||||||
|
<points ref={particlesRef}>
|
||||||
|
<bufferGeometry>
|
||||||
|
<bufferAttribute attach="attributes-position" args={[positions, 3]} />
|
||||||
|
<bufferAttribute attach="attributes-color" args={[colors, 3]} />
|
||||||
|
</bufferGeometry>
|
||||||
|
<pointsMaterial size={0.06} vertexColors transparent opacity={0.6} sizeAttenuation />
|
||||||
|
</points>
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
// 独立的 3D 场景组件,使用 memo 防止不必要的重渲染
|
// 独立的 3D 场景组件,使用 memo 防止不必要的重渲染
|
||||||
export type PointCloudRenderMode = 'points' | 'smoothHeatmap';
|
export type PointCloudRenderMode = 'points' | 'smoothHeatmap';
|
||||||
|
|
||||||
@@ -1202,6 +1285,8 @@ interface SceneContentProps {
|
|||||||
/** ROI 坐标拾取模式 */
|
/** ROI 坐标拾取模式 */
|
||||||
roiPickMode?: boolean;
|
roiPickMode?: boolean;
|
||||||
onRoiPointPicked?: (point: { x: number; y: number; z: number }) => void;
|
onRoiPointPicked?: (point: { x: number; y: number; z: number }) => void;
|
||||||
|
/** 一键全流程演示运行中:展示扫描动画 */
|
||||||
|
demoFlowActive?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SceneContent = memo(function SceneContent({
|
const SceneContent = memo(function SceneContent({
|
||||||
@@ -1228,6 +1313,7 @@ const SceneContent = memo(function SceneContent({
|
|||||||
plyOverlayData = null,
|
plyOverlayData = null,
|
||||||
roiPickMode = false,
|
roiPickMode = false,
|
||||||
onRoiPointPicked,
|
onRoiPointPicked,
|
||||||
|
demoFlowActive = false,
|
||||||
}: SceneContentProps) {
|
}: SceneContentProps) {
|
||||||
const pointPickActive = enablePointPicking && pointCloudRenderMode === 'points';
|
const pointPickActive = enablePointPicking && pointCloudRenderMode === 'points';
|
||||||
const [cadSurfaceGeometry, setCadSurfaceGeometry] = useState<THREE.BufferGeometry | null>(null);
|
const [cadSurfaceGeometry, setCadSurfaceGeometry] = useState<THREE.BufferGeometry | null>(null);
|
||||||
@@ -1378,6 +1464,9 @@ const SceneContent = memo(function SceneContent({
|
|||||||
{/* Grid */}
|
{/* Grid */}
|
||||||
<IndustrialGrid />
|
<IndustrialGrid />
|
||||||
|
|
||||||
|
{/* 一键全流程演示扫描动画 */}
|
||||||
|
<DemoFlowScanner active={demoFlowActive && !pointCloudData?.points?.length} />
|
||||||
|
|
||||||
{/* PLY 叠加层(kind=ply 插件结果) */}
|
{/* PLY 叠加层(kind=ply 插件结果) */}
|
||||||
{plyOverlayData && (
|
{plyOverlayData && (
|
||||||
<points>
|
<points>
|
||||||
@@ -1418,7 +1507,8 @@ const SceneContent = memo(function SceneContent({
|
|||||||
prevProps.cadHeatmapSuppressClient === nextProps.cadHeatmapSuppressClient &&
|
prevProps.cadHeatmapSuppressClient === nextProps.cadHeatmapSuppressClient &&
|
||||||
prevProps.dispRange === nextProps.dispRange &&
|
prevProps.dispRange === nextProps.dispRange &&
|
||||||
prevProps.plyOverlayData === nextProps.plyOverlayData &&
|
prevProps.plyOverlayData === nextProps.plyOverlayData &&
|
||||||
prevProps.roiPickMode === nextProps.roiPickMode;
|
prevProps.roiPickMode === nextProps.roiPickMode &&
|
||||||
|
prevProps.demoFlowActive === nextProps.demoFlowActive;
|
||||||
});
|
});
|
||||||
|
|
||||||
/** 默认向服务端请求的采样预算;非全量点云,仅用于预览(全量可达数十亿点) */
|
/** 默认向服务端请求的采样预算;非全量点云,仅用于预览(全量可达数十亿点) */
|
||||||
@@ -1535,6 +1625,7 @@ async function fetchBatchSampled(
|
|||||||
|
|
||||||
// 接收二进制,根据 X-Has-Color 决定每点 stride(4 floats 或 7 floats)
|
// 接收二进制,根据 X-Has-Color 决定每点 stride(4 floats 或 7 floats)
|
||||||
const hasColor = resp.headers.get('X-Has-Color') === '1';
|
const hasColor = resp.headers.get('X-Has-Color') === '1';
|
||||||
|
const hasError = resp.headers.get('X-Has-Error') === '1';
|
||||||
const stride = hasColor ? 7 : 4;
|
const stride = hasColor ? 7 : 4;
|
||||||
const arrayBuffer = await resp.arrayBuffer();
|
const arrayBuffer = await resp.arrayBuffer();
|
||||||
const floats = new Float32Array(arrayBuffer);
|
const floats = new Float32Array(arrayBuffer);
|
||||||
@@ -1551,7 +1642,8 @@ async function fetchBatchSampled(
|
|||||||
for (let i = 0; i < count; i++) {
|
for (let i = 0; i < count; i++) {
|
||||||
const base = i * stride;
|
const base = i * stride;
|
||||||
const x = floats[base], y = floats[base + 1], z = floats[base + 2], error = floats[base + 3];
|
const x = floats[base], y = floats[base + 1], z = floats[base + 2], error = floats[base + 3];
|
||||||
const pt: Point = { x, y, z, error };
|
const pt: Point = { x, y, z };
|
||||||
|
if (hasError && Number.isFinite(error)) pt.error = error;
|
||||||
if (hasColor) {
|
if (hasColor) {
|
||||||
pt.r = floats[base + 4];
|
pt.r = floats[base + 4];
|
||||||
pt.g = floats[base + 5];
|
pt.g = floats[base + 5];
|
||||||
@@ -1622,6 +1714,8 @@ interface PointCloudViewerProps {
|
|||||||
*/
|
*/
|
||||||
roiPickMode?: boolean;
|
roiPickMode?: boolean;
|
||||||
onRoiPointPicked?: (point: { x: number; y: number; z: number }) => void;
|
onRoiPointPicked?: (point: { x: number; y: number; z: number }) => void;
|
||||||
|
/** 一键全流程演示运行中:在 3D 场景内展示扫描动画 */
|
||||||
|
demoFlowActive?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PointCloudViewer({
|
export default function PointCloudViewer({
|
||||||
@@ -1651,6 +1745,7 @@ export default function PointCloudViewer({
|
|||||||
pluginOverlay = null,
|
pluginOverlay = null,
|
||||||
roiPickMode = false,
|
roiPickMode = false,
|
||||||
onRoiPointPicked,
|
onRoiPointPicked,
|
||||||
|
demoFlowActive = false,
|
||||||
}: PointCloudViewerProps) {
|
}: PointCloudViewerProps) {
|
||||||
// 点云数据状态
|
// 点云数据状态
|
||||||
const [loadedPointCloud, setLoadedPointCloud] = useState<PointCloudData | null>(null);
|
const [loadedPointCloud, setLoadedPointCloud] = useState<PointCloudData | null>(null);
|
||||||
@@ -1964,6 +2059,7 @@ export default function PointCloudViewer({
|
|||||||
|
|
||||||
const heatmapLegendBounds = useMemo(() => {
|
const heatmapLegendBounds = useMemo(() => {
|
||||||
if (!showHeatmap) return null;
|
if (!showHeatmap) return null;
|
||||||
|
if (pointCloudRenderMode !== 'smoothHeatmap') return null;
|
||||||
const hasVisibleCadHeatmap =
|
const hasVisibleCadHeatmap =
|
||||||
pointCloudRenderMode === 'smoothHeatmap' &&
|
pointCloudRenderMode === 'smoothHeatmap' &&
|
||||||
serverCadHeatmap.status === 'ready' &&
|
serverCadHeatmap.status === 'ready' &&
|
||||||
@@ -2120,6 +2216,7 @@ export default function PointCloudViewer({
|
|||||||
plyOverlayData={plyOverlayData}
|
plyOverlayData={plyOverlayData}
|
||||||
roiPickMode={roiPickMode}
|
roiPickMode={roiPickMode}
|
||||||
onRoiPointPicked={onRoiPointPicked}
|
onRoiPointPicked={onRoiPointPicked}
|
||||||
|
demoFlowActive={demoFlowActive}
|
||||||
/>
|
/>
|
||||||
</Canvas>
|
</Canvas>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -14,9 +14,19 @@ import {
|
|||||||
FileBox,
|
FileBox,
|
||||||
Eye,
|
Eye,
|
||||||
Library,
|
Library,
|
||||||
|
Pencil,
|
||||||
|
Info,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from './ui/dialog';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
import { useAuth } from '@/_core/hooks/useAuth';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
modelId: number;
|
modelId: number;
|
||||||
@@ -40,6 +50,50 @@ function formatSize(bytes: number) {
|
|||||||
return (bytes / 1e9).toFixed(2) + ' GB';
|
return (bytes / 1e9).toFixed(2) + ' GB';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function compactNumber(value: number, unit: number) {
|
||||||
|
const scaled = value / unit;
|
||||||
|
return Number.isInteger(scaled) ? String(scaled) : scaled.toFixed(1).replace(/\.0$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCompactPointCount(file: PointcloudMetadataFile) {
|
||||||
|
if (file.metadataStatus === 'error') return '解析失败';
|
||||||
|
if (file.metadataStatus !== 'ready' || file.pointCount == null) return '点数未知';
|
||||||
|
if (file.pointCount >= 1e8) return `${compactNumber(file.pointCount, 1e8)}亿点`;
|
||||||
|
if (file.pointCount >= 1e4) return `${compactNumber(file.pointCount, 1e4)}万点`;
|
||||||
|
return `${file.pointCount.toLocaleString('zh-CN')}点`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pointDataFormatLabels: Record<string, string> = {
|
||||||
|
ascii: 'ASCII',
|
||||||
|
binary_little_endian: '二进制(小端)',
|
||||||
|
binary_big_endian: '二进制(大端)',
|
||||||
|
};
|
||||||
|
|
||||||
|
type PointcloudMetadataFile = {
|
||||||
|
name: string;
|
||||||
|
fileSize: number;
|
||||||
|
metadataStatus: 'pending' | 'ready' | 'unsupported' | 'error';
|
||||||
|
pointCount: number | null;
|
||||||
|
pointAttributes: Array<{ name: string; type: string; bytes: number }> | null;
|
||||||
|
pointDataFormat: string | null;
|
||||||
|
bytesPerPoint: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PointcloudMetadataDetail = PointcloudMetadataFile & { sourceLabel: string };
|
||||||
|
|
||||||
|
function pointcloudFormat(name: string) {
|
||||||
|
const extension = name.split('.').pop();
|
||||||
|
return extension ? extension.toUpperCase() : '未知';
|
||||||
|
}
|
||||||
|
|
||||||
|
function unsupportedMetadataMessage(name: string) {
|
||||||
|
const extension = name.split('.').pop()?.toLowerCase();
|
||||||
|
if (extension === 'txt' || extension === 'xyz' || extension === 'pts') {
|
||||||
|
return '文本点云没有标准文件头,当前版本不执行完整扫描。';
|
||||||
|
}
|
||||||
|
return '当前版本暂不支持解析此格式的点云属性。';
|
||||||
|
}
|
||||||
|
|
||||||
function formatDateTime(value?: string | Date) {
|
function formatDateTime(value?: string | Date) {
|
||||||
if (!value) return '未知时间';
|
if (!value) return '未知时间';
|
||||||
const date = value instanceof Date ? value : new Date(value);
|
const date = value instanceof Date ? value : new Date(value);
|
||||||
@@ -47,6 +101,23 @@ function formatDateTime(value?: string | Date) {
|
|||||||
return date.toLocaleString('zh-CN', { hour12: false });
|
return date.toLocaleString('zh-CN', { hour12: false });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function pointcloudNameParts(name: string, filePath: string) {
|
||||||
|
const physicalName = filePath.split(/[\\/]/).pop() ?? filePath;
|
||||||
|
const dotIndex = physicalName.lastIndexOf('.');
|
||||||
|
const extension = dotIndex > 0 ? physicalName.slice(dotIndex) : '';
|
||||||
|
const baseName = extension && name.toLowerCase().endsWith(extension.toLowerCase())
|
||||||
|
? name.slice(0, -extension.length)
|
||||||
|
: name;
|
||||||
|
return { baseName, extension };
|
||||||
|
}
|
||||||
|
|
||||||
|
type PointcloudRenameTarget = {
|
||||||
|
scope: 'task' | 'model';
|
||||||
|
id: number;
|
||||||
|
baseName: string;
|
||||||
|
extension: string;
|
||||||
|
};
|
||||||
|
|
||||||
type InspectionTaskStatus = 'created' | 'pending' | 'processing' | 'completed' | 'error';
|
type InspectionTaskStatus = 'created' | 'pending' | 'processing' | 'completed' | 'error';
|
||||||
|
|
||||||
const taskStatusLabels: Record<InspectionTaskStatus, string> = {
|
const taskStatusLabels: Record<InspectionTaskStatus, string> = {
|
||||||
@@ -83,6 +154,7 @@ export default function TaskManager({
|
|||||||
onStartCreate,
|
onStartCreate,
|
||||||
onEndCreate,
|
onEndCreate,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
|
const { canWrite } = useAuth();
|
||||||
const utils = trpc.useUtils();
|
const utils = trpc.useUtils();
|
||||||
const [adding, setAdding] = useState(false);
|
const [adding, setAdding] = useState(false);
|
||||||
const [taskName, setTaskName] = useState('');
|
const [taskName, setTaskName] = useState('');
|
||||||
@@ -94,6 +166,9 @@ export default function TaskManager({
|
|||||||
const [pcLibraryOpen, setPcLibraryOpen] = useState(false);
|
const [pcLibraryOpen, setPcLibraryOpen] = useState(false);
|
||||||
const [selectedModelPcFileIds, setSelectedModelPcFileIds] = useState<Set<number>>(new Set());
|
const [selectedModelPcFileIds, setSelectedModelPcFileIds] = useState<Set<number>>(new Set());
|
||||||
const [uploadingModelPointcloud, setUploadingModelPointcloud] = useState(false);
|
const [uploadingModelPointcloud, setUploadingModelPointcloud] = useState(false);
|
||||||
|
const [renameTarget, setRenameTarget] = useState<PointcloudRenameTarget | null>(null);
|
||||||
|
const [renameError, setRenameError] = useState<string | null>(null);
|
||||||
|
const [metadataDetail, setMetadataDetail] = useState<PointcloudMetadataDetail | null>(null);
|
||||||
|
|
||||||
// ── 点云文件多选状态 ──────────────────────────────────────────────────────
|
// ── 点云文件多选状态 ──────────────────────────────────────────────────────
|
||||||
const [selectedFileIds, setSelectedFileIds] = useState<Set<number>>(new Set());
|
const [selectedFileIds, setSelectedFileIds] = useState<Set<number>>(new Set());
|
||||||
@@ -136,10 +211,36 @@ export default function TaskManager({
|
|||||||
const deletePcFile = trpc.pointcloudFile.delete.useMutation({
|
const deletePcFile = trpc.pointcloudFile.delete.useMutation({
|
||||||
onSuccess: () => utils.pointcloudFile.list.invalidate({ taskId: activeTaskId! }),
|
onSuccess: () => utils.pointcloudFile.list.invalidate({ taskId: activeTaskId! }),
|
||||||
});
|
});
|
||||||
|
const renamePcFile = trpc.pointcloudFile.rename.useMutation({
|
||||||
|
onSuccess: (updated) => {
|
||||||
|
if (activeTaskId != null) {
|
||||||
|
utils.pointcloudFile.list.setData({ taskId: activeTaskId }, (current) =>
|
||||||
|
current?.map((file) => (file.id === updated.id ? updated : file)),
|
||||||
|
);
|
||||||
|
void utils.pointcloudFile.list.invalidate({ taskId: activeTaskId });
|
||||||
|
}
|
||||||
|
setRenameTarget(null);
|
||||||
|
setRenameError(null);
|
||||||
|
toast.success('名称已修改');
|
||||||
|
},
|
||||||
|
onError: (error) => setRenameError(error.message || '修改名称失败'),
|
||||||
|
});
|
||||||
const { data: modelPcFiles = [] } = trpc.modelPointcloudFile.list.useQuery(
|
const { data: modelPcFiles = [] } = trpc.modelPointcloudFile.list.useQuery(
|
||||||
{ modelId },
|
{ modelId },
|
||||||
{ enabled: activeTaskId != null },
|
{ enabled: activeTaskId != null },
|
||||||
);
|
);
|
||||||
|
const renameModelPcFile = trpc.modelPointcloudFile.rename.useMutation({
|
||||||
|
onSuccess: (updated) => {
|
||||||
|
utils.modelPointcloudFile.list.setData({ modelId }, (current) =>
|
||||||
|
current?.map((file) => (file.id === updated.id ? updated : file)),
|
||||||
|
);
|
||||||
|
void utils.modelPointcloudFile.list.invalidate({ modelId });
|
||||||
|
setRenameTarget(null);
|
||||||
|
setRenameError(null);
|
||||||
|
toast.success('名称已修改');
|
||||||
|
},
|
||||||
|
onError: (error) => setRenameError(error.message || '修改名称失败'),
|
||||||
|
});
|
||||||
const linkPcFilesFromModel = trpc.pointcloudFile.linkFromModel.useMutation({
|
const linkPcFilesFromModel = trpc.pointcloudFile.linkFromModel.useMutation({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
if (activeTaskId != null) utils.pointcloudFile.list.invalidate({ taskId: activeTaskId });
|
if (activeTaskId != null) utils.pointcloudFile.list.invalidate({ taskId: activeTaskId });
|
||||||
@@ -181,6 +282,9 @@ export default function TaskManager({
|
|||||||
setSelectedModelCadFileIds(new Set());
|
setSelectedModelCadFileIds(new Set());
|
||||||
setPcLibraryOpen(false);
|
setPcLibraryOpen(false);
|
||||||
setCadLibraryOpen(false);
|
setCadLibraryOpen(false);
|
||||||
|
setRenameTarget(null);
|
||||||
|
setRenameError(null);
|
||||||
|
setMetadataDetail(null);
|
||||||
}, [activeTaskId]);
|
}, [activeTaskId]);
|
||||||
|
|
||||||
// 通知父组件点云选中变化
|
// 通知父组件点云选中变化
|
||||||
@@ -220,6 +324,34 @@ export default function TaskManager({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const renamePending = renamePcFile.isPending || renameModelPcFile.isPending;
|
||||||
|
|
||||||
|
function startRename(
|
||||||
|
scope: PointcloudRenameTarget['scope'],
|
||||||
|
file: { id: number; name: string; filePath: string },
|
||||||
|
) {
|
||||||
|
if (renamePending) return;
|
||||||
|
setRenameTarget({ scope, id: file.id, ...pointcloudNameParts(file.name, file.filePath) });
|
||||||
|
setRenameError(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelRename() {
|
||||||
|
if (renamePending) return;
|
||||||
|
setRenameTarget(null);
|
||||||
|
setRenameError(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function submitRename() {
|
||||||
|
if (!renameTarget || renamePending) return;
|
||||||
|
setRenameError(null);
|
||||||
|
const input = { id: renameTarget.id, baseName: renameTarget.baseName };
|
||||||
|
if (renameTarget.scope === 'task') {
|
||||||
|
renamePcFile.mutate(input);
|
||||||
|
} else {
|
||||||
|
renameModelPcFile.mutate(input);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function openUpload(taskId: number) {
|
function openUpload(taskId: number) {
|
||||||
pendingTaskId.current = taskId;
|
pendingTaskId.current = taskId;
|
||||||
fileRef.current?.click();
|
fileRef.current?.click();
|
||||||
@@ -396,7 +528,7 @@ export default function TaskManager({
|
|||||||
<span className="industrial-label flex items-center gap-1.5">
|
<span className="industrial-label flex items-center gap-1.5">
|
||||||
<ClipboardList className="w-3.5 h-3.5" /> 检测任务
|
<ClipboardList className="w-3.5 h-3.5" /> 检测任务
|
||||||
</span>
|
</span>
|
||||||
<button
|
{canWrite && <button
|
||||||
className="industrial-btn py-0.5 text-[10px] flex items-center gap-0.5"
|
className="industrial-btn py-0.5 text-[10px] flex items-center gap-0.5"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setAdding(true);
|
setAdding(true);
|
||||||
@@ -404,10 +536,10 @@ export default function TaskManager({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Plus className="w-3 h-3" /> 新建任务
|
<Plus className="w-3 h-3" /> 新建任务
|
||||||
</button>
|
</button>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{adding && (
|
{canWrite && adding && (
|
||||||
<div className="flex items-center gap-1 mb-1.5">
|
<div className="flex items-center gap-1 mb-1.5">
|
||||||
<input
|
<input
|
||||||
autoFocus
|
autoFocus
|
||||||
@@ -463,7 +595,7 @@ export default function TaskManager({
|
|||||||
>
|
>
|
||||||
{taskStatusLabels[status] ?? status}
|
{taskStatusLabels[status] ?? status}
|
||||||
</span>
|
</span>
|
||||||
<button
|
{canWrite && <button
|
||||||
className="hidden group-hover:block p-0.5 rounded hover:bg-secondary"
|
className="hidden group-hover:block p-0.5 rounded hover:bg-secondary"
|
||||||
title="上传点云文件"
|
title="上传点云文件"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
@@ -472,8 +604,8 @@ export default function TaskManager({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Upload className="w-3 h-3" />
|
<Upload className="w-3 h-3" />
|
||||||
</button>
|
</button>}
|
||||||
<button
|
{canWrite && <button
|
||||||
className="hidden group-hover:block p-0.5 rounded hover:bg-secondary text-destructive"
|
className="hidden group-hover:block p-0.5 rounded hover:bg-secondary text-destructive"
|
||||||
title="删除任务"
|
title="删除任务"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
@@ -482,7 +614,7 @@ export default function TaskManager({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Trash2 className="w-3 h-3" />
|
<Trash2 className="w-3 h-3" />
|
||||||
</button>
|
</button>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -490,7 +622,7 @@ export default function TaskManager({
|
|||||||
|
|
||||||
{tasks.length === 0 && (
|
{tasks.length === 0 && (
|
||||||
<div className="text-xs text-muted-foreground text-center py-4">
|
<div className="text-xs text-muted-foreground text-center py-4">
|
||||||
暂无任务,点击新建任务
|
{canWrite ? '暂无任务,点击新建任务' : '暂无任务'}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -526,20 +658,20 @@ export default function TaskManager({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<button
|
{canWrite && <button
|
||||||
className="industrial-btn flex-1 min-w-0 justify-center whitespace-nowrap py-0.5 text-[10px] flex items-center gap-0.5"
|
className="industrial-btn flex-1 min-w-0 justify-center whitespace-nowrap py-0.5 text-[10px] flex items-center gap-0.5"
|
||||||
onClick={() => openUpload(activeTaskId)}
|
onClick={() => openUpload(activeTaskId)}
|
||||||
>
|
>
|
||||||
<Upload className="w-3 h-3" /> 上传到任务
|
<Upload className="w-3 h-3" /> 上传到任务
|
||||||
</button>
|
</button>}
|
||||||
<button
|
{canWrite && <button
|
||||||
className={`industrial-btn flex-1 min-w-0 justify-center whitespace-nowrap py-0.5 text-[10px] flex items-center gap-0.5 ${
|
className={`industrial-btn flex-1 min-w-0 justify-center whitespace-nowrap py-0.5 text-[10px] flex items-center gap-0.5 ${
|
||||||
pcLibraryOpen ? 'text-primary border-primary/50' : ''
|
pcLibraryOpen ? 'text-primary border-primary/50' : ''
|
||||||
}`}
|
}`}
|
||||||
onClick={() => setPcLibraryOpen((open) => !open)}
|
onClick={() => setPcLibraryOpen((open) => !open)}
|
||||||
>
|
>
|
||||||
<Library className="w-3 h-3" /> 从车型库选择
|
<Library className="w-3 h-3" /> 从车型库选择
|
||||||
</button>
|
</button>}
|
||||||
{pcFiles.length > 0 && (
|
{pcFiles.length > 0 && (
|
||||||
<button
|
<button
|
||||||
className="industrial-btn industrial-btn-primary flex-1 min-w-0 justify-center whitespace-nowrap py-0.5 text-[10px] flex items-center gap-0.5"
|
className="industrial-btn industrial-btn-primary flex-1 min-w-0 justify-center whitespace-nowrap py-0.5 text-[10px] flex items-center gap-0.5"
|
||||||
@@ -568,7 +700,7 @@ export default function TaskManager({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{pcLibraryOpen && (
|
{canWrite && pcLibraryOpen && (
|
||||||
<div className="industrial-card rounded p-2 mb-2 space-y-2">
|
<div className="industrial-card rounded p-2 mb-2 space-y-2">
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<span className="text-[10px] text-muted-foreground">
|
<span className="text-[10px] text-muted-foreground">
|
||||||
@@ -584,29 +716,144 @@ export default function TaskManager({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1 max-h-40 overflow-auto">
|
<div className="space-y-1.5 max-h-56 overflow-auto pr-0.5">
|
||||||
{modelPcFiles.map((f) => {
|
{modelPcFiles.map((f) => {
|
||||||
const isChecked = selectedModelPcFileIds.has(f.id);
|
const isChecked = selectedModelPcFileIds.has(f.id);
|
||||||
|
const isEditing = renameTarget?.scope === 'model' && renameTarget.id === f.id;
|
||||||
return (
|
return (
|
||||||
<button
|
<div
|
||||||
key={f.id}
|
key={f.id}
|
||||||
type="button"
|
role="checkbox"
|
||||||
className={`w-full flex items-center gap-2 px-2 py-1.5 rounded text-xs text-left min-w-0 ${
|
aria-checked={isChecked}
|
||||||
isChecked ? 'bg-primary/10 border border-primary/30' : 'bg-secondary/40 hover:bg-secondary/70'
|
tabIndex={0}
|
||||||
|
className={`group relative w-full overflow-hidden rounded-lg border text-xs text-left min-w-0 cursor-pointer transition-all ${
|
||||||
|
isChecked
|
||||||
|
? 'border-cyan-400/55 bg-gradient-to-br from-cyan-500/15 via-cyan-500/5 to-blue-500/10 shadow-[0_0_16px_rgba(34,211,238,0.09)]'
|
||||||
|
: 'border-border/60 bg-gradient-to-br from-cyan-500/[0.07] via-background/40 to-blue-500/[0.06] hover:border-cyan-400/35 hover:from-cyan-500/10'
|
||||||
}`}
|
}`}
|
||||||
onClick={() => toggleModelPcFile(f.id)}
|
onClick={() => toggleModelPcFile(f.id)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (isEditing) return;
|
||||||
|
if (event.key === 'Enter' || event.key === ' ') {
|
||||||
|
event.preventDefault();
|
||||||
|
toggleModelPcFile(f.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{isChecked ? (
|
<div className="absolute inset-y-0 left-0 w-0.5 bg-gradient-to-b from-cyan-300 via-sky-400 to-blue-500" />
|
||||||
<CheckSquare className="w-3.5 h-3.5 text-primary flex-shrink-0" />
|
<div className="relative flex items-start gap-2 p-2 pl-2.5">
|
||||||
) : (
|
{isChecked ? (
|
||||||
<Square className="w-3.5 h-3.5 text-muted-foreground flex-shrink-0" />
|
<CheckSquare className="mt-0.5 w-3.5 h-3.5 text-cyan-300 flex-shrink-0" />
|
||||||
)}
|
) : (
|
||||||
<Cloud className="w-3.5 h-3.5 text-cyan-400 flex-shrink-0" />
|
<Square className="mt-0.5 w-3.5 h-3.5 text-muted-foreground flex-shrink-0" />
|
||||||
<span className="flex-1 min-w-0 truncate" title={f.name}>{f.name}</span>
|
)}
|
||||||
<span className="text-muted-foreground text-[10px] flex-shrink-0">
|
<Cloud className="mt-0.5 w-3.5 h-3.5 text-cyan-400 flex-shrink-0 drop-shadow-[0_0_5px_rgba(34,211,238,0.45)]" />
|
||||||
{formatSize(f.fileSize)}
|
{isEditing && renameTarget ? (
|
||||||
</span>
|
<div className="flex-1 min-w-0" onClick={(event) => event.stopPropagation()}>
|
||||||
</button>
|
<div className="flex items-center gap-1 min-w-0">
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
className="min-w-0 flex-1 rounded border border-primary/50 bg-background px-1.5 py-0.5 text-xs outline-none focus:border-primary"
|
||||||
|
value={renameTarget.baseName}
|
||||||
|
aria-label="车型库点云主文件名"
|
||||||
|
disabled={renamePending}
|
||||||
|
onChange={(event) => {
|
||||||
|
setRenameTarget({ ...renameTarget, baseName: event.target.value });
|
||||||
|
setRenameError(null);
|
||||||
|
}}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
event.preventDefault();
|
||||||
|
submitRename();
|
||||||
|
} else if (event.key === 'Escape') {
|
||||||
|
event.preventDefault();
|
||||||
|
cancelRename();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="text-muted-foreground flex-shrink-0">{renameTarget.extension}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="p-0.5 rounded text-green-400 hover:bg-secondary disabled:opacity-50"
|
||||||
|
title="确认修改"
|
||||||
|
aria-label="确认修改车型库点云名称"
|
||||||
|
disabled={renamePending}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
submitRename();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Check className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="p-0.5 rounded text-muted-foreground hover:bg-secondary disabled:opacity-50"
|
||||||
|
title="取消修改"
|
||||||
|
aria-label="取消修改车型库点云名称"
|
||||||
|
disabled={renamePending}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
cancelRename();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{renameError && <div className="mt-0.5 text-[10px] text-destructive">{renameError}</div>}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<span className="block min-h-4 line-clamp-2 break-all font-medium leading-4 text-foreground/90">
|
||||||
|
{f.name}
|
||||||
|
</span>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="right" className="max-w-sm break-all">{f.name}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
<div className="mt-1.5 grid grid-cols-[minmax(0,1fr)_auto] items-center gap-2">
|
||||||
|
<div className="flex min-w-0 items-center gap-1.5 overflow-hidden">
|
||||||
|
<span
|
||||||
|
className="flex-shrink-0 rounded-full border border-cyan-400/25 bg-cyan-400/10 px-1.5 py-0.5 text-[9px] font-medium text-cyan-200 whitespace-nowrap"
|
||||||
|
title={f.pointCount == null ? '点数未知' : `${f.pointCount.toLocaleString('zh-CN')} 点`}
|
||||||
|
>
|
||||||
|
{formatCompactPointCount(f)}
|
||||||
|
</span>
|
||||||
|
<span className="truncate text-[9px] text-muted-foreground whitespace-nowrap">
|
||||||
|
{formatSize(f.fileSize)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-shrink-0 items-center gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex h-5 w-5 items-center justify-center rounded-md border border-cyan-400/20 bg-cyan-400/10 text-cyan-300 hover:bg-cyan-400/20"
|
||||||
|
title="查看点云属性信息"
|
||||||
|
aria-label={`查看车型库点云属性:${f.name}`}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
setMetadataDetail({ ...f, sourceLabel: '车型点云库' });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Info className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex h-5 w-5 items-center justify-center rounded-md border border-transparent text-muted-foreground opacity-60 hover:border-border hover:bg-secondary hover:text-foreground group-hover:opacity-100"
|
||||||
|
title="修改名称"
|
||||||
|
aria-label={`修改车型库点云名称:${f.name}`}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
startRename('model', f);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Pencil className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{modelPcFiles.length === 0 && (
|
{modelPcFiles.length === 0 && (
|
||||||
@@ -647,6 +894,7 @@ export default function TaskManager({
|
|||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{pcFiles.map((f) => {
|
{pcFiles.map((f) => {
|
||||||
const isChecked = selectedFileIds.has(f.id);
|
const isChecked = selectedFileIds.has(f.id);
|
||||||
|
const isEditing = renameTarget?.scope === 'task' && renameTarget.id === f.id;
|
||||||
const meta = f as {
|
const meta = f as {
|
||||||
source?: string;
|
source?: string;
|
||||||
sourceResultId?: number | null;
|
sourceResultId?: number | null;
|
||||||
@@ -660,85 +908,194 @@ export default function TaskManager({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={f.id}
|
key={f.id}
|
||||||
className={`flex items-center gap-2 px-2 py-1.5 rounded text-xs group cursor-pointer
|
className={`group relative overflow-hidden rounded-lg border text-xs cursor-pointer transition-all
|
||||||
${isChecked ? 'bg-primary/10 border border-primary/30' : 'industrial-card'}`}
|
${isChecked
|
||||||
|
? 'border-cyan-400/55 bg-gradient-to-br from-cyan-500/15 via-cyan-500/5 to-blue-500/10 shadow-[0_0_18px_rgba(34,211,238,0.1)]'
|
||||||
|
: 'border-border/60 bg-gradient-to-br from-cyan-500/[0.06] via-background/50 to-blue-500/[0.05] hover:border-cyan-400/35 hover:from-cyan-500/10'}`}
|
||||||
onClick={() => toggleFile(f.id)}
|
onClick={() => toggleFile(f.id)}
|
||||||
>
|
>
|
||||||
{/* 复选框 */}
|
<div className="absolute inset-y-0 left-0 w-0.5 bg-gradient-to-b from-cyan-300 via-sky-400 to-blue-500" />
|
||||||
<button
|
<div className="relative flex items-start gap-2 p-2 pl-2.5">
|
||||||
type="button"
|
<button
|
||||||
className="flex-shrink-0"
|
type="button"
|
||||||
onClick={(e) => {
|
className="mt-0.5 flex-shrink-0"
|
||||||
e.stopPropagation();
|
onClick={(e) => {
|
||||||
toggleFile(f.id);
|
e.stopPropagation();
|
||||||
}}
|
toggleFile(f.id);
|
||||||
>
|
}}
|
||||||
{isChecked ? (
|
|
||||||
<CheckSquare className="w-3.5 h-3.5 text-primary" />
|
|
||||||
) : (
|
|
||||||
<Square className="w-3.5 h-3.5 text-muted-foreground" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
<Cloud
|
|
||||||
className={`w-3.5 h-3.5 flex-shrink-0 ${isChecked ? 'text-primary' : 'text-cyan-400'}`}
|
|
||||||
/>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<span className="flex-1 min-w-0 truncate">{f.name}</span>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent
|
|
||||||
side="right"
|
|
||||||
align="start"
|
|
||||||
className="max-w-[min(32rem,calc(100vw-2rem))]"
|
|
||||||
>
|
>
|
||||||
<div className="space-y-1 text-[11px]">
|
{isChecked ? (
|
||||||
<div className="break-all">{f.name}</div>
|
<CheckSquare className="w-3.5 h-3.5 text-cyan-300" />
|
||||||
{source === 'registered' && (
|
) : (
|
||||||
<>
|
<Square className="w-3.5 h-3.5 text-muted-foreground" />
|
||||||
<div>类型:已配准点云</div>
|
)}
|
||||||
{meta.sourceResultId != null && <div>结果编号:R{meta.sourceResultId}</div>}
|
</button>
|
||||||
<div>生成时间:{formatDateTime(meta.createdAt)}</div>
|
<Cloud
|
||||||
<div>
|
className={`mt-0.5 w-3.5 h-3.5 flex-shrink-0 drop-shadow-[0_0_5px_rgba(34,211,238,0.45)] ${isChecked ? 'text-cyan-300' : 'text-cyan-400'}`}
|
||||||
来源:
|
/>
|
||||||
{sourcePointcloud
|
{isEditing && renameTarget ? (
|
||||||
? sourcePointcloud.name
|
<div className="flex-1 min-w-0" onClick={(event) => event.stopPropagation()}>
|
||||||
: meta.sourcePointcloudFileId != null
|
<div className="flex items-center gap-1 min-w-0">
|
||||||
? `点云 #${meta.sourcePointcloudFileId}`
|
<input
|
||||||
: '旧记录未记录来源'}
|
autoFocus
|
||||||
</div>
|
className="min-w-0 flex-1 rounded border border-primary/50 bg-background px-1.5 py-0.5 text-xs outline-none focus:border-primary"
|
||||||
</>
|
value={renameTarget.baseName}
|
||||||
)}
|
aria-label="任务点云主文件名"
|
||||||
|
disabled={renamePending}
|
||||||
|
onChange={(event) => {
|
||||||
|
setRenameTarget({ ...renameTarget, baseName: event.target.value });
|
||||||
|
setRenameError(null);
|
||||||
|
}}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
event.preventDefault();
|
||||||
|
submitRename();
|
||||||
|
} else if (event.key === 'Escape') {
|
||||||
|
event.preventDefault();
|
||||||
|
cancelRename();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="text-muted-foreground flex-shrink-0">{renameTarget.extension}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="p-0.5 rounded text-green-400 hover:bg-secondary disabled:opacity-50"
|
||||||
|
title="确认修改"
|
||||||
|
aria-label="确认修改任务点云名称"
|
||||||
|
disabled={renamePending}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
submitRename();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Check className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="p-0.5 rounded text-muted-foreground hover:bg-secondary disabled:opacity-50"
|
||||||
|
title="取消修改"
|
||||||
|
aria-label="取消修改任务点云名称"
|
||||||
|
disabled={renamePending}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
cancelRename();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</TooltipContent>
|
{renameError && <div className="mt-0.5 text-[10px] text-destructive">{renameError}</div>}
|
||||||
</Tooltip>
|
</div>
|
||||||
{source === 'registered' && (
|
) : (
|
||||||
<span className="text-[9px] px-1 py-0.5 rounded bg-blue-900/40 text-blue-300 flex-shrink-0 whitespace-nowrap">
|
<div className="min-w-0 flex-1">
|
||||||
已配准
|
<div className="flex items-start gap-1.5">
|
||||||
</span>
|
<Tooltip>
|
||||||
)}
|
<TooltipTrigger asChild>
|
||||||
{source === 'registered' && meta.sourceResultId != null && (
|
<span className="min-w-0 flex-1 line-clamp-2 break-all font-medium leading-4 text-foreground/90">
|
||||||
<span className="text-[9px] px-1 py-0.5 rounded bg-sky-500/15 text-sky-300 border border-sky-500/30 flex-shrink-0 whitespace-nowrap">
|
{f.name}
|
||||||
R{meta.sourceResultId}
|
</span>
|
||||||
</span>
|
</TooltipTrigger>
|
||||||
)}
|
<TooltipContent
|
||||||
{source === 'model_library' && (
|
side="right"
|
||||||
<span className="text-[9px] px-1 py-0.5 rounded bg-amber-500/15 text-amber-300 border border-amber-500/30 flex-shrink-0 whitespace-nowrap">
|
align="start"
|
||||||
车型库
|
className="max-w-[min(32rem,calc(100vw-2rem))]"
|
||||||
</span>
|
>
|
||||||
)}
|
<div className="space-y-1 text-[11px]">
|
||||||
<span className="text-muted-foreground text-[10px] flex-shrink-0">
|
<div className="break-all">{f.name}</div>
|
||||||
{formatSize(f.fileSize)}
|
{source === 'registered' && (
|
||||||
</span>
|
<>
|
||||||
<button
|
<div>类型:已配准点云</div>
|
||||||
className="hidden group-hover:block p-0.5 rounded hover:bg-secondary text-destructive flex-shrink-0"
|
{meta.sourceResultId != null && <div>结果编号:R{meta.sourceResultId}</div>}
|
||||||
onClick={(e) => {
|
<div>生成时间:{formatDateTime(meta.createdAt)}</div>
|
||||||
e.stopPropagation();
|
<div>
|
||||||
if (confirm(`删除点云文件「${f.name}」?`))
|
来源:
|
||||||
deletePcFile.mutate({ id: f.id });
|
{sourcePointcloud
|
||||||
}}
|
? sourcePointcloud.name
|
||||||
>
|
: meta.sourcePointcloudFileId != null
|
||||||
<Trash2 className="w-3 h-3" />
|
? `点云 #${meta.sourcePointcloudFileId}`
|
||||||
</button>
|
: '旧记录未记录来源'}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
{source === 'registered' && (
|
||||||
|
<span
|
||||||
|
className="mt-px flex-shrink-0 rounded border border-blue-400/20 bg-blue-500/10 px-1 py-0.5 text-[8px] leading-3 text-blue-200"
|
||||||
|
title="已配准点云"
|
||||||
|
>
|
||||||
|
{meta.sourceResultId != null ? `R${meta.sourceResultId}` : '配准'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{source === 'model_library' && (
|
||||||
|
<span className="mt-px flex-shrink-0 rounded border border-amber-400/20 bg-amber-500/10 px-1 py-0.5 text-[8px] leading-3 text-amber-200">
|
||||||
|
车型库
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1.5 grid grid-cols-[minmax(0,1fr)_auto] items-center gap-2">
|
||||||
|
<div className="flex min-w-0 items-center gap-1.5 overflow-hidden">
|
||||||
|
<span
|
||||||
|
className="flex-shrink-0 rounded-full border border-cyan-400/25 bg-cyan-400/10 px-1.5 py-0.5 text-[9px] font-medium text-cyan-200 whitespace-nowrap"
|
||||||
|
title={f.pointCount == null ? '点数未知' : `${f.pointCount.toLocaleString('zh-CN')} 点`}
|
||||||
|
>
|
||||||
|
{formatCompactPointCount(f)}
|
||||||
|
</span>
|
||||||
|
<span className="truncate text-[9px] text-muted-foreground whitespace-nowrap">
|
||||||
|
{formatSize(f.fileSize)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-shrink-0 items-center gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex h-5 w-5 items-center justify-center rounded-md border border-cyan-400/20 bg-cyan-400/10 text-cyan-300 hover:bg-cyan-400/20"
|
||||||
|
title="查看点云属性信息"
|
||||||
|
aria-label={`查看任务点云属性:${f.name}`}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
const sourceLabel = source === 'registered'
|
||||||
|
? '算法生成(配准点云)'
|
||||||
|
: source === 'model_library'
|
||||||
|
? '车型点云库'
|
||||||
|
: '用户上传';
|
||||||
|
setMetadataDetail({ ...f, sourceLabel });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Info className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
{canWrite && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex h-5 w-5 items-center justify-center rounded-md border border-transparent text-muted-foreground opacity-60 hover:border-border hover:bg-secondary hover:text-foreground group-hover:opacity-100"
|
||||||
|
title="修改名称"
|
||||||
|
aria-label={`修改任务点云名称:${f.name}`}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
startRename('task', f);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Pencil className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{canWrite && <button
|
||||||
|
type="button"
|
||||||
|
className="flex h-5 w-5 items-center justify-center rounded-md border border-transparent text-destructive/70 opacity-60 hover:border-destructive/20 hover:bg-destructive/10 hover:text-destructive group-hover:opacity-100"
|
||||||
|
title="删除点云文件"
|
||||||
|
aria-label={`删除任务点云:${f.name}`}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (confirm(`删除点云文件「${f.name}」?`))
|
||||||
|
deletePcFile.mutate({ id: f.id });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3 h-3" />
|
||||||
|
</button>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -783,20 +1140,20 @@ export default function TaskManager({
|
|||||||
全选
|
全选
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button
|
{canWrite && <button
|
||||||
className="industrial-btn flex-1 min-w-0 justify-center whitespace-nowrap py-0.5 text-[10px] flex items-center gap-0.5"
|
className="industrial-btn flex-1 min-w-0 justify-center whitespace-nowrap py-0.5 text-[10px] flex items-center gap-0.5"
|
||||||
onClick={() => cadFileRef.current?.click()}
|
onClick={() => cadFileRef.current?.click()}
|
||||||
>
|
>
|
||||||
<Upload className="w-3 h-3" /> 上传到任务
|
<Upload className="w-3 h-3" /> 上传到任务
|
||||||
</button>
|
</button>}
|
||||||
<button
|
{canWrite && <button
|
||||||
className={`industrial-btn flex-1 min-w-0 justify-center whitespace-nowrap py-0.5 text-[10px] flex items-center gap-0.5 ${
|
className={`industrial-btn flex-1 min-w-0 justify-center whitespace-nowrap py-0.5 text-[10px] flex items-center gap-0.5 ${
|
||||||
cadLibraryOpen ? 'text-primary border-primary/50' : ''
|
cadLibraryOpen ? 'text-primary border-primary/50' : ''
|
||||||
}`}
|
}`}
|
||||||
onClick={() => setCadLibraryOpen((open) => !open)}
|
onClick={() => setCadLibraryOpen((open) => !open)}
|
||||||
>
|
>
|
||||||
<Library className="w-3 h-3" /> 从车型库选择
|
<Library className="w-3 h-3" /> 从车型库选择
|
||||||
</button>
|
</button>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -811,7 +1168,7 @@ export default function TaskManager({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{cadLibraryOpen && (
|
{canWrite && cadLibraryOpen && (
|
||||||
<div className="industrial-card rounded p-2 mb-2 space-y-2">
|
<div className="industrial-card rounded p-2 mb-2 space-y-2">
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<span className="text-[10px] text-muted-foreground">
|
<span className="text-[10px] text-muted-foreground">
|
||||||
@@ -955,7 +1312,7 @@ export default function TaskManager({
|
|||||||
<Eye className="w-3 h-3 text-primary" />
|
<Eye className="w-3 h-3 text-primary" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button
|
{canWrite && <button
|
||||||
className="hidden group-hover:block p-0.5 rounded hover:bg-secondary text-destructive flex-shrink-0"
|
className="hidden group-hover:block p-0.5 rounded hover:bg-secondary text-destructive flex-shrink-0"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -964,7 +1321,7 @@ export default function TaskManager({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Trash2 className="w-3 h-3" />
|
<Trash2 className="w-3 h-3" />
|
||||||
</button>
|
</button>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -1003,6 +1360,105 @@ export default function TaskManager({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<Dialog
|
||||||
|
open={metadataDetail !== null}
|
||||||
|
onOpenChange={(open) => { if (!open) setMetadataDetail(null); }}
|
||||||
|
>
|
||||||
|
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-2xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>点云属性信息</DialogTitle>
|
||||||
|
<DialogDescription>文件头中记录的点数、编码与每点字段。</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
{metadataDetail && (
|
||||||
|
<div className="space-y-4 text-sm">
|
||||||
|
<dl className="grid grid-cols-1 gap-x-6 gap-y-3 sm:grid-cols-2">
|
||||||
|
<div className="sm:col-span-2 min-w-0">
|
||||||
|
<dt className="text-xs text-muted-foreground">文件名</dt>
|
||||||
|
<dd className="mt-0.5 break-all">{metadataDetail.name}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-xs text-muted-foreground">来源</dt>
|
||||||
|
<dd className="mt-0.5">{metadataDetail.sourceLabel}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-xs text-muted-foreground">文件大小</dt>
|
||||||
|
<dd className="mt-0.5">{formatSize(metadataDetail.fileSize)}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-xs text-muted-foreground">格式</dt>
|
||||||
|
<dd className="mt-0.5">{pointcloudFormat(metadataDetail.name)}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-xs text-muted-foreground">编码</dt>
|
||||||
|
<dd className="mt-0.5">
|
||||||
|
{metadataDetail.pointDataFormat
|
||||||
|
? pointDataFormatLabels[metadataDetail.pointDataFormat] ?? metadataDetail.pointDataFormat
|
||||||
|
: '未知'}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-xs text-muted-foreground">精确点数</dt>
|
||||||
|
<dd className="mt-0.5 font-mono">
|
||||||
|
{metadataDetail.metadataStatus === 'ready' && metadataDetail.pointCount != null
|
||||||
|
? metadataDetail.pointCount.toLocaleString('en-US')
|
||||||
|
: '点数未知'}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-xs text-muted-foreground">每点字节数</dt>
|
||||||
|
<dd className="mt-0.5">
|
||||||
|
{metadataDetail.bytesPerPoint == null ? '未知' : `${metadataDetail.bytesPerPoint} 字节`}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
{metadataDetail.metadataStatus === 'ready' && metadataDetail.pointAttributes && (
|
||||||
|
<div className="overflow-hidden rounded border">
|
||||||
|
<table className="w-full text-left text-xs">
|
||||||
|
<thead className="bg-secondary/70 text-muted-foreground">
|
||||||
|
<tr>
|
||||||
|
<th className="px-3 py-2 font-medium">属性名称</th>
|
||||||
|
<th className="px-3 py-2 font-medium">数据类型</th>
|
||||||
|
<th className="px-3 py-2 text-right font-medium">字节数</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{metadataDetail.pointAttributes.map((attribute, index) => (
|
||||||
|
<tr key={`${attribute.name}-${index}`} className="border-t">
|
||||||
|
<td className="px-3 py-2 font-mono">{attribute.name}</td>
|
||||||
|
<td className="px-3 py-2 font-mono">{attribute.type}</td>
|
||||||
|
<td className="px-3 py-2 text-right">{attribute.bytes}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{metadataDetail.metadataStatus === 'unsupported' && (
|
||||||
|
<div className="rounded border border-amber-500/30 bg-amber-500/10 p-3 text-amber-200">
|
||||||
|
<div className="font-medium">点数未知</div>
|
||||||
|
<div className="mt-1 text-xs text-amber-100/80">
|
||||||
|
{unsupportedMetadataMessage(metadataDetail.name)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{metadataDetail.metadataStatus === 'error' && (
|
||||||
|
<div className="rounded border border-red-500/30 bg-red-500/10 p-3 text-red-200">
|
||||||
|
<div className="font-medium">属性解析失败</div>
|
||||||
|
<div className="mt-1 text-xs text-red-100/80">无法从该文件的 PLY 文件头读取属性信息。</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{metadataDetail.metadataStatus === 'pending' && (
|
||||||
|
<div className="rounded border border-blue-500/30 bg-blue-500/10 p-3 text-blue-200">
|
||||||
|
属性信息正在解析,请稍后刷新。
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
{/* 隐藏的文件 input,支持多选 */}
|
{/* 隐藏的文件 input,支持多选 */}
|
||||||
<input
|
<input
|
||||||
ref={fileRef}
|
ref={fileRef}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
Check,
|
Check,
|
||||||
X,
|
X,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
import { useAuth } from '@/_core/hooks/useAuth';
|
||||||
|
|
||||||
export type TreeSelection = {
|
export type TreeSelection = {
|
||||||
seriesId: number | null;
|
seriesId: number | null;
|
||||||
@@ -25,9 +26,11 @@ interface Props {
|
|||||||
function EditableText({
|
function EditableText({
|
||||||
value,
|
value,
|
||||||
onSave,
|
onSave,
|
||||||
|
canEdit,
|
||||||
}: {
|
}: {
|
||||||
value: string;
|
value: string;
|
||||||
onSave: (v: string) => void;
|
onSave: (v: string) => void;
|
||||||
|
canEdit: boolean;
|
||||||
}) {
|
}) {
|
||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
const [draft, setDraft] = useState(value);
|
const [draft, setDraft] = useState(value);
|
||||||
@@ -35,7 +38,7 @@ function EditableText({
|
|||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className="flex-1 cursor-pointer hover:text-foreground truncate"
|
className="flex-1 cursor-pointer hover:text-foreground truncate"
|
||||||
onDoubleClick={() => { setDraft(value); setEditing(true); }}
|
onDoubleClick={() => { if (canEdit) { setDraft(value); setEditing(true); } }}
|
||||||
>
|
>
|
||||||
{value}
|
{value}
|
||||||
</span>
|
</span>
|
||||||
@@ -60,6 +63,7 @@ function EditableText({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function VehicleTree({ selection, onSelect }: Props) {
|
export default function VehicleTree({ selection, onSelect }: Props) {
|
||||||
|
const { canWrite } = useAuth();
|
||||||
const utils = trpc.useUtils();
|
const utils = trpc.useUtils();
|
||||||
const [expandedSeries, setExpandedSeries] = useState<Set<number>>(new Set());
|
const [expandedSeries, setExpandedSeries] = useState<Set<number>>(new Set());
|
||||||
const [newSeriesName, setNewSeriesName] = useState('');
|
const [newSeriesName, setNewSeriesName] = useState('');
|
||||||
@@ -113,15 +117,15 @@ export default function VehicleTree({ selection, onSelect }: Props) {
|
|||||||
<div className="industrial-panel p-2 space-y-1">
|
<div className="industrial-panel p-2 space-y-1">
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<span className="industrial-label">车系 / 车型 / 任务</span>
|
<span className="industrial-label">车系 / 车型 / 任务</span>
|
||||||
<button
|
{canWrite && <button
|
||||||
className="industrial-btn py-0.5 text-[10px] flex items-center gap-0.5"
|
className="industrial-btn py-0.5 text-[10px] flex items-center gap-0.5"
|
||||||
onClick={() => setAddingSeries(true)}
|
onClick={() => setAddingSeries(true)}
|
||||||
>
|
>
|
||||||
<Plus className="w-3 h-3" /> 车系
|
<Plus className="w-3 h-3" /> 车系
|
||||||
</button>
|
</button>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{addingSeries && (
|
{canWrite && addingSeries && (
|
||||||
<div className="flex items-center gap-1 pl-1">
|
<div className="flex items-center gap-1 pl-1">
|
||||||
<input
|
<input
|
||||||
autoFocus
|
autoFocus
|
||||||
@@ -176,9 +180,10 @@ export default function VehicleTree({ selection, onSelect }: Props) {
|
|||||||
<Train className="w-3 h-3 flex-shrink-0 text-muted-foreground" />
|
<Train className="w-3 h-3 flex-shrink-0 text-muted-foreground" />
|
||||||
<EditableText
|
<EditableText
|
||||||
value={series.name}
|
value={series.name}
|
||||||
|
canEdit={canWrite}
|
||||||
onSave={name => updateSeries.mutate({ id: series.id, name })}
|
onSave={name => updateSeries.mutate({ id: series.id, name })}
|
||||||
/>
|
/>
|
||||||
<span className="hidden group-hover:flex items-center gap-0.5 flex-shrink-0">
|
{canWrite && <span className="hidden group-hover:flex items-center gap-0.5 flex-shrink-0">
|
||||||
<button
|
<button
|
||||||
className="p-0.5 rounded hover:bg-secondary"
|
className="p-0.5 rounded hover:bg-secondary"
|
||||||
title="新增车型"
|
title="新增车型"
|
||||||
@@ -196,11 +201,11 @@ export default function VehicleTree({ selection, onSelect }: Props) {
|
|||||||
>
|
>
|
||||||
<Trash2 className="w-3 h-3" />
|
<Trash2 className="w-3 h-3" />
|
||||||
</button>
|
</button>
|
||||||
</span>
|
</span>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 新增车型输入 */}
|
{/* 新增车型输入 */}
|
||||||
{addingModelFor === series.id && (
|
{canWrite && addingModelFor === series.id && (
|
||||||
<div className="flex items-center gap-1 pl-7 pr-1 py-0.5">
|
<div className="flex items-center gap-1 pl-7 pr-1 py-0.5">
|
||||||
<input
|
<input
|
||||||
autoFocus
|
autoFocus
|
||||||
@@ -258,9 +263,10 @@ export default function VehicleTree({ selection, onSelect }: Props) {
|
|||||||
<Box className="w-3 h-3 flex-shrink-0 text-blue-400" />
|
<Box className="w-3 h-3 flex-shrink-0 text-blue-400" />
|
||||||
<EditableText
|
<EditableText
|
||||||
value={model.name}
|
value={model.name}
|
||||||
|
canEdit={canWrite}
|
||||||
onSave={name => updateModel.mutate({ id: model.id, name })}
|
onSave={name => updateModel.mutate({ id: model.id, name })}
|
||||||
/>
|
/>
|
||||||
<span className="hidden group-hover:flex items-center gap-0.5 flex-shrink-0">
|
{canWrite && <span className="hidden group-hover:flex items-center gap-0.5 flex-shrink-0">
|
||||||
<button
|
<button
|
||||||
className="p-0.5 rounded hover:bg-secondary text-destructive"
|
className="p-0.5 rounded hover:bg-secondary text-destructive"
|
||||||
title="删除车型"
|
title="删除车型"
|
||||||
@@ -271,7 +277,7 @@ export default function VehicleTree({ selection, onSelect }: Props) {
|
|||||||
>
|
>
|
||||||
<Trash2 className="w-3 h-3" />
|
<Trash2 className="w-3 h-3" />
|
||||||
</button>
|
</button>
|
||||||
</span>
|
</span>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@@ -283,7 +289,7 @@ export default function VehicleTree({ selection, onSelect }: Props) {
|
|||||||
|
|
||||||
{seriesList.length === 0 && (
|
{seriesList.length === 0 && (
|
||||||
<div className="text-center py-8 text-xs text-muted-foreground">
|
<div className="text-center py-8 text-xs text-muted-foreground">
|
||||||
暂无车系,点击右上角新增
|
{canWrite ? '暂无车系,点击右上角新增' : '暂无车系'}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
115
client/src/components/demoFlowAggregation.test.ts
Normal file
115
client/src/components/demoFlowAggregation.test.ts
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { aggregateDemoFlowResults, type DemoFlowSourceResult } from './demoFlowAggregation';
|
||||||
|
|
||||||
|
function result(id: number, index: number, status: string, resultData: Record<string, unknown>): DemoFlowSourceResult {
|
||||||
|
return { id, type: 'plugin', pluginId: `plugin-${id}`, status, resultData: { demoFlowIndex: index, ...resultData } };
|
||||||
|
}
|
||||||
|
|
||||||
|
const nameOf = (item: DemoFlowSourceResult) => `算法${item.id}`;
|
||||||
|
|
||||||
|
describe('aggregateDemoFlowResults', () => {
|
||||||
|
it('按全流程顺序合并测量项,并保留来源和算法内顺序', () => {
|
||||||
|
const model = aggregateDemoFlowResults([
|
||||||
|
result(20, 2, 'completed', { measurements: [{ name: 'B1', value: 2 }, { name: 'B2', value: 3 }] }),
|
||||||
|
result(10, 1, 'completed', { measurements: [{ name: 'A1', value: 1 }] }),
|
||||||
|
], nameOf);
|
||||||
|
|
||||||
|
expect(model.measurementRows.map((row) => [row.measurement.name, row.resultId, row.flowIndex, row.measurementIndex])).toEqual([
|
||||||
|
['A1', 10, 1, 0],
|
||||||
|
['B1', 20, 2, 0],
|
||||||
|
['B2', 20, 2, 1],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('原样保留空值、公差文本、基准长度和自定义结论文案,并忽略空测量', () => {
|
||||||
|
const measurement = {
|
||||||
|
name: '斜率', value: null, unit: 'mm', tolerance: 0.5, toleranceLabel: '+8.5/-3', baseline: 1000,
|
||||||
|
status: 'warning' as const, passLabel: '达标', warningLabel: '复核', failLabel: '未达标',
|
||||||
|
};
|
||||||
|
const model = aggregateDemoFlowResults([
|
||||||
|
result(10, 1, 'completed', { measurements: [] }),
|
||||||
|
result(20, 2, 'completed', { measurements: [measurement] }),
|
||||||
|
], nameOf);
|
||||||
|
|
||||||
|
expect(model.measurementRows).toHaveLength(1);
|
||||||
|
expect(model.measurementRows[0].measurement).toEqual(measurement);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('默认不生成算法分组行,开启后按算法生成分组', () => {
|
||||||
|
const inputs = [
|
||||||
|
result(10, 1, 'completed', { measurements: [{ name: 'A', value: 1 }] }),
|
||||||
|
result(20, 2, 'completed', { measurements: [{ name: 'B', value: 2 }] }),
|
||||||
|
];
|
||||||
|
expect(aggregateDemoFlowResults(inputs, nameOf).tableRows.every((row) => row.kind === 'measurement')).toBe(true);
|
||||||
|
expect(aggregateDemoFlowResults(inputs, nameOf, { includeAlgorithmGroups: true }).tableRows.map((row) => row.kind)).toEqual([
|
||||||
|
'algorithm', 'measurement', 'algorithm', 'measurement',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('过滤参考项,同时保留其他状态和未设置状态的测量项', () => {
|
||||||
|
const model = aggregateDemoFlowResults([
|
||||||
|
result(10, 1, 'completed', {
|
||||||
|
measurements: [
|
||||||
|
{ name: '参考项', value: 0.1, status: 'info' },
|
||||||
|
{ name: '合格项', value: 1, status: 'pass' },
|
||||||
|
{ name: '警告项', value: 2, status: 'warning' },
|
||||||
|
{ name: '失败项', value: 3, status: 'fail' },
|
||||||
|
{ name: '无状态项', value: 4 },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
], nameOf);
|
||||||
|
|
||||||
|
expect(model.measurementRows.map((row) => row.measurement.name)).toEqual([
|
||||||
|
'合格项', '警告项', '失败项', '无状态项',
|
||||||
|
]);
|
||||||
|
expect(model.tableRows).toHaveLength(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('全部为参考项时进入空测量结果状态,且不生成算法分组行', () => {
|
||||||
|
const model = aggregateDemoFlowResults([
|
||||||
|
result(10, 1, 'completed', {
|
||||||
|
measurements: [
|
||||||
|
{ name: '配准误差 p95', value: 0.1, status: 'info' },
|
||||||
|
{ name: '配准误差均值', value: 0.05, status: 'info' },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
], nameOf, { includeAlgorithmGroups: true });
|
||||||
|
|
||||||
|
expect(model.measurementRows).toEqual([]);
|
||||||
|
expect(model.tableRows).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('让下载、图片、3D 和原始字段始终关联各自算法与结果 ID', () => {
|
||||||
|
const model = aggregateDemoFlowResults([
|
||||||
|
result(10, 1, 'completed', {
|
||||||
|
files: ['a.xlsx'], images: ['a.png'], overlay3d: { kind: 'ply', file: 'a.ply' }, extra: { source: 'a' },
|
||||||
|
}),
|
||||||
|
result(20, 2, 'completed', {
|
||||||
|
files: [{ name: '报表B', path: 'nested/b.xlsx' }], images: ['b.png'], overlay3d: { kind: 'none' }, extra: { source: 'b' },
|
||||||
|
}),
|
||||||
|
], nameOf);
|
||||||
|
|
||||||
|
expect(model.artifactGroups).toMatchObject([
|
||||||
|
{ resultId: 10, sourceAlgorithmId: 'plugin-10', files: [{ label: 'a.xlsx', path: 'a.xlsx' }], images: ['a.png'], extra: { source: 'a' } },
|
||||||
|
{ resultId: 20, sourceAlgorithmId: 'plugin-20', files: [{ label: '报表B', path: 'nested/b.xlsx' }], images: ['b.png'], extra: { source: 'b' } },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('为运行中、排队、失败和停止状态生成提示,同时保留部分完成结果', () => {
|
||||||
|
const model = aggregateDemoFlowResults([
|
||||||
|
result(10, 1, 'completed', { measurements: [{ name: '已完成', value: 1 }] }),
|
||||||
|
result(20, 2, 'processing', { progress: 0.42, stage: '计算中' }),
|
||||||
|
result(30, 3, 'pending', { stage: '等待资源' }),
|
||||||
|
result(40, 4, 'error', { error: '输入无效' }),
|
||||||
|
result(50, 5, 'cancelled', {}),
|
||||||
|
], nameOf);
|
||||||
|
|
||||||
|
expect(model.measurementRows.map((row) => row.measurement.name)).toEqual(['已完成']);
|
||||||
|
expect(model.statusNotices).toMatchObject([
|
||||||
|
{ resultId: 20, status: 'processing', progress: 0.42, detail: '计算中' },
|
||||||
|
{ resultId: 30, status: 'pending', detail: '等待资源' },
|
||||||
|
{ resultId: 40, status: 'error', detail: '输入无效' },
|
||||||
|
{ resultId: 50, status: 'cancelled' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
167
client/src/components/demoFlowAggregation.ts
Normal file
167
client/src/components/demoFlowAggregation.ts
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
import { filterVisibleMeasurements, type Measurement, type PluginResultLike } from './GenericResultView';
|
||||||
|
|
||||||
|
export type DemoFlowSourceResult = {
|
||||||
|
id: number;
|
||||||
|
type: string;
|
||||||
|
pluginId?: string | null;
|
||||||
|
status: string;
|
||||||
|
resultData: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DemoFlowMeasurementRow = {
|
||||||
|
kind: 'measurement';
|
||||||
|
sourceAlgorithmId: string;
|
||||||
|
algorithmName: string;
|
||||||
|
resultId: number;
|
||||||
|
flowIndex: number;
|
||||||
|
measurementIndex: number;
|
||||||
|
measurement: Measurement;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DemoFlowMeasurementGroupRow = {
|
||||||
|
kind: 'algorithm';
|
||||||
|
sourceAlgorithmId: string;
|
||||||
|
algorithmName: string;
|
||||||
|
resultId: number;
|
||||||
|
flowIndex: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DemoFlowTableRow = DemoFlowMeasurementRow | DemoFlowMeasurementGroupRow;
|
||||||
|
|
||||||
|
export type DemoFlowArtifactGroup = {
|
||||||
|
sourceAlgorithmId: string;
|
||||||
|
algorithmName: string;
|
||||||
|
resultId: number;
|
||||||
|
flowIndex: number;
|
||||||
|
files: { label: string; path: string }[];
|
||||||
|
images: string[];
|
||||||
|
overlay: PluginResultLike['overlay3d'];
|
||||||
|
inputPointCloudFileIds: unknown;
|
||||||
|
inputCadFileIds: unknown;
|
||||||
|
extra?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DemoFlowStatusNotice = {
|
||||||
|
sourceAlgorithmId: string;
|
||||||
|
algorithmName: string;
|
||||||
|
resultId: number;
|
||||||
|
status: string;
|
||||||
|
progress?: number;
|
||||||
|
detail?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DemoFlowAggregation = {
|
||||||
|
tableRows: DemoFlowTableRow[];
|
||||||
|
measurementRows: DemoFlowMeasurementRow[];
|
||||||
|
artifactGroups: DemoFlowArtifactGroup[];
|
||||||
|
statusNotices: DemoFlowStatusNotice[];
|
||||||
|
};
|
||||||
|
|
||||||
|
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 finiteNumber(value: unknown): number | undefined {
|
||||||
|
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function flowIndex(result: DemoFlowSourceResult, fallback: number) {
|
||||||
|
return finiteNumber(asRecord(result.resultData)?.demoFlowIndex) ?? fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeFiles(value: unknown): { label: string; path: string }[] {
|
||||||
|
if (!Array.isArray(value)) return [];
|
||||||
|
return value.flatMap((item) => {
|
||||||
|
if (typeof item === 'string' && item.length > 0) return [{ label: item, path: item }];
|
||||||
|
const record = asRecord(item);
|
||||||
|
if (!record) return [];
|
||||||
|
const path = typeof record.path === 'string'
|
||||||
|
? record.path
|
||||||
|
: typeof record.name === 'string'
|
||||||
|
? record.name
|
||||||
|
: undefined;
|
||||||
|
if (!path) return [];
|
||||||
|
const label = typeof record.name === 'string' ? record.name : path;
|
||||||
|
return [{ label, path }];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeImages(value: unknown): string[] {
|
||||||
|
if (!Array.isArray(value)) return [];
|
||||||
|
return value.filter((item): item is string => typeof item === 'string' && item.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasOverlay(value: PluginResultLike['overlay3d']) {
|
||||||
|
return Boolean(value && value.kind !== 'none');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function aggregateDemoFlowResults(
|
||||||
|
results: DemoFlowSourceResult[],
|
||||||
|
getAlgorithmName: (result: DemoFlowSourceResult) => string,
|
||||||
|
options: { includeAlgorithmGroups?: boolean } = {},
|
||||||
|
): DemoFlowAggregation {
|
||||||
|
const sorted = results
|
||||||
|
.map((result, originalIndex) => ({ result, originalIndex, index: flowIndex(result, originalIndex + 1) }))
|
||||||
|
.sort((a, b) => a.index - b.index || a.originalIndex - b.originalIndex);
|
||||||
|
const measurementRows: DemoFlowMeasurementRow[] = [];
|
||||||
|
const tableRows: DemoFlowTableRow[] = [];
|
||||||
|
const artifactGroups: DemoFlowArtifactGroup[] = [];
|
||||||
|
const statusNotices: DemoFlowStatusNotice[] = [];
|
||||||
|
|
||||||
|
for (const { result, index } of sorted) {
|
||||||
|
const data = (asRecord(result.resultData) ?? {}) as PluginResultLike & Record<string, unknown>;
|
||||||
|
const sourceAlgorithmId = result.pluginId ?? (typeof data.pluginId === 'string' ? data.pluginId : result.type);
|
||||||
|
const algorithmName = getAlgorithmName(result);
|
||||||
|
const measurements = filterVisibleMeasurements(Array.isArray(data.measurements) ? data.measurements : []);
|
||||||
|
|
||||||
|
if (measurements.length > 0 && options.includeAlgorithmGroups) {
|
||||||
|
tableRows.push({ kind: 'algorithm', sourceAlgorithmId, algorithmName, resultId: result.id, flowIndex: index });
|
||||||
|
}
|
||||||
|
measurements.forEach((measurement, measurementIndex) => {
|
||||||
|
const row: DemoFlowMeasurementRow = {
|
||||||
|
kind: 'measurement',
|
||||||
|
sourceAlgorithmId,
|
||||||
|
algorithmName,
|
||||||
|
resultId: result.id,
|
||||||
|
flowIndex: index,
|
||||||
|
measurementIndex,
|
||||||
|
measurement,
|
||||||
|
};
|
||||||
|
measurementRows.push(row);
|
||||||
|
tableRows.push(row);
|
||||||
|
});
|
||||||
|
|
||||||
|
const files = normalizeFiles(data.files);
|
||||||
|
const images = normalizeImages(data.images);
|
||||||
|
const extra = asRecord(data.extra) ?? undefined;
|
||||||
|
if (files.length > 0 || images.length > 0 || hasOverlay(data.overlay3d) || (extra && Object.keys(extra).length > 0)) {
|
||||||
|
artifactGroups.push({
|
||||||
|
sourceAlgorithmId,
|
||||||
|
algorithmName,
|
||||||
|
resultId: result.id,
|
||||||
|
flowIndex: index,
|
||||||
|
files,
|
||||||
|
images,
|
||||||
|
overlay: data.overlay3d,
|
||||||
|
inputPointCloudFileIds: data.inputPointCloudFileIds,
|
||||||
|
inputCadFileIds: data.inputCadFileIds,
|
||||||
|
extra,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.status !== 'completed') {
|
||||||
|
const progress = finiteNumber(data.progress);
|
||||||
|
const detail = result.status === 'error'
|
||||||
|
? (typeof data.error === 'string' ? data.error : typeof data.message === 'string' ? data.message : undefined)
|
||||||
|
: typeof data.stage === 'string'
|
||||||
|
? data.stage
|
||||||
|
: typeof data.error === 'string'
|
||||||
|
? data.error
|
||||||
|
: undefined;
|
||||||
|
statusNotices.push({ sourceAlgorithmId, algorithmName, resultId: result.id, status: result.status, progress, detail });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { tableRows, measurementRows, artifactGroups, statusNotices };
|
||||||
|
}
|
||||||
161
client/src/hooks/useDemoFlowState.ts
Normal file
161
client/src/hooks/useDemoFlowState.ts
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import { trpc } from '@/lib/trpc';
|
||||||
|
|
||||||
|
type AnalysisResultRow = {
|
||||||
|
id: number;
|
||||||
|
taskId?: number | null;
|
||||||
|
type: string;
|
||||||
|
pluginId?: string | null;
|
||||||
|
status: string;
|
||||||
|
createdAt: string | Date;
|
||||||
|
updatedAt: string | Date;
|
||||||
|
resultData: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DemoFlowStepStatus = 'pending' | 'processing' | 'completed' | 'error' | 'cancelled';
|
||||||
|
|
||||||
|
export interface DemoFlowStep {
|
||||||
|
id: number;
|
||||||
|
index: number;
|
||||||
|
role: 'registration' | 'measurement';
|
||||||
|
pluginId: string | null;
|
||||||
|
status: DemoFlowStepStatus;
|
||||||
|
progress?: number;
|
||||||
|
createdAt: string | Date;
|
||||||
|
updatedAt: string | Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DemoFlowState {
|
||||||
|
active: boolean;
|
||||||
|
runId: string | null;
|
||||||
|
total: number;
|
||||||
|
completed: number;
|
||||||
|
steps: DemoFlowStep[];
|
||||||
|
overallStatus: DemoFlowStepStatus | 'idle';
|
||||||
|
currentStep: DemoFlowStep | null;
|
||||||
|
overallProgress: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY_STATE: DemoFlowState = {
|
||||||
|
active: false,
|
||||||
|
runId: null,
|
||||||
|
total: 0,
|
||||||
|
completed: 0,
|
||||||
|
steps: [],
|
||||||
|
overallStatus: 'idle',
|
||||||
|
currentStep: null,
|
||||||
|
overallProgress: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
function isActiveStatus(step: DemoFlowStep): boolean {
|
||||||
|
return step.status === 'processing' || step.status === 'pending';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDemoFlowState(taskId: number | null): DemoFlowState {
|
||||||
|
const { data: results = [] } = trpc.analysisResult.list.useQuery(
|
||||||
|
{ taskId: taskId ?? 0 },
|
||||||
|
{ enabled: taskId != null, refetchInterval: 3000 },
|
||||||
|
);
|
||||||
|
|
||||||
|
return useMemo(() => {
|
||||||
|
if (taskId == null || results.length === 0) return EMPTY_STATE;
|
||||||
|
|
||||||
|
const rows = results as AnalysisResultRow[];
|
||||||
|
const steps: DemoFlowStep[] = [];
|
||||||
|
let activeRunId: string | null = null;
|
||||||
|
let declaredTotal = 0;
|
||||||
|
|
||||||
|
for (const r of rows) {
|
||||||
|
const data = asRecord(r.resultData);
|
||||||
|
if (!data) continue;
|
||||||
|
const runId = data.demoFlowRunId;
|
||||||
|
if (typeof runId !== 'string' || runId.length === 0) continue;
|
||||||
|
|
||||||
|
if (activeRunId == null) {
|
||||||
|
activeRunId = runId;
|
||||||
|
} else if (activeRunId !== runId) {
|
||||||
|
// 只关注最新的一次 demo 流(createdAt 最新的在最前)
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const index = numberFromUnknown(data.demoFlowIndex) ?? steps.length + 1;
|
||||||
|
const roleRaw = data.demoFlowRole;
|
||||||
|
const role = roleRaw === 'registration' ? 'registration' : 'measurement';
|
||||||
|
const progress = numberFromUnknown(data.progress);
|
||||||
|
const stepTotal = numberFromUnknown(data.demoFlowTotal) ?? 0;
|
||||||
|
if (stepTotal > declaredTotal) declaredTotal = stepTotal;
|
||||||
|
|
||||||
|
steps.push({
|
||||||
|
id: r.id,
|
||||||
|
index,
|
||||||
|
role,
|
||||||
|
pluginId: r.pluginId ?? null,
|
||||||
|
status: r.status as DemoFlowStepStatus,
|
||||||
|
progress,
|
||||||
|
createdAt: r.createdAt,
|
||||||
|
updatedAt: r.updatedAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (steps.length === 0) return EMPTY_STATE;
|
||||||
|
|
||||||
|
steps.sort((a, b) => a.index - b.index);
|
||||||
|
|
||||||
|
const total = Math.max(steps.length, declaredTotal);
|
||||||
|
|
||||||
|
// 用 pending 占位步骤补齐尚未创建结果记录的后续步骤
|
||||||
|
for (let i = steps.length + 1; i <= total; i++) {
|
||||||
|
steps.push({
|
||||||
|
id: -i,
|
||||||
|
index: i,
|
||||||
|
role: i === 1 ? 'registration' : 'measurement',
|
||||||
|
pluginId: null,
|
||||||
|
status: 'pending',
|
||||||
|
createdAt: steps[0]?.createdAt ?? new Date(),
|
||||||
|
updatedAt: steps[0]?.updatedAt ?? new Date(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const completed = steps.filter((s) => s.status === 'completed').length;
|
||||||
|
const active = steps.some(isActiveStatus);
|
||||||
|
|
||||||
|
const currentStep =
|
||||||
|
steps.find((s) => s.status === 'processing') ??
|
||||||
|
steps.find((s) => s.status === 'pending') ??
|
||||||
|
steps[steps.length - 1] ??
|
||||||
|
null;
|
||||||
|
|
||||||
|
let overallStatus: DemoFlowState['overallStatus'] = 'idle';
|
||||||
|
if (steps.some((s) => s.status === 'processing')) overallStatus = 'processing';
|
||||||
|
else if (steps.some((s) => s.status === 'pending')) overallStatus = 'pending';
|
||||||
|
else if (steps.every((s) => s.status === 'completed')) overallStatus = 'completed';
|
||||||
|
else if (steps.some((s) => s.status === 'error')) overallStatus = 'error';
|
||||||
|
else if (steps.every((s) => s.status === 'cancelled')) overallStatus = 'cancelled';
|
||||||
|
else if (steps.some((s) => s.status === 'cancelled')) overallStatus = 'cancelled';
|
||||||
|
|
||||||
|
const doneProgress = completed / total;
|
||||||
|
const currentProgress = currentStep && isActiveStatus(currentStep)
|
||||||
|
? (currentStep.progress ?? 0) / total
|
||||||
|
: 0;
|
||||||
|
const overallProgress = Math.min(100, Math.round((doneProgress + currentProgress) * 100));
|
||||||
|
|
||||||
|
return {
|
||||||
|
active,
|
||||||
|
runId: activeRunId,
|
||||||
|
total,
|
||||||
|
completed,
|
||||||
|
steps,
|
||||||
|
overallStatus,
|
||||||
|
currentStep,
|
||||||
|
overallProgress,
|
||||||
|
};
|
||||||
|
}, [results, taskId]);
|
||||||
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useState, useCallback, useEffect, 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 PointCloudViewer from '@/components/PointCloudViewer';
|
import PointCloudViewer from '@/components/PointCloudViewer';
|
||||||
|
import DemoFlowOverlay from '@/components/DemoFlowOverlay';
|
||||||
|
import { useDemoFlowState } from '@/hooks/useDemoFlowState';
|
||||||
import VehicleTree, { TreeSelection } from '@/components/VehicleTree';
|
import VehicleTree, { TreeSelection } from '@/components/VehicleTree';
|
||||||
import TaskManager from '@/components/TaskManager';
|
import TaskManager from '@/components/TaskManager';
|
||||||
import AnalysisPanel from '@/components/AnalysisPanel';
|
import AnalysisPanel from '@/components/AnalysisPanel';
|
||||||
@@ -9,7 +10,8 @@ import AlgorithmRunPanel from '@/components/AlgorithmRunPanel';
|
|||||||
import type { Overlay3D } from '@/components/GenericResultView';
|
import type { Overlay3D } from '@/components/GenericResultView';
|
||||||
import { trpc } from '@/lib/trpc';
|
import { trpc } from '@/lib/trpc';
|
||||||
import {
|
import {
|
||||||
LogIn,
|
LogOut,
|
||||||
|
Users,
|
||||||
Box,
|
Box,
|
||||||
Cloud,
|
Cloud,
|
||||||
Eye,
|
Eye,
|
||||||
@@ -22,7 +24,7 @@ import {
|
|||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
const { loading, isAuthenticated } = useAuth();
|
const { loading, user, logout, isSuperAdmin, canWrite } = useAuth();
|
||||||
const utils = trpc.useUtils();
|
const utils = trpc.useUtils();
|
||||||
|
|
||||||
// ── 树导航选中状态 ─────────────────────────────────────────────────────────
|
// ── 树导航选中状态 ─────────────────────────────────────────────────────────
|
||||||
@@ -66,8 +68,17 @@ export default function Home() {
|
|||||||
const [roiPickedPoint, setRoiPickedPoint] = useState<{ x: number; y: number; z: number } | null>(null);
|
const [roiPickedPoint, setRoiPickedPoint] = useState<{ x: number; y: number; z: number } | null>(null);
|
||||||
const [activeRoiPickTarget, setActiveRoiPickTarget] = useState<{ taskIdx: number; roiKey: 'source_roi' | 'target_roi'; corner: 'min' | 'max' } | null>(null);
|
const [activeRoiPickTarget, setActiveRoiPickTarget] = useState<{ taskIdx: number; roiKey: 'source_roi' | 'target_roi'; corner: 'min' | 'max' } | null>(null);
|
||||||
|
|
||||||
|
// ── 一键全流程演示状态(用于中央进度浮层与 3D 扫描动画) ─────────────────────
|
||||||
|
const demoFlowState = useDemoFlowState(selection.taskId);
|
||||||
|
const { data: pluginList } = trpc.algorithm.list.useQuery(undefined, { staleTime: 60_000 });
|
||||||
|
const pluginNameById = useMemo(() => {
|
||||||
|
const map = new Map<string, string>();
|
||||||
|
for (const p of pluginList?.plugins ?? []) map.set(p.id, p.name);
|
||||||
|
return map;
|
||||||
|
}, [pluginList]);
|
||||||
|
|
||||||
// ── 视图控制 ───────────────────────────────────────────────────────────────
|
// ── 视图控制 ───────────────────────────────────────────────────────────────
|
||||||
const [showHeatmap, setShowHeatmap] = useState(true);
|
const [showHeatmap, setShowHeatmap] = useState(false);
|
||||||
const [showPointCloud, setShowPointCloud] = useState(true);
|
const [showPointCloud, setShowPointCloud] = useState(true);
|
||||||
const [showCADModel] = useState(true);
|
const [showCADModel] = useState(true);
|
||||||
const [showWireframe, setShowWireframe] = useState(false);
|
const [showWireframe, setShowWireframe] = useState(false);
|
||||||
@@ -133,6 +144,7 @@ export default function Home() {
|
|||||||
setHeatmapViewResultId(null);
|
setHeatmapViewResultId(null);
|
||||||
setPluginOverlay(null);
|
setPluginOverlay(null);
|
||||||
setPointCloudRenderMode('points');
|
setPointCloudRenderMode('points');
|
||||||
|
setShowHeatmap(false);
|
||||||
setSelectedPcFileIds([]);
|
setSelectedPcFileIds([]);
|
||||||
setSelectedTaskCadFileIds([]);
|
setSelectedTaskCadFileIds([]);
|
||||||
setRoiPickMode(false);
|
setRoiPickMode(false);
|
||||||
@@ -217,6 +229,10 @@ export default function Home() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleLoadPointClouds = useCallback(async (taskId: number, _fileIds: number[]) => {
|
const handleLoadPointClouds = useCallback(async (taskId: number, _fileIds: number[]) => {
|
||||||
|
setHeatmapViewResultId(null);
|
||||||
|
setPluginOverlay(null);
|
||||||
|
setPointCloudRenderMode('points');
|
||||||
|
setShowHeatmap(false);
|
||||||
setPointCloudTaskId(taskId);
|
setPointCloudTaskId(taskId);
|
||||||
setPointCloudUrls([`__task:${taskId}`]);
|
setPointCloudUrls([`__task:${taskId}`]);
|
||||||
setPcLoading(true);
|
setPcLoading(true);
|
||||||
@@ -281,7 +297,7 @@ export default function Home() {
|
|||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<AlgorithmRunPanel
|
{canWrite && <AlgorithmRunPanel
|
||||||
taskId={selection.taskId}
|
taskId={selection.taskId}
|
||||||
modelId={selection.modelId}
|
modelId={selection.modelId}
|
||||||
pointCloudLoaded={pointCloudTaskId === selection.taskId && pointCloudUrls.length > 0}
|
pointCloudLoaded={pointCloudTaskId === selection.taskId && pointCloudUrls.length > 0}
|
||||||
@@ -297,8 +313,8 @@ export default function Home() {
|
|||||||
setRoiPickedPoint(null);
|
setRoiPickedPoint(null);
|
||||||
}}
|
}}
|
||||||
activeRoiPickTarget={activeRoiPickTarget}
|
activeRoiPickTarget={activeRoiPickTarget}
|
||||||
/>
|
/>}
|
||||||
<div className="h-px bg-border" />
|
{canWrite && <div className="h-px bg-border" />}
|
||||||
<AnalysisPanel
|
<AnalysisPanel
|
||||||
taskId={selection.taskId}
|
taskId={selection.taskId}
|
||||||
onReplay={resultId => console.log('[Replay] resultId', resultId)}
|
onReplay={resultId => console.log('[Replay] resultId', resultId)}
|
||||||
@@ -306,6 +322,7 @@ export default function Home() {
|
|||||||
setPluginOverlay(null);
|
setPluginOverlay(null);
|
||||||
setHeatmapViewResultId(resultId);
|
setHeatmapViewResultId(resultId);
|
||||||
setPointCloudRenderMode('smoothHeatmap');
|
setPointCloudRenderMode('smoothHeatmap');
|
||||||
|
setShowHeatmap(true);
|
||||||
}}
|
}}
|
||||||
onViewPluginOverlay={({ taskId, resultId, overlay, inputPointCloudFileIds }) => {
|
onViewPluginOverlay={({ taskId, resultId, overlay, inputPointCloudFileIds }) => {
|
||||||
const expectedIds = inputPointCloudFileIds ?? [];
|
const expectedIds = inputPointCloudFileIds ?? [];
|
||||||
@@ -348,6 +365,7 @@ export default function Home() {
|
|||||||
setHeatmapViewResultId(null);
|
setHeatmapViewResultId(null);
|
||||||
setPluginOverlay({ taskId, resultId, overlay });
|
setPluginOverlay({ taskId, resultId, overlay });
|
||||||
setPointCloudRenderMode(overlay.kind === 'vertexColors' ? 'smoothHeatmap' : 'points');
|
setPointCloudRenderMode(overlay.kind === 'vertexColors' ? 'smoothHeatmap' : 'points');
|
||||||
|
setShowHeatmap(overlay.kind === 'vertexColors');
|
||||||
}}
|
}}
|
||||||
externalPending={runAnalysisJob.isPending}
|
externalPending={runAnalysisJob.isPending}
|
||||||
onRunAssembly={() => {
|
onRunAssembly={() => {
|
||||||
@@ -420,10 +438,13 @@ export default function Home() {
|
|||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="w-7 h-7 rounded bg-secondary animate-pulse" />
|
<div className="w-7 h-7 rounded bg-secondary animate-pulse" />
|
||||||
) : !isAuthenticated ? (
|
) : user ? (
|
||||||
<a href={getLoginUrl()} className="industrial-btn industrial-btn-primary py-1.5">
|
<div className="flex items-center gap-2 text-xs">
|
||||||
<LogIn className="w-3.5 h-3.5" />
|
<div className="text-right leading-tight"><div>{user.username}</div><div className="text-[10px] text-muted-foreground">{user.role === 'super_admin' ? '超级管理员' : user.role === 'admin' ? '管理员' : '普通用户'}</div></div>
|
||||||
</a>
|
{isSuperAdmin && <a href="/users" className="industrial-btn py-1.5" title="用户管理"><Users className="w-3.5 h-3.5" /></a>}
|
||||||
|
<a href="/change-password" className="industrial-btn py-1.5">修改密码</a>
|
||||||
|
<button className="industrial-btn py-1.5" title="退出登录" onClick={() => void logout()}><LogOut className="w-3.5 h-3.5" /></button>
|
||||||
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -481,6 +502,7 @@ export default function Home() {
|
|||||||
setRoiPickedPoint(pt);
|
setRoiPickedPoint(pt);
|
||||||
setRoiPickMode(false);
|
setRoiPickMode(false);
|
||||||
}}
|
}}
|
||||||
|
demoFlowActive={demoFlowState.active}
|
||||||
className="w-full h-full"
|
className="w-full h-full"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -495,6 +517,8 @@ export default function Home() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<DemoFlowOverlay state={demoFlowState} pluginNameById={pluginNameById} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 操作提示 */}
|
{/* 操作提示 */}
|
||||||
@@ -549,6 +573,7 @@ export default function Home() {
|
|||||||
setPointCloudRenderMode('points');
|
setPointCloudRenderMode('points');
|
||||||
setHeatmapViewResultId(null);
|
setHeatmapViewResultId(null);
|
||||||
setPluginOverlay(null);
|
setPluginOverlay(null);
|
||||||
|
setShowHeatmap(false);
|
||||||
setShowPointCloud(true);
|
setShowPointCloud(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -564,14 +589,16 @@ export default function Home() {
|
|||||||
正在显示分析结果 #{heatmapViewResultId}
|
正在显示分析结果 #{heatmapViewResultId}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<button
|
{pointCloudRenderMode === 'smoothHeatmap' && (
|
||||||
type="button"
|
<button
|
||||||
className={`industrial-btn h-7 px-2 py-0 inline-flex items-center gap-1 text-[10px] ${showHeatmap ? 'industrial-btn-primary' : ''}`}
|
type="button"
|
||||||
onClick={() => setShowHeatmap(v => !v)}
|
className={`industrial-btn h-7 px-2 py-0 inline-flex items-center gap-1 text-[10px] ${showHeatmap ? 'industrial-btn-primary' : ''}`}
|
||||||
>
|
onClick={() => setShowHeatmap(v => !v)}
|
||||||
{showHeatmap ? <Eye className="w-3.5 h-3.5" /> : <EyeOff className="w-3.5 h-3.5" />}
|
>
|
||||||
<span>热力图</span>
|
{showHeatmap ? <Eye className="w-3.5 h-3.5" /> : <EyeOff className="w-3.5 h-3.5" />}
|
||||||
</button>
|
<span>热力图</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="h-5 w-px bg-border" />
|
<div className="h-5 w-px bg-border" />
|
||||||
|
|||||||
Reference in New Issue
Block a user