Files
massive-cloud/client/src/components/VehicleTree.tsx

292 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}