Compare commits
2 Commits
4b9a2f4cd9
...
e3dc7ebe21
| Author | SHA1 | Date | |
|---|---|---|---|
| e3dc7ebe21 | |||
| f879328562 |
@@ -59,7 +59,7 @@ MINIO_ROOT_USER=minioadmin
|
|||||||
MINIO_ROOT_PASSWORD=$(openssl rand -base64 16)
|
MINIO_ROOT_PASSWORD=$(openssl rand -base64 16)
|
||||||
|
|
||||||
# 应用配置
|
# 应用配置
|
||||||
VITE_APP_TITLE=宏量点云处理系统
|
VITE_APP_TITLE=宏量点云处理软件
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
# 或者手动编辑
|
# 或者手动编辑
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ COPY --from=deps /app/node_modules ./node_modules
|
|||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# 设置构建时环境变量
|
# 设置构建时环境变量
|
||||||
ARG VITE_APP_TITLE="宏量点云处理系统"
|
ARG VITE_APP_TITLE="宏量点云处理软件"
|
||||||
ENV VITE_APP_TITLE=$VITE_APP_TITLE
|
ENV VITE_APP_TITLE=$VITE_APP_TITLE
|
||||||
|
|
||||||
# 构建应用
|
# 构建应用
|
||||||
|
|||||||
@@ -11,9 +11,10 @@
|
|||||||
*/
|
*/
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { trpc } from '@/lib/trpc';
|
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 { toast } from 'sonner';
|
||||||
import TrainMeasurementPanel from './TrainMeasurementPanel';
|
import TrainMeasurementPanel from './TrainMeasurementPanel';
|
||||||
|
import DemoTaskSelectionDialog from './DemoTaskSelectionDialog';
|
||||||
|
|
||||||
const DEMO_MEASUREMENT_PLUGIN_IDS = [
|
const DEMO_MEASUREMENT_PLUGIN_IDS = [
|
||||||
'train-measurement',
|
'train-measurement',
|
||||||
@@ -130,6 +131,19 @@ export default function AlgorithmRunPanel({ taskId, modelId, pointCloudLoaded: _
|
|||||||
},
|
},
|
||||||
onError: (e) => toast.error(e.message || '一键全流程演示提交失败'),
|
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({
|
const cancelDemoAll = trpc.algorithm.cancelDemoAll.useMutation({
|
||||||
onSuccess: ({ cancelledIds }) => {
|
onSuccess: ({ cancelledIds }) => {
|
||||||
toast.success(cancelledIds.length > 0 ? `已停止 ${cancelledIds.length} 个运行任务` : '当前没有运行中的全流程任务');
|
toast.success(cancelledIds.length > 0 ? `已停止 ${cancelledIds.length} 个运行任务` : '当前没有运行中的全流程任务');
|
||||||
@@ -151,10 +165,15 @@ export default function AlgorithmRunPanel({ taskId, modelId, pointCloudLoaded: _
|
|||||||
{ taskId: taskId ?? 0 },
|
{ taskId: taskId ?? 0 },
|
||||||
{ enabled: taskId != null, refetchInterval: 3000 },
|
{ 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 [selected, setSelected] = useState<Set<string>>(new Set());
|
||||||
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||||||
const [paramsById, setParamsById] = useState<Record<string, Record<string, unknown>>>({});
|
const [paramsById, setParamsById] = useState<Record<string, Record<string, unknown>>>({});
|
||||||
|
const [demoTaskDialogOpen, setDemoTaskDialogOpen] = useState(false);
|
||||||
|
|
||||||
// 首次拉到 plugins 后用默认值初始化每个表单
|
// 首次拉到 plugins 后用默认值初始化每个表单
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -202,13 +221,13 @@ export default function AlgorithmRunPanel({ taskId, modelId, pointCloudLoaded: _
|
|||||||
...(hasDemoRegistrationPlugin ? ['vehicle-registration'] : []),
|
...(hasDemoRegistrationPlugin ? ['vehicle-registration'] : []),
|
||||||
...demoMeasurementPluginIds,
|
...demoMeasurementPluginIds,
|
||||||
];
|
];
|
||||||
const demoAlgorithmCount = demoAllPluginIds.length;
|
|
||||||
const demoMeasurementAlgorithmCount = demoMeasurementPluginIds.length;
|
const demoMeasurementAlgorithmCount = demoMeasurementPluginIds.length;
|
||||||
|
const demoTaskCount = demoTaskCatalog?.total ?? 0;
|
||||||
const demoRunTitle = `先执行整车配准,完成后自动提交 ${demoMeasurementAlgorithmCount} 个测量算法`;
|
const demoRunTitle = `先执行整车配准,完成后自动提交 ${demoMeasurementAlgorithmCount} 个测量算法`;
|
||||||
const demoPluginIds = new Set(demoAllPluginIds);
|
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 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 demoFlowRunning = runningDemoResults.length > 0;
|
||||||
const demoFlowActive = demoFlowRunning || runDemoAll.isPending;
|
const demoFlowActive = demoFlowRunning || runDemoAll.isPending || runDemoSelected.isPending;
|
||||||
|
|
||||||
const selectedPcCount = selectedPcFileIds?.length ?? 0;
|
const selectedPcCount = selectedPcFileIds?.length ?? 0;
|
||||||
const selectedCadCount = selectedCadFileIds?.length ?? 0;
|
const selectedCadCount = selectedCadFileIds?.length ?? 0;
|
||||||
@@ -232,7 +251,7 @@ export default function AlgorithmRunPanel({ taskId, modelId, pointCloudLoaded: _
|
|||||||
taskId == null ? '请先选择检测任务'
|
taskId == null ? '请先选择检测任务'
|
||||||
: (taskPcFileCount ?? 0) === 0 ? '请先上传点云文件'
|
: (taskPcFileCount ?? 0) === 0 ? '请先上传点云文件'
|
||||||
: demoFlowRunning ? '一键全流程运行中,请先停止或等待完成'
|
: demoFlowRunning ? '一键全流程运行中,请先停止或等待完成'
|
||||||
: (runBatch.isPending || runDemoAll.isPending || cancelDemoAll.isPending) ? '算法提交中,请稍候'
|
: (runBatch.isPending || runDemoAll.isPending || runDemoSelected.isPending || cancelDemoAll.isPending) ? '算法提交中,请稍候'
|
||||||
: null;
|
: null;
|
||||||
const registrationBlockReason =
|
const registrationBlockReason =
|
||||||
baseRunBlockReason
|
baseRunBlockReason
|
||||||
@@ -257,6 +276,8 @@ export default function AlgorithmRunPanel({ taskId, modelId, pointCloudLoaded: _
|
|||||||
: selectedCadCount > 1 ? '一键全流程演示一次只能选择 1 个 CAD 文件'
|
: selectedCadCount > 1 ? '一键全流程演示一次只能选择 1 个 CAD 文件'
|
||||||
: !hasDemoRegistrationPlugin ? '暂无可执行的整车配准算法'
|
: !hasDemoRegistrationPlugin ? '暂无可执行的整车配准算法'
|
||||||
: demoMeasurementPluginIds.length === 0 ? '暂无可执行的测量算法'
|
: demoMeasurementPluginIds.length === 0 ? '暂无可执行的测量算法'
|
||||||
|
: demoCatalogLoading ? '正在加载任务目录'
|
||||||
|
: demoTaskCount === 0 ? '当前检测对象暂无可运行任务'
|
||||||
: null);
|
: null);
|
||||||
const canRunDemoAll = demoBlockReason == null;
|
const canRunDemoAll = demoBlockReason == null;
|
||||||
const demoRouteHint =
|
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 = () => {
|
const handleCancelDemoAll = () => {
|
||||||
if (taskId == null || cancelDemoAll.isPending) return;
|
if (taskId == null || cancelDemoAll.isPending) return;
|
||||||
cancelDemoAll.mutate({ taskId });
|
cancelDemoAll.mutate({ taskId });
|
||||||
@@ -353,7 +388,7 @@ export default function AlgorithmRunPanel({ taskId, modelId, pointCloudLoaded: _
|
|||||||
<Cpu className="w-3.5 h-3.5" />
|
<Cpu className="w-3.5 h-3.5" />
|
||||||
一键全流程演示
|
一键全流程演示
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex flex-wrap items-center justify-end gap-1">
|
||||||
{demoFlowRunning && (
|
{demoFlowRunning && (
|
||||||
<span className="text-[9px] px-1.5 py-0.5 rounded bg-blue-900/40 text-blue-300 animate-pulse">
|
<span className="text-[9px] px-1.5 py-0.5 rounded bg-blue-900/40 text-blue-300 animate-pulse">
|
||||||
运行中 {runningDemoResults.length}
|
运行中 {runningDemoResults.length}
|
||||||
@@ -373,18 +408,32 @@ export default function AlgorithmRunPanel({ taskId, modelId, pointCloudLoaded: _
|
|||||||
停止全流程
|
停止全流程
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<>
|
||||||
type="button"
|
<button
|
||||||
className={`industrial-btn industrial-btn-primary py-0.5 text-[10px] flex items-center gap-0.5 ${
|
type="button"
|
||||||
canRunDemoAll ? '' : 'opacity-50 cursor-not-allowed'
|
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}
|
disabled={!canRunDemoAll}
|
||||||
title={demoBlockReason ?? demoRunTitle}
|
onClick={handleRunDemoAll}
|
||||||
>
|
title={demoBlockReason ?? demoRunTitle}
|
||||||
{runDemoAll.isPending ? <RefreshCw className="w-3 h-3 animate-spin" /> : <Play className="w-3 h-3" />}
|
>
|
||||||
运行全部 {demoAlgorithmCount} 个算法
|
{runDemoAll.isPending ? <RefreshCw className="w-3 h-3 animate-spin" /> : <Play className="w-3 h-3" />}
|
||||||
</button>
|
运行全部任务({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>
|
||||||
</div>
|
</div>
|
||||||
@@ -523,6 +572,17 @@ export default function AlgorithmRunPanel({ taskId, modelId, pointCloudLoaded: _
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<DemoTaskSelectionDialog
|
||||||
|
open={demoTaskDialogOpen}
|
||||||
|
onOpenChange={setDemoTaskDialogOpen}
|
||||||
|
items={demoTaskCatalog?.items ?? []}
|
||||||
|
total={demoTaskCount}
|
||||||
|
loading={demoCatalogLoading}
|
||||||
|
pending={runDemoSelected.isPending}
|
||||||
|
runBlockReason={demoBlockReason}
|
||||||
|
onRun={handleRunDemoSelected}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -263,6 +263,9 @@ type DemoFlowMeta = {
|
|||||||
role?: string;
|
role?: string;
|
||||||
index?: number;
|
index?: number;
|
||||||
total?: number;
|
total?: number;
|
||||||
|
mode?: 'all' | 'selected';
|
||||||
|
selectedTaskNos?: number[];
|
||||||
|
selectedTaskCount?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
const SHOW_LEGACY_ANALYSIS_ACTIONS = false;
|
const SHOW_LEGACY_ANALYSIS_ACTIONS = false;
|
||||||
@@ -293,6 +296,11 @@ function readDemoFlowMeta(result: AnalysisResultRow): DemoFlowMeta | null {
|
|||||||
role: typeof data.demoFlowRole === 'string' ? data.demoFlowRole : undefined,
|
role: typeof data.demoFlowRole === 'string' ? data.demoFlowRole : undefined,
|
||||||
index: numberFromUnknown(data.demoFlowIndex),
|
index: numberFromUnknown(data.demoFlowIndex),
|
||||||
total: numberFromUnknown(data.demoFlowTotal),
|
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 cardKey = `${variant}:${group.runId}`;
|
||||||
const resultsExpanded = !collapsedFlowResultCards.has(cardKey);
|
const resultsExpanded = !collapsedFlowResultCards.has(cardKey);
|
||||||
const artifactsExpanded = expandedFlowArtifactCards.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(
|
const aggregated = aggregateDemoFlowResults(
|
||||||
group.results,
|
group.results,
|
||||||
(result) => getPluginDisplayName(result.pluginId, pluginNameById, getResultTitle(result, pluginNameById)),
|
(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
|
const sortedTableRows = SHOW_DEMO_FLOW_ALGORITHM_GROUP_ROWS
|
||||||
? aggregated.tableRows
|
? 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" />}
|
{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" />
|
<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">
|
<span className="text-[10px] text-muted-foreground flex-shrink-0">
|
||||||
{completed}/{total} 完成
|
{completed}/{total} 完成
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
219
client/src/components/DemoTaskSelectionDialog.tsx
Normal file
219
client/src/components/DemoTaskSelectionDialog.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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('全流程结果使用统一结论文案,不采用算法自定义文案', () => {
|
it('全流程结果使用统一结论文案,不采用算法自定义文案', () => {
|
||||||
expect(demoFlowStatusLabel({ status: 'pass' })).toBe('合格');
|
expect(demoFlowStatusLabel({ status: 'pass' })).toBe('合格');
|
||||||
expect(demoFlowStatusLabel({ status: 'warning' })).toBe('警告');
|
expect(demoFlowStatusLabel({ status: 'warning' })).toBe('警告');
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ export function demoFlowStatusLabel(measurement: Pick<Measurement, 'status'>): s
|
|||||||
export function aggregateDemoFlowResults(
|
export function aggregateDemoFlowResults(
|
||||||
results: DemoFlowSourceResult[],
|
results: DemoFlowSourceResult[],
|
||||||
getAlgorithmName: (result: DemoFlowSourceResult) => string,
|
getAlgorithmName: (result: DemoFlowSourceResult) => string,
|
||||||
options: { includeAlgorithmGroups?: boolean } = {},
|
options: { includeAlgorithmGroups?: boolean; selectedTaskNos?: readonly number[] } = {},
|
||||||
): DemoFlowAggregation {
|
): DemoFlowAggregation {
|
||||||
const sorted = results
|
const sorted = results
|
||||||
.map((result, originalIndex) => ({ result, originalIndex, index: flowIndex(result, originalIndex + 1) }))
|
.map((result, originalIndex) => ({ result, originalIndex, index: flowIndex(result, originalIndex + 1) }))
|
||||||
@@ -132,12 +132,18 @@ export function aggregateDemoFlowResults(
|
|||||||
const tableRows: DemoFlowTableRow[] = [];
|
const tableRows: DemoFlowTableRow[] = [];
|
||||||
const artifactGroups: DemoFlowArtifactGroup[] = [];
|
const artifactGroups: DemoFlowArtifactGroup[] = [];
|
||||||
const statusNotices: DemoFlowStatusNotice[] = [];
|
const statusNotices: DemoFlowStatusNotice[] = [];
|
||||||
|
const selectedTaskNos = options.selectedTaskNos
|
||||||
|
? new Set(options.selectedTaskNos.map(String))
|
||||||
|
: null;
|
||||||
|
|
||||||
for (const { result, index } of sorted) {
|
for (const { result, index } of sorted) {
|
||||||
const data = (asRecord(result.resultData) ?? {}) as PluginResultLike & Record<string, unknown>;
|
const data = (asRecord(result.resultData) ?? {}) as PluginResultLike & Record<string, unknown>;
|
||||||
const sourceAlgorithmId = result.pluginId ?? (typeof data.pluginId === 'string' ? data.pluginId : result.type);
|
const sourceAlgorithmId = result.pluginId ?? (typeof data.pluginId === 'string' ? data.pluginId : result.type);
|
||||||
const algorithmName = getAlgorithmName(result);
|
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) {
|
if (measurements.length > 0 && options.includeAlgorithmGroups) {
|
||||||
tableRows.push({ kind: 'algorithm', sourceAlgorithmId, algorithmName, resultId: result.id, flowIndex: index });
|
tableRows.push({ kind: 'algorithm', sourceAlgorithmId, algorithmName, resultId: result.id, flowIndex: index });
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import { trpc } from '@/lib/trpc';
|
|||||||
import {
|
import {
|
||||||
LogOut,
|
LogOut,
|
||||||
Users,
|
Users,
|
||||||
Box,
|
|
||||||
Cloud,
|
Cloud,
|
||||||
Eye,
|
Eye,
|
||||||
EyeOff,
|
EyeOff,
|
||||||
@@ -400,11 +399,8 @@ export default function Home() {
|
|||||||
{/* 顶部工具栏 */}
|
{/* 顶部工具栏 */}
|
||||||
<header className="h-12 border-b border-border bg-card flex items-center justify-between px-4 flex-shrink-0">
|
<header className="h-12 border-b border-border bg-card flex items-center justify-between px-4 flex-shrink-0">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center">
|
||||||
<div className="w-7 h-7 rounded bg-primary/20 flex items-center justify-center">
|
<h1 className="text-sm font-semibold">宏量点云处理软件</h1>
|
||||||
<Box className="w-4 h-4 text-primary" />
|
|
||||||
</div>
|
|
||||||
<h1 className="text-sm font-semibold">宏量点云处理系统</h1>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{activeModel && (
|
{activeModel && (
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ export default function Login() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-xl font-semibold">宏量点云处理系统</h1>
|
<h1 className="text-xl font-semibold">宏量点云处理软件</h1>
|
||||||
<p className="text-sm text-muted-foreground mt-1">使用本地账号登录</p>
|
<p className="text-sm text-muted-foreground mt-1">使用本地账号登录</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ services:
|
|||||||
- S3_SECRET_KEY=${MINIO_ROOT_PASSWORD:-minioadmin123}
|
- S3_SECRET_KEY=${MINIO_ROOT_PASSWORD:-minioadmin123}
|
||||||
- S3_BUCKET=pointcloud-data
|
- S3_BUCKET=pointcloud-data
|
||||||
- S3_REGION=us-east-1
|
- S3_REGION=us-east-1
|
||||||
- VITE_APP_TITLE=宏量点云处理系统
|
- VITE_APP_TITLE=宏量点云处理软件
|
||||||
# 数据目录路径
|
# 数据目录路径
|
||||||
- DATA_DIR=/data
|
- DATA_DIR=/data
|
||||||
# 算法源码根(通用插件 Route B 通过 {algRoot} 占位符引用)
|
# 算法源码根(通用插件 Route B 通过 {algRoot} 占位符引用)
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ services:
|
|||||||
# 算法源码根(通用插件 Route B 通过 {algRoot} 占位符引用)
|
# 算法源码根(通用插件 Route B 通过 {algRoot} 占位符引用)
|
||||||
- ALG_ROOT=/alg
|
- ALG_ROOT=/alg
|
||||||
# 应用配置
|
# 应用配置
|
||||||
- VITE_APP_TITLE=宏量点云处理系统
|
- VITE_APP_TITLE=宏量点云处理软件
|
||||||
depends_on:
|
depends_on:
|
||||||
mysql:
|
mysql:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ MINIO_ROOT_PASSWORD=minioadmin123
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 应用标题
|
# 应用标题
|
||||||
VITE_APP_TITLE=宏量点云处理系统
|
VITE_APP_TITLE=宏量点云处理软件
|
||||||
```
|
```
|
||||||
|
|
||||||
## 可选配置
|
## 可选配置
|
||||||
|
|||||||
@@ -201,6 +201,10 @@ const DEMO_FLOW_RESULT_DATA_KEYS = [
|
|||||||
'demoFlowSourcePointCloudFileId',
|
'demoFlowSourcePointCloudFileId',
|
||||||
'demoFlowCadFileId',
|
'demoFlowCadFileId',
|
||||||
'demoFlowCancelledAt',
|
'demoFlowCancelledAt',
|
||||||
|
'demoFlowMode',
|
||||||
|
'demoFlowSelectedTaskNos',
|
||||||
|
'demoFlowSelectedTaskCount',
|
||||||
|
'demoFlowPluginTaskNos',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
async function readDemoFlowResultData(resultId: number): Promise<Record<string, unknown>> {
|
async function readDemoFlowResultData(resultId: number): Promise<Record<string, unknown>> {
|
||||||
@@ -389,8 +393,19 @@ export async function runAlgorithmJob(
|
|||||||
algorithmProtocolVersion: result?.schemaVersion ?? manifest.schemaVersion,
|
algorithmProtocolVersion: result?.schemaVersion ?? manifest.schemaVersion,
|
||||||
});
|
});
|
||||||
logCompatibilityWarnings(resultId, normalized.warnings);
|
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)
|
const normalizedMeasurements = Array.isArray(result?.measurements)
|
||||||
? normalized.measurements
|
? requestedDemoTaskNos
|
||||||
|
? normalized.measurements.filter((measurement) => (
|
||||||
|
typeof measurement.taskNo === 'string' && requestedDemoTaskNos.has(measurement.taskNo)
|
||||||
|
))
|
||||||
|
: normalized.measurements
|
||||||
: undefined;
|
: undefined;
|
||||||
const resultData: PluginResultData = {
|
const resultData: PluginResultData = {
|
||||||
kind: 'plugin',
|
kind: 'plugin',
|
||||||
|
|||||||
84
server/demoFlow.test.ts
Normal file
84
server/demoFlow.test.ts
Normal file
@@ -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] },
|
||||||
|
}]);
|
||||||
|
});
|
||||||
|
});
|
||||||
120
server/demoFlow.ts
Normal file
120
server/demoFlow.ts
Normal file
@@ -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<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<number, string>();
|
||||||
|
|
||||||
|
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<string, number[]>();
|
||||||
|
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 };
|
||||||
|
}
|
||||||
@@ -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_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))
|
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": "车钩面板距纵向中心的右偏转角",
|
"name": "车钩面板距纵向中心的右偏转角",
|
||||||
"value": round(offset_right, 6),
|
"value": round(offset_right, 6),
|
||||||
"unit": "mm",
|
"unit": "mm",
|
||||||
"baseline": 700.0,
|
"baseline": 700.0,
|
||||||
"tolerance": 1.0,
|
"tolerance": 1.0,
|
||||||
"status": _status_for(offset_right, 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": "车钩面板距参考面的右偏转角",
|
"name": "车钩面板距参考面的右偏转角",
|
||||||
"value": round(offset_ref, 6),
|
"value": round(offset_ref, 6),
|
||||||
"unit": "mm",
|
"unit": "mm",
|
||||||
"baseline": 375.0,
|
"baseline": 375.0,
|
||||||
"tolerance": 0.8,
|
"tolerance": 0.8,
|
||||||
"status": _status_for(offset_ref, 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",
|
"name": "最大 x 偏差 dx_y",
|
||||||
"value": dx_y,
|
"value": dx_y,
|
||||||
@@ -198,7 +228,7 @@ def main() -> int:
|
|||||||
"unit": "mm",
|
"unit": "mm",
|
||||||
"status": "info",
|
"status": "info",
|
||||||
},
|
},
|
||||||
]
|
])
|
||||||
|
|
||||||
payload_out = {
|
payload_out = {
|
||||||
"schemaVersion": getattr(result, "schema_version", "1.1"),
|
"schemaVersion": getattr(result, "schema_version", "1.1"),
|
||||||
|
|||||||
@@ -226,8 +226,17 @@ def main() -> int:
|
|||||||
result_template_path = alg_dir / "excel" / "测量结果.xlsx"
|
result_template_path = alg_dir / "excel" / "测量结果.xlsx"
|
||||||
if not result_template_path.is_file():
|
if not result_template_path.is_file():
|
||||||
raise RuntimeError(f"内置测量结果模板不存在: {result_template_path}")
|
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(
|
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),
|
data_root=str(data_root),
|
||||||
pointcloud_kind="noise",
|
pointcloud_kind="noise",
|
||||||
point_chunk_size=8192,
|
point_chunk_size=8192,
|
||||||
|
|||||||
24
server/plugins/adapters/test_demo_task_selection.py
Normal file
24
server/plugins/adapters/test_demo_task_selection.py
Normal file
@@ -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()
|
||||||
@@ -19,6 +19,7 @@ v2.2 变更:
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import sys
|
import sys
|
||||||
import traceback
|
import traceback
|
||||||
from pathlib import Path
|
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):
|
def _to_float(value):
|
||||||
if value is None:
|
if value is None:
|
||||||
return None
|
return None
|
||||||
@@ -155,6 +168,13 @@ def main() -> int:
|
|||||||
if t.get("enabled", True)
|
if t.get("enabled", True)
|
||||||
}
|
}
|
||||||
tm.tasks = [t for t in tm.tasks if t.name in _enabled_names]
|
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"
|
viz_dir = out_dir / "visualizations"
|
||||||
|
|||||||
@@ -49,6 +49,11 @@ def main() -> int:
|
|||||||
try:
|
try:
|
||||||
emit_progress(0.05, "加载挠度算法包 v1.2.1")
|
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)
|
pointcloud_path = first_pointcloud(payload)
|
||||||
if not pointcloud_path:
|
if not pointcloud_path:
|
||||||
|
|||||||
@@ -8,6 +8,13 @@
|
|||||||
"category": "几何测量",
|
"category": "几何测量",
|
||||||
"requiresPointCloud": true,
|
"requiresPointCloud": true,
|
||||||
"requiresCad": false,
|
"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": [
|
"inputs": [
|
||||||
{
|
{
|
||||||
"name": "profile",
|
"name": "profile",
|
||||||
|
|||||||
@@ -8,6 +8,29 @@
|
|||||||
"category": "几何测量",
|
"category": "几何测量",
|
||||||
"requiresPointCloud": false,
|
"requiresPointCloud": false,
|
||||||
"requiresCad": 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": [
|
"inputs": [
|
||||||
{
|
{
|
||||||
"name": "useBatchDatasets",
|
"name": "useBatchDatasets",
|
||||||
|
|||||||
@@ -8,6 +8,25 @@
|
|||||||
"category": "尺寸测量",
|
"category": "尺寸测量",
|
||||||
"requiresPointCloud": true,
|
"requiresPointCloud": true,
|
||||||
"requiresCad": false,
|
"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",
|
"uiPanel": "train-measurement",
|
||||||
"inputs": [
|
"inputs": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -8,6 +8,10 @@
|
|||||||
"category": "几何测量",
|
"category": "几何测量",
|
||||||
"requiresPointCloud": true,
|
"requiresPointCloud": true,
|
||||||
"requiresCad": false,
|
"requiresCad": false,
|
||||||
|
"demoSelection": { "kind": "task-nos", "param": "demoTaskNos" },
|
||||||
|
"demoTasks": [
|
||||||
|
{ "taskNo": 5, "name": "纵向梁到直线的垂直偏差(挠度)", "category": "挠度", "inspectionObjects": ["head", "middle"] }
|
||||||
|
],
|
||||||
"inputs": [
|
"inputs": [
|
||||||
{
|
{
|
||||||
"name": "leftRoiPath",
|
"name": "leftRoiPath",
|
||||||
|
|||||||
@@ -68,6 +68,22 @@ export interface PluginRuntimeMock {
|
|||||||
|
|
||||||
export type PluginRuntime = PluginRuntimeProcess | 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) */
|
/** 插件 manifest(写在 server/plugins/manifests/*.json) */
|
||||||
export interface PluginManifest {
|
export interface PluginManifest {
|
||||||
/** 全局唯一 ID(kebab-case,例如 coupler-panel) */
|
/** 全局唯一 ID(kebab-case,例如 coupler-panel) */
|
||||||
@@ -89,6 +105,10 @@ export interface PluginManifest {
|
|||||||
requiresCad?: boolean;
|
requiresCad?: boolean;
|
||||||
/** 是否需要点云(默认 true) */
|
/** 是否需要点云(默认 true) */
|
||||||
requiresPointCloud?: boolean;
|
requiresPointCloud?: boolean;
|
||||||
|
/** “选择任务运行”模式的权威任务目录。 */
|
||||||
|
demoTasks?: PluginDemoTask[];
|
||||||
|
/** 选中任务传给适配器的方式。 */
|
||||||
|
demoSelection?: PluginDemoSelection;
|
||||||
runtime: PluginRuntime;
|
runtime: PluginRuntime;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { enqueueDeviationHeatmapJob } from "./deviationHeatmapJob.js";
|
|||||||
import { enqueueSmoothHeatmapJob } from "./smoothHeatmapJob.js";
|
import { enqueueSmoothHeatmapJob } from "./smoothHeatmapJob.js";
|
||||||
import { stepParseCacheDir } from "./stepParse.js";
|
import { stepParseCacheDir } from "./stepParse.js";
|
||||||
import { listPlugins, getPlugin } from "./plugins/registry.js";
|
import { listPlugins, getPlugin } from "./plugins/registry.js";
|
||||||
|
import { buildDemoTaskCatalog, resolveSelectedDemoJobs } from "./demoFlow.js";
|
||||||
import { enqueueAlgorithmJob, algorithmJobStats, cancelAlgorithmJob, cancelAlgorithmJobsByTask } from "./algorithmJob.js";
|
import { enqueueAlgorithmJob, algorithmJobStats, cancelAlgorithmJob, cancelAlgorithmJobsByTask } from "./algorithmJob.js";
|
||||||
import { cancelLegacyAnalysisJob, cancelLegacyAnalysisJobsByTask } from "./analysisJobControl.js";
|
import { cancelLegacyAnalysisJob, cancelLegacyAnalysisJobsByTask } from "./analysisJobControl.js";
|
||||||
import { deleteAnalysisResult } from "./analysisResultDeletion.js";
|
import { deleteAnalysisResult } from "./analysisResultDeletion.js";
|
||||||
@@ -313,13 +314,26 @@ async function cancelTaskAnalysisResultsByTask(taskId: number, reason = "用户
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface DemoFlowMeasurementJob {
|
||||||
|
pluginId: string;
|
||||||
|
params?: Record<string, unknown>;
|
||||||
|
taskNos?: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DemoFlowMetadata {
|
||||||
|
demoFlowMode: "all" | "selected";
|
||||||
|
demoFlowSelectedTaskNos?: number[];
|
||||||
|
demoFlowSelectedTaskCount?: number;
|
||||||
|
}
|
||||||
|
|
||||||
async function enqueueDemoRegisteredMeasurementsAfterRegistration(input: {
|
async function enqueueDemoRegisteredMeasurementsAfterRegistration(input: {
|
||||||
taskId: number;
|
taskId: number;
|
||||||
sourcePointCloudFileId: number;
|
sourcePointCloudFileId: number;
|
||||||
cadFileId: number;
|
cadFileId: number;
|
||||||
registrationResultId: number;
|
registrationResultId: number;
|
||||||
measurementPluginIds: string[];
|
measurementJobs: DemoFlowMeasurementJob[];
|
||||||
demoFlowRunId: string;
|
demoFlowRunId: string;
|
||||||
|
metadata: DemoFlowMetadata;
|
||||||
}) {
|
}) {
|
||||||
const registrationResult = await db.getTaskAnalysisResultById(input.registrationResultId);
|
const registrationResult = await db.getTaskAnalysisResultById(input.registrationResultId);
|
||||||
if (registrationResult?.status !== "completed") {
|
if (registrationResult?.status !== "completed") {
|
||||||
@@ -335,13 +349,14 @@ async function enqueueDemoRegisteredMeasurementsAfterRegistration(input: {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (let idx = 0; idx < input.measurementPluginIds.length; idx += 1) {
|
for (let idx = 0; idx < input.measurementJobs.length; idx += 1) {
|
||||||
const pluginId = input.measurementPluginIds[idx];
|
const job = input.measurementJobs[idx];
|
||||||
|
const pluginId = job.pluginId;
|
||||||
const manifest = getPlugin(pluginId);
|
const manifest = getPlugin(pluginId);
|
||||||
await createAndEnqueuePluginJob({
|
await createAndEnqueuePluginJob({
|
||||||
taskId: input.taskId,
|
taskId: input.taskId,
|
||||||
pluginId,
|
pluginId,
|
||||||
params: paramsForDemoPlugin(pluginId),
|
params: { ...paramsForDemoPlugin(pluginId), ...(job.params ?? {}) },
|
||||||
pointCloudFileIds: DEMO_REGISTERED_POINTCLOUD_PLUGIN_IDS.has(pluginId)
|
pointCloudFileIds: DEMO_REGISTERED_POINTCLOUD_PLUGIN_IDS.has(pluginId)
|
||||||
? [registered.id]
|
? [registered.id]
|
||||||
: [input.sourcePointCloudFileId],
|
: [input.sourcePointCloudFileId],
|
||||||
@@ -350,14 +365,86 @@ async function enqueueDemoRegisteredMeasurementsAfterRegistration(input: {
|
|||||||
demoFlowRunId: input.demoFlowRunId,
|
demoFlowRunId: input.demoFlowRunId,
|
||||||
demoFlowRole: "measurement",
|
demoFlowRole: "measurement",
|
||||||
demoFlowIndex: idx + 2,
|
demoFlowIndex: idx + 2,
|
||||||
demoFlowTotal: input.measurementPluginIds.length + 1,
|
demoFlowTotal: input.measurementJobs.length + 1,
|
||||||
demoFlowSourcePointCloudFileId: input.sourcePointCloudFileId,
|
demoFlowSourcePointCloudFileId: input.sourcePointCloudFileId,
|
||||||
demoFlowCadFileId: input.cadFileId,
|
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({
|
export const appRouter = router({
|
||||||
system: systemRouter,
|
system: systemRouter,
|
||||||
|
|
||||||
@@ -1325,6 +1412,19 @@ export const appRouter = router({
|
|||||||
return { success: true as const, jobs: created, scheduler: algorithmJobStats() };
|
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
|
runDemoAll: protectedProcedure
|
||||||
.input(
|
.input(
|
||||||
@@ -1337,85 +1437,50 @@ export const appRouter = router({
|
|||||||
)
|
)
|
||||||
.mutation(async ({ input }) => {
|
.mutation(async ({ input }) => {
|
||||||
const measurementPluginIds = normalizeDemoMeasurementPluginIds(input.measurementPluginIds);
|
const measurementPluginIds = normalizeDemoMeasurementPluginIds(input.measurementPluginIds);
|
||||||
const demoFlowRunId = randomUUID();
|
return startDemoFlow({
|
||||||
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({
|
|
||||||
taskId: input.taskId,
|
taskId: input.taskId,
|
||||||
pluginId: "vehicle-registration",
|
sourcePointCloudFileId: input.sourcePointCloudFileId,
|
||||||
params: {},
|
cadFileId: input.cadFileId,
|
||||||
pointCloudFileIds: [input.sourcePointCloudFileId],
|
measurementJobs: measurementPluginIds.map((pluginId) => ({ pluginId })),
|
||||||
cadFileIds: [input.cadFileId],
|
metadata: { demoFlowMode: "all" },
|
||||||
resultData: {
|
});
|
||||||
demoFlowRunId,
|
}),
|
||||||
demoFlowRole: "registration",
|
|
||||||
demoFlowIndex: 1,
|
/** 自选任务全流程:服务端校验任务并只提交覆盖所选任务的最小插件集合。 */
|
||||||
demoFlowTotal: measurementPluginIds.length + 1,
|
runDemoSelected: protectedProcedure
|
||||||
demoFlowSourcePointCloudFileId: input.sourcePointCloudFileId,
|
.input(z.object({
|
||||||
demoFlowCadFileId: input.cadFileId,
|
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<typeof resolveSelectedDemoJobs>;
|
||||||
|
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 };
|
return { ...result, selectedTaskNos: selectedPlan.taskNos };
|
||||||
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(),
|
|
||||||
};
|
|
||||||
}),
|
}),
|
||||||
|
|
||||||
cancelDemoAll: protectedProcedure
|
cancelDemoAll: protectedProcedure
|
||||||
|
|||||||
Reference in New Issue
Block a user