11
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-04-16 21:47:20 +08:00
parent 2456c10c63
commit 09d4c0c31b
79 changed files with 11873 additions and 2341 deletions

View File

@@ -3,6 +3,7 @@ import {
useDeferredValue,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
@@ -13,7 +14,7 @@ import {
} from '../data/customWorldVisuals';
import { resolveCustomWorldCampScene } from '../services/customWorldCamp';
import {
buildCustomWorldCreatorIntentDisplayText,
buildCustomWorldCreatorIntentFoundationText,
normalizeCustomWorldCreatorIntent,
} from '../services/customWorldCreatorIntent';
import { AnimationState, Character, CustomWorldProfile } from '../types';
@@ -24,6 +25,16 @@ import { CustomWorldNpcPortrait } from './CustomWorldNpcVisualEditor';
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[];
@@ -35,6 +46,9 @@ interface CustomWorldEntityCatalogProps {
onDeleteLandmarks?: (ids: string[]) => void;
createActionLabel?: string;
onCreateAction?: () => void;
createActionDisabled?: boolean;
pendingGeneratedEntity?: PendingGeneratedEntity | null;
recentGeneratedIds?: RecentGeneratedIds;
readOnly?: boolean;
}
@@ -48,11 +62,13 @@ const RESULT_TABS: Array<{ id: ResultTab; label: string }> = [
function Section({
title,
subtitle,
badge,
actions,
children,
}: {
title: string;
subtitle?: string;
badge?: ReactNode;
actions?: ReactNode;
children: ReactNode;
}) {
@@ -72,7 +88,10 @@ function Section({
</div>
) : null}
</div>
{actions}
<div className="flex items-center gap-2">
{badge}
{actions}
</div>
</div>
<div className="mt-3">{children}</div>
</div>
@@ -83,10 +102,12 @@ function SmallButton({
onClick,
children,
tone = 'default',
disabled = false,
}: {
onClick: () => void;
children: ReactNode;
tone?: 'default' | 'sky' | 'rose';
disabled?: boolean;
}) {
const toneClassName =
tone === 'sky'
@@ -99,7 +120,8 @@ function SmallButton({
<button
type="button"
onClick={onClick}
className={`rounded-full border px-3 py-1 text-[11px] transition-colors ${toneClassName}`}
disabled={disabled}
className={`rounded-full border px-3 py-1 text-[11px] transition-colors ${toneClassName} ${disabled ? 'cursor-not-allowed opacity-45' : ''}`}
>
{children}
</button>
@@ -161,23 +183,120 @@ function EmptyState({ title }: { title: string }) {
);
}
function NewBadge() {
return (
<span className="rounded-full border border-amber-300/24 bg-amber-500/12 px-2.5 py-1 text-[10px] font-semibold text-amber-100">
</span>
);
}
function PendingEntityCard({
title,
phaseLabel,
progress,
}: {
title: string;
phaseLabel: string;
progress: number;
}) {
return (
<div className="rounded-[1.35rem] border border-sky-300/18 bg-sky-500/10 px-4 py-4">
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-sm font-semibold text-white">{title}</div>
<div className="mt-1 text-xs leading-6 text-sky-50/90">
{phaseLabel}
</div>
</div>
<div className="rounded-full border border-sky-300/20 bg-black/20 px-2.5 py-1 text-[10px] text-sky-100">
{Math.round(progress)}%
</div>
</div>
<div className="mt-3 h-2.5 overflow-hidden rounded-full border border-white/10 bg-black/30">
<div
className="h-full bg-[linear-gradient(90deg,#38bdf8_0%,#67e8f9_100%)] transition-[width] duration-300"
style={{ width: `${Math.max(6, Math.min(100, progress))}%` }}
/>
</div>
</div>
);
}
function CatalogCard({
title,
description,
media,
badge,
isSelectionMode,
isSelected,
onClick,
layout = 'stacked',
mediaClassName,
disabled = false,
}: {
title: string;
description: string;
media: ReactNode;
badge?: ReactNode;
isSelectionMode: boolean;
isSelected: boolean;
onClick: () => void;
layout?: 'stacked' | 'compact';
mediaClassName?: string;
disabled?: boolean;
}) {
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'
: 'border-white/10 bg-black/20 text-zinc-400'
}`}
>
{isSelected ? '已选' : '选择'}
</div>
) : null;
if (layout === 'compact') {
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
className={`w-full rounded-[1.3rem] border p-2.5 text-left transition-colors ${
isSelected
? 'border-rose-300/35 bg-rose-500/10'
: disabled
? 'border-white/10 bg-black/20'
: 'border-white/10 bg-black/20 hover:border-white/20 hover:bg-black/28'
}`}
>
<div className="flex items-start gap-3">
<div
className={`shrink-0 overflow-hidden rounded-[1rem] border border-white/8 bg-black/25 ${mediaClassName ?? 'h-[4.75rem] w-[4.75rem]'}`}
>
{media}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 text-[15px] font-semibold leading-5 text-white">
{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">
{description || '暂无描述'}
</div>
</div>
</div>
</button>
);
}
return (
<button
type="button"
@@ -192,24 +311,19 @@ function CatalogCard({
}`}
>
<div className="space-y-3">
<div className="overflow-hidden rounded-[1.1rem] border border-white/8 bg-black/25">
<div
className={`overflow-hidden rounded-[1.1rem] border border-white/8 bg-black/25 ${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>
{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'
: 'border-white/10 bg-black/20 text-zinc-400'
}`}
>
{isSelected ? '已选' : '选择'}
</div>
) : null}
<div className="flex items-center gap-2">
{badge}
{selectionBadge}
</div>
</div>
<div className="text-sm leading-6 text-zinc-300">
{description || '暂无描述'}
@@ -276,7 +390,7 @@ function buildStructuredFoundationEntries(profile: CustomWorldProfile) {
return [
{
id: 'world-hook',
label: '世界核心',
label: '世界一句话',
value:
creatorIntent?.worldHook ||
profile.anchorPack?.worldSummary ||
@@ -384,8 +498,16 @@ export function CustomWorldEntityCatalog({
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,
@@ -423,6 +545,18 @@ export function CustomWorldEntityCatalog({
),
[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(
() =>
@@ -460,6 +594,16 @@ export function CustomWorldEntityCatalog({
() => buildStructuredFoundationEntries(profile),
[profile],
);
const structuredFoundationSourceText = useMemo(
() =>
buildCustomWorldCreatorIntentFoundationText(profile.creatorIntent).trim() ||
profile.settingText.trim(),
[profile.creatorIntent, profile.settingText],
);
const normalizedCreatorIntent = useMemo(
() => normalizeCustomWorldCreatorIntent(profile.creatorIntent),
[profile.creatorIntent],
);
const filteredSceneEntries = useMemo(() => {
const openingSceneEntry = {
id: 'custom-world-opening-scene',
@@ -477,7 +621,13 @@ export function CustomWorldEntityCatalog({
imageSrc: landmarkImageById.get(landmark.id) ?? landmark.imageSrc,
searchText: buildLandmarkSearchText(landmark, storyNpcById, landmarkById),
}));
const allEntries = [openingSceneEntry, ...landmarkEntries];
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;
@@ -492,32 +642,35 @@ export function CustomWorldEntityCatalog({
landmarkById,
landmarkImageById,
profile,
recentLandmarkIdSet,
resolvedCampImageSrc,
resolvedCampScene,
storyNpcById,
]);
const creatorIntentSummary = useMemo(
() =>
buildCustomWorldCreatorIntentDisplayText(profile.creatorIntent).trim(),
[profile.creatorIntent],
);
const lockedCharacterNames = useMemo(
() =>
new Set(
profile.creatorIntent?.keyCharacters
normalizedCreatorIntent?.keyCharacters
.filter((entry) => entry.locked)
.map((entry) => entry.name.trim())
.filter(Boolean) ?? [],
),
[profile.creatorIntent],
[normalizedCreatorIntent],
);
const counts = {
world: 1,
playable: profile.playableNpcs.length,
story: profile.storyNpcs.length,
landmarks: profile.landmarks.length + 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 =
@@ -532,6 +685,16 @@ export function CustomWorldEntityCatalog({
}
}, [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('至少保留一个可扮演角色,才能正常进入自定义世界。');
@@ -584,7 +747,10 @@ export function CustomWorldEntityCatalog({
};
return (
<div className="h-full min-h-0 space-y-3 overflow-y-auto overscroll-contain pr-1 scrollbar-hide">
<div
ref={scrollContainerRef}
className="h-full min-h-0 space-y-3 overflow-y-auto overscroll-contain pr-1 scrollbar-hide"
>
<div className="px-1 pb-1 text-center">
<div className="text-[11px] font-bold tracking-[0.28em] text-zinc-500">
@@ -638,7 +804,11 @@ export function CustomWorldEntityCatalog({
) : (
<>
{!readOnly && createActionLabel && onCreateAction ? (
<SmallButton onClick={onCreateAction} tone="sky">
<SmallButton
onClick={onCreateAction}
tone="sky"
disabled={createActionDisabled}
>
{createActionLabel}
</SmallButton>
) : null}
@@ -662,6 +832,29 @@ export function CustomWorldEntityCatalog({
{activeTab === 'world' ? (
<>
<Section title="档案规模">
<div className="grid grid-cols-3 gap-2 text-center text-[11px] text-zinc-300">
<div className="rounded-xl border border-white/8 bg-black/20 px-2 py-3">
<div className="text-xl font-black text-white">
{profile.playableNpcs.length}
</div>
<div></div>
</div>
<div className="rounded-xl border border-white/8 bg-black/20 px-2 py-3">
<div className="text-xl font-black text-white">
{profile.storyNpcs.length}
</div>
<div></div>
</div>
<div className="rounded-xl border border-white/8 bg-black/20 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={
@@ -694,10 +887,29 @@ export function CustomWorldEntityCatalog({
</Section>
<Section
title="原始设定"
subtitle="把开局最关键的 6 个原始锚点拆开看,后续精修会更顺。"
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="text-[11px] font-bold tracking-[0.18em] text-zinc-500">
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{structuredFoundationEntries.map((entry) => (
<div
@@ -713,45 +925,16 @@ export function CustomWorldEntityCatalog({
</div>
))}
</div>
{profile.settingText ? (
<div className="whitespace-pre-line rounded-2xl border border-white/8 bg-black/20 px-4 py-4 text-sm leading-7 text-zinc-200">
{profile.settingText}
{structuredFoundationSourceText ? (
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-4">
<div className="text-[11px] font-bold tracking-[0.18em] text-zinc-500">
</div>
<div className="mt-2 whitespace-pre-line text-sm leading-7 text-zinc-200">
{structuredFoundationSourceText}
</div>
</div>
) : null}
{creatorIntentSummary && creatorIntentSummary !== profile.settingText ? (
<div className="whitespace-pre-line rounded-2xl border border-sky-300/14 bg-sky-500/8 px-4 py-4 text-sm leading-7 text-sky-50/95">
{creatorIntentSummary}
</div>
) : null}
</div>
</Section>
<Section
title="档案规模"
subtitle="结果页现在会同时维护场景角色归属和场景之间的相对位置连接关系。"
>
<div className="grid grid-cols-1 gap-2 text-center text-[11px] text-zinc-300 sm:grid-cols-3">
<div className="rounded-xl border border-white/8 bg-black/20 px-2 py-3">
<div className="text-xl font-black text-white">
{profile.playableNpcs.length}
</div>
<div></div>
</div>
<div className="rounded-xl border border-white/8 bg-black/20 px-2 py-3">
<div className="text-xl font-black text-white">
{profile.storyNpcs.length}
</div>
<div></div>
</div>
<div className="rounded-xl border border-white/8 bg-black/20 px-2 py-3">
<div className="text-xl font-black text-white">
{profile.landmarks.length + 1}
</div>
<div></div>
</div>
</div>
<div className="mt-3 rounded-2xl border border-sky-300/14 bg-sky-500/8 px-4 py-3 text-sm leading-6 text-sky-50/90">
</div>
</Section>
</>
@@ -759,6 +942,13 @@ export function CustomWorldEntityCatalog({
{activeTab === 'playable' ? (
<div className="space-y-3">
{pendingGeneratedEntity?.kind === 'playable' ? (
<PendingEntityCard
title={pendingGeneratedEntity.title}
phaseLabel={pendingGeneratedEntity.phaseLabel}
progress={pendingGeneratedEntity.progress}
/>
) : null}
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3 text-sm text-zinc-300">
{readOnly
? '当前是草稿结果预览,可先浏览角色结构,再回到工作区继续精修。'
@@ -776,6 +966,7 @@ export function CustomWorldEntityCatalog({
<Section
title={role.name}
subtitle={role.title}
badge={recentPlayableIdSet.has(role.id) ? <NewBadge /> : null}
actions={
readOnly ? (
<SmallButton
@@ -927,6 +1118,13 @@ export function CustomWorldEntityCatalog({
{activeTab === 'story' ? (
<div className="space-y-3">
{pendingGeneratedEntity?.kind === 'story' ? (
<PendingEntityCard
title={pendingGeneratedEntity.title}
phaseLabel={pendingGeneratedEntity.phaseLabel}
progress={pendingGeneratedEntity.progress}
/>
) : null}
{filteredStory.length === 0 ? (
<EmptyState title="当前没有符合搜索条件的场景角色。" />
) : (
@@ -935,8 +1133,11 @@ export function CustomWorldEntityCatalog({
<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]"
onClick={() =>
isBulkDeleteMode
? toggleBulkSelected(npc.id)
@@ -957,8 +1158,9 @@ export function CustomWorldEntityCatalog({
npc={npc}
profile={profile}
visual={npc.visual}
className="aspect-square"
scale={2.18}
className="h-full w-full"
contentClassName="min-h-0 p-2"
scale={1.82}
preferImageSrc
/>
}
@@ -971,6 +1173,13 @@ export function CustomWorldEntityCatalog({
{activeTab === 'landmarks' ? (
<div className="space-y-3">
{pendingGeneratedEntity?.kind === 'landmark' ? (
<PendingEntityCard
title={pendingGeneratedEntity.title}
phaseLabel={pendingGeneratedEntity.phaseLabel}
progress={pendingGeneratedEntity.progress}
/>
) : null}
{filteredSceneEntries.length === 0 ? (
<EmptyState title="当前没有符合搜索条件的场景。" />
) : (
@@ -983,6 +1192,11 @@ export function CustomWorldEntityCatalog({
? `开局场景 · ${scene.description}`
: scene.description
}
badge={
scene.kind === 'landmark' && recentLandmarkIdSet.has(scene.id) ? (
<NewBadge />
) : null
}
isSelectionMode={scene.kind === 'landmark' && isBulkDeleteMode}
isSelected={
scene.kind === 'landmark' &&
@@ -990,7 +1204,7 @@ export function CustomWorldEntityCatalog({
}
onClick={() =>
scene.kind === 'camp'
? onEditTarget({ kind: 'world' })
? onEditTarget({ kind: 'camp' })
: isBulkDeleteMode
? toggleBulkSelected(scene.id)
: onEditTarget({