feat: show demo flow progress and elapsed time
This commit is contained in:
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';
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user