feat: show demo flow progress and elapsed time

This commit is contained in:
2026-07-24 15:38:50 +08:00
parent 9262390037
commit 66c7dd1b65
6 changed files with 404 additions and 114 deletions

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