feat: refine task and demo workflow UI

This commit is contained in:
2026-07-22 11:07:12 +08:00
parent 752ea3f944
commit 33c5fb9b9c
11 changed files with 1703 additions and 210 deletions

View File

@@ -1,7 +1,9 @@
import { useState, useEffect, useMemo, useRef } from 'react';
import { trpc } from '@/lib/trpc';
import { BarChart2, Ruler, Zap, Play, Trash2, ChevronDown, ChevronRight, RefreshCw, Flame, Box, Layers, Crosshair, Cpu, Timer, Square, Maximize2 } from 'lucide-react';
import GenericResultView, { type Overlay3D } from './GenericResultView';
import { useAuth } from '@/_core/hooks/useAuth';
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 { 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 (
<table className="w-full text-[10px] border-collapse">
<thead>
<tr className="text-muted-foreground">
<th className="text-left py-1 font-normal">项目</th>
<th className="text-right py-1 font-normal">值</th>
<th className="text-right py-1 font-normal">公差</th>
{showDetails && <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>
</tr>
</thead>
@@ -123,8 +126,8 @@ function MeasurementTable({ items }: { items: Measurement[] }) {
{items.map((m, i) => (
<tr key={i} className="border-t border-border/40">
<td className="py-1">{m.name}</td>
<td className="text-right font-mono">{m.value.toFixed(3)} {m.unit}</td>
<td className="text-right text-muted-foreground">±{m.tolerance}</td>
{showDetails && <td className="text-right font-mono">{m.value.toFixed(3)} {m.unit}</td>}
{showDetails && <td className="text-right text-muted-foreground">±{m.tolerance}</td>}
<td className="text-right">
<span className={`px-1 py-0.5 rounded text-[9px] ${
m.status === 'pass' ? 'bg-green-900/40 text-green-400' :
@@ -233,6 +236,7 @@ type DemoFlowMeta = {
};
const SHOW_LEGACY_ANALYSIS_ACTIONS = false;
const SHOW_DEMO_FLOW_ALGORITHM_GROUP_ROWS = false;
type AnalysisListItem =
| { kind: 'result'; result: AnalysisResultRow }
@@ -316,9 +320,14 @@ function getDemoFlowTotal(results: AnalysisResultRow[]) {
}
export default function AnalysisPanel({ taskId, onReplay, onViewSmoothHeatmap, onRunAssembly, onRunDeviation, externalPending, onViewPluginOverlay }: Props) {
const { canWrite } = useAuth();
const utils = trpc.useUtils();
const [expandedIds, setExpandedIds] = useState<Set<number>>(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 initializedDemoResultIds = useRef<Set<number>>(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 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);
return (
@@ -445,7 +486,7 @@ export default function AnalysisPanel({ taskId, onReplay, onViewSmoothHeatmap, o
<Play className="w-3 h-3 text-primary" />
</button>
)}
{isCancellableStatus(r.status) && (
{canWrite && isCancellableStatus(r.status) && (
<button
className="p-0.5 rounded hover:bg-secondary text-amber-300 disabled:opacity-50"
title="停止"
@@ -455,13 +496,13 @@ export default function AnalysisPanel({ taskId, onReplay, onViewSmoothHeatmap, o
<Square className="w-3 h-3" />
</button>
)}
<button
{canWrite && <button
className="p-0.5 rounded hover:bg-secondary text-destructive"
title="删除"
onClick={e => { e.stopPropagation(); deleteResult.mutate({ id: r.id }); }}
>
<Trash2 className="w-3 h-3" />
</button>
</button>}
</span>
</div>
<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' && (
<div className="px-3 pb-3 pt-1 border-t border-border/40 space-y-3">
{errorStats && <ErrorStatCard stats={errorStats} />}
{measurements && measurements.length > 0 && (
{measurements.length > 0 && (
<div>
<div className="text-[10px] text-muted-foreground mb-1">尺寸测量</div>
<MeasurementTable items={measurements} />
<MeasurementTable items={measurements} displayMode={variant} />
</div>
)}
</div>
@@ -677,6 +718,7 @@ export default function AnalysisPanel({ taskId, onReplay, onViewSmoothHeatmap, o
pluginId={r.pluginId ?? undefined}
pluginName={getPluginDisplayName(r.pluginId, pluginNameById, '算法插件')}
data={(r.resultData as Parameters<typeof GenericResultView>[0]['data']) ?? {}}
displayMode={variant}
onViewOverlay={onViewPluginOverlay}
/>
</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) {
const expanded = expandedFlowIds.has(group.runId);
const status = getDemoFlowStatus(group.results);
const total = getDemoFlowTotal(group.results);
const completed = group.results.filter((r) => r.status === 'completed').length;
const createdAt = group.results.reduce((min, r) => Math.min(min, resultTimeMs(r)), Number.POSITIVE_INFINITY);
const runningItems = group.results
.filter((r) => r.status === 'processing' || r.status === 'pending')
.slice(0, 2)
.map((r) => {
const data = asRecord(r.resultData);
const progress = numberFromUnknown(data?.progress);
return `${getResultTitle(r, pluginNameById)}${progress != null ? ` ${Math.round(progress * 100)}%` : ''}`;
});
const cardKey = `${variant}:${group.runId}`;
const resultsExpanded = !collapsedFlowResultCards.has(cardKey);
const artifactsExpanded = expandedFlowArtifactCards.has(cardKey);
const aggregated = aggregateDemoFlowResults(
group.results,
(result) => getPluginDisplayName(result.pluginId, pluginNameById, getResultTitle(result, pluginNameById)),
{ includeAlgorithmGroups: SHOW_DEMO_FLOW_ALGORITHM_GROUP_ROWS },
);
const showMeasurementDetails = variant === 'maximized';
return (
<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>
{expanded && (
<div className="px-2 pb-2 pt-1 border-t border-border/40 space-y-2">
{runningItems.length > 0 && (
<div className="text-[10px] text-muted-foreground truncate">
运行中:{runningItems.join('、')}
<div 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={() => toggleFlowResultCard(cardKey)}
>
{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 className={variant === 'maximized' ? 'grid grid-cols-1 lg:grid-cols-2 gap-2' : 'space-y-1.5'}>
{group.results.map((result) => renderResultCard(result))}
</div>
</div>
)}
@@ -739,7 +981,7 @@ export default function AnalysisPanel({ taskId, onReplay, onViewSmoothHeatmap, o
{listItems.map((item) => (
item.kind === 'demoFlow'
? renderDemoFlowCard(item, variant)
: renderResultCard(item.result)
: renderResultCard(item.result, variant)
))}
</div>
);
@@ -764,7 +1006,7 @@ export default function AnalysisPanel({ taskId, onReplay, onViewSmoothHeatmap, o
误差分析当前后端仍是模拟统计结果,不作为正式入口展示。
装配检测仍有 legacy 后台任务,但暂不作为主流程按钮展示。
偏差热力依赖 STL/OBJ,不适合当前以 STEP 为主的流程,暂时隐藏。 */}
{SHOW_LEGACY_ANALYSIS_ACTIONS && (
{canWrite && SHOW_LEGACY_ANALYSIS_ACTIONS && (
<>
<button
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 && (
<div className="text-xs text-muted-foreground text-center py-4">
暂无分析结果,点击运行分析
{canWrite ? '暂无分析结果,点击运行分析' : '暂无分析结果'}
</div>
)}

View File

@@ -1,4 +1,4 @@
import { Skeleton } from './ui/skeleton';
import { Skeleton } from "./ui/skeleton";
export function DashboardLayoutSkeleton() {
return (
@@ -7,24 +7,24 @@ export function DashboardLayoutSkeleton() {
<div className="w-[280px] border-r border-border bg-background p-4 space-y-6">
{/* Logo area */}
<div className="flex items-center gap-3 px-2">
<Skeleton className="h-8 w-8 rounded-md" />
<Skeleton className="h-4 w-24" />
<Skeleton className="bg-muted h-8 w-8 rounded-md" />
<Skeleton className="bg-muted h-4 w-24" />
</div>
{/* Menu items */}
<div className="space-y-2 px-2">
<Skeleton className="h-10 w-full rounded-lg" />
<Skeleton className="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="bg-muted h-10 w-full rounded-lg" />
<Skeleton className="bg-muted h-10 w-full rounded-lg" />
</div>
{/* User profile area at bottom */}
<div className="absolute bottom-4 left-4 right-4">
<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">
<Skeleton className="h-3 w-20" />
<Skeleton className="h-2 w-32" />
<Skeleton className="bg-muted h-3 w-20" />
<Skeleton className="bg-muted h-2 w-32" />
</div>
</div>
</div>
@@ -33,13 +33,13 @@ export function DashboardLayoutSkeleton() {
{/* Main content skeleton */}
<div className="flex-1 p-4 space-y-4">
{/* 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">
<Skeleton className="h-32 rounded-xl" />
<Skeleton className="h-32 rounded-xl" />
<Skeleton className="h-32 rounded-xl" />
<Skeleton className="bg-muted h-32 rounded-xl" />
<Skeleton className="bg-muted h-32 rounded-xl" />
<Skeleton className="bg-muted h-32 rounded-xl" />
</div>
<Skeleton className="h-64 rounded-xl" />
<Skeleton className="bg-muted h-64 rounded-xl" />
</div>
</div>
);

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

View File

@@ -12,7 +12,7 @@ import { Box, ChevronDown, ChevronRight, FileSpreadsheet, Image as ImageIcon } f
interface Measurement {
name: string;
value: number;
value: number | null;
unit?: string;
tolerance?: number;
/** 原始公差文本,例如 +8.5/-3;存在时优先展示 */
@@ -22,6 +22,7 @@ interface Measurement {
status?: 'pass' | 'warning' | 'fail' | 'info';
/** 自定义通过/不通过文字(覆盖默认的"合格"/"超差") */
passLabel?: string;
warningLabel?: string;
failLabel?: string;
}
@@ -66,6 +67,8 @@ interface Props {
pluginId?: string;
pluginName?: string;
data: PluginResultLike;
/** 右侧普通面板隐藏数值详情,最大化弹窗展示完整四列。 */
displayMode?: 'panel' | 'maximized';
/** 用户点击「在 3D 中查看」时回调,把叠加层信息传给 Home → PointCloudViewer */
onViewOverlay?: (overlay: {
taskId: number;
@@ -76,7 +79,7 @@ interface Props {
}) => void;
}
function statusClass(s?: Measurement['status']) {
export function statusClass(s?: Measurement['status']) {
switch (s) {
case 'pass':
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) {
case 'pass':
return m.passLabel ?? '合格';
case 'warning':
return '警告';
return m.warningLabel ?? '警告';
case 'fail':
return m.failLabel ?? '超差';
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 '—';
const abs = Math.abs(v);
if (abs !== 0 && (abs < 0.001 || abs >= 1e6)) return v.toExponential(3);
return v.toFixed(3);
}
function numberArrayFromUnknown(value: unknown): number[] | undefined {
export function numberArrayFromUnknown(value: unknown): number[] | undefined {
if (!Array.isArray(value)) return undefined;
const ids = value.filter((id): id is number => typeof id === 'number' && Number.isInteger(id));
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.kind === 'ply') {
const files = Array.isArray(overlay.files)
@@ -157,11 +169,13 @@ export default function GenericResultView({
pluginId,
pluginName,
data,
displayMode = 'panel',
onViewOverlay,
}: Props) {
const [showExtra, setShowExtra] = useState(false);
const errorStats = data.errorStats ?? null;
const measurements = data.measurements ?? [];
const measurements = filterVisibleMeasurements(data.measurements ?? []);
const showMeasurementDetails = displayMode === 'maximized';
const images = data.images ?? [];
const files = (data.files ?? []).map((f) =>
typeof f === 'string' ? f : (f as { name?: string; path?: string }).name ?? String(f)
@@ -206,8 +220,8 @@ export default function GenericResultView({
<thead>
<tr className="text-muted-foreground">
<th className="text-left py-1 font-normal">项目</th>
<th className="text-right py-1 font-normal">值</th>
<th className="text-right py-1 font-normal">公差</th>
{showMeasurementDetails && <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>
</tr>
</thead>
@@ -217,12 +231,12 @@ export default function GenericResultView({
<td className="py-1 truncate max-w-[140px]" title={m.name}>
{m.name}
</td>
<td className="text-right font-mono">
{showMeasurementDetails && <td className="text-right font-mono">
{m.baseline != null
? `${formatValue(m.value)}/${m.baseline}mm`
: `${formatValue(m.value)} ${m.unit ?? ''}`}
</td>
<td className="text-right text-muted-foreground">
</td>}
{showMeasurementDetails && <td className="text-right text-muted-foreground">
{m.toleranceLabel != null
? m.toleranceLabel
: m.tolerance != null
@@ -230,7 +244,7 @@ export default function GenericResultView({
? `${m.tolerance}/${m.baseline}mm`
: `±${m.tolerance}`
: '—'}
</td>
</td>}
<td className="text-right">
<span className={`px-1 py-0.5 rounded text-[9px] ${statusClass(m.status)}`}>
{m.status === 'info' ? '参考' : statusLabel(m)}

View File

@@ -44,9 +44,11 @@ function errorToColor(error: number, minError: number, maxError: number): THREE.
function getHeatmapLegendBounds(
points: Point[],
mode: 'points' | 'smoothHeatmap',
): { min: number; max: number; midTick: string } {
): { min: number; max: number; midTick: string } | null {
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 =
minError < 0 && maxError > 0
? '0'
@@ -56,10 +58,12 @@ function getHeatmapLegendBounds(
let minE = Infinity;
let maxE = -Infinity;
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 > maxE) maxE = e;
}
if (!Number.isFinite(minE) || !Number.isFinite(maxE)) return null;
if (!(maxE > minE)) {
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 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 maxError: number;
if (errorRangeMode === 'percentile') {
const r = displayErrorRangeSmoothHeatmap(data.points);
if (errorRangeMode === 'percentile' && hasErrorValues) {
const r = displayErrorRangeSmoothHeatmap(pointsWithError);
minError = r.minError;
maxError = r.maxError;
} else {
minError = Infinity;
maxError = -Infinity;
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 > maxError) maxError = err;
}
@@ -420,8 +427,8 @@ const PointCloudMesh = memo(function PointCloudMesh({
colors[i3] = point.r;
colors[i3 + 1] = point.g;
colors[i3 + 2] = point.b;
} else if (showHeatmap) {
const color = errorToColor(point.error ?? 0, minError, maxError);
} else if (showHeatmap && hasErrorValues && Number.isFinite(point.error)) {
const color = errorToColor(point.error!, minError, maxError);
colors[i3] = color.r;
colors[i3 + 1] = color.g;
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 防止不必要的重渲染
export type PointCloudRenderMode = 'points' | 'smoothHeatmap';
@@ -1202,6 +1285,8 @@ interface SceneContentProps {
/** ROI 坐标拾取模式 */
roiPickMode?: boolean;
onRoiPointPicked?: (point: { x: number; y: number; z: number }) => void;
/** 一键全流程演示运行中:展示扫描动画 */
demoFlowActive?: boolean;
}
const SceneContent = memo(function SceneContent({
@@ -1228,6 +1313,7 @@ const SceneContent = memo(function SceneContent({
plyOverlayData = null,
roiPickMode = false,
onRoiPointPicked,
demoFlowActive = false,
}: SceneContentProps) {
const pointPickActive = enablePointPicking && pointCloudRenderMode === 'points';
const [cadSurfaceGeometry, setCadSurfaceGeometry] = useState<THREE.BufferGeometry | null>(null);
@@ -1378,6 +1464,9 @@ const SceneContent = memo(function SceneContent({
{/* Grid */}
<IndustrialGrid />
{/* 一键全流程演示扫描动画 */}
<DemoFlowScanner active={demoFlowActive && !pointCloudData?.points?.length} />
{/* PLY 叠加层(kind=ply 插件结果) */}
{plyOverlayData && (
<points>
@@ -1418,7 +1507,8 @@ const SceneContent = memo(function SceneContent({
prevProps.cadHeatmapSuppressClient === nextProps.cadHeatmapSuppressClient &&
prevProps.dispRange === nextProps.dispRange &&
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)
const hasColor = resp.headers.get('X-Has-Color') === '1';
const hasError = resp.headers.get('X-Has-Error') === '1';
const stride = hasColor ? 7 : 4;
const arrayBuffer = await resp.arrayBuffer();
const floats = new Float32Array(arrayBuffer);
@@ -1551,7 +1642,8 @@ async function fetchBatchSampled(
for (let i = 0; i < count; i++) {
const base = i * stride;
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) {
pt.r = floats[base + 4];
pt.g = floats[base + 5];
@@ -1622,6 +1714,8 @@ interface PointCloudViewerProps {
*/
roiPickMode?: boolean;
onRoiPointPicked?: (point: { x: number; y: number; z: number }) => void;
/** 一键全流程演示运行中:在 3D 场景内展示扫描动画 */
demoFlowActive?: boolean;
}
export default function PointCloudViewer({
@@ -1651,6 +1745,7 @@ export default function PointCloudViewer({
pluginOverlay = null,
roiPickMode = false,
onRoiPointPicked,
demoFlowActive = false,
}: PointCloudViewerProps) {
// 点云数据状态
const [loadedPointCloud, setLoadedPointCloud] = useState<PointCloudData | null>(null);
@@ -1964,6 +2059,7 @@ export default function PointCloudViewer({
const heatmapLegendBounds = useMemo(() => {
if (!showHeatmap) return null;
if (pointCloudRenderMode !== 'smoothHeatmap') return null;
const hasVisibleCadHeatmap =
pointCloudRenderMode === 'smoothHeatmap' &&
serverCadHeatmap.status === 'ready' &&
@@ -2120,6 +2216,7 @@ export default function PointCloudViewer({
plyOverlayData={plyOverlayData}
roiPickMode={roiPickMode}
onRoiPointPicked={onRoiPointPicked}
demoFlowActive={demoFlowActive}
/>
</Canvas>
</div>

View File

@@ -14,9 +14,19 @@ import {
FileBox,
Eye,
Library,
Pencil,
Info,
} from 'lucide-react';
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from './ui/dialog';
import { toast } from 'sonner';
import { useAuth } from '@/_core/hooks/useAuth';
interface Props {
modelId: number;
@@ -40,6 +50,50 @@ function formatSize(bytes: number) {
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) {
if (!value) return '未知时间';
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 });
}
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';
const taskStatusLabels: Record<InspectionTaskStatus, string> = {
@@ -83,6 +154,7 @@ export default function TaskManager({
onStartCreate,
onEndCreate,
}: Props) {
const { canWrite } = useAuth();
const utils = trpc.useUtils();
const [adding, setAdding] = useState(false);
const [taskName, setTaskName] = useState('');
@@ -94,6 +166,9 @@ export default function TaskManager({
const [pcLibraryOpen, setPcLibraryOpen] = useState(false);
const [selectedModelPcFileIds, setSelectedModelPcFileIds] = useState<Set<number>>(new Set());
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());
@@ -136,10 +211,36 @@ export default function TaskManager({
const deletePcFile = trpc.pointcloudFile.delete.useMutation({
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(
{ modelId },
{ 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({
onSuccess: () => {
if (activeTaskId != null) utils.pointcloudFile.list.invalidate({ taskId: activeTaskId });
@@ -181,6 +282,9 @@ export default function TaskManager({
setSelectedModelCadFileIds(new Set());
setPcLibraryOpen(false);
setCadLibraryOpen(false);
setRenameTarget(null);
setRenameError(null);
setMetadataDetail(null);
}, [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) {
pendingTaskId.current = taskId;
fileRef.current?.click();
@@ -396,7 +528,7 @@ export default function TaskManager({
<span className="industrial-label flex items-center gap-1.5">
<ClipboardList className="w-3.5 h-3.5" /> 检测任务
</span>
<button
{canWrite && <button
className="industrial-btn py-0.5 text-[10px] flex items-center gap-0.5"
onClick={() => {
setAdding(true);
@@ -404,10 +536,10 @@ export default function TaskManager({
}}
>
<Plus className="w-3 h-3" /> 新建任务
</button>
</button>}
</div>
{adding && (
{canWrite && adding && (
<div className="flex items-center gap-1 mb-1.5">
<input
autoFocus
@@ -463,7 +595,7 @@ export default function TaskManager({
>
{taskStatusLabels[status] ?? status}
</span>
<button
{canWrite && <button
className="hidden group-hover:block p-0.5 rounded hover:bg-secondary"
title="上传点云文件"
onClick={(e) => {
@@ -472,8 +604,8 @@ export default function TaskManager({
}}
>
<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"
title="删除任务"
onClick={(e) => {
@@ -482,7 +614,7 @@ export default function TaskManager({
}}
>
<Trash2 className="w-3 h-3" />
</button>
</button>}
</div>
);
})}
@@ -490,7 +622,7 @@ export default function TaskManager({
{tasks.length === 0 && (
<div className="text-xs text-muted-foreground text-center py-4">
暂无任务,点击新建任务
{canWrite ? '暂无任务,点击新建任务' : '暂无任务'}
</div>
)}
</div>
@@ -526,20 +658,20 @@ export default function TaskManager({
</div>
</div>
<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"
onClick={() => openUpload(activeTaskId)}
>
<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 ${
pcLibraryOpen ? 'text-primary border-primary/50' : ''
}`}
onClick={() => setPcLibraryOpen((open) => !open)}
>
<Library className="w-3 h-3" /> 从车型库选择
</button>
</button>}
{pcFiles.length > 0 && (
<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"
@@ -568,7 +700,7 @@ export default function TaskManager({
</div>
)}
{pcLibraryOpen && (
{canWrite && pcLibraryOpen && (
<div className="industrial-card rounded p-2 mb-2 space-y-2">
<div className="flex items-center justify-between gap-2">
<span className="text-[10px] text-muted-foreground">
@@ -584,29 +716,144 @@ export default function TaskManager({
</button>
</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) => {
const isChecked = selectedModelPcFileIds.has(f.id);
const isEditing = renameTarget?.scope === 'model' && renameTarget.id === f.id;
return (
<button
<div
key={f.id}
type="button"
className={`w-full flex items-center gap-2 px-2 py-1.5 rounded text-xs text-left min-w-0 ${
isChecked ? 'bg-primary/10 border border-primary/30' : 'bg-secondary/40 hover:bg-secondary/70'
role="checkbox"
aria-checked={isChecked}
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)}
onKeyDown={(event) => {
if (isEditing) return;
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
toggleModelPcFile(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" />
<div className="relative flex items-start gap-2 p-2 pl-2.5">
{isChecked ? (
<CheckSquare className="w-3.5 h-3.5 text-primary flex-shrink-0" />
<CheckSquare className="mt-0.5 w-3.5 h-3.5 text-cyan-300 flex-shrink-0" />
) : (
<Square className="w-3.5 h-3.5 text-muted-foreground flex-shrink-0" />
<Square className="mt-0.5 w-3.5 h-3.5 text-muted-foreground flex-shrink-0" />
)}
<Cloud className="w-3.5 h-3.5 text-cyan-400 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)]" />
{isEditing && renameTarget ? (
<div className="flex-1 min-w-0" onClick={(event) => event.stopPropagation()}>
<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 && (
@@ -647,6 +894,7 @@ export default function TaskManager({
<div className="space-y-1">
{pcFiles.map((f) => {
const isChecked = selectedFileIds.has(f.id);
const isEditing = renameTarget?.scope === 'task' && renameTarget.id === f.id;
const meta = f as {
source?: string;
sourceResultId?: number | null;
@@ -660,31 +908,92 @@ export default function TaskManager({
return (
<div
key={f.id}
className={`flex items-center gap-2 px-2 py-1.5 rounded text-xs group cursor-pointer
${isChecked ? 'bg-primary/10 border border-primary/30' : 'industrial-card'}`}
className={`group relative overflow-hidden rounded-lg border text-xs 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_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)}
>
{/* 复选框 */}
<div className="absolute inset-y-0 left-0 w-0.5 bg-gradient-to-b from-cyan-300 via-sky-400 to-blue-500" />
<div className="relative flex items-start gap-2 p-2 pl-2.5">
<button
type="button"
className="flex-shrink-0"
className="mt-0.5 flex-shrink-0"
onClick={(e) => {
e.stopPropagation();
toggleFile(f.id);
}}
>
{isChecked ? (
<CheckSquare className="w-3.5 h-3.5 text-primary" />
<CheckSquare className="w-3.5 h-3.5 text-cyan-300" />
) : (
<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'}`}
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'}`}
/>
{isEditing && renameTarget ? (
<div className="flex-1 min-w-0" onClick={(event) => event.stopPropagation()}>
<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">
<div className="flex items-start gap-1.5">
<Tooltip>
<TooltipTrigger asChild>
<span className="flex-1 min-w-0 truncate">{f.name}</span>
<span className="min-w-0 flex-1 line-clamp-2 break-all font-medium leading-4 text-foreground/90">
{f.name}
</span>
</TooltipTrigger>
<TooltipContent
side="right"
@@ -712,25 +1021,68 @@ export default function TaskManager({
</TooltipContent>
</Tooltip>
{source === 'registered' && (
<span className="text-[9px] px-1 py-0.5 rounded bg-blue-900/40 text-blue-300 flex-shrink-0 whitespace-nowrap">
已配准
</span>
)}
{source === 'registered' && meta.sourceResultId != null && (
<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">
R{meta.sourceResultId}
<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="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">
<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>
)}
<span className="text-muted-foreground text-[10px] flex-shrink-0">
</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
className="hidden group-hover:block p-0.5 rounded hover:bg-secondary text-destructive flex-shrink-0"
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}」?`))
@@ -738,7 +1090,12 @@ export default function TaskManager({
}}
>
<Trash2 className="w-3 h-3" />
</button>
</button>}
</div>
</div>
</div>
)}
</div>
</div>
);
})}
@@ -783,20 +1140,20 @@ export default function TaskManager({
全选
</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"
onClick={() => cadFileRef.current?.click()}
>
<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 ${
cadLibraryOpen ? 'text-primary border-primary/50' : ''
}`}
onClick={() => setCadLibraryOpen((open) => !open)}
>
<Library className="w-3 h-3" /> 从车型库选择
</button>
</button>}
</div>
</div>
@@ -811,7 +1168,7 @@ export default function TaskManager({
</div>
)}
{cadLibraryOpen && (
{canWrite && cadLibraryOpen && (
<div className="industrial-card rounded p-2 mb-2 space-y-2">
<div className="flex items-center justify-between gap-2">
<span className="text-[10px] text-muted-foreground">
@@ -955,7 +1312,7 @@ export default function TaskManager({
<Eye className="w-3 h-3 text-primary" />
</button>
)}
<button
{canWrite && <button
className="hidden group-hover:block p-0.5 rounded hover:bg-secondary text-destructive flex-shrink-0"
onClick={(e) => {
e.stopPropagation();
@@ -964,7 +1321,7 @@ export default function TaskManager({
}}
>
<Trash2 className="w-3 h-3" />
</button>
</button>}
</div>
);
})}
@@ -1003,6 +1360,105 @@ export default function TaskManager({
</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
ref={fileRef}

View File

@@ -10,6 +10,7 @@ import {
Check,
X,
} from 'lucide-react';
import { useAuth } from '@/_core/hooks/useAuth';
export type TreeSelection = {
seriesId: number | null;
@@ -25,9 +26,11 @@ interface Props {
function EditableText({
value,
onSave,
canEdit,
}: {
value: string;
onSave: (v: string) => void;
canEdit: boolean;
}) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(value);
@@ -35,7 +38,7 @@ function EditableText({
return (
<span
className="flex-1 cursor-pointer hover:text-foreground truncate"
onDoubleClick={() => { setDraft(value); setEditing(true); }}
onDoubleClick={() => { if (canEdit) { setDraft(value); setEditing(true); } }}
>
{value}
</span>
@@ -60,6 +63,7 @@ function EditableText({
}
export default function VehicleTree({ selection, onSelect }: Props) {
const { canWrite } = useAuth();
const utils = trpc.useUtils();
const [expandedSeries, setExpandedSeries] = useState<Set<number>>(new Set());
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="flex items-center justify-between mb-2">
<span className="industrial-label">车系 / 车型 / 任务</span>
<button
{canWrite && <button
className="industrial-btn py-0.5 text-[10px] flex items-center gap-0.5"
onClick={() => setAddingSeries(true)}
>
<Plus className="w-3 h-3" /> 车系
</button>
</button>}
</div>
{addingSeries && (
{canWrite && addingSeries && (
<div className="flex items-center gap-1 pl-1">
<input
autoFocus
@@ -176,9 +180,10 @@ export default function VehicleTree({ selection, onSelect }: Props) {
<Train className="w-3 h-3 flex-shrink-0 text-muted-foreground" />
<EditableText
value={series.name}
canEdit={canWrite}
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
className="p-0.5 rounded hover:bg-secondary"
title="新增车型"
@@ -196,11 +201,11 @@ export default function VehicleTree({ selection, onSelect }: Props) {
>
<Trash2 className="w-3 h-3" />
</button>
</span>
</span>}
</div>
{/* 新增车型输入 */}
{addingModelFor === series.id && (
{canWrite && addingModelFor === series.id && (
<div className="flex items-center gap-1 pl-7 pr-1 py-0.5">
<input
autoFocus
@@ -258,9 +263,10 @@ export default function VehicleTree({ selection, onSelect }: Props) {
<Box className="w-3 h-3 flex-shrink-0 text-blue-400" />
<EditableText
value={model.name}
canEdit={canWrite}
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
className="p-0.5 rounded hover:bg-secondary text-destructive"
title="删除车型"
@@ -271,7 +277,7 @@ export default function VehicleTree({ selection, onSelect }: Props) {
>
<Trash2 className="w-3 h-3" />
</button>
</span>
</span>}
</div>
</div>
@@ -283,7 +289,7 @@ export default function VehicleTree({ selection, onSelect }: Props) {
{seriesList.length === 0 && (
<div className="text-center py-8 text-xs text-muted-foreground">
暂无车系,点击右上角新增
{canWrite ? '暂无车系,点击右上角新增' : '暂无车系'}
</div>
)}
</div>

View 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' },
]);
});
});

View 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 };
}

View 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]);
}

View File

@@ -1,7 +1,8 @@
import { useState, useCallback, useEffect, useMemo } from 'react';
import { useAuth } from '@/_core/hooks/useAuth';
import { getLoginUrl } from '@/const';
import PointCloudViewer from '@/components/PointCloudViewer';
import DemoFlowOverlay from '@/components/DemoFlowOverlay';
import { useDemoFlowState } from '@/hooks/useDemoFlowState';
import VehicleTree, { TreeSelection } from '@/components/VehicleTree';
import TaskManager from '@/components/TaskManager';
import AnalysisPanel from '@/components/AnalysisPanel';
@@ -9,7 +10,8 @@ import AlgorithmRunPanel from '@/components/AlgorithmRunPanel';
import type { Overlay3D } from '@/components/GenericResultView';
import { trpc } from '@/lib/trpc';
import {
LogIn,
LogOut,
Users,
Box,
Cloud,
Eye,
@@ -22,7 +24,7 @@ import {
import { toast } from 'sonner';
export default function Home() {
const { loading, isAuthenticated } = useAuth();
const { loading, user, logout, isSuperAdmin, canWrite } = useAuth();
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 [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 [showCADModel] = useState(true);
const [showWireframe, setShowWireframe] = useState(false);
@@ -133,6 +144,7 @@ export default function Home() {
setHeatmapViewResultId(null);
setPluginOverlay(null);
setPointCloudRenderMode('points');
setShowHeatmap(false);
setSelectedPcFileIds([]);
setSelectedTaskCadFileIds([]);
setRoiPickMode(false);
@@ -217,6 +229,10 @@ export default function Home() {
);
const handleLoadPointClouds = useCallback(async (taskId: number, _fileIds: number[]) => {
setHeatmapViewResultId(null);
setPluginOverlay(null);
setPointCloudRenderMode('points');
setShowHeatmap(false);
setPointCloudTaskId(taskId);
setPointCloudUrls([`__task:${taskId}`]);
setPcLoading(true);
@@ -281,7 +297,7 @@ export default function Home() {
}
return (
<div className="space-y-4">
<AlgorithmRunPanel
{canWrite && <AlgorithmRunPanel
taskId={selection.taskId}
modelId={selection.modelId}
pointCloudLoaded={pointCloudTaskId === selection.taskId && pointCloudUrls.length > 0}
@@ -297,8 +313,8 @@ export default function Home() {
setRoiPickedPoint(null);
}}
activeRoiPickTarget={activeRoiPickTarget}
/>
<div className="h-px bg-border" />
/>}
{canWrite && <div className="h-px bg-border" />}
<AnalysisPanel
taskId={selection.taskId}
onReplay={resultId => console.log('[Replay] resultId', resultId)}
@@ -306,6 +322,7 @@ export default function Home() {
setPluginOverlay(null);
setHeatmapViewResultId(resultId);
setPointCloudRenderMode('smoothHeatmap');
setShowHeatmap(true);
}}
onViewPluginOverlay={({ taskId, resultId, overlay, inputPointCloudFileIds }) => {
const expectedIds = inputPointCloudFileIds ?? [];
@@ -348,6 +365,7 @@ export default function Home() {
setHeatmapViewResultId(null);
setPluginOverlay({ taskId, resultId, overlay });
setPointCloudRenderMode(overlay.kind === 'vertexColors' ? 'smoothHeatmap' : 'points');
setShowHeatmap(overlay.kind === 'vertexColors');
}}
externalPending={runAnalysisJob.isPending}
onRunAssembly={() => {
@@ -420,10 +438,13 @@ export default function Home() {
{loading ? (
<div className="w-7 h-7 rounded bg-secondary animate-pulse" />
) : !isAuthenticated ? (
<a href={getLoginUrl()} className="industrial-btn industrial-btn-primary py-1.5">
<LogIn className="w-3.5 h-3.5" />
</a>
) : user ? (
<div className="flex items-center gap-2 text-xs">
<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>
{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}
</div>
</header>
@@ -481,6 +502,7 @@ export default function Home() {
setRoiPickedPoint(pt);
setRoiPickMode(false);
}}
demoFlowActive={demoFlowState.active}
className="w-full h-full"
/>
@@ -495,6 +517,8 @@ export default function Home() {
</div>
</div>
)}
<DemoFlowOverlay state={demoFlowState} pluginNameById={pluginNameById} />
</div>
{/* 操作提示 */}
@@ -549,6 +573,7 @@ export default function Home() {
setPointCloudRenderMode('points');
setHeatmapViewResultId(null);
setPluginOverlay(null);
setShowHeatmap(false);
setShowPointCloud(true);
return;
}
@@ -564,6 +589,7 @@ export default function Home() {
正在显示分析结果 #{heatmapViewResultId}
</span>
)}
{pointCloudRenderMode === 'smoothHeatmap' && (
<button
type="button"
className={`industrial-btn h-7 px-2 py-0 inline-flex items-center gap-1 text-[10px] ${showHeatmap ? 'industrial-btn-primary' : ''}`}
@@ -572,6 +598,7 @@ export default function Home() {
{showHeatmap ? <Eye className="w-3.5 h-3.5" /> : <EyeOff className="w-3.5 h-3.5" />}
<span>热力图</span>
</button>
)}
</div>
<div className="h-5 w-px bg-border" />