Compare commits
2 Commits
33c5fb9b9c
...
66c7dd1b65
| Author | SHA1 | Date | |
|---|---|---|---|
| 66c7dd1b65 | |||
| 9262390037 |
@@ -173,6 +173,33 @@ def step_to_open3d_mesh(step_path, mesh_deflection=0.1):
|
|||||||
return mesh
|
return mesh
|
||||||
|
|
||||||
|
|
||||||
|
def load_cad_mesh(cad_path, mesh_deflection=0.1):
|
||||||
|
"""根据后缀加载 CAD mesh:STEP 走 OCC 离散化,PLY 直接读取三角网格。"""
|
||||||
|
ext = os.path.splitext(cad_path)[1].lower()
|
||||||
|
|
||||||
|
if ext in (".step", ".stp"):
|
||||||
|
return step_to_open3d_mesh(cad_path, mesh_deflection=mesh_deflection)
|
||||||
|
|
||||||
|
if ext == ".ply":
|
||||||
|
print(f"[INFO] Loading CAD mesh from PLY: {cad_path}")
|
||||||
|
mesh = o3d.io.read_triangle_mesh(cad_path)
|
||||||
|
if len(mesh.vertices) == 0 or len(mesh.triangles) == 0:
|
||||||
|
raise RuntimeError(f"PLY CAD mesh 为空或读取失败: {cad_path}")
|
||||||
|
|
||||||
|
mesh.remove_duplicated_vertices()
|
||||||
|
mesh.remove_duplicated_triangles()
|
||||||
|
mesh.remove_degenerate_triangles()
|
||||||
|
mesh.compute_vertex_normals()
|
||||||
|
|
||||||
|
print(
|
||||||
|
f"[OK] PLY CAD mesh loaded: {len(mesh.vertices)} vertices, "
|
||||||
|
f"{len(mesh.triangles)} triangles"
|
||||||
|
)
|
||||||
|
return mesh
|
||||||
|
|
||||||
|
raise RuntimeError(f"不支持的 CAD 格式: {cad_path}(仅支持 .step/.stp/.ply)")
|
||||||
|
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# 工具函数
|
# 工具函数
|
||||||
# ============================================================
|
# ============================================================
|
||||||
@@ -529,7 +556,7 @@ def translate_align_by_front_tail(
|
|||||||
cad_pcd = None
|
cad_pcd = None
|
||||||
|
|
||||||
if cad_pcd is None:
|
if cad_pcd is None:
|
||||||
cad_mesh = step_to_open3d_mesh(
|
cad_mesh = load_cad_mesh(
|
||||||
cad_path,
|
cad_path,
|
||||||
mesh_deflection=mesh_deflection
|
mesh_deflection=mesh_deflection
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { Check, Loader2, Circle, X, AlertTriangle, Cpu, Scan, Minimize2, Maximize2 } from 'lucide-react';
|
import { Check, Loader2, Circle, X, AlertTriangle, Cpu, Scan, Minimize2, Maximize2 } from 'lucide-react';
|
||||||
import type { DemoFlowState, DemoFlowStep, DemoFlowStepStatus } from '@/hooks/useDemoFlowState';
|
import {
|
||||||
|
formatElapsedTime,
|
||||||
|
type DemoFlowState,
|
||||||
|
type DemoFlowStep,
|
||||||
|
type DemoFlowStepStatus,
|
||||||
|
} from '@/hooks/useDemoFlowState';
|
||||||
|
|
||||||
interface DemoFlowOverlayProps {
|
interface DemoFlowOverlayProps {
|
||||||
state: DemoFlowState;
|
state: DemoFlowState;
|
||||||
@@ -64,16 +69,13 @@ function overallStatusLabel(status: DemoFlowState['overallStatus']): string {
|
|||||||
|
|
||||||
export default function DemoFlowOverlay({ state, pluginNameById }: DemoFlowOverlayProps) {
|
export default function DemoFlowOverlay({ state, pluginNameById }: DemoFlowOverlayProps) {
|
||||||
const [collapsedRunIds, setCollapsedRunIds] = useState<Set<string>>(() => new Set());
|
const [collapsedRunIds, setCollapsedRunIds] = useState<Set<string>>(() => new Set());
|
||||||
const title = useMemo(() => {
|
const title = useMemo(
|
||||||
switch (state.overallStatus) {
|
() => state.completedPointCount == null
|
||||||
case 'processing': return '一键全流程演示运行中';
|
? '点云数量暂不可用'
|
||||||
case 'pending': return '一键全流程演示排队中';
|
: `已完成 ${(state.completedPointCount / 1e8).toFixed(2)} 亿点云的测量`,
|
||||||
case 'completed': return '一键全流程演示已完成';
|
[state.completedPointCount],
|
||||||
case 'error': return '一键全流程演示失败';
|
);
|
||||||
case 'cancelled': return '一键全流程演示已停止';
|
const elapsedTime = formatElapsedTime(state.elapsedMs);
|
||||||
default: return '一键全流程演示';
|
|
||||||
}
|
|
||||||
}, [state.overallStatus]);
|
|
||||||
|
|
||||||
if (!state.active && state.overallStatus === 'idle') return null;
|
if (!state.active && state.overallStatus === 'idle') return null;
|
||||||
|
|
||||||
@@ -91,11 +93,12 @@ export default function DemoFlowOverlay({ state, pluginNameById }: DemoFlowOverl
|
|||||||
if (isCollapsed) {
|
if (isCollapsed) {
|
||||||
return (
|
return (
|
||||||
<div className="absolute inset-0 z-30 pointer-events-none">
|
<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="industrial-panel pointer-events-auto absolute top-9 right-3 flex w-72 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="min-w-0 flex-1">
|
||||||
<div className="mb-1.5 flex items-center justify-between gap-3 text-[10px]">
|
<div className="truncate text-[10px] font-medium" title={title}>{title}</div>
|
||||||
<span className="truncate font-medium">一键全流程 · {overallStatusLabel(state.overallStatus)}</span>
|
<div className="mb-1.5 flex items-center justify-between gap-3 text-[9px] text-muted-foreground">
|
||||||
<span className="flex-shrink-0 font-mono text-muted-foreground">{state.overallProgress}%</span>
|
<span className="truncate">{overallStatusLabel(state.overallStatus)} · {elapsedTime}</span>
|
||||||
|
<span className="flex-shrink-0 font-mono">{state.overallProgress}%</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="h-1 w-full overflow-hidden rounded-full bg-secondary">
|
<div className="h-1 w-full overflow-hidden rounded-full bg-secondary">
|
||||||
<div
|
<div
|
||||||
@@ -120,7 +123,7 @@ export default function DemoFlowOverlay({ state, pluginNameById }: DemoFlowOverl
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="absolute inset-0 z-30 flex items-center justify-center pointer-events-none">
|
<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="industrial-panel pointer-events-auto mx-4 w-full max-w-lg border border-border/60 bg-[oklch(0.1_0.01_240)]/90 px-5 py-5 shadow-2xl backdrop-blur-sm sm:px-6">
|
||||||
<div className="flex items-center gap-3 mb-4">
|
<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">
|
<div className="w-8 h-8 rounded-lg bg-primary/20 flex items-center justify-center">
|
||||||
{state.overallStatus === 'processing' ? (
|
{state.overallStatus === 'processing' ? (
|
||||||
@@ -130,9 +133,9 @@ export default function DemoFlowOverlay({ state, pluginNameById }: DemoFlowOverl
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<h3 className="text-sm font-medium truncate">{title}</h3>
|
<h3 className="break-words text-sm font-medium">{title}</h3>
|
||||||
<p className="text-[10px] text-muted-foreground">
|
<p className="text-[10px] text-muted-foreground">
|
||||||
{state.completed}/{state.total} 步骤已完成 · 总进度 {state.overallProgress}%
|
{state.completed}/{state.total} 步骤已完成 · 总进度 {state.overallProgress}% · 算法运行时间 {elapsedTime}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@@ -166,9 +169,9 @@ export default function DemoFlowOverlay({ state, pluginNameById }: DemoFlowOverl
|
|||||||
<div className="absolute left-[9px] top-5 bottom-[-16px] w-px bg-border/40" />
|
<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="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={`min-w-0 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">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<span className="text-xs font-medium truncate">{label}</span>
|
<span className="min-w-0 flex-1 truncate text-xs font-medium">{label}</span>
|
||||||
<span className="text-[9px] text-muted-foreground flex-shrink-0">
|
<span className="text-[9px] text-muted-foreground flex-shrink-0">
|
||||||
{step.status === 'processing' ? '运行中'
|
{step.status === 'processing' ? '运行中'
|
||||||
: step.status === 'pending' ? '排队中'
|
: step.status === 'pending' ? '排队中'
|
||||||
|
|||||||
132
client/src/hooks/useDemoFlowState.test.ts
Normal file
132
client/src/hooks/useDemoFlowState.test.ts
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
aggregateDemoFlowState,
|
||||||
|
formatElapsedTime,
|
||||||
|
type AnalysisResultRow,
|
||||||
|
} from './useDemoFlowState';
|
||||||
|
|
||||||
|
const STARTED_AT = '2026-07-24T00:00:10.000Z';
|
||||||
|
|
||||||
|
function result(
|
||||||
|
overrides: Partial<AnalysisResultRow> & Pick<AnalysisResultRow, 'id' | 'status'>,
|
||||||
|
): AnalysisResultRow {
|
||||||
|
return {
|
||||||
|
taskId: 1,
|
||||||
|
type: 'plugin',
|
||||||
|
pluginId: 'vehicle-registration',
|
||||||
|
createdAt: STARTED_AT,
|
||||||
|
updatedAt: STARTED_AT,
|
||||||
|
resultData: {
|
||||||
|
demoFlowRunId: 'run-1',
|
||||||
|
demoFlowRole: 'registration',
|
||||||
|
demoFlowIndex: 1,
|
||||||
|
demoFlowTotal: 2,
|
||||||
|
demoFlowSourcePointCloudFileId: 42,
|
||||||
|
progress: 0.7,
|
||||||
|
},
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('aggregateDemoFlowState', () => {
|
||||||
|
it('按源点云总点数和整体进度估算已完成点云量', () => {
|
||||||
|
const state = aggregateDemoFlowState(
|
||||||
|
1,
|
||||||
|
[result({ id: 1, status: 'processing' })],
|
||||||
|
[{ id: 42, metadataStatus: 'ready', pointCount: 1_000_000_000 }],
|
||||||
|
new Date('2026-07-24T00:00:20.000Z').getTime(),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(state.overallProgress).toBe(35);
|
||||||
|
expect(state.completedPointCount).toBe(350_000_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('元数据缺失时不生成虚假的点云数量', () => {
|
||||||
|
const state = aggregateDemoFlowState(
|
||||||
|
1,
|
||||||
|
[result({ id: 1, status: 'processing' })],
|
||||||
|
[{ id: 42, metadataStatus: 'pending', pointCount: null }],
|
||||||
|
new Date('2026-07-24T00:00:20.000Z').getTime(),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(state.sourcePointCount).toBeNull();
|
||||||
|
expect(state.completedPointCount).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('运行中使用当前时间计时,结束后冻结在最后更新时间', () => {
|
||||||
|
const running = aggregateDemoFlowState(
|
||||||
|
1,
|
||||||
|
[result({ id: 1, status: 'processing' })],
|
||||||
|
[],
|
||||||
|
new Date('2026-07-24T00:00:40.000Z').getTime(),
|
||||||
|
);
|
||||||
|
const completed = aggregateDemoFlowState(
|
||||||
|
1,
|
||||||
|
[result({
|
||||||
|
id: 1,
|
||||||
|
status: 'completed',
|
||||||
|
updatedAt: '2026-07-24T00:00:25.000Z',
|
||||||
|
resultData: {
|
||||||
|
demoFlowRunId: 'run-1',
|
||||||
|
demoFlowRole: 'registration',
|
||||||
|
demoFlowIndex: 1,
|
||||||
|
demoFlowTotal: 1,
|
||||||
|
demoFlowSourcePointCloudFileId: 42,
|
||||||
|
progress: 1,
|
||||||
|
},
|
||||||
|
})],
|
||||||
|
[],
|
||||||
|
new Date('2026-07-24T00:01:40.000Z').getTime(),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(running.elapsedMs).toBe(30_000);
|
||||||
|
expect(completed.elapsedMs).toBe(15_000);
|
||||||
|
expect(completed.endedAt).toBe(new Date('2026-07-24T00:00:25.000Z').getTime());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('配准失败后把未创建步骤标为停止,不再聚合成排队中', () => {
|
||||||
|
const state = aggregateDemoFlowState(
|
||||||
|
1,
|
||||||
|
[result({ id: 1, status: 'error', updatedAt: '2026-07-24T00:00:25.000Z' })],
|
||||||
|
[],
|
||||||
|
new Date('2026-07-24T00:00:40.000Z').getTime(),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(state.active).toBe(false);
|
||||||
|
expect(state.overallStatus).toBe('error');
|
||||||
|
expect(state.steps[1]?.status).toBe('cancelled');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('停止标记可让已完成配准后的占位步骤和计时正确冻结', () => {
|
||||||
|
const cancelledAt = '2026-07-24T00:00:28.000Z';
|
||||||
|
const state = aggregateDemoFlowState(
|
||||||
|
1,
|
||||||
|
[result({
|
||||||
|
id: 1,
|
||||||
|
status: 'completed',
|
||||||
|
updatedAt: cancelledAt,
|
||||||
|
resultData: {
|
||||||
|
demoFlowRunId: 'run-1',
|
||||||
|
demoFlowRole: 'registration',
|
||||||
|
demoFlowIndex: 1,
|
||||||
|
demoFlowTotal: 2,
|
||||||
|
demoFlowSourcePointCloudFileId: 42,
|
||||||
|
progress: 1,
|
||||||
|
demoFlowCancelledAt: cancelledAt,
|
||||||
|
},
|
||||||
|
})],
|
||||||
|
[],
|
||||||
|
new Date('2026-07-24T00:01:40.000Z').getTime(),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(state.overallStatus).toBe('cancelled');
|
||||||
|
expect(state.steps[1]?.status).toBe('cancelled');
|
||||||
|
expect(state.elapsedMs).toBe(18_000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('formatElapsedTime', () => {
|
||||||
|
it('小时超过 24 后继续累计', () => {
|
||||||
|
expect(formatElapsedTime((27 * 3600 + 2 * 60 + 3) * 1000)).toBe('27:02:03');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useMemo } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { trpc } from '@/lib/trpc';
|
import { trpc } from '@/lib/trpc';
|
||||||
|
|
||||||
type AnalysisResultRow = {
|
export type AnalysisResultRow = {
|
||||||
id: number;
|
id: number;
|
||||||
taskId?: number | null;
|
taskId?: number | null;
|
||||||
type: string;
|
type: string;
|
||||||
@@ -12,6 +12,12 @@ type AnalysisResultRow = {
|
|||||||
resultData: unknown;
|
resultData: unknown;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type PointcloudFileRow = {
|
||||||
|
id: number;
|
||||||
|
metadataStatus?: string | null;
|
||||||
|
pointCount?: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
||||||
return value as Record<string, unknown>;
|
return value as Record<string, unknown>;
|
||||||
@@ -21,6 +27,20 @@ function numberFromUnknown(value: unknown): number | undefined {
|
|||||||
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function timestampFromUnknown(value: unknown): number | null {
|
||||||
|
if (typeof value !== 'string' && !(value instanceof Date)) return null;
|
||||||
|
const timestamp = new Date(value).getTime();
|
||||||
|
return Number.isFinite(timestamp) ? timestamp : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatElapsedTime(elapsedMs: number): string {
|
||||||
|
const totalSeconds = Math.max(0, Math.floor(elapsedMs / 1000));
|
||||||
|
const hours = Math.floor(totalSeconds / 3600);
|
||||||
|
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||||
|
const seconds = totalSeconds % 60;
|
||||||
|
return [hours, minutes, seconds].map((part) => String(part).padStart(2, '0')).join(':');
|
||||||
|
}
|
||||||
|
|
||||||
export type DemoFlowStepStatus = 'pending' | 'processing' | 'completed' | 'error' | 'cancelled';
|
export type DemoFlowStepStatus = 'pending' | 'processing' | 'completed' | 'error' | 'cancelled';
|
||||||
|
|
||||||
export interface DemoFlowStep {
|
export interface DemoFlowStep {
|
||||||
@@ -43,6 +63,12 @@ export interface DemoFlowState {
|
|||||||
overallStatus: DemoFlowStepStatus | 'idle';
|
overallStatus: DemoFlowStepStatus | 'idle';
|
||||||
currentStep: DemoFlowStep | null;
|
currentStep: DemoFlowStep | null;
|
||||||
overallProgress: number;
|
overallProgress: number;
|
||||||
|
sourcePointCloudFileId: number | null;
|
||||||
|
sourcePointCount: number | null;
|
||||||
|
completedPointCount: number | null;
|
||||||
|
startedAt: number | null;
|
||||||
|
endedAt: number | null;
|
||||||
|
elapsedMs: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const EMPTY_STATE: DemoFlowState = {
|
const EMPTY_STATE: DemoFlowState = {
|
||||||
@@ -54,108 +80,194 @@ const EMPTY_STATE: DemoFlowState = {
|
|||||||
overallStatus: 'idle',
|
overallStatus: 'idle',
|
||||||
currentStep: null,
|
currentStep: null,
|
||||||
overallProgress: 0,
|
overallProgress: 0,
|
||||||
|
sourcePointCloudFileId: null,
|
||||||
|
sourcePointCount: null,
|
||||||
|
completedPointCount: null,
|
||||||
|
startedAt: null,
|
||||||
|
endedAt: null,
|
||||||
|
elapsedMs: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
function isActiveStatus(step: DemoFlowStep): boolean {
|
function isActiveStatus(step: DemoFlowStep): boolean {
|
||||||
return step.status === 'processing' || step.status === 'pending';
|
return step.status === 'processing' || step.status === 'pending';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function aggregateDemoFlowState(
|
||||||
|
taskId: number | null,
|
||||||
|
results: AnalysisResultRow[],
|
||||||
|
pointcloudFiles: PointcloudFileRow[],
|
||||||
|
nowMs: number,
|
||||||
|
): DemoFlowState {
|
||||||
|
if (taskId == null || results.length === 0) return EMPTY_STATE;
|
||||||
|
|
||||||
|
const steps: DemoFlowStep[] = [];
|
||||||
|
let activeRunId: string | null = null;
|
||||||
|
let declaredTotal = 0;
|
||||||
|
let sourcePointCloudFileId: number | null = null;
|
||||||
|
let cancelledAt: number | null = null;
|
||||||
|
|
||||||
|
for (const r of results) {
|
||||||
|
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) {
|
||||||
|
// 分析结果按 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;
|
||||||
|
const sourceId = numberFromUnknown(data.demoFlowSourcePointCloudFileId);
|
||||||
|
const rowCancelledAt = timestampFromUnknown(data.demoFlowCancelledAt);
|
||||||
|
if (stepTotal > declaredTotal) declaredTotal = stepTotal;
|
||||||
|
if (sourcePointCloudFileId == null && sourceId != null) sourcePointCloudFileId = sourceId;
|
||||||
|
if (rowCancelledAt != null && (cancelledAt == null || rowCancelledAt > cancelledAt)) {
|
||||||
|
cancelledAt = rowCancelledAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 || activeRunId == null) return EMPTY_STATE;
|
||||||
|
|
||||||
|
steps.sort((a, b) => a.index - b.index);
|
||||||
|
const actualSteps = [...steps];
|
||||||
|
const total = Math.max(actualSteps.length, declaredTotal);
|
||||||
|
const hasActiveActualStep = actualSteps.some(isActiveStatus);
|
||||||
|
const hasError = actualSteps.some((step) => step.status === 'error');
|
||||||
|
const hasCancelled = cancelledAt != null || actualSteps.some((step) => step.status === 'cancelled');
|
||||||
|
const terminalStatus: 'error' | 'cancelled' | null = !hasActiveActualStep
|
||||||
|
? hasError
|
||||||
|
? 'error'
|
||||||
|
: hasCancelled
|
||||||
|
? 'cancelled'
|
||||||
|
: null
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// 链路已失败或停止时,尚未创建的步骤不会再执行,不能继续显示为“排队中”。
|
||||||
|
for (let index = actualSteps.length + 1; index <= total; index += 1) {
|
||||||
|
steps.push({
|
||||||
|
id: -index,
|
||||||
|
index,
|
||||||
|
role: index === 1 ? 'registration' : 'measurement',
|
||||||
|
pluginId: null,
|
||||||
|
status: terminalStatus == null ? 'pending' : 'cancelled',
|
||||||
|
createdAt: steps[0]?.createdAt ?? new Date(nowMs),
|
||||||
|
updatedAt: steps[0]?.updatedAt ?? new Date(nowMs),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const completed = steps.filter((step) => step.status === 'completed').length;
|
||||||
|
let overallStatus: DemoFlowState['overallStatus'];
|
||||||
|
if (actualSteps.some((step) => step.status === 'processing')) overallStatus = 'processing';
|
||||||
|
else if (actualSteps.some((step) => step.status === 'pending')) overallStatus = 'pending';
|
||||||
|
else if (terminalStatus != null) overallStatus = terminalStatus;
|
||||||
|
else if (actualSteps.length === total && actualSteps.every((step) => step.status === 'completed')) {
|
||||||
|
overallStatus = 'completed';
|
||||||
|
} else {
|
||||||
|
overallStatus = 'pending';
|
||||||
|
}
|
||||||
|
|
||||||
|
const active = overallStatus === 'processing' || overallStatus === 'pending';
|
||||||
|
const currentStep =
|
||||||
|
steps.find((step) => step.status === 'processing') ??
|
||||||
|
steps.find((step) => step.status === 'pending') ??
|
||||||
|
actualSteps[actualSteps.length - 1] ??
|
||||||
|
null;
|
||||||
|
const doneProgress = total > 0 ? completed / total : 0;
|
||||||
|
const currentProgress = currentStep && isActiveStatus(currentStep) && total > 0
|
||||||
|
? Math.min(1, Math.max(0, currentStep.progress ?? 0)) / total
|
||||||
|
: 0;
|
||||||
|
const overallProgress = Math.min(100, Math.round((doneProgress + currentProgress) * 100));
|
||||||
|
|
||||||
|
const sourcePointcloud = sourcePointCloudFileId == null
|
||||||
|
? undefined
|
||||||
|
: pointcloudFiles.find((file) => file.id === sourcePointCloudFileId);
|
||||||
|
const sourcePointCount =
|
||||||
|
sourcePointcloud?.metadataStatus === 'ready'
|
||||||
|
&& typeof sourcePointcloud.pointCount === 'number'
|
||||||
|
&& Number.isFinite(sourcePointcloud.pointCount)
|
||||||
|
&& sourcePointcloud.pointCount >= 0
|
||||||
|
? sourcePointcloud.pointCount
|
||||||
|
: null;
|
||||||
|
const completedPointCount =
|
||||||
|
sourcePointCount == null ? null : sourcePointCount * (overallProgress / 100);
|
||||||
|
|
||||||
|
const startTimestamps = actualSteps
|
||||||
|
.map((step) => timestampFromUnknown(step.createdAt))
|
||||||
|
.filter((value): value is number => value != null);
|
||||||
|
const updateTimestamps = actualSteps
|
||||||
|
.map((step) => timestampFromUnknown(step.updatedAt))
|
||||||
|
.filter((value): value is number => value != null);
|
||||||
|
const startedAt = startTimestamps.length > 0 ? Math.min(...startTimestamps) : null;
|
||||||
|
const endedAt = active
|
||||||
|
? null
|
||||||
|
: cancelledAt ?? (updateTimestamps.length > 0 ? Math.max(...updateTimestamps) : startedAt);
|
||||||
|
const elapsedMs = startedAt == null
|
||||||
|
? 0
|
||||||
|
: Math.max(0, (endedAt ?? nowMs) - startedAt);
|
||||||
|
|
||||||
|
return {
|
||||||
|
active,
|
||||||
|
runId: activeRunId,
|
||||||
|
total,
|
||||||
|
completed,
|
||||||
|
steps,
|
||||||
|
overallStatus,
|
||||||
|
currentStep,
|
||||||
|
overallProgress,
|
||||||
|
sourcePointCloudFileId,
|
||||||
|
sourcePointCount,
|
||||||
|
completedPointCount,
|
||||||
|
startedAt,
|
||||||
|
endedAt,
|
||||||
|
elapsedMs,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function useDemoFlowState(taskId: number | null): DemoFlowState {
|
export function useDemoFlowState(taskId: number | null): DemoFlowState {
|
||||||
const { data: results = [] } = trpc.analysisResult.list.useQuery(
|
const { data: results = [] } = trpc.analysisResult.list.useQuery(
|
||||||
{ taskId: taskId ?? 0 },
|
{ taskId: taskId ?? 0 },
|
||||||
{ enabled: taskId != null, refetchInterval: 3000 },
|
{ enabled: taskId != null, refetchInterval: 3000 },
|
||||||
);
|
);
|
||||||
|
const { data: pointcloudFiles = [] } = trpc.pointcloudFile.list.useQuery(
|
||||||
|
{ taskId: taskId ?? 0 },
|
||||||
|
{ enabled: taskId != null },
|
||||||
|
);
|
||||||
|
const [nowMs, setNowMs] = useState(() => Date.now());
|
||||||
|
|
||||||
return useMemo(() => {
|
const state = useMemo(
|
||||||
if (taskId == null || results.length === 0) return EMPTY_STATE;
|
() => aggregateDemoFlowState(
|
||||||
|
taskId,
|
||||||
|
results as AnalysisResultRow[],
|
||||||
|
pointcloudFiles as PointcloudFileRow[],
|
||||||
|
nowMs,
|
||||||
|
),
|
||||||
|
[nowMs, pointcloudFiles, results, taskId],
|
||||||
|
);
|
||||||
|
|
||||||
const rows = results as AnalysisResultRow[];
|
useEffect(() => {
|
||||||
const steps: DemoFlowStep[] = [];
|
setNowMs(Date.now());
|
||||||
let activeRunId: string | null = null;
|
if (!state.active) return;
|
||||||
let declaredTotal = 0;
|
const intervalId = window.setInterval(() => setNowMs(Date.now()), 1000);
|
||||||
|
return () => window.clearInterval(intervalId);
|
||||||
|
}, [state.active, state.runId]);
|
||||||
|
|
||||||
for (const r of rows) {
|
return state;
|
||||||
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]);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useState, useCallback, useEffect, useMemo } from 'react';
|
|||||||
import { useAuth } from '@/_core/hooks/useAuth';
|
import { useAuth } from '@/_core/hooks/useAuth';
|
||||||
import PointCloudViewer from '@/components/PointCloudViewer';
|
import PointCloudViewer from '@/components/PointCloudViewer';
|
||||||
import DemoFlowOverlay from '@/components/DemoFlowOverlay';
|
import DemoFlowOverlay from '@/components/DemoFlowOverlay';
|
||||||
import { useDemoFlowState } from '@/hooks/useDemoFlowState';
|
import { formatElapsedTime, useDemoFlowState } from '@/hooks/useDemoFlowState';
|
||||||
import VehicleTree, { TreeSelection } from '@/components/VehicleTree';
|
import VehicleTree, { TreeSelection } from '@/components/VehicleTree';
|
||||||
import TaskManager from '@/components/TaskManager';
|
import TaskManager from '@/components/TaskManager';
|
||||||
import AnalysisPanel from '@/components/AnalysisPanel';
|
import AnalysisPanel from '@/components/AnalysisPanel';
|
||||||
@@ -26,6 +26,21 @@ import { toast } from 'sonner';
|
|||||||
export default function Home() {
|
export default function Home() {
|
||||||
const { loading, user, logout, isSuperAdmin, canWrite } = useAuth();
|
const { loading, user, logout, isSuperAdmin, canWrite } = useAuth();
|
||||||
const utils = trpc.useUtils();
|
const utils = trpc.useUtils();
|
||||||
|
const [sessionElapsedMs, setSessionElapsedMs] = useState(0);
|
||||||
|
|
||||||
|
// 本次使用时间只属于当前页面会话;刷新或重新登录后从零开始。
|
||||||
|
useEffect(() => {
|
||||||
|
if (!user) {
|
||||||
|
setSessionElapsedMs(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const startedAt = Date.now();
|
||||||
|
setSessionElapsedMs(0);
|
||||||
|
const intervalId = window.setInterval(() => {
|
||||||
|
setSessionElapsedMs(Date.now() - startedAt);
|
||||||
|
}, 1000);
|
||||||
|
return () => window.clearInterval(intervalId);
|
||||||
|
}, [user?.id]);
|
||||||
|
|
||||||
// ── 树导航选中状态 ─────────────────────────────────────────────────────────
|
// ── 树导航选中状态 ─────────────────────────────────────────────────────────
|
||||||
const [selection, setSelection] = useState<TreeSelection>({
|
const [selection, setSelection] = useState<TreeSelection>({
|
||||||
@@ -440,7 +455,14 @@ export default function Home() {
|
|||||||
<div className="w-7 h-7 rounded bg-secondary animate-pulse" />
|
<div className="w-7 h-7 rounded bg-secondary animate-pulse" />
|
||||||
) : user ? (
|
) : user ? (
|
||||||
<div className="flex items-center gap-2 text-xs">
|
<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>
|
<div className="text-right leading-tight">
|
||||||
|
<div>{user.username}</div>
|
||||||
|
<div className="whitespace-nowrap text-[10px] text-muted-foreground">
|
||||||
|
{user.role === 'super_admin' ? '超级管理员' : user.role === 'admin' ? '管理员' : '普通用户'}
|
||||||
|
<span className="mx-1">·</span>
|
||||||
|
本次使用时间 <span className="font-mono tabular-nums">{formatElapsedTime(sessionElapsedMs)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{isSuperAdmin && <a href="/users" className="industrial-btn py-1.5" title="用户管理"><Users className="w-3.5 h-3.5" /></a>}
|
{isSuperAdmin && <a href="/users" className="industrial-btn py-1.5" title="用户管理"><Users className="w-3.5 h-3.5" /></a>}
|
||||||
<a href="/change-password" className="industrial-btn py-1.5">修改密码</a>
|
<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>
|
<button className="industrial-btn py-1.5" title="退出登录" onClick={() => void logout()}><LogOut className="w-3.5 h-3.5" /></button>
|
||||||
|
|||||||
@@ -187,6 +187,7 @@ const DEMO_FLOW_RESULT_DATA_KEYS = [
|
|||||||
'demoFlowTotal',
|
'demoFlowTotal',
|
||||||
'demoFlowSourcePointCloudFileId',
|
'demoFlowSourcePointCloudFileId',
|
||||||
'demoFlowCadFileId',
|
'demoFlowCadFileId',
|
||||||
|
'demoFlowCancelledAt',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
async function readDemoFlowResultData(resultId: number): Promise<Record<string, unknown>> {
|
async function readDemoFlowResultData(resultId: number): Promise<Record<string, unknown>> {
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ def main() -> int:
|
|||||||
pc_path = first_pointcloud(payload)
|
pc_path = first_pointcloud(payload)
|
||||||
cad_path = inputs.get("cadPath")
|
cad_path = inputs.get("cadPath")
|
||||||
if not cad_path:
|
if not cad_path:
|
||||||
raise RuntimeError("整车配准需要 STEP CAD 文件,请在任务下上传并选中 CAD。")
|
raise RuntimeError("整车配准需要 CAD 文件(STEP 或 PLY mesh),请在任务下上传并选中 CAD。")
|
||||||
|
|
||||||
axis = str(config.get("axis") or "x")
|
axis = str(config.get("axis") or "x")
|
||||||
crop_ratio = float(config.get("cropRatio") or 0.01)
|
crop_ratio = float(config.get("cropRatio") or 0.01)
|
||||||
|
|||||||
@@ -1262,6 +1262,26 @@ export const appRouter = router({
|
|||||||
.mutation(async ({ input }) => {
|
.mutation(async ({ input }) => {
|
||||||
markDemoFlowCancelled(input.taskId);
|
markDemoFlowCancelled(input.taskId);
|
||||||
const result = await cancelTaskAnalysisResultsByTask(input.taskId, "用户停止一键全流程演示");
|
const result = await cancelTaskAnalysisResultsByTask(input.taskId, "用户停止一键全流程演示");
|
||||||
|
const rows = await db.listTaskAnalysisResults(input.taskId);
|
||||||
|
const latestDemoRow = rows.find((row) => {
|
||||||
|
const data = row.resultData as Record<string, unknown> | null;
|
||||||
|
return typeof data?.demoFlowRunId === "string";
|
||||||
|
});
|
||||||
|
if (latestDemoRow) {
|
||||||
|
const latestDemoData = latestDemoRow.resultData as Record<string, unknown>;
|
||||||
|
const latestRunId = latestDemoData.demoFlowRunId;
|
||||||
|
const cancelledAt = new Date().toISOString();
|
||||||
|
// 即使停止发生在“配准完成、测量记录尚未创建”的短暂窗口,
|
||||||
|
// 也在本次链路的全部现有记录上留下结束标记,避免刷新后重新显示为排队中。
|
||||||
|
await Promise.all(
|
||||||
|
rows
|
||||||
|
.filter((row) => {
|
||||||
|
const data = row.resultData as Record<string, unknown> | null;
|
||||||
|
return data?.demoFlowRunId === latestRunId;
|
||||||
|
})
|
||||||
|
.map((row) => db.patchTaskAnalysisResultData(row.id, { demoFlowCancelledAt: cancelledAt })),
|
||||||
|
);
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
...result,
|
...result,
|
||||||
scheduler: algorithmJobStats(),
|
scheduler: algorithmJobStats(),
|
||||||
|
|||||||
Reference in New Issue
Block a user