import { type ReactNode, useDeferredValue, useEffect, useMemo, useRef, useState, } from 'react'; import type { EightAnchorContent, KeyRelationshipValue, } from '../../packages/shared/src/contracts/customWorldAgent'; import { getCustomWorldSceneRelativePositionLabel } from '../data/customWorldSceneGraph'; import { resolveCustomWorldCampSceneImage, resolveCustomWorldLandmarkImageMap, } from '../data/customWorldVisuals'; import { resolveCustomWorldCampScene } from '../services/customWorldCamp'; import { normalizeCustomWorldCreatorIntent } from '../services/customWorldCreatorIntent'; import { AnimationState, Character, CustomWorldProfile, type SceneActBlueprint, type SceneChapterBlueprint, } from '../types'; import { CharacterAnimator } from './CharacterAnimator'; import type { RpgCreationEditorTarget } from './rpg-creation-editor/RpgCreationEntityEditorModal'; import { CustomWorldNpcPortrait } from './CustomWorldNpcVisualEditor'; import { ResolvedAssetImage } from './ResolvedAssetImage'; 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: RpgCreationEditorTarget) => 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 (
{title}
{subtitle ? (
{subtitle}
) : null}
{badge} {actions}
{children}
); } function SmallButton({ onClick, children, tone = 'default', disabled = false, }: { onClick: React.MouseEventHandler; children: ReactNode; tone?: 'default' | 'sky' | 'rose'; disabled?: boolean; actions?: ReactNode; }) { const toneClassName = tone === 'sky' ? 'platform-button platform-button--primary' : tone === 'rose' ? 'platform-button platform-button--danger' : 'platform-button platform-button--ghost'; return ( ); } function SearchBox({ value, onChange, placeholder, }: { value: string; onChange: (value: string) => void; placeholder: string; }) { return (
onChange(event.target.value)} placeholder={placeholder} className="w-full bg-transparent text-sm text-[var(--platform-text-strong)] outline-none placeholder:text-[var(--platform-text-soft)]" />
); } function ImageFrame({ src, alt, fallbackLabel, tone = 'square', }: { src?: string; alt: string; fallbackLabel: string; tone?: 'square' | 'landscape'; }) { return (
{src ? ( ) : (
{fallbackLabel}
)}
); } function EmptyState({ title }: { title: string }) { return (
{title}
); } function buildFallbackRenderKey( value: string | null | undefined, fallback: string, ) { const normalizedValue = value?.trim(); return normalizedValue ? normalizedValue : fallback; } function NewBadge() { return ( ); } function PendingEntityCard({ title, phaseLabel, progress, }: { title: string; phaseLabel: string; progress: number; }) { return (
{title}
{phaseLabel}
{Math.round(progress)}%
); } function resolveSceneEntrySceneChapters(params: { sceneChapters: CustomWorldProfile['sceneChapterBlueprints']; sceneId: string; sceneName: string; }) { const sceneChapters = params.sceneChapters ?? []; const normalizedSceneId = params.sceneId.trim(); const normalizedSceneName = params.sceneName.trim(); const directMatches = sceneChapters.filter( (chapter) => chapter.sceneId.trim() === normalizedSceneId, ); if (directMatches.length > 0) { return directMatches; } const linkedMatches = sceneChapters.filter((chapter) => chapter.linkedLandmarkIds.some( (landmarkId) => landmarkId.trim() === normalizedSceneId, ), ); if (linkedMatches.length > 0) { return linkedMatches; } return sceneChapters.filter((chapter) => { const chapterTitle = chapter.title.trim(); return ( chapterTitle === normalizedSceneName || chapter.summary.includes(normalizedSceneName) || chapter.acts.some( (act) => act.title.includes(normalizedSceneName) || act.summary.includes(normalizedSceneName), ) ); }); } function buildSceneActParticipantText( act: SceneActBlueprint, roleById: Map< string, | CustomWorldProfile['playableNpcs'][number] | CustomWorldProfile['storyNpcs'][number] >, ) { const primaryRoleName = roleById.get(act.primaryNpcId)?.name?.trim() || ''; const supportRoleNames = act.encounterNpcIds .filter((roleId) => roleId !== act.primaryNpcId) .map((roleId) => roleById.get(roleId)?.name?.trim() || '') .filter(Boolean); return compactTextList([ primaryRoleName ? `主角色:${primaryRoleName}` : '', supportRoleNames.length > 0 ? `相遇角色:${supportRoleNames.join('、')}` : '', ]).join(';'); } function buildSceneChapterSearchText( sceneChapters: SceneChapterBlueprint[], roleById: Map< string, | CustomWorldProfile['playableNpcs'][number] | CustomWorldProfile['storyNpcs'][number] >, ) { return sceneChapters .flatMap((chapter) => [ chapter.title, chapter.summary, ...chapter.acts.flatMap((act) => [ act.title, act.summary, act.actGoal, act.transitionHook, buildSceneActParticipantText(act, roleById), ]), ]) .filter(Boolean) .join(' '); } function resolveSceneCardImage(params: { sceneImageSrc?: string | null; sceneChapters: SceneChapterBlueprint[]; }) { const firstActImageSrc = params.sceneChapters .flatMap((chapter) => chapter.acts) .map((act) => act.backgroundImageSrc?.trim() || '') .find(Boolean) || ''; return firstActImageSrc || params.sceneImageSrc?.trim() || ''; } function collectSceneActImagePreviews(sceneChapters: SceneChapterBlueprint[]) { return sceneChapters.flatMap((chapter) => chapter.acts .map((act, index) => ({ id: act.id.trim() || `${chapter.id}-act-${index}`, title: act.title.trim() || `第${index + 1}幕`, imageSrc: act.backgroundImageSrc?.trim() || '', })) .filter((act) => act.imageSrc), ); } function buildFallbackSceneActImagePreviews(params: { sceneChapters: SceneChapterBlueprint[]; sceneImageSrc?: string | null; }) { const actPreviews = collectSceneActImagePreviews(params.sceneChapters); const sceneImageSrc = params.sceneImageSrc?.trim() || ''; if (actPreviews.length > 0 || !sceneImageSrc) { return actPreviews; } // 中文注释:旧草稿可能只把开局场景图写在 camp.imageSrc,尚未回填到每一幕;目录侧先用场景图兜底,避免开局场景看起来没有幕图片。 return [1, 2, 3].map((actNumber) => ({ id: `fallback-scene-act-${actNumber}`, title: `第${actNumber}幕`, imageSrc: sceneImageSrc, })); } function SceneActPreviewStrip({ acts, sceneName, }: { acts: Array<{ id: string; title: string; imageSrc: string }>; sceneName: string; }) { if (acts.length <= 0) return null; return (
{acts.map((act) => (
))}
); } function CatalogCard({ title, description, media, badge, isSelectionMode, isSelected, onClick, layout = 'stacked', mediaClassName, disabled = false, actions, }: { title: string; description: string; media: ReactNode; badge?: ReactNode; isSelectionMode: boolean; isSelected: boolean; onClick: () => void; layout?: 'stacked' | 'compact'; mediaClassName?: string; disabled?: boolean; actions?: ReactNode; }) { const selectionBadge = isSelectionMode ? (
{isSelected ? '已选' : '选择'}
) : null; if (layout === 'compact') { return (
{media}
{title}
{badge} {selectionBadge}
{description || '暂无描述'}
{actions ?
{actions}
: null}
); } return (
{media}
{title}
{badge} {selectionBadge}
{description || '暂无描述'}
{actions ?
{actions}
: null}
); } 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) { return values.map((value) => value?.trim() ?? '').filter(Boolean); } function buildPlayableRoleCardDescription( role: CustomWorldProfile['playableNpcs'][number], ) { const summary = role.description.trim() || role.backstoryReveal.publicSummary.trim() || role.backstory.trim() || role.motivation.trim(); return compactTextList([role.title || role.role, summary]).join(' / '); } function resolvePlayableRolePreviewImage( role: CustomWorldProfile['playableNpcs'][number], previewCharacter: Character | null, ) { if (role.imageSrc?.trim()) { return role.imageSrc; } if (previewCharacter?.portrait?.trim()) { return previewCharacter.portrait; } if (previewCharacter?.avatar?.trim()) { return previewCharacter.avatar; } return ''; } function toText(value: unknown) { return typeof value === 'string' ? value.trim() : ''; } function toTextArray(value: unknown) { return Array.isArray(value) ? value.map((item) => toText(item)).filter(Boolean) : []; } function toRecord(value: unknown) { return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record) : null; } function buildRelationshipSeedText(value: unknown) { const record = toRecord(value); if (!record) { return ''; } return compactTextList([ toText(record.name), toText(record.role), toText(record.relationToPlayer) ? `与玩家:${toText(record.relationToPlayer)}` : '', toText(record.hiddenHook) ? `代价/暗线:${toText(record.hiddenHook)}` : '', ]).join(';'); } function buildKeyRelationshipText(value: KeyRelationshipValue) { return compactTextList([ value.pairs, value.relationshipType, value.secretOrCost ? `代价/秘密:${value.secretOrCost}` : '', ]).join(';'); } function buildAnchorContentFromProfileFallback( profile: CustomWorldProfile, ): EightAnchorContent { const creatorIntent = normalizeCustomWorldCreatorIntent(profile.creatorIntent); const relationshipSeed = creatorIntent?.keyCharacters[0] ?? null; return { worldPromise: { hook: creatorIntent?.worldHook || profile.anchorPack?.worldSummary || profile.summary, differentiator: profile.subtitle || profile.settingText, desiredExperience: compactTextList([ creatorIntent?.toneDirectives.join('、') || '', profile.tone, ]).join(';') || profile.tone, }, playerFantasy: { playerRole: creatorIntent?.playerPremise || profile.playerGoal, corePursuit: profile.playerGoal, fearOfLoss: relationshipSeed?.hiddenHook || creatorIntent?.coreConflicts[0] || profile.coreConflicts[0] || '', }, themeBoundary: { toneKeywords: compactTextList([ creatorIntent?.themeKeywords.join('、') || '', creatorIntent?.toneDirectives.join('、') || '', ]), aestheticDirectives: compactTextList([profile.tone, profile.subtitle]), forbiddenDirectives: creatorIntent?.forbiddenDirectives ?? [], }, playerEntryPoint: { openingIdentity: creatorIntent?.playerPremise || '', openingProblem: creatorIntent?.openingSituation || profile.coreConflicts[0] || '', entryMotivation: profile.playerGoal, }, coreConflict: { surfaceConflicts: creatorIntent?.coreConflicts.length ? creatorIntent.coreConflicts : profile.coreConflicts, hiddenCrisis: relationshipSeed?.hiddenHook || profile.summary || profile.settingText, firstTouchedConflict: creatorIntent?.openingSituation || profile.coreConflicts[0] || profile.playerGoal, }, keyRelationships: relationshipSeed ? [ { pairs: compactTextList([ relationshipSeed.name, relationshipSeed.role, ]).join(' · '), relationshipType: relationshipSeed.relationToPlayer || '', secretOrCost: relationshipSeed.hiddenHook || '', }, ] : [], hiddenLines: { hiddenTruths: compactTextList([ relationshipSeed?.hiddenHook || '', profile.summary, ]), misdirectionHints: compactTextList([ profile.subtitle, profile.majorFactions[0] || '', ]), revealPacing: creatorIntent?.openingSituation || profile.coreConflicts[0] || profile.playerGoal, }, iconicElements: { iconicMotifs: creatorIntent?.iconicElements.length ? creatorIntent.iconicElements : compactTextList([ profile.anchorPack?.motifDirectives.join('、') || '', profile.landmarks[0]?.name || '', ]), institutionsOrArtifacts: compactTextList([ profile.camp?.name || '', profile.majorFactions[0] || '', ]), hardRules: compactTextList([profile.playerGoal, profile.coreConflicts[0] || '']), }, }; } function getProfileAnchorContent(profile: CustomWorldProfile) { const anchorContentRecord = profile.anchorContent; if (!anchorContentRecord) { return buildAnchorContentFromProfileFallback(profile); } const worldPromiseRecord = toRecord(anchorContentRecord.worldPromise); const playerFantasyRecord = toRecord(anchorContentRecord.playerFantasy); const themeBoundaryRecord = toRecord(anchorContentRecord.themeBoundary); const playerEntryPointRecord = toRecord(anchorContentRecord.playerEntryPoint); const coreConflictRecord = toRecord(anchorContentRecord.coreConflict); const hiddenLinesRecord = toRecord(anchorContentRecord.hiddenLines); const iconicElementsRecord = toRecord(anchorContentRecord.iconicElements); return { worldPromise: worldPromiseRecord ? { hook: toText(worldPromiseRecord.hook), differentiator: toText(worldPromiseRecord.differentiator), desiredExperience: toText(worldPromiseRecord.desiredExperience), } : null, playerFantasy: playerFantasyRecord ? { playerRole: toText(playerFantasyRecord.playerRole), corePursuit: toText(playerFantasyRecord.corePursuit), fearOfLoss: toText(playerFantasyRecord.fearOfLoss), } : null, themeBoundary: themeBoundaryRecord ? { toneKeywords: toTextArray(themeBoundaryRecord.toneKeywords), aestheticDirectives: toTextArray( themeBoundaryRecord.aestheticDirectives, ), forbiddenDirectives: toTextArray(themeBoundaryRecord.forbiddenDirectives), } : null, playerEntryPoint: playerEntryPointRecord ? { openingIdentity: toText(playerEntryPointRecord.openingIdentity), openingProblem: toText(playerEntryPointRecord.openingProblem), entryMotivation: toText(playerEntryPointRecord.entryMotivation), } : null, coreConflict: coreConflictRecord ? { surfaceConflicts: toTextArray(coreConflictRecord.surfaceConflicts), hiddenCrisis: toText(coreConflictRecord.hiddenCrisis), firstTouchedConflict: toText(coreConflictRecord.firstTouchedConflict), } : null, keyRelationships: Array.isArray(anchorContentRecord.keyRelationships) ? anchorContentRecord.keyRelationships .map((entry) => toRecord(entry)) .filter(Boolean) .map((entry) => ({ pairs: toText(entry?.pairs), relationshipType: toText(entry?.relationshipType), secretOrCost: toText(entry?.secretOrCost), })) : [], hiddenLines: hiddenLinesRecord ? { hiddenTruths: toTextArray(hiddenLinesRecord.hiddenTruths), misdirectionHints: toTextArray(hiddenLinesRecord.misdirectionHints), revealPacing: toText(hiddenLinesRecord.revealPacing), } : null, iconicElements: iconicElementsRecord ? { iconicMotifs: toTextArray(iconicElementsRecord.iconicMotifs), institutionsOrArtifacts: toTextArray( iconicElementsRecord.institutionsOrArtifacts, ), hardRules: toTextArray(iconicElementsRecord.hardRules), } : null, } satisfies EightAnchorContent; } function buildOpeningSceneSearchText( profile: CustomWorldProfile, campScene: ReturnType, ) { return [ campScene.name, campScene.description, profile.playerGoal, profile.summary, '开局场景', '开局归处', ].join(' '); } function buildStructuredFoundationEntries(profile: CustomWorldProfile) { const creatorIntent = normalizeCustomWorldCreatorIntent(profile.creatorIntent); const anchorContent = getProfileAnchorContent(profile); const fallbackRelationshipText = buildRelationshipSeedText(creatorIntent?.keyCharacters[0]) || profile.playableNpcs[0]?.relationshipHooks.join(';') || profile.storyNpcs[0]?.relationshipHooks.join(';') || ''; return [ { id: 'world-promise', label: '世界承诺', value: compactTextList([ anchorContent.worldPromise?.hook || '', anchorContent.worldPromise?.differentiator || '', anchorContent.worldPromise?.desiredExperience || '', ]).join(';'), }, { id: 'player-fantasy', label: '玩家幻想', value: compactTextList([ anchorContent.playerFantasy?.playerRole || '', anchorContent.playerFantasy?.corePursuit || '', anchorContent.playerFantasy?.fearOfLoss || '', ]).join(';'), }, { id: 'theme-boundary', label: '主题边界', value: compactTextList([ anchorContent.themeBoundary?.toneKeywords.join('、') || '', anchorContent.themeBoundary?.aestheticDirectives.join('、') || '', anchorContent.themeBoundary?.forbiddenDirectives.length ? `避免:${anchorContent.themeBoundary.forbiddenDirectives.join('、')}` : '', ]).join(';'), }, { id: 'player-entry-point', label: '玩家切入口', value: compactTextList([ anchorContent.playerEntryPoint?.openingIdentity || '', anchorContent.playerEntryPoint?.openingProblem || '', anchorContent.playerEntryPoint?.entryMotivation || '', ]).join(';'), }, { id: 'core-conflict', label: '核心冲突', value: compactTextList([ anchorContent.coreConflict?.surfaceConflicts.join('、') || '', anchorContent.coreConflict?.hiddenCrisis || '', anchorContent.coreConflict?.firstTouchedConflict || '', ]).join(';'), }, { id: 'key-relationships', label: '关键关系', value: anchorContent.keyRelationships.map(buildKeyRelationshipText).join('\n') || fallbackRelationshipText, }, { id: 'hidden-lines', label: '暗线与揭示', value: compactTextList([ anchorContent.hiddenLines?.hiddenTruths.join('、') || '', anchorContent.hiddenLines?.misdirectionHints.join('、') || '', anchorContent.hiddenLines?.revealPacing || '', ]).join(';'), }, { id: 'iconic-elements', label: '标志元素', value: compactTextList([ anchorContent.iconicElements?.iconicMotifs.join('、') || '', anchorContent.iconicElements?.institutionsOrArtifacts.join('、') || '', anchorContent.iconicElements?.hardRules.join('、') || '', ]).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, landmarkById: Map, ) { return [ landmark.name, landmark.description, ...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(null); const [searchDraft, setSearchDraft] = useState(''); const [bulkDeleteMode, setBulkDeleteMode] = useState( null, ); const [selectedBulkIds, setSelectedBulkIds] = useState([]); const deferredSearch = useDeferredValue(searchDraft.trim()); const storyNpcById = useMemo( () => new Map(profile.storyNpcs.map((npc) => [npc.id, npc])), [profile.storyNpcs], ); const roleById = useMemo( () => new Map( [...profile.playableNpcs, ...profile.storyNpcs].map((role) => [ role.id, role, ]), ), [profile.playableNpcs, 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 normalizedCreatorIntent = useMemo( () => normalizeCustomWorldCreatorIntent(profile.creatorIntent), [profile.creatorIntent], ); const filteredSceneEntries = useMemo(() => { const openingSceneChapters = resolveSceneEntrySceneChapters({ sceneChapters: profile.sceneChapterBlueprints, sceneId: resolvedCampScene.id, sceneName: resolvedCampScene.name, }); const openingSceneImageSrc = resolveSceneCardImage({ sceneImageSrc: resolvedCampImageSrc, sceneChapters: openingSceneChapters, }); const openingSceneEntry = { id: resolvedCampScene.id, kind: 'camp' as const, name: resolvedCampScene.name, description: resolvedCampScene.description, imageSrc: openingSceneImageSrc, sceneChapters: openingSceneChapters, actPreviews: buildFallbackSceneActImagePreviews({ sceneChapters: openingSceneChapters, sceneImageSrc: openingSceneImageSrc, }), searchText: [ buildOpeningSceneSearchText(profile, resolvedCampScene), buildSceneChapterSearchText(openingSceneChapters, roleById), ] .filter(Boolean) .join(' '), }; const landmarkEntries = profile.landmarks.map((landmark) => { const sceneChapters = resolveSceneEntrySceneChapters({ sceneChapters: profile.sceneChapterBlueprints, sceneId: landmark.id, sceneName: landmark.name, }); const sceneImageSrc = resolveSceneCardImage({ sceneImageSrc: landmarkImageById.get(landmark.id) ?? landmark.imageSrc, sceneChapters, }); return { id: landmark.id, kind: 'landmark' as const, name: landmark.name, description: landmark.description, imageSrc: sceneImageSrc, sceneChapters, actPreviews: buildFallbackSceneActImagePreviews({ sceneChapters, sceneImageSrc, }), searchText: [ buildLandmarkSearchText(landmark, storyNpcById, landmarkById), buildSceneChapterSearchText(sceneChapters, roleById), ] .filter(Boolean) .join(' '), }; }); 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, landmarkById, landmarkImageById, profile, recentLandmarkIdSet, resolvedCampImageSrc, resolvedCampScene, roleById, 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; 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 (
世界档案
{profile.name}
{profile.subtitle}
{RESULT_TABS.map((tab) => (
))}
{activeTab !== 'world' ? (
{isBulkDeleteMode ? ( <>
已选 {selectedBulkIds.length}
取消 删除选中 ) : ( <> {!readOnly && createActionLabel && onCreateAction ? ( {createActionLabel} ) : null} {!readOnly && bulkDeleteTab && ((bulkDeleteTab === 'story' && onDeleteStoryNpcs) || (bulkDeleteTab === 'landmarks' && onDeleteLandmarks)) ? ( startBulkDelete(bulkDeleteTab)} tone="rose" > 批量删除 ) : null} )}
) : null}
{activeTab === 'world' ? (
{profile.playableNpcs.length}
可扮演角色
{profile.storyNpcs.length}
场景角色
{profile.landmarks.length + 1}
场景
onEditTarget({ kind: 'world' })} tone="sky" > 查看详情 ) : ( onEditTarget({ kind: 'world' })} tone="sky" > 编辑 ) } >

{profile.summary}

主线目标:{profile.playerGoal}
世界基调:{profile.tone}
onEditTarget({ kind: 'world' })} tone="sky" > 查看详情 ) : ( onEditTarget({ kind: 'world' })} tone="sky" > 编辑 ) } >
{structuredFoundationEntries.map((entry) => (
{entry.label}
{entry.value || '待补充'}
))}
) : null} {activeTab === 'playable' ? (
{pendingGeneratedEntity?.kind === 'playable' ? ( ) : null} {filteredPlayable.length === 0 ? ( ) : ( filteredPlayable.map((role, index) => { const previewCharacter = previewCharacterById.get(role.id) ?? null; const previewImageSrc = resolvePlayableRolePreviewImage( role, previewCharacter, ); const description = buildPlayableRoleCardDescription(role); return (
: null} isSelectionMode={false} isSelected={false} layout="compact" mediaClassName="h-[4.75rem] w-[4.75rem] sm:h-[5.25rem] sm:w-[5.25rem] xl:h-[5.75rem] xl:w-[5.75rem]" onClick={() => onEditTarget({ kind: 'playable', mode: 'edit', id: role.id, }) } media={ role.imageSrc?.trim() ? ( ) : previewCharacter ? ( ) : previewImageSrc ? ( ) : (
{role.name.slice(0, 4) || '角色'}
) } />
{lockedCharacterNames.has(role.name.trim()) ? ( 创作者锁定 ) : null} 初始好感 {role.initialAffinity} {role.generatedVisualAssetId ? ( 已生成主图 ) : null} {role.tags.slice(0, 2).map((tag) => ( {tag} ))} {!readOnly ? (
removePlayable(role.id, role.name)} tone="rose" > 删除
) : null}
); }) )}
) : null} {activeTab === 'story' ? (
{pendingGeneratedEntity?.kind === 'story' ? ( ) : null} {filteredStory.length === 0 ? ( ) : ( filteredStory.map((npc, index) => (
: 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] xl:h-[5.75rem] xl:w-[5.75rem]" onClick={() => isBulkDeleteMode ? toggleBulkSelected(npc.id) : readOnly ? onEditTarget({ kind: 'story', mode: 'edit', id: npc.id, }) : onEditTarget({ kind: 'story', mode: 'edit', id: npc.id, }) } media={ } />
)) )}
) : null} {activeTab === 'landmarks' ? (
{pendingGeneratedEntity?.kind === 'landmark' ? ( ) : null} {filteredSceneEntries.length === 0 ? ( ) : ( filteredSceneEntries.map((scene, index) => ( ) : 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={ } actions={ } disabled={scene.kind === 'camp' && isBulkDeleteMode} /> )) )}
) : null}
); }