899 lines
35 KiB
TypeScript
899 lines
35 KiB
TypeScript
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>
|
||
);
|
||
}
|