diff --git a/client/src/components/AnalysisPanel.tsx b/client/src/components/AnalysisPanel.tsx index 5e730f7..b3b90fd 100644 --- a/client/src/components/AnalysisPanel.tsx +++ b/client/src/components/AnalysisPanel.tsx @@ -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 ( - - + {showDetails && } + {showDetails && } @@ -123,8 +126,8 @@ function MeasurementTable({ items }: { items: Measurement[] }) { {items.map((m, i) => ( - - + {showDetails && } + {showDetails && } - - + {showMeasurementDetails && } + {showMeasurementDetails && } @@ -217,12 +231,12 @@ export default function GenericResultView({ - - } + {showMeasurementDetails && + }
项目值公差值公差结果
{m.name}{m.value.toFixed(3)} {m.unit}±{m.tolerance}{m.value.toFixed(3)} {m.unit}±{m.tolerance} >(new Set()); const [expandedFlowIds, setExpandedFlowIds] = useState>(new Set()); + const [collapsedFlowResultCards, setCollapsedFlowResultCards] = useState>(new Set()); + const [expandedFlowArtifactCards, setExpandedFlowArtifactCards] = useState>(new Set()); + const [expandedFlowArtifactGroups, setExpandedFlowArtifactGroups] = useState>(new Set()); + const [expandedFlowArtifactExtras, setExpandedFlowArtifactExtras] = useState>(new Set()); const [maximizedOpen, setMaximizedOpen] = useState(false); const initializedDemoResultIds = useRef>(new Set()); const initializedDemoFlowIds = useRef>(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 )} - {isCancellableStatus(r.status) && ( + {canWrite && isCancellableStatus(r.status) && ( )} - + }
@@ -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' && (
{errorStats && } - {measurements && measurements.length > 0 && ( + {measurements.length > 0 && (
尺寸测量
- +
)}
@@ -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[0]['data']) ?? {}} + displayMode={variant} onViewOverlay={onViewPluginOverlay} />
@@ -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 ( +
+ {notice.algorithmName} + · {getStatusLabel(notice.status)}{progress} + {notice.detail && · {notice.detail}} +
+ ); + } + + 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 ( +
+ + + {groupExpanded &&
+ + {artifact.files.length > 0 && ( +
+
报表下载
+
+ {artifact.files.map((file) => ( + + + {file.label} + + ))} +
+
+ )} + + {artifact.images.length > 0 && ( +
+
产物图片
+
+ {artifact.images.map((image) => ( + + + {image} + + ))} +
+
+ )} + + {overlay.kind !== 'none' && onViewPluginOverlay && ( + + )} + + {artifact.extra && Object.keys(artifact.extra).length > 0 && ( +
+ + {extraExpanded && ( +
+                {JSON.stringify(artifact.extra, null, 2)}
+              
+ )} +
+ )} +
} +
+ ); + } + function renderDemoFlowCard(group: Extract, 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 (
@@ -719,13 +882,92 @@ export default function AnalysisPanel({ taskId, onReplay, onViewSmoothHeatmap, o
{expanded && (
- {runningItems.length > 0 && ( -
- 运行中:{runningItems.join('、')} -
- )} -
- {group.results.map((result) => renderResultCard(result))} +
+ + {resultsExpanded && ( +
+ {aggregated.statusNotices.length > 0 && ( +
+ {aggregated.statusNotices.map(renderFlowStatusNotice)} +
+ )} + {aggregated.tableRows.length === 0 ? ( +
暂无测量结果
+ ) : ( +
+ + + + + {showMeasurementDetails && } + {showMeasurementDetails && } + + + + + {aggregated.tableRows.map((row) => row.kind === 'algorithm' ? ( + + + + ) : ( + + + {showMeasurementDetails && } + {showMeasurementDetails && } + + + ))} + +
项目值公差结果
{row.algorithmName}
{row.measurement.name} + {row.measurement.baseline != null + ? `${formatValue(row.measurement.value)}/${row.measurement.baseline}mm` + : `${formatValue(row.measurement.value)} ${row.measurement.unit ?? ''}`} + + {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}` + : '—'} + + + {row.measurement.status === 'info' ? '参考' : statusLabel(row.measurement)} + +
+
+ )} +
+ )} +
+ +
+ + {artifactsExpanded && ( +
+ {aggregated.artifactGroups.length > 0 + ? aggregated.artifactGroups.map((artifact) => renderFlowArtifactGroup(artifact, cardKey)) + :
暂无算法产物
} +
+ )}
)} @@ -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) ))}
); @@ -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 && ( <> + + + ); + } + + return ( +
+
+
+
+ {state.overallStatus === 'processing' ? ( + + ) : ( + + )} +
+
+

{title}

+

+ {state.completed}/{state.total} 步骤已完成 · 总进度 {state.overallProgress}% +

+
+ +
+ + {/* 总进度条 */} +
+
+
+ + {/* 步骤条 */} +
+ {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 ( +
+ {idx !== state.steps.length - 1 && ( +
+ )} +
{statusIcon(step.status)}
+
+
+ {label} + + {step.status === 'processing' ? '运行中' + : step.status === 'pending' ? '排队中' + : step.status === 'completed' ? '已完成' + : step.status === 'error' ? '失败' + : step.status === 'cancelled' ? '已停止' + : step.status} + +
+ {showProgress && ( +
+
+
+
+
+ {Math.round((step.progress ?? 0) * 100)}% +
+
+ )} +
+
+ ); + })} +
+ + {state.overallStatus === 'processing' && state.currentStep && ( +
+ 当前正在执行:{stepLabel(state.currentStep, pluginNameById)} +
+ )} +
+
+ ); +} diff --git a/client/src/components/GenericResultView.tsx b/client/src/components/GenericResultView.tsx index af64684..4b8d79c 100644 --- a/client/src/components/GenericResultView.tsx +++ b/client/src/components/GenericResultView.tsx @@ -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(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({
项目值公差值公差结果
{m.name} + {showMeasurementDetails && {m.baseline != null ? `${formatValue(m.value)}/${m.baseline}mm` : `${formatValue(m.value)} ${m.unit ?? ''}`} - + {m.toleranceLabel != null ? m.toleranceLabel : m.tolerance != null @@ -230,7 +244,7 @@ export default function GenericResultView({ ? `${m.tolerance}/${m.baseline}mm` : `±${m.tolerance}` : '—'} - {m.status === 'info' ? '参考' : statusLabel(m)} diff --git a/client/src/components/PointCloudViewer.tsx b/client/src/components/PointCloudViewer.tsx index d300fe9..dcb5b43 100644 --- a/client/src/components/PointCloudViewer.tsx +++ b/client/src/components/PointCloudViewer.tsx @@ -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(null); + const sweepRef = useRef(null); + const particlesRef = useRef(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 ( + + {/* 旋转圆环 */} + + + + + + {/* 扫描平面 */} + + + + + + {/* 粒子环 */} + + + + + + + + + ); +}); + // 独立的 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(null); @@ -1378,6 +1464,9 @@ const SceneContent = memo(function SceneContent({ {/* Grid */} + {/* 一键全流程演示扫描动画 */} + + {/* PLY 叠加层(kind=ply 插件结果) */} {plyOverlayData && ( @@ -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(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} /> diff --git a/client/src/components/TaskManager.tsx b/client/src/components/TaskManager.tsx index 0212db6..bd1743f 100644 --- a/client/src/components/TaskManager.tsx +++ b/client/src/components/TaskManager.tsx @@ -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 = { + 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 = { @@ -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>(new Set()); const [uploadingModelPointcloud, setUploadingModelPointcloud] = useState(false); + const [renameTarget, setRenameTarget] = useState(null); + const [renameError, setRenameError] = useState(null); + const [metadataDetail, setMetadataDetail] = useState(null); // ── 点云文件多选状态 ────────────────────────────────────────────────────── const [selectedFileIds, setSelectedFileIds] = useState>(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({ 检测任务 - + } - {adding && ( + {canWrite && adding && (
{taskStatusLabels[status] ?? status} - - + }
); })} @@ -490,7 +622,7 @@ export default function TaskManager({ {tasks.length === 0 && (
- 暂无任务,点击新建任务 + {canWrite ? '暂无任务,点击新建任务' : '暂无任务'}
)} @@ -526,20 +658,20 @@ export default function TaskManager({
- - + } {pcFiles.length > 0 && (
-
+
{modelPcFiles.map((f) => { const isChecked = selectedModelPcFileIds.has(f.id); + const isEditing = renameTarget?.scope === 'model' && renameTarget.id === f.id; return ( - +
+
+ {isChecked ? ( + + ) : ( + + )} + + {isEditing && renameTarget ? ( +
event.stopPropagation()}> +
+ { + 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(); + } + }} + /> + {renameTarget.extension} + + +
+ {renameError &&
{renameError}
} +
+ ) : ( +
+ + + + {f.name} + + + {f.name} + +
+
+ + {formatCompactPointCount(f)} + + + {formatSize(f.fileSize)} + +
+
+ + +
+
+
+ )} +
+
); })} {modelPcFiles.length === 0 && ( @@ -647,6 +894,7 @@ export default function TaskManager({
{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,85 +908,194 @@ export default function TaskManager({ return (
toggleFile(f.id)} > - {/* 复选框 */} - - - - - {f.name} - - +
+ + + {isEditing && renameTarget ? ( +
event.stopPropagation()}> +
+ { + 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(); + } + }} + /> + {renameTarget.extension} + +
- - - {source === 'registered' && ( - - 已配准 - - )} - {source === 'registered' && meta.sourceResultId != null && ( - - R{meta.sourceResultId} - - )} - {source === 'model_library' && ( - - 车型库 - - )} - - {formatSize(f.fileSize)} - - + {renameError &&
{renameError}
} +
+ ) : ( +
+
+ + + + {f.name} + + + +
+
{f.name}
+ {source === 'registered' && ( + <> +
类型:已配准点云
+ {meta.sourceResultId != null &&
结果编号:R{meta.sourceResultId}
} +
生成时间:{formatDateTime(meta.createdAt)}
+
+ 来源: + {sourcePointcloud + ? sourcePointcloud.name + : meta.sourcePointcloudFileId != null + ? `点云 #${meta.sourcePointcloudFileId}` + : '旧记录未记录来源'} +
+ + )} +
+
+
+ {source === 'registered' && ( + + {meta.sourceResultId != null ? `R${meta.sourceResultId}` : '配准'} + + )} + {source === 'model_library' && ( + + 车型库 + + )} +
+
+
+ + {formatCompactPointCount(f)} + + + {formatSize(f.fileSize)} + +
+
+ + {canWrite && ( + + )} + {canWrite && } +
+
+
+ )} +
); })} @@ -783,20 +1140,20 @@ export default function TaskManager({ 全选 )} - - + }
@@ -811,7 +1168,7 @@ export default function TaskManager({
)} - {cadLibraryOpen && ( + {canWrite && cadLibraryOpen && (
@@ -955,7 +1312,7 @@ export default function TaskManager({ )} - + }
); })} @@ -1003,6 +1360,105 @@ export default function TaskManager({
)} + { if (!open) setMetadataDetail(null); }} + > + + + 点云属性信息 + 文件头中记录的点数、编码与每点字段。 + + {metadataDetail && ( +
+
+
+
文件名
+
{metadataDetail.name}
+
+
+
来源
+
{metadataDetail.sourceLabel}
+
+
+
文件大小
+
{formatSize(metadataDetail.fileSize)}
+
+
+
格式
+
{pointcloudFormat(metadataDetail.name)}
+
+
+
编码
+
+ {metadataDetail.pointDataFormat + ? pointDataFormatLabels[metadataDetail.pointDataFormat] ?? metadataDetail.pointDataFormat + : '未知'} +
+
+
+
精确点数
+
+ {metadataDetail.metadataStatus === 'ready' && metadataDetail.pointCount != null + ? metadataDetail.pointCount.toLocaleString('en-US') + : '点数未知'} +
+
+
+
每点字节数
+
+ {metadataDetail.bytesPerPoint == null ? '未知' : `${metadataDetail.bytesPerPoint} 字节`} +
+
+
+ + {metadataDetail.metadataStatus === 'ready' && metadataDetail.pointAttributes && ( +
+ + + + + + + + + + {metadataDetail.pointAttributes.map((attribute, index) => ( + + + + + + ))} + +
属性名称数据类型字节数
{attribute.name}{attribute.type}{attribute.bytes}
+
+ )} + + {metadataDetail.metadataStatus === 'unsupported' && ( +
+
点数未知
+
+ {unsupportedMetadataMessage(metadataDetail.name)} +
+
+ )} + {metadataDetail.metadataStatus === 'error' && ( +
+
属性解析失败
+
无法从该文件的 PLY 文件头读取属性信息。
+
+ )} + {metadataDetail.metadataStatus === 'pending' && ( +
+ 属性信息正在解析,请稍后刷新。 +
+ )} +
+ )} +
+
+ {/* 隐藏的文件 input,支持多选 */} void; + canEdit: boolean; }) { const [editing, setEditing] = useState(false); const [draft, setDraft] = useState(value); @@ -35,7 +38,7 @@ function EditableText({ return ( { setDraft(value); setEditing(true); }} + onDoubleClick={() => { if (canEdit) { setDraft(value); setEditing(true); } }} > {value} @@ -60,6 +63,7 @@ function EditableText({ } export default function VehicleTree({ selection, onSelect }: Props) { + const { canWrite } = useAuth(); const utils = trpc.useUtils(); const [expandedSeries, setExpandedSeries] = useState>(new Set()); const [newSeriesName, setNewSeriesName] = useState(''); @@ -113,15 +117,15 @@ export default function VehicleTree({ selection, onSelect }: Props) {
车系 / 车型 / 任务 - + }
- {addingSeries && ( + {canWrite && addingSeries && (
updateSeries.mutate({ id: series.id, name })} /> - + {canWrite && - + }
{/* 新增车型输入 */} - {addingModelFor === series.id && ( + {canWrite && addingModelFor === series.id && (
updateModel.mutate({ id: model.id, name })} /> - + {canWrite && - + }
@@ -283,7 +289,7 @@ export default function VehicleTree({ selection, onSelect }: Props) { {seriesList.length === 0 && (
- 暂无车系,点击右上角新增 + {canWrite ? '暂无车系,点击右上角新增' : '暂无车系'}
)} diff --git a/client/src/components/demoFlowAggregation.test.ts b/client/src/components/demoFlowAggregation.test.ts new file mode 100644 index 0000000..fb2b075 --- /dev/null +++ b/client/src/components/demoFlowAggregation.test.ts @@ -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): 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' }, + ]); + }); +}); diff --git a/client/src/components/demoFlowAggregation.ts b/client/src/components/demoFlowAggregation.ts new file mode 100644 index 0000000..ea7765d --- /dev/null +++ b/client/src/components/demoFlowAggregation.ts @@ -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; +}; + +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 | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + return value as Record; +} + +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; + 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 }; +} diff --git a/client/src/hooks/useDemoFlowState.ts b/client/src/hooks/useDemoFlowState.ts new file mode 100644 index 0000000..eb1f6ef --- /dev/null +++ b/client/src/hooks/useDemoFlowState.ts @@ -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 | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + return value as Record; +} + +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]); +} diff --git a/client/src/pages/Home.tsx b/client/src/pages/Home.tsx index e127d85..793ad37 100644 --- a/client/src/pages/Home.tsx +++ b/client/src/pages/Home.tsx @@ -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(); + 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 (
- 0} @@ -297,8 +313,8 @@ export default function Home() { setRoiPickedPoint(null); }} activeRoiPickTarget={activeRoiPickTarget} - /> -
+ />} + {canWrite &&
} 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 ? (
- ) : !isAuthenticated ? ( - - - + ) : user ? ( +
+
{user.username}
{user.role === 'super_admin' ? '超级管理员' : user.role === 'admin' ? '管理员' : '普通用户'}
+ {isSuperAdmin && } + 修改密码 + +
) : null}
@@ -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() {
)} + +
{/* 操作提示 */} @@ -549,6 +573,7 @@ export default function Home() { setPointCloudRenderMode('points'); setHeatmapViewResultId(null); setPluginOverlay(null); + setShowHeatmap(false); setShowPointCloud(true); return; } @@ -564,14 +589,16 @@ export default function Home() { 正在显示分析结果 #{heatmapViewResultId}
)} - + {pointCloudRenderMode === 'smoothHeatmap' && ( + + )}