Compare commits

..

2 Commits

Author SHA1 Message Date
66c7dd1b65 feat: show demo flow progress and elapsed time 2026-07-24 15:38:50 +08:00
9262390037 feat: support PLY meshes in vehicle registration 2026-07-24 15:38:10 +08:00
8 changed files with 433 additions and 116 deletions

View File

@@ -173,6 +173,33 @@ def step_to_open3d_mesh(step_path, mesh_deflection=0.1):
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
if cad_pcd is None:
cad_mesh = step_to_open3d_mesh(
cad_mesh = load_cad_mesh(
cad_path,
mesh_deflection=mesh_deflection
)

View File

@@ -1,6 +1,11 @@
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';
import {
formatElapsedTime,
type DemoFlowState,
type DemoFlowStep,
type DemoFlowStepStatus,
} from '@/hooks/useDemoFlowState';
interface DemoFlowOverlayProps {
state: DemoFlowState;
@@ -64,16 +69,13 @@ function overallStatusLabel(status: DemoFlowState['overallStatus']): string {
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]);
const title = useMemo(
() => state.completedPointCount == null
? '点云数量暂不可用'
: `已完成 ${(state.completedPointCount / 1e8).toFixed(2)} 亿点云的测量`,
[state.completedPointCount],
);
const elapsedTime = formatElapsedTime(state.elapsedMs);
if (!state.active && state.overallStatus === 'idle') return null;
@@ -91,11 +93,12 @@ export default function DemoFlowOverlay({ state, pluginNameById }: DemoFlowOverl
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="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="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 className="truncate text-[10px] font-medium" title={title}>{title}</div>
<div className="mb-1.5 flex items-center justify-between gap-3 text-[9px] text-muted-foreground">
<span className="truncate">{overallStatusLabel(state.overallStatus)} · {elapsedTime}</span>
<span className="flex-shrink-0 font-mono">{state.overallProgress}%</span>
</div>
<div className="h-1 w-full overflow-hidden rounded-full bg-secondary">
<div
@@ -120,7 +123,7 @@ export default function DemoFlowOverlay({ state, pluginNameById }: DemoFlowOverl
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="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="w-8 h-8 rounded-lg bg-primary/20 flex items-center justify-center">
{state.overallStatus === 'processing' ? (
@@ -130,9 +133,9 @@ export default function DemoFlowOverlay({ state, pluginNameById }: DemoFlowOverl
)}
</div>
<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">
{state.completed}/{state.total} 步骤已完成 · 总进度 {state.overallProgress}%
{state.completed}/{state.total} 步骤已完成 · 总进度 {state.overallProgress}% · 算法运行时间 {elapsedTime}
</p>
</div>
<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="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">
<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">
{step.status === 'processing' ? '运行中'
: step.status === 'pending' ? '排队中'

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

View File

@@ -1,7 +1,7 @@
import { useMemo } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { trpc } from '@/lib/trpc';
type AnalysisResultRow = {
export type AnalysisResultRow = {
id: number;
taskId?: number | null;
type: string;
@@ -12,6 +12,12 @@ type AnalysisResultRow = {
resultData: unknown;
};
export type PointcloudFileRow = {
id: number;
metadataStatus?: string | null;
pointCount?: number | null;
};
function asRecord(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
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;
}
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 interface DemoFlowStep {
@@ -43,6 +63,12 @@ export interface DemoFlowState {
overallStatus: DemoFlowStepStatus | 'idle';
currentStep: DemoFlowStep | null;
overallProgress: number;
sourcePointCloudFileId: number | null;
sourcePointCount: number | null;
completedPointCount: number | null;
startedAt: number | null;
endedAt: number | null;
elapsedMs: number;
}
const EMPTY_STATE: DemoFlowState = {
@@ -54,108 +80,194 @@ const EMPTY_STATE: DemoFlowState = {
overallStatus: 'idle',
currentStep: null,
overallProgress: 0,
sourcePointCloudFileId: null,
sourcePointCount: null,
completedPointCount: null,
startedAt: null,
endedAt: null,
elapsedMs: 0,
};
function isActiveStatus(step: DemoFlowStep): boolean {
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 {
const { data: results = [] } = trpc.analysisResult.list.useQuery(
{ taskId: taskId ?? 0 },
{ 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(() => {
if (taskId == null || results.length === 0) return EMPTY_STATE;
const state = useMemo(
() => aggregateDemoFlowState(
taskId,
results as AnalysisResultRow[],
pointcloudFiles as PointcloudFileRow[],
nowMs,
),
[nowMs, pointcloudFiles, results, taskId],
);
const rows = results as AnalysisResultRow[];
const steps: DemoFlowStep[] = [];
let activeRunId: string | null = null;
let declaredTotal = 0;
useEffect(() => {
setNowMs(Date.now());
if (!state.active) return;
const intervalId = window.setInterval(() => setNowMs(Date.now()), 1000);
return () => window.clearInterval(intervalId);
}, [state.active, state.runId]);
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]);
return state;
}

View File

@@ -2,7 +2,7 @@ import { useState, useCallback, useEffect, useMemo } from 'react';
import { useAuth } from '@/_core/hooks/useAuth';
import PointCloudViewer from '@/components/PointCloudViewer';
import DemoFlowOverlay from '@/components/DemoFlowOverlay';
import { useDemoFlowState } from '@/hooks/useDemoFlowState';
import { formatElapsedTime, useDemoFlowState } from '@/hooks/useDemoFlowState';
import VehicleTree, { TreeSelection } from '@/components/VehicleTree';
import TaskManager from '@/components/TaskManager';
import AnalysisPanel from '@/components/AnalysisPanel';
@@ -26,6 +26,21 @@ import { toast } from 'sonner';
export default function Home() {
const { loading, user, logout, isSuperAdmin, canWrite } = useAuth();
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>({
@@ -440,7 +455,14 @@ export default function Home() {
<div className="w-7 h-7 rounded bg-secondary animate-pulse" />
) : 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>
<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>}
<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>

View File

@@ -187,6 +187,7 @@ const DEMO_FLOW_RESULT_DATA_KEYS = [
'demoFlowTotal',
'demoFlowSourcePointCloudFileId',
'demoFlowCadFileId',
'demoFlowCancelledAt',
] as const;
async function readDemoFlowResultData(resultId: number): Promise<Record<string, unknown>> {

View File

@@ -43,7 +43,7 @@ def main() -> int:
pc_path = first_pointcloud(payload)
cad_path = inputs.get("cadPath")
if not cad_path:
raise RuntimeError("整车配准需要 STEP CAD 文件,请在任务下上传并选中 CAD。")
raise RuntimeError("整车配准需要 CAD 文件(STEP 或 PLY mesh),请在任务下上传并选中 CAD。")
axis = str(config.get("axis") or "x")
crop_ratio = float(config.get("cropRatio") or 0.01)

View File

@@ -1262,6 +1262,26 @@ export const appRouter = router({
.mutation(async ({ input }) => {
markDemoFlowCancelled(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 {
...result,
scheduler: algorithmJobStats(),