Files
Genarrative/src/components/ItemCatalogEditor.tsx

899 lines
35 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 { type ReactNode, useDeferredValue, useEffect, useMemo, useState } from 'react';
import { PRESET_CHARACTERS } from '../data/characterPresets';
import { getInventoryItemValue } from '../data/economy';
import { validateItemOverrides } from '../data/editorValidation';
import { getEquipmentSlotFromItem, getEquipmentSlotLabel } from '../data/equipmentEffects';
import { isInventoryItemUsable, resolveInventoryItemUseEffect } from '../data/inventoryEffects';
import {
applyItemCatalogOverride,
buildItemCatalogFromAssetPaths,
createInventoryItemFromCatalogEntry,
ITEM_CATALOG_API_PATH,
ITEM_CATEGORY_OPTIONS,
ITEM_OVERRIDES_API_PATH,
} from '../data/itemCatalog';
import { fetchJson, saveJsonObject } from '../editor/shared/jsonClient';
import { SectionCard as Section } from '../editor/shared/SectionCard';
import { type ItemCatalogOverride, type ItemRarity, type TimedBuildBuff,WorldType } from '../types';
import { PixelIcon } from './PixelIcon';
const ITEM_PREVIEW_CHARACTER = PRESET_CHARACTERS[0] ?? null;
const LIST_PREVIEW_LIMIT = 240;
type ItemCatalogAssetResponse = {
assetPaths: string[];
};
const RARITY_OPTIONS: ItemRarity[] = ['common', 'uncommon', 'rare', 'epic', 'legendary'];
const RARITY_LABELS: Record<ItemRarity, string> = {
common: '普通',
uncommon: '不普通',
rare: '稀有',
epic: '史诗',
legendary: '传奇',
};
function arraysEqual(left: string[], right: string[]) {
if (left.length !== right.length) return false;
return left.every((value, index) => value === right[index]);
}
function parseTagsInput(value: string) {
return [...new Set(
value
.split(/[\n,]/u)
.map(tag => tag.trim())
.filter(Boolean),
)];
}
function tagsInputValue(tags: string[]) {
return tags.join(', ');
}
function parseBuildBuffLines(
value: string,
sourceType: TimedBuildBuff['sourceType'],
sourceId: string,
) {
return value
.split('\n')
.map(line => line.trim())
.filter(Boolean)
.map((line, index) => {
const [namePart, tagsPart, durationPart] = line.split('|').map(part => part.trim());
const tags = parseTagsInput(tagsPart ?? '');
const durationTurns = Math.max(1, Number(durationPart ?? '1') || 1);
return {
id: `${sourceId}-buff-${index + 1}`,
sourceType,
sourceId,
name: namePart || `${sourceId}-buff-${index + 1}`,
tags,
durationTurns,
} satisfies TimedBuildBuff;
})
.filter(buff => buff.tags.length > 0);
}
function buildBuffLinesValue(buffs: TimedBuildBuff[] | null | undefined) {
return (buffs ?? [])
.map(buff => `${buff.name}|${buff.tags.join(',')}|${buff.durationTurns}`)
.join('\n');
}
function Label({ children }: { children: ReactNode }) {
return <div className="mb-1 text-xs font-medium text-zinc-300">{children}</div>;
}
function TextInput({
value,
onChange,
placeholder,
disabled = false,
}: {
value: string;
onChange: (value: string) => void;
placeholder?: string;
disabled?: boolean;
}) {
return (
<input
value={value}
onChange={event => onChange(event.target.value)}
placeholder={placeholder}
disabled={disabled}
className="w-full rounded-lg border border-white/10 bg-black/30 px-3 py-2 text-sm text-white outline-none transition focus:border-emerald-400/40 disabled:cursor-not-allowed disabled:opacity-60"
/>
);
}
function TextArea({
value,
onChange,
rows = 4,
}: {
value: string;
onChange: (value: string) => void;
rows?: number;
}) {
return (
<textarea
rows={rows}
value={value}
onChange={event => onChange(event.target.value)}
className="w-full rounded-lg border border-white/10 bg-black/30 px-3 py-2 text-sm leading-relaxed text-white outline-none transition focus:border-emerald-400/40"
/>
);
}
function Select({
value,
onChange,
options,
}: {
value: string;
onChange: (value: string) => void;
options: Array<{ label: string; value: string }>;
}) {
return (
<select
value={value}
onChange={event => onChange(event.target.value)}
className="w-full rounded-lg border border-white/10 bg-black/30 px-3 py-2 text-sm text-white outline-none transition focus:border-emerald-400/40"
>
{options.map(option => (
<option key={`${option.value}-${option.label}`} value={option.value}>
{option.label}
</option>
))}
</select>
);
}
export function ItemCatalogEditor() {
const [assetPaths, setAssetPaths] = useState<string[]>([]);
const [overrideMap, setOverrideMap] = useState<Record<string, ItemCatalogOverride>>({});
const [selectedItemId, setSelectedItemId] = useState('');
const [searchText, setSearchText] = useState('');
const [categoryFilter, setCategoryFilter] = useState('ALL');
const [rarityFilter, setRarityFilter] = useState<'ALL' | ItemRarity>('ALL');
const [previewWorld, setPreviewWorld] = useState<WorldType>(WorldType.WUXIA);
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [loadError, setLoadError] = useState<string | null>(null);
const [saveMessage, setSaveMessage] = useState<string | null>(null);
const deferredSearchText = useDeferredValue(searchText);
useEffect(() => {
let disposed = false;
const load = async () => {
setIsLoading(true);
setLoadError(null);
try {
const [catalogResponse, overridesResponse] = await Promise.all([
fetchJson<ItemCatalogAssetResponse>(ITEM_CATALOG_API_PATH),
fetchJson<Record<string, ItemCatalogOverride>>(ITEM_OVERRIDES_API_PATH),
]);
if (disposed) return;
const nextAssetPaths = catalogResponse.assetPaths ?? [];
setAssetPaths(nextAssetPaths);
setOverrideMap(overridesResponse ?? {});
setSelectedItemId(current => current || (buildItemCatalogFromAssetPaths(nextAssetPaths)[0]?.id ?? ''));
} catch (error) {
if (disposed) return;
setLoadError(error instanceof Error ? error.message : '物品目录加载失败');
} finally {
if (!disposed) {
setIsLoading(false);
}
}
};
void load();
return () => {
disposed = true;
};
}, []);
const baseItems = useMemo(
() => buildItemCatalogFromAssetPaths(assetPaths),
[assetPaths],
);
const baseItemMap = useMemo(
() => new Map(baseItems.map(item => [item.id, item])),
[baseItems],
);
const effectiveItems = useMemo(
() => baseItems.map(item => applyItemCatalogOverride(item, overrideMap[item.id])),
[baseItems, overrideMap],
);
const filteredItems = useMemo(() => {
const query = deferredSearchText.trim().toLowerCase();
return effectiveItems.filter(item => {
if (categoryFilter !== 'ALL' && item.category !== categoryFilter) return false;
if (rarityFilter !== 'ALL' && item.rarity !== rarityFilter) return false;
if (!query) return true;
const haystack = [
item.name,
item.category,
item.rarity,
item.description,
item.sourcePath,
...item.tags,
].join(' ').toLowerCase();
return haystack.includes(query);
});
}, [categoryFilter, deferredSearchText, effectiveItems, rarityFilter]);
const visibleItems = useMemo(
() => filteredItems.slice(0, LIST_PREVIEW_LIMIT),
[filteredItems],
);
useEffect(() => {
if (!effectiveItems.length) {
setSelectedItemId('');
return;
}
if (!selectedItemId || !baseItemMap.has(selectedItemId)) {
setSelectedItemId(effectiveItems[0]?.id ?? '');
}
}, [baseItemMap, effectiveItems, selectedItemId]);
const selectedBaseItem = selectedItemId ? baseItemMap.get(selectedItemId) ?? null : null;
const selectedItem = selectedBaseItem
? applyItemCatalogOverride(selectedBaseItem, overrideMap[selectedBaseItem.id])
: null;
const selectedOverride = selectedItemId ? overrideMap[selectedItemId] ?? null : null;
const previewInventoryItem = useMemo(
() => selectedItem ? createInventoryItemFromCatalogEntry(selectedItem, 1, previewWorld) : null,
[previewWorld, selectedItem],
);
const worldProfile = selectedItem?.worldProfiles?.[previewWorld] ?? null;
const previewUseEffect = useMemo(
() => (previewInventoryItem && ITEM_PREVIEW_CHARACTER)
? resolveInventoryItemUseEffect(previewInventoryItem, ITEM_PREVIEW_CHARACTER)
: null,
[previewInventoryItem],
);
const previewEquipmentSlot = useMemo(
() => previewInventoryItem
? getEquipmentSlotFromItem(previewInventoryItem)
: null,
[previewInventoryItem],
);
const updateSelectedOverride = <K extends keyof ItemCatalogOverride>(
key: K,
value: ItemCatalogOverride[K],
) => {
if (!selectedBaseItem) return;
setOverrideMap(current => {
const nextOverride = {
...(current[selectedBaseItem.id] ?? {}),
[key]: value,
};
const normalizedOverride: ItemCatalogOverride = {...nextOverride};
if ((normalizedOverride.name ?? selectedBaseItem.name) === selectedBaseItem.name) {
delete normalizedOverride.name;
}
if ((normalizedOverride.category ?? selectedBaseItem.category) === selectedBaseItem.category) {
delete normalizedOverride.category;
}
if ((normalizedOverride.rarity ?? selectedBaseItem.rarity) === selectedBaseItem.rarity) {
delete normalizedOverride.rarity;
}
if ((normalizedOverride.description ?? selectedBaseItem.description) === selectedBaseItem.description) {
delete normalizedOverride.description;
}
if (
normalizedOverride.tags &&
arraysEqual(normalizedOverride.tags, selectedBaseItem.tags)
) {
delete normalizedOverride.tags;
}
const hasOverride = Object.keys(normalizedOverride).length > 0;
if (!hasOverride) {
const { [selectedBaseItem.id]: _removed, ...rest } = current;
return rest;
}
return {
...current,
[selectedBaseItem.id]: normalizedOverride,
};
});
};
const updateSelectedStatProfileField = (
key: 'maxHpBonus' | 'maxManaBonus' | 'outgoingDamageBonus' | 'incomingDamageMultiplier',
value: number,
) => {
if (!selectedItem) return;
const nextProfile = {
...(selectedItem.statProfile ?? {}),
[key]: value,
};
updateSelectedOverride('statProfile', nextProfile);
};
const updateSelectedUseProfileField = (
key: 'hpRestore' | 'manaRestore' | 'cooldownReduction',
value: number,
) => {
if (!selectedItem) return;
const nextProfile = {
...(selectedItem.useProfile ?? {}),
[key]: value,
buildBuffs: selectedItem.useProfile?.buildBuffs ?? [],
};
updateSelectedOverride('useProfile', nextProfile);
};
const updateSelectedUseProfileBuffs = (value: string) => {
if (!selectedItem) return;
const nextProfile = {
...(selectedItem.useProfile ?? {}),
hpRestore: selectedItem.useProfile?.hpRestore ?? 0,
manaRestore: selectedItem.useProfile?.manaRestore ?? 0,
cooldownReduction: selectedItem.useProfile?.cooldownReduction ?? 0,
buildBuffs: parseBuildBuffLines(value, 'item', selectedItem.id),
};
updateSelectedOverride('useProfile', nextProfile);
};
const updateSelectedBuildProfileField = (
key: 'role' | 'setId' | 'setName' | 'pieceName' | 'forgeRank',
value: string | number,
) => {
if (!selectedItem) return;
const nextProfile = {
...(selectedItem.buildProfile ?? { role: '', tags: [], synergy: [], craftTags: [], forgeRank: 0 }),
[key]: value,
tags: selectedItem.buildProfile?.tags ?? [],
synergy: selectedItem.buildProfile?.synergy ?? [],
craftTags: selectedItem.buildProfile?.craftTags ?? [],
};
updateSelectedOverride('buildProfile', nextProfile);
};
const updateSelectedBuildProfileTags = (key: 'tags' | 'synergy' | 'craftTags', value: string) => {
if (!selectedItem) return;
const nextProfile = {
...(selectedItem.buildProfile ?? { role: '', tags: [], synergy: [], craftTags: [], forgeRank: 0 }),
role: selectedItem.buildProfile?.role ?? '',
tags: key === 'tags' ? parseTagsInput(value) : (selectedItem.buildProfile?.tags ?? []),
synergy: key === 'synergy' ? parseTagsInput(value) : (selectedItem.buildProfile?.synergy ?? []),
craftTags: key === 'craftTags' ? parseTagsInput(value) : (selectedItem.buildProfile?.craftTags ?? []),
forgeRank: selectedItem.buildProfile?.forgeRank ?? 0,
};
updateSelectedOverride('buildProfile', nextProfile);
};
const resetSelectedOverride = () => {
if (!selectedItemId) return;
setOverrideMap(current => {
const { [selectedItemId]: _removed, ...rest } = current;
return rest;
});
};
const handleSave = async () => {
const validationErrors = validateItemOverrides(
overrideMap,
baseItems.map(item => item.id),
);
if (validationErrors.length > 0) {
setSaveMessage(validationErrors.slice(0, 3).join(' | '));
setTimeout(() => setSaveMessage(null), 5000);
return;
}
setIsSaving(true);
setSaveMessage(null);
try {
await saveJsonObject(ITEM_OVERRIDES_API_PATH, overrideMap as Record<string, unknown>);
setSaveMessage('物品覆盖已保存到 src/data/itemOverrides.json。');
setTimeout(() => setSaveMessage(null), 5000);
} catch (error) {
setSaveMessage(error instanceof Error ? error.message : '保存失败');
setTimeout(() => setSaveMessage(null), 5000);
} finally {
setIsSaving(false);
}
};
const categoryOptions = [
{ label: '全部分类', value: 'ALL' },
...ITEM_CATEGORY_OPTIONS.map(category => ({ label: category, value: category })),
];
const rarityOptions = [
{ label: '全部稀有度', value: 'ALL' },
...RARITY_OPTIONS.map(rarity => ({ label: rarity, value: rarity })),
];
if (isLoading) {
return (
<div className="rounded-2xl border border-white/10 bg-black/20 p-6 text-sm text-zinc-300">
...
</div>
);
}
if (loadError) {
return (
<div className="rounded-2xl border border-rose-400/20 bg-rose-500/10 p-6 text-sm text-rose-100">
{loadError}
</div>
);
}
return (
<div className="grid gap-6 xl:grid-cols-[360px_1fr_420px]">
<Section title="物品列表" description="基于 public/Icons 下的全部 png 素材自动构建物品目录,可按名称、路径、分类和稀有度筛选。">
<div className="grid gap-3">
<div>
<Label></Label>
<TextInput
value={searchText}
onChange={setSearchText}
placeholder="按名称、路径、标签搜索"
/>
</div>
<div className="grid gap-3 md:grid-cols-2">
<div>
<Label></Label>
<Select value={categoryFilter} onChange={setCategoryFilter} options={categoryOptions} />
</div>
<div>
<Label></Label>
<Select value={rarityFilter} onChange={value => setRarityFilter(value as 'ALL' | ItemRarity)} options={rarityOptions} />
</div>
</div>
</div>
<div className="mt-4 rounded-xl border border-white/10 bg-white/[0.03] px-3 py-2 text-xs text-zinc-400">
{effectiveItems.length} {filteredItems.length} {Math.min(visibleItems.length, LIST_PREVIEW_LIMIT)}
</div>
<div className="mt-4 max-h-[70vh] space-y-2 overflow-y-auto pr-1 scrollbar-hide">
{visibleItems.map(item => {
const selected = item.id === selectedItemId;
const overridden = Boolean(overrideMap[item.id]);
return (
<button
key={item.id}
type="button"
onClick={() => setSelectedItemId(item.id)}
className={`flex w-full items-center gap-3 rounded-xl border px-3 py-2 text-left transition ${
selected
? 'border-emerald-400/40 bg-emerald-500/10'
: 'border-white/8 bg-black/20 hover:border-white/15'
}`}
>
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-lg border border-white/10 bg-black/35">
<PixelIcon src={item.iconSrc} className="h-9 w-9" />
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-semibold text-white">{item.name}</div>
<div className="mt-1 truncate text-[10px] text-zinc-500">{item.sourcePath}</div>
<div className="mt-1 flex flex-wrap gap-1.5">
<span className="rounded-full border border-white/10 bg-white/[0.04] px-2 py-0.5 text-[10px] text-zinc-300">
{item.category}
</span>
<span className="rounded-full border border-white/10 bg-white/[0.04] px-2 py-0.5 text-[10px] text-zinc-300">
{item.rarity}
</span>
{overridden && (
<span className="rounded-full border border-amber-400/20 bg-amber-500/10 px-2 py-0.5 text-[10px] text-amber-100">
</span>
)}
</div>
</div>
</button>
);
})}
</div>
</Section>
<Section title="物品预览" description="这里会实时预览当前素材构建出的物品效果,包括图标、系统推断结果以及一张背包卡片。">
{selectedItem ? (
<div className="space-y-5">
<div className="grid gap-4 lg:grid-cols-[220px_1fr]">
<div className="flex min-h-[240px] items-center justify-center rounded-2xl border border-white/10 bg-[radial-gradient(circle_at_top,rgba(255,255,255,0.12),transparent_45%),linear-gradient(180deg,#171a22,#0d1016)] p-6">
<PixelIcon src={selectedItem.iconSrc} className="h-40 w-40" />
</div>
<div className="space-y-3 rounded-2xl border border-white/10 bg-black/20 p-4">
<div>
<div className="text-xs uppercase tracking-[0.2em] text-zinc-500">{selectedItem.category}</div>
<div className="mt-1 text-xl font-semibold text-white">{selectedItem.name}</div>
<div className="mt-1 text-xs text-zinc-500">{selectedItem.sourcePath}</div>
</div>
<div className="max-w-[12rem]">
<Label></Label>
<Select
value={previewWorld}
onChange={value => setPreviewWorld(value as WorldType)}
options={[
{ label: '武侠', value: WorldType.WUXIA },
{ label: '仙侠', value: WorldType.XIANXIA },
]}
/>
</div>
<div className="flex flex-wrap gap-2">
<span className="rounded-full border border-white/10 bg-white/[0.04] px-2 py-1 text-[11px] text-zinc-200">
: {RARITY_LABELS[selectedItem.rarity]}
</span>
<span className="rounded-full border border-white/10 bg-white/[0.04] px-2 py-1 text-[11px] text-zinc-200">
: {getInventoryItemValue(previewInventoryItem!)}
</span>
<span className="rounded-full border border-white/10 bg-white/[0.04] px-2 py-1 text-[11px] text-zinc-200">
使: {isInventoryItemUsable(previewInventoryItem!) ? '是' : '否'}
</span>
<span className="rounded-full border border-white/10 bg-white/[0.04] px-2 py-1 text-[11px] text-zinc-200">
: {previewEquipmentSlot ? getEquipmentSlotLabel(previewEquipmentSlot) : '否'}
</span>
<span className="rounded-full border border-white/10 bg-white/[0.04] px-2 py-1 text-[11px] text-zinc-200">
: {selectedItem.worldAffinity === 'neutral' ? '中立' : selectedItem.worldAffinity === 'wuxia' ? '武侠' : selectedItem.worldAffinity === 'xianxia' ? '仙侠' : '中立'}
</span>
</div>
<p className="text-sm leading-relaxed text-zinc-300">{selectedItem.description}</p>
{worldProfile && (
<div className="rounded-xl border border-white/10 bg-white/[0.03] px-3 py-3">
<div className="text-[11px] uppercase tracking-[0.18em] text-zinc-500">
{previewWorld === WorldType.WUXIA ? '武侠命名' : '仙侠命名'}
</div>
<div className="mt-1 text-sm font-semibold text-white">{worldProfile.name}</div>
<div className="mt-2 text-sm leading-relaxed text-zinc-300">{worldProfile.description}</div>
</div>
)}
<div className="flex flex-wrap gap-2">
{selectedItem.tags.length > 0 ? selectedItem.tags.map(tag => (
<span
key={`${selectedItem.id}-${tag}`}
className="rounded-full border border-white/10 bg-black/25 px-2 py-1 text-[10px] text-zinc-300"
>
{tag}
</span>
)) : (
<span className="rounded-full border border-white/10 bg-black/25 px-2 py-1 text-[10px] text-zinc-300">
</span>
)}
</div>
</div>
</div>
<div className="grid gap-4 lg:grid-cols-3">
<div className="rounded-2xl border border-white/10 bg-black/20 p-4">
<div className="mb-2 text-xs font-semibold uppercase tracking-[0.2em] text-zinc-500"></div>
<div className="space-y-1 text-sm text-zinc-300">
<div>: {selectedItem.statProfile?.maxHpBonus ?? 0}</div>
<div>: {selectedItem.statProfile?.maxManaBonus ?? 0}</div>
<div>: {selectedItem.statProfile?.outgoingDamageBonus ?? 0}</div>
<div>: {selectedItem.statProfile?.incomingDamageMultiplier ?? 1}</div>
</div>
</div>
<div className="rounded-2xl border border-white/10 bg-black/20 p-4">
<div className="mb-2 text-xs font-semibold uppercase tracking-[0.2em] text-zinc-500">使</div>
{selectedItem.useProfile ? (
<div className="space-y-1 text-sm text-zinc-300">
<div>: {selectedItem.useProfile.hpRestore ?? 0}</div>
<div>: {selectedItem.useProfile.manaRestore ?? 0}</div>
<div>: {selectedItem.useProfile.cooldownReduction ?? 0}</div>
<div>: {(selectedItem.useProfile.buildBuffs ?? []).map(buff => buff.name).join(' / ') || '无'}</div>
</div>
) : (
<div className="text-sm text-zinc-500">使</div>
)}
</div>
<div className="rounded-2xl border border-white/10 bg-black/20 p-4">
<div className="mb-2 text-xs font-semibold uppercase tracking-[0.2em] text-zinc-500"> / </div>
{selectedItem.buildProfile ? (
<div className="space-y-1 text-sm text-zinc-300">
<div>: {selectedItem.buildProfile.role}</div>
<div>: {selectedItem.buildProfile.setName ?? '无'}</div>
<div>: {selectedItem.buildProfile.pieceName ?? '独立'}</div>
<div>{(selectedItem.buildProfile.synergy ?? []).join(' / ') || '无'}</div>
<div>: {(selectedItem.buildProfile.craftTags ?? []).join(' / ') || '无'}</div>
<div>: {selectedItem.buildProfile.forgeRank ?? 0}</div>
</div>
) : (
<div className="text-sm text-zinc-500"></div>
)}
</div>
</div>
<div className="rounded-2xl border border-white/10 bg-black/20 p-4">
<div className="mb-3 text-xs font-semibold uppercase tracking-[0.2em] text-zinc-500"></div>
<div className="flex items-center gap-4 rounded-2xl border border-white/10 bg-black/25 p-4">
<div className="relative flex h-24 w-24 shrink-0 items-center justify-center rounded-2xl border border-white/10 bg-white/[0.04]">
<PixelIcon src={selectedItem.iconSrc} className="h-14 w-14" />
<div className="absolute bottom-2 right-2 rounded-full border border-black/30 bg-black/65 px-1.5 py-0.5 text-[10px] font-semibold text-white">
1
</div>
</div>
<div className="min-w-0 flex-1 space-y-2">
<div className="text-base font-semibold text-white">{selectedItem.name}</div>
<div className="text-sm text-zinc-400">{selectedItem.category} / {RARITY_LABELS[selectedItem.rarity]}</div>
{previewUseEffect && (
<div className="text-sm text-zinc-300">
+{previewUseEffect.hpRestore} / +{previewUseEffect.manaRestore} / -{previewUseEffect.cooldownReduction}
</div>
)}
{!previewUseEffect && (
<div className="text-sm text-zinc-400">
使
</div>
)}
</div>
</div>
</div>
</div>
) : (
<div className="rounded-xl border border-white/10 bg-black/20 px-4 py-6 text-sm text-zinc-400">
</div>
)}
</Section>
<Section title="物品字段" description="编辑当前物品的覆盖字段。未修改的字段不会写入 override重置后会恢复自动生成值。">
{selectedBaseItem && selectedItem ? (
<div className="space-y-4">
<div>
<Label> ID</Label>
<TextInput value={selectedItem.id} onChange={() => undefined} disabled />
</div>
<div>
<Label></Label>
<TextInput value={selectedItem.sourcePath} onChange={() => undefined} disabled />
</div>
<div>
<Label></Label>
<TextInput
value={selectedItem.name}
onChange={value => updateSelectedOverride('name', value)}
/>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div>
<Label></Label>
<Select
value={selectedItem.category}
onChange={value => updateSelectedOverride('category', value)}
options={ITEM_CATEGORY_OPTIONS.map(category => ({ label: category, value: category }))}
/>
</div>
<div>
<Label></Label>
<Select
value={selectedItem.rarity}
onChange={value => updateSelectedOverride('rarity', value as ItemRarity)}
options={RARITY_OPTIONS.map(rarity => ({ label: RARITY_LABELS[rarity], value: rarity }))}
/>
</div>
</div>
<div>
<Label></Label>
<TextArea
value={tagsInputValue(selectedItem.tags)}
onChange={value => updateSelectedOverride('tags', parseTagsInput(value))}
rows={4}
/>
<div className="mt-1 text-xs text-zinc-500"></div>
</div>
<div>
<Label></Label>
<TextArea
value={selectedItem.description}
onChange={value => updateSelectedOverride('description', value)}
rows={5}
/>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div>
<Label></Label>
<TextInput
value={String(selectedItem.statProfile?.maxHpBonus ?? 0)}
onChange={value => updateSelectedStatProfileField('maxHpBonus', Number(value) || 0)}
/>
</div>
<div>
<Label></Label>
<TextInput
value={String(selectedItem.statProfile?.maxManaBonus ?? 0)}
onChange={value => updateSelectedStatProfileField('maxManaBonus', Number(value) || 0)}
/>
</div>
<div>
<Label></Label>
<TextInput
value={String(selectedItem.statProfile?.outgoingDamageBonus ?? 0)}
onChange={value => updateSelectedStatProfileField('outgoingDamageBonus', Number(value) || 0)}
/>
</div>
<div>
<Label></Label>
<TextInput
value={String(selectedItem.statProfile?.incomingDamageMultiplier ?? 1)}
onChange={value => updateSelectedStatProfileField('incomingDamageMultiplier', Number(value) || 1)}
/>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div>
<Label>使</Label>
<TextInput
value={String(selectedItem.useProfile?.hpRestore ?? 0)}
onChange={value => updateSelectedUseProfileField('hpRestore', Number(value) || 0)}
/>
</div>
<div>
<Label>使</Label>
<TextInput
value={String(selectedItem.useProfile?.manaRestore ?? 0)}
onChange={value => updateSelectedUseProfileField('manaRestore', Number(value) || 0)}
/>
</div>
<div>
<Label>使</Label>
<TextInput
value={String(selectedItem.useProfile?.cooldownReduction ?? 0)}
onChange={value => updateSelectedUseProfileField('cooldownReduction', Number(value) || 0)}
/>
</div>
</div>
<div>
<Label>使|1,2|</Label>
<TextArea
value={buildBuffLinesValue(selectedItem.useProfile?.buildBuffs)}
onChange={updateSelectedUseProfileBuffs}
rows={4}
/>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div>
<Label></Label>
<TextInput
value={selectedItem.buildProfile?.role ?? ''}
onChange={value => updateSelectedBuildProfileField('role', value)}
/>
</div>
<div>
<Label> ID</Label>
<TextInput
value={selectedItem.buildProfile?.setId ?? ''}
onChange={value => updateSelectedBuildProfileField('setId', value)}
/>
</div>
<div>
<Label></Label>
<TextInput
value={selectedItem.buildProfile?.setName ?? ''}
onChange={value => updateSelectedBuildProfileField('setName', value)}
/>
</div>
<div>
<Label></Label>
<TextInput
value={selectedItem.buildProfile?.pieceName ?? ''}
onChange={value => updateSelectedBuildProfileField('pieceName', value)}
/>
</div>
<div>
<Label></Label>
<TextInput
value={String(selectedItem.buildProfile?.forgeRank ?? 0)}
onChange={value => updateSelectedBuildProfileField('forgeRank', Number(value) || 0)}
/>
</div>
</div>
<div>
<Label></Label>
<TextArea
value={tagsInputValue(selectedItem.buildProfile?.tags ?? [])}
onChange={value => updateSelectedBuildProfileTags('tags', value)}
rows={3}
/>
</div>
<div>
<Label></Label>
<TextArea
value={tagsInputValue(selectedItem.buildProfile?.synergy ?? [])}
onChange={value => updateSelectedBuildProfileTags('synergy', value)}
rows={3}
/>
</div>
<div>
<Label></Label>
<TextArea
value={tagsInputValue(selectedItem.buildProfile?.craftTags ?? [])}
onChange={value => updateSelectedBuildProfileTags('craftTags', value)}
rows={3}
/>
</div>
<div className="rounded-xl border border-white/10 bg-white/[0.03] px-3 py-2 text-xs text-zinc-400">
{selectedOverride ? '该物品有覆盖字段,保存后会写入 itemOverrides.json。' : '当前全部字段都在使用自动生成值。'}
</div>
<div className="flex flex-wrap items-center gap-3">
<button
type="button"
onClick={handleSave}
disabled={isSaving}
className="rounded-lg bg-emerald-500 px-4 py-2 text-sm font-medium text-black transition hover:bg-emerald-400 disabled:cursor-not-allowed disabled:opacity-60"
>
{isSaving ? '保存中...' : '保存物品覆盖'}
</button>
<button
type="button"
onClick={resetSelectedOverride}
disabled={!selectedOverride}
className={`rounded-lg border px-4 py-2 text-sm transition ${
selectedOverride
? 'border-white/15 bg-black/20 text-white hover:border-white/30'
: 'border-white/8 bg-black/20 text-zinc-500'
}`}
>
</button>
{saveMessage && <div className="text-xs text-zinc-400">{saveMessage}</div>}
</div>
</div>
) : (
<div className="rounded-xl border border-white/10 bg-black/20 px-4 py-6 text-sm text-zinc-400">
</div>
)}
</Section>
</div>
);
}