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({

View File

@@ -0,0 +1,181 @@
/* @vitest-environment jsdom */
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { expect, test, vi } from 'vitest';
import type {
CustomWorldNpc,
CustomWorldPlayableNpc,
CustomWorldProfile,
} from '../types';
import { CustomWorldEntityEditorModal } from './CustomWorldEntityEditorModal';
vi.mock('./CharacterAnimator', () => ({
CharacterAnimator: () => <div></div>,
}));
vi.mock('./CustomWorldNpcVisualEditor', () => ({
CustomWorldNpcPortrait: ({ npc }: { npc: { name: string } }) => (
<div>{npc.name}</div>
),
CustomWorldNpcVisualEditor: () => <div></div>,
}));
vi.mock('./asset-studio/characterAssetWorkflowPersistence', () => ({
fetchCharacterWorkflowCache: vi.fn().mockResolvedValue({ cache: null }),
generateCharacterPromptBundle: vi.fn().mockResolvedValue({
visualPromptText: '自动生成的形象提示词',
animationPromptText: '自动生成的动作提示词',
}),
saveCharacterWorkflowCache: vi.fn().mockResolvedValue(undefined),
generateCharacterVisualCandidates: vi.fn(),
publishCharacterVisualAsset: vi.fn(),
generateCharacterAnimationDraft: vi.fn(),
publishCharacterAnimationAssets: vi.fn(),
}));
function createBackstoryReveal() {
return {
publicSummary: '公开背景',
chapters: [
{
id: 'surface',
title: '表层来意',
affinityRequired: 6,
teaser: '表层来意',
content: '表层来意内容',
contextSnippet: '表层来意摘要',
},
{
id: 'scar',
title: '旧事裂痕',
affinityRequired: 12,
teaser: '旧事裂痕',
content: '旧事裂痕内容',
contextSnippet: '旧事裂痕摘要',
},
{
id: 'hidden',
title: '隐藏执念',
affinityRequired: 18,
teaser: '隐藏执念',
content: '隐藏执念内容',
contextSnippet: '隐藏执念摘要',
},
{
id: 'final',
title: '最终底牌',
affinityRequired: 24,
teaser: '最终底牌',
content: '最终底牌内容',
contextSnippet: '最终底牌摘要',
},
],
};
}
function createPlayableRole(id: string, name: string): CustomWorldPlayableNpc {
return {
id,
name,
title: '同行者',
role: '协作战力',
description: `${name}的定位描述`,
backstory: `${name}的背景`,
personality: `${name}的性格`,
motivation: `${name}的动机`,
combatStyle: `${name}的战斗风格`,
initialAffinity: 18,
relationshipHooks: ['关系钩子'],
relations: [],
tags: ['测试'],
backstoryReveal: createBackstoryReveal(),
skills: [],
initialItems: [],
templateCharacterId: 'knight-female-1',
};
}
function createStoryRole(id: string, name: string): CustomWorldNpc {
return {
...createPlayableRole(id, name),
initialAffinity: 6,
visual: undefined,
};
}
function createProfile(): CustomWorldProfile {
return {
id: 'world-1',
settingText: '潮雾群岛上的禁制与旧航道正在一起失衡。',
name: '潮雾群岛',
subtitle: '旧航道与沉钟回响',
summary: '一座正在被旧誓与新利益共同撕扯的群岛世界。',
tone: '压抑、潮湿、带着未解旧伤。',
playerGoal: '找到能让群岛重新稳定的关键节点。',
templateWorldType: 'WUXIA',
majorFactions: ['守潮盟', '沉钟会'],
coreConflicts: ['旧航道归属', '沉钟遗产争夺'],
attributeSchema: {},
playableNpcs: [createPlayableRole('playable-1', '沈砺')],
storyNpcs: [createStoryRole('story-1', '顾潮音')],
items: [],
camp: {
name: '潮灯居',
description: '玩家最初落脚的旧灯塔内院。',
dangerLevel: 'medium',
},
landmarks: [],
creatorIntent: null,
anchorPack: null,
lockState: null,
ownedSettingLayers: null,
generationMode: 'full',
generationStatus: 'complete',
} as unknown as CustomWorldProfile;
}
test('playable角色打开AI工坊后不会自动关闭', async () => {
const user = userEvent.setup();
const handleClose = vi.fn();
render(
<CustomWorldEntityEditorModal
profile={createProfile()}
target={{ kind: 'playable', mode: 'edit', id: 'playable-1' }}
onClose={handleClose}
onProfileChange={vi.fn()}
/>,
);
await user.click(screen.getByRole('button', { name: 'AI生成' }));
await waitFor(() => {
expect(screen.getByText('AI角色形象生成')).toBeTruthy();
});
expect(handleClose).not.toHaveBeenCalled();
});
test('场景角色打开AI工坊后不会自动关闭', async () => {
const user = userEvent.setup();
const handleClose = vi.fn();
render(
<CustomWorldEntityEditorModal
profile={createProfile()}
target={{ kind: 'story', mode: 'edit', id: 'story-1' }}
onClose={handleClose}
onProfileChange={vi.fn()}
/>,
);
await user.click(screen.getByRole('button', { name: 'AI生成' }));
await waitFor(() => {
expect(screen.getByText('AI角色形象生成')).toBeTruthy();
});
expect(handleClose).not.toHaveBeenCalled();
});

File diff suppressed because it is too large Load Diff

View File

@@ -284,6 +284,12 @@ function ActionButton({
return (
<button
type="button"
onPointerDown={(event) => {
event.stopPropagation();
}}
onMouseDown={(event) => {
event.stopPropagation();
}}
onClick={onClick}
className={`rounded-full border px-4 py-2 text-sm font-semibold transition-colors ${
tone === 'sky'
@@ -301,6 +307,7 @@ export function CustomWorldNpcPortrait({
profile,
visual,
className = '',
contentClassName = 'min-h-[7rem] p-3',
scale = 2.05,
preferImageSrc = false,
}: {
@@ -308,6 +315,7 @@ export function CustomWorldNpcPortrait({
profile?: CustomWorldProfile | null;
visual?: CustomWorldNpcVisual;
className?: string;
contentClassName?: string;
scale?: number;
preferImageSrc?: boolean;
}) {
@@ -321,7 +329,9 @@ export function CustomWorldNpcPortrait({
return (
<div className={`relative overflow-hidden rounded-2xl border border-white/10 bg-[radial-gradient(circle_at_top,rgba(56,189,248,0.16),transparent_48%),linear-gradient(180deg,rgba(19,24,39,0.96),rgba(8,10,17,0.92))] ${className}`}>
<div className="absolute inset-0 opacity-10 [background-image:linear-gradient(rgba(255,255,255,0.16)_1px,transparent_1px),linear-gradient(90deg,rgba(255,255,255,0.16)_1px,transparent_1px)] [background-size:16px_16px]" />
<div className="relative flex h-full min-h-[7rem] items-center justify-center p-3">
<div
className={`relative flex h-full items-center justify-center ${contentClassName}`}
>
{preferredImageSrc ? (
<img
src={preferredImageSrc}

View File

@@ -0,0 +1,244 @@
/* @vitest-environment jsdom */
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { useState } from 'react';
import { expect, test, vi } from 'vitest';
import type { CustomWorldPlayableNpc, CustomWorldProfile } from '../types';
import { CustomWorldResultView } from './CustomWorldResultView';
vi.mock('../services/aiService', () => ({
generateCustomWorldPlayableNpc: vi.fn(),
generateCustomWorldStoryNpc: vi.fn(),
generateCustomWorldLandmark: vi.fn(),
}));
vi.mock('./CharacterAnimator', () => ({
CharacterAnimator: () => <div></div>,
}));
vi.mock('./CustomWorldNpcVisualEditor', () => ({
CustomWorldNpcPortrait: ({ npc }: { npc: { name: string } }) => (
<div>{npc.name}</div>
),
}));
vi.mock('./CustomWorldEntityEditorModal', () => ({
CustomWorldEntityEditorModal: () => null,
}));
async function loadAiService() {
return import('../services/aiService');
}
function createBackstoryReveal() {
return {
publicSummary: '公开背景',
chapters: [
{
id: 'surface',
title: '表层来意',
affinityRequired: 6,
teaser: '表层来意',
content: '表层来意内容',
contextSnippet: '表层来意摘要',
},
{
id: 'scar',
title: '旧事裂痕',
affinityRequired: 12,
teaser: '旧事裂痕',
content: '旧事裂痕内容',
contextSnippet: '旧事裂痕摘要',
},
{
id: 'hidden',
title: '隐藏执念',
affinityRequired: 18,
teaser: '隐藏执念',
content: '隐藏执念内容',
contextSnippet: '隐藏执念摘要',
},
{
id: 'final',
title: '最终底牌',
affinityRequired: 24,
teaser: '最终底牌',
content: '最终底牌内容',
contextSnippet: '最终底牌摘要',
},
],
};
}
function createPlayableRole(id: string, name: string): CustomWorldPlayableNpc {
return {
id,
name,
title: '同行者',
role: '协作战力',
description: `${name}的定位描述`,
backstory: `${name}的背景`,
personality: `${name}的性格`,
motivation: `${name}的动机`,
combatStyle: `${name}的战斗风格`,
initialAffinity: 18,
relationshipHooks: ['关系钩子'],
relations: [],
tags: ['测试'],
backstoryReveal: createBackstoryReveal(),
skills: [
{
id: `${id}-skill-1`,
name: '技能一',
summary: '技能说明一',
style: '起手压制',
},
{
id: `${id}-skill-2`,
name: '技能二',
summary: '技能说明二',
style: '机动周旋',
},
{
id: `${id}-skill-3`,
name: '技能三',
summary: '技能说明三',
style: '爆发终结',
},
],
initialItems: [
{
id: `${id}-item-1`,
name: '物品一',
category: '武器',
quantity: 1,
rarity: 'rare',
description: '物品说明一',
tags: ['测试'],
},
{
id: `${id}-item-2`,
name: '物品二',
category: '专属物品',
quantity: 1,
rarity: 'rare',
description: '物品说明二',
tags: ['测试'],
},
{
id: `${id}-item-3`,
name: '物品三',
category: '消耗品',
quantity: 2,
rarity: 'uncommon',
description: '物品说明三',
tags: ['测试'],
},
],
};
}
const baseProfile = {
id: 'world-1',
settingText: '潮雾群岛上的禁制与旧航道正在一起失衡。',
name: '潮雾群岛',
subtitle: '旧航道与沉钟回响',
summary: '一座正在被旧誓与新利益共同撕扯的群岛世界。',
tone: '压抑、潮湿、带着未解旧伤。',
playerGoal: '找到能让群岛重新稳定的关键节点。',
templateWorldType: 'WUXIA',
majorFactions: ['守潮盟', '沉钟会'],
coreConflicts: ['旧航道归属', '沉钟遗产争夺'],
attributeSchema: {},
playableNpcs: [createPlayableRole('playable-1', '沈砺')],
storyNpcs: [
{
...createPlayableRole('story-1', '顾潮音'),
initialAffinity: 6,
},
],
items: [],
camp: {
name: '潮灯居',
description: '玩家最初落脚的旧灯塔内院。',
dangerLevel: 'medium',
},
landmarks: [
{
id: 'landmark-1',
name: '沉钟栈桥',
description: '旧钟与潮声常年相撞的码头栈桥。',
dangerLevel: 'medium',
sceneNpcIds: ['story-1'],
connections: [],
},
],
creatorIntent: null,
anchorPack: null,
lockState: null,
ownedSettingLayers: null,
generationMode: 'full',
generationStatus: 'complete',
} as unknown as CustomWorldProfile;
function ResultViewHarness() {
const [profile, setProfile] = useState(baseProfile);
return (
<CustomWorldResultView
profile={profile}
previewCharacters={[]}
isGenerating={false}
progress={0}
progressLabel=""
error={null}
onBack={() => {}}
onProfileChange={setProfile}
/>
);
}
test('clicking新增可扮演角色 shows pending item, disables button, and marks result as new', async () => {
const aiService = await loadAiService();
const user = userEvent.setup();
let resolveGeneration: ((value: CustomWorldPlayableNpc) => void) | null = null;
vi.mocked(aiService.generateCustomWorldPlayableNpc).mockImplementation(
() =>
new Promise<CustomWorldPlayableNpc>((resolve) => {
resolveGeneration = resolve;
}),
);
render(<ResultViewHarness />);
await user.click(screen.getByRole('button', { name: //u }));
await user.click(screen.getByRole('button', { name: '新增可扮演角色' }));
expect(screen.getByText('新可扮演角色')).toBeTruthy();
expect(screen.getByText('正在整理世界上下文')).toBeTruthy();
const createButton = screen.getByRole('button', { name: '新增可扮演角色' });
expect((createButton as HTMLButtonElement).disabled).toBe(true);
const finishGeneration = resolveGeneration;
if (!finishGeneration) {
throw new Error('expected pending playable generation resolver');
}
(finishGeneration as (value: CustomWorldPlayableNpc) => void)(
createPlayableRole('playable-2', '云止'),
);
await waitFor(() => {
expect(screen.getByText('云止')).toBeTruthy();
});
await waitFor(() => {
expect(screen.queryByText('新可扮演角色')).toBeNull();
});
expect(screen.getAllByText('新').length).toBeGreaterThan(0);
});

View File

@@ -1,7 +1,18 @@
import { type ReactNode, useMemo, useState } from 'react';
import { type ReactNode, useEffect, useMemo, useRef, useState } from 'react';
import { normalizeCustomWorldLandmarks } from '../data/customWorldSceneGraph';
import { Character, CustomWorldProfile } from '../types';
import {
generateCustomWorldLandmark,
generateCustomWorldPlayableNpc,
generateCustomWorldStoryNpc,
} from '../services/aiService';
import {
Character,
CustomWorldLandmark,
CustomWorldNpc,
CustomWorldPlayableNpc,
CustomWorldProfile,
} from '../types';
import { getNineSliceStyle, UI_CHROME } from '../uiAssets';
import {
CustomWorldEntityCatalog,
@@ -23,15 +34,28 @@ interface CustomWorldResultViewProps {
onEditSetting?: () => void;
onRegenerate?: () => void;
onContinueExpand?: () => void;
onSave?: () => void;
onEnterWorld?: () => void;
onProfileChange: (profile: CustomWorldProfile) => void;
readOnly?: boolean;
backLabel?: string;
editActionLabel?: string;
regenerateActionLabel?: string;
saveActionLabel?: string;
enterWorldActionLabel?: string;
autoSaveState?: 'idle' | 'saving' | 'saved' | 'error';
}
type EntityGenerationKind = 'playable' | 'story' | 'landmark';
type PendingGeneratedEntity = {
id: string;
kind: EntityGenerationKind;
title: string;
progress: number;
phaseLabel: string;
};
type RecentGeneratedIds = Record<EntityGenerationKind, string[]>;
function SmallButton({
onClick,
children,
@@ -75,6 +99,66 @@ function getCreateLabelByTab(activeTab: ResultTab) {
return '';
}
function createPendingGeneratedEntity(
kind: EntityGenerationKind,
): PendingGeneratedEntity {
return {
id: `pending-${kind}-${Date.now()}`,
kind,
title:
kind === 'playable'
? '新可扮演角色'
: kind === 'story'
? '新场景角色'
: '新场景',
progress: 8,
phaseLabel: '正在整理世界上下文',
};
}
function resolvePendingPhaseLabel(
kind: EntityGenerationKind,
progress: number,
) {
if (progress < 28) {
return '正在整理世界上下文';
}
if (progress < 72) {
return kind === 'landmark' ? '正在推理场景结构' : '正在推理角色结构';
}
return '正在回写结果';
}
function prependPlayableNpc(
profile: CustomWorldProfile,
npc: CustomWorldPlayableNpc,
) {
return {
...profile,
playableNpcs: [npc, ...profile.playableNpcs],
} satisfies CustomWorldProfile;
}
function prependStoryNpc(profile: CustomWorldProfile, npc: CustomWorldNpc) {
return {
...profile,
storyNpcs: [npc, ...profile.storyNpcs],
} satisfies CustomWorldProfile;
}
function prependLandmark(
profile: CustomWorldProfile,
landmark: CustomWorldLandmark,
) {
return {
...profile,
landmarks: normalizeCustomWorldLandmarks({
landmarks: [landmark, ...profile.landmarks],
storyNpcs: profile.storyNpcs,
}),
} satisfies CustomWorldProfile;
}
function removeStoryNpcsFromProfile(
profile: CustomWorldProfile,
ids: string[],
@@ -129,17 +213,31 @@ export function CustomWorldResultView({
onEditSetting,
onRegenerate: triggerRegenerate,
onContinueExpand,
onSave,
onEnterWorld,
onProfileChange,
readOnly = false,
backLabel = '返回',
editActionLabel = '修改设定',
regenerateActionLabel = '重新生成',
saveActionLabel = '保存到我的作品',
enterWorldActionLabel = '进入世界',
autoSaveState = 'idle',
}: CustomWorldResultViewProps) {
const [editorTarget, setEditorTarget] =
useState<CustomWorldEditorTarget | null>(null);
const [activeTab, setActiveTab] = useState<ResultTab>('world');
const [pendingGeneratedEntity, setPendingGeneratedEntity] =
useState<PendingGeneratedEntity | null>(null);
const [recentGeneratedIds, setRecentGeneratedIds] = useState<RecentGeneratedIds>(
{
playable: [],
story: [],
landmark: [],
},
);
const [localGenerationError, setLocalGenerationError] = useState<string | null>(
null,
);
const pendingProgressTimerRef = useRef<number | null>(null);
const createTarget = useMemo(
() => getCreateTargetByTab(activeTab),
@@ -149,6 +247,89 @@ export function CustomWorldResultView({
() => getCreateLabelByTab(activeTab),
[activeTab],
);
const stopPendingProgressTimer = () => {
if (pendingProgressTimerRef.current !== null) {
window.clearInterval(pendingProgressTimerRef.current);
pendingProgressTimerRef.current = null;
}
};
useEffect(() => () => stopPendingProgressTimer(), []);
const startPendingProgress = (kind: EntityGenerationKind) => {
stopPendingProgressTimer();
setPendingGeneratedEntity(createPendingGeneratedEntity(kind));
pendingProgressTimerRef.current = window.setInterval(() => {
setPendingGeneratedEntity((current) => {
if (!current || current.kind !== kind) {
return current;
}
const nextProgress = Math.min(
current.progress + (current.progress < 56 ? 11 : 5),
88,
);
return {
...current,
progress: nextProgress,
phaseLabel: resolvePendingPhaseLabel(kind, nextProgress),
};
});
}, 520);
};
const finishPendingProgress = () => {
stopPendingProgressTimer();
setPendingGeneratedEntity(null);
};
const markGeneratedAsRecent = (
kind: EntityGenerationKind,
generatedId: string,
) => {
setRecentGeneratedIds((current) => ({
...current,
[kind]: [generatedId, ...current[kind].filter((id) => id !== generatedId)].slice(
0,
6,
),
}));
};
const handleGenerateEntity = async (kind: EntityGenerationKind) => {
if (readOnly || isGenerating || pendingGeneratedEntity) {
return;
}
setLocalGenerationError(null);
startPendingProgress(kind);
try {
if (kind === 'playable') {
const nextNpc = await generateCustomWorldPlayableNpc({ profile });
onProfileChange(prependPlayableNpc(profile, nextNpc));
markGeneratedAsRecent('playable', nextNpc.id);
} else if (kind === 'story') {
const nextNpc = await generateCustomWorldStoryNpc({ profile });
onProfileChange(prependStoryNpc(profile, nextNpc));
markGeneratedAsRecent('story', nextNpc.id);
} else {
const nextLandmark = await generateCustomWorldLandmark({ profile });
onProfileChange(prependLandmark(profile, nextLandmark));
markGeneratedAsRecent('landmark', nextLandmark.id);
}
} catch (generationError) {
setLocalGenerationError(
generationError instanceof Error
? generationError.message
: '生成失败,请稍后重试。',
);
} finally {
finishPendingProgress();
}
};
const onRegenerate = () => {
if (isGenerating || !triggerRegenerate) return;
@@ -169,10 +350,24 @@ export function CustomWorldResultView({
if (ids.length === 0) return;
onProfileChange(removeLandmarksFromProfile(profile, ids));
};
const autoSaveBadge =
autoSaveState === 'saved' ? (
<div className="rounded-full border border-emerald-300/20 bg-emerald-500/10 px-3 py-1 text-[11px] text-emerald-100">
</div>
) : autoSaveState === 'saving' ? (
<div className="rounded-full border border-amber-300/20 bg-amber-500/10 px-3 py-1 text-[11px] text-amber-100">
</div>
) : autoSaveState === 'error' ? (
<div className="rounded-full border border-rose-300/20 bg-rose-500/10 px-3 py-1 text-[11px] text-rose-100">
</div>
) : null;
return (
<div className="flex h-full min-h-0 flex-col">
<div className="mb-4 flex justify-start">
<div className="mb-4 flex items-center justify-between gap-3">
<button
type="button"
onClick={onBack}
@@ -181,6 +376,7 @@ export function CustomWorldResultView({
>
{backLabel}
</button>
{autoSaveBadge}
</div>
<div className="min-h-0 flex-1 overflow-hidden">
@@ -197,8 +393,27 @@ export function CustomWorldResultView({
onCreateAction={
readOnly || !createTarget
? undefined
: () => setEditorTarget(createTarget)
: () => {
if (activeTab === 'playable') {
void handleGenerateEntity('playable');
return;
}
if (activeTab === 'story') {
void handleGenerateEntity('story');
return;
}
if (activeTab === 'landmarks') {
void handleGenerateEntity('landmark');
return;
}
setEditorTarget(createTarget);
}
}
createActionDisabled={Boolean(
isGenerating || pendingGeneratedEntity,
)}
pendingGeneratedEntity={pendingGeneratedEntity}
recentGeneratedIds={recentGeneratedIds}
readOnly={readOnly}
/>
</div>
@@ -225,6 +440,11 @@ export function CustomWorldResultView({
{error}
</div>
) : null}
{!error && localGenerationError ? (
<div className="mt-3 rounded-2xl border border-rose-400/18 bg-rose-500/10 px-4 py-3 text-sm leading-6 text-rose-100">
{localGenerationError}
</div>
) : null}
<div className="mt-4 flex flex-col gap-3">
{profile.generationStatus === 'key_only' ? (
@@ -250,10 +470,10 @@ export function CustomWorldResultView({
</SmallButton>
) : null}
{onSave ? (
{onEnterWorld ? (
<button
type="button"
onClick={onSave}
onClick={onEnterWorld}
disabled={isGenerating}
className={`pixel-nine-slice pixel-pressable text-left ${isGenerating ? 'opacity-55' : ''}`}
style={getNineSliceStyle(UI_CHROME.choiceButton, {
@@ -263,7 +483,7 @@ export function CustomWorldResultView({
>
<div className="flex items-center justify-between gap-4">
<span className="text-sm font-semibold text-white">
{saveActionLabel}
{enterWorldActionLabel}
</span>
<span className="text-white/60"></span>
</div>

File diff suppressed because it is too large Load Diff

View File

@@ -5,7 +5,7 @@ import {
} from '../../types';
export const MASTER_VISUAL_WIDTH = 1024;
export const MASTER_VISUAL_HEIGHT = 1536;
export const MASTER_VISUAL_HEIGHT = 1024;
export const GENERATED_FRAME_WIDTH = 192;
export const GENERATED_FRAME_HEIGHT = 256;
@@ -769,6 +769,34 @@ async function normalizeFrameSourceToDataUrl(
return canvas.toDataURL('image/png');
}
export async function normalizeMasterVisualSourceToDataUrl(
source: string,
options: {
applyChromaKey?: boolean;
} = {},
) {
const image = await loadImageFromSource(source);
const { canvas, context } = createCanvas(
MASTER_VISUAL_WIDTH,
MASTER_VISUAL_HEIGHT,
);
context.clearRect(0, 0, canvas.width, canvas.height);
drawContainedImage(context, image, {
width: canvas.width,
height: canvas.height,
});
if (options.applyChromaKey !== false) {
applyGreenScreenAlpha(context, canvas.width, canvas.height);
}
return {
dataUrl: canvas.toDataURL('image/png'),
width: canvas.width,
height: canvas.height,
};
}
function seekVideo(video: HTMLVideoElement, targetTime: number) {
return new Promise<void>((resolve, reject) => {
if (Math.abs(video.currentTime - targetTime) < 0.001) {

View File

@@ -6,6 +6,10 @@ import { fetchJson } from '../../editor/shared/jsonClient';
export const CHARACTER_VISUAL_GENERATE_API_PATH =
ASSET_API_PATHS.characterVisualGenerate;
export const CHARACTER_PROMPT_BUNDLE_GENERATE_API_PATH =
ASSET_API_PATHS.characterPromptBundleGenerate;
export const CHARACTER_WORKFLOW_CACHE_API_PATH =
ASSET_API_PATHS.characterWorkflowCache;
export const CHARACTER_VISUAL_PUBLISH_API_PATH =
ASSET_API_PATHS.characterVisualPublish;
export const CHARACTER_VISUAL_JOB_API_PATH = ASSET_API_PATHS.characterVisualJobs;
@@ -43,6 +47,43 @@ export type CharacterVisualDraft = {
height: number;
};
export type CharacterPromptBundlePayload = {
roleKind: 'playable' | 'story';
characterName: string;
roleTitle?: string;
roleLabel?: string;
description?: string;
backstory?: string;
personality?: string;
motivation?: string;
combatStyle?: string;
tags?: string[];
characterBriefText: string;
};
export type CharacterPromptBundleResult = {
ok: true;
visualPromptText: string;
animationPromptText: string;
scenePromptText: string;
source: 'llm' | 'fallback';
model: string | null;
};
export type CharacterAssetWorkflowCache = {
characterId: string;
visualPromptText: string;
animationPromptText: string;
visualDrafts: CharacterVisualDraft[];
selectedVisualDraftId: string;
selectedAnimation: string;
imageSrc?: string;
generatedVisualAssetId?: string;
generatedAnimationSetId?: string;
animationMap?: Record<string, unknown> | null;
updatedAt?: string;
};
export type CharacterVisualGenerationPayload = {
characterId: string;
sourceMode: Exclude<CharacterVisualSourceMode, 'upload'>;
@@ -129,7 +170,41 @@ export async function generateCharacterVisualCandidates(
model: string;
prompt: string;
drafts: CharacterVisualDraft[];
}>(CHARACTER_VISUAL_GENERATE_API_PATH, payload, '生成角色主形象候选失败');
}>(CHARACTER_VISUAL_GENERATE_API_PATH, payload, '生成角色主形象失败');
}
export async function generateCharacterPromptBundle(
payload: CharacterPromptBundlePayload,
) {
return postApiJson<CharacterPromptBundleResult>(
CHARACTER_PROMPT_BUNDLE_GENERATE_API_PATH,
payload,
'生成默认提示词失败',
);
}
export async function fetchCharacterWorkflowCache(characterId: string) {
return fetchJson<{
ok: true;
cache: CharacterAssetWorkflowCache | null;
}>(
`${CHARACTER_WORKFLOW_CACHE_API_PATH}/${encodeURIComponent(characterId)}`,
'读取角色形象生成缓存失败',
);
}
export async function saveCharacterWorkflowCache(
payload: CharacterAssetWorkflowCache,
) {
return postApiJson<{
ok: true;
cache: CharacterAssetWorkflowCache;
saveMessage: string;
}>(
CHARACTER_WORKFLOW_CACHE_API_PATH,
payload,
'保存角色形象生成缓存失败',
);
}
export async function fetchCharacterVisualJobStatus(taskId: string) {

View File

@@ -0,0 +1,76 @@
export type PromptDefaultRole = {
name: string;
title: string;
role: string;
description?: string;
backstory?: string;
personality?: string;
motivation?: string;
combatStyle?: string;
tags?: string[];
};
export type CustomWorldRolePromptBundle = {
visualPromptText: string;
animationPromptText: string;
scenePromptText: string;
};
function cleanSeedText(value: string | undefined, maxLength: number) {
return (value ?? '').replace(/\s+/gu, ' ').trim().slice(0, maxLength);
}
export function buildDefaultRolePromptBundle(
role: PromptDefaultRole,
): CustomWorldRolePromptBundle {
const characterName = cleanSeedText(role.name, 40) || '该角色';
const roleAnchor =
[cleanSeedText(role.title, 60), cleanSeedText(role.role, 60)]
.filter(Boolean)
.join(' / ') || '关键角色';
const descriptionAnchor =
cleanSeedText(role.description, 220) ||
cleanSeedText(role.backstory, 260) ||
cleanSeedText(role.personality, 160) ||
'识别度鲜明';
const combatAnchor =
cleanSeedText(role.combatStyle, 180) ||
cleanSeedText(role.motivation, 180) ||
'动作重心稳定';
const tagAnchor =
role.tags && role.tags.length > 0
? `保留 ${role.tags.slice(0, 8).join('、')} 的角色识别点。`
: '';
return {
visualPromptText: [
`${characterName}${roleAnchor}`,
'单人全身2D 横版 RPG 角色主图,侧身朝右,脚底完整可见,服装、发型、武器与轮廓保持稳定清楚。',
`外观气质围绕:${descriptionAnchor}`,
`动作识别点参考:${combatAnchor}`,
tagAnchor,
'构图干净,主体明确,不做正面立绘,不做夸张透视。',
]
.filter(Boolean)
.join(' '),
animationPromptText: [
`${characterName}核心动作试片。`,
'保持同一角色的服装、发型、武器和体型一致,镜头稳定,侧身朝右,动作连贯自然。',
`动作气质参考:${combatAnchor}`,
role.personality ? `角色状态补充:${cleanSeedText(role.personality, 160)}` : '',
'起手清楚,发力明确,收招干净,避免漂移、乱摆和形体变形。',
]
.filter(Boolean)
.join(' '),
scenePromptText: [
`${characterName}关联主场景,适合作为首次登场区域或常驻活动空间。`,
'16:9 横版 RPG 场景背景,上半部分突出中远景氛围,下半部分是清晰可站立地面。',
`场景叙事气质围绕:${descriptionAnchor}`,
role.backstory ? `环境背景可埋入:${cleanSeedText(role.backstory, 260)}` : '',
role.motivation ? `场景目标暗示可参考:${cleanSeedText(role.motivation, 160)}` : '',
'整体风格统一克制,适合作为剧情探索与战斗底图。',
]
.filter(Boolean)
.join(' '),
};
}

View File

@@ -17,6 +17,8 @@ import { type ComponentType, useMemo } from 'react';
import type {
CustomWorldGalleryCard,
CustomWorldLibraryEntry,
ProfileDashboardCardKey,
ProfileDashboardSummary,
} from '../../../packages/shared/src/contracts/runtime';
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
import type { AuthUser } from '../../services/authService';
@@ -72,7 +74,11 @@ function WorldCard({
const coverImage = resolvePlatformWorldCoverImage(entry);
const leadPortrait = resolvePlatformWorldLeadPortrait(entry);
const tags = [
...new Set(buildPlatformWorldTags(entry).map((tag) => tag.trim()).filter(Boolean)),
...new Set(
buildPlatformWorldTags(entry)
.map((tag) => tag.trim())
.filter(Boolean),
),
].slice(0, 3);
return (
@@ -224,19 +230,53 @@ function describeBindingStatus(bindingStatus: AuthUser['bindingStatus']) {
return bindingStatus === 'pending_bind_phone' ? '待绑定手机号' : '正常';
}
function formatPlayTime(playTimeMs: number) {
const totalSeconds = Math.max(0, Math.floor(playTimeMs / 1000));
const days = Math.floor(totalSeconds / 86400);
const hours = Math.floor((totalSeconds % 86400) / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
function formatCompactPlayTime(playTimeMs: number) {
const totalMinutes = Math.max(0, Math.floor(playTimeMs / 60000));
const days = totalMinutes / 1440;
if (days > 0) {
return `${days}${hours}小时`;
if (days >= 10) {
return `${Math.floor(days)}`;
}
if (hours > 0) {
return `${hours}小时 ${minutes}`;
if (days >= 1) {
return `${days.toFixed(days >= 3 ? 0 : 1)}`;
}
return `${minutes}`;
const hours = totalMinutes / 60;
if (hours >= 1) {
return `${hours.toFixed(hours >= 10 ? 0 : 1)}小时`;
}
return `${Math.max(0, totalMinutes)}`;
}
function formatDashboardCount(value: number) {
const normalizedValue = Math.max(0, Math.round(value));
if (normalizedValue >= 100000000) {
return `${(normalizedValue / 100000000).toFixed(1)}亿`;
}
if (normalizedValue >= 10000) {
return `${(normalizedValue / 10000).toFixed(1)}`;
}
return normalizedValue.toLocaleString('zh-CN');
}
function formatDashboardUpdatedAt(value: string | null | undefined) {
if (!value) {
return '暂无更新记录';
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return value;
}
return date.toLocaleString('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
}
function buildPublicUserCode(user: AuthUser | null | undefined) {
@@ -249,7 +289,9 @@ function buildPublicUserCode(user: AuthUser | null | undefined) {
}
function getUserAvatarLabel(user: AuthUser | null | undefined) {
return (user?.displayName || user?.username || '叙').slice(0, 1).toUpperCase();
return (user?.displayName || user?.username || '叙')
.slice(0, 1)
.toUpperCase();
}
function copyText(value: string) {
@@ -261,23 +303,40 @@ function copyText(value: string) {
}
function ProfileStatCard({
cardKey,
label,
value,
onClick,
icon,
}: {
cardKey: ProfileDashboardCardKey;
label: string;
value: string;
onClick?: ((cardKey: ProfileDashboardCardKey) => void) | null;
icon: ComponentType<{ className?: string }>;
}) {
const Icon = icon;
return (
<div className="rounded-[1.35rem] border border-white/10 bg-black/18 px-4 py-3">
<button
type="button"
onClick={onClick ? () => onClick(cardKey) : undefined}
className="rounded-[1.35rem] border border-white/10 bg-black/18 px-4 py-3 text-left transition hover:border-white/20 hover:bg-white/6"
>
<div className="flex items-center gap-2 text-zinc-400">
<Icon className="h-4 w-4" />
<span className="text-[11px] tracking-[0.16em]">{label}</span>
</div>
<div className="mt-3 text-lg font-black text-white">{value}</div>
</button>
);
}
function ProfileStatCardSkeleton() {
return (
<div className="rounded-[1.35rem] border border-white/10 bg-black/18 px-4 py-3">
<div className="h-4 w-20 animate-pulse rounded-full bg-white/10" />
<div className="mt-3 h-7 w-16 animate-pulse rounded-full bg-white/12" />
</div>
);
}
@@ -316,13 +375,20 @@ export function PlatformHomeView({
latestEntries,
myEntries,
historyEntries,
historyError,
profileDashboard,
isLoadingPlatform,
isLoadingDashboard,
isClearingHistory,
platformError,
dashboardError,
onContinueGame,
onClearHistory,
onOpenCreateWorld,
onOpenCreateTypePicker,
onOpenGalleryDetail,
onOpenLibraryDetail,
onOpenProfileDashboardCard,
}: {
activeTab: PlatformHomeTab;
onTabChange: (tab: PlatformHomeTab) => void;
@@ -332,15 +398,22 @@ export function PlatformHomeView({
latestEntries: CustomWorldGalleryCard[];
myEntries: CustomWorldLibraryEntry<CustomWorldProfile>[];
historyEntries: PlatformBrowseHistoryEntry[];
historyError: string | null;
profileDashboard: ProfileDashboardSummary | null;
isLoadingPlatform: boolean;
isLoadingDashboard: boolean;
isClearingHistory: boolean;
platformError: string | null;
dashboardError: string | null;
onContinueGame: () => void;
onClearHistory: () => void;
onOpenCreateWorld: () => void;
onOpenCreateTypePicker: () => void;
onOpenGalleryDetail: (entry: CustomWorldGalleryCard) => void;
onOpenLibraryDetail: (
entry: CustomWorldLibraryEntry<CustomWorldProfile>,
) => void;
onOpenProfileDashboardCard?: (cardKey: ProfileDashboardCardKey) => void;
}) {
const authUi = useAuthUi();
const featuredShelf = useMemo(
@@ -362,11 +435,11 @@ export function PlatformHomeView({
'上一次冒险已经保存,可以从这里继续推进故事。';
const publicUserCode = buildPublicUserCode(authUi?.user);
const avatarLabel = getUserAvatarLabel(authUi?.user);
const remainingNarrativeCoins = savedSnapshot?.gameState.playerCurrency ?? 0;
const totalPlayTime = formatPlayTime(
savedSnapshot?.gameState.runtimeStats.playTimeMs ?? 0,
const remainingNarrativeCoins = profileDashboard?.walletBalance ?? 0;
const totalPlayTime = formatCompactPlayTime(
profileDashboard?.totalPlayTimeMs ?? 0,
);
const playedWorkCount = hasSavedGame ? 1 : 0;
const playedWorkCount = profileDashboard?.playedWorldCount ?? 0;
const tabIcons = {
home: "/Icons/Admurin's Pixel Items/Admurin's Pixel Items/Miscellaneous/Singles/192_RustyTrinket_House.png",
create: '/Icons/01_Scroll.png',
@@ -647,21 +720,66 @@ export function PlatformHomeView({
})}
>
<div className="grid grid-cols-3 gap-3">
<ProfileStatCard
label="剩余叙世币"
value={`${remainingNarrativeCoins}`}
icon={Coins}
/>
<ProfileStatCard
label="总游戏时长"
value={totalPlayTime}
icon={Clock3}
/>
<ProfileStatCard
label="玩过作品"
value={`${playedWorkCount}`}
icon={BookOpen}
/>
{isLoadingDashboard ? (
<>
<ProfileStatCardSkeleton />
<ProfileStatCardSkeleton />
<ProfileStatCardSkeleton />
</>
) : dashboardError ? (
<>
<ProfileStatCard
cardKey="wallet"
label="剩余叙世币"
value="暂不可用"
icon={Coins}
onClick={onOpenProfileDashboardCard}
/>
<ProfileStatCard
cardKey="playTime"
label="总游戏时长"
value="暂不可用"
icon={Clock3}
onClick={onOpenProfileDashboardCard}
/>
<ProfileStatCard
cardKey="playedWorks"
label="玩过作品"
value="暂不可用"
icon={BookOpen}
onClick={onOpenProfileDashboardCard}
/>
</>
) : (
<>
<ProfileStatCard
cardKey="wallet"
label="剩余叙世币"
value={formatDashboardCount(remainingNarrativeCoins)}
icon={Coins}
onClick={onOpenProfileDashboardCard}
/>
<ProfileStatCard
cardKey="playTime"
label="总游戏时长"
value={totalPlayTime}
icon={Clock3}
onClick={onOpenProfileDashboardCard}
/>
<ProfileStatCard
cardKey="playedWorks"
label="玩过作品"
value={formatDashboardCount(playedWorkCount)}
icon={BookOpen}
onClick={onOpenProfileDashboardCard}
/>
</>
)}
</div>
<div className="mt-3 text-[11px] text-zinc-500">
{dashboardError
? dashboardError
: `更新于 ${formatDashboardUpdatedAt(profileDashboard?.updatedAt)}`}
</div>
</section>
@@ -719,8 +837,27 @@ export function PlatformHomeView({
paddingY: 14,
})}
>
<SectionHeader title="历史浏览" detail="最近看过的作品" />
{historyEntries.length > 0 ? (
<div className="mb-3 flex items-start justify-between gap-3">
<SectionHeader title="历史浏览" detail="最近看过的作品" />
{historyEntries.length > 0 ? (
<button
type="button"
onClick={onClearHistory}
disabled={isClearingHistory}
className="shrink-0 rounded-full border border-white/10 bg-black/18 px-3 py-1.5 text-[11px] text-zinc-300 transition hover:border-white/20 hover:text-white disabled:cursor-not-allowed disabled:opacity-55"
>
{isClearingHistory ? '清空中' : '清空'}
</button>
) : null}
</div>
{historyError ? (
<div className="mb-3 rounded-2xl border border-rose-400/20 bg-rose-500/10 px-4 py-3 text-sm leading-6 text-rose-100">
{historyError}
</div>
) : null}
{isLoadingPlatform && historyEntries.length === 0 ? (
<EmptyShelf text="正在读取浏览历史..." />
) : historyEntries.length > 0 ? (
<div className="flex gap-3 overflow-x-auto pb-1 scrollbar-hide">
{historyEntries.map((entry) => (
<button
@@ -771,7 +908,9 @@ export function PlatformHomeView({
{entry.authorDisplayName}
</div>
<div className="mt-2 line-clamp-3 text-xs leading-5 text-zinc-400">
{entry.summaryText || entry.subtitle || '等待补充世界摘要。'}
{entry.summaryText ||
entry.subtitle ||
'等待补充世界摘要。'}
</div>
</div>
</div>
@@ -815,7 +954,9 @@ export function PlatformHomeView({
<Settings className="h-[1.125rem] w-[1.125rem]" />
</div>
<div>
<div className="text-base font-semibold text-white"></div>
<div className="text-base font-semibold text-white">
</div>
<div className="text-xs text-zinc-400"></div>
</div>
</div>

View File

@@ -12,12 +12,18 @@ import {
getCustomWorldAgentOperation,
getCustomWorldAgentSession,
} from '../../services/aiService';
import type { AuthUser } from '../../services/authService';
import {
clearProfileBrowseHistory,
getProfileDashboard,
listCustomWorldGallery,
listCustomWorldLibrary,
listProfileBrowseHistory,
upsertCustomWorldProfile,
upsertProfileBrowseHistory,
} from '../../services/storageService';
import type { GameState } from '../../types';
import { AuthUiContext } from '../auth/AuthUiContext';
import {
PreGameSelectionFlow,
type SelectionStage,
@@ -33,11 +39,16 @@ vi.mock('../../services/aiService', () => ({
}));
vi.mock('../../services/storageService', () => ({
clearProfileBrowseHistory: vi.fn(),
getCustomWorldGalleryDetail: vi.fn(),
getProfileDashboard: vi.fn(),
listCustomWorldGallery: vi.fn(),
listCustomWorldLibrary: vi.fn(),
listProfileBrowseHistory: vi.fn(),
publishCustomWorldProfile: vi.fn(),
syncProfileBrowseHistory: vi.fn(),
unpublishCustomWorldProfile: vi.fn(),
upsertProfileBrowseHistory: vi.fn(),
upsertCustomWorldProfile: vi.fn(),
}));
@@ -108,11 +119,21 @@ const mockSession: CustomWorldAgentSessionSnapshot = {
updatedAt: '2026-04-14T12:00:00.000Z',
};
function TestWrapper() {
const mockAuthUser: AuthUser = {
id: 'user-1',
username: 'tester',
displayName: '测试玩家',
phoneNumberMasked: null,
loginMethod: 'password',
bindingStatus: 'active',
wechatBound: false,
};
function TestWrapper({ withAuth = false }: { withAuth?: boolean } = {}) {
const [selectionStage, setSelectionStage] =
useState<SelectionStage>('platform');
return (
const content = (
<PreGameSelectionFlow
selectionStage={selectionStage}
setSelectionStage={setSelectionStage}
@@ -124,14 +145,41 @@ function TestWrapper() {
handleCustomWorldSelect={() => {}}
/>
);
if (!withAuth) {
return content;
}
return (
<AuthUiContext.Provider
value={{
user: mockAuthUser,
openAccountModal: () => {},
logout: async () => {},
setGlobalAccountActionsVisible: () => {},
}}
>
{content}
</AuthUiContext.Provider>
);
}
beforeEach(() => {
vi.clearAllMocks();
window.history.replaceState(null, '', '/');
window.sessionStorage.clear();
window.localStorage.clear();
vi.mocked(getProfileDashboard).mockResolvedValue({
walletBalance: 0,
totalPlayTimeMs: 0,
playedWorldCount: 0,
updatedAt: '2026-04-16T12:00:00.000Z',
});
vi.mocked(listCustomWorldLibrary).mockResolvedValue([]);
vi.mocked(listCustomWorldGallery).mockResolvedValue([]);
vi.mocked(listProfileBrowseHistory).mockResolvedValue([]);
vi.mocked(upsertProfileBrowseHistory).mockResolvedValue([]);
vi.mocked(clearProfileBrowseHistory).mockResolvedValue([]);
vi.mocked(upsertCustomWorldProfile).mockResolvedValue({
entry: {
ownerUserId: 'user-1',
@@ -350,26 +398,56 @@ test('existing draft sessions enter the legacy result layout directly', async ()
await waitFor(
async () => {
expect(await screen.findByText('世界档案')).toBeTruthy();
expect(
screen.getByRole('button', {
name: /||/u,
}),
).toBeTruthy();
expect(screen.getByText('已自动保存')).toBeTruthy();
expect(screen.getByRole('button', { name: //u })).toBeTruthy();
},
{ timeout: 2500 },
);
expect(screen.queryByText(/Agent/u)).toBeNull();
expect(screen.queryByRole('button', { name: /^/u })).toBeNull();
expect(screen.getByText(//u)).toBeTruthy();
expect(screen.getByText(//u)).toBeTruthy();
await user.click(screen.getByRole('button', { name: //u }));
await user.click(screen.getByRole('button', { name: //u }));
expect(await screen.findByText(//u)).toBeTruthy();
expect(
screen.getByRole('button', { name: /AI/u }),
).toBeTruthy();
expect(screen.getByRole('button', { name: /AI/u })).toBeTruthy();
expect(screen.getByText('技能')).toBeTruthy();
});
test('profile tab loads server browse history and can clear it after confirmation', async () => {
const user = userEvent.setup();
vi.mocked(listProfileBrowseHistory).mockResolvedValue([
{
ownerUserId: 'author-1',
profileId: 'world-1',
worldName: '潮雾列岛',
subtitle: '旧灯塔与失控航路',
summaryText: '最近浏览过的公开作品。',
coverImageSrc: null,
themeMode: 'tide',
authorDisplayName: '潮汐作者',
visitedAt: '2026-04-16T12:00:00.000Z',
},
]);
vi.mocked(clearProfileBrowseHistory).mockResolvedValue([]);
vi.spyOn(window, 'confirm').mockReturnValue(true);
render(<TestWrapper withAuth />);
await user.click(screen.getByRole('button', { name: '我的' }));
expect(await screen.findByText('潮雾列岛')).toBeTruthy();
await user.click(screen.getByRole('button', { name: '清空' }));
await waitFor(() => {
expect(clearProfileBrowseHistory).toHaveBeenCalledTimes(1);
});
expect(
screen.getByText('你最近还没有浏览过作品详情,去首页或发现逛一逛吧。'),
).toBeTruthy();
});

File diff suppressed because it is too large Load Diff