Files
Genarrative/src/components/CustomWorldEntityCatalog.tsx
kdletters 0a4ccdf45c 继续收口平台分段与泥点确认
新增泥点确认状态机共享 hook 并接入拼图与抓大鹅工作台

将首页发现页与个人中心剩余切换条收口到 PlatformSegmentedTabs

统一平台弹窗 header 关闭入口并补齐相关测试

更新前端组件收口文档与团队决策记录
2026-06-11 01:30:13 +08:00

1477 lines
46 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
type ReactNode,
useDeferredValue,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { getCustomWorldSceneRelativePositionLabel } from '../data/customWorldSceneGraph';
import { normalizeCustomWorldCreatorIntent } from '../services/customWorldCreatorIntent';
import {
buildCustomWorldFoundationEntries,
parseFoundationTagText,
} from '../services/customWorldFoundationEntries';
import { buildCustomWorldScenePresentations } from '../services/customWorldScenePresentation';
import {
AnimationState,
type Character,
type CustomWorldOpeningCgProfile,
type CustomWorldProfile,
type SceneActBlueprint,
type SceneChapterBlueprint,
} from '../types';
import { CharacterAnimator } from './CharacterAnimator';
import { PlatformAcknowledgeStatusDialog } from './common/PlatformAcknowledgeStatusDialog';
import { PlatformActionButton } from './common/PlatformActionButton';
import { PlatformDangerConfirmDialog } from './common/PlatformDangerConfirmDialog';
import { PlatformEmptyState } from './common/PlatformEmptyState';
import { PlatformMediaFrame } from './common/PlatformMediaFrame';
import { PlatformPillBadge } from './common/PlatformPillBadge';
import { PlatformProgressBar } from './common/PlatformProgressBar';
import { PlatformSegmentedTabs } from './common/PlatformSegmentedTabs';
import { PlatformStatGrid } from './common/PlatformStatGrid';
import { PlatformStatusMessage } from './common/PlatformStatusMessage';
import { PlatformSubpanel } from './common/PlatformSubpanel';
import { PlatformTextField } from './common/PlatformTextField';
import { CustomWorldNpcPortrait } from './CustomWorldNpcVisualEditor';
import { ResolvedAssetImage } from './ResolvedAssetImage';
import { ResolvedAssetVideo } from './ResolvedAssetVideo';
import type { RpgCreationEditorTarget } from './rpg-creation-editor/RpgCreationEntityEditorModal';
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;
openingCgGenerating?: boolean;
openingCgPhaseLabel?: string | null;
openingCgGenerateDisabled?: boolean;
onGenerateOpeningCg?: () => void;
pendingGeneratedEntity?: PendingGeneratedEntity | null;
recentGeneratedIds?: RecentGeneratedIds;
readOnly?: boolean;
}
const RESULT_TABS: Array<{ id: ResultTab; label: string }> = [
{ id: 'world', label: '世界' },
{ id: 'landmarks', label: '场景' },
{ id: 'playable', label: '可扮演角色' },
{ id: 'story', label: '场景角色' },
];
function Section({
title,
subtitle,
badge,
actions,
children,
className = '',
}: {
title: string;
subtitle?: string;
badge?: ReactNode;
actions?: ReactNode;
children: ReactNode;
className?: string;
}) {
return (
<div
className={`platform-surface platform-surface--soft px-3.5 py-3 ${className}`}
>
<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: React.MouseEventHandler<HTMLButtonElement>;
children: ReactNode;
tone?: 'default' | 'sky' | 'rose';
disabled?: boolean;
actions?: ReactNode;
}) {
return (
<PlatformActionButton
onClick={onClick}
disabled={disabled}
tone={tone === 'sky' ? 'primary' : tone === 'rose' ? 'danger' : 'ghost'}
shape="pill"
size="xs"
className="min-h-0 py-1 text-[11px]"
>
{children}
</PlatformActionButton>
);
}
function SearchBox({
value,
onChange,
placeholder,
}: {
value: string;
onChange: (value: string) => void;
placeholder: string;
}) {
return (
<PlatformTextField
value={value}
onChange={(event) => onChange(event.target.value)}
placeholder={placeholder}
density="compact"
className="rounded-2xl bg-[var(--platform-subpanel-fill)] px-3 py-2 placeholder:text-[var(--platform-text-soft)]"
/>
);
}
function EmptyState({ title }: { title: string }) {
return (
<PlatformEmptyState
surface="dashed"
size="compact"
className="rounded-2xl px-5 py-6 text-center"
>
<div className="text-sm text-[var(--platform-text-base)]">{title}</div>
</PlatformEmptyState>
);
}
function buildFallbackRenderKey(
value: string | null | undefined,
fallback: string,
) {
const normalizedValue = value?.trim();
return normalizedValue ? normalizedValue : fallback;
}
function NewBadge() {
return (
<PlatformPillBadge tone="warning" size="xxs" className="font-semibold">
</PlatformPillBadge>
);
}
function PendingEntityCard({
title,
phaseLabel,
progress,
}: {
title: string;
phaseLabel: string;
progress: number;
}) {
return (
<PlatformStatusMessage
tone="info"
surface="platform"
size="md"
className="rounded-[1.35rem] py-4"
>
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-sm font-semibold text-[var(--platform-text-strong)]">
{title}
</div>
<div className="mt-1 text-xs leading-6">{phaseLabel}</div>
</div>
<PlatformPillBadge tone="cool" size="xxs">
{Math.round(progress)}%
</PlatformPillBadge>
</div>
<PlatformProgressBar
value={progress}
minVisibleValue={6}
size="sm"
ariaLabel={`${title} 进度`}
className="mt-3"
fillClassName="bg-[var(--platform-button-primary-solid)]"
/>
</PlatformStatusMessage>
);
}
function OpeningCgPreview({
openingCg,
isGenerating,
phaseLabel,
generateDisabled,
readOnly,
onGenerate,
}: {
openingCg?: CustomWorldOpeningCgProfile | null;
isGenerating: boolean;
phaseLabel?: string | null;
generateDisabled?: boolean;
readOnly: boolean;
onGenerate?: () => void;
}) {
const hasVideo = Boolean(openingCg?.videoSrc?.trim());
const buttonLabel = hasVideo ? '重新生成' : '生成';
return (
<div className="space-y-3">
<div className="overflow-hidden rounded-2xl border border-[var(--platform-subpanel-border)] bg-black/35 aspect-video">
{hasVideo ? (
<ResolvedAssetVideo
src={openingCg?.videoSrc}
className="h-full w-full object-cover"
controls
playsInline
preload="metadata"
/>
) : openingCg?.storyboardImageSrc ? (
<ResolvedAssetImage
src={openingCg.storyboardImageSrc}
alt="开局 CG 故事板"
className="h-full w-full object-cover"
/>
) : (
<div className="flex h-full w-full items-center justify-center text-sm font-semibold tracking-[0.18em] text-zinc-500">
CG
</div>
)}
</div>
<div className="flex flex-wrap items-center gap-2">
<PlatformPillBadge tone="neutral" size="xxs">
80
</PlatformPillBadge>
<PlatformPillBadge tone="neutral" size="xxs">
10
</PlatformPillBadge>
{hasVideo ? (
<PlatformPillBadge tone="success" size="xxs">
</PlatformPillBadge>
) : null}
{!readOnly && onGenerate ? (
<div className="ml-auto">
<SmallButton
onClick={onGenerate}
tone="sky"
disabled={isGenerating || generateDisabled}
>
{isGenerating ? (phaseLabel ?? '生成中') : buttonLabel}
</SmallButton>
</div>
) : null}
</div>
{isGenerating ? (
<PlatformProgressBar
value={66}
ariaLabel={`${buttonLabel} 进度`}
indeterminate
fillClassName="animate-pulse bg-[linear-gradient(90deg,#df7f40_0%,#cc754c_52%,#eaccb3_100%)]"
/>
) : null}
{openingCg?.status === 'failed' && openingCg.errorMessage ? (
<PlatformStatusMessage
tone="error"
surface="platform"
size="xs"
className="rounded-2xl leading-5"
>
{openingCg.errorMessage}
</PlatformStatusMessage>
) : null}
</div>
);
}
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.sceneTaskDescription,
...chapter.acts.flatMap((act) => [
act.title,
act.summary,
act.actGoal,
act.transitionHook,
buildSceneActParticipantText(act, roleById),
]),
])
.filter(Boolean)
.join(' ');
}
function buildSceneTaskDescriptionText(sceneChapters: SceneChapterBlueprint[]) {
return (
compactTextList(
sceneChapters.map((chapter) => chapter.sceneTaskDescription),
)[0] ?? ''
);
}
function SceneActPreviewStrip({
acts,
sceneName,
}: {
acts: Array<{ id: string; title: string; imageSrc: string }>;
sceneName: string;
}) {
if (acts.length <= 0) return null;
return (
<div className="flex w-full gap-1.5 overflow-x-auto pb-0.5">
{acts.map((act) => (
<PlatformSubpanel
key={act.id}
as="div"
padding="none"
radius="sm"
className="h-12 w-[5.25rem] shrink-0 overflow-hidden rounded-xl"
aria-label={`${sceneName}-${act.title}预览`}
>
<ResolvedAssetImage
src={act.imageSrc}
alt={`${sceneName}-${act.title}`}
className="h-full w-full object-cover"
/>
</PlatformSubpanel>
))}
</div>
);
}
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 ? (
<PlatformPillBadge
tone={isSelected ? 'danger' : 'muted'}
size="xs"
className="shrink-0 py-1 text-[10px]"
>
{isSelected ? '已选' : '选择'}
</PlatformPillBadge>
) : null;
if (layout === 'compact') {
return (
<PlatformSubpanel
as="button"
tabIndex={disabled ? -1 : 0}
onClick={disabled ? undefined : onClick}
disabled={disabled}
aria-disabled={disabled}
padding="none"
radius="md"
surface={isSelected ? 'danger' : 'platform'}
className="w-full rounded-[1.3rem] p-2.5 text-left transition-colors xl:p-3"
>
<div className="flex items-start gap-3 xl:gap-3.5">
<PlatformSubpanel
as="div"
padding="none"
radius="sm"
className={`shrink-0 overflow-hidden rounded-[1rem] ${mediaClassName ?? 'h-[4.75rem] w-[4.75rem]'}`}
>
{media}
</PlatformSubpanel>
<div className="min-w-0 flex-1 xl:min-h-[5.6rem]">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 text-[15px] font-semibold leading-5 text-white xl:line-clamp-1">
{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 xl:line-clamp-2">
{description || '暂无描述'}
</div>
{actions ? (
<div className="mt-2 flex flex-wrap gap-2">{actions}</div>
) : null}
</div>
</div>
</PlatformSubpanel>
);
}
return (
<PlatformSubpanel
as="button"
tabIndex={disabled ? -1 : 0}
onClick={disabled ? undefined : onClick}
disabled={disabled}
aria-disabled={disabled}
padding="none"
radius="md"
surface={isSelected ? 'danger' : 'platform'}
className="w-full rounded-[1.4rem] p-3 text-left transition-colors"
>
<div className="space-y-3">
<PlatformSubpanel
as="div"
padding="none"
radius="sm"
className={`overflow-hidden rounded-[1.1rem] ${mediaClassName ?? ''}`}
>
{media}
</PlatformSubpanel>
<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>
{actions ? <div className="flex flex-wrap gap-2">{actions}</div> : null}
</div>
</PlatformSubpanel>
);
}
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 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 buildOpeningSceneSearchText(
profile: CustomWorldProfile,
campScene: { name: string; description: string },
) {
return [
campScene.name,
campScene.description,
profile.playerGoal,
profile.summary,
'开局场景',
'开局归处',
].join(' ');
}
type CatalogRole =
| CustomWorldProfile['playableNpcs'][number]
| CustomWorldProfile['storyNpcs'][number];
type BulkDeleteTab = 'story' | 'landmarks';
type EntityCatalogConfirmState =
| { kind: 'minimum-playable' }
| { kind: 'delete-playable'; id: string; name: string }
| { kind: 'bulk-delete'; tab: BulkDeleteTab; ids: string[]; label: string };
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.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,
openingCgGenerating = false,
openingCgPhaseLabel = null,
openingCgGenerateDisabled = false,
onGenerateOpeningCg,
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 [confirmState, setConfirmState] =
useState<EntityCatalogConfirmState | null>(null);
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 scenePresentations = useMemo(
() => buildCustomWorldScenePresentations(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 structuredFoundationEntries = useMemo(
() => buildCustomWorldFoundationEntries(profile),
[profile],
);
const normalizedCreatorIntent = useMemo(
() => normalizeCustomWorldCreatorIntent(profile.creatorIntent),
[profile.creatorIntent],
);
const attributeSlots = Array.isArray(profile.attributeSchema?.slots)
? profile.attributeSchema.slots
: [];
const filteredSceneEntries = useMemo(() => {
const openingSceneEntry = {
...scenePresentations.camp,
sceneTaskDescription: buildSceneTaskDescriptionText(
scenePresentations.camp.sceneChapters,
),
searchText: [
buildOpeningSceneSearchText(profile, scenePresentations.camp),
buildSceneChapterSearchText(
scenePresentations.camp.sceneChapters,
roleById,
),
]
.filter(Boolean)
.join(' '),
};
const landmarkEntries = scenePresentations.landmarks.map((scene) => {
const landmark = profile.landmarks.find((entry) => entry.id === scene.id);
return {
...scene,
sceneTaskDescription: buildSceneTaskDescriptionText(
scene.sceneChapters,
),
searchText: [
landmark
? buildLandmarkSearchText(landmark, storyNpcById, landmarkById)
: '',
buildSceneChapterSearchText(scene.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,
profile,
recentLandmarkIdSet,
roleById,
scenePresentations,
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 resultTabItems = useMemo(
() =>
RESULT_TABS.map((tab) => ({
id: tab.id,
ariaLabel: `${tab.label} ${counts[tab.id]}`,
label: (
<div className="text-left">
<div className="font-semibold">{tab.label}</div>
<div className="mt-1 text-[10px] tracking-[0.16em] text-[var(--platform-text-soft)]">
{counts[tab.id]}
</div>
</div>
),
})),
[counts],
);
const worldStatItems = [
{ label: '可扮演角色', value: profile.playableNpcs.length },
{ label: '场景角色', value: profile.storyNpcs.length },
{ label: '场景', value: profile.landmarks.length + 1 },
];
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) {
setConfirmState({ kind: 'minimum-playable' });
return;
}
setConfirmState({ kind: 'delete-playable', id, name });
};
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' ? '场景角色' : '场景';
setConfirmState({
kind: 'bulk-delete',
tab: bulkDeleteTab,
ids: selectedBulkIds,
label,
});
};
const closeConfirmDialog = () => {
setConfirmState(null);
};
const executeConfirmAction = () => {
if (!confirmState) {
return;
}
if (confirmState.kind === 'minimum-playable') {
closeConfirmDialog();
return;
}
if (confirmState.kind === 'delete-playable') {
onProfileChange({
...profile,
playableNpcs: profile.playableNpcs.filter(
(role) => role.id !== confirmState.id,
),
});
closeConfirmDialog();
return;
}
if (confirmState.tab === 'story') {
onDeleteStoryNpcs?.(confirmState.ids);
} else {
onDeleteLandmarks?.(confirmState.ids);
}
cancelBulkDelete();
closeConfirmDialog();
};
const confirmDialogConfig = (() => {
if (!confirmState || confirmState.kind === 'minimum-playable') {
return null;
}
if (confirmState.kind === 'delete-playable') {
return {
title: '删除角色',
confirmLabel: '确认删除',
body: `确认删除可扮演角色「${confirmState.name}」吗?`,
};
}
return {
title: '批量删除',
confirmLabel: '确认删除',
body: `确认批量删除 ${confirmState.ids.length}${confirmState.label}吗?`,
};
})();
return (
<div
ref={scrollContainerRef}
className="h-full min-h-0 space-y-3 overflow-y-auto overscroll-contain pr-1 scrollbar-hide xl:space-y-4 xl:pr-2 2xl:space-y-5 2xl:pr-3"
>
<div className="px-1 pb-1 text-center xl:flex xl:items-end xl:justify-between xl:gap-6 xl:rounded-[2rem] xl:border xl:border-[var(--platform-subpanel-border)] xl:bg-white/55 xl:px-6 xl:py-3 xl:text-left xl:shadow-[0_18px_70px_rgba(112,57,30,0.08)] xl:backdrop-blur-sm 2xl:px-7">
<div className="text-[11px] font-bold tracking-[0.28em] text-zinc-500">
</div>
<div className="min-w-0 xl:flex xl:flex-1 xl:items-end xl:justify-between xl:gap-5">
<div className="mt-2 truncate text-3xl font-black text-[var(--platform-text-strong)] sm:text-[2.2rem] xl:mt-0 xl:text-[2rem] 2xl:text-[2.25rem]">
{profile.name}
</div>
<div className="mt-2 min-w-0 text-sm tracking-[0.18em] text-zinc-400 xl:mt-0 xl:max-w-[34rem] xl:truncate xl:text-right xl:text-xs">
{profile.subtitle}
</div>
</div>
</div>
<div className="platform-sticky-fade sticky top-0 z-10 -mx-1 space-y-3 px-1 pb-3 pt-1 backdrop-blur-sm xl:rounded-[1.75rem] xl:border xl:border-[var(--platform-subpanel-border)] xl:bg-white/70 xl:px-4 xl:py-3 xl:shadow-[0_16px_48px_rgba(112,57,30,0.08)]">
<PlatformSegmentedTabs
items={resultTabItems}
activeId={activeTab}
onChange={onActiveTabChange}
layout="scroll"
gap="md"
frame="bare"
surface="transparent"
size="sm"
tone="neutral"
semantics="tabs"
ariaLabel="世界实体目录"
className="pb-1 xl:pb-0"
itemClassName={(_, active) =>
[
'platform-tab shrink-0 !min-h-0 !rounded-full !px-3 !py-2 xl:min-w-[5.25rem] xl:!px-4 xl:!py-2',
active ? 'platform-tab--active' : null,
]
.filter(Boolean)
.join(' ')
}
/>
{activeTab !== 'world' ? (
<div className="flex flex-col gap-2 sm:flex-row sm:items-center xl:gap-3">
<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 ? (
<>
<PlatformPillBadge tone="neutral" size="xs">
{selectedBulkIds.length}
</PlatformPillBadge>
<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' ? (
<div className="space-y-3 xl:grid xl:grid-cols-[minmax(18rem,0.82fr)_minmax(0,1fr)_minmax(24rem,1.08fr)] xl:items-start xl:gap-3 xl:space-y-0 2xl:gap-4">
<Section title="档案规模">
<PlatformStatGrid
items={worldStatItems}
columns="three"
density="compact"
surface="plain"
itemClassName="platform-subpanel rounded-xl py-3"
className="text-[11px] text-zinc-300"
/>
</Section>
<Section title="开局 CG">
<OpeningCgPreview
openingCg={profile.openingCg}
isGenerating={openingCgGenerating}
phaseLabel={openingCgPhaseLabel}
generateDisabled={openingCgGenerateDisabled}
readOnly={readOnly}
onGenerate={onGenerateOpeningCg}
/>
</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>
<PlatformStatusMessage
tone="warning"
surface="platform"
size="sm"
className="rounded-2xl py-3"
>
线{profile.playerGoal}
</PlatformStatusMessage>
<PlatformSubpanel
as="div"
radius="md"
padding="sm"
className="rounded-2xl px-3 py-3 text-zinc-300"
>
{profile.tone}
</PlatformSubpanel>
</div>
</Section>
<Section
title="基本设定"
className="xl:col-span-3"
actions={
readOnly ? (
<SmallButton
onClick={() => onEditTarget({ kind: 'foundation' })}
tone="sky"
>
</SmallButton>
) : (
<SmallButton
onClick={() => onEditTarget({ kind: 'foundation' })}
tone="sky"
>
</SmallButton>
)
}
>
<div className="space-y-3">
<PlatformSubpanel
as="div"
title="角色维度"
radius="md"
padding="md"
bodyClassName="mt-3 grid grid-cols-2 gap-2 sm:grid-cols-3 xl:grid-cols-6"
className="rounded-2xl px-4 py-4"
>
{attributeSlots.map((slot) => (
<PlatformSubpanel
key={slot.slotId}
as="div"
surface="dark"
radius="xs"
padding="sm"
className="bg-black/15"
>
<div className="text-sm font-semibold text-white">
{slot.name}
</div>
</PlatformSubpanel>
))}
</PlatformSubpanel>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{structuredFoundationEntries.map((entry) => (
<PlatformSubpanel
key={entry.id}
as="div"
title={entry.label}
radius="md"
padding="md"
className="rounded-2xl px-4 py-4"
bodyClassName={
entry.value ? 'mt-3 flex flex-wrap gap-2' : ''
}
>
{entry.value ? (
parseFoundationTagText(entry.value).map((tag, index) => (
<PlatformPillBadge
key={`${entry.id}-${index}-${tag}`}
tone="darkSoft"
size="sm"
className="leading-5"
>
{tag}
</PlatformPillBadge>
))
) : (
<div className="mt-2 text-sm leading-7 text-zinc-100">
</div>
)}
</PlatformSubpanel>
))}
</div>
</div>
</Section>
</div>
) : null}
{activeTab === 'playable' ? (
<div className="space-y-3 xl:grid xl:grid-cols-3 xl:gap-3 xl:space-y-0 2xl:grid-cols-4 2xl:gap-4">
{pendingGeneratedEntity?.kind === 'playable' ? (
<PendingEntityCard
title={pendingGeneratedEntity.title}
phaseLabel={pendingGeneratedEntity.phaseLabel}
progress={pendingGeneratedEntity.progress}
/>
) : null}
{filteredPlayable.length === 0 ? (
<EmptyState title="当前没有符合搜索条件的可扮演角色。" />
) : (
filteredPlayable.map((role, index) => {
const previewCharacter =
previewCharacterById.get(role.id) ?? null;
const previewImageSrc = resolvePlayableRolePreviewImage(
role,
previewCharacter,
);
const description = buildPlayableRoleCardDescription(role);
return (
<div
key={buildFallbackRenderKey(
role.id,
`playable-role-${index}-${role.name.trim() || 'unnamed'}`,
)}
className="space-y-2"
>
<CatalogCard
title={role.name}
description={description || '暂无描述'}
badge={
recentPlayableIdSet.has(role.id) ? <NewBadge /> : 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() ? (
<ResolvedAssetImage
src={role.imageSrc}
alt={role.name}
className="h-full w-full object-cover object-top"
/>
) : previewCharacter ? (
<CharacterAnimator
state={AnimationState.RUN}
character={previewCharacter}
className="h-full w-full"
imageClassName="object-bottom"
/>
) : previewImageSrc ? (
<ResolvedAssetImage
src={previewImageSrc}
alt={role.name}
className="h-full w-full object-cover object-top"
/>
) : (
<div className="flex h-full w-full items-center justify-center bg-[rgba(255,255,255,0.64)] px-3 text-center text-xs font-semibold tracking-[0.16em] text-[var(--platform-text-soft)]">
{role.name.slice(0, 4) || '角色'}
</div>
)
}
/>
<div className="flex flex-wrap items-center gap-2 px-1">
{lockedCharacterNames.has(role.name.trim()) ? (
<PlatformPillBadge tone="warning" size="xxs">
</PlatformPillBadge>
) : null}
<PlatformPillBadge tone="neutral" size="xxs">
{role.initialAffinity}
</PlatformPillBadge>
{role.generatedVisualAssetId ? (
<PlatformPillBadge tone="success" size="xxs">
</PlatformPillBadge>
) : null}
{role.tags.slice(0, 2).map((tag) => (
<PlatformPillBadge
key={`${role.id}-${tag}`}
tone="neutral"
size="xxs"
>
{tag}
</PlatformPillBadge>
))}
{!readOnly ? (
<div className="ml-auto">
<SmallButton
onClick={() => removePlayable(role.id, role.name)}
tone="rose"
>
</SmallButton>
</div>
) : null}
</div>
</div>
);
})
)}
</div>
) : null}
{activeTab === 'story' ? (
<div className="space-y-3 xl:grid xl:grid-cols-3 xl:gap-3 xl:space-y-0 2xl:grid-cols-4 2xl:gap-4">
{pendingGeneratedEntity?.kind === 'story' ? (
<PendingEntityCard
title={pendingGeneratedEntity.title}
phaseLabel={pendingGeneratedEntity.phaseLabel}
progress={pendingGeneratedEntity.progress}
/>
) : null}
{filteredStory.length === 0 ? (
<EmptyState title="当前没有符合搜索条件的场景角色。" />
) : (
filteredStory.map((npc, index) => (
<div
key={buildFallbackRenderKey(
npc.id,
`story-npc-${index}-${npc.name.trim() || 'unnamed'}`,
)}
>
<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] 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={
<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 xl:grid xl:grid-cols-3 xl:gap-3 xl:space-y-0 2xl:grid-cols-4 2xl:gap-4">
{pendingGeneratedEntity?.kind === 'landmark' ? (
<PendingEntityCard
title={pendingGeneratedEntity.title}
phaseLabel={pendingGeneratedEntity.phaseLabel}
progress={pendingGeneratedEntity.progress}
/>
) : null}
{filteredSceneEntries.length === 0 ? (
<EmptyState title="当前没有符合搜索条件的场景。" />
) : (
filteredSceneEntries.map((scene, index) => (
<CatalogCard
key={buildFallbackRenderKey(
scene.id,
`scene-entry-${index}-${scene.name.trim() || scene.kind}`,
)}
title={scene.name}
description={compactTextList([
scene.kind === 'camp'
? `开局场景 · ${scene.description}`
: scene.description,
scene.sceneTaskDescription,
]).join(' / ')}
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={
<PlatformMediaFrame
src={scene.imageSrc}
alt={scene.name}
fallbackLabel={scene.name.slice(0, 4) || '场景'}
aspect="landscape"
/>
}
actions={
<SceneActPreviewStrip
acts={scene.actPreviews}
sceneName={scene.name}
/>
}
disabled={scene.kind === 'camp' && isBulkDeleteMode}
/>
))
)}
</div>
) : null}
{confirmDialogConfig ? (
<PlatformDangerConfirmDialog
open
title={confirmDialogConfig.title}
onClose={closeConfirmDialog}
onConfirm={executeConfirmAction}
confirmLabel={confirmDialogConfig.confirmLabel}
>
{confirmDialogConfig.body}
</PlatformDangerConfirmDialog>
) : null}
{confirmState?.kind === 'minimum-playable' ? (
<PlatformAcknowledgeStatusDialog
status="error"
title="无法删除"
description="至少保留一个可扮演角色,才能正常进入自定义世界。"
onClose={closeConfirmDialog}
closeOnBackdrop={false}
/>
) : null}
</div>
);
}