feat: update task file selection UI

This commit is contained in:
2026-07-07 18:15:47 +08:00
parent b5440fa1c3
commit dc760de7a2
4 changed files with 539 additions and 95 deletions

View File

@@ -9,7 +9,7 @@
* - 多选执行时调用 `algorithm.runBatch` 一次性把所有作业丢进后端并发池
* (默认 4 并发),20 分钟内出结果(工信部验收要求)。
*/
import { useEffect, useMemo, useState } from 'react';
import { useEffect, useState } from 'react';
import { trpc } from '@/lib/trpc';
import { AlertTriangle, CheckCircle2, ChevronDown, ChevronRight, Cpu, Play, RefreshCw, Scan } from 'lucide-react';
import { toast } from 'sonner';
@@ -42,12 +42,23 @@ interface PluginSummary {
runtimeKind: 'process' | 'mock';
}
interface PointcloudFileSummary {
id: number;
name: string;
source?: string;
sourceResultId?: number | null;
sourcePointcloudFileId?: number | null;
createdAt?: string | Date;
}
interface Props {
taskId: number | null;
/** 是否已加载点云(3D 视图,仅用于提示,不再作为执行前置条件) */
pointCloudLoaded: boolean;
/** 当前任务下的点云文件总数(>0 即可执行算法,无需先在 3D 视图加载) */
taskPcFileCount?: number;
/** 当前任务下的点云文件元数据(用于配准输入校验与重复配准确认) */
taskPointcloudFiles?: PointcloudFileSummary[];
/** 当前任务中用户选中的点云文件 ID(空/undefined = 全部) */
selectedPcFileIds?: number[];
/** 当前任务中选中的点云中是否包含配准后的点云 */
@@ -60,6 +71,13 @@ interface Props {
activeRoiPickTarget?: { taskIdx: number; roiKey: string; corner: string } | null;
}
function formatDateTime(value?: string | Date) {
if (!value) return '未知时间';
const date = value instanceof Date ? value : new Date(value);
if (Number.isNaN(date.getTime())) return '未知时间';
return date.toLocaleString('zh-CN', { hour12: false });
}
function defaultParamsFor(plugin: PluginSummary): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const field of plugin.inputs) {
@@ -68,7 +86,7 @@ function defaultParamsFor(plugin: PluginSummary): Record<string, unknown> {
return out;
}
export default function AlgorithmRunPanel({ taskId, pointCloudLoaded: _pointCloudLoaded, taskPcFileCount, selectedPcFileIds, hasRegisteredCloud, selectedCadFileIds, roiPickedPoint, onStartRoiPick, activeRoiPickTarget }: Props) {
export default function AlgorithmRunPanel({ taskId, pointCloudLoaded: _pointCloudLoaded, taskPcFileCount, taskPointcloudFiles = [], selectedPcFileIds, hasRegisteredCloud, selectedCadFileIds, roiPickedPoint, onStartRoiPick, activeRoiPickTarget }: Props) {
const utils = trpc.useUtils();
const { data, isLoading } = trpc.algorithm.list.useQuery(undefined, {
staleTime: 60_000,
@@ -78,6 +96,7 @@ export default function AlgorithmRunPanel({ taskId, pointCloudLoaded: _pointClou
toast.success(`已提交 ${jobs.length} 个算法到并发池,进度请查看下方「分析结果」`);
if (taskId != null) {
void utils.analysisResult.list.invalidate({ taskId });
void utils.pointcloudFile.list.invalidate({ taskId });
}
},
onError: (e) => toast.error(e.message || '提交失败'),
@@ -125,14 +144,81 @@ export default function AlgorithmRunPanel({ taskId, pointCloudLoaded: _pointClou
}));
};
const canRun = useMemo(
// 任务下有点云文件(taskPcFileCount > 0)即可执行,无需先在 3D 视图加载
() => taskId != null && (taskPcFileCount ?? 0) > 0 && selected.size > 0 && !runBatch.isPending,
[taskId, taskPcFileCount, selected, runBatch.isPending],
);
// 区分配准插件 vs 测量插件
const regPlugins = plugins.filter((p) => p.category === '配准');
const measPlugins = plugins.filter((p) => p.category !== '配准');
const selectedMeasIds = measPlugins.filter((p) => selected.has(p.id)).map((p) => p.id);
const selectedRegIds = regPlugins.filter((p) => selected.has(p.id)).map((p) => p.id);
const handleRun = (ids: string[]) => {
if (!canRun || taskId == null) return;
const selectedPcCount = selectedPcFileIds?.length ?? 0;
const selectedCadCount = selectedCadFileIds?.length ?? 0;
const selectedRegistrationPointcloud =
selectedPcCount === 1
? taskPointcloudFiles.find((f) => f.id === selectedPcFileIds?.[0])
: undefined;
const latestRegistrationChild = selectedRegistrationPointcloud?.source !== 'registered'
? taskPointcloudFiles
.filter((f) => (
f.source === 'registered'
&& f.sourcePointcloudFileId === selectedRegistrationPointcloud?.id
))
.sort((a, b) => {
const aTime = a.createdAt ? new Date(a.createdAt).getTime() : 0;
const bTime = b.createdAt ? new Date(b.createdAt).getTime() : 0;
return bTime - aTime;
})[0]
: undefined;
const baseRunBlockReason =
taskId == null ? '请先选择检测任务'
: (taskPcFileCount ?? 0) === 0 ? '请先上传点云文件'
: runBatch.isPending ? '算法提交中,请稍候'
: null;
const registrationBlockReason =
baseRunBlockReason
?? (selectedRegIds.length === 0 ? '请勾选配准算法'
: selectedPcCount === 0 ? '整车配准必须选择 1 个点云文件'
: selectedPcCount > 1 ? '整车配准一次只能选择 1 个点云文件'
: selectedRegistrationPointcloud?.source === 'registered' ? '请选择原始点云进行配准,已配准点云不能再次配准'
: selectedCadCount === 0 ? '整车配准必须选择 1 个 CAD 文件'
: selectedCadCount > 1 ? '整车配准一次只能选择 1 个 CAD 文件'
: null);
const measurementBlockReason =
baseRunBlockReason
?? (selectedMeasIds.length === 0 ? '请至少勾选一个测量算法' : null);
const canRunRegistration = registrationBlockReason == null;
const canRunMeasurement = measurementBlockReason == null;
const duplicateRegistrationWarning =
latestRegistrationChild && selectedRegistrationPointcloud
? `点云「${selectedRegistrationPointcloud.name}」已有配准产物「${latestRegistrationChild.name}」(${formatDateTime(latestRegistrationChild.createdAt)}),再次执行会生成新产物。`
: null;
const handleRun = (ids: string[], mode: 'registration' | 'measurement') => {
const blockReason = mode === 'registration' ? registrationBlockReason : measurementBlockReason;
if (blockReason) {
toast.error(blockReason);
return;
}
if (taskId == null) return;
const allowDuplicateRegistration =
mode === 'registration' && duplicateRegistrationWarning
? window.confirm(`${duplicateRegistrationWarning}\n\n是否确认重新配准?`)
: false;
if (mode === 'registration' && duplicateRegistrationWarning && !allowDuplicateRegistration) {
return;
}
const pointCloudFileIds =
mode === 'registration'
? selectedPcFileIds
: selectedPcFileIds && selectedPcFileIds.length > 0
? selectedPcFileIds
: undefined;
const cadFileIds =
mode === 'registration'
? selectedCadFileIds
: selectedCadFileIds && selectedCadFileIds.length > 0
? selectedCadFileIds
: undefined;
const jobs = ids.map((pluginId) => ({
pluginId,
params: paramsById[pluginId] ?? {},
@@ -140,21 +226,12 @@ export default function AlgorithmRunPanel({ taskId, pointCloudLoaded: _pointClou
runBatch.mutate({
taskId,
jobs,
...(selectedPcFileIds && selectedPcFileIds.length > 0
? { pointCloudFileIds: selectedPcFileIds }
: {}),
...(selectedCadFileIds && selectedCadFileIds.length > 0
? { cadFileIds: selectedCadFileIds }
: {}),
...(pointCloudFileIds ? { pointCloudFileIds } : {}),
...(cadFileIds ? { cadFileIds } : {}),
...(allowDuplicateRegistration ? { allowDuplicateRegistration } : {}),
});
};
// 区分配准插件 vs 测量插件
const regPlugins = plugins.filter((p) => p.category === '配准');
const measPlugins = plugins.filter((p) => p.category !== '配准');
const selectedMeasIds = measPlugins.filter((p) => selected.has(p.id)).map((p) => p.id);
const selectedRegIds = regPlugins.filter((p) => selected.has(p.id)).map((p) => p.id);
// 不再需要 showRegWarning 变量,已直接内联到 JSX 里
return (
@@ -169,11 +246,11 @@ export default function AlgorithmRunPanel({ taskId, pointCloudLoaded: _pointClou
<button
type="button"
className={`industrial-btn industrial-btn-primary py-0.5 text-[10px] flex items-center gap-0.5 ${
canRun && selectedRegIds.length > 0 ? '' : 'opacity-50 cursor-not-allowed'
canRunRegistration ? '' : 'opacity-50 cursor-not-allowed'
}`}
disabled={!canRun || selectedRegIds.length === 0}
onClick={() => handleRun(selectedRegIds)}
title={selectedRegIds.length === 0 ? '请勾选配准算法' : '执行配准'}
disabled={!canRunRegistration}
onClick={() => handleRun(selectedRegIds, 'registration')}
title={registrationBlockReason ?? '执行配准'}
>
{runBatch.isPending ? <RefreshCw className="w-3 h-3 animate-spin" /> : <Play className="w-3 h-3" />}
执行({selectedRegIds.length})
@@ -182,6 +259,18 @@ export default function AlgorithmRunPanel({ taskId, pointCloudLoaded: _pointClou
{regPlugins.length === 0 && !isLoading && (
<div className="text-[10px] text-muted-foreground italic px-1">暂无配准插件</div>
)}
{selectedRegIds.length > 0 && registrationBlockReason && !baseRunBlockReason && (
<div className="flex items-start gap-1.5 bg-amber-900/20 border border-amber-700/40 rounded p-2">
<AlertTriangle className="w-3 h-3 text-amber-400 flex-shrink-0 mt-0.5" />
<span className="text-[10px] text-amber-300">{registrationBlockReason}</span>
</div>
)}
{selectedRegIds.length > 0 && !registrationBlockReason && duplicateRegistrationWarning && (
<div className="flex items-start gap-1.5 bg-amber-900/20 border border-amber-700/40 rounded p-2">
<AlertTriangle className="w-3 h-3 text-amber-400 flex-shrink-0 mt-0.5" />
<span className="text-[10px] text-amber-300">{duplicateRegistrationWarning}</span>
</div>
)}
{regPlugins.map((p) => (
<PluginCard
key={p.id}
@@ -219,16 +308,11 @@ export default function AlgorithmRunPanel({ taskId, pointCloudLoaded: _pointClou
<button
type="button"
className={`industrial-btn industrial-btn-primary py-0.5 text-[10px] flex items-center gap-0.5 ${
canRun && selectedMeasIds.length > 0 ? '' : 'opacity-50 cursor-not-allowed'
canRunMeasurement ? '' : 'opacity-50 cursor-not-allowed'
}`}
disabled={!canRun || selectedMeasIds.length === 0}
onClick={() => handleRun(selectedMeasIds)}
title={
taskId == null ? '请先选择检测任务'
: (taskPcFileCount ?? 0) === 0 ? '请先上传点云文件'
: selectedMeasIds.length === 0 ? '请至少勾选一个测量算法'
: '并发执行已勾选算法'
}
disabled={!canRunMeasurement}
onClick={() => handleRun(selectedMeasIds, 'measurement')}
title={measurementBlockReason ?? '并发执行已勾选算法'}
>
{runBatch.isPending ? <RefreshCw className="w-3 h-3 animate-spin" /> : <Play className="w-3 h-3" />}
执行({selectedMeasIds.length})

View File

@@ -2,6 +2,7 @@ import { useState, useEffect } from 'react';
import { trpc } from '@/lib/trpc';
import { BarChart2, Ruler, Zap, Play, Trash2, ChevronDown, ChevronRight, RefreshCw, Flame, Box, Layers, Crosshair, Cpu, Timer } from 'lucide-react';
import GenericResultView, { type Overlay3D } from './GenericResultView';
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
/** 将毫秒数格式化为可读时长,如 2m 34s / 47s / 1h 5m */
function fmtDuration(ms: number): string {
@@ -162,6 +163,16 @@ type AssemblyMeta = SmoothHeatmapMeta & {
parts?: AssemblyPartRow[];
};
function getResultTitle(r: { type: string; pluginId?: string }) {
if (r.type === 'error_analysis') return '误差分析';
if (r.type === 'measurement') return '尺寸测量';
if (r.type === 'smooth_heatmap') return '平滑热力';
if (r.type === 'assembly_inspection') return '装配检测';
if (r.type === 'obj_deviation') return '偏差热力';
if (r.type === 'plugin') return `算法 · ${r.pluginId ?? '插件'}`;
return '压缩';
}
export default function AnalysisPanel({ taskId, onReplay, onViewSmoothHeatmap, onRunAssembly, onRunDeviation, externalPending, onViewPluginOverlay }: Props) {
const utils = trpc.useUtils();
const [expandedIds, setExpandedIds] = useState<Set<number>>(new Set());
@@ -243,12 +254,13 @@ export default function AnalysisPanel({ taskId, onReplay, onViewSmoothHeatmap, o
const expanded = expandedIds.has(r.id);
const errorStats = r.errorStats as ErrorStats | null;
const measurements = r.measurements as Measurement[] | null;
const resultTitle = getResultTitle(r as { type: string; pluginId?: string });
return (
<div key={r.id} className="industrial-card rounded overflow-hidden">
<div key={r.id} className="industrial-card rounded overflow-hidden min-w-0">
{/* 标题行 */}
<div
className="flex items-center gap-2 px-2 py-1.5 cursor-pointer hover:bg-secondary/40 group"
className="flex items-center gap-2 px-2 py-1.5 cursor-pointer hover:bg-secondary/40 group min-w-0"
onClick={() => toggleExpand(r.id)}
>
{expanded ? <ChevronDown className="w-3 h-3 text-muted-foreground" /> : <ChevronRight className="w-3 h-3 text-muted-foreground" />}
@@ -259,21 +271,18 @@ export default function AnalysisPanel({ taskId, onReplay, onViewSmoothHeatmap, o
{r.type === 'assembly_inspection' && <Layers className="w-3.5 h-3.5 text-cyan-400 flex-shrink-0" />}
{r.type === 'obj_deviation' && <Crosshair className="w-3.5 h-3.5 text-rose-400 flex-shrink-0" />}
{r.type === 'plugin' && <Cpu className="w-3.5 h-3.5 text-violet-400 flex-shrink-0" />}
<span className="text-xs flex-1 truncate">
{r.type === 'error_analysis'
? '误差分析'
: r.type === 'measurement'
? '尺寸测量'
: r.type === 'smooth_heatmap'
? '平滑热力'
: r.type === 'assembly_inspection'
? '装配检测'
: r.type === 'obj_deviation'
? '偏差热力'
: r.type === 'plugin'
? `算法 · ${(r as { pluginId?: string }).pluginId ?? '插件'}`
: '压缩'}
</span>
<Tooltip>
<TooltipTrigger asChild>
<span className="text-xs flex-1 min-w-0 truncate">{resultTitle}</span>
</TooltipTrigger>
<TooltipContent
side="left"
align="start"
className="max-w-[min(28rem,calc(100vw-2rem))] break-all"
>
{resultTitle}
</TooltipContent>
</Tooltip>
<span className="text-[10px] text-muted-foreground">
{new Date(r.createdAt).toLocaleDateString('zh-CN')}
</span>

View File

@@ -13,7 +13,9 @@ import {
Square,
FileBox,
Eye,
Library,
} from 'lucide-react';
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
interface Props {
modelId: number;
@@ -37,6 +39,13 @@ function formatSize(bytes: number) {
return (bytes / 1e9).toFixed(2) + ' GB';
}
function formatDateTime(value?: string | Date) {
if (!value) return '未知时间';
const date = value instanceof Date ? value : new Date(value);
if (Number.isNaN(date.getTime())) return '未知时间';
return date.toLocaleString('zh-CN', { hour12: false });
}
export default function TaskManager({
modelId,
activeTaskId,
@@ -54,7 +63,11 @@ export default function TaskManager({
const [uploadingFor, setUploadingFor] = useState<number | null>(null);
const [uploadProgress, setUploadProgress] = useState(0);
const fileRef = useRef<HTMLInputElement>(null);
const modelPointcloudFileRef = useRef<HTMLInputElement>(null);
const pendingTaskId = useRef<number | null>(null);
const [pcLibraryOpen, setPcLibraryOpen] = useState(false);
const [selectedModelPcFileIds, setSelectedModelPcFileIds] = useState<Set<number>>(new Set());
const [uploadingModelPointcloud, setUploadingModelPointcloud] = useState(false);
// ── 点云文件多选状态 ──────────────────────────────────────────────────────
const [selectedFileIds, setSelectedFileIds] = useState<Set<number>>(new Set());
@@ -62,8 +75,12 @@ export default function TaskManager({
// ── 任务级 CAD 文件多选状态 ───────────────────────────────────────────────
const [selectedCadFileIds, setSelectedCadFileIds] = useState<Set<number>>(new Set());
const cadFileRef = useRef<HTMLInputElement>(null);
const modelCadFileRef = useRef<HTMLInputElement>(null);
const [uploadingCadFor, setUploadingCadFor] = useState<number | null>(null);
const [cadUploadProgress, setCadUploadProgress] = useState(0);
const [cadLibraryOpen, setCadLibraryOpen] = useState(false);
const [selectedModelCadFileIds, setSelectedModelCadFileIds] = useState<Set<number>>(new Set());
const [uploadingModelCad, setUploadingModelCad] = useState(false);
const { data: tasks = [] } = trpc.inspectionTask.listByModel.useQuery({ modelId });
const createTask = trpc.inspectionTask.create.useMutation({
@@ -81,6 +98,16 @@ export default function TaskManager({
const deletePcFile = trpc.pointcloudFile.delete.useMutation({
onSuccess: () => utils.pointcloudFile.list.invalidate({ taskId: activeTaskId! }),
});
const { data: modelPcFiles = [] } = trpc.modelPointcloudFile.list.useQuery(
{ modelId },
{ enabled: activeTaskId != null },
);
const linkPcFilesFromModel = trpc.pointcloudFile.linkFromModel.useMutation({
onSuccess: () => {
if (activeTaskId != null) utils.pointcloudFile.list.invalidate({ taskId: activeTaskId });
setSelectedModelPcFileIds(new Set());
},
});
// 当前选中任务下的 CAD 文件
const { data: taskCadFiles = [] } = trpc.taskCadFile.list.useQuery(
@@ -90,6 +117,16 @@ export default function TaskManager({
const deleteTaskCadFile = trpc.taskCadFile.delete.useMutation({
onSuccess: () => utils.taskCadFile.list.invalidate({ taskId: activeTaskId! }),
});
const { data: modelCadFiles = [] } = trpc.cadFile.list.useQuery(
{ modelId },
{ enabled: activeTaskId != null },
);
const linkCadFilesFromModel = trpc.taskCadFile.linkFromModel.useMutation({
onSuccess: () => {
if (activeTaskId != null) utils.taskCadFile.list.invalidate({ taskId: activeTaskId });
setSelectedModelCadFileIds(new Set());
},
});
// CAD 三角化采样缓存状态(key: cadFileId, value: 是否已缓存)
const cadFileIds = taskCadFiles.map((f) => f.id);
@@ -102,6 +139,10 @@ export default function TaskManager({
useEffect(() => {
setSelectedFileIds(new Set());
setSelectedCadFileIds(new Set());
setSelectedModelPcFileIds(new Set());
setSelectedModelCadFileIds(new Set());
setPcLibraryOpen(false);
setCadLibraryOpen(false);
}, [activeTaskId]);
// 通知父组件点云选中变化
@@ -133,6 +174,14 @@ export default function TaskManager({
});
}
function toggleModelPcFile(id: number) {
setSelectedModelPcFileIds((prev) => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
}
function openUpload(taskId: number) {
pendingTaskId.current = taskId;
fileRef.current?.click();
@@ -172,6 +221,27 @@ export default function TaskManager({
}
}
async function handleModelPointcloudUpload(files: FileList) {
setUploadingModelPointcloud(true);
try {
for (let i = 0; i < files.length; i++) {
const fd = new FormData();
fd.append('file', files[i]);
const res = await fetch(`/api/files/upload/model-pointcloud/${modelId}`, {
method: 'POST',
body: fd,
});
if (!res.ok) throw new Error(await res.text());
}
utils.modelPointcloudFile.list.invalidate({ modelId });
} catch (e) {
console.error('Model pointcloud upload error', e);
alert('上传失败: ' + (e as Error).message);
} finally {
setUploadingModelPointcloud(false);
}
}
// 加载按钮:有勾选就只加载勾选的,否则加载全部
function handleLoad() {
if (activeTaskId == null || pcFiles.length === 0) return;
@@ -198,6 +268,28 @@ export default function TaskManager({
});
}
function toggleModelCadFile(id: number) {
setSelectedModelCadFileIds((prev) => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
}
function taskCadReadUrl(file: { name: string; source?: string }) {
const encoded = encodeURIComponent(file.name);
if (file.source === 'model_library') {
return `/api/files/read/cad/${modelId}/${encoded}`;
}
return `/api/files/read/task-cad/${activeTaskId}/${encoded}`;
}
function canPreviewCadIn3D(file: { name: string; format?: string }) {
const format = file.format?.toLowerCase();
const ext = file.name.split('.').pop()?.toLowerCase();
return format === 'stl' || format === 'obj' || ext === 'stl' || ext === 'obj';
}
async function handleCadUpload(files: FileList) {
if (activeTaskId == null) return;
setUploadingCadFor(activeTaskId);
@@ -231,6 +323,27 @@ export default function TaskManager({
}
}
async function handleModelCadUpload(files: FileList) {
setUploadingModelCad(true);
try {
for (let i = 0; i < files.length; i++) {
const fd = new FormData();
fd.append('file', files[i]);
const res = await fetch(`/api/files/upload/cad/${modelId}`, {
method: 'POST',
body: fd,
});
if (!res.ok) throw new Error(await res.text());
}
utils.cadFile.list.invalidate({ modelId });
} catch (e) {
console.error('Model CAD upload error', e);
alert('上传失败: ' + (e as Error).message);
} finally {
setUploadingModelCad(false);
}
}
return (
<div className="space-y-3">
{/* 任务列表 */}
@@ -353,40 +466,50 @@ export default function TaskManager({
{activeTaskId != null && (
<div>
{/* 标题栏 */}
<div className="flex items-center justify-between mb-1.5">
<div className="flex items-center gap-1.5">
{/* 全选复选框 */}
{pcFiles.length > 0 && (
<button
type="button"
className="text-muted-foreground hover:text-foreground"
title={allSelected ? '取消全选' : '全选'}
onClick={toggleAllFiles}
>
{allSelected ? (
<CheckSquare className="w-3.5 h-3.5 text-primary" />
) : (
<Square className="w-3.5 h-3.5" />
)}
</button>
)}
<span className="industrial-label flex items-center gap-1">
<Cloud className="w-3.5 h-3.5" /> 点云文件
<span className="text-[10px] text-muted-foreground font-normal">
({someSelected ? `${selectedFileIds.size}/` : ''}{pcFiles.length})
<div className="mb-1.5 space-y-1.5">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-1.5 min-w-0">
{/* 全选复选框 */}
{pcFiles.length > 0 && (
<button
type="button"
className="text-muted-foreground hover:text-foreground flex-shrink-0"
title={allSelected ? '取消全选' : '全选'}
onClick={toggleAllFiles}
>
{allSelected ? (
<CheckSquare className="w-3.5 h-3.5 text-primary" />
) : (
<Square className="w-3.5 h-3.5" />
)}
</button>
)}
<span className="industrial-label flex items-center gap-1 whitespace-nowrap">
<Cloud className="w-3.5 h-3.5" /> 点云文件
<span className="text-[10px] text-muted-foreground font-normal">
({someSelected ? `${selectedFileIds.size}/` : ''}{pcFiles.length})
</span>
</span>
</span>
</div>
</div>
<div className="flex items-center gap-1">
<button
className="industrial-btn py-0.5 text-[10px] flex items-center gap-0.5"
className="industrial-btn flex-1 min-w-0 justify-center whitespace-nowrap py-0.5 text-[10px] flex items-center gap-0.5"
onClick={() => openUpload(activeTaskId)}
>
<Upload className="w-3 h-3" /> 上传
<Upload className="w-3 h-3" /> 上传到任务
</button>
<button
className={`industrial-btn flex-1 min-w-0 justify-center whitespace-nowrap py-0.5 text-[10px] flex items-center gap-0.5 ${
pcLibraryOpen ? 'text-primary border-primary/50' : ''
}`}
onClick={() => setPcLibraryOpen((open) => !open)}
>
<Library className="w-3 h-3" /> 从车型库选择
</button>
{pcFiles.length > 0 && (
<button
className="industrial-btn industrial-btn-primary py-0.5 text-[10px] flex items-center gap-0.5"
className="industrial-btn industrial-btn-primary flex-1 min-w-0 justify-center whitespace-nowrap py-0.5 text-[10px] flex items-center gap-0.5"
title={
someSelected
? `加载选中的 ${selectedFileIds.size} 个文件`
@@ -412,9 +535,84 @@ export default function TaskManager({
</div>
)}
{pcLibraryOpen && (
<div className="industrial-card rounded p-2 mb-2 space-y-2">
<div className="flex items-center justify-between gap-2">
<span className="text-[10px] text-muted-foreground">
车型点云库 ({modelPcFiles.length})
</span>
<button
className="industrial-btn py-0.5 text-[10px] flex items-center gap-0.5"
disabled={uploadingModelPointcloud}
onClick={() => modelPointcloudFileRef.current?.click()}
>
<Upload className="w-3 h-3" />
{uploadingModelPointcloud ? '上传中' : '上传到车型库'}
</button>
</div>
<div className="space-y-1 max-h-40 overflow-auto">
{modelPcFiles.map((f) => {
const isChecked = selectedModelPcFileIds.has(f.id);
return (
<button
key={f.id}
type="button"
className={`w-full flex items-center gap-2 px-2 py-1.5 rounded text-xs text-left min-w-0 ${
isChecked ? 'bg-primary/10 border border-primary/30' : 'bg-secondary/40 hover:bg-secondary/70'
}`}
onClick={() => toggleModelPcFile(f.id)}
>
{isChecked ? (
<CheckSquare className="w-3.5 h-3.5 text-primary flex-shrink-0" />
) : (
<Square className="w-3.5 h-3.5 text-muted-foreground flex-shrink-0" />
)}
<Cloud className="w-3.5 h-3.5 text-cyan-400 flex-shrink-0" />
<span className="flex-1 min-w-0 truncate" title={f.name}>{f.name}</span>
<span className="text-muted-foreground text-[10px] flex-shrink-0">
{formatSize(f.fileSize)}
</span>
</button>
);
})}
{modelPcFiles.length === 0 && (
<div className="text-xs text-muted-foreground text-center py-3">
车型点云库为空
</div>
)}
</div>
<button
className="industrial-btn industrial-btn-primary w-full py-1 text-[10px] flex items-center justify-center gap-1"
disabled={activeTaskId == null || selectedModelPcFileIds.size === 0 || linkPcFilesFromModel.isPending}
onClick={() => {
if (activeTaskId == null || selectedModelPcFileIds.size === 0) return;
linkPcFilesFromModel.mutate({
taskId: activeTaskId,
modelPointcloudFileIds: Array.from(selectedModelPcFileIds),
});
}}
>
<Plus className="w-3 h-3" />
添加到当前任务{selectedModelPcFileIds.size > 0 ? ` (${selectedModelPcFileIds.size})` : ''}
</button>
</div>
)}
<div className="space-y-1">
{pcFiles.map((f) => {
const isChecked = selectedFileIds.has(f.id);
const meta = f as {
source?: string;
sourceResultId?: number | null;
sourcePointcloudFileId?: number | null;
createdAt?: string | Date;
};
const source = meta.source;
const sourcePointcloud = meta.sourcePointcloudFileId
? pcFiles.find((row) => row.id === meta.sourcePointcloudFileId)
: undefined;
return (
<div
key={f.id}
@@ -440,14 +638,50 @@ export default function TaskManager({
<Cloud
className={`w-3.5 h-3.5 flex-shrink-0 ${isChecked ? 'text-primary' : 'text-cyan-400'}`}
/>
<span className="flex-1 truncate" title={f.name}>
{f.name}
</span>
{(f as { source?: string }).source === 'registered' && (
<Tooltip>
<TooltipTrigger asChild>
<span className="flex-1 min-w-0 truncate">{f.name}</span>
</TooltipTrigger>
<TooltipContent
side="right"
align="start"
className="max-w-[min(32rem,calc(100vw-2rem))]"
>
<div className="space-y-1 text-[11px]">
<div className="break-all">{f.name}</div>
{source === 'registered' && (
<>
<div>类型:已配准点云</div>
{meta.sourceResultId != null && <div>结果编号:R{meta.sourceResultId}</div>}
<div>生成时间:{formatDateTime(meta.createdAt)}</div>
<div>
来源:
{sourcePointcloud
? sourcePointcloud.name
: meta.sourcePointcloudFileId != null
? `点云 #${meta.sourcePointcloudFileId}`
: '旧记录未记录来源'}
</div>
</>
)}
</div>
</TooltipContent>
</Tooltip>
{source === 'registered' && (
<span className="text-[9px] px-1 py-0.5 rounded bg-blue-900/40 text-blue-300 flex-shrink-0 whitespace-nowrap">
已配准
</span>
)}
{source === 'registered' && meta.sourceResultId != null && (
<span className="text-[9px] px-1 py-0.5 rounded bg-sky-500/15 text-sky-300 border border-sky-500/30 flex-shrink-0 whitespace-nowrap">
R{meta.sourceResultId}
</span>
)}
{source === 'model_library' && (
<span className="text-[9px] px-1 py-0.5 rounded bg-amber-500/15 text-amber-300 border border-amber-500/30 flex-shrink-0 whitespace-nowrap">
车型库
</span>
)}
<span className="text-muted-foreground text-[10px] flex-shrink-0">
{formatSize(f.fileSize)}
</span>
@@ -483,17 +717,19 @@ export default function TaskManager({
{/* 任务级 CAD 文件 */}
{activeTaskId != null && (
<div>
<div className="flex items-center justify-between mb-1.5">
<span className="industrial-label flex items-center gap-1">
<FileBox className="w-3.5 h-3.5" /> 任务 CAD
<span className="text-[10px] text-muted-foreground font-normal">
({taskCadFiles.length})
<div className="mb-1.5 space-y-1.5">
<div className="flex items-center justify-between gap-2">
<span className="industrial-label flex items-center gap-1 whitespace-nowrap">
<FileBox className="w-3.5 h-3.5" /> 任务 CAD
<span className="text-[10px] text-muted-foreground font-normal">
({taskCadFiles.length})
</span>
</span>
</span>
</div>
<div className="flex items-center gap-1">
{taskCadFiles.length > 0 && (
<button
className="industrial-btn py-0.5 text-[10px] flex items-center gap-0.5"
className="industrial-btn flex-1 min-w-0 justify-center whitespace-nowrap py-0.5 text-[10px] flex items-center gap-0.5"
onClick={toggleAllCadFiles}
title={allCadSelected ? '取消全选' : '全选'}
>
@@ -504,10 +740,18 @@ export default function TaskManager({
</button>
)}
<button
className="industrial-btn py-0.5 text-[10px] flex items-center gap-0.5"
className="industrial-btn flex-1 min-w-0 justify-center whitespace-nowrap py-0.5 text-[10px] flex items-center gap-0.5"
onClick={() => cadFileRef.current?.click()}
>
<Upload className="w-3 h-3" /> 上传
<Upload className="w-3 h-3" /> 上传到任务
</button>
<button
className={`industrial-btn flex-1 min-w-0 justify-center whitespace-nowrap py-0.5 text-[10px] flex items-center gap-0.5 ${
cadLibraryOpen ? 'text-primary border-primary/50' : ''
}`}
onClick={() => setCadLibraryOpen((open) => !open)}
>
<Library className="w-3 h-3" /> 从车型库选择
</button>
</div>
</div>
@@ -523,13 +767,80 @@ export default function TaskManager({
</div>
)}
{cadLibraryOpen && (
<div className="industrial-card rounded p-2 mb-2 space-y-2">
<div className="flex items-center justify-between gap-2">
<span className="text-[10px] text-muted-foreground">
车型 CAD 库 ({modelCadFiles.length})
</span>
<button
className="industrial-btn py-0.5 text-[10px] flex items-center gap-0.5"
disabled={uploadingModelCad}
onClick={() => modelCadFileRef.current?.click()}
>
<Upload className="w-3 h-3" />
{uploadingModelCad ? '上传中' : '上传到车型库'}
</button>
</div>
<div className="space-y-1 max-h-40 overflow-auto">
{modelCadFiles.map((f) => {
const isChecked = selectedModelCadFileIds.has(f.id);
return (
<button
key={f.id}
type="button"
className={`w-full flex items-center gap-2 px-2 py-1.5 rounded text-xs text-left min-w-0 ${
isChecked ? 'bg-primary/10 border border-primary/30' : 'bg-secondary/40 hover:bg-secondary/70'
}`}
onClick={() => toggleModelCadFile(f.id)}
>
{isChecked ? (
<CheckSquare className="w-3.5 h-3.5 text-primary flex-shrink-0" />
) : (
<Square className="w-3.5 h-3.5 text-muted-foreground flex-shrink-0" />
)}
<FileBox className="w-3.5 h-3.5 text-blue-400 flex-shrink-0" />
<span className="flex-1 min-w-0 truncate" title={f.name}>{f.name}</span>
<span className="text-muted-foreground text-[10px] flex-shrink-0">
{f.format.toUpperCase()} · {formatSize(f.fileSize)}
</span>
</button>
);
})}
{modelCadFiles.length === 0 && (
<div className="text-xs text-muted-foreground text-center py-3">
车型 CAD 库为空
</div>
)}
</div>
<button
className="industrial-btn industrial-btn-primary w-full py-1 text-[10px] flex items-center justify-center gap-1"
disabled={activeTaskId == null || selectedModelCadFileIds.size === 0 || linkCadFilesFromModel.isPending}
onClick={() => {
if (activeTaskId == null || selectedModelCadFileIds.size === 0) return;
linkCadFilesFromModel.mutate({
taskId: activeTaskId,
modelCadFileIds: Array.from(selectedModelCadFileIds),
});
}}
>
<Plus className="w-3 h-3" />
添加到当前任务{selectedModelCadFileIds.size > 0 ? ` (${selectedModelCadFileIds.size})` : ''}
</button>
</div>
)}
<div className="space-y-1">
{taskCadFiles.map((f) => {
const isChecked = selectedCadFileIds.has(f.id);
const source = (f as { source?: string }).source;
const canPreview = canPreviewCadIn3D(f);
return (
<div
key={f.id}
className={`flex items-center gap-2 px-2 py-1.5 rounded text-xs group cursor-pointer
className={`flex items-center gap-2 px-2 py-1.5 rounded text-xs group cursor-pointer min-w-0
${isChecked ? 'bg-primary/10 border border-primary/30' : 'industrial-card'}`}
onClick={() => toggleCadFile(f.id)}
>
@@ -545,7 +856,23 @@ export default function TaskManager({
)}
</button>
<FileBox className={`w-3.5 h-3.5 flex-shrink-0 ${isChecked ? 'text-primary' : 'text-blue-400'}`} />
<span className="flex-1 truncate" title={f.name}>{f.name}</span>
<Tooltip>
<TooltipTrigger asChild>
<span className="flex-1 min-w-0 truncate">{f.name}</span>
</TooltipTrigger>
<TooltipContent
side="right"
align="start"
className="max-w-[min(32rem,calc(100vw-2rem))] break-all"
>
{f.name}
</TooltipContent>
</Tooltip>
{source === 'model_library' && (
<span className="text-[9px] px-1 py-0.5 rounded bg-amber-500/15 text-amber-300 border border-amber-500/30 flex-shrink-0 whitespace-nowrap">
车型库
</span>
)}
{cadSampleCacheMap[f.id] && (
<span
className="text-[9px] px-1 py-0.5 rounded bg-green-500/15 text-green-400 border border-green-500/30 flex-shrink-0"
@@ -557,14 +884,14 @@ export default function TaskManager({
<span className="text-muted-foreground text-[10px] flex-shrink-0">
{formatSize(f.fileSize)}
</span>
{onSelectTaskCad && (
{onSelectTaskCad && canPreview && (
<button
className="hidden group-hover:block p-0.5 rounded hover:bg-secondary flex-shrink-0"
title="在 3D 视图中显示"
onClick={(e) => {
e.stopPropagation();
onSelectTaskCad(
`/api/files/read/task-cad/${activeTaskId}/${encodeURIComponent(f.name)}`,
taskCadReadUrl({ name: f.name, source }),
f.format,
f.name,
);
@@ -600,11 +927,14 @@ export default function TaskManager({
</p>
{selectedCadFileIds.size === 1 && onSelectTaskCad && (() => {
const f = taskCadFiles.find(f => selectedCadFileIds.has(f.id));
return f ? (
return f && canPreviewCadIn3D(f) ? (
<button
className="industrial-btn w-full py-0.5 text-[10px] flex items-center justify-center gap-1"
onClick={() => onSelectTaskCad(
`/api/files/read/task-cad/${activeTaskId}/${encodeURIComponent(f.name)}`,
taskCadReadUrl({
name: f.name,
source: (f as { source?: string }).source,
}),
f.format,
f.name,
)}
@@ -629,6 +959,16 @@ export default function TaskManager({
e.target.files && e.target.files.length > 0 && handleUpload(e.target.files)
}
/>
<input
ref={modelPointcloudFileRef}
type="file"
multiple
accept=".txt,.xyz,.pts,.ply,.pcd,.las,.laz"
className="hidden"
onChange={(e) =>
e.target.files && e.target.files.length > 0 && handleModelPointcloudUpload(e.target.files)
}
/>
<input
ref={cadFileRef}
type="file"
@@ -639,6 +979,16 @@ export default function TaskManager({
e.target.files && e.target.files.length > 0 && handleCadUpload(e.target.files)
}
/>
<input
ref={modelCadFileRef}
type="file"
multiple
accept=".obj,.stl,.step,.stp,.iges,.igs"
className="hidden"
onChange={(e) =>
e.target.files && e.target.files.length > 0 && handleModelCadUpload(e.target.files)
}
/>
</div>
);
}

View File

@@ -352,6 +352,7 @@ export default function Home() {
taskId={selection.taskId}
pointCloudLoaded={pointCloudTaskId === selection.taskId && pointCloudUrls.length > 0}
taskPcFileCount={taskPcFiles.length}
taskPointcloudFiles={taskPcFiles}
selectedPcFileIds={selectedPcFileIds}
hasRegisteredCloud={hasRegisteredCloud}
selectedCadFileIds={selectedTaskCadFileIds}