1234 lines
41 KiB
TypeScript
1234 lines
41 KiB
TypeScript
import {
|
||
type ReactNode,
|
||
useDeferredValue,
|
||
useEffect,
|
||
useMemo,
|
||
useRef,
|
||
useState,
|
||
} from 'react';
|
||
|
||
import { getCustomWorldSceneRelativePositionLabel } from '../data/customWorldSceneGraph';
|
||
import {
|
||
resolveCustomWorldCampSceneImage,
|
||
resolveCustomWorldLandmarkImageMap,
|
||
} from '../data/customWorldVisuals';
|
||
import { resolveCustomWorldCampScene } from '../services/customWorldCamp';
|
||
import {
|
||
buildCustomWorldCreatorIntentFoundationText,
|
||
normalizeCustomWorldCreatorIntent,
|
||
} from '../services/customWorldCreatorIntent';
|
||
import { AnimationState, Character, CustomWorldProfile } from '../types';
|
||
import { getNineSliceStyle, UI_CHROME } from '../uiAssets';
|
||
import { CharacterAnimator } from './CharacterAnimator';
|
||
import type { CustomWorldEditorTarget } from './CustomWorldEntityEditorModal';
|
||
import { CustomWorldNpcPortrait } from './CustomWorldNpcVisualEditor';
|
||
|
||
export type ResultTab = 'world' | 'playable' | 'story' | 'landmarks';
|
||
|
||
type PendingGeneratedEntity = {
|
||
id: string;
|
||
kind: 'playable' | 'story' | 'landmark';
|
||
title: string;
|
||
progress: number;
|
||
phaseLabel: string;
|
||
};
|
||
|
||
type RecentGeneratedIds = Record<'playable' | 'story' | 'landmark', string[]>;
|
||
|
||
interface CustomWorldEntityCatalogProps {
|
||
profile: CustomWorldProfile;
|
||
previewCharacters: Character[];
|
||
activeTab: ResultTab;
|
||
onActiveTabChange: (tab: ResultTab) => void;
|
||
onEditTarget: (target: CustomWorldEditorTarget) => void;
|
||
onProfileChange: (profile: CustomWorldProfile) => void;
|
||
onDeleteStoryNpcs?: (ids: string[]) => void;
|
||
onDeleteLandmarks?: (ids: string[]) => void;
|
||
createActionLabel?: string;
|
||
onCreateAction?: () => void;
|
||
createActionDisabled?: boolean;
|
||
pendingGeneratedEntity?: PendingGeneratedEntity | null;
|
||
recentGeneratedIds?: RecentGeneratedIds;
|
||
readOnly?: boolean;
|
||
}
|
||
|
||
const RESULT_TABS: Array<{ id: ResultTab; label: string }> = [
|
||
{ id: 'world', label: '世界' },
|
||
{ id: 'playable', label: '可扮演角色' },
|
||
{ id: 'story', label: '场景角色' },
|
||
{ id: 'landmarks', label: '场景' },
|
||
];
|
||
|
||
function Section({
|
||
title,
|
||
subtitle,
|
||
badge,
|
||
actions,
|
||
children,
|
||
}: {
|
||
title: string;
|
||
subtitle?: string;
|
||
badge?: ReactNode;
|
||
actions?: ReactNode;
|
||
children: ReactNode;
|
||
}) {
|
||
return (
|
||
<div
|
||
className="pixel-nine-slice pixel-panel"
|
||
style={getNineSliceStyle(UI_CHROME.panel, { paddingX: 14, paddingY: 12 })}
|
||
>
|
||
<div className="flex items-start justify-between gap-3">
|
||
<div className="min-w-0">
|
||
<div className="text-xs font-bold tracking-[0.16em] text-white">
|
||
{title}
|
||
</div>
|
||
{subtitle ? (
|
||
<div className="mt-1 text-xs leading-6 text-zinc-500">
|
||
{subtitle}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
{badge}
|
||
{actions}
|
||
</div>
|
||
</div>
|
||
<div className="mt-3">{children}</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function SmallButton({
|
||
onClick,
|
||
children,
|
||
tone = 'default',
|
||
disabled = false,
|
||
}: {
|
||
onClick: () => void;
|
||
children: ReactNode;
|
||
tone?: 'default' | 'sky' | 'rose';
|
||
disabled?: boolean;
|
||
}) {
|
||
const toneClassName =
|
||
tone === 'sky'
|
||
? 'border-sky-300/20 bg-sky-500/10 text-sky-100 hover:text-white'
|
||
: tone === 'rose'
|
||
? 'border-rose-300/20 bg-rose-500/10 text-rose-100 hover:text-white'
|
||
: 'border-white/10 bg-black/20 text-zinc-300 hover:text-white';
|
||
|
||
return (
|
||
<button
|
||
type="button"
|
||
onClick={onClick}
|
||
disabled={disabled}
|
||
className={`rounded-full border px-3 py-1 text-[11px] transition-colors ${toneClassName} ${disabled ? 'cursor-not-allowed opacity-45' : ''}`}
|
||
>
|
||
{children}
|
||
</button>
|
||
);
|
||
}
|
||
|
||
function SearchBox({
|
||
value,
|
||
onChange,
|
||
placeholder,
|
||
}: {
|
||
value: string;
|
||
onChange: (value: string) => void;
|
||
placeholder: string;
|
||
}) {
|
||
return (
|
||
<div className="rounded-2xl border border-white/10 bg-black/20 px-3 py-2">
|
||
<input
|
||
value={value}
|
||
onChange={(event) => onChange(event.target.value)}
|
||
placeholder={placeholder}
|
||
className="w-full bg-transparent text-sm text-zinc-100 outline-none placeholder:text-zinc-500"
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function ImageFrame({
|
||
src,
|
||
alt,
|
||
fallbackLabel,
|
||
tone = 'square',
|
||
}: {
|
||
src?: string;
|
||
alt: string;
|
||
fallbackLabel: string;
|
||
tone?: 'square' | 'landscape';
|
||
}) {
|
||
return (
|
||
<div
|
||
className={`overflow-hidden rounded-2xl border border-white/10 bg-[radial-gradient(circle_at_top,rgba(56,189,248,0.16),transparent_48%),linear-gradient(180deg,rgba(19,24,39,0.96),rgba(8,10,17,0.92))] ${tone === 'landscape' ? 'aspect-[16/9]' : 'aspect-square'}`}
|
||
>
|
||
{src ? (
|
||
<img src={src} alt={alt} className="h-full w-full object-cover" />
|
||
) : (
|
||
<div className="flex h-full w-full items-center justify-center px-4 text-center text-sm font-semibold tracking-[0.18em] text-zinc-400">
|
||
{fallbackLabel}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function EmptyState({ title }: { title: string }) {
|
||
return (
|
||
<div className="rounded-2xl border border-dashed border-white/12 bg-black/20 px-5 py-6 text-center">
|
||
<div className="text-sm text-zinc-300">{title}</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function NewBadge() {
|
||
return (
|
||
<span className="rounded-full border border-amber-300/24 bg-amber-500/12 px-2.5 py-1 text-[10px] font-semibold text-amber-100">
|
||
新
|
||
</span>
|
||
);
|
||
}
|
||
|
||
function PendingEntityCard({
|
||
title,
|
||
phaseLabel,
|
||
progress,
|
||
}: {
|
||
title: string;
|
||
phaseLabel: string;
|
||
progress: number;
|
||
}) {
|
||
return (
|
||
<div className="rounded-[1.35rem] border border-sky-300/18 bg-sky-500/10 px-4 py-4">
|
||
<div className="flex items-start justify-between gap-3">
|
||
<div>
|
||
<div className="text-sm font-semibold text-white">{title}</div>
|
||
<div className="mt-1 text-xs leading-6 text-sky-50/90">
|
||
{phaseLabel}
|
||
</div>
|
||
</div>
|
||
<div className="rounded-full border border-sky-300/20 bg-black/20 px-2.5 py-1 text-[10px] text-sky-100">
|
||
{Math.round(progress)}%
|
||
</div>
|
||
</div>
|
||
<div className="mt-3 h-2.5 overflow-hidden rounded-full border border-white/10 bg-black/30">
|
||
<div
|
||
className="h-full bg-[linear-gradient(90deg,#38bdf8_0%,#67e8f9_100%)] transition-[width] duration-300"
|
||
style={{ width: `${Math.max(6, Math.min(100, progress))}%` }}
|
||
/>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function CatalogCard({
|
||
title,
|
||
description,
|
||
media,
|
||
badge,
|
||
isSelectionMode,
|
||
isSelected,
|
||
onClick,
|
||
layout = 'stacked',
|
||
mediaClassName,
|
||
disabled = false,
|
||
}: {
|
||
title: string;
|
||
description: string;
|
||
media: ReactNode;
|
||
badge?: ReactNode;
|
||
isSelectionMode: boolean;
|
||
isSelected: boolean;
|
||
onClick: () => void;
|
||
layout?: 'stacked' | 'compact';
|
||
mediaClassName?: string;
|
||
disabled?: boolean;
|
||
}) {
|
||
const selectionBadge = isSelectionMode ? (
|
||
<div
|
||
className={`shrink-0 rounded-full border px-2.5 py-1 text-[10px] ${
|
||
isSelected
|
||
? 'border-rose-300/25 bg-rose-500/14 text-rose-50'
|
||
: 'border-white/10 bg-black/20 text-zinc-400'
|
||
}`}
|
||
>
|
||
{isSelected ? '已选' : '选择'}
|
||
</div>
|
||
) : null;
|
||
|
||
if (layout === 'compact') {
|
||
return (
|
||
<button
|
||
type="button"
|
||
onClick={onClick}
|
||
disabled={disabled}
|
||
className={`w-full rounded-[1.3rem] border p-2.5 text-left transition-colors ${
|
||
isSelected
|
||
? 'border-rose-300/35 bg-rose-500/10'
|
||
: disabled
|
||
? 'border-white/10 bg-black/20'
|
||
: 'border-white/10 bg-black/20 hover:border-white/20 hover:bg-black/28'
|
||
}`}
|
||
>
|
||
<div className="flex items-start gap-3">
|
||
<div
|
||
className={`shrink-0 overflow-hidden rounded-[1rem] border border-white/8 bg-black/25 ${mediaClassName ?? 'h-[4.75rem] w-[4.75rem]'}`}
|
||
>
|
||
{media}
|
||
</div>
|
||
<div className="min-w-0 flex-1">
|
||
<div className="flex items-start justify-between gap-3">
|
||
<div className="min-w-0 text-[15px] font-semibold leading-5 text-white">
|
||
{title}
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
{badge}
|
||
{selectionBadge}
|
||
</div>
|
||
</div>
|
||
<div className="mt-1.5 text-sm leading-5 text-zinc-300">
|
||
{description || '暂无描述'}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</button>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<button
|
||
type="button"
|
||
onClick={onClick}
|
||
disabled={disabled}
|
||
className={`w-full rounded-[1.4rem] border p-3 text-left transition-colors ${
|
||
isSelected
|
||
? 'border-rose-300/35 bg-rose-500/10'
|
||
: disabled
|
||
? 'border-white/10 bg-black/20'
|
||
: 'border-white/10 bg-black/20 hover:border-white/20 hover:bg-black/28'
|
||
}`}
|
||
>
|
||
<div className="space-y-3">
|
||
<div
|
||
className={`overflow-hidden rounded-[1.1rem] border border-white/8 bg-black/25 ${mediaClassName ?? ''}`}
|
||
>
|
||
{media}
|
||
</div>
|
||
<div className="flex items-start justify-between gap-3">
|
||
<div className="min-w-0 text-base font-semibold text-white">
|
||
{title}
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
{badge}
|
||
{selectionBadge}
|
||
</div>
|
||
</div>
|
||
<div className="text-sm leading-6 text-zinc-300">
|
||
{description || '暂无描述'}
|
||
</div>
|
||
</div>
|
||
</button>
|
||
);
|
||
}
|
||
|
||
function matchText(text: string, query: string) {
|
||
return text.toLowerCase().includes(query.toLowerCase());
|
||
}
|
||
|
||
function getSearchPlaceholder(tab: ResultTab) {
|
||
if (tab === 'playable') return '搜索角色名称、称号、标签';
|
||
if (tab === 'story') return '搜索场景角色名称、身份、动机';
|
||
if (tab === 'landmarks') return '搜索场景名称、描述、NPC、连接';
|
||
return '搜索';
|
||
}
|
||
|
||
function compactTextList(values: Array<string | null | undefined>) {
|
||
return values.map((value) => value?.trim() ?? '').filter(Boolean);
|
||
}
|
||
|
||
function buildOpeningSceneSearchText(
|
||
profile: CustomWorldProfile,
|
||
campScene: ReturnType<typeof resolveCustomWorldCampScene>,
|
||
) {
|
||
return [
|
||
campScene.name,
|
||
campScene.description,
|
||
campScene.dangerLevel,
|
||
profile.playerGoal,
|
||
profile.summary,
|
||
'开局场景',
|
||
'开局归处',
|
||
].join(' ');
|
||
}
|
||
|
||
function buildStructuredFoundationEntries(profile: CustomWorldProfile) {
|
||
const creatorIntent = normalizeCustomWorldCreatorIntent(profile.creatorIntent);
|
||
const relationshipSeed = creatorIntent?.keyCharacters[0];
|
||
const relationshipText = relationshipSeed
|
||
? compactTextList([
|
||
relationshipSeed.name,
|
||
relationshipSeed.role,
|
||
relationshipSeed.relationToPlayer
|
||
? `与玩家:${relationshipSeed.relationToPlayer}`
|
||
: '',
|
||
relationshipSeed.hiddenHook
|
||
? `暗线:${relationshipSeed.hiddenHook}`
|
||
: '',
|
||
]).join(' · ')
|
||
: '';
|
||
const themeToneText = compactTextList([
|
||
creatorIntent?.themeKeywords.join('、') || '',
|
||
creatorIntent?.toneDirectives.join('、') || '',
|
||
]).join(' / ');
|
||
const playerOpeningText = compactTextList([
|
||
creatorIntent?.playerPremise || '',
|
||
creatorIntent?.openingSituation || '',
|
||
]).join(';');
|
||
|
||
return [
|
||
{
|
||
id: 'world-hook',
|
||
label: '世界一句话',
|
||
value:
|
||
creatorIntent?.worldHook ||
|
||
profile.anchorPack?.worldSummary ||
|
||
profile.summary,
|
||
},
|
||
{
|
||
id: 'player-opening',
|
||
label: '玩家开局',
|
||
value: playerOpeningText || profile.playerGoal,
|
||
},
|
||
{
|
||
id: 'theme-tone',
|
||
label: '主题气质',
|
||
value: themeToneText || profile.tone,
|
||
},
|
||
{
|
||
id: 'core-conflict',
|
||
label: '核心冲突',
|
||
value:
|
||
creatorIntent?.coreConflicts.join(';') ||
|
||
profile.coreConflicts.join(';') ||
|
||
profile.summary,
|
||
},
|
||
{
|
||
id: 'relationship-seed',
|
||
label: '关键关系',
|
||
value:
|
||
relationshipText ||
|
||
profile.playableNpcs[0]?.relationshipHooks.join(';') ||
|
||
profile.storyNpcs[0]?.relationshipHooks.join(';') ||
|
||
'待补充',
|
||
},
|
||
{
|
||
id: 'iconic-elements',
|
||
label: '标志元素',
|
||
value:
|
||
creatorIntent?.iconicElements.join('、') ||
|
||
profile.anchorPack?.motifDirectives.join('、') ||
|
||
'待补充',
|
||
},
|
||
];
|
||
}
|
||
|
||
type CatalogRole =
|
||
| CustomWorldProfile['playableNpcs'][number]
|
||
| CustomWorldProfile['storyNpcs'][number];
|
||
|
||
type BulkDeleteTab = 'story' | 'landmarks';
|
||
|
||
function buildRoleSearchText(role: CatalogRole) {
|
||
return [
|
||
role.name,
|
||
role.title,
|
||
role.role,
|
||
role.description,
|
||
role.backstory,
|
||
role.backstoryReveal.publicSummary,
|
||
role.personality,
|
||
role.motivation,
|
||
role.combatStyle,
|
||
...role.backstoryReveal.chapters.flatMap((chapter) => [
|
||
chapter.title,
|
||
chapter.teaser,
|
||
chapter.content,
|
||
chapter.contextSnippet,
|
||
]),
|
||
...role.skills.flatMap((skill) => [skill.name, skill.summary, skill.style]),
|
||
...role.initialItems.flatMap((item) => [
|
||
item.name,
|
||
item.category,
|
||
item.description,
|
||
...item.tags,
|
||
]),
|
||
...role.relationshipHooks,
|
||
...role.tags,
|
||
].join(' ');
|
||
}
|
||
|
||
function buildLandmarkSearchText(
|
||
landmark: CustomWorldProfile['landmarks'][number],
|
||
storyNpcById: Map<string, CustomWorldProfile['storyNpcs'][number]>,
|
||
landmarkById: Map<string, CustomWorldProfile['landmarks'][number]>,
|
||
) {
|
||
return [
|
||
landmark.name,
|
||
landmark.description,
|
||
landmark.dangerLevel,
|
||
...landmark.sceneNpcIds.map((npcId) => storyNpcById.get(npcId)?.name ?? ''),
|
||
...landmark.connections.flatMap((connection) => [
|
||
landmarkById.get(connection.targetLandmarkId)?.name ?? '',
|
||
getCustomWorldSceneRelativePositionLabel(connection.relativePosition),
|
||
connection.summary,
|
||
]),
|
||
].join(' ');
|
||
}
|
||
|
||
export function CustomWorldEntityCatalog({
|
||
profile,
|
||
previewCharacters,
|
||
activeTab,
|
||
onActiveTabChange,
|
||
onEditTarget,
|
||
onProfileChange,
|
||
onDeleteStoryNpcs,
|
||
onDeleteLandmarks,
|
||
createActionLabel,
|
||
onCreateAction,
|
||
createActionDisabled = false,
|
||
pendingGeneratedEntity = null,
|
||
recentGeneratedIds = {
|
||
playable: [],
|
||
story: [],
|
||
landmark: [],
|
||
},
|
||
readOnly = false,
|
||
}: CustomWorldEntityCatalogProps) {
|
||
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
|
||
const [searchDraft, setSearchDraft] = useState('');
|
||
const [bulkDeleteMode, setBulkDeleteMode] = useState<BulkDeleteTab | null>(
|
||
null,
|
||
);
|
||
const [selectedBulkIds, setSelectedBulkIds] = useState<string[]>([]);
|
||
const deferredSearch = useDeferredValue(searchDraft.trim());
|
||
|
||
const storyNpcById = useMemo(
|
||
() => new Map(profile.storyNpcs.map((npc) => [npc.id, npc])),
|
||
[profile.storyNpcs],
|
||
);
|
||
const landmarkById = useMemo(
|
||
() => new Map(profile.landmarks.map((landmark) => [landmark.id, landmark])),
|
||
[profile.landmarks],
|
||
);
|
||
const landmarkImageById = useMemo(
|
||
() => resolveCustomWorldLandmarkImageMap(profile),
|
||
[profile],
|
||
);
|
||
const resolvedCampScene = useMemo(
|
||
() => resolveCustomWorldCampScene(profile),
|
||
[profile],
|
||
);
|
||
const resolvedCampImageSrc = useMemo(
|
||
() => resolveCustomWorldCampSceneImage(profile),
|
||
[profile],
|
||
);
|
||
const previewCharacterById = useMemo(
|
||
() =>
|
||
new Map(
|
||
profile.playableNpcs.map((role, index) => [
|
||
role.id,
|
||
previewCharacters[index] ?? null,
|
||
]),
|
||
),
|
||
[previewCharacters, profile.playableNpcs],
|
||
);
|
||
const recentPlayableIdSet = useMemo(
|
||
() => new Set(recentGeneratedIds.playable),
|
||
[recentGeneratedIds.playable],
|
||
);
|
||
const recentStoryIdSet = useMemo(
|
||
() => new Set(recentGeneratedIds.story),
|
||
[recentGeneratedIds.story],
|
||
);
|
||
const recentLandmarkIdSet = useMemo(
|
||
() => new Set(recentGeneratedIds.landmark),
|
||
[recentGeneratedIds.landmark],
|
||
);
|
||
|
||
const filteredPlayable = useMemo(
|
||
() =>
|
||
profile.playableNpcs.filter(
|
||
(role) =>
|
||
!deferredSearch ||
|
||
matchText(buildRoleSearchText(role), deferredSearch),
|
||
),
|
||
[deferredSearch, profile.playableNpcs],
|
||
);
|
||
|
||
const filteredStory = useMemo(
|
||
() =>
|
||
profile.storyNpcs.filter(
|
||
(npc) =>
|
||
!deferredSearch ||
|
||
matchText(buildRoleSearchText(npc), deferredSearch),
|
||
),
|
||
[deferredSearch, profile.storyNpcs],
|
||
);
|
||
|
||
const filteredLandmarks = useMemo(
|
||
() =>
|
||
profile.landmarks.filter(
|
||
(landmark) =>
|
||
!deferredSearch ||
|
||
matchText(
|
||
buildLandmarkSearchText(landmark, storyNpcById, landmarkById),
|
||
deferredSearch,
|
||
),
|
||
),
|
||
[deferredSearch, landmarkById, profile.landmarks, storyNpcById],
|
||
);
|
||
const structuredFoundationEntries = useMemo(
|
||
() => buildStructuredFoundationEntries(profile),
|
||
[profile],
|
||
);
|
||
const structuredFoundationSourceText = useMemo(
|
||
() =>
|
||
buildCustomWorldCreatorIntentFoundationText(profile.creatorIntent).trim() ||
|
||
profile.settingText.trim(),
|
||
[profile.creatorIntent, profile.settingText],
|
||
);
|
||
const normalizedCreatorIntent = useMemo(
|
||
() => normalizeCustomWorldCreatorIntent(profile.creatorIntent),
|
||
[profile.creatorIntent],
|
||
);
|
||
const filteredSceneEntries = useMemo(() => {
|
||
const openingSceneEntry = {
|
||
id: 'custom-world-opening-scene',
|
||
kind: 'camp' as const,
|
||
name: resolvedCampScene.name,
|
||
description: resolvedCampScene.description,
|
||
imageSrc: resolvedCampImageSrc,
|
||
searchText: buildOpeningSceneSearchText(profile, resolvedCampScene),
|
||
};
|
||
const landmarkEntries = filteredLandmarks.map((landmark) => ({
|
||
id: landmark.id,
|
||
kind: 'landmark' as const,
|
||
name: landmark.name,
|
||
description: landmark.description,
|
||
imageSrc: landmarkImageById.get(landmark.id) ?? landmark.imageSrc,
|
||
searchText: buildLandmarkSearchText(landmark, storyNpcById, landmarkById),
|
||
}));
|
||
const recentEntries = landmarkEntries.filter((entry) =>
|
||
recentLandmarkIdSet.has(entry.id),
|
||
);
|
||
const restEntries = landmarkEntries.filter(
|
||
(entry) => !recentLandmarkIdSet.has(entry.id),
|
||
);
|
||
const allEntries = [...recentEntries, openingSceneEntry, ...restEntries];
|
||
|
||
if (!deferredSearch) {
|
||
return allEntries;
|
||
}
|
||
|
||
return allEntries.filter((entry) =>
|
||
matchText(entry.searchText, deferredSearch),
|
||
);
|
||
}, [
|
||
deferredSearch,
|
||
filteredLandmarks,
|
||
landmarkById,
|
||
landmarkImageById,
|
||
profile,
|
||
recentLandmarkIdSet,
|
||
resolvedCampImageSrc,
|
||
resolvedCampScene,
|
||
storyNpcById,
|
||
]);
|
||
|
||
const lockedCharacterNames = useMemo(
|
||
() =>
|
||
new Set(
|
||
normalizedCreatorIntent?.keyCharacters
|
||
.filter((entry) => entry.locked)
|
||
.map((entry) => entry.name.trim())
|
||
.filter(Boolean) ?? [],
|
||
),
|
||
[normalizedCreatorIntent],
|
||
);
|
||
|
||
const counts = {
|
||
world: 1,
|
||
playable:
|
||
profile.playableNpcs.length +
|
||
(pendingGeneratedEntity?.kind === 'playable' ? 1 : 0),
|
||
story:
|
||
profile.storyNpcs.length +
|
||
(pendingGeneratedEntity?.kind === 'story' ? 1 : 0),
|
||
landmarks:
|
||
profile.landmarks.length +
|
||
1 +
|
||
(pendingGeneratedEntity?.kind === 'landmark' ? 1 : 0),
|
||
} satisfies Record<ResultTab, number>;
|
||
|
||
const bulkDeleteTab: BulkDeleteTab | null =
|
||
activeTab === 'story' || activeTab === 'landmarks' ? activeTab : null;
|
||
const isBulkDeleteMode =
|
||
bulkDeleteTab !== null && bulkDeleteMode === bulkDeleteTab;
|
||
|
||
useEffect(() => {
|
||
if (bulkDeleteMode && bulkDeleteMode !== activeTab) {
|
||
setBulkDeleteMode(null);
|
||
setSelectedBulkIds([]);
|
||
}
|
||
}, [activeTab, bulkDeleteMode]);
|
||
|
||
useEffect(() => {
|
||
const container = scrollContainerRef.current;
|
||
if (!container) return;
|
||
if (typeof container.scrollTo === 'function') {
|
||
container.scrollTo({ top: 0, behavior: 'auto' });
|
||
return;
|
||
}
|
||
container.scrollTop = 0;
|
||
}, [activeTab]);
|
||
|
||
const removePlayable = (id: string, name: string) => {
|
||
if (profile.playableNpcs.length <= 1) {
|
||
window.alert('至少保留一个可扮演角色,才能正常进入自定义世界。');
|
||
return;
|
||
}
|
||
if (!window.confirm(`确认删除可扮演角色「${name}」吗?`)) return;
|
||
onProfileChange({
|
||
...profile,
|
||
playableNpcs: profile.playableNpcs.filter((role) => role.id !== id),
|
||
});
|
||
};
|
||
|
||
const startBulkDelete = (tab: BulkDeleteTab) => {
|
||
setBulkDeleteMode(tab);
|
||
setSelectedBulkIds([]);
|
||
};
|
||
|
||
const cancelBulkDelete = () => {
|
||
setBulkDeleteMode(null);
|
||
setSelectedBulkIds([]);
|
||
};
|
||
|
||
const toggleBulkSelected = (id: string) => {
|
||
setSelectedBulkIds((current) =>
|
||
current.includes(id)
|
||
? current.filter((entry) => entry !== id)
|
||
: [...current, id],
|
||
);
|
||
};
|
||
|
||
const confirmBulkDelete = () => {
|
||
if (!bulkDeleteTab || selectedBulkIds.length === 0) {
|
||
return;
|
||
}
|
||
|
||
const label = bulkDeleteTab === 'story' ? '场景角色' : '场景';
|
||
const confirmed = window.confirm(
|
||
`确认批量删除 ${selectedBulkIds.length} 个${label}吗?`,
|
||
);
|
||
if (!confirmed) {
|
||
return;
|
||
}
|
||
|
||
if (bulkDeleteTab === 'story') {
|
||
onDeleteStoryNpcs?.(selectedBulkIds);
|
||
} else {
|
||
onDeleteLandmarks?.(selectedBulkIds);
|
||
}
|
||
cancelBulkDelete();
|
||
};
|
||
|
||
return (
|
||
<div
|
||
ref={scrollContainerRef}
|
||
className="h-full min-h-0 space-y-3 overflow-y-auto overscroll-contain pr-1 scrollbar-hide"
|
||
>
|
||
<div className="px-1 pb-1 text-center">
|
||
<div className="text-[11px] font-bold tracking-[0.28em] text-zinc-500">
|
||
世界档案
|
||
</div>
|
||
<div className="mt-2 text-3xl font-black text-white sm:text-[2.2rem]">
|
||
{profile.name}
|
||
</div>
|
||
<div className="mt-2 text-sm tracking-[0.18em] text-zinc-400">
|
||
{profile.subtitle}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="sticky top-0 z-10 -mx-1 space-y-3 bg-[linear-gradient(180deg,rgba(8,10,17,0.98)_0%,rgba(8,10,17,0.95)_75%,rgba(8,10,17,0)_100%)] px-1 pb-3 pt-1">
|
||
<div className="flex gap-2 overflow-x-auto pb-1 scrollbar-hide">
|
||
{RESULT_TABS.map((tab) => (
|
||
<div key={tab.id}>
|
||
<button
|
||
type="button"
|
||
onClick={() => onActiveTabChange(tab.id)}
|
||
className={`rounded-full border px-3 py-2 text-left text-sm transition-colors ${activeTab === tab.id ? 'border-sky-300/25 bg-sky-500/12 text-white' : 'border-white/10 bg-black/20 text-zinc-300 hover:text-white'}`}
|
||
>
|
||
<div className="font-semibold">{tab.label}</div>
|
||
<div className="mt-1 text-[10px] tracking-[0.16em] text-white/55">
|
||
{counts[tab.id]}
|
||
</div>
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
{activeTab !== 'world' ? (
|
||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||
<div className="min-w-0 flex-1">
|
||
<SearchBox
|
||
value={searchDraft}
|
||
onChange={setSearchDraft}
|
||
placeholder={getSearchPlaceholder(activeTab)}
|
||
/>
|
||
</div>
|
||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||
{isBulkDeleteMode ? (
|
||
<>
|
||
<div className="rounded-full border border-white/10 bg-black/20 px-3 py-1 text-[11px] text-zinc-300">
|
||
已选 {selectedBulkIds.length}
|
||
</div>
|
||
<SmallButton onClick={cancelBulkDelete}>取消</SmallButton>
|
||
<SmallButton onClick={confirmBulkDelete} tone="rose">
|
||
删除选中
|
||
</SmallButton>
|
||
</>
|
||
) : (
|
||
<>
|
||
{!readOnly && createActionLabel && onCreateAction ? (
|
||
<SmallButton
|
||
onClick={onCreateAction}
|
||
tone="sky"
|
||
disabled={createActionDisabled}
|
||
>
|
||
{createActionLabel}
|
||
</SmallButton>
|
||
) : null}
|
||
{!readOnly &&
|
||
bulkDeleteTab &&
|
||
((bulkDeleteTab === 'story' && onDeleteStoryNpcs) ||
|
||
(bulkDeleteTab === 'landmarks' && onDeleteLandmarks)) ? (
|
||
<SmallButton
|
||
onClick={() => startBulkDelete(bulkDeleteTab)}
|
||
tone="rose"
|
||
>
|
||
批量删除
|
||
</SmallButton>
|
||
) : null}
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
|
||
{activeTab === 'world' ? (
|
||
<>
|
||
<Section title="档案规模">
|
||
<div className="grid grid-cols-3 gap-2 text-center text-[11px] text-zinc-300">
|
||
<div className="rounded-xl border border-white/8 bg-black/20 px-2 py-3">
|
||
<div className="text-xl font-black text-white">
|
||
{profile.playableNpcs.length}
|
||
</div>
|
||
<div>可扮演角色</div>
|
||
</div>
|
||
<div className="rounded-xl border border-white/8 bg-black/20 px-2 py-3">
|
||
<div className="text-xl font-black text-white">
|
||
{profile.storyNpcs.length}
|
||
</div>
|
||
<div>场景角色</div>
|
||
</div>
|
||
<div className="rounded-xl border border-white/8 bg-black/20 px-2 py-3">
|
||
<div className="text-xl font-black text-white">
|
||
{profile.landmarks.length + 1}
|
||
</div>
|
||
<div>场景</div>
|
||
</div>
|
||
</div>
|
||
</Section>
|
||
|
||
<Section
|
||
title="世界概述"
|
||
actions={
|
||
readOnly ? (
|
||
<SmallButton
|
||
onClick={() => onEditTarget({ kind: 'world' })}
|
||
tone="sky"
|
||
>
|
||
查看详情
|
||
</SmallButton>
|
||
) : (
|
||
<SmallButton
|
||
onClick={() => onEditTarget({ kind: 'world' })}
|
||
tone="sky"
|
||
>
|
||
编辑
|
||
</SmallButton>
|
||
)
|
||
}
|
||
>
|
||
<div className="space-y-3 text-sm leading-7 text-zinc-300">
|
||
<p>{profile.summary}</p>
|
||
<div className="rounded-2xl border border-amber-300/12 bg-amber-500/8 px-3 py-3 text-amber-100">
|
||
主线目标:{profile.playerGoal}
|
||
</div>
|
||
<div className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3">
|
||
世界基调:{profile.tone}
|
||
</div>
|
||
</div>
|
||
</Section>
|
||
|
||
<Section
|
||
title="基本设定"
|
||
actions={
|
||
readOnly ? (
|
||
<SmallButton
|
||
onClick={() => onEditTarget({ kind: 'world' })}
|
||
tone="sky"
|
||
>
|
||
查看详情
|
||
</SmallButton>
|
||
) : (
|
||
<SmallButton
|
||
onClick={() => onEditTarget({ kind: 'world' })}
|
||
tone="sky"
|
||
>
|
||
编辑
|
||
</SmallButton>
|
||
)
|
||
}
|
||
>
|
||
<div className="space-y-3">
|
||
<div className="text-[11px] font-bold tracking-[0.18em] text-zinc-500">
|
||
解析字段
|
||
</div>
|
||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||
{structuredFoundationEntries.map((entry) => (
|
||
<div
|
||
key={entry.id}
|
||
className="rounded-2xl border border-white/8 bg-black/20 px-4 py-4"
|
||
>
|
||
<div className="text-[11px] font-bold tracking-[0.18em] text-zinc-500">
|
||
{entry.label}
|
||
</div>
|
||
<div className="mt-2 text-sm leading-7 text-zinc-100">
|
||
{entry.value || '待补充'}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
{structuredFoundationSourceText ? (
|
||
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-4">
|
||
<div className="text-[11px] font-bold tracking-[0.18em] text-zinc-500">
|
||
锚点原文
|
||
</div>
|
||
<div className="mt-2 whitespace-pre-line text-sm leading-7 text-zinc-200">
|
||
{structuredFoundationSourceText}
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
</Section>
|
||
</>
|
||
) : null}
|
||
|
||
{activeTab === 'playable' ? (
|
||
<div className="space-y-3">
|
||
{pendingGeneratedEntity?.kind === 'playable' ? (
|
||
<PendingEntityCard
|
||
title={pendingGeneratedEntity.title}
|
||
phaseLabel={pendingGeneratedEntity.phaseLabel}
|
||
progress={pendingGeneratedEntity.progress}
|
||
/>
|
||
) : null}
|
||
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3 text-sm text-zinc-300">
|
||
{readOnly
|
||
? '当前是草稿结果预览,可先浏览角色结构,再回到工作区继续精修。'
|
||
: '可扮演角色支持新增、删除与更换外观模板。'}
|
||
</div>
|
||
{filteredPlayable.length === 0 ? (
|
||
<EmptyState title="当前没有符合搜索条件的可扮演角色。" />
|
||
) : (
|
||
filteredPlayable.map((role) => {
|
||
const previewCharacter =
|
||
previewCharacterById.get(role.id) ?? null;
|
||
|
||
return (
|
||
<div key={role.id}>
|
||
<Section
|
||
title={role.name}
|
||
subtitle={role.title}
|
||
badge={recentPlayableIdSet.has(role.id) ? <NewBadge /> : null}
|
||
actions={
|
||
readOnly ? (
|
||
<SmallButton
|
||
onClick={() =>
|
||
onEditTarget({
|
||
kind: 'playable',
|
||
mode: 'edit',
|
||
id: role.id,
|
||
})
|
||
}
|
||
tone="sky"
|
||
>
|
||
查看详情
|
||
</SmallButton>
|
||
) : (
|
||
<div className="flex items-center gap-2">
|
||
<SmallButton
|
||
onClick={() =>
|
||
onEditTarget({
|
||
kind: 'playable',
|
||
mode: 'edit',
|
||
id: role.id,
|
||
})
|
||
}
|
||
tone="sky"
|
||
>
|
||
编辑
|
||
</SmallButton>
|
||
<SmallButton
|
||
onClick={() => removePlayable(role.id, role.name)}
|
||
tone="rose"
|
||
>
|
||
删除
|
||
</SmallButton>
|
||
</div>
|
||
)
|
||
}
|
||
>
|
||
<div className="flex flex-col gap-3 sm:flex-row">
|
||
<div className="flex h-28 w-28 shrink-0 items-end justify-center overflow-hidden rounded-2xl border border-white/10 bg-black/35">
|
||
{previewCharacter ? (
|
||
<CharacterAnimator
|
||
state={AnimationState.RUN}
|
||
character={previewCharacter}
|
||
className="h-full w-full"
|
||
imageClassName="object-bottom"
|
||
/>
|
||
) : null}
|
||
</div>
|
||
<div className="min-w-0 flex-1">
|
||
{lockedCharacterNames.has(role.name.trim()) ? (
|
||
<div className="mb-2 inline-flex rounded-full border border-amber-300/18 bg-amber-500/10 px-2.5 py-1 text-[10px] text-amber-100">
|
||
创作者锁定角色
|
||
</div>
|
||
) : null}
|
||
<div className="text-sm leading-6 text-zinc-300">
|
||
{role.description}
|
||
</div>
|
||
<div className="mt-2 text-xs leading-6 text-zinc-400">
|
||
{role.backstory}
|
||
</div>
|
||
<div className="mt-3 rounded-xl border border-sky-300/12 bg-sky-500/8 px-3 py-2 text-xs leading-6 text-sky-50/95">
|
||
公开背景:
|
||
{role.backstoryReveal.publicSummary || '未填写'}
|
||
</div>
|
||
<div className="mt-3 grid gap-2 sm:grid-cols-2">
|
||
<div className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs leading-6 text-zinc-300">
|
||
身份:{role.role}
|
||
</div>
|
||
<div className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs leading-6 text-zinc-300">
|
||
初始好感:{role.initialAffinity}
|
||
</div>
|
||
<div className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs leading-6 text-zinc-300">
|
||
性格:{role.personality}
|
||
</div>
|
||
<div className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs leading-6 text-zinc-300">
|
||
战斗:{role.combatStyle}
|
||
</div>
|
||
</div>
|
||
<div className="mt-3 rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs leading-6 text-zinc-300">
|
||
动机:{role.motivation}
|
||
</div>
|
||
<div className="mt-3 rounded-xl border border-white/8 bg-black/20 px-3 py-3">
|
||
<div className="text-[11px] font-bold tracking-[0.16em] text-zinc-400">
|
||
好感背景章节
|
||
</div>
|
||
<div className="mt-2 space-y-2">
|
||
{role.backstoryReveal.chapters.map((chapter) => (
|
||
<div
|
||
key={`${role.id}-${chapter.id}`}
|
||
className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs leading-6 text-zinc-300"
|
||
>
|
||
{chapter.affinityRequired} 好感 ·{' '}
|
||
{chapter.title}:{chapter.teaser}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
<div className="mt-3 rounded-xl border border-white/8 bg-black/20 px-3 py-3">
|
||
<div className="text-[11px] font-bold tracking-[0.16em] text-zinc-400">
|
||
技能
|
||
</div>
|
||
<div className="mt-2 space-y-2">
|
||
{role.skills.map((skill) => (
|
||
<div
|
||
key={`${role.id}-${skill.id}`}
|
||
className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs leading-6 text-zinc-300"
|
||
>
|
||
{skill.name} · {skill.style}:{skill.summary}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
<div className="mt-3 rounded-xl border border-white/8 bg-black/20 px-3 py-3">
|
||
<div className="text-[11px] font-bold tracking-[0.16em] text-zinc-400">
|
||
初始物品
|
||
</div>
|
||
<div className="mt-2 space-y-2">
|
||
{role.initialItems.map((item) => (
|
||
<div
|
||
key={`${role.id}-${item.id}`}
|
||
className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs leading-6 text-zinc-300"
|
||
>
|
||
{item.name} x{item.quantity} · {item.category} ·{' '}
|
||
{item.rarity}:{item.description}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
<div className="mt-3 flex flex-wrap gap-2">
|
||
{role.tags.map((tag) => (
|
||
<span
|
||
key={`${role.id}-${tag}`}
|
||
className="rounded-full border border-white/10 bg-black/20 px-2.5 py-1 text-[10px] text-zinc-300"
|
||
>
|
||
{tag}
|
||
</span>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</Section>
|
||
</div>
|
||
);
|
||
})
|
||
)}
|
||
</div>
|
||
) : null}
|
||
|
||
{activeTab === 'story' ? (
|
||
<div className="space-y-3">
|
||
{pendingGeneratedEntity?.kind === 'story' ? (
|
||
<PendingEntityCard
|
||
title={pendingGeneratedEntity.title}
|
||
phaseLabel={pendingGeneratedEntity.phaseLabel}
|
||
progress={pendingGeneratedEntity.progress}
|
||
/>
|
||
) : null}
|
||
{filteredStory.length === 0 ? (
|
||
<EmptyState title="当前没有符合搜索条件的场景角色。" />
|
||
) : (
|
||
filteredStory.map((npc) => (
|
||
<div key={npc.id}>
|
||
<CatalogCard
|
||
title={npc.name}
|
||
description={npc.description}
|
||
badge={recentStoryIdSet.has(npc.id) ? <NewBadge /> : null}
|
||
isSelectionMode={isBulkDeleteMode}
|
||
isSelected={selectedBulkIds.includes(npc.id)}
|
||
layout="compact"
|
||
mediaClassName="h-[4.75rem] w-[4.75rem] sm:h-[5.25rem] sm:w-[5.25rem]"
|
||
onClick={() =>
|
||
isBulkDeleteMode
|
||
? toggleBulkSelected(npc.id)
|
||
: readOnly
|
||
? onEditTarget({
|
||
kind: 'story',
|
||
mode: 'edit',
|
||
id: npc.id,
|
||
})
|
||
: onEditTarget({
|
||
kind: 'story',
|
||
mode: 'edit',
|
||
id: npc.id,
|
||
})
|
||
}
|
||
media={
|
||
<CustomWorldNpcPortrait
|
||
npc={npc}
|
||
profile={profile}
|
||
visual={npc.visual}
|
||
className="h-full w-full"
|
||
contentClassName="min-h-0 p-2"
|
||
scale={1.82}
|
||
preferImageSrc
|
||
/>
|
||
}
|
||
/>
|
||
</div>
|
||
))
|
||
)}
|
||
</div>
|
||
) : null}
|
||
|
||
{activeTab === 'landmarks' ? (
|
||
<div className="space-y-3">
|
||
{pendingGeneratedEntity?.kind === 'landmark' ? (
|
||
<PendingEntityCard
|
||
title={pendingGeneratedEntity.title}
|
||
phaseLabel={pendingGeneratedEntity.phaseLabel}
|
||
progress={pendingGeneratedEntity.progress}
|
||
/>
|
||
) : null}
|
||
{filteredSceneEntries.length === 0 ? (
|
||
<EmptyState title="当前没有符合搜索条件的场景。" />
|
||
) : (
|
||
filteredSceneEntries.map((scene) => (
|
||
<div key={scene.id}>
|
||
<CatalogCard
|
||
title={scene.name}
|
||
description={
|
||
scene.kind === 'camp'
|
||
? `开局场景 · ${scene.description}`
|
||
: scene.description
|
||
}
|
||
badge={
|
||
scene.kind === 'landmark' && recentLandmarkIdSet.has(scene.id) ? (
|
||
<NewBadge />
|
||
) : null
|
||
}
|
||
isSelectionMode={scene.kind === 'landmark' && isBulkDeleteMode}
|
||
isSelected={
|
||
scene.kind === 'landmark' &&
|
||
selectedBulkIds.includes(scene.id)
|
||
}
|
||
onClick={() =>
|
||
scene.kind === 'camp'
|
||
? onEditTarget({ kind: 'camp' })
|
||
: isBulkDeleteMode
|
||
? toggleBulkSelected(scene.id)
|
||
: onEditTarget({
|
||
kind: 'landmark',
|
||
mode: 'edit',
|
||
id: scene.id,
|
||
})
|
||
}
|
||
media={
|
||
<ImageFrame
|
||
src={scene.imageSrc}
|
||
alt={scene.name}
|
||
fallbackLabel={scene.name.slice(0, 4) || '场景'}
|
||
tone="landscape"
|
||
/>
|
||
}
|
||
disabled={scene.kind === 'camp' && isBulkDeleteMode}
|
||
/>
|
||
</div>
|
||
))
|
||
)}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|