diff --git a/client/src/components/AlgorithmRunPanel.tsx b/client/src/components/AlgorithmRunPanel.tsx index 68c0bc7..b5edea6 100644 --- a/client/src/components/AlgorithmRunPanel.tsx +++ b/client/src/components/AlgorithmRunPanel.tsx @@ -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>(new Set()); const [expanded, setExpanded] = useState>(new Set()); const [paramsById, setParamsById] = useState>>({}); + 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: _ 一键全流程演示 -
+
{demoFlowRunning && ( 运行中 {runningDemoResults.length} @@ -373,18 +408,32 @@ export default function AlgorithmRunPanel({ taskId, modelId, pointCloudLoaded: _ 停止全流程 ) : ( - + <> + + + )}
@@ -523,6 +572,17 @@ export default function AlgorithmRunPanel({ taskId, modelId, pointCloudLoaded: _ /> ))} + + ); } diff --git a/client/src/components/AnalysisPanel.tsx b/client/src/components/AnalysisPanel.tsx index bdac908..d908ae3 100644 --- a/client/src/components/AnalysisPanel.tsx +++ b/client/src/components/AnalysisPanel.tsx @@ -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 ? : } - 全流程算法结果 + {flowTitle} {completed}/{total} 完成 diff --git a/client/src/components/DemoTaskSelectionDialog.tsx b/client/src/components/DemoTaskSelectionDialog.tsx new file mode 100644 index 0000000..b512aa5 --- /dev/null +++ b/client/src/components/DemoTaskSelectionDialog.tsx @@ -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>(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 ( + + + + + + 选择任务运行 + + + 当前检测对象可运行 {total} 项;系统仍会先执行 1 次整车配准。 + +
+ + setSearch(event.target.value)} + placeholder="搜索任务号或任务名称" + aria-label="搜索任务号或任务名称" + className="pl-9" + /> +
+
+ + +
+
+ +
+ {loading ? ( +
正在加载任务目录…
+ ) : filteredItems.length === 0 ? ( +
没有匹配的任务
+ ) : ( +
+ {filteredItems.map((item) => { + const checkboxId = `demo-task-${item.taskNo}`; + return ( + + ); + })} +
+ )} +
+ + +
+
+ 已选 {selected.size} 项 · 将执行 {selectedPluginCount} 个测量算法 + 1 次整车配准 +
+ {startBlockReason &&
{startBlockReason}
} +
+
+ + +
+
+
+
+ ); +} diff --git a/client/src/components/demoFlowAggregation.test.ts b/client/src/components/demoFlowAggregation.test.ts index a917416..2808dba 100644 --- a/client/src/components/demoFlowAggregation.test.ts +++ b/client/src/components/demoFlowAggregation.test.ts @@ -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('警告'); diff --git a/client/src/components/demoFlowAggregation.ts b/client/src/components/demoFlowAggregation.ts index 38fb0ad..df21e66 100644 --- a/client/src/components/demoFlowAggregation.ts +++ b/client/src/components/demoFlowAggregation.ts @@ -123,7 +123,7 @@ export function demoFlowStatusLabel(measurement: Pick): 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; 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 }); diff --git a/server/algorithmJobRunner.ts b/server/algorithmJobRunner.ts index d32fb3d..1e758e8 100644 --- a/server/algorithmJobRunner.ts +++ b/server/algorithmJobRunner.ts @@ -201,6 +201,10 @@ const DEMO_FLOW_RESULT_DATA_KEYS = [ 'demoFlowSourcePointCloudFileId', 'demoFlowCadFileId', 'demoFlowCancelledAt', + 'demoFlowMode', + 'demoFlowSelectedTaskNos', + 'demoFlowSelectedTaskCount', + 'demoFlowPluginTaskNos', ] as const; async function readDemoFlowResultData(resultId: number): Promise> { @@ -389,8 +393,19 @@ export async function runAlgorithmJob( algorithmProtocolVersion: result?.schemaVersion ?? manifest.schemaVersion, }); logCompatibilityWarnings(resultId, normalized.warnings); + const requestedDemoTaskNos = Array.isArray(params.demoTaskNos) + ? new Set( + params.demoTaskNos + .filter((value): value is number => typeof value === 'number' && Number.isInteger(value)) + .map(String), + ) + : null; const normalizedMeasurements = Array.isArray(result?.measurements) - ? normalized.measurements + ? requestedDemoTaskNos + ? normalized.measurements.filter((measurement) => ( + typeof measurement.taskNo === 'string' && requestedDemoTaskNos.has(measurement.taskNo) + )) + : normalized.measurements : undefined; const resultData: PluginResultData = { kind: 'plugin', diff --git a/server/demoFlow.test.ts b/server/demoFlow.test.ts new file mode 100644 index 0000000..c19fe92 --- /dev/null +++ b/server/demoFlow.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from 'vitest'; +import { buildDemoTaskCatalog, resolveSelectedDemoJobs } from './demoFlow'; +import { listPlugins } from './plugins/registry'; +import type { PluginManifest } from './plugins/types'; + +function manifest(id: string, tasks: PluginManifest['demoTasks']): PluginManifest { + return { + id, + name: id, + description: id, + version: '1', + schemaVersion: 1, + demoTasks: tasks, + demoSelection: { kind: 'task-nos', param: 'demoTaskNos' }, + runtime: { kind: 'mock', fixture: 'unused.json' }, + }; +} + +const manifests = [ + manifest('distance', [ + { taskNo: 2, name: '车体长度', category: '尺寸', inspectionObjects: ['head', 'middle'] }, + ]), + manifest('contour', [ + { taskNo: 11, name: '车钩面板的平整度', category: '轮廓', inspectionObjects: ['head', 'middle'] }, + ]), + manifest('coupler', [ + { taskNo: 9, name: '参考面右偏转', category: '车钩', inspectionObjects: ['middle'] }, + { taskNo: 10, name: '纵向中心右偏转', category: '车钩', inspectionObjects: ['head'] }, + ]), +]; + +describe('demo task catalog', () => { + it('按数值排序并标记不适用于当前检测对象的任务', () => { + const catalog = buildDemoTaskCatalog(manifests, 'head', '头车'); + expect(catalog.items.map((item) => item.taskNo)).toEqual([2, 9, 10, 11]); + expect(catalog.total).toBe(3); + expect(catalog.items.find((item) => item.taskNo === 9)).toMatchObject({ enabled: false }); + }); + + it('跨算法选择转换为最小作业集合并去重', () => { + const catalog = buildDemoTaskCatalog(manifests, 'head', '头车'); + const plan = resolveSelectedDemoJobs(manifests, catalog, [11, 2, 10, 2]); + expect(plan.taskNos).toEqual([2, 10, 11]); + expect(plan.jobs.map((job) => job.pluginId)).toEqual(['distance', 'coupler', 'contour']); + expect(plan.jobs).toHaveLength(3); + }); + + it('拒绝当前检测对象不可运行的任务', () => { + const catalog = buildDemoTaskCatalog(manifests, 'head', '头车'); + expect(() => resolveSelectedDemoJobs(manifests, catalog, [9])).toThrow('不适用于当前头车'); + }); +}); + +describe('actual plugin demo task catalog', () => { + const actualManifests = listPlugins(); + + it('返回真实非连续任务号、名称与检测对象动态数量', () => { + const head = buildDemoTaskCatalog(actualManifests, 'head', '头车'); + const middle = buildDemoTaskCatalog(actualManifests, 'middle', '中车'); + expect(head.total).toBe(39); + expect(middle.total).toBe(26); + expect(head.items.find((item) => item.taskNo === 11)?.name).toBe('车钩面板的平整度'); + expect(head.items.some((item) => item.taskNo === 3)).toBe(false); + expect(head.items.map((item) => item.taskNo)).toEqual( + [...head.items.map((item) => item.taskNo)].sort((a, b) => a - b), + ); + }); + + it('任务 2、11、10 生成三个插件作业,单个车钩任务只生成一次共享作业', () => { + const head = buildDemoTaskCatalog(actualManifests, 'head', '头车'); + const mixed = resolveSelectedDemoJobs(actualManifests, head, [2, 11, 10]); + expect(mixed.jobs.map((job) => job.pluginId)).toEqual([ + 'train-measurement', + 'coupler-panel', + 'flatness-suite', + ]); + const couplerOnly = resolveSelectedDemoJobs(actualManifests, head, [8]); + expect(couplerOnly.jobs).toEqual([{ + pluginId: 'coupler-panel', + taskNos: [8], + params: { demoTaskNos: [8] }, + }]); + }); +}); diff --git a/server/demoFlow.ts b/server/demoFlow.ts new file mode 100644 index 0000000..70e424d --- /dev/null +++ b/server/demoFlow.ts @@ -0,0 +1,120 @@ +import type { + DemoInspectionObject, + PluginDemoTask, + PluginManifest, +} from './plugins/types.js'; + +export interface DemoTaskCatalogItem extends PluginDemoTask { + pluginId: string; + pluginName: string; + enabled: boolean; + disabledReason?: string; +} + +export interface DemoTaskCatalog { + inspectionObject: DemoInspectionObject | 'unknown'; + inspectionObjectName?: string; + total: number; + items: DemoTaskCatalogItem[]; +} + +export interface DemoSelectedPluginJob { + pluginId: string; + taskNos: number[]; + params: Record; +} + +function disabledReasonFor( + task: PluginDemoTask, + inspectionObject: DemoInspectionObject | 'unknown', + inspectionObjectName?: string, +): string | undefined { + if (!task.inspectionObjects?.length) return undefined; + if (inspectionObject === 'unknown') { + return inspectionObjectName + ? `检测对象“${inspectionObjectName}”尚未配置任务适用规则` + : '当前任务尚未确认头车或中车检测对象'; + } + if (!task.inspectionObjects.includes(inspectionObject)) { + return `不适用于当前${inspectionObject === 'head' ? '头车' : '中车'}检测对象`; + } + return undefined; +} + +/** 从插件 manifest 构建服务端权威任务目录。 */ +export function buildDemoTaskCatalog( + manifests: readonly PluginManifest[], + inspectionObject: DemoInspectionObject | 'unknown', + inspectionObjectName?: string, +): DemoTaskCatalog { + const items: DemoTaskCatalogItem[] = []; + const claimedTaskNos = new Map(); + + for (const manifest of manifests) { + for (const task of manifest.demoTasks ?? []) { + const previousPlugin = claimedTaskNos.get(task.taskNo); + if (previousPlugin) { + throw new Error(`演示任务 ${task.taskNo} 被插件 ${previousPlugin} 与 ${manifest.id} 重复声明`); + } + claimedTaskNos.set(task.taskNo, manifest.id); + const disabledReason = disabledReasonFor(task, inspectionObject, inspectionObjectName); + items.push({ + ...task, + pluginId: manifest.id, + pluginName: manifest.name, + enabled: disabledReason == null, + ...(disabledReason ? { disabledReason } : {}), + }); + } + } + + items.sort((a, b) => a.taskNo - b.taskNo); + return { + inspectionObject, + ...(inspectionObjectName ? { inspectionObjectName } : {}), + total: items.filter((item) => item.enabled).length, + items, + }; +} + +/** 校验所选任务并转换为最小插件作业集合。 */ +export function resolveSelectedDemoJobs( + manifests: readonly PluginManifest[], + catalog: DemoTaskCatalog, + requestedTaskNos: readonly number[], +): { taskNos: number[]; jobs: DemoSelectedPluginJob[] } { + const taskNos = Array.from(new Set(requestedTaskNos)).sort((a, b) => a - b); + if (taskNos.length === 0) throw new Error('请至少选择一个任务'); + + const itemByTaskNo = new Map(catalog.items.map((item) => [item.taskNo, item])); + for (const taskNo of taskNos) { + const item = itemByTaskNo.get(taskNo); + if (!item) throw new Error(`任务 ${taskNo} 不存在或当前算法未提供`); + if (!item.enabled) throw new Error(`任务 ${taskNo} 当前不可执行:${item.disabledReason ?? '运行条件不满足'}`); + } + + const taskNosByPlugin = new Map(); + for (const taskNo of taskNos) { + const pluginId = itemByTaskNo.get(taskNo)!.pluginId; + const pluginTaskNos = taskNosByPlugin.get(pluginId) ?? []; + pluginTaskNos.push(taskNo); + taskNosByPlugin.set(pluginId, pluginTaskNos); + } + + const manifestById = new Map(manifests.map((manifest) => [manifest.id, manifest])); + const jobs: DemoSelectedPluginJob[] = []; + for (const [pluginId, pluginTaskNos] of Array.from(taskNosByPlugin.entries())) { + const manifest = manifestById.get(pluginId); + if (!manifest) throw new Error(`未注册的算法插件:${pluginId}`); + if (manifest.demoSelection?.kind !== 'task-nos' || !manifest.demoSelection.param) { + throw new Error(`算法插件 ${pluginId} 未配置任务选择执行方式`); + } + jobs.push({ + pluginId, + taskNos: pluginTaskNos, + params: { [manifest.demoSelection.param]: pluginTaskNos }, + }); + } + + return { taskNos, jobs }; +} diff --git a/server/plugins/adapters/coupler_panel_run.py b/server/plugins/adapters/coupler_panel_run.py index fec1c49..b3f6015 100644 --- a/server/plugins/adapters/coupler_panel_run.py +++ b/server/plugins/adapters/coupler_panel_run.py @@ -155,23 +155,53 @@ def main() -> int: offset_right = 700.0 * _math.tan(_math.radians(raw_right)) if 'raw_right' in dir() else 700.0 * _math.tan(_math.radians(theta_right)) offset_ref = 375.0 * _math.tan(_math.radians(raw_ref)) if 'raw_ref' in dir() else 375.0 * _math.tan(_math.radians(theta_ref)) - measurements = [ - { + inspection_object = str((payload.get("context") or {}).get("inspectionObject") or "unknown") + longitudinal_task_no = "10" if inspection_object == "head" else "12" if inspection_object == "middle" else None + reference_task_no = "8" if inspection_object == "head" else "9" if inspection_object == "middle" else None + raw_selected_task_nos = config.get("demoTaskNos") + selected_task_nos = ( + {str(int(value)) for value in raw_selected_task_nos if isinstance(value, (int, float)) and float(value).is_integer()} + if isinstance(raw_selected_task_nos, list) + else None + ) + + def _selected(task_no: str | None) -> bool: + return selected_task_nos is None or (task_no is not None and task_no in selected_task_nos) + + measurements = [] + if _selected(longitudinal_task_no): + longitudinal_measurement = { "name": "车钩面板距纵向中心的右偏转角", "value": round(offset_right, 6), "unit": "mm", "baseline": 700.0, "tolerance": 1.0, "status": _status_for(offset_right, 1.0), - }, - { + } + if longitudinal_task_no: + longitudinal_measurement.update({ + "taskNo": longitudinal_task_no, + "metricCode": "coupler.longitudinal_right_offset", + "name": "纵向中心右偏转", + }) + measurements.append(longitudinal_measurement) + if _selected(reference_task_no): + reference_measurement = { "name": "车钩面板距参考面的右偏转角", "value": round(offset_ref, 6), "unit": "mm", "baseline": 375.0, "tolerance": 0.8, "status": _status_for(offset_ref, 0.8), - }, + } + if reference_task_no: + reference_measurement.update({ + "taskNo": reference_task_no, + "metricCode": "coupler.reference_right_offset", + "name": "参考面右偏转", + }) + measurements.append(reference_measurement) + measurements.extend([ { "name": "最大 x 偏差 dx_y", "value": dx_y, @@ -198,7 +228,7 @@ def main() -> int: "unit": "mm", "status": "info", }, - ] + ]) payload_out = { "schemaVersion": getattr(result, "schema_version", "1.1"), diff --git a/server/plugins/adapters/flatness_suite_run.py b/server/plugins/adapters/flatness_suite_run.py index 3df6e64..2bcd552 100644 --- a/server/plugins/adapters/flatness_suite_run.py +++ b/server/plugins/adapters/flatness_suite_run.py @@ -226,8 +226,17 @@ def main() -> int: result_template_path = alg_dir / "excel" / "测量结果.xlsx" if not result_template_path.is_file(): raise RuntimeError(f"内置测量结果模板不存在: {result_template_path}") + selected_task_nos = config.get("demoTaskNos") + selected_task_ids = ( + [str(int(value)) for value in selected_task_nos if isinstance(value, (int, float)) and float(value).is_integer()] + if isinstance(selected_task_nos, list) + else None + ) + if selected_task_ids == []: + raise RuntimeError("请至少选择一个轮廓测量任务") res = pipeline.run_contour_batch( - all_tasks=True, + task_ids=selected_task_ids, + all_tasks=selected_task_ids is None, data_root=str(data_root), pointcloud_kind="noise", point_chunk_size=8192, diff --git a/server/plugins/adapters/test_demo_task_selection.py b/server/plugins/adapters/test_demo_task_selection.py new file mode 100644 index 0000000..9ddebdb --- /dev/null +++ b/server/plugins/adapters/test_demo_task_selection.py @@ -0,0 +1,24 @@ +import importlib.util +import unittest +from pathlib import Path + + +def _load_adapter(name: str): + path = Path(__file__).with_name(name) + spec = importlib.util.spec_from_file_location(path.stem, path) + module = importlib.util.module_from_spec(spec) + assert spec and spec.loader + spec.loader.exec_module(module) + return module + + +class DemoTaskSelectionTest(unittest.TestCase): + def test_train_task_number_parsing_and_selection(self): + adapter = _load_adapter("train_measurement_run.py") + self.assertEqual(adapter._task_no_from_name("任务46_车体横断面对角线差值"), "46") + self.assertEqual(adapter._selected_task_nos({"demoTaskNos": [2, 46, 2]}), {"2", "46"}) + self.assertIsNone(adapter._selected_task_nos({})) + + +if __name__ == "__main__": + unittest.main() diff --git a/server/plugins/adapters/train_measurement_run.py b/server/plugins/adapters/train_measurement_run.py index 411a421..e55cdce 100644 --- a/server/plugins/adapters/train_measurement_run.py +++ b/server/plugins/adapters/train_measurement_run.py @@ -19,6 +19,7 @@ v2.2 变更: from __future__ import annotations import os +import re import sys import traceback from pathlib import Path @@ -33,6 +34,18 @@ from _plugin_io import ( ) +def _task_no_from_name(name: object) -> str | None: + match = re.match(r"^任务\s*(\d+)", str(name or "")) + return match.group(1) if match else None + + +def _selected_task_nos(config: dict) -> set[str] | None: + raw = config.get("demoTaskNos") + if not isinstance(raw, list): + return None + return {str(int(value)) for value in raw if isinstance(value, (int, float)) and float(value).is_integer()} + + def _to_float(value): if value is None: return None @@ -155,6 +168,13 @@ def main() -> int: if t.get("enabled", True) } tm.tasks = [t for t in tm.tasks if t.name in _enabled_names] + selected_task_nos = _selected_task_nos(config) + if selected_task_nos is not None: + available_task_nos = {_task_no_from_name(task.name) for task in tm.tasks} + missing_task_nos = selected_task_nos - available_task_nos + if missing_task_nos: + raise RuntimeError(f"测距配置中找不到所选任务:{','.join(sorted(missing_task_nos, key=int))}") + tm.tasks = [task for task in tm.tasks if _task_no_from_name(task.name) in selected_task_nos] # 注入可视化输出目录 viz_dir = out_dir / "visualizations" diff --git a/server/plugins/adapters/underframe_deflection_run.py b/server/plugins/adapters/underframe_deflection_run.py index 59a7e59..333a392 100644 --- a/server/plugins/adapters/underframe_deflection_run.py +++ b/server/plugins/adapters/underframe_deflection_run.py @@ -49,6 +49,11 @@ def main() -> int: try: emit_progress(0.05, "加载挠度算法包 v1.2.1") + selected_task_nos = config.get("demoTaskNos") + if isinstance(selected_task_nos, list) and 5 not in selected_task_nos: + write_error_result(out_dir, "构架挠度插件仅支持任务 5") + return 1 + # ── 输入文件 ──────────────────────────────────────────────────────── pointcloud_path = first_pointcloud(payload) if not pointcloud_path: diff --git a/server/plugins/manifests/coupler-panel.json b/server/plugins/manifests/coupler-panel.json index 98b083e..641d90c 100644 --- a/server/plugins/manifests/coupler-panel.json +++ b/server/plugins/manifests/coupler-panel.json @@ -8,6 +8,13 @@ "category": "几何测量", "requiresPointCloud": true, "requiresCad": false, + "demoSelection": { "kind": "task-nos", "param": "demoTaskNos" }, + "demoTasks": [ + { "taskNo": 8, "name": "车钩面板到参考面的右偏转", "category": "车钩", "inspectionObjects": ["head"] }, + { "taskNo": 9, "name": "车钩面板到参考面的右偏转", "category": "车钩", "inspectionObjects": ["middle"] }, + { "taskNo": 10, "name": "车钩面板到纵向中心的右偏转", "category": "车钩", "inspectionObjects": ["head"] }, + { "taskNo": 12, "name": "车钩面板到纵向中心的右偏转", "category": "车钩", "inspectionObjects": ["middle"] } + ], "inputs": [ { "name": "profile", diff --git a/server/plugins/manifests/flatness-suite.json b/server/plugins/manifests/flatness-suite.json index 32b1c72..1e1cd20 100644 --- a/server/plugins/manifests/flatness-suite.json +++ b/server/plugins/manifests/flatness-suite.json @@ -8,6 +8,29 @@ "category": "几何测量", "requiresPointCloud": false, "requiresCad": false, + "demoSelection": { "kind": "task-nos", "param": "demoTaskNos" }, + "demoTasks": [ + { "taskNo": 11, "name": "车钩面板的平整度", "category": "轮廓", "inspectionObjects": ["head", "middle"] }, + { "taskNo": 13, "name": "车长方向平面度", "category": "轮廓", "inspectionObjects": ["head", "middle"] }, + { "taskNo": 17, "name": "车高方向弧形轮廓测量", "category": "轮廓", "inspectionObjects": ["head", "middle"] }, + { "taskNo": 20, "name": "门柱平整度", "category": "轮廓", "inspectionObjects": ["head", "middle"] }, + { "taskNo": 22, "name": "从门口到外部轮廓的距离", "category": "轮廓", "inspectionObjects": ["head", "middle"] }, + { "taskNo": 23, "name": "从窗口到外部轮廓的距离", "category": "轮廓", "inspectionObjects": ["head", "middle"] }, + { "taskNo": 25, "name": "内风挡外圈平面度", "category": "轮廓", "inspectionObjects": ["head", "middle"] }, + { "taskNo": 26, "name": "内风挡区域平面度", "category": "轮廓", "inspectionObjects": ["head", "middle"] }, + { "taskNo": 27, "name": "外风挡区域平面度", "category": "轮廓", "inspectionObjects": ["head", "middle"] }, + { "taskNo": 31, "name": "车头前墙区域平面度", "category": "轮廓", "inspectionObjects": ["head"] }, + { "taskNo": 34, "name": "司机室门外弧形轮廓", "category": "轮廓", "inspectionObjects": ["head"] }, + { "taskNo": 35, "name": "前窗框侧轮廓", "category": "轮廓", "inspectionObjects": ["head"] }, + { "taskNo": 36, "name": "前窗框弧形轮廓", "category": "轮廓", "inspectionObjects": ["head"] }, + { "taskNo": 37, "name": "顶灯框侧轮廓", "category": "轮廓", "inspectionObjects": ["head"] }, + { "taskNo": 38, "name": "顶灯框弧形轮廓", "category": "轮廓", "inspectionObjects": ["head"] }, + { "taskNo": 39, "name": "逃生窗侧轮廓", "category": "轮廓", "inspectionObjects": ["head"] }, + { "taskNo": 40, "name": "逃生窗弧形轮廓", "category": "轮廓", "inspectionObjects": ["head"] }, + { "taskNo": 41, "name": "曲面窗侧轮廓", "category": "轮廓", "inspectionObjects": ["head"] }, + { "taskNo": 42, "name": "曲面窗弧形轮廓", "category": "轮廓", "inspectionObjects": ["head"] }, + { "taskNo": 45, "name": "地板平度", "category": "轮廓", "inspectionObjects": ["head", "middle"] } + ], "inputs": [ { "name": "useBatchDatasets", diff --git a/server/plugins/manifests/train-measurement.json b/server/plugins/manifests/train-measurement.json index 38c881f..78af936 100644 --- a/server/plugins/manifests/train-measurement.json +++ b/server/plugins/manifests/train-measurement.json @@ -8,6 +8,25 @@ "category": "尺寸测量", "requiresPointCloud": true, "requiresCad": false, + "demoSelection": { "kind": "task-nos", "param": "demoTaskNos" }, + "demoTasks": [ + { "taskNo": 1, "name": "转向架距离", "category": "尺寸", "inspectionObjects": ["head", "middle"] }, + { "taskNo": 2, "name": "车体长度", "category": "尺寸", "inspectionObjects": ["head", "middle"] }, + { "taskNo": 7, "name": "边梁下平面到参考线的距离", "category": "尺寸", "inspectionObjects": ["head", "middle"] }, + { "taskNo": 15, "name": "侧墙最大点到参考平面距离", "category": "尺寸", "inspectionObjects": ["head", "middle"] }, + { "taskNo": 16, "name": "车体最大宽度", "category": "尺寸", "inspectionObjects": ["head", "middle"] }, + { "taskNo": 18, "name": "门口宽度", "category": "尺寸", "inspectionObjects": ["head", "middle"] }, + { "taskNo": 21, "name": "侧门高度", "category": "尺寸", "inspectionObjects": ["head", "middle"] }, + { "taskNo": 28, "name": "端门口宽度", "category": "尺寸", "inspectionObjects": ["head", "middle"] }, + { "taskNo": 29, "name": "端门口高度", "category": "尺寸", "inspectionObjects": ["head", "middle"] }, + { "taskNo": 30, "name": "参考线到司机室辅助孔距离", "category": "尺寸", "inspectionObjects": ["head"] }, + { "taskNo": 32, "name": "司机室登车门门口宽度", "category": "尺寸", "inspectionObjects": ["head"] }, + { "taskNo": 33, "name": "司机室登车门门口高度", "category": "尺寸", "inspectionObjects": ["head"] }, + { "taskNo": 43, "name": "窗口下沿高度", "category": "尺寸", "inspectionObjects": ["head", "middle"] }, + { "taskNo": 44, "name": "车顶高度", "category": "尺寸", "inspectionObjects": ["head", "middle"] }, + { "taskNo": 46, "name": "车体横断面对角线差值", "category": "尺寸", "inspectionObjects": ["head", "middle"] }, + { "taskNo": 47, "name": "内端墙门口高度", "category": "尺寸", "inspectionObjects": ["head", "middle"] } + ], "uiPanel": "train-measurement", "inputs": [ { diff --git a/server/plugins/manifests/underframe-deflection.json b/server/plugins/manifests/underframe-deflection.json index 22b2b3e..f1ad566 100644 --- a/server/plugins/manifests/underframe-deflection.json +++ b/server/plugins/manifests/underframe-deflection.json @@ -8,6 +8,10 @@ "category": "几何测量", "requiresPointCloud": true, "requiresCad": false, + "demoSelection": { "kind": "task-nos", "param": "demoTaskNos" }, + "demoTasks": [ + { "taskNo": 5, "name": "纵向梁到直线的垂直偏差(挠度)", "category": "挠度", "inspectionObjects": ["head", "middle"] } + ], "inputs": [ { "name": "leftRoiPath", diff --git a/server/plugins/types.ts b/server/plugins/types.ts index 5fe3b39..9440960 100644 --- a/server/plugins/types.ts +++ b/server/plugins/types.ts @@ -68,6 +68,22 @@ export interface PluginRuntimeMock { export type PluginRuntime = PluginRuntimeProcess | PluginRuntimeMock; +export type DemoInspectionObject = 'head' | 'middle'; + +/** 插件可在“全流程任务选择”中暴露的正式检验任务。 */ +export interface PluginDemoTask { + taskNo: number; + name: string; + category: string; + inspectionObjects?: DemoInspectionObject[]; +} + +/** 任务选择如何转换为插件参数。 */ +export interface PluginDemoSelection { + kind: 'task-nos'; + param: string; +} + /** 插件 manifest(写在 server/plugins/manifests/*.json) */ export interface PluginManifest { /** 全局唯一 ID(kebab-case,例如 coupler-panel) */ @@ -89,6 +105,10 @@ export interface PluginManifest { requiresCad?: boolean; /** 是否需要点云(默认 true) */ requiresPointCloud?: boolean; + /** “选择任务运行”模式的权威任务目录。 */ + demoTasks?: PluginDemoTask[]; + /** 选中任务传给适配器的方式。 */ + demoSelection?: PluginDemoSelection; runtime: PluginRuntime; } diff --git a/server/routers.ts b/server/routers.ts index c965eb9..4500a83 100644 --- a/server/routers.ts +++ b/server/routers.ts @@ -13,6 +13,7 @@ import { enqueueDeviationHeatmapJob } from "./deviationHeatmapJob.js"; import { enqueueSmoothHeatmapJob } from "./smoothHeatmapJob.js"; import { stepParseCacheDir } from "./stepParse.js"; import { listPlugins, getPlugin } from "./plugins/registry.js"; +import { buildDemoTaskCatalog, resolveSelectedDemoJobs } from "./demoFlow.js"; import { enqueueAlgorithmJob, algorithmJobStats, cancelAlgorithmJob, cancelAlgorithmJobsByTask } from "./algorithmJob.js"; import { cancelLegacyAnalysisJob, cancelLegacyAnalysisJobsByTask } from "./analysisJobControl.js"; import { deleteAnalysisResult } from "./analysisResultDeletion.js"; @@ -313,13 +314,26 @@ async function cancelTaskAnalysisResultsByTask(taskId: number, reason = "用户 }; } +interface DemoFlowMeasurementJob { + pluginId: string; + params?: Record; + taskNos?: number[]; +} + +interface DemoFlowMetadata { + demoFlowMode: "all" | "selected"; + demoFlowSelectedTaskNos?: number[]; + demoFlowSelectedTaskCount?: number; +} + async function enqueueDemoRegisteredMeasurementsAfterRegistration(input: { taskId: number; sourcePointCloudFileId: number; cadFileId: number; registrationResultId: number; - measurementPluginIds: string[]; + measurementJobs: DemoFlowMeasurementJob[]; demoFlowRunId: string; + metadata: DemoFlowMetadata; }) { const registrationResult = await db.getTaskAnalysisResultById(input.registrationResultId); if (registrationResult?.status !== "completed") { @@ -335,13 +349,14 @@ async function enqueueDemoRegisteredMeasurementsAfterRegistration(input: { return; } - for (let idx = 0; idx < input.measurementPluginIds.length; idx += 1) { - const pluginId = input.measurementPluginIds[idx]; + for (let idx = 0; idx < input.measurementJobs.length; idx += 1) { + const job = input.measurementJobs[idx]; + const pluginId = job.pluginId; const manifest = getPlugin(pluginId); await createAndEnqueuePluginJob({ taskId: input.taskId, pluginId, - params: paramsForDemoPlugin(pluginId), + params: { ...paramsForDemoPlugin(pluginId), ...(job.params ?? {}) }, pointCloudFileIds: DEMO_REGISTERED_POINTCLOUD_PLUGIN_IDS.has(pluginId) ? [registered.id] : [input.sourcePointCloudFileId], @@ -350,14 +365,86 @@ async function enqueueDemoRegisteredMeasurementsAfterRegistration(input: { demoFlowRunId: input.demoFlowRunId, demoFlowRole: "measurement", demoFlowIndex: idx + 2, - demoFlowTotal: input.measurementPluginIds.length + 1, + demoFlowTotal: input.measurementJobs.length + 1, demoFlowSourcePointCloudFileId: input.sourcePointCloudFileId, demoFlowCadFileId: input.cadFileId, + ...input.metadata, + ...(job.taskNos ? { demoFlowPluginTaskNos: job.taskNos } : {}), }, }); } } +async function startDemoFlow(input: { + taskId: number; + sourcePointCloudFileId: number; + cadFileId: number; + measurementJobs: DemoFlowMeasurementJob[]; + metadata: DemoFlowMetadata; +}) { + const demoFlowRunId = randomUUID(); + clearDemoFlowCancelled(input.taskId); + for (const job of input.measurementJobs) { + const manifest = getPlugin(job.pluginId); + if (!manifest) throw new TRPCError({ code: "NOT_FOUND", message: `未注册的算法插件:${job.pluginId}` }); + if (manifest.category === "配准") { + throw new TRPCError({ code: "BAD_REQUEST", message: "全流程的测量算法列表不能包含配准算法。" }); + } + } + + const pointcloudFiles = await db.listTaskPointcloudFiles(input.taskId); + const sourcePointcloud = pointcloudFiles.find((row) => row.id === input.sourcePointCloudFileId); + if (!sourcePointcloud) throw new TRPCError({ code: "NOT_FOUND", message: "选中的原始点云文件不存在。" }); + if (sourcePointcloud.source === "registered") { + throw new TRPCError({ code: "BAD_REQUEST", message: "请选择原始点云,系统会自动找到对应配准产物。" }); + } + const cadFiles = await db.listTaskCadFiles(input.taskId); + const cad = cadFiles.find((row) => row.id === input.cadFileId); + if (!cad) throw new TRPCError({ code: "NOT_FOUND", message: "选中的 CAD 文件不存在。" }); + await validateTaskInputCompatibility(input.taskId, sourcePointcloud, cad); + + const registration = await createAndEnqueuePluginJob({ + taskId: input.taskId, + pluginId: "vehicle-registration", + params: {}, + pointCloudFileIds: [input.sourcePointCloudFileId], + cadFileIds: [input.cadFileId], + resultData: { + demoFlowRunId, + demoFlowRole: "registration", + demoFlowIndex: 1, + demoFlowTotal: input.measurementJobs.length + 1, + demoFlowSourcePointCloudFileId: input.sourcePointCloudFileId, + demoFlowCadFileId: input.cadFileId, + ...input.metadata, + }, + }); + const registrationJob = { id: registration.id, pluginId: registration.pluginId }; + void registration.promise.then(() => { + if (isDemoFlowCancelled(input.taskId)) { + console.log(`[demoFlow] 任务 ${input.taskId} 已停止全流程,跳过后续测量提交。`); + return undefined; + } + return enqueueDemoRegisteredMeasurementsAfterRegistration({ + taskId: input.taskId, + sourcePointCloudFileId: input.sourcePointCloudFileId, + cadFileId: input.cadFileId, + registrationResultId: registration.id, + measurementJobs: input.measurementJobs, + demoFlowRunId, + metadata: input.metadata, + }); + }).catch((err) => console.error("[demoFlow] 配准链式提交失败:", err)); + + return { + success: true as const, + jobs: [] as { id: number; pluginId: string }[], + registrationJob, + queuedMeasurementCount: input.measurementJobs.length, + scheduler: algorithmJobStats(), + }; +} + export const appRouter = router({ system: systemRouter, @@ -1325,6 +1412,19 @@ export const appRouter = router({ return { success: true as const, jobs: created, scheduler: algorithmJobStats() }; }), + /** 当前检测对象可用的正式任务目录。 */ + demoTaskCatalog: protectedProcedure + .input(z.object({ taskId: z.number() })) + .query(async ({ input }) => { + const task = await db.getInspectionTaskById(input.taskId); + if (!task) throw new TRPCError({ code: "NOT_FOUND", message: "检测任务不存在。" }); + const inspectionObject = task.inspectionObjectId + ? await db.getInspectionObjectById(task.inspectionObjectId) + : undefined; + const context = inspectionObjectContextFromName(inspectionObject?.name); + return buildDemoTaskCatalog(listPlugins(), context.inspectionObject, context.inspectionObjectName); + }), + /** 一键全流程演示:先整车配准,成功后再一起提交测量算法。 */ runDemoAll: protectedProcedure .input( @@ -1337,85 +1437,50 @@ export const appRouter = router({ ) .mutation(async ({ input }) => { const measurementPluginIds = normalizeDemoMeasurementPluginIds(input.measurementPluginIds); - const demoFlowRunId = randomUUID(); - clearDemoFlowCancelled(input.taskId); - for (const pluginId of measurementPluginIds) { - const manifest = getPlugin(pluginId); - if (!manifest) { - throw new TRPCError({ - code: "NOT_FOUND", - message: `未注册的算法插件:${pluginId}`, - }); - } - if (manifest.category === "配准") { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "一键全流程演示的测量算法列表不能包含配准算法。", - }); - } - } - - const pointcloudFiles = await db.listTaskPointcloudFiles(input.taskId); - const sourcePointcloud = pointcloudFiles.find((row) => row.id === input.sourcePointCloudFileId); - if (!sourcePointcloud) { - throw new TRPCError({ code: "NOT_FOUND", message: "选中的原始点云文件不存在。" }); - } - if (sourcePointcloud.source === "registered") { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "请选择原始点云,系统会自动找到对应配准产物。", - }); - } - - const cadFiles = await db.listTaskCadFiles(input.taskId); - const cad = cadFiles.find((row) => row.id === input.cadFileId); - if (!cad) { - throw new TRPCError({ code: "NOT_FOUND", message: "选中的 CAD 文件不存在。" }); - } - await validateTaskInputCompatibility(input.taskId, sourcePointcloud, cad); - - const registration = await createAndEnqueuePluginJob({ + return startDemoFlow({ taskId: input.taskId, - pluginId: "vehicle-registration", - params: {}, - pointCloudFileIds: [input.sourcePointCloudFileId], - cadFileIds: [input.cadFileId], - resultData: { - demoFlowRunId, - demoFlowRole: "registration", - demoFlowIndex: 1, - demoFlowTotal: measurementPluginIds.length + 1, - demoFlowSourcePointCloudFileId: input.sourcePointCloudFileId, - demoFlowCadFileId: input.cadFileId, + sourcePointCloudFileId: input.sourcePointCloudFileId, + cadFileId: input.cadFileId, + measurementJobs: measurementPluginIds.map((pluginId) => ({ pluginId })), + metadata: { demoFlowMode: "all" }, + }); + }), + + /** 自选任务全流程:服务端校验任务并只提交覆盖所选任务的最小插件集合。 */ + runDemoSelected: protectedProcedure + .input(z.object({ + taskId: z.number(), + sourcePointCloudFileId: z.number(), + cadFileId: z.number(), + taskNos: z.array(z.number().int().positive()).min(1).max(64), + })) + .mutation(async ({ input }) => { + const task = await db.getInspectionTaskById(input.taskId); + if (!task) throw new TRPCError({ code: "NOT_FOUND", message: "检测任务不存在。" }); + const inspectionObject = task.inspectionObjectId + ? await db.getInspectionObjectById(task.inspectionObjectId) + : undefined; + const context = inspectionObjectContextFromName(inspectionObject?.name); + const manifests = listPlugins(); + const catalog = buildDemoTaskCatalog(manifests, context.inspectionObject, context.inspectionObjectName); + let selectedPlan: ReturnType; + try { + selectedPlan = resolveSelectedDemoJobs(manifests, catalog, input.taskNos); + } catch (error) { + throw new TRPCError({ code: "BAD_REQUEST", message: error instanceof Error ? error.message : "所选任务无效" }); + } + const result = await startDemoFlow({ + taskId: input.taskId, + sourcePointCloudFileId: input.sourcePointCloudFileId, + cadFileId: input.cadFileId, + measurementJobs: selectedPlan.jobs, + metadata: { + demoFlowMode: "selected", + demoFlowSelectedTaskNos: selectedPlan.taskNos, + demoFlowSelectedTaskCount: selectedPlan.taskNos.length, }, }); - const registrationJob = { id: registration.id, pluginId: registration.pluginId }; - void registration.promise - .then(() => { - if (isDemoFlowCancelled(input.taskId)) { - console.log(`[runDemoAll] 任务 ${input.taskId} 已停止全流程,跳过后续测量提交。`); - return undefined; - } - return enqueueDemoRegisteredMeasurementsAfterRegistration({ - taskId: input.taskId, - sourcePointCloudFileId: input.sourcePointCloudFileId, - cadFileId: input.cadFileId, - registrationResultId: registration.id, - measurementPluginIds, - demoFlowRunId, - }); - }) - .catch((err) => { - console.error("[runDemoAll] 配准链式提交失败:", err); - }); - - return { - success: true as const, - jobs: [] as { id: number; pluginId: string }[], - registrationJob, - queuedMeasurementCount: measurementPluginIds.length, - scheduler: algorithmJobStats(), - }; + return { ...result, selectedTaskNos: selectedPlan.taskNos }; }), cancelDemoAll: protectedProcedure