feat: initial commit - massive-cloud point cloud processing system
This commit is contained in:
291
client/src/components/VehicleTree.tsx
Normal file
291
client/src/components/VehicleTree.tsx
Normal file
@@ -0,0 +1,291 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { trpc } from '@/lib/trpc';
|
||||
import {
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
Plus,
|
||||
Trash2,
|
||||
Train,
|
||||
Box,
|
||||
Check,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
|
||||
export type TreeSelection = {
|
||||
seriesId: number | null;
|
||||
modelId: number | null;
|
||||
taskId: number | null;
|
||||
};
|
||||
|
||||
interface Props {
|
||||
selection: TreeSelection;
|
||||
onSelect: (s: TreeSelection) => void;
|
||||
}
|
||||
|
||||
function EditableText({
|
||||
value,
|
||||
onSave,
|
||||
}: {
|
||||
value: string;
|
||||
onSave: (v: string) => void;
|
||||
}) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState(value);
|
||||
if (!editing) {
|
||||
return (
|
||||
<span
|
||||
className="flex-1 cursor-pointer hover:text-foreground truncate"
|
||||
onDoubleClick={() => { setDraft(value); setEditing(true); }}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="flex items-center gap-1 flex-1">
|
||||
<input
|
||||
autoFocus
|
||||
className="flex-1 bg-secondary text-xs px-1 py-0.5 rounded outline-none border border-primary"
|
||||
value={draft}
|
||||
onChange={e => setDraft(e.target.value)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter') { onSave(draft); setEditing(false); }
|
||||
if (e.key === 'Escape') setEditing(false);
|
||||
}}
|
||||
/>
|
||||
<button onClick={() => { onSave(draft); setEditing(false); }} className="text-green-400"><Check className="w-3 h-3" /></button>
|
||||
<button onClick={() => setEditing(false)} className="text-muted-foreground"><X className="w-3 h-3" /></button>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default function VehicleTree({ selection, onSelect }: Props) {
|
||||
const utils = trpc.useUtils();
|
||||
const [expandedSeries, setExpandedSeries] = useState<Set<number>>(new Set());
|
||||
const [newSeriesName, setNewSeriesName] = useState('');
|
||||
const [addingSeries, setAddingSeries] = useState(false);
|
||||
const [addingModelFor, setAddingModelFor] = useState<number | null>(null);
|
||||
const [newModelName, setNewModelName] = useState('');
|
||||
|
||||
const { data: seriesList = [] } = trpc.vehicleSeries.list.useQuery();
|
||||
|
||||
// 固定次数的 Hooks:用 trpc.useQueries(内部是单个 useQueries),禁止在 map 里调 useQuery
|
||||
const modelQueryResults = trpc.useQueries((t) =>
|
||||
seriesList.map((s) =>
|
||||
t.vehicleModel.listBySeries(
|
||||
{ seriesId: s.id },
|
||||
{ enabled: expandedSeries.has(s.id) }
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
const createSeries = trpc.vehicleSeries.create.useMutation({
|
||||
onSuccess: () => utils.vehicleSeries.list.invalidate(),
|
||||
});
|
||||
const deleteSeries = trpc.vehicleSeries.delete.useMutation({
|
||||
onSuccess: () => utils.vehicleSeries.list.invalidate(),
|
||||
});
|
||||
const updateSeries = trpc.vehicleSeries.update.useMutation({
|
||||
onSuccess: () => utils.vehicleSeries.list.invalidate(),
|
||||
});
|
||||
const createModel = trpc.vehicleModel.create.useMutation({
|
||||
onSuccess: (_, vars) => utils.vehicleModel.listBySeries.invalidate({ seriesId: vars.seriesId }),
|
||||
});
|
||||
const deleteModel = trpc.vehicleModel.delete.useMutation({
|
||||
onSuccess: () => {
|
||||
seriesList.forEach(s => utils.vehicleModel.listBySeries.invalidate({ seriesId: s.id }));
|
||||
},
|
||||
});
|
||||
const updateModel = trpc.vehicleModel.update.useMutation({
|
||||
onSuccess: () => {
|
||||
seriesList.forEach(s => utils.vehicleModel.listBySeries.invalidate({ seriesId: s.id }));
|
||||
},
|
||||
});
|
||||
|
||||
function toggleSeries(id: number) {
|
||||
setExpandedSeries(prev => {
|
||||
const n = new Set(prev);
|
||||
n.has(id) ? n.delete(id) : n.add(id);
|
||||
return n;
|
||||
});
|
||||
}
|
||||
return (
|
||||
<div className="industrial-panel p-2 space-y-1">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="industrial-label">车系 / 车型 / 任务</span>
|
||||
<button
|
||||
className="industrial-btn py-0.5 text-[10px] flex items-center gap-0.5"
|
||||
onClick={() => setAddingSeries(true)}
|
||||
>
|
||||
<Plus className="w-3 h-3" /> 车系
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{addingSeries && (
|
||||
<div className="flex items-center gap-1 pl-1">
|
||||
<input
|
||||
autoFocus
|
||||
className="flex-1 bg-secondary text-xs px-2 py-1 rounded outline-none border border-primary"
|
||||
placeholder="车系名称"
|
||||
value={newSeriesName}
|
||||
onChange={e => setNewSeriesName(e.target.value)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter' && newSeriesName.trim()) {
|
||||
createSeries.mutate({ name: newSeriesName.trim() });
|
||||
setNewSeriesName('');
|
||||
setAddingSeries(false);
|
||||
}
|
||||
if (e.key === 'Escape') setAddingSeries(false);
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
className="text-green-400"
|
||||
onClick={() => {
|
||||
if (newSeriesName.trim()) {
|
||||
createSeries.mutate({ name: newSeriesName.trim() });
|
||||
setNewSeriesName('');
|
||||
}
|
||||
setAddingSeries(false);
|
||||
}}
|
||||
>
|
||||
<Check className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button className="text-muted-foreground" onClick={() => setAddingSeries(false)}>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{seriesList.map((series, si) => {
|
||||
const expanded = expandedSeries.has(series.id);
|
||||
const models = modelQueryResults[si]?.data ?? [];
|
||||
const isSeriesSelected = selection.seriesId === series.id && !selection.modelId;
|
||||
|
||||
return (
|
||||
<div key={series.id}>
|
||||
{/* 车系行 */}
|
||||
<div
|
||||
className={`flex items-center gap-1 px-1 py-1 rounded cursor-pointer group text-xs
|
||||
${isSeriesSelected ? 'bg-primary/20 text-primary' : 'hover:bg-secondary/60'}`}
|
||||
onClick={() => {
|
||||
toggleSeries(series.id);
|
||||
onSelect({ seriesId: series.id, modelId: null, taskId: null });
|
||||
}}
|
||||
>
|
||||
{expanded ? <ChevronDown className="w-3 h-3 flex-shrink-0 text-muted-foreground" /> : <ChevronRight className="w-3 h-3 flex-shrink-0 text-muted-foreground" />}
|
||||
<Train className="w-3 h-3 flex-shrink-0 text-muted-foreground" />
|
||||
<EditableText
|
||||
value={series.name}
|
||||
onSave={name => updateSeries.mutate({ id: series.id, name })}
|
||||
/>
|
||||
<span className="hidden group-hover:flex items-center gap-0.5 flex-shrink-0">
|
||||
<button
|
||||
className="p-0.5 rounded hover:bg-secondary"
|
||||
title="新增车型"
|
||||
onClick={e => { e.stopPropagation(); setAddingModelFor(series.id); setNewModelName(''); }}
|
||||
>
|
||||
<Plus className="w-3 h-3" />
|
||||
</button>
|
||||
<button
|
||||
className="p-0.5 rounded hover:bg-secondary text-destructive"
|
||||
title="删除车系"
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
if (confirm(`删除车系「${series.name}」?`)) deleteSeries.mutate({ id: series.id });
|
||||
}}
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 新增车型输入 */}
|
||||
{addingModelFor === series.id && (
|
||||
<div className="flex items-center gap-1 pl-7 pr-1 py-0.5">
|
||||
<input
|
||||
autoFocus
|
||||
className="flex-1 bg-secondary text-xs px-2 py-1 rounded outline-none border border-primary"
|
||||
placeholder="车型名称"
|
||||
value={newModelName}
|
||||
onChange={e => setNewModelName(e.target.value)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter' && newModelName.trim()) {
|
||||
createModel.mutate({ seriesId: series.id, name: newModelName.trim() });
|
||||
setNewModelName('');
|
||||
setAddingModelFor(null);
|
||||
setExpandedSeries(prev => {
|
||||
const n = new Set(prev);
|
||||
n.add(series.id);
|
||||
return n;
|
||||
});
|
||||
}
|
||||
if (e.key === 'Escape') setAddingModelFor(null);
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
className="text-green-400"
|
||||
onClick={() => {
|
||||
if (newModelName.trim()) {
|
||||
createModel.mutate({ seriesId: series.id, name: newModelName.trim() });
|
||||
setExpandedSeries(prev => {
|
||||
const n = new Set(prev);
|
||||
n.add(series.id);
|
||||
return n;
|
||||
});
|
||||
}
|
||||
setAddingModelFor(null);
|
||||
}}
|
||||
>
|
||||
<Check className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button className="text-muted-foreground" onClick={() => setAddingModelFor(null)}>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 车型列表 */}
|
||||
{expanded && models.map(model => {
|
||||
const isModelSelected = selection.modelId === model.id;
|
||||
|
||||
return (
|
||||
<div key={model.id}>
|
||||
<div
|
||||
className={`flex items-center gap-1 px-1 py-1 pl-5 rounded cursor-pointer group text-xs
|
||||
${isModelSelected ? 'bg-primary/20 text-primary' : 'hover:bg-secondary/60'}`}
|
||||
onClick={() => onSelect({ seriesId: series.id, modelId: model.id, taskId: null })}
|
||||
>
|
||||
<Box className="w-3 h-3 flex-shrink-0 text-blue-400" />
|
||||
<EditableText
|
||||
value={model.name}
|
||||
onSave={name => updateModel.mutate({ id: model.id, name })}
|
||||
/>
|
||||
<span className="hidden group-hover:flex items-center gap-0.5 flex-shrink-0">
|
||||
<button
|
||||
className="p-0.5 rounded hover:bg-secondary text-destructive"
|
||||
title="删除车型"
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
if (confirm(`删除车型「${model.name}」?`)) deleteModel.mutate({ id: model.id });
|
||||
}}
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{seriesList.length === 0 && (
|
||||
<div className="text-center py-8 text-xs text-muted-foreground">
|
||||
暂无车系,点击右上角新增
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user