Files
Genarrative/src/components/CustomWorldEntityCatalog.tsx
kdletters 1ad25e30f8 收口前端平台组件库能力
新增 PlatformUiKit 通用弹窗、按钮、状态、空态、媒体、表单和标签等公共组件
迁移结果页、创作工作台、认证入口、RPG 暗色面板和运行态弹窗的重复 UI chrome
补充组件测试、页面回归测试、技术文档和 Hermes 共享决策记录
2026-06-10 10:24:18 +08:00

1461 lines
45 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 { PlatformActionButton } from './common/PlatformActionButton';
import { PlatformEmptyState } from './common/PlatformEmptyState';
import { PlatformMediaFrame } from './common/PlatformMediaFrame';
import { PlatformPillBadge } from './common/PlatformPillBadge';
import { PlatformProgressBar } from './common/PlatformProgressBar';
import { PlatformStatGrid } from './common/PlatformStatGrid';
import { PlatformStatusMessage } from './common/PlatformStatusMessage';
import { PlatformSubpanel } from './common/PlatformSubpanel';
import { PlatformTextField } from './common/PlatformTextField';
import { UnifiedConfirmDialog } from './common/UnifiedConfirmDialog';
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 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) {
return null;
}
if (confirmState.kind === 'minimum-playable') {
return {
title: '无法删除',
confirmLabel: '知道了',
confirmTone: 'primary' as const,
showCancel: false,
body: '至少保留一个可扮演角色,才能正常进入自定义世界。',
};
}
if (confirmState.kind === 'delete-playable') {
return {
title: '删除角色',
confirmLabel: '确认删除',
confirmTone: 'danger' as const,
showCancel: true,
body: `确认删除可扮演角色「${confirmState.name}」吗?`,
};
}
return {
title: '批量删除',
confirmLabel: '确认删除',
confirmTone: 'danger' as const,
showCancel: true,
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)]">
<div className="flex gap-2 overflow-x-auto pb-1 scrollbar-hide xl:pb-0">
{RESULT_TABS.map((tab) => (
<div key={tab.id}>
<button
type="button"
onClick={() => onActiveTabChange(tab.id)}
className={`platform-tab px-3 py-2 text-left text-sm xl:min-w-[5.25rem] xl:px-4 xl:py-2 ${activeTab === tab.id ? 'platform-tab--active' : ''}`}
>
<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>
</button>
</div>
))}
</div>
{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 ? (
<UnifiedConfirmDialog
open
title={confirmDialogConfig.title}
onClose={closeConfirmDialog}
onConfirm={executeConfirmAction}
confirmLabel={confirmDialogConfig.confirmLabel}
confirmTone={confirmDialogConfig.confirmTone}
showCancel={confirmDialogConfig.showCancel}
closeOnBackdrop={confirmState?.kind !== 'minimum-playable'}
>
{confirmDialogConfig.body}
</UnifiedConfirmDialog>
) : null}
</div>
);
}