292 lines
11 KiB
TypeScript
292 lines
11 KiB
TypeScript
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>
|
||
);
|
||
}
|