feat: update task file selection UI
This commit is contained in:
@@ -9,7 +9,7 @@
|
|||||||
* - 多选执行时调用 `algorithm.runBatch` 一次性把所有作业丢进后端并发池
|
* - 多选执行时调用 `algorithm.runBatch` 一次性把所有作业丢进后端并发池
|
||||||
* (默认 4 并发),20 分钟内出结果(工信部验收要求)。
|
* (默认 4 并发),20 分钟内出结果(工信部验收要求)。
|
||||||
*/
|
*/
|
||||||
import { useEffect, useMemo, 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 } from 'lucide-react';
|
import { AlertTriangle, CheckCircle2, ChevronDown, ChevronRight, Cpu, Play, RefreshCw, Scan } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
@@ -42,12 +42,23 @@ interface PluginSummary {
|
|||||||
runtimeKind: 'process' | 'mock';
|
runtimeKind: 'process' | 'mock';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface PointcloudFileSummary {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
source?: string;
|
||||||
|
sourceResultId?: number | null;
|
||||||
|
sourcePointcloudFileId?: number | null;
|
||||||
|
createdAt?: string | Date;
|
||||||
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
taskId: number | null;
|
taskId: number | null;
|
||||||
/** 是否已加载点云(3D 视图,仅用于提示,不再作为执行前置条件) */
|
/** 是否已加载点云(3D 视图,仅用于提示,不再作为执行前置条件) */
|
||||||
pointCloudLoaded: boolean;
|
pointCloudLoaded: boolean;
|
||||||
/** 当前任务下的点云文件总数(>0 即可执行算法,无需先在 3D 视图加载) */
|
/** 当前任务下的点云文件总数(>0 即可执行算法,无需先在 3D 视图加载) */
|
||||||
taskPcFileCount?: number;
|
taskPcFileCount?: number;
|
||||||
|
/** 当前任务下的点云文件元数据(用于配准输入校验与重复配准确认) */
|
||||||
|
taskPointcloudFiles?: PointcloudFileSummary[];
|
||||||
/** 当前任务中用户选中的点云文件 ID(空/undefined = 全部) */
|
/** 当前任务中用户选中的点云文件 ID(空/undefined = 全部) */
|
||||||
selectedPcFileIds?: number[];
|
selectedPcFileIds?: number[];
|
||||||
/** 当前任务中选中的点云中是否包含配准后的点云 */
|
/** 当前任务中选中的点云中是否包含配准后的点云 */
|
||||||
@@ -60,6 +71,13 @@ interface Props {
|
|||||||
activeRoiPickTarget?: { taskIdx: number; roiKey: string; corner: string } | null;
|
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> {
|
function defaultParamsFor(plugin: PluginSummary): Record<string, unknown> {
|
||||||
const out: Record<string, unknown> = {};
|
const out: Record<string, unknown> = {};
|
||||||
for (const field of plugin.inputs) {
|
for (const field of plugin.inputs) {
|
||||||
@@ -68,7 +86,7 @@ function defaultParamsFor(plugin: PluginSummary): Record<string, unknown> {
|
|||||||
return out;
|
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 utils = trpc.useUtils();
|
||||||
const { data, isLoading } = trpc.algorithm.list.useQuery(undefined, {
|
const { data, isLoading } = trpc.algorithm.list.useQuery(undefined, {
|
||||||
staleTime: 60_000,
|
staleTime: 60_000,
|
||||||
@@ -78,6 +96,7 @@ export default function AlgorithmRunPanel({ taskId, pointCloudLoaded: _pointClou
|
|||||||
toast.success(`已提交 ${jobs.length} 个算法到并发池,进度请查看下方「分析结果」`);
|
toast.success(`已提交 ${jobs.length} 个算法到并发池,进度请查看下方「分析结果」`);
|
||||||
if (taskId != null) {
|
if (taskId != null) {
|
||||||
void utils.analysisResult.list.invalidate({ taskId });
|
void utils.analysisResult.list.invalidate({ taskId });
|
||||||
|
void utils.pointcloudFile.list.invalidate({ taskId });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onError: (e) => toast.error(e.message || '提交失败'),
|
onError: (e) => toast.error(e.message || '提交失败'),
|
||||||
@@ -125,14 +144,81 @@ export default function AlgorithmRunPanel({ taskId, pointCloudLoaded: _pointClou
|
|||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
const canRun = useMemo(
|
// 区分配准插件 vs 测量插件
|
||||||
// 任务下有点云文件(taskPcFileCount > 0)即可执行,无需先在 3D 视图加载
|
const regPlugins = plugins.filter((p) => p.category === '配准');
|
||||||
() => taskId != null && (taskPcFileCount ?? 0) > 0 && selected.size > 0 && !runBatch.isPending,
|
const measPlugins = plugins.filter((p) => p.category !== '配准');
|
||||||
[taskId, taskPcFileCount, selected, runBatch.isPending],
|
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[]) => {
|
const selectedPcCount = selectedPcFileIds?.length ?? 0;
|
||||||
if (!canRun || taskId == null) return;
|
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) => ({
|
const jobs = ids.map((pluginId) => ({
|
||||||
pluginId,
|
pluginId,
|
||||||
params: paramsById[pluginId] ?? {},
|
params: paramsById[pluginId] ?? {},
|
||||||
@@ -140,21 +226,12 @@ export default function AlgorithmRunPanel({ taskId, pointCloudLoaded: _pointClou
|
|||||||
runBatch.mutate({
|
runBatch.mutate({
|
||||||
taskId,
|
taskId,
|
||||||
jobs,
|
jobs,
|
||||||
...(selectedPcFileIds && selectedPcFileIds.length > 0
|
...(pointCloudFileIds ? { pointCloudFileIds } : {}),
|
||||||
? { pointCloudFileIds: selectedPcFileIds }
|
...(cadFileIds ? { cadFileIds } : {}),
|
||||||
: {}),
|
...(allowDuplicateRegistration ? { allowDuplicateRegistration } : {}),
|
||||||
...(selectedCadFileIds && selectedCadFileIds.length > 0
|
|
||||||
? { cadFileIds: selectedCadFileIds }
|
|
||||||
: {}),
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// 区分配准插件 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 里
|
// 不再需要 showRegWarning 变量,已直接内联到 JSX 里
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -169,11 +246,11 @@ export default function AlgorithmRunPanel({ taskId, pointCloudLoaded: _pointClou
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`industrial-btn industrial-btn-primary py-0.5 text-[10px] flex items-center gap-0.5 ${
|
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}
|
disabled={!canRunRegistration}
|
||||||
onClick={() => handleRun(selectedRegIds)}
|
onClick={() => handleRun(selectedRegIds, 'registration')}
|
||||||
title={selectedRegIds.length === 0 ? '请勾选配准算法' : '执行配准'}
|
title={registrationBlockReason ?? '执行配准'}
|
||||||
>
|
>
|
||||||
{runBatch.isPending ? <RefreshCw className="w-3 h-3 animate-spin" /> : <Play className="w-3 h-3" />}
|
{runBatch.isPending ? <RefreshCw className="w-3 h-3 animate-spin" /> : <Play className="w-3 h-3" />}
|
||||||
执行({selectedRegIds.length})
|
执行({selectedRegIds.length})
|
||||||
@@ -182,6 +259,18 @@ export default function AlgorithmRunPanel({ taskId, pointCloudLoaded: _pointClou
|
|||||||
{regPlugins.length === 0 && !isLoading && (
|
{regPlugins.length === 0 && !isLoading && (
|
||||||
<div className="text-[10px] text-muted-foreground italic px-1">暂无配准插件</div>
|
<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) => (
|
{regPlugins.map((p) => (
|
||||||
<PluginCard
|
<PluginCard
|
||||||
key={p.id}
|
key={p.id}
|
||||||
@@ -219,16 +308,11 @@ export default function AlgorithmRunPanel({ taskId, pointCloudLoaded: _pointClou
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`industrial-btn industrial-btn-primary py-0.5 text-[10px] flex items-center gap-0.5 ${
|
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}
|
disabled={!canRunMeasurement}
|
||||||
onClick={() => handleRun(selectedMeasIds)}
|
onClick={() => handleRun(selectedMeasIds, 'measurement')}
|
||||||
title={
|
title={measurementBlockReason ?? '并发执行已勾选算法'}
|
||||||
taskId == null ? '请先选择检测任务'
|
|
||||||
: (taskPcFileCount ?? 0) === 0 ? '请先上传点云文件'
|
|
||||||
: selectedMeasIds.length === 0 ? '请至少勾选一个测量算法'
|
|
||||||
: '并发执行已勾选算法'
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{runBatch.isPending ? <RefreshCw className="w-3 h-3 animate-spin" /> : <Play className="w-3 h-3" />}
|
{runBatch.isPending ? <RefreshCw className="w-3 h-3 animate-spin" /> : <Play className="w-3 h-3" />}
|
||||||
执行({selectedMeasIds.length})
|
执行({selectedMeasIds.length})
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useState, useEffect } from 'react';
|
|||||||
import { trpc } from '@/lib/trpc';
|
import { trpc } from '@/lib/trpc';
|
||||||
import { BarChart2, Ruler, Zap, Play, Trash2, ChevronDown, ChevronRight, RefreshCw, Flame, Box, Layers, Crosshair, Cpu, Timer } from 'lucide-react';
|
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 GenericResultView, { type Overlay3D } from './GenericResultView';
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
|
||||||
|
|
||||||
/** 将毫秒数格式化为可读时长,如 2m 34s / 47s / 1h 5m */
|
/** 将毫秒数格式化为可读时长,如 2m 34s / 47s / 1h 5m */
|
||||||
function fmtDuration(ms: number): string {
|
function fmtDuration(ms: number): string {
|
||||||
@@ -162,6 +163,16 @@ type AssemblyMeta = SmoothHeatmapMeta & {
|
|||||||
parts?: AssemblyPartRow[];
|
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) {
|
export default function AnalysisPanel({ taskId, onReplay, onViewSmoothHeatmap, onRunAssembly, onRunDeviation, externalPending, onViewPluginOverlay }: Props) {
|
||||||
const utils = trpc.useUtils();
|
const utils = trpc.useUtils();
|
||||||
const [expandedIds, setExpandedIds] = useState<Set<number>>(new Set());
|
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 expanded = expandedIds.has(r.id);
|
||||||
const errorStats = r.errorStats as ErrorStats | null;
|
const errorStats = r.errorStats as ErrorStats | null;
|
||||||
const measurements = r.measurements as Measurement[] | null;
|
const measurements = r.measurements as Measurement[] | null;
|
||||||
|
const resultTitle = getResultTitle(r as { type: string; pluginId?: string });
|
||||||
|
|
||||||
return (
|
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
|
<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)}
|
onClick={() => toggleExpand(r.id)}
|
||||||
>
|
>
|
||||||
{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" />}
|
||||||
@@ -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 === '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 === '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" />}
|
{r.type === 'plugin' && <Cpu className="w-3.5 h-3.5 text-violet-400 flex-shrink-0" />}
|
||||||
<span className="text-xs flex-1 truncate">
|
<Tooltip>
|
||||||
{r.type === 'error_analysis'
|
<TooltipTrigger asChild>
|
||||||
? '误差分析'
|
<span className="text-xs flex-1 min-w-0 truncate">{resultTitle}</span>
|
||||||
: r.type === 'measurement'
|
</TooltipTrigger>
|
||||||
? '尺寸测量'
|
<TooltipContent
|
||||||
: r.type === 'smooth_heatmap'
|
side="left"
|
||||||
? '平滑热力'
|
align="start"
|
||||||
: r.type === 'assembly_inspection'
|
className="max-w-[min(28rem,calc(100vw-2rem))] break-all"
|
||||||
? '装配检测'
|
>
|
||||||
: r.type === 'obj_deviation'
|
{resultTitle}
|
||||||
? '偏差热力'
|
</TooltipContent>
|
||||||
: r.type === 'plugin'
|
</Tooltip>
|
||||||
? `算法 · ${(r as { pluginId?: string }).pluginId ?? '插件'}`
|
|
||||||
: '压缩'}
|
|
||||||
</span>
|
|
||||||
<span className="text-[10px] text-muted-foreground">
|
<span className="text-[10px] text-muted-foreground">
|
||||||
{new Date(r.createdAt).toLocaleDateString('zh-CN')}
|
{new Date(r.createdAt).toLocaleDateString('zh-CN')}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -13,7 +13,9 @@ import {
|
|||||||
Square,
|
Square,
|
||||||
FileBox,
|
FileBox,
|
||||||
Eye,
|
Eye,
|
||||||
|
Library,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
modelId: number;
|
modelId: number;
|
||||||
@@ -37,6 +39,13 @@ function formatSize(bytes: number) {
|
|||||||
return (bytes / 1e9).toFixed(2) + ' GB';
|
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({
|
export default function TaskManager({
|
||||||
modelId,
|
modelId,
|
||||||
activeTaskId,
|
activeTaskId,
|
||||||
@@ -54,7 +63,11 @@ export default function TaskManager({
|
|||||||
const [uploadingFor, setUploadingFor] = useState<number | null>(null);
|
const [uploadingFor, setUploadingFor] = useState<number | null>(null);
|
||||||
const [uploadProgress, setUploadProgress] = useState(0);
|
const [uploadProgress, setUploadProgress] = useState(0);
|
||||||
const fileRef = useRef<HTMLInputElement>(null);
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
|
const modelPointcloudFileRef = useRef<HTMLInputElement>(null);
|
||||||
const pendingTaskId = useRef<number | null>(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());
|
const [selectedFileIds, setSelectedFileIds] = useState<Set<number>>(new Set());
|
||||||
@@ -62,8 +75,12 @@ export default function TaskManager({
|
|||||||
// ── 任务级 CAD 文件多选状态 ───────────────────────────────────────────────
|
// ── 任务级 CAD 文件多选状态 ───────────────────────────────────────────────
|
||||||
const [selectedCadFileIds, setSelectedCadFileIds] = useState<Set<number>>(new Set());
|
const [selectedCadFileIds, setSelectedCadFileIds] = useState<Set<number>>(new Set());
|
||||||
const cadFileRef = useRef<HTMLInputElement>(null);
|
const cadFileRef = useRef<HTMLInputElement>(null);
|
||||||
|
const modelCadFileRef = useRef<HTMLInputElement>(null);
|
||||||
const [uploadingCadFor, setUploadingCadFor] = useState<number | null>(null);
|
const [uploadingCadFor, setUploadingCadFor] = useState<number | null>(null);
|
||||||
const [cadUploadProgress, setCadUploadProgress] = useState(0);
|
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 { data: tasks = [] } = trpc.inspectionTask.listByModel.useQuery({ modelId });
|
||||||
const createTask = trpc.inspectionTask.create.useMutation({
|
const createTask = trpc.inspectionTask.create.useMutation({
|
||||||
@@ -81,6 +98,16 @@ export default function TaskManager({
|
|||||||
const deletePcFile = trpc.pointcloudFile.delete.useMutation({
|
const deletePcFile = trpc.pointcloudFile.delete.useMutation({
|
||||||
onSuccess: () => utils.pointcloudFile.list.invalidate({ taskId: activeTaskId! }),
|
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 文件
|
// 当前选中任务下的 CAD 文件
|
||||||
const { data: taskCadFiles = [] } = trpc.taskCadFile.list.useQuery(
|
const { data: taskCadFiles = [] } = trpc.taskCadFile.list.useQuery(
|
||||||
@@ -90,6 +117,16 @@ export default function TaskManager({
|
|||||||
const deleteTaskCadFile = trpc.taskCadFile.delete.useMutation({
|
const deleteTaskCadFile = trpc.taskCadFile.delete.useMutation({
|
||||||
onSuccess: () => utils.taskCadFile.list.invalidate({ taskId: activeTaskId! }),
|
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: 是否已缓存)
|
// CAD 三角化采样缓存状态(key: cadFileId, value: 是否已缓存)
|
||||||
const cadFileIds = taskCadFiles.map((f) => f.id);
|
const cadFileIds = taskCadFiles.map((f) => f.id);
|
||||||
@@ -102,6 +139,10 @@ export default function TaskManager({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSelectedFileIds(new Set());
|
setSelectedFileIds(new Set());
|
||||||
setSelectedCadFileIds(new Set());
|
setSelectedCadFileIds(new Set());
|
||||||
|
setSelectedModelPcFileIds(new Set());
|
||||||
|
setSelectedModelCadFileIds(new Set());
|
||||||
|
setPcLibraryOpen(false);
|
||||||
|
setCadLibraryOpen(false);
|
||||||
}, [activeTaskId]);
|
}, [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) {
|
function openUpload(taskId: number) {
|
||||||
pendingTaskId.current = taskId;
|
pendingTaskId.current = taskId;
|
||||||
fileRef.current?.click();
|
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() {
|
function handleLoad() {
|
||||||
if (activeTaskId == null || pcFiles.length === 0) return;
|
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) {
|
async function handleCadUpload(files: FileList) {
|
||||||
if (activeTaskId == null) return;
|
if (activeTaskId == null) return;
|
||||||
setUploadingCadFor(activeTaskId);
|
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 (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{/* 任务列表 */}
|
{/* 任务列表 */}
|
||||||
@@ -353,13 +466,14 @@ export default function TaskManager({
|
|||||||
{activeTaskId != null && (
|
{activeTaskId != null && (
|
||||||
<div>
|
<div>
|
||||||
{/* 标题栏 */}
|
{/* 标题栏 */}
|
||||||
<div className="flex items-center justify-between mb-1.5">
|
<div className="mb-1.5 space-y-1.5">
|
||||||
<div className="flex items-center gap-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 && (
|
{pcFiles.length > 0 && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="text-muted-foreground hover:text-foreground"
|
className="text-muted-foreground hover:text-foreground flex-shrink-0"
|
||||||
title={allSelected ? '取消全选' : '全选'}
|
title={allSelected ? '取消全选' : '全选'}
|
||||||
onClick={toggleAllFiles}
|
onClick={toggleAllFiles}
|
||||||
>
|
>
|
||||||
@@ -370,23 +484,32 @@ export default function TaskManager({
|
|||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<span className="industrial-label flex items-center gap-1">
|
<span className="industrial-label flex items-center gap-1 whitespace-nowrap">
|
||||||
<Cloud className="w-3.5 h-3.5" /> 点云文件
|
<Cloud className="w-3.5 h-3.5" /> 点云文件
|
||||||
<span className="text-[10px] text-muted-foreground font-normal">
|
<span className="text-[10px] text-muted-foreground font-normal">
|
||||||
({someSelected ? `${selectedFileIds.size}/` : ''}{pcFiles.length})
|
({someSelected ? `${selectedFileIds.size}/` : ''}{pcFiles.length})
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<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={() => openUpload(activeTaskId)}
|
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>
|
</button>
|
||||||
{pcFiles.length > 0 && (
|
{pcFiles.length > 0 && (
|
||||||
<button
|
<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={
|
title={
|
||||||
someSelected
|
someSelected
|
||||||
? `加载选中的 ${selectedFileIds.size} 个文件`
|
? `加载选中的 ${selectedFileIds.size} 个文件`
|
||||||
@@ -412,9 +535,84 @@ export default function TaskManager({
|
|||||||
</div>
|
</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">
|
<div className="space-y-1">
|
||||||
{pcFiles.map((f) => {
|
{pcFiles.map((f) => {
|
||||||
const isChecked = selectedFileIds.has(f.id);
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
key={f.id}
|
key={f.id}
|
||||||
@@ -440,14 +638,50 @@ export default function TaskManager({
|
|||||||
<Cloud
|
<Cloud
|
||||||
className={`w-3.5 h-3.5 flex-shrink-0 ${isChecked ? 'text-primary' : 'text-cyan-400'}`}
|
className={`w-3.5 h-3.5 flex-shrink-0 ${isChecked ? 'text-primary' : 'text-cyan-400'}`}
|
||||||
/>
|
/>
|
||||||
<span className="flex-1 truncate" title={f.name}>
|
<Tooltip>
|
||||||
{f.name}
|
<TooltipTrigger asChild>
|
||||||
</span>
|
<span className="flex-1 min-w-0 truncate">{f.name}</span>
|
||||||
{(f as { source?: string }).source === 'registered' && (
|
</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 className="text-[9px] px-1 py-0.5 rounded bg-blue-900/40 text-blue-300 flex-shrink-0 whitespace-nowrap">
|
||||||
已配准
|
已配准
|
||||||
</span>
|
</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">
|
<span className="text-muted-foreground text-[10px] flex-shrink-0">
|
||||||
{formatSize(f.fileSize)}
|
{formatSize(f.fileSize)}
|
||||||
</span>
|
</span>
|
||||||
@@ -483,17 +717,19 @@ export default function TaskManager({
|
|||||||
{/* 任务级 CAD 文件 */}
|
{/* 任务级 CAD 文件 */}
|
||||||
{activeTaskId != null && (
|
{activeTaskId != null && (
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-1.5">
|
<div className="mb-1.5 space-y-1.5">
|
||||||
<span className="industrial-label flex items-center gap-1">
|
<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
|
<FileBox className="w-3.5 h-3.5" /> 任务 CAD
|
||||||
<span className="text-[10px] text-muted-foreground font-normal">
|
<span className="text-[10px] text-muted-foreground font-normal">
|
||||||
({taskCadFiles.length})
|
({taskCadFiles.length})
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
{taskCadFiles.length > 0 && (
|
{taskCadFiles.length > 0 && (
|
||||||
<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={toggleAllCadFiles}
|
onClick={toggleAllCadFiles}
|
||||||
title={allCadSelected ? '取消全选' : '全选'}
|
title={allCadSelected ? '取消全选' : '全选'}
|
||||||
>
|
>
|
||||||
@@ -504,10 +740,18 @@ export default function TaskManager({
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<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()}
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -523,13 +767,80 @@ export default function TaskManager({
|
|||||||
</div>
|
</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">
|
<div className="space-y-1">
|
||||||
{taskCadFiles.map((f) => {
|
{taskCadFiles.map((f) => {
|
||||||
const isChecked = selectedCadFileIds.has(f.id);
|
const isChecked = selectedCadFileIds.has(f.id);
|
||||||
|
const source = (f as { source?: string }).source;
|
||||||
|
const canPreview = canPreviewCadIn3D(f);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={f.id}
|
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'}`}
|
${isChecked ? 'bg-primary/10 border border-primary/30' : 'industrial-card'}`}
|
||||||
onClick={() => toggleCadFile(f.id)}
|
onClick={() => toggleCadFile(f.id)}
|
||||||
>
|
>
|
||||||
@@ -545,7 +856,23 @@ export default function TaskManager({
|
|||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
<FileBox className={`w-3.5 h-3.5 flex-shrink-0 ${isChecked ? 'text-primary' : 'text-blue-400'}`} />
|
<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] && (
|
{cadSampleCacheMap[f.id] && (
|
||||||
<span
|
<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"
|
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">
|
<span className="text-muted-foreground text-[10px] flex-shrink-0">
|
||||||
{formatSize(f.fileSize)}
|
{formatSize(f.fileSize)}
|
||||||
</span>
|
</span>
|
||||||
{onSelectTaskCad && (
|
{onSelectTaskCad && canPreview && (
|
||||||
<button
|
<button
|
||||||
className="hidden group-hover:block p-0.5 rounded hover:bg-secondary flex-shrink-0"
|
className="hidden group-hover:block p-0.5 rounded hover:bg-secondary flex-shrink-0"
|
||||||
title="在 3D 视图中显示"
|
title="在 3D 视图中显示"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onSelectTaskCad(
|
onSelectTaskCad(
|
||||||
`/api/files/read/task-cad/${activeTaskId}/${encodeURIComponent(f.name)}`,
|
taskCadReadUrl({ name: f.name, source }),
|
||||||
f.format,
|
f.format,
|
||||||
f.name,
|
f.name,
|
||||||
);
|
);
|
||||||
@@ -600,11 +927,14 @@ export default function TaskManager({
|
|||||||
</p>
|
</p>
|
||||||
{selectedCadFileIds.size === 1 && onSelectTaskCad && (() => {
|
{selectedCadFileIds.size === 1 && onSelectTaskCad && (() => {
|
||||||
const f = taskCadFiles.find(f => selectedCadFileIds.has(f.id));
|
const f = taskCadFiles.find(f => selectedCadFileIds.has(f.id));
|
||||||
return f ? (
|
return f && canPreviewCadIn3D(f) ? (
|
||||||
<button
|
<button
|
||||||
className="industrial-btn w-full py-0.5 text-[10px] flex items-center justify-center gap-1"
|
className="industrial-btn w-full py-0.5 text-[10px] flex items-center justify-center gap-1"
|
||||||
onClick={() => onSelectTaskCad(
|
onClick={() => onSelectTaskCad(
|
||||||
`/api/files/read/task-cad/${activeTaskId}/${encodeURIComponent(f.name)}`,
|
taskCadReadUrl({
|
||||||
|
name: f.name,
|
||||||
|
source: (f as { source?: string }).source,
|
||||||
|
}),
|
||||||
f.format,
|
f.format,
|
||||||
f.name,
|
f.name,
|
||||||
)}
|
)}
|
||||||
@@ -629,6 +959,16 @@ export default function TaskManager({
|
|||||||
e.target.files && e.target.files.length > 0 && handleUpload(e.target.files)
|
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
|
<input
|
||||||
ref={cadFileRef}
|
ref={cadFileRef}
|
||||||
type="file"
|
type="file"
|
||||||
@@ -639,6 +979,16 @@ export default function TaskManager({
|
|||||||
e.target.files && e.target.files.length > 0 && handleCadUpload(e.target.files)
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -352,6 +352,7 @@ export default function Home() {
|
|||||||
taskId={selection.taskId}
|
taskId={selection.taskId}
|
||||||
pointCloudLoaded={pointCloudTaskId === selection.taskId && pointCloudUrls.length > 0}
|
pointCloudLoaded={pointCloudTaskId === selection.taskId && pointCloudUrls.length > 0}
|
||||||
taskPcFileCount={taskPcFiles.length}
|
taskPcFileCount={taskPcFiles.length}
|
||||||
|
taskPointcloudFiles={taskPcFiles}
|
||||||
selectedPcFileIds={selectedPcFileIds}
|
selectedPcFileIds={selectedPcFileIds}
|
||||||
hasRegisteredCloud={hasRegisteredCloud}
|
hasRegisteredCloud={hasRegisteredCloud}
|
||||||
selectedCadFileIds={selectedTaskCadFileIds}
|
selectedCadFileIds={selectedTaskCadFileIds}
|
||||||
|
|||||||
Reference in New Issue
Block a user