feat: add selectable demo flow tasks

This commit is contained in:
2026-09-02 15:23:15 +08:00
parent f879328562
commit e3dc7ebe21
19 changed files with 871 additions and 110 deletions

View File

@@ -11,9 +11,10 @@
*/
import { useEffect, useState } from 'react';
import { trpc } from '@/lib/trpc';
import { AlertTriangle, CheckCircle2, ChevronDown, ChevronRight, Cpu, Play, RefreshCw, Scan, Square } from 'lucide-react';
import { AlertTriangle, CheckCircle2, ChevronDown, ChevronRight, Cpu, ListChecks, Play, RefreshCw, Scan, Square } from 'lucide-react';
import { toast } from 'sonner';
import TrainMeasurementPanel from './TrainMeasurementPanel';
import DemoTaskSelectionDialog from './DemoTaskSelectionDialog';
const DEMO_MEASUREMENT_PLUGIN_IDS = [
'train-measurement',
@@ -130,6 +131,19 @@ export default function AlgorithmRunPanel({ taskId, modelId, pointCloudLoaded: _
},
onError: (e) => toast.error(e.message || '一键全流程演示提交失败'),
});
const runDemoSelected = trpc.algorithm.runDemoSelected.useMutation({
onSuccess: ({ registrationJob, queuedMeasurementCount, selectedTaskNos }) => {
if (registrationJob) {
toast.success(`已提交自选 ${selectedTaskNos.length} 项;配准完成后将自动提交 ${queuedMeasurementCount} 个测量算法`);
}
if (taskId != null) {
void utils.analysisResult.list.invalidate({ taskId });
void utils.pointcloudFile.list.invalidate({ taskId });
}
if (modelId != null) void utils.inspectionTask.listByModel.invalidate({ modelId });
},
onError: (e) => toast.error(e.message || '选择任务运行提交失败'),
});
const cancelDemoAll = trpc.algorithm.cancelDemoAll.useMutation({
onSuccess: ({ cancelledIds }) => {
toast.success(cancelledIds.length > 0 ? `已停止 ${cancelledIds.length} 个运行任务` : '当前没有运行中的全流程任务');
@@ -151,10 +165,15 @@ export default function AlgorithmRunPanel({ taskId, modelId, pointCloudLoaded: _
{ taskId: taskId ?? 0 },
{ enabled: taskId != null, refetchInterval: 3000 },
);
const { data: demoTaskCatalog, isLoading: demoCatalogLoading } = trpc.algorithm.demoTaskCatalog.useQuery(
{ taskId: taskId ?? 0 },
{ enabled: taskId != null, staleTime: 60_000 },
);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [expanded, setExpanded] = useState<Set<string>>(new Set());
const [paramsById, setParamsById] = useState<Record<string, Record<string, unknown>>>({});
const [demoTaskDialogOpen, setDemoTaskDialogOpen] = useState(false);
// 首次拉到 plugins 后用默认值初始化每个表单
useEffect(() => {
@@ -202,13 +221,13 @@ export default function AlgorithmRunPanel({ taskId, modelId, pointCloudLoaded: _
...(hasDemoRegistrationPlugin ? ['vehicle-registration'] : []),
...demoMeasurementPluginIds,
];
const demoAlgorithmCount = demoAllPluginIds.length;
const demoMeasurementAlgorithmCount = demoMeasurementPluginIds.length;
const demoTaskCount = demoTaskCatalog?.total ?? 0;
const demoRunTitle = `先执行整车配准,完成后自动提交 ${demoMeasurementAlgorithmCount} 个测量算法`;
const demoPluginIds = new Set(demoAllPluginIds);
const runningDemoResults = analysisResults.filter((r) => (r.status === 'processing' || r.status === 'pending') && r.type === 'plugin' && r.pluginId && demoPluginIds.has(r.pluginId));
const demoFlowRunning = runningDemoResults.length > 0;
const demoFlowActive = demoFlowRunning || runDemoAll.isPending;
const demoFlowActive = demoFlowRunning || runDemoAll.isPending || runDemoSelected.isPending;
const selectedPcCount = selectedPcFileIds?.length ?? 0;
const selectedCadCount = selectedCadFileIds?.length ?? 0;
@@ -232,7 +251,7 @@ export default function AlgorithmRunPanel({ taskId, modelId, pointCloudLoaded: _
taskId == null ? '请先选择检测任务'
: (taskPcFileCount ?? 0) === 0 ? '请先上传点云文件'
: demoFlowRunning ? '一键全流程运行中,请先停止或等待完成'
: (runBatch.isPending || runDemoAll.isPending || cancelDemoAll.isPending) ? '算法提交中,请稍候'
: (runBatch.isPending || runDemoAll.isPending || runDemoSelected.isPending || cancelDemoAll.isPending) ? '算法提交中,请稍候'
: null;
const registrationBlockReason =
baseRunBlockReason
@@ -257,6 +276,8 @@ export default function AlgorithmRunPanel({ taskId, modelId, pointCloudLoaded: _
: selectedCadCount > 1 ? '一键全流程演示一次只能选择 1 个 CAD 文件'
: !hasDemoRegistrationPlugin ? '暂无可执行的整车配准算法'
: demoMeasurementPluginIds.length === 0 ? '暂无可执行的测量算法'
: demoCatalogLoading ? '正在加载任务目录'
: demoTaskCount === 0 ? '当前检测对象暂无可运行任务'
: null);
const canRunDemoAll = demoBlockReason == null;
const demoRouteHint =
@@ -337,6 +358,20 @@ export default function AlgorithmRunPanel({ taskId, modelId, pointCloudLoaded: _
});
};
const handleRunDemoSelected = (taskNos: number[]) => {
if (demoBlockReason) {
toast.error(demoBlockReason);
return;
}
if (taskId == null || !selectedPcFileIds?.[0] || !selectedCadFileIds?.[0]) return;
runDemoSelected.mutate({
taskId,
sourcePointCloudFileId: selectedPcFileIds[0],
cadFileId: selectedCadFileIds[0],
taskNos,
});
};
const handleCancelDemoAll = () => {
if (taskId == null || cancelDemoAll.isPending) return;
cancelDemoAll.mutate({ taskId });
@@ -353,7 +388,7 @@ export default function AlgorithmRunPanel({ taskId, modelId, pointCloudLoaded: _
<Cpu className="w-3.5 h-3.5" />
一键全流程演示
</span>
<div className="flex items-center gap-1">
<div className="flex flex-wrap items-center justify-end gap-1">
{demoFlowRunning && (
<span className="text-[9px] px-1.5 py-0.5 rounded bg-blue-900/40 text-blue-300 animate-pulse">
运行中 {runningDemoResults.length}
@@ -373,18 +408,32 @@ export default function AlgorithmRunPanel({ taskId, modelId, pointCloudLoaded: _
停止全流程
</button>
) : (
<button
type="button"
className={`industrial-btn industrial-btn-primary py-0.5 text-[10px] flex items-center gap-0.5 ${
canRunDemoAll ? '' : 'opacity-50 cursor-not-allowed'
}`}
disabled={!canRunDemoAll}
onClick={handleRunDemoAll}
title={demoBlockReason ?? demoRunTitle}
>
{runDemoAll.isPending ? <RefreshCw className="w-3 h-3 animate-spin" /> : <Play className="w-3 h-3" />}
运行全部 {demoAlgorithmCount} 个算法
</button>
<>
<button
type="button"
className={`industrial-btn industrial-btn-primary py-0.5 text-[10px] flex items-center gap-0.5 ${
canRunDemoAll ? '' : 'opacity-50 cursor-not-allowed'
}`}
disabled={!canRunDemoAll}
onClick={handleRunDemoAll}
title={demoBlockReason ?? demoRunTitle}
>
{runDemoAll.isPending ? <RefreshCw className="w-3 h-3 animate-spin" /> : <Play className="w-3 h-3" />}
运行全部任务({demoTaskCount}项)
</button>
<button
type="button"
className={`industrial-btn py-0.5 text-[10px] flex items-center gap-0.5 ${
taskId != null && !demoCatalogLoading && demoTaskCount > 0 ? '' : 'opacity-50 cursor-not-allowed'
}`}
disabled={taskId == null || demoCatalogLoading || demoTaskCount === 0}
onClick={() => setDemoTaskDialogOpen(true)}
title="选择正式任务并运行最小测量算法集合"
>
<ListChecks className="w-3 h-3" />
选择任务运行
</button>
</>
)}
</div>
</div>
@@ -523,6 +572,17 @@ export default function AlgorithmRunPanel({ taskId, modelId, pointCloudLoaded: _
/>
))}
</div>
<DemoTaskSelectionDialog
open={demoTaskDialogOpen}
onOpenChange={setDemoTaskDialogOpen}
items={demoTaskCatalog?.items ?? []}
total={demoTaskCount}
loading={demoCatalogLoading}
pending={runDemoSelected.isPending}
runBlockReason={demoBlockReason}
onRun={handleRunDemoSelected}
/>
</div>
);
}

View File

@@ -263,6 +263,9 @@ type DemoFlowMeta = {
role?: string;
index?: number;
total?: number;
mode?: 'all' | 'selected';
selectedTaskNos?: number[];
selectedTaskCount?: number;
};
const SHOW_LEGACY_ANALYSIS_ACTIONS = false;
@@ -293,6 +296,11 @@ function readDemoFlowMeta(result: AnalysisResultRow): DemoFlowMeta | null {
role: typeof data.demoFlowRole === 'string' ? data.demoFlowRole : undefined,
index: numberFromUnknown(data.demoFlowIndex),
total: numberFromUnknown(data.demoFlowTotal),
mode: data.demoFlowMode === 'selected' ? 'selected' : data.demoFlowMode === 'all' ? 'all' : undefined,
selectedTaskNos: Array.isArray(data.demoFlowSelectedTaskNos)
? data.demoFlowSelectedTaskNos.filter((value): value is number => typeof value === 'number' && Number.isInteger(value))
: undefined,
selectedTaskCount: numberFromUnknown(data.demoFlowSelectedTaskCount),
};
}
@@ -886,10 +894,16 @@ export default function AnalysisPanel({ taskId, onReplay, onViewSmoothHeatmap, o
const cardKey = `${variant}:${group.runId}`;
const resultsExpanded = !collapsedFlowResultCards.has(cardKey);
const artifactsExpanded = expandedFlowArtifactCards.has(cardKey);
const flowMeta = group.results.map(readDemoFlowMeta).find((meta) => meta?.mode) ?? null;
const selectedTaskNos = flowMeta?.mode === 'selected' ? flowMeta.selectedTaskNos : undefined;
const selectedTaskCount = flowMeta?.selectedTaskCount ?? selectedTaskNos?.length ?? 0;
const flowTitle = flowMeta?.mode === 'selected'
? `全流程算法结果 · 自选 ${selectedTaskCount} 项`
: '全流程算法结果 · 全部任务';
const aggregated = aggregateDemoFlowResults(
group.results,
(result) => getPluginDisplayName(result.pluginId, pluginNameById, getResultTitle(result, pluginNameById)),
{ includeAlgorithmGroups: SHOW_DEMO_FLOW_ALGORITHM_GROUP_ROWS },
{ includeAlgorithmGroups: SHOW_DEMO_FLOW_ALGORITHM_GROUP_ROWS, selectedTaskNos },
);
const sortedTableRows = SHOW_DEMO_FLOW_ALGORITHM_GROUP_ROWS
? aggregated.tableRows
@@ -908,7 +922,7 @@ export default function AnalysisPanel({ taskId, onReplay, onViewSmoothHeatmap, o
>
{expanded ? <ChevronDown className="w-3 h-3 text-muted-foreground" /> : <ChevronRight className="w-3 h-3 text-muted-foreground" />}
<Layers className="w-3.5 h-3.5 text-cyan-400 flex-shrink-0" />
<span className="text-xs flex-1 min-w-0 truncate">全流程算法结果</span>
<span className="text-xs flex-1 min-w-0 truncate">{flowTitle}</span>
<span className="text-[10px] text-muted-foreground flex-shrink-0">
{completed}/{total} 完成
</span>

View File

@@ -0,0 +1,219 @@
import { useEffect, useMemo, useState } from 'react';
import { Layers, Play, Search, XCircle } from 'lucide-react';
import { Button } from './ui/button';
import { Checkbox } from './ui/checkbox';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from './ui/dialog';
import { Input } from './ui/input';
export interface DemoTaskCatalogItem {
taskNo: number;
name: string;
category: string;
pluginId: string;
pluginName: string;
enabled: boolean;
disabledReason?: string;
}
interface Props {
open: boolean;
onOpenChange: (open: boolean) => void;
items: DemoTaskCatalogItem[];
total: number;
loading: boolean;
pending: boolean;
runBlockReason: string | null;
onRun: (taskNos: number[]) => void;
}
export default function DemoTaskSelectionDialog({
open,
onOpenChange,
items,
total,
loading,
pending,
runBlockReason,
onRun,
}: Props) {
const [search, setSearch] = useState('');
const [selected, setSelected] = useState<Set<number>>(new Set());
useEffect(() => {
if (!open) return;
setSearch('');
setSelected(new Set());
}, [open]);
const filteredItems = useMemo(() => {
const keyword = search.trim().toLocaleLowerCase('zh-CN');
if (!keyword) return items;
return items.filter((item) => (
String(item.taskNo).includes(keyword)
|| `任务${String(item.taskNo).padStart(2, '0')}`.toLocaleLowerCase('zh-CN').includes(keyword)
|| item.name.toLocaleLowerCase('zh-CN').includes(keyword)
));
}, [items, search]);
const selectableResults = filteredItems.filter((item) => item.enabled);
const selectedCountInResults = selectableResults.filter((item) => selected.has(item.taskNo)).length;
const selectAllState = selectableResults.length > 0 && selectedCountInResults === selectableResults.length
? true
: selectedCountInResults > 0
? 'indeterminate'
: false;
const selectedPluginCount = new Set(
items.filter((item) => selected.has(item.taskNo)).map((item) => item.pluginId),
).size;
const startBlockReason = selected.size === 0 ? '请至少选择一个任务' : runBlockReason;
const toggleTask = (taskNo: number) => {
setSelected((previous) => {
const next = new Set(previous);
if (next.has(taskNo)) next.delete(taskNo);
else next.add(taskNo);
return next;
});
};
const toggleAllResults = () => {
setSelected((previous) => {
const next = new Set(previous);
if (selectAllState === true) {
selectableResults.forEach((item) => next.delete(item.taskNo));
} else {
selectableResults.forEach((item) => next.add(item.taskNo));
}
return next;
});
};
const handleRun = () => {
if (startBlockReason) return;
onRun(Array.from(selected).sort((a, b) => a - b));
onOpenChange(false);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[88vh] w-[min(960px,calc(100vw-1rem))] max-w-none grid-rows-[auto_minmax(0,1fr)_auto] gap-0 overflow-hidden p-0 sm:max-w-none">
<DialogHeader className="border-b border-border/60 px-4 py-4 pr-12 sm:px-6">
<DialogTitle className="flex items-center gap-2 text-base sm:text-lg">
<Layers className="size-4 text-cyan-400" />
选择任务运行
</DialogTitle>
<DialogDescription>
当前检测对象可运行 {total} 项;系统仍会先执行 1 次整车配准。
</DialogDescription>
<div className="relative pt-2">
<Search className="pointer-events-none absolute left-3 top-[18px] size-4 text-muted-foreground" />
<Input
autoFocus
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder="搜索任务号或任务名称"
aria-label="搜索任务号或任务名称"
className="pl-9"
/>
</div>
<div className="flex min-h-8 flex-wrap items-center gap-3 pt-1 text-xs">
<label className="flex cursor-pointer items-center gap-2 rounded px-1 py-1 focus-within:ring-2 focus-within:ring-ring">
<Checkbox
checked={selectAllState}
onCheckedChange={toggleAllResults}
disabled={selectableResults.length === 0}
aria-label="全选当前搜索结果"
/>
全选当前结果({selectableResults.length})
</label>
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 px-2 text-xs"
onClick={() => setSelected(new Set())}
disabled={selected.size === 0}
>
<XCircle className="size-3.5" /> 清空
</Button>
</div>
</DialogHeader>
<div className="min-h-0 overflow-y-auto px-3 py-3 sm:px-6 sm:py-4">
{loading ? (
<div className="py-12 text-center text-sm text-muted-foreground">正在加载任务目录…</div>
) : filteredItems.length === 0 ? (
<div className="py-12 text-center text-sm text-muted-foreground">没有匹配的任务</div>
) : (
<div className="grid grid-cols-1 gap-2 md:grid-cols-2" role="group" aria-label="可选任务">
{filteredItems.map((item) => {
const checkboxId = `demo-task-${item.taskNo}`;
return (
<label
key={item.taskNo}
htmlFor={checkboxId}
className={`flex min-h-14 items-start gap-3 rounded-md border px-3 py-2.5 transition-colors motion-reduce:transition-none ${
item.enabled
? selected.has(item.taskNo)
? 'cursor-pointer border-primary/70 bg-primary/10 hover:bg-primary/15'
: 'cursor-pointer border-border/70 hover:border-primary/40 hover:bg-secondary/50'
: 'cursor-not-allowed border-border/40 bg-muted/20 opacity-55'
}`}
title={item.disabledReason}
>
<Checkbox
id={checkboxId}
checked={selected.has(item.taskNo)}
disabled={!item.enabled}
onCheckedChange={() => toggleTask(item.taskNo)}
aria-describedby={!item.enabled ? `${checkboxId}-reason` : undefined}
className="mt-0.5"
/>
<span className="min-w-0 flex-1">
<span className="flex items-center gap-2">
<span className="rounded bg-cyan-950/70 px-1.5 py-0.5 font-mono text-[10px] font-medium text-cyan-300">
任务 {String(item.taskNo).padStart(2, '0')}
</span>
<span className="rounded bg-secondary px-1.5 py-0.5 text-[9px] text-muted-foreground">
{item.category}
</span>
</span>
<span className="mt-1 block text-xs leading-5 text-foreground">{item.name}</span>
{!item.enabled && item.disabledReason && (
<span id={`${checkboxId}-reason`} className="mt-0.5 block text-[10px] text-amber-400">
{item.disabledReason}
</span>
)}
</span>
</label>
);
})}
</div>
)}
</div>
<DialogFooter className="border-t border-border/60 bg-background/95 px-4 py-3 backdrop-blur sm:items-center sm:justify-between sm:px-6">
<div className="text-left" aria-live="polite" aria-atomic="true">
<div className="text-xs font-medium">
已选 {selected.size} 项 · 将执行 {selectedPluginCount} 个测量算法 + 1 次整车配准
</div>
{startBlockReason && <div className="mt-0.5 text-[10px] text-amber-400">{startBlockReason}</div>}
</div>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" size="sm" onClick={() => onOpenChange(false)}>取消</Button>
<Button type="button" size="sm" onClick={handleRun} disabled={Boolean(startBlockReason) || pending}>
<Play className="size-3.5" />
{pending ? '提交中…' : '开始运行'}
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -84,6 +84,23 @@ describe('aggregateDemoFlowResults', () => {
]);
});
it('自选模式只聚合选中的正式任务,任务 5 保留左右梁两个位置', () => {
const results = [result(1, 1, 'completed', {
measurements: [
{ taskNo: '2', name: '车体长度', value: 1 },
{ taskNo: '5', name: '挠度', position: '左梁', value: 2 },
{ taskNo: '5', name: '挠度', position: '右梁', value: 3 },
{ taskNo: '10', name: '纵向中心右偏转', value: 4 },
],
})];
const model = aggregateDemoFlowResults(results, () => '算法', { selectedTaskNos: [5, 10] });
expect(model.measurementRows.map((row) => [row.measurement.taskNo, row.measurement.position])).toEqual([
['5', '左梁'],
['5', '右梁'],
['10', undefined],
]);
});
it('全流程结果使用统一结论文案,不采用算法自定义文案', () => {
expect(demoFlowStatusLabel({ status: 'pass' })).toBe('合格');
expect(demoFlowStatusLabel({ status: 'warning' })).toBe('警告');

View File

@@ -123,7 +123,7 @@ export function demoFlowStatusLabel(measurement: Pick<Measurement, 'status'>): s
export function aggregateDemoFlowResults(
results: DemoFlowSourceResult[],
getAlgorithmName: (result: DemoFlowSourceResult) => string,
options: { includeAlgorithmGroups?: boolean } = {},
options: { includeAlgorithmGroups?: boolean; selectedTaskNos?: readonly number[] } = {},
): DemoFlowAggregation {
const sorted = results
.map((result, originalIndex) => ({ result, originalIndex, index: flowIndex(result, originalIndex + 1) }))
@@ -132,12 +132,18 @@ export function aggregateDemoFlowResults(
const tableRows: DemoFlowTableRow[] = [];
const artifactGroups: DemoFlowArtifactGroup[] = [];
const statusNotices: DemoFlowStatusNotice[] = [];
const selectedTaskNos = options.selectedTaskNos
? new Set(options.selectedTaskNos.map(String))
: null;
for (const { result, index } of sorted) {
const data = (asRecord(result.resultData) ?? {}) as PluginResultLike & Record<string, unknown>;
const sourceAlgorithmId = result.pluginId ?? (typeof data.pluginId === 'string' ? data.pluginId : result.type);
const algorithmName = getAlgorithmName(result);
const measurements = filterDemoFlowMeasurements(Array.isArray(data.measurements) ? data.measurements : []);
const measurements = filterDemoFlowMeasurements(Array.isArray(data.measurements) ? data.measurements : [])
.filter((measurement) => !selectedTaskNos || (
typeof measurement.taskNo === 'string' && selectedTaskNos.has(measurement.taskNo)
));
if (measurements.length > 0 && options.includeAlgorithmGroups) {
tableRows.push({ kind: 'algorithm', sourceAlgorithmId, algorithmName, resultId: result.id, flowIndex: index });