1655 lines
52 KiB
TypeScript
1655 lines
52 KiB
TypeScript
import {
|
||
type ReactNode,
|
||
useDeferredValue,
|
||
useEffect,
|
||
useMemo,
|
||
useRef,
|
||
useState,
|
||
} from 'react';
|
||
|
||
import type {
|
||
EightAnchorContent,
|
||
KeyRelationshipValue,
|
||
} from '../../packages/shared/src/contracts/customWorldAgent';
|
||
import { getCustomWorldSceneRelativePositionLabel } from '../data/customWorldSceneGraph';
|
||
import {
|
||
resolveCustomWorldCampSceneImage,
|
||
resolveCustomWorldLandmarkImageMap,
|
||
} from '../data/customWorldVisuals';
|
||
import { resolveCustomWorldCampScene } from '../services/customWorldCamp';
|
||
import { normalizeCustomWorldCreatorIntent } from '../services/customWorldCreatorIntent';
|
||
import {
|
||
AnimationState,
|
||
Character,
|
||
CustomWorldProfile,
|
||
type SceneActBlueprint,
|
||
type SceneChapterBlueprint,
|
||
} from '../types';
|
||
import { CharacterAnimator } from './CharacterAnimator';
|
||
import type { RpgCreationEditorTarget } from './rpg-creation-editor/RpgCreationEntityEditorModal';
|
||
import { CustomWorldNpcPortrait } from './CustomWorldNpcVisualEditor';
|
||
import { ResolvedAssetImage } from './ResolvedAssetImage';
|
||
|
||
export type ResultTab = 'world' | 'playable' | 'story' | 'landmarks';
|
||
|
||
type PendingGeneratedEntity = {
|
||
id: string;
|
||
kind: 'playable' | 'story' | 'landmark';
|
||
title: string;
|
||
progress: number;
|
||
phaseLabel: string;
|
||
};
|
||
|
||
type RecentGeneratedIds = Record<'playable' | 'story' | 'landmark', string[]>;
|
||
|
||
interface CustomWorldEntityCatalogProps {
|
||
profile: CustomWorldProfile;
|
||
previewCharacters: Character[];
|
||
activeTab: ResultTab;
|
||
onActiveTabChange: (tab: ResultTab) => void;
|
||
onEditTarget: (target: RpgCreationEditorTarget) => void;
|
||
onProfileChange: (profile: CustomWorldProfile) => void;
|
||
onDeleteStoryNpcs?: (ids: string[]) => void;
|
||
onDeleteLandmarks?: (ids: string[]) => void;
|
||
createActionLabel?: string;
|
||
onCreateAction?: () => void;
|
||
createActionDisabled?: boolean;
|
||
pendingGeneratedEntity?: PendingGeneratedEntity | null;
|
||
recentGeneratedIds?: RecentGeneratedIds;
|
||
readOnly?: boolean;
|
||
}
|
||
|
||
const RESULT_TABS: Array<{ id: ResultTab; label: string }> = [
|
||
{ id: 'world', label: '世界' },
|
||
{ id: 'playable', label: '可扮演角色' },
|
||
{ id: 'story', label: '场景角色' },
|
||
{ id: 'landmarks', label: '场景' },
|
||
];
|
||
|
||
function Section({
|
||
title,
|
||
subtitle,
|
||
badge,
|
||
actions,
|
||
children,
|
||
}: {
|
||
title: string;
|
||
subtitle?: string;
|
||
badge?: ReactNode;
|
||
actions?: ReactNode;
|
||
children: ReactNode;
|
||
}) {
|
||
return (
|
||
<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 resolveSceneEntrySceneChapters(params: {
|
||
sceneChapters: CustomWorldProfile['sceneChapterBlueprints'];
|
||
sceneId: string;
|
||
sceneName: string;
|
||
}) {
|
||
const sceneChapters = params.sceneChapters ?? [];
|
||
const normalizedSceneId = params.sceneId.trim();
|
||
const normalizedSceneName = params.sceneName.trim();
|
||
|
||
const directMatches = sceneChapters.filter(
|
||
(chapter) => chapter.sceneId.trim() === normalizedSceneId,
|
||
);
|
||
if (directMatches.length > 0) {
|
||
return directMatches;
|
||
}
|
||
|
||
const linkedMatches = sceneChapters.filter((chapter) =>
|
||
chapter.linkedLandmarkIds.some(
|
||
(landmarkId) => landmarkId.trim() === normalizedSceneId,
|
||
),
|
||
);
|
||
if (linkedMatches.length > 0) {
|
||
return linkedMatches;
|
||
}
|
||
|
||
return sceneChapters.filter((chapter) => {
|
||
const chapterTitle = chapter.title.trim();
|
||
return (
|
||
chapterTitle === normalizedSceneName ||
|
||
chapter.summary.includes(normalizedSceneName) ||
|
||
chapter.acts.some(
|
||
(act) =>
|
||
act.title.includes(normalizedSceneName) ||
|
||
act.summary.includes(normalizedSceneName),
|
||
)
|
||
);
|
||
});
|
||
}
|
||
|
||
function buildSceneActParticipantText(
|
||
act: SceneActBlueprint,
|
||
roleById: Map<
|
||
string,
|
||
| CustomWorldProfile['playableNpcs'][number]
|
||
| CustomWorldProfile['storyNpcs'][number]
|
||
>,
|
||
) {
|
||
const primaryRoleName = roleById.get(act.primaryNpcId)?.name?.trim() || '';
|
||
const supportRoleNames = act.encounterNpcIds
|
||
.filter((roleId) => roleId !== act.primaryNpcId)
|
||
.map((roleId) => roleById.get(roleId)?.name?.trim() || '')
|
||
.filter(Boolean);
|
||
|
||
return compactTextList([
|
||
primaryRoleName ? `主角色:${primaryRoleName}` : '',
|
||
supportRoleNames.length > 0
|
||
? `相遇角色:${supportRoleNames.join('、')}`
|
||
: '',
|
||
]).join(';');
|
||
}
|
||
|
||
function buildSceneChapterSearchText(
|
||
sceneChapters: SceneChapterBlueprint[],
|
||
roleById: Map<
|
||
string,
|
||
| CustomWorldProfile['playableNpcs'][number]
|
||
| CustomWorldProfile['storyNpcs'][number]
|
||
>,
|
||
) {
|
||
return sceneChapters
|
||
.flatMap((chapter) => [
|
||
chapter.title,
|
||
chapter.summary,
|
||
...chapter.acts.flatMap((act) => [
|
||
act.title,
|
||
act.summary,
|
||
act.actGoal,
|
||
act.transitionHook,
|
||
buildSceneActParticipantText(act, roleById),
|
||
]),
|
||
])
|
||
.filter(Boolean)
|
||
.join(' ');
|
||
}
|
||
|
||
function resolveSceneCardImage(params: {
|
||
sceneImageSrc?: string | null;
|
||
sceneChapters: SceneChapterBlueprint[];
|
||
}) {
|
||
const firstActImageSrc =
|
||
params.sceneChapters
|
||
.flatMap((chapter) => chapter.acts)
|
||
.map((act) => act.backgroundImageSrc?.trim() || '')
|
||
.find(Boolean) || '';
|
||
|
||
return firstActImageSrc || params.sceneImageSrc?.trim() || '';
|
||
}
|
||
|
||
function collectSceneActImagePreviews(sceneChapters: SceneChapterBlueprint[]) {
|
||
return sceneChapters.flatMap((chapter) =>
|
||
chapter.acts
|
||
.map((act, index) => ({
|
||
id: act.id.trim() || `${chapter.id}-act-${index}`,
|
||
title: act.title.trim() || `第${index + 1}幕`,
|
||
imageSrc: act.backgroundImageSrc?.trim() || '',
|
||
}))
|
||
.filter((act) => act.imageSrc),
|
||
);
|
||
}
|
||
|
||
function buildFallbackSceneActImagePreviews(params: {
|
||
sceneChapters: SceneChapterBlueprint[];
|
||
sceneImageSrc?: string | null;
|
||
}) {
|
||
const actPreviews = collectSceneActImagePreviews(params.sceneChapters);
|
||
const sceneImageSrc = params.sceneImageSrc?.trim() || '';
|
||
|
||
if (actPreviews.length > 0 || !sceneImageSrc) {
|
||
return actPreviews;
|
||
}
|
||
|
||
// 中文注释:旧草稿可能只把开局场景图写在 camp.imageSrc,尚未回填到每一幕;目录侧先用场景图兜底,避免开局场景看起来没有幕图片。
|
||
return [1, 2, 3].map((actNumber) => ({
|
||
id: `fallback-scene-act-${actNumber}`,
|
||
title: `第${actNumber}幕`,
|
||
imageSrc: sceneImageSrc,
|
||
}));
|
||
}
|
||
|
||
function SceneActPreviewStrip({
|
||
acts,
|
||
sceneName,
|
||
}: {
|
||
acts: Array<{ id: string; title: string; imageSrc: string }>;
|
||
sceneName: string;
|
||
}) {
|
||
if (acts.length <= 0) return null;
|
||
|
||
return (
|
||
<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 toText(value: unknown) {
|
||
return typeof value === 'string' ? value.trim() : '';
|
||
}
|
||
|
||
function toTextArray(value: unknown) {
|
||
return Array.isArray(value)
|
||
? value.map((item) => toText(item)).filter(Boolean)
|
||
: [];
|
||
}
|
||
|
||
function toRecord(value: unknown) {
|
||
return value && typeof value === 'object' && !Array.isArray(value)
|
||
? (value as Record<string, unknown>)
|
||
: null;
|
||
}
|
||
|
||
function buildRelationshipSeedText(value: unknown) {
|
||
const record = toRecord(value);
|
||
if (!record) {
|
||
return '';
|
||
}
|
||
|
||
return compactTextList([
|
||
toText(record.name),
|
||
toText(record.role),
|
||
toText(record.relationToPlayer)
|
||
? `与玩家:${toText(record.relationToPlayer)}`
|
||
: '',
|
||
toText(record.hiddenHook) ? `代价/暗线:${toText(record.hiddenHook)}` : '',
|
||
]).join(';');
|
||
}
|
||
|
||
function buildKeyRelationshipText(value: KeyRelationshipValue) {
|
||
return compactTextList([
|
||
value.pairs,
|
||
value.relationshipType,
|
||
value.secretOrCost ? `代价/秘密:${value.secretOrCost}` : '',
|
||
]).join(';');
|
||
}
|
||
|
||
function buildAnchorContentFromProfileFallback(
|
||
profile: CustomWorldProfile,
|
||
): EightAnchorContent {
|
||
const creatorIntent = normalizeCustomWorldCreatorIntent(profile.creatorIntent);
|
||
const relationshipSeed = creatorIntent?.keyCharacters[0] ?? null;
|
||
|
||
return {
|
||
worldPromise: {
|
||
hook:
|
||
creatorIntent?.worldHook ||
|
||
profile.anchorPack?.worldSummary ||
|
||
profile.summary,
|
||
differentiator: profile.subtitle || profile.settingText,
|
||
desiredExperience:
|
||
compactTextList([
|
||
creatorIntent?.toneDirectives.join('、') || '',
|
||
profile.tone,
|
||
]).join(';') || profile.tone,
|
||
},
|
||
playerFantasy: {
|
||
playerRole: creatorIntent?.playerPremise || profile.playerGoal,
|
||
corePursuit: profile.playerGoal,
|
||
fearOfLoss:
|
||
relationshipSeed?.hiddenHook ||
|
||
creatorIntent?.coreConflicts[0] ||
|
||
profile.coreConflicts[0] ||
|
||
'',
|
||
},
|
||
themeBoundary: {
|
||
toneKeywords: compactTextList([
|
||
creatorIntent?.themeKeywords.join('、') || '',
|
||
creatorIntent?.toneDirectives.join('、') || '',
|
||
]),
|
||
aestheticDirectives: compactTextList([profile.tone, profile.subtitle]),
|
||
forbiddenDirectives: creatorIntent?.forbiddenDirectives ?? [],
|
||
},
|
||
playerEntryPoint: {
|
||
openingIdentity: creatorIntent?.playerPremise || '',
|
||
openingProblem:
|
||
creatorIntent?.openingSituation || profile.coreConflicts[0] || '',
|
||
entryMotivation: profile.playerGoal,
|
||
},
|
||
coreConflict: {
|
||
surfaceConflicts:
|
||
creatorIntent?.coreConflicts.length
|
||
? creatorIntent.coreConflicts
|
||
: profile.coreConflicts,
|
||
hiddenCrisis:
|
||
relationshipSeed?.hiddenHook ||
|
||
profile.summary ||
|
||
profile.settingText,
|
||
firstTouchedConflict:
|
||
creatorIntent?.openingSituation ||
|
||
profile.coreConflicts[0] ||
|
||
profile.playerGoal,
|
||
},
|
||
keyRelationships: relationshipSeed
|
||
? [
|
||
{
|
||
pairs: compactTextList([
|
||
relationshipSeed.name,
|
||
relationshipSeed.role,
|
||
]).join(' · '),
|
||
relationshipType: relationshipSeed.relationToPlayer || '',
|
||
secretOrCost: relationshipSeed.hiddenHook || '',
|
||
},
|
||
]
|
||
: [],
|
||
hiddenLines: {
|
||
hiddenTruths: compactTextList([
|
||
relationshipSeed?.hiddenHook || '',
|
||
profile.summary,
|
||
]),
|
||
misdirectionHints: compactTextList([
|
||
profile.subtitle,
|
||
profile.majorFactions[0] || '',
|
||
]),
|
||
revealPacing:
|
||
creatorIntent?.openingSituation ||
|
||
profile.coreConflicts[0] ||
|
||
profile.playerGoal,
|
||
},
|
||
iconicElements: {
|
||
iconicMotifs:
|
||
creatorIntent?.iconicElements.length
|
||
? creatorIntent.iconicElements
|
||
: compactTextList([
|
||
profile.anchorPack?.motifDirectives.join('、') || '',
|
||
profile.landmarks[0]?.name || '',
|
||
]),
|
||
institutionsOrArtifacts: compactTextList([
|
||
profile.camp?.name || '',
|
||
profile.majorFactions[0] || '',
|
||
]),
|
||
hardRules: compactTextList([profile.playerGoal, profile.coreConflicts[0] || '']),
|
||
},
|
||
};
|
||
}
|
||
|
||
function getProfileAnchorContent(profile: CustomWorldProfile) {
|
||
const anchorContentRecord = profile.anchorContent;
|
||
if (!anchorContentRecord) {
|
||
return buildAnchorContentFromProfileFallback(profile);
|
||
}
|
||
|
||
const worldPromiseRecord = toRecord(anchorContentRecord.worldPromise);
|
||
const playerFantasyRecord = toRecord(anchorContentRecord.playerFantasy);
|
||
const themeBoundaryRecord = toRecord(anchorContentRecord.themeBoundary);
|
||
const playerEntryPointRecord = toRecord(anchorContentRecord.playerEntryPoint);
|
||
const coreConflictRecord = toRecord(anchorContentRecord.coreConflict);
|
||
const hiddenLinesRecord = toRecord(anchorContentRecord.hiddenLines);
|
||
const iconicElementsRecord = toRecord(anchorContentRecord.iconicElements);
|
||
|
||
return {
|
||
worldPromise: worldPromiseRecord
|
||
? {
|
||
hook: toText(worldPromiseRecord.hook),
|
||
differentiator: toText(worldPromiseRecord.differentiator),
|
||
desiredExperience: toText(worldPromiseRecord.desiredExperience),
|
||
}
|
||
: null,
|
||
playerFantasy: playerFantasyRecord
|
||
? {
|
||
playerRole: toText(playerFantasyRecord.playerRole),
|
||
corePursuit: toText(playerFantasyRecord.corePursuit),
|
||
fearOfLoss: toText(playerFantasyRecord.fearOfLoss),
|
||
}
|
||
: null,
|
||
themeBoundary: themeBoundaryRecord
|
||
? {
|
||
toneKeywords: toTextArray(themeBoundaryRecord.toneKeywords),
|
||
aestheticDirectives: toTextArray(
|
||
themeBoundaryRecord.aestheticDirectives,
|
||
),
|
||
forbiddenDirectives: toTextArray(themeBoundaryRecord.forbiddenDirectives),
|
||
}
|
||
: null,
|
||
playerEntryPoint: playerEntryPointRecord
|
||
? {
|
||
openingIdentity: toText(playerEntryPointRecord.openingIdentity),
|
||
openingProblem: toText(playerEntryPointRecord.openingProblem),
|
||
entryMotivation: toText(playerEntryPointRecord.entryMotivation),
|
||
}
|
||
: null,
|
||
coreConflict: coreConflictRecord
|
||
? {
|
||
surfaceConflicts: toTextArray(coreConflictRecord.surfaceConflicts),
|
||
hiddenCrisis: toText(coreConflictRecord.hiddenCrisis),
|
||
firstTouchedConflict: toText(coreConflictRecord.firstTouchedConflict),
|
||
}
|
||
: null,
|
||
keyRelationships: Array.isArray(anchorContentRecord.keyRelationships)
|
||
? anchorContentRecord.keyRelationships
|
||
.map((entry) => toRecord(entry))
|
||
.filter(Boolean)
|
||
.map((entry) => ({
|
||
pairs: toText(entry?.pairs),
|
||
relationshipType: toText(entry?.relationshipType),
|
||
secretOrCost: toText(entry?.secretOrCost),
|
||
}))
|
||
: [],
|
||
hiddenLines: hiddenLinesRecord
|
||
? {
|
||
hiddenTruths: toTextArray(hiddenLinesRecord.hiddenTruths),
|
||
misdirectionHints: toTextArray(hiddenLinesRecord.misdirectionHints),
|
||
revealPacing: toText(hiddenLinesRecord.revealPacing),
|
||
}
|
||
: null,
|
||
iconicElements: iconicElementsRecord
|
||
? {
|
||
iconicMotifs: toTextArray(iconicElementsRecord.iconicMotifs),
|
||
institutionsOrArtifacts: toTextArray(
|
||
iconicElementsRecord.institutionsOrArtifacts,
|
||
),
|
||
hardRules: toTextArray(iconicElementsRecord.hardRules),
|
||
}
|
||
: null,
|
||
} satisfies EightAnchorContent;
|
||
}
|
||
|
||
function buildOpeningSceneSearchText(
|
||
profile: CustomWorldProfile,
|
||
campScene: ReturnType<typeof resolveCustomWorldCampScene>,
|
||
) {
|
||
return [
|
||
campScene.name,
|
||
campScene.description,
|
||
profile.playerGoal,
|
||
profile.summary,
|
||
'开局场景',
|
||
'开局归处',
|
||
].join(' ');
|
||
}
|
||
|
||
function buildStructuredFoundationEntries(profile: CustomWorldProfile) {
|
||
const creatorIntent = normalizeCustomWorldCreatorIntent(profile.creatorIntent);
|
||
const anchorContent = getProfileAnchorContent(profile);
|
||
const fallbackRelationshipText =
|
||
buildRelationshipSeedText(creatorIntent?.keyCharacters[0]) ||
|
||
profile.playableNpcs[0]?.relationshipHooks.join(';') ||
|
||
profile.storyNpcs[0]?.relationshipHooks.join(';') ||
|
||
'';
|
||
|
||
return [
|
||
{
|
||
id: 'world-promise',
|
||
label: '世界承诺',
|
||
value: compactTextList([
|
||
anchorContent.worldPromise?.hook || '',
|
||
anchorContent.worldPromise?.differentiator || '',
|
||
anchorContent.worldPromise?.desiredExperience || '',
|
||
]).join(';'),
|
||
},
|
||
{
|
||
id: 'player-fantasy',
|
||
label: '玩家幻想',
|
||
value: compactTextList([
|
||
anchorContent.playerFantasy?.playerRole || '',
|
||
anchorContent.playerFantasy?.corePursuit || '',
|
||
anchorContent.playerFantasy?.fearOfLoss || '',
|
||
]).join(';'),
|
||
},
|
||
{
|
||
id: 'theme-boundary',
|
||
label: '主题边界',
|
||
value: compactTextList([
|
||
anchorContent.themeBoundary?.toneKeywords.join('、') || '',
|
||
anchorContent.themeBoundary?.aestheticDirectives.join('、') || '',
|
||
anchorContent.themeBoundary?.forbiddenDirectives.length
|
||
? `避免:${anchorContent.themeBoundary.forbiddenDirectives.join('、')}`
|
||
: '',
|
||
]).join(';'),
|
||
},
|
||
{
|
||
id: 'player-entry-point',
|
||
label: '玩家切入口',
|
||
value: compactTextList([
|
||
anchorContent.playerEntryPoint?.openingIdentity || '',
|
||
anchorContent.playerEntryPoint?.openingProblem || '',
|
||
anchorContent.playerEntryPoint?.entryMotivation || '',
|
||
]).join(';'),
|
||
},
|
||
{
|
||
id: 'core-conflict',
|
||
label: '核心冲突',
|
||
value: compactTextList([
|
||
anchorContent.coreConflict?.surfaceConflicts.join('、') || '',
|
||
anchorContent.coreConflict?.hiddenCrisis || '',
|
||
anchorContent.coreConflict?.firstTouchedConflict || '',
|
||
]).join(';'),
|
||
},
|
||
{
|
||
id: 'key-relationships',
|
||
label: '关键关系',
|
||
value:
|
||
anchorContent.keyRelationships.map(buildKeyRelationshipText).join('\n') ||
|
||
fallbackRelationshipText,
|
||
},
|
||
{
|
||
id: 'hidden-lines',
|
||
label: '暗线与揭示',
|
||
value: compactTextList([
|
||
anchorContent.hiddenLines?.hiddenTruths.join('、') || '',
|
||
anchorContent.hiddenLines?.misdirectionHints.join('、') || '',
|
||
anchorContent.hiddenLines?.revealPacing || '',
|
||
]).join(';'),
|
||
},
|
||
{
|
||
id: 'iconic-elements',
|
||
label: '标志元素',
|
||
value: compactTextList([
|
||
anchorContent.iconicElements?.iconicMotifs.join('、') || '',
|
||
anchorContent.iconicElements?.institutionsOrArtifacts.join('、') || '',
|
||
anchorContent.iconicElements?.hardRules.join('、') || '',
|
||
]).join(';'),
|
||
},
|
||
];
|
||
}
|
||
|
||
type CatalogRole =
|
||
| CustomWorldProfile['playableNpcs'][number]
|
||
| CustomWorldProfile['storyNpcs'][number];
|
||
|
||
type BulkDeleteTab = 'story' | 'landmarks';
|
||
|
||
function buildRoleSearchText(role: CatalogRole) {
|
||
return [
|
||
role.name,
|
||
role.title,
|
||
role.role,
|
||
role.description,
|
||
role.backstory,
|
||
role.backstoryReveal.publicSummary,
|
||
role.personality,
|
||
role.motivation,
|
||
role.combatStyle,
|
||
...role.backstoryReveal.chapters.flatMap((chapter) => [
|
||
chapter.title,
|
||
chapter.teaser,
|
||
chapter.content,
|
||
chapter.contextSnippet,
|
||
]),
|
||
...role.skills.flatMap((skill) => [skill.name, skill.summary, skill.style]),
|
||
...role.initialItems.flatMap((item) => [
|
||
item.name,
|
||
item.category,
|
||
item.description,
|
||
...item.tags,
|
||
]),
|
||
...role.relationshipHooks,
|
||
...role.tags,
|
||
].join(' ');
|
||
}
|
||
|
||
function buildLandmarkSearchText(
|
||
landmark: CustomWorldProfile['landmarks'][number],
|
||
storyNpcById: Map<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,
|
||
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 landmarkImageById = useMemo(
|
||
() => resolveCustomWorldLandmarkImageMap(profile),
|
||
[profile],
|
||
);
|
||
const resolvedCampScene = useMemo(
|
||
() => resolveCustomWorldCampScene(profile),
|
||
[profile],
|
||
);
|
||
const resolvedCampImageSrc = useMemo(
|
||
() => resolveCustomWorldCampSceneImage(profile),
|
||
[profile],
|
||
);
|
||
const previewCharacterById = useMemo(
|
||
() =>
|
||
new Map(
|
||
profile.playableNpcs.map((role, index) => [
|
||
role.id,
|
||
previewCharacters[index] ?? null,
|
||
]),
|
||
),
|
||
[previewCharacters, profile.playableNpcs],
|
||
);
|
||
const recentPlayableIdSet = useMemo(
|
||
() => new Set(recentGeneratedIds.playable),
|
||
[recentGeneratedIds.playable],
|
||
);
|
||
const recentStoryIdSet = useMemo(
|
||
() => new Set(recentGeneratedIds.story),
|
||
[recentGeneratedIds.story],
|
||
);
|
||
const recentLandmarkIdSet = useMemo(
|
||
() => new Set(recentGeneratedIds.landmark),
|
||
[recentGeneratedIds.landmark],
|
||
);
|
||
|
||
const filteredPlayable = useMemo(
|
||
() =>
|
||
profile.playableNpcs.filter(
|
||
(role) =>
|
||
!deferredSearch ||
|
||
matchText(buildRoleSearchText(role), deferredSearch),
|
||
),
|
||
[deferredSearch, profile.playableNpcs],
|
||
);
|
||
|
||
const filteredStory = useMemo(
|
||
() =>
|
||
profile.storyNpcs.filter(
|
||
(npc) =>
|
||
!deferredSearch ||
|
||
matchText(buildRoleSearchText(npc), deferredSearch),
|
||
),
|
||
[deferredSearch, profile.storyNpcs],
|
||
);
|
||
|
||
const filteredLandmarks = useMemo(
|
||
() =>
|
||
profile.landmarks.filter(
|
||
(landmark) =>
|
||
!deferredSearch ||
|
||
matchText(
|
||
buildLandmarkSearchText(landmark, storyNpcById, landmarkById),
|
||
deferredSearch,
|
||
),
|
||
),
|
||
[deferredSearch, landmarkById, profile.landmarks, storyNpcById],
|
||
);
|
||
const structuredFoundationEntries = useMemo(
|
||
() => buildStructuredFoundationEntries(profile),
|
||
[profile],
|
||
);
|
||
const normalizedCreatorIntent = useMemo(
|
||
() => normalizeCustomWorldCreatorIntent(profile.creatorIntent),
|
||
[profile.creatorIntent],
|
||
);
|
||
const filteredSceneEntries = useMemo(() => {
|
||
const openingSceneChapters = resolveSceneEntrySceneChapters({
|
||
sceneChapters: profile.sceneChapterBlueprints,
|
||
sceneId: resolvedCampScene.id,
|
||
sceneName: resolvedCampScene.name,
|
||
});
|
||
const openingSceneImageSrc = resolveSceneCardImage({
|
||
sceneImageSrc: resolvedCampImageSrc,
|
||
sceneChapters: openingSceneChapters,
|
||
});
|
||
const openingSceneEntry = {
|
||
id: resolvedCampScene.id,
|
||
kind: 'camp' as const,
|
||
name: resolvedCampScene.name,
|
||
description: resolvedCampScene.description,
|
||
imageSrc: openingSceneImageSrc,
|
||
sceneChapters: openingSceneChapters,
|
||
actPreviews: buildFallbackSceneActImagePreviews({
|
||
sceneChapters: openingSceneChapters,
|
||
sceneImageSrc: openingSceneImageSrc,
|
||
}),
|
||
searchText: [
|
||
buildOpeningSceneSearchText(profile, resolvedCampScene),
|
||
buildSceneChapterSearchText(openingSceneChapters, roleById),
|
||
]
|
||
.filter(Boolean)
|
||
.join(' '),
|
||
};
|
||
const landmarkEntries = profile.landmarks.map((landmark) => {
|
||
const sceneChapters = resolveSceneEntrySceneChapters({
|
||
sceneChapters: profile.sceneChapterBlueprints,
|
||
sceneId: landmark.id,
|
||
sceneName: landmark.name,
|
||
});
|
||
const sceneImageSrc = resolveSceneCardImage({
|
||
sceneImageSrc: landmarkImageById.get(landmark.id) ?? landmark.imageSrc,
|
||
sceneChapters,
|
||
});
|
||
|
||
return {
|
||
id: landmark.id,
|
||
kind: 'landmark' as const,
|
||
name: landmark.name,
|
||
description: landmark.description,
|
||
imageSrc: sceneImageSrc,
|
||
sceneChapters,
|
||
actPreviews: buildFallbackSceneActImagePreviews({
|
||
sceneChapters,
|
||
sceneImageSrc,
|
||
}),
|
||
searchText: [
|
||
buildLandmarkSearchText(landmark, storyNpcById, landmarkById),
|
||
buildSceneChapterSearchText(sceneChapters, roleById),
|
||
]
|
||
.filter(Boolean)
|
||
.join(' '),
|
||
};
|
||
});
|
||
const recentEntries = landmarkEntries.filter((entry) =>
|
||
recentLandmarkIdSet.has(entry.id),
|
||
);
|
||
const restEntries = landmarkEntries.filter(
|
||
(entry) => !recentLandmarkIdSet.has(entry.id),
|
||
);
|
||
const allEntries = [...recentEntries, openingSceneEntry, ...restEntries];
|
||
|
||
if (!deferredSearch) {
|
||
return allEntries;
|
||
}
|
||
|
||
return allEntries.filter((entry) =>
|
||
matchText(entry.searchText, deferredSearch),
|
||
);
|
||
}, [
|
||
deferredSearch,
|
||
landmarkById,
|
||
landmarkImageById,
|
||
profile,
|
||
recentLandmarkIdSet,
|
||
resolvedCampImageSrc,
|
||
resolvedCampScene,
|
||
roleById,
|
||
storyNpcById,
|
||
]);
|
||
|
||
const lockedCharacterNames = useMemo(
|
||
() =>
|
||
new Set(
|
||
normalizedCreatorIntent?.keyCharacters
|
||
.filter((entry) => entry.locked)
|
||
.map((entry) => entry.name.trim())
|
||
.filter(Boolean) ?? [],
|
||
),
|
||
[normalizedCreatorIntent],
|
||
);
|
||
|
||
const counts = {
|
||
world: 1,
|
||
playable:
|
||
profile.playableNpcs.length +
|
||
(pendingGeneratedEntity?.kind === 'playable' ? 1 : 0),
|
||
story:
|
||
profile.storyNpcs.length +
|
||
(pendingGeneratedEntity?.kind === 'story' ? 1 : 0),
|
||
landmarks:
|
||
profile.landmarks.length +
|
||
1 +
|
||
(pendingGeneratedEntity?.kind === 'landmark' ? 1 : 0),
|
||
} satisfies Record<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="世界概述"
|
||
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: 'world' })}
|
||
tone="sky"
|
||
>
|
||
查看详情
|
||
</SmallButton>
|
||
) : (
|
||
<SmallButton
|
||
onClick={() => onEditTarget({ kind: 'world' })}
|
||
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>
|
||
<div className="mt-2 whitespace-pre-line text-sm leading-7 text-zinc-100">
|
||
{entry.value || '待补充'}
|
||
</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={
|
||
scene.kind === 'camp'
|
||
? `开局场景 · ${scene.description}`
|
||
: scene.description
|
||
}
|
||
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>
|
||
);
|
||
}
|
||
|