新增 PlatformUiKit 通用弹窗、按钮、状态、空态、媒体、表单和标签等公共组件 迁移结果页、创作工作台、认证入口、RPG 暗色面板和运行态弹窗的重复 UI chrome 补充组件测试、页面回归测试、技术文档和 Hermes 共享决策记录
1461 lines
45 KiB
TypeScript
1461 lines
45 KiB
TypeScript
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>
|
||
);
|
||
}
|