feat: add inspection object resource workflow
This commit is contained in:
176
client/src/components/InspectionObjectConfig.tsx
Normal file
176
client/src/components/InspectionObjectConfig.tsx
Normal file
@@ -0,0 +1,176 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { CheckCircle2, CircleAlert, Plus, Settings2, Trash2 } from 'lucide-react';
|
||||
import { trpc } from '@/lib/trpc';
|
||||
import { Button } from './ui/button';
|
||||
import {
|
||||
AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
|
||||
AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from './ui/alert-dialog';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from './ui/dialog';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from './ui/tabs';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
type Props = { modelId: number };
|
||||
|
||||
const stateLabels = {
|
||||
missing_pointcloud: '未配置点云',
|
||||
missing_cad: '未配置 CAD',
|
||||
ready_draft: '配置完整,未启用',
|
||||
active: '已启用',
|
||||
} as const;
|
||||
|
||||
type ReferenceCounts = {
|
||||
taskReferenceCount: number;
|
||||
pointcloudReferenceCount: number;
|
||||
cadReferenceCount: number;
|
||||
};
|
||||
|
||||
function referenceReason(object: ReferenceCounts) {
|
||||
return [
|
||||
object.taskReferenceCount > 0 ? `${object.taskReferenceCount} 个任务` : null,
|
||||
object.pointcloudReferenceCount > 0 ? `${object.pointcloudReferenceCount} 个点云` : null,
|
||||
object.cadReferenceCount > 0 ? `${object.cadReferenceCount} 个 CAD 关联` : null,
|
||||
].filter(Boolean).join('、');
|
||||
}
|
||||
|
||||
export default function InspectionObjectConfig({ modelId }: Props) {
|
||||
const utils = trpc.useUtils();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [name, setName] = useState('');
|
||||
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||||
const [pointcloudId, setPointcloudId] = useState<number | null>(null);
|
||||
const [cadId, setCadId] = useState<number | null>(null);
|
||||
const { data: objects = [] } = trpc.inspectionObject.list.useQuery({ modelId }, { enabled: open });
|
||||
const { data: pointclouds = [] } = trpc.modelPointcloudFile.list.useQuery({ modelId }, { enabled: open });
|
||||
const { data: cadFiles = [] } = trpc.cadFile.list.useQuery({ modelId }, { enabled: open });
|
||||
const selected = useMemo(() => objects.find(object => object.id === selectedId), [objects, selectedId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selected && objects.length > 0) setSelectedId(objects[0].id);
|
||||
}, [objects, selected]);
|
||||
useEffect(() => {
|
||||
setPointcloudId(selected?.defaultModelPointcloudId ?? null);
|
||||
setCadId(selected?.defaultModelCadFileId ?? null);
|
||||
}, [selected]);
|
||||
|
||||
const invalidate = async () => {
|
||||
await Promise.all([
|
||||
utils.inspectionObject.list.invalidate({ modelId }),
|
||||
utils.modelPointcloudFile.list.invalidate({ modelId }),
|
||||
utils.cadFile.list.invalidate({ modelId }),
|
||||
]);
|
||||
};
|
||||
const create = trpc.inspectionObject.create.useMutation({
|
||||
onSuccess: async object => { setName(''); setSelectedId(object.id); await invalidate(); toast.success('检测对象已创建'); },
|
||||
onError: error => toast.error(error.message),
|
||||
});
|
||||
const assignPointcloud = trpc.modelPointcloudFile.assignInspectionObject.useMutation({
|
||||
onSuccess: async (_file, variables) => {
|
||||
await invalidate();
|
||||
const objectName = objects.find(object => object.id === variables.inspectionObjectId)?.name;
|
||||
toast.success(objectName ? `点云已归属到“${objectName}”` : '点云归属已更新');
|
||||
},
|
||||
onError: error => toast.error(error.message),
|
||||
});
|
||||
const setCadObjects = trpc.cadFile.setInspectionObjects.useMutation({
|
||||
onSuccess: async () => { await invalidate(); toast.success('CAD 适用对象已更新'); },
|
||||
onError: error => toast.error(error.message),
|
||||
});
|
||||
const configure = trpc.inspectionObject.configureDefaults.useMutation({
|
||||
onSuccess: async () => { await invalidate(); toast.success('默认输入方案已保存并启用'); },
|
||||
onError: error => toast.error(error.message),
|
||||
});
|
||||
const setStatus = trpc.inspectionObject.setStatus.useMutation({
|
||||
onSuccess: async () => { await invalidate(); toast.success('检测对象已停用'); },
|
||||
onError: error => toast.error(error.message),
|
||||
});
|
||||
const deleteObject = trpc.inspectionObject.delete.useMutation({
|
||||
onSuccess: async () => {
|
||||
setSelectedId(null);
|
||||
await invalidate();
|
||||
toast.success('检测对象已删除');
|
||||
},
|
||||
onError: error => toast.error(error.message),
|
||||
});
|
||||
|
||||
const compatiblePointclouds = pointclouds.filter(file => file.inspectionObjectId === selectedId && file.status === 'available');
|
||||
const compatibleCadFiles = cadFiles.filter(file => file.inspectionObjectIds.includes(selectedId ?? -1) && file.status === 'available');
|
||||
|
||||
return <Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<button className="industrial-btn flex shrink-0 items-center gap-1 whitespace-nowrap px-2 py-0.5 text-[10px]"><Settings2 className="h-3 w-3" /> 检测对象配置</button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="flex max-h-[92vh] max-w-[calc(100vw-1rem)] flex-col overflow-hidden p-4 sm:max-w-4xl sm:p-6">
|
||||
<DialogHeader className="shrink-0">
|
||||
<DialogTitle>检测对象配置</DialogTitle>
|
||||
<DialogDescription>资源归属是当前车型的统一设置;只有默认输入方案会随所选检测对象变化,并且仅影响以后创建的任务。</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Tabs defaultValue="objects" className="min-h-0 flex-1 overflow-hidden">
|
||||
<TabsList className="grid w-full shrink-0 grid-cols-2 sm:w-80">
|
||||
<TabsTrigger value="objects">对象配置</TabsTrigger>
|
||||
<TabsTrigger value="resources">资源归属</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="objects" className="min-h-0 overflow-y-auto pr-1">
|
||||
<div className="grid min-w-0 gap-4 md:grid-cols-[15rem_minmax(0,1fr)]">
|
||||
<aside className="min-w-0 space-y-3 rounded-lg border bg-secondary/20 p-3">
|
||||
<div className="flex min-w-0 gap-1">
|
||||
<input aria-label="新检测对象名称" className="min-w-0 flex-1 rounded border bg-background px-2 py-1.5 text-xs" placeholder="如:头车" value={name} onChange={event => setName(event.target.value)} />
|
||||
<Button aria-label="创建检测对象" size="icon" className="h-7 w-7 shrink-0" disabled={!name.trim() || create.isPending} onClick={() => create.mutate({ modelId, name: name.trim() })}><Plus className="h-3.5 w-3.5" /></Button>
|
||||
</div>
|
||||
<div className="max-h-56 space-y-1 overflow-y-auto md:max-h-[25rem]">
|
||||
{objects.map(object => <button key={object.id} onClick={() => setSelectedId(object.id)} className={`w-full rounded border px-2 py-2 text-left text-xs ${selectedId === object.id ? 'border-primary bg-primary/10' : 'border-transparent hover:bg-secondary'}`}>
|
||||
<span className="flex items-center justify-between gap-2"><strong className="min-w-0 truncate" title={object.name}>{object.name}</strong>{object.configurationState === 'active' ? <CheckCircle2 className="h-3.5 w-3.5 shrink-0 text-green-400" /> : <CircleAlert className="h-3.5 w-3.5 shrink-0 text-amber-400" />}</span>
|
||||
<span className="mt-1 block text-[10px] text-muted-foreground">{stateLabels[object.configurationState]}</span>
|
||||
</button>)}
|
||||
{objects.length === 0 && <p className="py-6 text-center text-xs text-muted-foreground">尚未创建检测对象</p>}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{selected ? <main className="min-w-0 space-y-4">
|
||||
<section className="space-y-3 rounded-lg border border-primary/20 bg-primary/5 p-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="min-w-0"><h3 className="truncate text-sm font-medium" title={selected.name}>{selected.name}</h3><p className="text-xs text-muted-foreground">{stateLabels[selected.configurationState]}</p></div>
|
||||
<span className={`rounded-full px-2 py-1 text-[10px] ${selected.status === 'active' ? 'bg-green-500/15 text-green-400' : 'bg-amber-500/15 text-amber-400'}`}>{selected.status === 'active' ? '已启用' : '草稿'}</span>
|
||||
</div>
|
||||
<div><h3 className="text-sm font-medium">默认输入方案</h3><p className="text-xs text-muted-foreground">新建“{selected.name}”任务时自动关联并勾选以下资源。</p></div>
|
||||
<label className="grid min-w-0 gap-1 text-xs"><span>预置点云</span><select className="min-w-0 rounded border bg-background px-2 py-1.5" value={pointcloudId ?? ''} onChange={event => setPointcloudId(Number(event.target.value) || null)}><option value="">请选择</option>{compatiblePointclouds.map(file => <option key={file.id} value={file.id}>{file.name}</option>)}</select></label>
|
||||
<label className="grid min-w-0 gap-1 text-xs"><span>默认 CAD</span><select className="min-w-0 rounded border bg-background px-2 py-1.5" value={cadId ?? ''} onChange={event => setCadId(Number(event.target.value) || null)}><option value="">请选择</option>{compatibleCadFiles.map(file => <option key={file.id} value={file.id}>{file.name}</option>)}</select></label>
|
||||
<div className="flex flex-wrap gap-2"><Button size="sm" disabled={!pointcloudId || !cadId || configure.isPending} onClick={() => configure.mutate({ id: selected.id, defaultModelPointcloudId: pointcloudId!, defaultModelCadFileId: cadId!, active: true })}>保存并启用</Button><Button size="sm" variant="outline" disabled={selected.status === 'draft' || setStatus.isPending} onClick={() => setStatus.mutate({ id: selected.id, status: 'draft' })}>停用</Button></div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-2 rounded-lg border border-destructive/30 p-4">
|
||||
<div><h3 className="text-sm font-medium text-destructive">删除检测对象</h3><p className="text-xs text-muted-foreground">仅可删除没有任务、点云和 CAD 关联的空对象,不会自动解绑或迁移历史数据。</p></div>
|
||||
{selected.canDelete ? <AlertDialog>
|
||||
<AlertDialogTrigger asChild><Button size="sm" variant="destructive"><Trash2 className="h-3.5 w-3.5" />删除检测对象</Button></AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader><AlertDialogTitle>确认删除“{selected.name}”?</AlertDialogTitle><AlertDialogDescription>删除后无法恢复。系统会在执行删除时再次检查任务、点云和 CAD 引用。</AlertDialogDescription></AlertDialogHeader>
|
||||
<AlertDialogFooter><AlertDialogCancel>取消</AlertDialogCancel><AlertDialogAction className="bg-destructive text-white hover:bg-destructive/90" disabled={deleteObject.isPending} onClick={() => deleteObject.mutate({ id: selected.id })}>确认删除</AlertDialogAction></AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog> : <div className="space-y-1"><Button size="sm" variant="destructive" disabled><Trash2 className="h-3.5 w-3.5" />删除检测对象</Button><p className="text-xs text-amber-500">需先处理:{referenceReason(selected)}。</p></div>}
|
||||
</section>
|
||||
</main> : <div className="grid min-h-48 place-items-center rounded-lg border border-dashed text-sm text-muted-foreground">请先创建检测对象</div>}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="resources" className="min-h-0 overflow-y-auto pr-1">
|
||||
<section className="min-w-0 space-y-3">
|
||||
<div><h3 className="text-sm font-medium">车型级资源归属</h3><p className="text-xs text-muted-foreground">每个点云必须归属一个检测对象;每个 CAD 可适用于多个检测对象,但至少保留一个。</p></div>
|
||||
{objects.length === 0 && <div className="rounded-lg border border-dashed p-8 text-center text-sm text-muted-foreground">请先在“对象配置”中创建检测对象</div>}
|
||||
{objects.length > 0 && <div className="grid min-w-0 gap-3 lg:grid-cols-2">
|
||||
<div className="min-w-0 rounded-lg border p-3"><h4 className="mb-2 text-xs font-medium">车型库点云归属</h4><div className="max-h-[50vh] space-y-1 overflow-y-auto overflow-x-hidden">
|
||||
{pointclouds.map(file => <div key={file.id} className="flex min-w-0 flex-col gap-1 rounded px-1.5 py-1.5 text-xs hover:bg-secondary sm:flex-row sm:items-center sm:gap-2"><span className="min-w-0 flex-1 truncate" title={file.name}>{file.name}</span><select className={`min-w-0 rounded border bg-background px-1.5 py-1 text-[10px] outline-none focus:border-primary sm:w-32 sm:shrink-0 ${file.inspectionObjectId == null ? 'border-amber-500/50 text-amber-400' : 'border-border'}`} aria-label={`${file.name} 的归属检测对象`} value={file.inspectionObjectId ?? ''} disabled={assignPointcloud.isPending} onChange={event => { const inspectionObjectId = Number(event.target.value); if (inspectionObjectId && inspectionObjectId !== file.inspectionObjectId) assignPointcloud.mutate({ id: file.id, inspectionObjectId }); }}><option value="" disabled>未分类</option>{objects.map(object => <option key={object.id} value={object.id}>{object.name}</option>)}</select></div>)}
|
||||
{pointclouds.length === 0 && <p className="py-4 text-center text-[10px] text-muted-foreground">车型库暂无点云</p>}
|
||||
</div></div>
|
||||
<div className="min-w-0 rounded-lg border p-3"><h4 className="mb-2 text-xs font-medium">车型库 CAD · 适用对象</h4><div className="max-h-[50vh] space-y-2 overflow-y-auto overflow-x-hidden">
|
||||
{cadFiles.map(file => <fieldset key={file.id} className="min-w-0 rounded border p-2"><legend className="max-w-full truncate px-1 text-xs font-medium" title={file.name}>{file.name}</legend><div className="flex min-w-0 flex-wrap gap-x-3 gap-y-1">{objects.map(object => { const checked = file.inspectionObjectIds.includes(object.id); return <label key={object.id} className="flex min-w-0 items-center gap-1.5 text-xs"><input type="checkbox" checked={checked} disabled={setCadObjects.isPending} onChange={() => { const next = checked ? file.inspectionObjectIds.filter(id => id !== object.id) : [...file.inspectionObjectIds, object.id]; if (next.length === 0) return toast.error('CAD 至少需要一个适用检测对象'); setCadObjects.mutate({ id: file.id, inspectionObjectIds: next }); }} /><span className="max-w-32 truncate" title={object.name}>{object.name}</span></label>; })}</div></fieldset>)}
|
||||
{cadFiles.length === 0 && <p className="py-4 text-center text-[10px] text-muted-foreground">车型库暂无 CAD</p>}
|
||||
</div></div>
|
||||
</div>}
|
||||
</section>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</DialogContent>
|
||||
</Dialog>;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -284,7 +284,7 @@ export default function Home() {
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<VehicleTree selection={selection} onSelect={setSelection} />
|
||||
<div className="h-px bg-border my-1" />
|
||||
<TaskManager
|
||||
@@ -296,7 +296,7 @@ export default function Home() {
|
||||
onCadFilesSelectionChange={setSelectedTaskCadFileIds}
|
||||
onSelectTaskCad={handleSelectCad}
|
||||
/>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -475,7 +475,7 @@ export default function Home() {
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
{/* 左侧面板 */}
|
||||
{showLeftPanel && (
|
||||
<aside className="w-72 border-r border-border bg-card/30 flex-shrink-0 overflow-y-auto p-3 space-y-2">
|
||||
<aside className="flex w-72 flex-shrink-0 flex-col overflow-hidden border-r border-border bg-card/30 p-3">
|
||||
{renderLeftPanel()}
|
||||
</aside>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user