1274 lines
40 KiB
TypeScript
1274 lines
40 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 CustomWorldProfile,
|
||
type SceneActBlueprint,
|
||
type SceneChapterBlueprint,
|
||
} from '../types';
|
||
import { CharacterAnimator } from './CharacterAnimator';
|
||
import { CustomWorldNpcPortrait } from './CustomWorldNpcVisualEditor';
|
||
import { ResolvedAssetImage } from './ResolvedAssetImage';
|
||
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;
|
||
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,
|
||
}: {
|
||
title: string;
|
||
subtitle?: string;
|
||
badge?: ReactNode;
|
||
actions?: ReactNode;
|
||
children: ReactNode;
|
||
}) {
|
||
return (
|
||
<div className="platform-surface platform-surface--soft px-3.5 py-3">
|
||
<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;
|
||
}) {
|
||
const toneClassName =
|
||
tone === 'sky'
|
||
? 'platform-button platform-button--primary'
|
||
: tone === 'rose'
|
||
? 'platform-button platform-button--danger'
|
||
: 'platform-button platform-button--ghost';
|
||
|
||
return (
|
||
<button
|
||
type="button"
|
||
onClick={onClick}
|
||
disabled={disabled}
|
||
className={`${toneClassName} min-h-0 rounded-full px-3 py-1 text-[11px] ${disabled ? 'cursor-not-allowed opacity-45' : ''}`}
|
||
>
|
||
{children}
|
||
</button>
|
||
);
|
||
}
|
||
|
||
function SearchBox({
|
||
value,
|
||
onChange,
|
||
placeholder,
|
||
}: {
|
||
value: string;
|
||
onChange: (value: string) => void;
|
||
placeholder: string;
|
||
}) {
|
||
return (
|
||
<div className="platform-subpanel rounded-2xl px-3 py-2">
|
||
<input
|
||
value={value}
|
||
onChange={(event) => onChange(event.target.value)}
|
||
placeholder={placeholder}
|
||
className="w-full bg-transparent text-sm text-[var(--platform-text-strong)] outline-none placeholder:text-[var(--platform-text-soft)]"
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function ImageFrame({
|
||
src,
|
||
alt,
|
||
fallbackLabel,
|
||
tone = 'square',
|
||
}: {
|
||
src?: string;
|
||
alt: string;
|
||
fallbackLabel: string;
|
||
tone?: 'square' | 'landscape';
|
||
}) {
|
||
return (
|
||
<div
|
||
className={`overflow-hidden rounded-2xl border border-[var(--platform-subpanel-border)] bg-[radial-gradient(circle_at_top,rgba(255,255,255,0.22),transparent_42%),linear-gradient(180deg,rgba(255,96,147,0.92),rgba(255,146,109,0.84))] ${tone === 'landscape' ? 'aspect-[16/9]' : 'aspect-square'}`}
|
||
>
|
||
{src ? (
|
||
<ResolvedAssetImage
|
||
src={src}
|
||
alt={alt}
|
||
className="h-full w-full object-cover"
|
||
/>
|
||
) : (
|
||
<div className="flex h-full w-full items-center justify-center px-4 text-center text-sm font-semibold tracking-[0.18em] text-zinc-400">
|
||
{fallbackLabel}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function EmptyState({ title }: { title: string }) {
|
||
return (
|
||
<div className="platform-subpanel rounded-2xl border-dashed px-5 py-6 text-center">
|
||
<div className="text-sm text-[var(--platform-text-base)]">{title}</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function buildFallbackRenderKey(
|
||
value: string | null | undefined,
|
||
fallback: string,
|
||
) {
|
||
const normalizedValue = value?.trim();
|
||
return normalizedValue ? normalizedValue : fallback;
|
||
}
|
||
|
||
function NewBadge() {
|
||
return (
|
||
<span className="platform-pill platform-pill--warm px-2.5 py-1 text-[10px] font-semibold">
|
||
新
|
||
</span>
|
||
);
|
||
}
|
||
|
||
function PendingEntityCard({
|
||
title,
|
||
phaseLabel,
|
||
progress,
|
||
}: {
|
||
title: string;
|
||
phaseLabel: string;
|
||
progress: number;
|
||
}) {
|
||
return (
|
||
<div className="platform-banner platform-banner--info rounded-[1.35rem] px-4 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>
|
||
<div className="platform-pill platform-pill--cool px-2.5 py-1 text-[10px]">
|
||
{Math.round(progress)}%
|
||
</div>
|
||
</div>
|
||
<div className="platform-progress-track mt-3 h-2.5 overflow-hidden rounded-full">
|
||
<div
|
||
className="h-full bg-[linear-gradient(90deg,#ff4f8b_0%,#ff8a73_100%)] transition-[width] duration-300"
|
||
style={{ width: `${Math.max(6, Math.min(100, progress))}%` }}
|
||
/>
|
||
</div>
|
||
</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) => (
|
||
<div
|
||
key={act.id}
|
||
className="platform-subpanel h-12 w-[5.25rem] shrink-0 overflow-hidden rounded-xl"
|
||
title={act.title}
|
||
>
|
||
<ResolvedAssetImage
|
||
src={act.imageSrc}
|
||
alt={`${sceneName}-${act.title}`}
|
||
className="h-full w-full object-cover"
|
||
/>
|
||
</div>
|
||
))}
|
||
</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 ? (
|
||
<div
|
||
className={`shrink-0 rounded-full border px-2.5 py-1 text-[10px] ${
|
||
isSelected
|
||
? 'border-rose-300/25 bg-rose-500/14 text-rose-50'
|
||
: 'platform-subpanel text-[var(--platform-text-soft)]'
|
||
}`}
|
||
>
|
||
{isSelected ? '已选' : '选择'}
|
||
</div>
|
||
) : null;
|
||
|
||
if (layout === 'compact') {
|
||
return (
|
||
<div
|
||
role="button"
|
||
tabIndex={disabled ? -1 : 0}
|
||
onClick={disabled ? undefined : onClick}
|
||
aria-disabled={disabled}
|
||
className={`w-full rounded-[1.3rem] border p-2.5 text-left transition-colors xl:p-3 ${
|
||
isSelected
|
||
? 'border-rose-300/35 bg-rose-500/10'
|
||
: 'platform-subpanel'
|
||
}`}
|
||
>
|
||
<div className="flex items-start gap-3 xl:gap-3.5">
|
||
<div
|
||
className={`platform-subpanel shrink-0 overflow-hidden rounded-[1rem] ${mediaClassName ?? 'h-[4.75rem] w-[4.75rem]'}`}
|
||
>
|
||
{media}
|
||
</div>
|
||
<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>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div
|
||
role="button"
|
||
tabIndex={disabled ? -1 : 0}
|
||
onClick={disabled ? undefined : onClick}
|
||
aria-disabled={disabled}
|
||
className={`w-full rounded-[1.4rem] border p-3 text-left transition-colors ${
|
||
isSelected
|
||
? 'border-rose-300/35 bg-rose-500/10'
|
||
: 'platform-subpanel'
|
||
}`}
|
||
>
|
||
<div className="space-y-3">
|
||
<div
|
||
className={`platform-subpanel overflow-hidden rounded-[1.1rem] ${mediaClassName ?? ''}`}
|
||
>
|
||
{media}
|
||
</div>
|
||
<div className="flex items-start justify-between gap-3">
|
||
<div className="min-w-0 text-base font-semibold text-white">
|
||
{title}
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
{badge}
|
||
{selectionBadge}
|
||
</div>
|
||
</div>
|
||
<div className="text-sm leading-6 text-zinc-300">
|
||
{description || '暂无描述'}
|
||
</div>
|
||
{actions ? <div className="flex flex-wrap gap-2">{actions}</div> : null}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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';
|
||
|
||
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(' ');
|
||
}
|
||
|
||
function buildAttributeSlotSummary(
|
||
slot: CustomWorldProfile['attributeSchema']['slots'][number],
|
||
) {
|
||
return compactTextList([
|
||
slot.combatUseText,
|
||
slot.socialUseText,
|
||
slot.explorationUseText,
|
||
]).join(' / ');
|
||
}
|
||
|
||
export function CustomWorldEntityCatalog({
|
||
profile,
|
||
previewCharacters,
|
||
activeTab,
|
||
onActiveTabChange,
|
||
onEditTarget,
|
||
onProfileChange,
|
||
onDeleteStoryNpcs,
|
||
onDeleteLandmarks,
|
||
createActionLabel,
|
||
onCreateAction,
|
||
createActionDisabled = false,
|
||
pendingGeneratedEntity = null,
|
||
recentGeneratedIds = {
|
||
playable: [],
|
||
story: [],
|
||
landmark: [],
|
||
},
|
||
readOnly = false,
|
||
}: CustomWorldEntityCatalogProps) {
|
||
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
|
||
const [searchDraft, setSearchDraft] = useState('');
|
||
const [bulkDeleteMode, setBulkDeleteMode] = useState<BulkDeleteTab | null>(
|
||
null,
|
||
);
|
||
const [selectedBulkIds, setSelectedBulkIds] = useState<string[]>([]);
|
||
const deferredSearch = useDeferredValue(searchDraft.trim());
|
||
|
||
const storyNpcById = useMemo(
|
||
() => new Map(profile.storyNpcs.map((npc) => [npc.id, npc])),
|
||
[profile.storyNpcs],
|
||
);
|
||
const 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 bulkDeleteTab: BulkDeleteTab | null =
|
||
activeTab === 'story' || activeTab === 'landmarks' ? activeTab : null;
|
||
const isBulkDeleteMode =
|
||
bulkDeleteTab !== null && bulkDeleteMode === bulkDeleteTab;
|
||
|
||
useEffect(() => {
|
||
if (bulkDeleteMode && bulkDeleteMode !== activeTab) {
|
||
setBulkDeleteMode(null);
|
||
setSelectedBulkIds([]);
|
||
}
|
||
}, [activeTab, bulkDeleteMode]);
|
||
|
||
useEffect(() => {
|
||
const container = scrollContainerRef.current;
|
||
if (!container) return;
|
||
if (typeof container.scrollTo === 'function') {
|
||
container.scrollTo({ top: 0, behavior: 'auto' });
|
||
return;
|
||
}
|
||
container.scrollTop = 0;
|
||
}, [activeTab]);
|
||
|
||
const removePlayable = (id: string, name: string) => {
|
||
if (profile.playableNpcs.length <= 1) {
|
||
window.alert('至少保留一个可扮演角色,才能正常进入自定义世界。');
|
||
return;
|
||
}
|
||
if (!window.confirm(`确认删除可扮演角色「${name}」吗?`)) return;
|
||
onProfileChange({
|
||
...profile,
|
||
playableNpcs: profile.playableNpcs.filter((role) => role.id !== id),
|
||
});
|
||
};
|
||
|
||
const startBulkDelete = (tab: BulkDeleteTab) => {
|
||
setBulkDeleteMode(tab);
|
||
setSelectedBulkIds([]);
|
||
};
|
||
|
||
const cancelBulkDelete = () => {
|
||
setBulkDeleteMode(null);
|
||
setSelectedBulkIds([]);
|
||
};
|
||
|
||
const toggleBulkSelected = (id: string) => {
|
||
setSelectedBulkIds((current) =>
|
||
current.includes(id)
|
||
? current.filter((entry) => entry !== id)
|
||
: [...current, id],
|
||
);
|
||
};
|
||
|
||
const confirmBulkDelete = () => {
|
||
if (!bulkDeleteTab || selectedBulkIds.length === 0) {
|
||
return;
|
||
}
|
||
|
||
const label = bulkDeleteTab === 'story' ? '场景角色' : '场景';
|
||
const confirmed = window.confirm(
|
||
`确认批量删除 ${selectedBulkIds.length} 个${label}吗?`,
|
||
);
|
||
if (!confirmed) {
|
||
return;
|
||
}
|
||
|
||
if (bulkDeleteTab === 'story') {
|
||
onDeleteStoryNpcs?.(selectedBulkIds);
|
||
} else {
|
||
onDeleteLandmarks?.(selectedBulkIds);
|
||
}
|
||
cancelBulkDelete();
|
||
};
|
||
|
||
return (
|
||
<div
|
||
ref={scrollContainerRef}
|
||
className="h-full min-h-0 space-y-3 overflow-y-auto overscroll-contain pr-1 scrollbar-hide xl:space-y-4 xl:pr-2"
|
||
>
|
||
<div className="px-1 pb-1 text-center xl:rounded-[2rem] xl:border xl:border-[var(--platform-subpanel-border)] xl:bg-white/55 xl:px-6 xl:py-4 xl:text-left xl:shadow-[0_18px_70px_rgba(255,79,139,0.08)] xl:backdrop-blur-sm">
|
||
<div className="text-[11px] font-bold tracking-[0.28em] text-zinc-500">
|
||
世界档案
|
||
</div>
|
||
<div className="mt-2 text-3xl font-black text-[var(--platform-text-strong)] sm:text-[2.2rem] xl:mt-1 xl:text-[2rem]">
|
||
{profile.name}
|
||
</div>
|
||
<div className="mt-2 text-sm tracking-[0.18em] text-zinc-400 xl:mt-1 xl:text-xs">
|
||
{profile.subtitle}
|
||
</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(255,79,139,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 ? (
|
||
<>
|
||
<div className="platform-pill platform-pill--neutral px-3 py-1 text-[11px]">
|
||
已选 {selectedBulkIds.length}
|
||
</div>
|
||
<SmallButton onClick={cancelBulkDelete}>取消</SmallButton>
|
||
<SmallButton onClick={confirmBulkDelete} tone="rose">
|
||
删除选中
|
||
</SmallButton>
|
||
</>
|
||
) : (
|
||
<>
|
||
{!readOnly && createActionLabel && onCreateAction ? (
|
||
<SmallButton
|
||
onClick={onCreateAction}
|
||
tone="sky"
|
||
disabled={createActionDisabled}
|
||
>
|
||
{createActionLabel}
|
||
</SmallButton>
|
||
) : null}
|
||
{!readOnly &&
|
||
bulkDeleteTab &&
|
||
((bulkDeleteTab === 'story' && onDeleteStoryNpcs) ||
|
||
(bulkDeleteTab === 'landmarks' && onDeleteLandmarks)) ? (
|
||
<SmallButton
|
||
onClick={() => startBulkDelete(bulkDeleteTab)}
|
||
tone="rose"
|
||
>
|
||
批量删除
|
||
</SmallButton>
|
||
) : null}
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
|
||
{activeTab === 'world' ? (
|
||
<div className="space-y-3 xl:grid xl:grid-cols-[0.8fr_1.2fr] xl:items-start xl:gap-3 xl:space-y-0">
|
||
<Section title="档案规模">
|
||
<div className="grid grid-cols-3 gap-2 text-center text-[11px] text-zinc-300">
|
||
<div className="platform-subpanel rounded-xl px-2 py-3">
|
||
<div className="text-xl font-black text-white">
|
||
{profile.playableNpcs.length}
|
||
</div>
|
||
<div>可扮演角色</div>
|
||
</div>
|
||
<div className="platform-subpanel rounded-xl px-2 py-3">
|
||
<div className="text-xl font-black text-white">
|
||
{profile.storyNpcs.length}
|
||
</div>
|
||
<div>场景角色</div>
|
||
</div>
|
||
<div className="platform-subpanel rounded-xl px-2 py-3">
|
||
<div className="text-xl font-black text-white">
|
||
{profile.landmarks.length + 1}
|
||
</div>
|
||
<div>场景</div>
|
||
</div>
|
||
</div>
|
||
</Section>
|
||
|
||
<Section
|
||
title="角色维度"
|
||
subtitle={profile.attributeSchema?.schemaName}
|
||
>
|
||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
|
||
{attributeSlots.map((slot) => (
|
||
<div
|
||
key={slot.slotId}
|
||
className="platform-subpanel rounded-xl px-3 py-3"
|
||
>
|
||
<div className="text-sm font-semibold text-white">
|
||
{slot.name}
|
||
</div>
|
||
<div className="mt-1 line-clamp-2 text-[11px] leading-5 text-zinc-400">
|
||
{buildAttributeSlotSummary(slot) || slot.definition}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</Section>
|
||
|
||
<Section
|
||
title="世界概述"
|
||
actions={
|
||
readOnly ? (
|
||
<SmallButton
|
||
onClick={() => onEditTarget({ kind: 'world' })}
|
||
tone="sky"
|
||
>
|
||
查看详情
|
||
</SmallButton>
|
||
) : (
|
||
<SmallButton
|
||
onClick={() => onEditTarget({ kind: 'world' })}
|
||
tone="sky"
|
||
>
|
||
编辑
|
||
</SmallButton>
|
||
)
|
||
}
|
||
>
|
||
<div className="space-y-3 text-sm leading-7 text-zinc-300">
|
||
<p>{profile.summary}</p>
|
||
<div className="platform-banner platform-banner--warning rounded-2xl px-3 py-3">
|
||
主线目标:{profile.playerGoal}
|
||
</div>
|
||
<div className="platform-subpanel rounded-2xl px-3 py-3">
|
||
世界基调:{profile.tone}
|
||
</div>
|
||
</div>
|
||
</Section>
|
||
|
||
<Section
|
||
title="基本设定"
|
||
actions={
|
||
readOnly ? (
|
||
<SmallButton
|
||
onClick={() => onEditTarget({ kind: 'foundation' })}
|
||
tone="sky"
|
||
>
|
||
查看详情
|
||
</SmallButton>
|
||
) : (
|
||
<SmallButton
|
||
onClick={() => onEditTarget({ kind: 'foundation' })}
|
||
tone="sky"
|
||
>
|
||
编辑
|
||
</SmallButton>
|
||
)
|
||
}
|
||
>
|
||
<div className="space-y-3">
|
||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||
{structuredFoundationEntries.map((entry) => (
|
||
<div
|
||
key={entry.id}
|
||
className="platform-subpanel rounded-2xl px-4 py-4"
|
||
>
|
||
<div className="text-[11px] font-bold tracking-[0.18em] text-zinc-500">
|
||
{entry.label}
|
||
</div>
|
||
{entry.value ? (
|
||
<div className="mt-3 flex flex-wrap gap-2">
|
||
{parseFoundationTagText(entry.value).map((tag, index) => (
|
||
<span
|
||
key={`${entry.id}-${index}-${tag}`}
|
||
className="rounded-full border border-white/10 bg-white/[0.06] px-3 py-1 text-xs leading-5 text-zinc-100"
|
||
>
|
||
{tag}
|
||
</span>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<div className="mt-2 text-sm leading-7 text-zinc-100">
|
||
待补充
|
||
</div>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</Section>
|
||
</div>
|
||
) : null}
|
||
|
||
{activeTab === 'playable' ? (
|
||
<div className="space-y-3 xl:grid xl:grid-cols-2 xl:gap-3 xl:space-y-0 2xl:grid-cols-3">
|
||
{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()) ? (
|
||
<span className="platform-pill platform-pill--warm px-2.5 py-1 text-[10px]">
|
||
创作者锁定
|
||
</span>
|
||
) : null}
|
||
<span className="platform-pill platform-pill--neutral px-2.5 py-1 text-[10px]">
|
||
初始好感 {role.initialAffinity}
|
||
</span>
|
||
{role.generatedVisualAssetId ? (
|
||
<span className="platform-pill platform-pill--success px-2.5 py-1 text-[10px]">
|
||
已生成主图
|
||
</span>
|
||
) : null}
|
||
{role.tags.slice(0, 2).map((tag) => (
|
||
<span
|
||
key={`${role.id}-${tag}`}
|
||
className="platform-pill platform-pill--neutral px-2.5 py-1 text-[10px]"
|
||
>
|
||
{tag}
|
||
</span>
|
||
))}
|
||
{!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-2 xl:gap-3 xl:space-y-0 2xl:grid-cols-3">
|
||
{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-2 xl:gap-3 xl:space-y-0 2xl:grid-cols-3">
|
||
{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={
|
||
<ImageFrame
|
||
src={scene.imageSrc}
|
||
alt={scene.name}
|
||
fallbackLabel={scene.name.slice(0, 4) || '场景'}
|
||
tone="landscape"
|
||
/>
|
||
}
|
||
actions={
|
||
<SceneActPreviewStrip
|
||
acts={scene.actPreviews}
|
||
sceneName={scene.name}
|
||
/>
|
||
}
|
||
disabled={scene.kind === 'camp' && isBulkDeleteMode}
|
||
/>
|
||
))
|
||
)}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|
||
|