1
This commit is contained in:
@@ -7,16 +7,16 @@
|
||||
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 {
|
||||
buildCustomWorldFoundationEntries,
|
||||
parseFoundationTagText,
|
||||
} from '../services/customWorldFoundationEntries';
|
||||
import { normalizeCustomWorldCreatorIntent } from '../services/customWorldCreatorIntent';
|
||||
import {
|
||||
AnimationState,
|
||||
@@ -557,226 +557,6 @@ function resolvePlayableRolePreviewImage(
|
||||
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>,
|
||||
@@ -791,91 +571,6 @@ function buildOpeningSceneSearchText(
|
||||
].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];
|
||||
@@ -1042,7 +737,7 @@ export function CustomWorldEntityCatalog({
|
||||
[deferredSearch, landmarkById, profile.landmarks, storyNpcById],
|
||||
);
|
||||
const structuredFoundationEntries = useMemo(
|
||||
() => buildStructuredFoundationEntries(profile),
|
||||
() => buildCustomWorldFoundationEntries(profile),
|
||||
[profile],
|
||||
);
|
||||
const normalizedCreatorIntent = useMemo(
|
||||
@@ -1376,14 +1071,14 @@ export function CustomWorldEntityCatalog({
|
||||
actions={
|
||||
readOnly ? (
|
||||
<SmallButton
|
||||
onClick={() => onEditTarget({ kind: 'world' })}
|
||||
onClick={() => onEditTarget({ kind: 'foundation' })}
|
||||
tone="sky"
|
||||
>
|
||||
查看详情
|
||||
</SmallButton>
|
||||
) : (
|
||||
<SmallButton
|
||||
onClick={() => onEditTarget({ kind: 'world' })}
|
||||
onClick={() => onEditTarget({ kind: 'foundation' })}
|
||||
tone="sky"
|
||||
>
|
||||
编辑
|
||||
@@ -1401,9 +1096,22 @@ export function CustomWorldEntityCatalog({
|
||||
<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>
|
||||
{entry.value ? (
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{parseFoundationTagText(entry.value).map((tag, index) => (
|
||||
<span
|
||||
key={`${entry.id}-${index}-${tag}`}
|
||||
className="rounded-full border border-white/10 bg-white/[0.06] px-3 py-1 text-xs leading-5 text-zinc-100"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-2 text-sm leading-7 text-zinc-100">
|
||||
待补充
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -479,6 +479,81 @@ test('场景角色修改后右上角关闭才弹确认', async () => {
|
||||
expect(screen.getAllByText('确认关闭').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('世界页基本设定编辑按钮打开基本设定编辑目标', async () => {
|
||||
const user = userEvent.setup();
|
||||
const handleEditTarget = vi.fn();
|
||||
|
||||
render(
|
||||
<CustomWorldEntityCatalog
|
||||
profile={createProfile()}
|
||||
previewCharacters={[]}
|
||||
activeTab="world"
|
||||
onActiveTabChange={() => {}}
|
||||
onEditTarget={handleEditTarget}
|
||||
onProfileChange={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const editButtons = screen.getAllByRole('button', { name: '编辑' });
|
||||
const foundationEditButton = editButtons[1];
|
||||
expect(foundationEditButton).toBeDefined();
|
||||
await user.click(foundationEditButton as HTMLElement);
|
||||
|
||||
expect(handleEditTarget).toHaveBeenCalledWith({ kind: 'foundation' });
|
||||
});
|
||||
|
||||
test('基本设定用分号拆分成标签展示', () => {
|
||||
const profile = {
|
||||
...createProfile(),
|
||||
anchorContent: {
|
||||
worldPromise: {
|
||||
hook: '机械微生物吞并进化',
|
||||
differentiator: '角色被迫寄生改造',
|
||||
desiredExperience: '在失控系统里求生',
|
||||
},
|
||||
playerFantasy: null,
|
||||
themeBoundary: null,
|
||||
playerEntryPoint: null,
|
||||
coreConflict: null,
|
||||
keyRelationships: [],
|
||||
hiddenLines: null,
|
||||
iconicElements: null,
|
||||
},
|
||||
} as CustomWorldProfile;
|
||||
|
||||
render(
|
||||
<CustomWorldEntityCatalog
|
||||
profile={profile}
|
||||
previewCharacters={[]}
|
||||
activeTab="world"
|
||||
onActiveTabChange={() => {}}
|
||||
onEditTarget={() => {}}
|
||||
onProfileChange={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const foundationSection = screen.getByText('世界承诺').closest('div');
|
||||
|
||||
expect(foundationSection).not.toBeNull();
|
||||
expect(screen.getByText('机械微生物吞并进化')).toBeTruthy();
|
||||
expect(screen.getByText('角色被迫寄生改造')).toBeTruthy();
|
||||
expect(screen.getByText('在失控系统里求生')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('基本设定目标打开独立编辑面板', () => {
|
||||
render(
|
||||
<RpgCreationEntityEditorModal
|
||||
profile={createProfile()}
|
||||
target={{ kind: 'foundation' }}
|
||||
onClose={vi.fn()}
|
||||
onProfileChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('编辑基本设定')).toBeTruthy();
|
||||
expect(screen.queryByText('编辑世界信息')).toBeNull();
|
||||
});
|
||||
|
||||
test('可扮演角色列表使用缩略卡片并点击进入编辑', async () => {
|
||||
const user = userEvent.setup();
|
||||
const handleEditTarget = vi.fn();
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
type BigFishAgentWorkspaceProps = {
|
||||
session: BigFishSessionSnapshotResponse | null;
|
||||
streamingReplyText?: string;
|
||||
isStreamingReply?: boolean;
|
||||
isBusy?: boolean;
|
||||
error?: string | null;
|
||||
onBack: () => void;
|
||||
@@ -72,6 +73,7 @@ function mapBigFishSession(
|
||||
export function BigFishAgentWorkspace({
|
||||
session,
|
||||
streamingReplyText = '',
|
||||
isStreamingReply = false,
|
||||
isBusy = false,
|
||||
error = null,
|
||||
onBack,
|
||||
@@ -86,7 +88,7 @@ export function BigFishAgentWorkspace({
|
||||
composerPlaceholder="说说这局的生态、成长或爽点..."
|
||||
primaryActionLabel="生成结果页"
|
||||
streamingReplyText={streamingReplyText}
|
||||
isStreamingReply={Boolean(streamingReplyText)}
|
||||
isStreamingReply={isStreamingReply}
|
||||
isBusy={isBusy}
|
||||
error={error}
|
||||
quickActions={createCreationAgentChatQuickActions()}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import * as creationAgentServices from '../../services/creation-agent';
|
||||
import { createCreationAgentChatQuickActions } from '../../services/creation-agent';
|
||||
|
||||
import {
|
||||
type CreationAgentTheme,
|
||||
CreationAgentWorkspace,
|
||||
@@ -58,9 +58,9 @@ test('creation agent workspace keeps initial chat progress at zero percent', ()
|
||||
const progressbar = screen.getByRole('progressbar');
|
||||
|
||||
expect(progressbar.getAttribute('aria-valuenow')).toBe('0');
|
||||
expect((progressbar.firstElementChild as HTMLElement | null)?.style.width).toBe(
|
||||
'0%',
|
||||
);
|
||||
expect(
|
||||
(progressbar.firstElementChild as HTMLElement | null)?.style.width,
|
||||
).toBe('0%');
|
||||
});
|
||||
|
||||
test('creation agent workspace filters duplicate recommended replies', () => {
|
||||
@@ -153,6 +153,41 @@ test('creation agent workspace renders streaming assistant text', () => {
|
||||
expect(screen.getByText(/那我先顺着这个方向收一下/u)).toBeTruthy();
|
||||
});
|
||||
|
||||
test('creation agent workspace renders waiting dots before first streamed token', () => {
|
||||
ensureScrollApis();
|
||||
|
||||
render(
|
||||
<CreationAgentWorkspace
|
||||
session={{
|
||||
sessionId: 'creation-agent-session-1',
|
||||
title: '统一共创',
|
||||
currentTurn: 1,
|
||||
progressPercent: 20,
|
||||
anchors: [],
|
||||
messages: [
|
||||
{
|
||||
id: 'message-1',
|
||||
role: 'user',
|
||||
kind: 'chat',
|
||||
text: '我想做一个潮湿压抑的海上世界。',
|
||||
},
|
||||
],
|
||||
}}
|
||||
theme={testTheme}
|
||||
loadingText="正在准备"
|
||||
composerPlaceholder="输入消息"
|
||||
primaryActionLabel="生成结果页"
|
||||
streamingReplyText=""
|
||||
isStreamingReply
|
||||
onBack={() => {}}
|
||||
onSubmitText={() => {}}
|
||||
onPrimaryAction={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('creation-agent-waiting-dots')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('creation agent workspace appends streaming assistant message after stable message list', () => {
|
||||
ensureScrollApis();
|
||||
|
||||
@@ -200,7 +235,9 @@ test('creation agent workspace appends streaming assistant message after stable
|
||||
const bubbles = screen
|
||||
.getByTestId('creation-agent-message-list')
|
||||
.querySelectorAll('.whitespace-pre-wrap');
|
||||
const bubbleTexts = Array.from(bubbles).map((node) => node.textContent?.trim());
|
||||
const bubbleTexts = Array.from(bubbles).map((node) =>
|
||||
node.textContent?.trim(),
|
||||
);
|
||||
|
||||
expect(bubbleTexts).toEqual([
|
||||
'我想做一个潮湿压抑的海上世界。',
|
||||
@@ -411,3 +448,104 @@ test('creation agent workspace stops auto-follow when user scrolls away from bot
|
||||
|
||||
expect(scrollToSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('creation agent workspace appends parsed document text into composer', async () => {
|
||||
ensureScrollApis();
|
||||
|
||||
vi.spyOn(
|
||||
creationAgentServices,
|
||||
'parseCreationAgentDocumentInput',
|
||||
).mockResolvedValue({
|
||||
document: {
|
||||
fileName: '世界设定.md',
|
||||
contentType: 'text/markdown',
|
||||
sizeBytes: 24,
|
||||
text: '第一章:潮湿的港口',
|
||||
},
|
||||
});
|
||||
|
||||
render(
|
||||
<CreationAgentWorkspace
|
||||
session={{
|
||||
sessionId: 'creation-agent-session-1',
|
||||
title: null,
|
||||
currentTurn: 0,
|
||||
progressPercent: 0,
|
||||
anchors: [],
|
||||
messages: [],
|
||||
}}
|
||||
theme={testTheme}
|
||||
loadingText="正在准备"
|
||||
composerPlaceholder="输入消息"
|
||||
primaryActionLabel="生成结果页"
|
||||
onBack={() => {}}
|
||||
onSubmitText={() => {}}
|
||||
onPrimaryAction={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('输入消息'), {
|
||||
target: {
|
||||
value: '已有方向',
|
||||
},
|
||||
});
|
||||
|
||||
const input = document.querySelector<HTMLInputElement>('input[type="file"]');
|
||||
expect(input).toBeTruthy();
|
||||
|
||||
fireEvent.change(input!, {
|
||||
target: {
|
||||
files: [new File(['unused'], '世界设定.md', { type: 'text/markdown' })],
|
||||
},
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
(screen.getByPlaceholderText('输入消息') as HTMLTextAreaElement).value,
|
||||
).toBe('已有方向\n\n第一章:潮湿的港口');
|
||||
});
|
||||
});
|
||||
|
||||
test('creation agent workspace shows document parse error near composer', async () => {
|
||||
ensureScrollApis();
|
||||
|
||||
vi.spyOn(
|
||||
creationAgentServices,
|
||||
'parseCreationAgentDocumentInput',
|
||||
).mockRejectedValue(new Error('暂时只支持 txt、md、csv、json 文本文档。'));
|
||||
|
||||
render(
|
||||
<CreationAgentWorkspace
|
||||
session={{
|
||||
sessionId: 'creation-agent-session-1',
|
||||
title: null,
|
||||
currentTurn: 0,
|
||||
progressPercent: 0,
|
||||
anchors: [],
|
||||
messages: [],
|
||||
}}
|
||||
theme={testTheme}
|
||||
loadingText="正在准备"
|
||||
composerPlaceholder="输入消息"
|
||||
primaryActionLabel="生成结果页"
|
||||
onBack={() => {}}
|
||||
onSubmitText={() => {}}
|
||||
onPrimaryAction={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const input = document.querySelector<HTMLInputElement>('input[type="file"]');
|
||||
expect(input).toBeTruthy();
|
||||
|
||||
fireEvent.change(input!, {
|
||||
target: {
|
||||
files: [new File(['unused'], '世界设定.docx')],
|
||||
},
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText('暂时只支持 txt、md、csv、json 文本文档。'),
|
||||
).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { ArrowLeft, Send, Sparkles } from 'lucide-react';
|
||||
import { ArrowLeft, Paperclip, Send, Sparkles } from 'lucide-react';
|
||||
import type { ChangeEvent } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import {
|
||||
type CreationAgentProgressCopy,
|
||||
normalizeCreationAgentProgress,
|
||||
parseCreationAgentDocumentInput,
|
||||
resolveCreationAgentProgressHint,
|
||||
} from '../../services/creation-agent';
|
||||
|
||||
@@ -80,12 +82,13 @@ type CreationAgentWorkspaceProps = {
|
||||
};
|
||||
|
||||
const AUTO_SCROLL_FOLLOW_THRESHOLD_PX = 96;
|
||||
const DOCUMENT_INPUT_ACCEPT =
|
||||
'.txt,.md,.markdown,.csv,.json,text/plain,text/markdown,text/csv,application/json';
|
||||
|
||||
function uniqueRecommendedReplies(recommendedReplies: string[] = []) {
|
||||
return [...new Set(recommendedReplies.map((reply) => reply.trim()).filter(Boolean))].slice(
|
||||
0,
|
||||
3,
|
||||
);
|
||||
return [
|
||||
...new Set(recommendedReplies.map((reply) => reply.trim()).filter(Boolean)),
|
||||
].slice(0, 3);
|
||||
}
|
||||
|
||||
function CreationAgentOperationBanner({
|
||||
@@ -178,9 +181,8 @@ function CreationAgentMessageBubble({
|
||||
}) {
|
||||
const isUser = message.role === 'user';
|
||||
const isSystem = message.role === 'system';
|
||||
const visibleRecommendedReplies = isUser || isStreaming
|
||||
? []
|
||||
: uniqueRecommendedReplies(recommendedReplies);
|
||||
const visibleRecommendedReplies =
|
||||
isUser || isStreaming ? [] : uniqueRecommendedReplies(recommendedReplies);
|
||||
const bubbleToneClass = isUser
|
||||
? theme.userBubbleClass
|
||||
: isSystem
|
||||
@@ -201,7 +203,11 @@ function CreationAgentMessageBubble({
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-1.5 py-1">
|
||||
<div
|
||||
data-testid="creation-agent-waiting-dots"
|
||||
aria-label="等待回复"
|
||||
className="flex items-center gap-1.5 py-1"
|
||||
>
|
||||
<span className="h-2 w-2 animate-pulse rounded-full bg-[var(--platform-text-soft)] [animation-delay:-0.2s]" />
|
||||
<span className="h-2 w-2 animate-pulse rounded-full bg-[var(--platform-text-soft)] [animation-delay:-0.1s]" />
|
||||
<span className="h-2 w-2 animate-pulse rounded-full bg-[var(--platform-text-soft)]" />
|
||||
@@ -238,7 +244,10 @@ function shouldShowQuickAction(
|
||||
return false;
|
||||
}
|
||||
|
||||
if (typeof action.minTurn === 'number' && session.currentTurn < action.minTurn) {
|
||||
if (
|
||||
typeof action.minTurn === 'number' &&
|
||||
session.currentTurn < action.minTurn
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -287,8 +296,13 @@ export function CreationAgentWorkspace({
|
||||
onQuickAction,
|
||||
}: CreationAgentWorkspaceProps) {
|
||||
const [draftText, setDraftText] = useState('');
|
||||
const [documentInputError, setDocumentInputError] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [isParsingDocumentInput, setIsParsingDocumentInput] = useState(false);
|
||||
// 统一聊天区只在用户仍停留在底部附近时跟随新内容,避免流式回复持续抢走阅读位置。
|
||||
const messageListRef = useRef<HTMLDivElement | null>(null);
|
||||
const documentInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const shouldAutoScrollRef = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -298,7 +312,12 @@ export function CreationAgentWorkspace({
|
||||
}
|
||||
|
||||
scrollMessageListToBottom(container);
|
||||
}, [session?.sessionId, session?.messages, streamingReplyText, isStreamingReply]);
|
||||
}, [
|
||||
session?.sessionId,
|
||||
session?.messages,
|
||||
streamingReplyText,
|
||||
isStreamingReply,
|
||||
]);
|
||||
|
||||
if (!session) {
|
||||
return (
|
||||
@@ -318,7 +337,8 @@ export function CreationAgentWorkspace({
|
||||
shouldShowQuickAction(action, session, progress),
|
||||
);
|
||||
const streamingMessageId = `streaming-assistant-${session.sessionId}`;
|
||||
const shouldShowStreamingReply = isStreamingReply && streamingReplyText.trim();
|
||||
// 用户消息提交后、首个流式文本到达前,也要立刻展示等待气泡。
|
||||
const shouldShowStreamingReply = isStreamingReply;
|
||||
const displayedMessages = shouldShowStreamingReply
|
||||
? [
|
||||
...session.messages,
|
||||
@@ -356,18 +376,61 @@ export function CreationAgentWorkspace({
|
||||
|
||||
const submit = () => {
|
||||
const text = draftText.trim();
|
||||
if (!text || isBusy) {
|
||||
if (!text || isBusy || isParsingDocumentInput) {
|
||||
return;
|
||||
}
|
||||
|
||||
armAutoScrollToBottom();
|
||||
onSubmitText(text);
|
||||
setDraftText('');
|
||||
setDocumentInputError(null);
|
||||
};
|
||||
|
||||
const appendDocumentInputText = (text: string) => {
|
||||
setDraftText((current) => {
|
||||
const currentText = current.trimEnd();
|
||||
const nextText = text.trim();
|
||||
|
||||
return currentText ? `${currentText}\n\n${nextText}` : nextText;
|
||||
});
|
||||
};
|
||||
|
||||
const openDocumentInputPicker = () => {
|
||||
documentInputRef.current?.click();
|
||||
};
|
||||
|
||||
const handleDocumentInputChange = async (
|
||||
event: ChangeEvent<HTMLInputElement>,
|
||||
) => {
|
||||
const file = event.target.files?.[0] ?? null;
|
||||
event.target.value = '';
|
||||
|
||||
if (!file || isBusy || isParsingDocumentInput) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsParsingDocumentInput(true);
|
||||
setDocumentInputError(null);
|
||||
|
||||
try {
|
||||
const response = await parseCreationAgentDocumentInput(file);
|
||||
appendDocumentInputText(response.document.text);
|
||||
} catch (parseError) {
|
||||
setDocumentInputError(
|
||||
parseError instanceof Error
|
||||
? parseError.message
|
||||
: '解析文档失败,请重新选择文件。',
|
||||
);
|
||||
} finally {
|
||||
setIsParsingDocumentInput(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex h-full min-h-0 w-full max-w-6xl flex-col gap-3 overflow-hidden px-1 sm:px-0">
|
||||
<div className={`relative overflow-hidden rounded-[1.8rem] px-4 py-4 text-white shadow-[0_20px_60px_rgba(15,23,42,0.18)] sm:px-5 ${theme.heroClass}`}>
|
||||
<div
|
||||
className={`relative overflow-hidden rounded-[1.8rem] px-4 py-4 text-white shadow-[0_20px_60px_rgba(15,23,42,0.18)] sm:px-5 ${theme.heroClass}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<button
|
||||
type="button"
|
||||
@@ -482,20 +545,43 @@ export function CreationAgentWorkspace({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
{documentInputError || error ? (
|
||||
<div className="mx-4 mb-3 rounded-[1rem] border border-red-200/70 bg-red-50 px-3 py-2 text-sm text-red-600">
|
||||
{error}
|
||||
{documentInputError || error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="border-t border-[var(--platform-subpanel-border)] p-3">
|
||||
<div className="flex items-end gap-2 rounded-[1.25rem] bg-white/80 p-2">
|
||||
<input
|
||||
ref={documentInputRef}
|
||||
type="file"
|
||||
accept={DOCUMENT_INPUT_ACCEPT}
|
||||
className="hidden"
|
||||
onChange={handleDocumentInputChange}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={
|
||||
isParsingDocumentInput ? '正在解析文档' : '上传文档'
|
||||
}
|
||||
title={isParsingDocumentInput ? '正在解析文档' : '上传文档'}
|
||||
aria-busy={isParsingDocumentInput}
|
||||
disabled={isBusy || isParsingDocumentInput}
|
||||
onClick={openDocumentInputPicker}
|
||||
className="inline-flex h-11 w-11 shrink-0 items-center justify-center rounded-full text-[var(--platform-text-base)] hover:bg-black/5 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
<Paperclip
|
||||
className={`h-4 w-4 ${isParsingDocumentInput ? 'animate-pulse' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
<textarea
|
||||
value={draftText}
|
||||
disabled={isBusy}
|
||||
disabled={isBusy || isParsingDocumentInput}
|
||||
rows={2}
|
||||
onChange={(event) => {
|
||||
setDraftText(event.target.value);
|
||||
setDocumentInputError(null);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
@@ -509,7 +595,7 @@ export function CreationAgentWorkspace({
|
||||
<button
|
||||
type="button"
|
||||
aria-label="发送"
|
||||
disabled={isBusy || !draftText.trim()}
|
||||
disabled={isBusy || isParsingDocumentInput || !draftText.trim()}
|
||||
onClick={submit}
|
||||
className={`inline-flex h-11 w-11 items-center justify-center rounded-full text-white disabled:cursor-not-allowed disabled:opacity-40 ${theme.userBubbleClass}`}
|
||||
>
|
||||
|
||||
@@ -4,15 +4,16 @@ import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contra
|
||||
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
|
||||
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
|
||||
import { CustomWorldCreationStartCard } from './CustomWorldCreationStartCard';
|
||||
import {
|
||||
CustomWorldWorkCard,
|
||||
type UnifiedCreationWorkItem,
|
||||
} from './CustomWorldWorkCard';
|
||||
import { CustomWorldWorkCard } from './CustomWorldWorkCard';
|
||||
import {
|
||||
type CustomWorldWorkFilter,
|
||||
CustomWorldWorkTabs,
|
||||
} from './CustomWorldWorkTabs';
|
||||
import type { PlatformCreationTypeId } from '../platform-entry/platformEntryCreationTypes';
|
||||
import {
|
||||
buildCreationWorkShelfItems,
|
||||
type CreationWorkShelfItem,
|
||||
} from './creationWorkShelf';
|
||||
|
||||
type CustomWorldCreationHubProps = {
|
||||
items: CustomWorldWorkSummary[];
|
||||
@@ -71,42 +72,111 @@ export function CustomWorldCreationHub({
|
||||
}: CustomWorldCreationHubProps) {
|
||||
const [activeFilter, setActiveFilter] =
|
||||
useState<CustomWorldWorkFilter>('all');
|
||||
const unifiedItems = useMemo<UnifiedCreationWorkItem[]>(
|
||||
() => [
|
||||
...items.map((item) => ({ kind: 'rpg', item }) as const),
|
||||
...bigFishItems.map((item) => ({ kind: 'big-fish', item }) as const),
|
||||
...puzzleItems.map((item) => ({ kind: 'puzzle', item }) as const),
|
||||
const shelfItems = useMemo(
|
||||
() =>
|
||||
buildCreationWorkShelfItems({
|
||||
rpgItems: items,
|
||||
bigFishItems,
|
||||
puzzleItems,
|
||||
canDeleteRpg: Boolean(onDeletePublished),
|
||||
canDeleteBigFish: Boolean(onDeleteBigFish),
|
||||
canDeletePuzzle: Boolean(onDeletePuzzle),
|
||||
}),
|
||||
[
|
||||
bigFishItems,
|
||||
items,
|
||||
onDeleteBigFish,
|
||||
onDeletePublished,
|
||||
onDeletePuzzle,
|
||||
puzzleItems,
|
||||
],
|
||||
[bigFishItems, items, puzzleItems],
|
||||
);
|
||||
const draftCount = unifiedItems.filter((entry) =>
|
||||
entry.kind === 'puzzle'
|
||||
? entry.item.publicationStatus === 'draft'
|
||||
: entry.kind === 'big-fish'
|
||||
? entry.item.status === 'draft'
|
||||
: entry.item.status === 'draft',
|
||||
).length;
|
||||
const publishedCount = unifiedItems.filter((entry) =>
|
||||
entry.kind === 'puzzle'
|
||||
? entry.item.publicationStatus === 'published'
|
||||
: entry.kind === 'big-fish'
|
||||
? entry.item.status === 'published'
|
||||
: entry.item.status === 'published',
|
||||
const draftCount = shelfItems.filter((entry) => entry.status === 'draft').length;
|
||||
const publishedCount = shelfItems.filter(
|
||||
(entry) => entry.status === 'published',
|
||||
).length;
|
||||
const filteredItems = useMemo(
|
||||
() =>
|
||||
unifiedItems.filter((entry) =>
|
||||
activeFilter === 'all'
|
||||
? true
|
||||
: entry.kind === 'puzzle'
|
||||
? entry.item.publicationStatus === activeFilter
|
||||
: entry.kind === 'big-fish'
|
||||
? entry.item.status === activeFilter
|
||||
: entry.item.status === activeFilter,
|
||||
shelfItems.filter((entry) =>
|
||||
activeFilter === 'all' ? true : entry.status === activeFilter,
|
||||
),
|
||||
[activeFilter, unifiedItems],
|
||||
[activeFilter, shelfItems],
|
||||
);
|
||||
|
||||
function handleOpenShelfItem(item: CreationWorkShelfItem) {
|
||||
switch (item.source.kind) {
|
||||
case 'puzzle':
|
||||
onOpenPuzzleDetail?.(item.source.item);
|
||||
return;
|
||||
case 'big-fish':
|
||||
onOpenBigFishDetail?.(item.source.item);
|
||||
return;
|
||||
case 'rpg':
|
||||
if (item.status === 'draft') {
|
||||
onOpenDraft(item.source.item);
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.source.item.profileId) {
|
||||
onEnterPublished(item.source.item.profileId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function buildExperienceAction(item: CreationWorkShelfItem) {
|
||||
if (!item.canExperience) {
|
||||
return null;
|
||||
}
|
||||
|
||||
switch (item.source.kind) {
|
||||
case 'puzzle': {
|
||||
const sourceItem = item.source.item;
|
||||
return () => {
|
||||
onExperiencePuzzle?.(sourceItem.profileId);
|
||||
};
|
||||
}
|
||||
case 'big-fish': {
|
||||
const sourceItem = item.source.item;
|
||||
return () => {
|
||||
onExperienceBigFish?.(sourceItem);
|
||||
};
|
||||
}
|
||||
case 'rpg': {
|
||||
const sourceItem = item.source.item;
|
||||
return () => {
|
||||
onExperienceRpg?.(sourceItem);
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function buildDeleteAction(item: CreationWorkShelfItem) {
|
||||
if (!item.canDelete) {
|
||||
return null;
|
||||
}
|
||||
|
||||
switch (item.source.kind) {
|
||||
case 'puzzle': {
|
||||
const sourceItem = item.source.item;
|
||||
return () => {
|
||||
onDeletePuzzle?.(sourceItem);
|
||||
};
|
||||
}
|
||||
case 'big-fish': {
|
||||
const sourceItem = item.source.item;
|
||||
return () => {
|
||||
onDeleteBigFish?.(sourceItem);
|
||||
};
|
||||
}
|
||||
case 'rpg': {
|
||||
const sourceItem = item.source.item;
|
||||
return () => {
|
||||
onDeletePublished?.(sourceItem);
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="platform-page-stage platform-remap-surface space-y-4 px-3 pb-4 pt-3 sm:px-4 sm:pt-4 xl:px-5 xl:pb-5 xl:pt-5">
|
||||
<div className="space-y-4 xl:space-y-3">
|
||||
@@ -158,65 +228,16 @@ export function CustomWorldCreationHub({
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
|
||||
{filteredItems.map((item) => (
|
||||
<CustomWorldWorkCard
|
||||
key={`${item.kind}-${item.item.workId}`}
|
||||
key={`${item.kind}-${item.id}`}
|
||||
item={item}
|
||||
onOpen={() => {
|
||||
if (item.kind === 'puzzle') {
|
||||
onOpenPuzzleDetail?.(item.item);
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.kind === 'big-fish') {
|
||||
onOpenBigFishDetail?.(item.item);
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.item.status === 'draft') {
|
||||
onOpenDraft(item.item);
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.item.profileId) {
|
||||
onEnterPublished(item.item.profileId);
|
||||
}
|
||||
}}
|
||||
onExperience={
|
||||
item.kind === 'puzzle'
|
||||
? item.item.publicationStatus === 'published'
|
||||
? () => {
|
||||
onExperiencePuzzle?.(item.item.profileId);
|
||||
}
|
||||
: null
|
||||
: item.kind === 'big-fish'
|
||||
? item.item.status === 'published'
|
||||
? () => {
|
||||
onExperienceBigFish?.(item.item);
|
||||
}
|
||||
: null
|
||||
: item.item.status === 'published' && item.item.canEnterWorld
|
||||
? () => {
|
||||
onExperienceRpg?.(item.item);
|
||||
}
|
||||
: null
|
||||
}
|
||||
onDelete={
|
||||
item.kind === 'puzzle'
|
||||
? () => {
|
||||
onDeletePuzzle?.(item.item);
|
||||
}
|
||||
: item.kind === 'big-fish'
|
||||
? () => {
|
||||
onDeleteBigFish?.(item.item);
|
||||
}
|
||||
: () => {
|
||||
onDeletePublished?.(item.item);
|
||||
}
|
||||
}
|
||||
deleteBusy={deletingWorkId === item.item.workId}
|
||||
onOpen={() => handleOpenShelfItem(item)}
|
||||
onExperience={buildExperienceAction(item)}
|
||||
onDelete={buildDeleteAction(item)}
|
||||
deleteBusy={deletingWorkId === item.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : unifiedItems.length === 0 ? (
|
||||
) : shelfItems.length === 0 ? (
|
||||
<EmptyState title="还没有作品" />
|
||||
) : (
|
||||
<EmptyState title="当前筛选下没有内容" />
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldAgent';
|
||||
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
|
||||
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
|
||||
import { CustomWorldCoverArtwork } from '../CustomWorldCoverArtwork';
|
||||
import type { CreationWorkShelfItem } from './creationWorkShelf';
|
||||
|
||||
function formatUpdatedAt(value: string) {
|
||||
const date = new Date(value);
|
||||
@@ -17,28 +15,20 @@ function formatUpdatedAt(value: string) {
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
export type UnifiedCreationWorkItem =
|
||||
| {
|
||||
kind: 'rpg';
|
||||
item: CustomWorldWorkSummary;
|
||||
}
|
||||
| {
|
||||
kind: 'big-fish';
|
||||
item: BigFishWorkSummary;
|
||||
}
|
||||
| {
|
||||
kind: 'puzzle';
|
||||
item: PuzzleWorkSummary;
|
||||
};
|
||||
|
||||
type CustomWorldWorkCardProps = {
|
||||
item: UnifiedCreationWorkItem;
|
||||
item: CreationWorkShelfItem;
|
||||
onOpen: () => void;
|
||||
onExperience?: (() => void) | null;
|
||||
onDelete?: (() => void) | null;
|
||||
deleteBusy?: boolean;
|
||||
};
|
||||
|
||||
const BADGE_TONE_CLASS: Record<CreationWorkShelfItem['badges'][number]['tone'], string> = {
|
||||
warm: 'platform-pill--warm',
|
||||
success: 'platform-pill--success',
|
||||
neutral: 'platform-pill--neutral',
|
||||
};
|
||||
|
||||
export function CustomWorldWorkCard({
|
||||
item,
|
||||
onOpen,
|
||||
@@ -46,40 +36,11 @@ export function CustomWorldWorkCard({
|
||||
onDelete = null,
|
||||
deleteBusy = false,
|
||||
}: CustomWorldWorkCardProps) {
|
||||
const isPuzzle = item.kind === 'puzzle';
|
||||
const isBigFish = item.kind === 'big-fish';
|
||||
const isDraft =
|
||||
item.kind === 'puzzle'
|
||||
? item.item.publicationStatus === 'draft'
|
||||
: item.kind === 'big-fish'
|
||||
? item.item.status === 'draft'
|
||||
: item.item.status === 'draft';
|
||||
const openActionLabel = isPuzzle || isBigFish
|
||||
? isDraft
|
||||
? '继续创作'
|
||||
: '查看详情'
|
||||
: isDraft
|
||||
? item.item.playableNpcCount > 0 || item.item.landmarkCount > 0
|
||||
? '继续完善'
|
||||
: '继续创作'
|
||||
: '查看详情';
|
||||
const title =
|
||||
item.kind === 'puzzle' ? item.item.levelName : item.item.title;
|
||||
const subtitle =
|
||||
item.kind === 'puzzle' ? item.item.authorDisplayName : item.item.subtitle;
|
||||
const summary = item.item.summary;
|
||||
const updatedAt = item.item.updatedAt;
|
||||
const coverImageSrc = item.item.coverImageSrc ?? null;
|
||||
const coverRenderMode =
|
||||
item.kind === 'rpg' ? item.item.coverRenderMode : 'image';
|
||||
const coverCharacterImageSrcs =
|
||||
item.kind === 'rpg' ? item.item.coverCharacterImageSrcs : [];
|
||||
|
||||
return (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`${openActionLabel}《${title}》`}
|
||||
aria-label={`${item.openActionLabel}《${item.title}》`}
|
||||
onClick={onOpen}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== 'Enter' && event.key !== ' ') {
|
||||
@@ -92,48 +53,29 @@ export function CustomWorldWorkCard({
|
||||
className="platform-surface platform-interactive-card relative min-h-[16rem] cursor-pointer overflow-hidden px-4 py-4 text-left sm:min-h-[15.5rem] xl:min-h-[14rem] xl:px-4 xl:py-3.5"
|
||||
>
|
||||
<CustomWorldCoverArtwork
|
||||
imageSrc={coverImageSrc}
|
||||
title={title}
|
||||
imageSrc={item.coverImageSrc}
|
||||
title={item.title}
|
||||
fallbackLabel="封面"
|
||||
renderMode={coverRenderMode}
|
||||
characterImageSrcs={coverCharacterImageSrcs}
|
||||
renderMode={item.coverRenderMode}
|
||||
characterImageSrcs={item.coverCharacterImageSrcs}
|
||||
className="platform-cover-artwork absolute inset-0"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-[var(--platform-card-overlay-strong)]" />
|
||||
<div className="pointer-events-none relative z-20 flex min-h-[14rem] flex-col sm:min-h-[13.5rem] xl:min-h-[12.5rem]">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<span
|
||||
className={`platform-pill px-3 py-1 text-[10px] ${
|
||||
isDraft
|
||||
? 'platform-pill--warm'
|
||||
: 'platform-pill--success'
|
||||
}`}
|
||||
>
|
||||
{isDraft ? '草稿' : '已发布'}
|
||||
</span>
|
||||
<span className="platform-pill platform-pill--neutral px-3 py-1 text-[10px]">
|
||||
{isPuzzle ? '拼图' : isBigFish ? '大鱼' : 'RPG'}
|
||||
</span>
|
||||
{item.kind === 'rpg' && item.item.stageLabel ? (
|
||||
<span className="platform-pill platform-pill--neutral px-3 py-1 text-[10px]">
|
||||
{item.item.stageLabel}
|
||||
{item.badges.map((badge) => (
|
||||
<span
|
||||
key={`${item.id}-${badge.id}`}
|
||||
className={`platform-pill ${BADGE_TONE_CLASS[badge.tone]} px-3 py-1 text-[10px]`}
|
||||
>
|
||||
{badge.label}
|
||||
</span>
|
||||
) : null}
|
||||
{isPuzzle
|
||||
? item.item.themeTags.slice(0, 2).map((tag) => (
|
||||
<span
|
||||
key={`${item.item.profileId}-${tag}`}
|
||||
className="platform-pill platform-pill--neutral px-3 py-1 text-[10px]"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))
|
||||
: null}
|
||||
))}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<span className="text-[11px] text-[var(--platform-text-soft)]">
|
||||
{formatUpdatedAt(updatedAt)}
|
||||
{formatUpdatedAt(item.updatedAt)}
|
||||
</span>
|
||||
{onDelete ? (
|
||||
<button
|
||||
@@ -174,69 +116,26 @@ export function CustomWorldWorkCard({
|
||||
|
||||
<div className="mt-4 min-h-0 xl:mt-3">
|
||||
<div className="text-2xl font-black text-[var(--platform-text-strong)] xl:text-xl">
|
||||
{title}
|
||||
{item.title}
|
||||
</div>
|
||||
<div className="mt-1 text-xs tracking-[0.12em] text-[var(--platform-text-soft)]">
|
||||
{subtitle}
|
||||
{item.subtitle}
|
||||
</div>
|
||||
<div className="mt-3 line-clamp-2 text-sm leading-6 text-[color:color-mix(in_srgb,var(--platform-text-base)_92%,transparent)] xl:mt-2">
|
||||
{summary}
|
||||
{item.summary}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-auto flex flex-col gap-3 pt-4 sm:flex-row sm:items-end sm:justify-between xl:gap-2 xl:pt-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{isPuzzle ? (
|
||||
<>
|
||||
<span className="platform-pill platform-pill--neutral px-3 py-1 text-[10px]">
|
||||
作者 {item.item.authorDisplayName}
|
||||
</span>
|
||||
<span className="platform-pill platform-pill--neutral px-3 py-1 text-[10px]">
|
||||
游玩 {item.item.playCount}
|
||||
</span>
|
||||
</>
|
||||
) : isBigFish ? (
|
||||
<>
|
||||
<span className="platform-pill platform-pill--neutral px-3 py-1 text-[10px]">
|
||||
关卡 {item.item.levelCount}
|
||||
</span>
|
||||
<span className="platform-pill platform-pill--neutral px-3 py-1 text-[10px]">
|
||||
主图 {item.item.levelMainImageReadyCount}
|
||||
</span>
|
||||
<span className="platform-pill platform-pill--neutral px-3 py-1 text-[10px]">
|
||||
动作 {item.item.levelMotionReadyCount}
|
||||
</span>
|
||||
{item.item.backgroundReady ? (
|
||||
<span className="platform-pill platform-pill--success px-3 py-1 text-[10px]">
|
||||
背景已就绪
|
||||
</span>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="platform-pill platform-pill--neutral px-3 py-1 text-[10px]">
|
||||
{isDraft ? '角色' : '可扮演角色'} {item.item.playableNpcCount}
|
||||
</span>
|
||||
<span className="platform-pill platform-pill--neutral px-3 py-1 text-[10px]">
|
||||
地点 {item.item.landmarkCount}
|
||||
</span>
|
||||
{item.item.roleVisualReadyCount ? (
|
||||
<span className="platform-pill platform-pill--warm px-3 py-1 text-[10px]">
|
||||
主图 {item.item.roleVisualReadyCount}
|
||||
</span>
|
||||
) : null}
|
||||
{item.item.roleAnimationReadyCount ? (
|
||||
<span className="platform-pill platform-pill--success px-3 py-1 text-[10px]">
|
||||
动作 {item.item.roleAnimationReadyCount}
|
||||
</span>
|
||||
) : null}
|
||||
{item.item.roleAssetSummaryLabel ? (
|
||||
<span className="platform-pill platform-pill--neutral px-3 py-1 text-[10px]">
|
||||
{item.item.roleAssetSummaryLabel}
|
||||
</span>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
{item.metrics.map((metric) => (
|
||||
<span
|
||||
key={`${item.id}-${metric.id}`}
|
||||
className={`platform-pill ${BADGE_TONE_CLASS[metric.tone ?? 'neutral']} px-3 py-1 text-[10px]`}
|
||||
>
|
||||
{metric.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 sm:justify-end xl:gap-1.5">
|
||||
{onExperience ? (
|
||||
|
||||
247
src/components/custom-world-home/creationWorkShelf.ts
Normal file
247
src/components/custom-world-home/creationWorkShelf.ts
Normal file
@@ -0,0 +1,247 @@
|
||||
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
|
||||
import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldAgent';
|
||||
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
|
||||
|
||||
export type CreationWorkShelfKind = 'rpg' | 'big-fish' | 'puzzle';
|
||||
export type CreationWorkShelfStatus = 'draft' | 'published';
|
||||
|
||||
export type CreationWorkShelfBadgeTone = 'warm' | 'success' | 'neutral';
|
||||
|
||||
export type CreationWorkShelfBadge = {
|
||||
id: string;
|
||||
label: string;
|
||||
tone: CreationWorkShelfBadgeTone;
|
||||
};
|
||||
|
||||
export type CreationWorkShelfMetric = {
|
||||
id: string;
|
||||
label: string;
|
||||
tone?: CreationWorkShelfBadgeTone;
|
||||
};
|
||||
|
||||
export type CreationWorkShelfSource =
|
||||
| {
|
||||
kind: 'rpg';
|
||||
item: CustomWorldWorkSummary;
|
||||
}
|
||||
| {
|
||||
kind: 'big-fish';
|
||||
item: BigFishWorkSummary;
|
||||
}
|
||||
| {
|
||||
kind: 'puzzle';
|
||||
item: PuzzleWorkSummary;
|
||||
};
|
||||
|
||||
export type CreationWorkShelfItem = {
|
||||
id: string;
|
||||
kind: CreationWorkShelfKind;
|
||||
status: CreationWorkShelfStatus;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
summary: string;
|
||||
updatedAt: string;
|
||||
coverImageSrc: string | null;
|
||||
coverRenderMode: 'image' | 'scene_with_roles';
|
||||
coverCharacterImageSrcs: string[];
|
||||
typeLabel: string;
|
||||
openActionLabel: string;
|
||||
canExperience: boolean;
|
||||
canDelete: boolean;
|
||||
badges: CreationWorkShelfBadge[];
|
||||
metrics: CreationWorkShelfMetric[];
|
||||
source: CreationWorkShelfSource;
|
||||
};
|
||||
|
||||
export function buildCreationWorkShelfItems(params: {
|
||||
rpgItems: CustomWorldWorkSummary[];
|
||||
bigFishItems: BigFishWorkSummary[];
|
||||
puzzleItems: PuzzleWorkSummary[];
|
||||
canDeleteRpg?: boolean;
|
||||
canDeleteBigFish?: boolean;
|
||||
canDeletePuzzle?: boolean;
|
||||
}) {
|
||||
const {
|
||||
rpgItems,
|
||||
bigFishItems,
|
||||
puzzleItems,
|
||||
canDeleteRpg = false,
|
||||
canDeleteBigFish = false,
|
||||
canDeletePuzzle = false,
|
||||
} = params;
|
||||
|
||||
return [
|
||||
...rpgItems.map((item) => mapRpgWorkToShelfItem(item, canDeleteRpg)),
|
||||
...bigFishItems.map((item) =>
|
||||
mapBigFishWorkToShelfItem(item, canDeleteBigFish),
|
||||
),
|
||||
...puzzleItems.map((item) => mapPuzzleWorkToShelfItem(item, canDeletePuzzle)),
|
||||
].sort(
|
||||
(left, right) =>
|
||||
getShelfItemTime(right.updatedAt) - getShelfItemTime(left.updatedAt),
|
||||
);
|
||||
}
|
||||
|
||||
function mapRpgWorkToShelfItem(
|
||||
item: CustomWorldWorkSummary,
|
||||
canDelete: boolean,
|
||||
): CreationWorkShelfItem {
|
||||
const isDraft = item.status === 'draft';
|
||||
const badges: CreationWorkShelfBadge[] = [
|
||||
buildStatusBadge(item.status),
|
||||
{ id: 'type', label: 'RPG', tone: 'neutral' },
|
||||
];
|
||||
if (item.stageLabel) {
|
||||
badges.push({ id: 'stage', label: item.stageLabel, tone: 'neutral' });
|
||||
}
|
||||
|
||||
const metrics: CreationWorkShelfMetric[] = [
|
||||
{
|
||||
id: 'playable-npc-count',
|
||||
label: `${isDraft ? '角色' : '可扮演角色'} ${item.playableNpcCount}`,
|
||||
},
|
||||
{ id: 'landmark-count', label: `地点 ${item.landmarkCount}` },
|
||||
];
|
||||
if (item.roleVisualReadyCount) {
|
||||
metrics.push({
|
||||
id: 'role-visual-ready-count',
|
||||
label: `主图 ${item.roleVisualReadyCount}`,
|
||||
tone: 'warm',
|
||||
});
|
||||
}
|
||||
if (item.roleAnimationReadyCount) {
|
||||
metrics.push({
|
||||
id: 'role-animation-ready-count',
|
||||
label: `动作 ${item.roleAnimationReadyCount}`,
|
||||
tone: 'success',
|
||||
});
|
||||
}
|
||||
if (item.roleAssetSummaryLabel) {
|
||||
metrics.push({
|
||||
id: 'role-asset-summary',
|
||||
label: item.roleAssetSummaryLabel,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
id: item.workId,
|
||||
kind: 'rpg',
|
||||
status: item.status,
|
||||
title: item.title,
|
||||
subtitle: item.subtitle,
|
||||
summary: item.summary,
|
||||
updatedAt: item.updatedAt,
|
||||
coverImageSrc: item.coverImageSrc ?? null,
|
||||
coverRenderMode: item.coverRenderMode ?? 'image',
|
||||
coverCharacterImageSrcs: item.coverCharacterImageSrcs ?? [],
|
||||
typeLabel: 'RPG',
|
||||
openActionLabel: isDraft
|
||||
? item.playableNpcCount > 0 || item.landmarkCount > 0
|
||||
? '继续完善'
|
||||
: '继续创作'
|
||||
: '查看详情',
|
||||
canExperience: item.status === 'published' && item.canEnterWorld,
|
||||
canDelete,
|
||||
badges,
|
||||
metrics,
|
||||
source: { kind: 'rpg', item },
|
||||
};
|
||||
}
|
||||
|
||||
function mapBigFishWorkToShelfItem(
|
||||
item: BigFishWorkSummary,
|
||||
canDelete: boolean,
|
||||
): CreationWorkShelfItem {
|
||||
return {
|
||||
id: item.workId,
|
||||
kind: 'big-fish',
|
||||
status: item.status,
|
||||
title: item.title,
|
||||
subtitle: item.subtitle,
|
||||
summary: item.summary,
|
||||
updatedAt: item.updatedAt,
|
||||
coverImageSrc: item.coverImageSrc ?? null,
|
||||
coverRenderMode: 'image',
|
||||
coverCharacterImageSrcs: [],
|
||||
typeLabel: '大鱼',
|
||||
openActionLabel: item.status === 'draft' ? '继续创作' : '查看详情',
|
||||
canExperience: item.status === 'published',
|
||||
canDelete,
|
||||
badges: [
|
||||
buildStatusBadge(item.status),
|
||||
{ id: 'type', label: '大鱼', tone: 'neutral' },
|
||||
],
|
||||
metrics: [
|
||||
{ id: 'level-count', label: `关卡 ${item.levelCount}` },
|
||||
{
|
||||
id: 'level-main-image-ready-count',
|
||||
label: `主图 ${item.levelMainImageReadyCount}`,
|
||||
},
|
||||
{
|
||||
id: 'level-motion-ready-count',
|
||||
label: `动作 ${item.levelMotionReadyCount}`,
|
||||
},
|
||||
...(item.backgroundReady
|
||||
? [
|
||||
{
|
||||
id: 'background-ready',
|
||||
label: '背景已就绪',
|
||||
tone: 'success' as const,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
source: { kind: 'big-fish', item },
|
||||
};
|
||||
}
|
||||
|
||||
function mapPuzzleWorkToShelfItem(
|
||||
item: PuzzleWorkSummary,
|
||||
canDelete: boolean,
|
||||
): CreationWorkShelfItem {
|
||||
const status = item.publicationStatus;
|
||||
|
||||
return {
|
||||
id: item.workId,
|
||||
kind: 'puzzle',
|
||||
status,
|
||||
title: item.levelName,
|
||||
subtitle: item.authorDisplayName,
|
||||
summary: item.summary,
|
||||
updatedAt: item.updatedAt,
|
||||
coverImageSrc: item.coverImageSrc ?? null,
|
||||
coverRenderMode: 'image',
|
||||
coverCharacterImageSrcs: [],
|
||||
typeLabel: '拼图',
|
||||
openActionLabel: status === 'draft' ? '继续创作' : '查看详情',
|
||||
canExperience: status === 'published',
|
||||
canDelete,
|
||||
badges: [
|
||||
buildStatusBadge(status),
|
||||
{ id: 'type', label: '拼图', tone: 'neutral' },
|
||||
...item.themeTags.slice(0, 2).map((tag) => ({
|
||||
id: `tag:${tag}`,
|
||||
label: tag,
|
||||
tone: 'neutral' as const,
|
||||
})),
|
||||
],
|
||||
metrics: [
|
||||
{ id: 'author', label: `作者 ${item.authorDisplayName}` },
|
||||
{ id: 'play-count', label: `游玩 ${item.playCount}` },
|
||||
],
|
||||
source: { kind: 'puzzle', item },
|
||||
};
|
||||
}
|
||||
|
||||
function buildStatusBadge(status: CreationWorkShelfStatus): CreationWorkShelfBadge {
|
||||
return {
|
||||
id: 'status',
|
||||
label: status === 'draft' ? '草稿' : '已发布',
|
||||
tone: status === 'draft' ? 'warm' : 'success',
|
||||
};
|
||||
}
|
||||
|
||||
function getShelfItemTime(value: string) {
|
||||
const timestamp = new Date(value).getTime();
|
||||
return Number.isNaN(timestamp) ? 0 : timestamp;
|
||||
}
|
||||
@@ -59,7 +59,7 @@ import {
|
||||
getPuzzleAgentSession,
|
||||
streamPuzzleAgentMessage,
|
||||
} from '../../services/puzzle-agent';
|
||||
import { getPuzzleGalleryDetail } from '../../services/puzzle-gallery';
|
||||
import { getPuzzleGalleryDetail, listPuzzleGallery } from '../../services/puzzle-gallery';
|
||||
import {
|
||||
advanceLocalPuzzleLevel,
|
||||
dragLocalPuzzlePiece,
|
||||
@@ -82,6 +82,11 @@ import { useRpgCreationAgentOperationPolling } from '../rpg-entry/useRpgCreation
|
||||
import { useRpgCreationEnterWorld } from '../rpg-entry/useRpgCreationEnterWorld';
|
||||
import { useRpgCreationResultAutosave } from '../rpg-entry/useRpgCreationResultAutosave';
|
||||
import { useRpgCreationSessionController } from '../rpg-entry/useRpgCreationSessionController';
|
||||
import {
|
||||
isPuzzleGalleryEntry,
|
||||
mapPuzzleWorkToPlatformGalleryCard,
|
||||
type PlatformPublicGalleryCard,
|
||||
} from '../rpg-entry/rpgEntryWorldPresentation';
|
||||
import { PlatformEntryCreationTypeModal } from './PlatformEntryCreationTypeModal';
|
||||
import { PlatformEntryHomeView } from './PlatformEntryHomeView';
|
||||
import {
|
||||
@@ -93,6 +98,7 @@ import type { PlatformCreationTypeId } from './platformEntryCreationTypes';
|
||||
import type { PlatformEntryFlowShellProps } from './platformEntryTypes';
|
||||
import { PlatformEntryWorldDetailView } from './PlatformEntryWorldDetailView';
|
||||
import { usePlatformEntryBootstrap } from './usePlatformEntryBootstrap';
|
||||
import { usePlatformCreationAgentFlowController } from './usePlatformCreationAgentFlowController';
|
||||
import { usePlatformEntryLibraryDetail } from './usePlatformEntryLibraryDetail';
|
||||
import { usePlatformEntryNavigation } from './usePlatformEntryNavigation';
|
||||
|
||||
@@ -114,6 +120,33 @@ const AGENT_RESULT_STRUCTURAL_BLOCKER_CODES = new Set([
|
||||
'publish_missing_first_act',
|
||||
]);
|
||||
|
||||
function getPlatformPublicGalleryEntryTime(entry: PlatformPublicGalleryCard) {
|
||||
const rawTime = entry.publishedAt ?? entry.updatedAt;
|
||||
const timestamp = new Date(rawTime).getTime();
|
||||
return Number.isNaN(timestamp) ? 0 : timestamp;
|
||||
}
|
||||
|
||||
function getPlatformPublicGalleryEntryKey(entry: PlatformPublicGalleryCard) {
|
||||
return `${isPuzzleGalleryEntry(entry) ? 'puzzle' : 'rpg'}:${entry.ownerUserId}:${entry.profileId}`;
|
||||
}
|
||||
|
||||
function mergePlatformPublicGalleryEntries(
|
||||
rpgEntries: CustomWorldGalleryCard[],
|
||||
puzzleEntries: PlatformPublicGalleryCard[],
|
||||
) {
|
||||
const entryMap = new Map<string, PlatformPublicGalleryCard>();
|
||||
|
||||
[...rpgEntries, ...puzzleEntries].forEach((entry) => {
|
||||
entryMap.set(getPlatformPublicGalleryEntryKey(entry), entry);
|
||||
});
|
||||
|
||||
return Array.from(entryMap.values()).sort(
|
||||
(left, right) =>
|
||||
getPlatformPublicGalleryEntryTime(right) -
|
||||
getPlatformPublicGalleryEntryTime(left),
|
||||
);
|
||||
}
|
||||
|
||||
function readProfileTextField(
|
||||
profile: CustomWorldProfile | null,
|
||||
paths: string[],
|
||||
@@ -304,28 +337,20 @@ export function PlatformEntryFlowShellImpl({
|
||||
const [showCreationTypeModal, setShowCreationTypeModal] = useState(false);
|
||||
const [selectedDetailEntry, setSelectedDetailEntry] =
|
||||
useState<CustomWorldLibraryEntry<CustomWorldProfile> | null>(null);
|
||||
const [bigFishSession, setBigFishSession] =
|
||||
useState<BigFishSessionSnapshotResponse | null>(null);
|
||||
const [bigFishWorks, setBigFishWorks] = useState<BigFishWorkSummary[]>([]);
|
||||
const [bigFishRun, setBigFishRun] =
|
||||
useState<BigFishRuntimeSnapshotResponse | null>(null);
|
||||
const [bigFishError, setBigFishError] = useState<string | null>(null);
|
||||
const [isBigFishBusy, setIsBigFishBusy] = useState(false);
|
||||
const [isBigFishLoadingLibrary, setIsBigFishLoadingLibrary] = useState(false);
|
||||
const [streamingBigFishReplyText, setStreamingBigFishReplyText] =
|
||||
useState('');
|
||||
const [isStreamingBigFishReply, setIsStreamingBigFishReply] = useState(false);
|
||||
const bigFishInputInFlightRef = useRef(false);
|
||||
const [puzzleSession, setPuzzleSession] =
|
||||
useState<PuzzleAgentSessionSnapshot | null>(null);
|
||||
const [puzzleOperation, setPuzzleOperation] =
|
||||
useState<PuzzleAgentOperationRecord | null>(null);
|
||||
const [puzzleWorks, setPuzzleWorks] = useState<PuzzleWorkSummary[]>([]);
|
||||
const [puzzleGalleryEntries, setPuzzleGalleryEntries] = useState<
|
||||
PuzzleWorkSummary[]
|
||||
>([]);
|
||||
const [selectedPuzzleDetail, setSelectedPuzzleDetail] =
|
||||
useState<PuzzleWorkSummary | null>(null);
|
||||
const [puzzleRun, setPuzzleRun] = useState<PuzzleRunSnapshot | null>(null);
|
||||
const [puzzleError, setPuzzleError] = useState<string | null>(null);
|
||||
const [isPuzzleBusy, setIsPuzzleBusy] = useState(false);
|
||||
const [isPuzzleLoadingLibrary, setIsPuzzleLoadingLibrary] = useState(false);
|
||||
const [isSearchingPublicCode, setIsSearchingPublicCode] = useState(false);
|
||||
const [publicSearchError, setPublicSearchError] = useState<string | null>(null);
|
||||
@@ -334,8 +359,6 @@ export function PlatformEntryFlowShellImpl({
|
||||
const [deletingCreationWorkId, setDeletingCreationWorkId] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [streamingPuzzleReplyText, setStreamingPuzzleReplyText] = useState('');
|
||||
const [isStreamingPuzzleReply, setIsStreamingPuzzleReply] = useState(false);
|
||||
const hasInitialAgentSession = Boolean(
|
||||
readCustomWorldAgentUiState().activeSessionId,
|
||||
);
|
||||
@@ -359,6 +382,17 @@ export function PlatformEntryFlowShellImpl({
|
||||
setPlatformTab('create');
|
||||
}, [setPlatformTab]);
|
||||
|
||||
const resolveBigFishErrorMessage = useCallback(
|
||||
(error: unknown, fallback: string) =>
|
||||
resolveRpgCreationErrorMessage(error, fallback),
|
||||
[],
|
||||
);
|
||||
const resolvePuzzleErrorMessage = useCallback(
|
||||
(error: unknown, fallback: string) =>
|
||||
resolveRpgCreationErrorMessage(error, fallback),
|
||||
[],
|
||||
);
|
||||
|
||||
const sessionController = useRpgCreationSessionController({
|
||||
userId: authUi?.user?.id,
|
||||
openLoginModal: authUi?.openLoginModal,
|
||||
@@ -441,10 +475,18 @@ export function PlatformEntryFlowShellImpl({
|
||||
agentSessionProfile: sessionController.agentDraftResultProfile,
|
||||
agentSession: sessionController.agentSession,
|
||||
handleCustomWorldSelect,
|
||||
executePublishWorld: () =>
|
||||
autosaveCoordinator.executeAgentActionAndWait({
|
||||
executePublishWorld: async () => {
|
||||
const latestSession = await autosaveCoordinator.executeAgentActionAndWait({
|
||||
action: 'publish_world',
|
||||
}),
|
||||
});
|
||||
// 发布动作会在后端同步 gallery 投影;前端发布完成后立即刷新首页/分类页共用的公开作品列表。
|
||||
await Promise.allSettled([
|
||||
platformBootstrap.refreshPublishedGallery(),
|
||||
platformBootstrap.refreshCustomWorldWorks(),
|
||||
refreshPuzzleGallery(),
|
||||
]);
|
||||
return latestSession;
|
||||
},
|
||||
setGeneratedCustomWorldProfile:
|
||||
sessionController.setGeneratedCustomWorldProfile,
|
||||
});
|
||||
@@ -495,8 +537,24 @@ export function PlatformEntryFlowShellImpl({
|
||||
}, [agentResultPreview]);
|
||||
|
||||
const featuredGalleryEntries = useMemo(
|
||||
() => platformBootstrap.publishedGalleryEntries.slice(0, 6),
|
||||
[platformBootstrap.publishedGalleryEntries],
|
||||
() => {
|
||||
const puzzlePublicEntries = puzzleGalleryEntries.map(
|
||||
mapPuzzleWorkToPlatformGalleryCard,
|
||||
);
|
||||
return mergePlatformPublicGalleryEntries(
|
||||
platformBootstrap.publishedGalleryEntries,
|
||||
puzzlePublicEntries,
|
||||
).slice(0, 6);
|
||||
},
|
||||
[platformBootstrap.publishedGalleryEntries, puzzleGalleryEntries],
|
||||
);
|
||||
const latestGalleryEntries = useMemo(
|
||||
() =>
|
||||
mergePlatformPublicGalleryEntries(
|
||||
platformBootstrap.publishedGalleryEntries,
|
||||
puzzleGalleryEntries.map(mapPuzzleWorkToPlatformGalleryCard),
|
||||
),
|
||||
[platformBootstrap.publishedGalleryEntries, puzzleGalleryEntries],
|
||||
);
|
||||
|
||||
const creationHubItems =
|
||||
@@ -523,17 +581,6 @@ export function PlatformEntryFlowShellImpl({
|
||||
setSelectionStage,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectionStage === 'big-fish-result' && !bigFishSession?.draft) {
|
||||
setSelectionStage(
|
||||
bigFishSession ? 'big-fish-agent-workspace' : 'platform',
|
||||
);
|
||||
}
|
||||
if (selectionStage === 'big-fish-runtime' && !bigFishRun) {
|
||||
setSelectionStage(bigFishSession?.draft ? 'big-fish-result' : 'platform');
|
||||
}
|
||||
}, [bigFishRun, bigFishSession, selectionStage, setSelectionStage]);
|
||||
|
||||
const runProtectedAction = useCallback(
|
||||
(action: () => void) => {
|
||||
if (!authUi?.requireAuth) {
|
||||
@@ -647,17 +694,6 @@ export function PlatformEntryFlowShellImpl({
|
||||
setShowCreationTypeModal(true);
|
||||
}, [prepareCreationLaunch]);
|
||||
|
||||
const resolveBigFishErrorMessage = useCallback(
|
||||
(error: unknown, fallback: string) =>
|
||||
resolveRpgCreationErrorMessage(error, fallback),
|
||||
[],
|
||||
);
|
||||
const resolvePuzzleErrorMessage = useCallback(
|
||||
(error: unknown, fallback: string) =>
|
||||
resolveRpgCreationErrorMessage(error, fallback),
|
||||
[],
|
||||
);
|
||||
|
||||
const refreshBigFishShelf = useCallback(async () => {
|
||||
setIsBigFishLoadingLibrary(true);
|
||||
|
||||
@@ -690,64 +726,144 @@ export function PlatformEntryFlowShellImpl({
|
||||
}
|
||||
}, [resolvePuzzleErrorMessage]);
|
||||
|
||||
const openBigFishAgentWorkspace = useCallback(async () => {
|
||||
if (isBigFishBusy) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsBigFishBusy(true);
|
||||
setBigFishError(null);
|
||||
setBigFishRun(null);
|
||||
|
||||
const refreshPuzzleGallery = useCallback(async () => {
|
||||
try {
|
||||
const { session } = await createBigFishCreationSession({});
|
||||
setBigFishSession(session);
|
||||
enterCreateTab();
|
||||
setShowCreationTypeModal(false);
|
||||
setSelectionStage('big-fish-agent-workspace');
|
||||
const galleryResponse = await listPuzzleGallery();
|
||||
setPuzzleGalleryEntries(galleryResponse.items);
|
||||
return galleryResponse.items;
|
||||
} catch (error) {
|
||||
setBigFishError(
|
||||
resolveBigFishErrorMessage(error, '开启大鱼吃小鱼共创工作台失败。'),
|
||||
setPuzzleGalleryEntries([]);
|
||||
setPuzzleError(
|
||||
resolvePuzzleErrorMessage(error, '读取拼图广场失败。'),
|
||||
);
|
||||
} finally {
|
||||
setIsBigFishBusy(false);
|
||||
return [];
|
||||
}
|
||||
}, [
|
||||
}, [resolvePuzzleErrorMessage]);
|
||||
|
||||
const bigFishFlow = usePlatformCreationAgentFlowController<
|
||||
BigFishSessionSnapshotResponse,
|
||||
Record<string, never>,
|
||||
{ session: BigFishSessionSnapshotResponse },
|
||||
SendBigFishMessageRequest,
|
||||
ExecuteBigFishActionRequest,
|
||||
{ session: BigFishSessionSnapshotResponse }
|
||||
>({
|
||||
client: {
|
||||
createSession: createBigFishCreationSession,
|
||||
getSession: getBigFishCreationSession,
|
||||
streamMessage: streamBigFishCreationMessage,
|
||||
executeAction: executeBigFishCreationAction,
|
||||
selectSession: (response) => response.session,
|
||||
},
|
||||
createPayload: {},
|
||||
workspaceStage: 'big-fish-agent-workspace',
|
||||
resultStage: 'big-fish-result',
|
||||
platformStage: 'platform',
|
||||
isCompileAction: (payload) => payload.action === 'big_fish_compile_draft',
|
||||
resolveErrorMessage: resolveBigFishErrorMessage,
|
||||
errorMessages: {
|
||||
open: '开启大鱼吃小鱼共创工作台失败。',
|
||||
restoreMissingSession: '这份大鱼吃小鱼草稿缺少会话信息,请重新开始创作。',
|
||||
restore: '读取大鱼吃小鱼创作草稿失败。',
|
||||
submit: '发送大鱼吃小鱼共创消息失败。',
|
||||
execute: '执行大鱼吃小鱼操作失败。',
|
||||
},
|
||||
enterCreateTab,
|
||||
isBigFishBusy,
|
||||
resolveBigFishErrorMessage,
|
||||
setSelectionStage,
|
||||
]);
|
||||
onSessionOpened: () => {
|
||||
setShowCreationTypeModal(false);
|
||||
},
|
||||
onActionComplete: ({ response, setSession }) => {
|
||||
setSession(response.session);
|
||||
},
|
||||
});
|
||||
|
||||
const puzzleFlow = usePlatformCreationAgentFlowController<
|
||||
PuzzleAgentSessionSnapshot,
|
||||
Record<string, never>,
|
||||
{ session: PuzzleAgentSessionSnapshot },
|
||||
SendPuzzleAgentMessageRequest,
|
||||
PuzzleAgentActionRequest,
|
||||
{ operation: PuzzleAgentOperationRecord }
|
||||
>({
|
||||
client: {
|
||||
createSession: createPuzzleAgentSession,
|
||||
getSession: getPuzzleAgentSession,
|
||||
streamMessage: streamPuzzleAgentMessage,
|
||||
executeAction: executePuzzleAgentAction,
|
||||
selectSession: (response) => response.session,
|
||||
},
|
||||
createPayload: {},
|
||||
workspaceStage: 'puzzle-agent-workspace',
|
||||
resultStage: 'puzzle-result',
|
||||
platformStage: 'platform',
|
||||
isCompileAction: (payload) => payload.action === 'compile_puzzle_draft',
|
||||
resolveErrorMessage: resolvePuzzleErrorMessage,
|
||||
errorMessages: {
|
||||
open: '开启拼图共创工作台失败。',
|
||||
restoreMissingSession: '这份拼图草稿缺少会话信息,请重新开始创作。',
|
||||
restore: '读取拼图创作草稿失败。',
|
||||
submit: '发送拼图共创消息失败。',
|
||||
execute: '执行拼图操作失败。',
|
||||
},
|
||||
enterCreateTab,
|
||||
setSelectionStage,
|
||||
onSessionOpened: () => {
|
||||
setShowCreationTypeModal(false);
|
||||
},
|
||||
onActionComplete: async ({ payload, response, session, setSession }) => {
|
||||
setPuzzleOperation(response.operation);
|
||||
|
||||
if (payload.action === 'publish_puzzle_work') {
|
||||
await Promise.allSettled([
|
||||
refreshPuzzleShelf(),
|
||||
refreshPuzzleGallery(),
|
||||
]);
|
||||
}
|
||||
|
||||
const latestResponse = await getPuzzleAgentSession(session.sessionId);
|
||||
const latestSession = latestResponse.session;
|
||||
setSession(latestSession);
|
||||
|
||||
if (
|
||||
payload.action === 'publish_puzzle_work' &&
|
||||
latestSession.publishedProfileId
|
||||
) {
|
||||
const galleryDetail = await getPuzzleGalleryDetail(
|
||||
latestSession.publishedProfileId,
|
||||
);
|
||||
setSelectedPuzzleDetail(galleryDetail.item);
|
||||
setSelectionStage('puzzle-gallery-detail');
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const bigFishSession = bigFishFlow.session;
|
||||
const bigFishError = bigFishFlow.error;
|
||||
const setBigFishError = bigFishFlow.setError;
|
||||
const isBigFishBusy = bigFishFlow.isBusy;
|
||||
const setIsBigFishBusy = bigFishFlow.setIsBusy;
|
||||
const streamingBigFishReplyText = bigFishFlow.streamingReplyText;
|
||||
const isStreamingBigFishReply = bigFishFlow.isStreamingReply;
|
||||
|
||||
const puzzleSession = puzzleFlow.session;
|
||||
const puzzleError = puzzleFlow.error;
|
||||
const setPuzzleError = puzzleFlow.setError;
|
||||
const isPuzzleBusy = puzzleFlow.isBusy;
|
||||
const setIsPuzzleBusy = puzzleFlow.setIsBusy;
|
||||
const streamingPuzzleReplyText = puzzleFlow.streamingReplyText;
|
||||
const isStreamingPuzzleReply = puzzleFlow.isStreamingReply;
|
||||
|
||||
const openBigFishAgentWorkspace = useCallback(async () => {
|
||||
setBigFishRun(null);
|
||||
await bigFishFlow.openWorkspace();
|
||||
}, [bigFishFlow]);
|
||||
|
||||
const openPuzzleAgentWorkspace = useCallback(async () => {
|
||||
if (isPuzzleBusy) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsPuzzleBusy(true);
|
||||
setPuzzleError(null);
|
||||
setPuzzleRun(null);
|
||||
setPuzzleOperation(null);
|
||||
|
||||
try {
|
||||
const { session } = await createPuzzleAgentSession({});
|
||||
setPuzzleSession(session);
|
||||
enterCreateTab();
|
||||
setShowCreationTypeModal(false);
|
||||
setSelectionStage('puzzle-agent-workspace');
|
||||
} catch (error) {
|
||||
setPuzzleError(
|
||||
resolvePuzzleErrorMessage(error, '开启拼图共创工作台失败。'),
|
||||
);
|
||||
} finally {
|
||||
setIsPuzzleBusy(false);
|
||||
}
|
||||
}, [
|
||||
enterCreateTab,
|
||||
isPuzzleBusy,
|
||||
resolvePuzzleErrorMessage,
|
||||
setSelectionStage,
|
||||
]);
|
||||
await puzzleFlow.openWorkspace();
|
||||
}, [puzzleFlow]);
|
||||
|
||||
const handleCreationHubCreateType = useCallback(
|
||||
(type: PlatformCreationTypeId) => {
|
||||
@@ -789,206 +905,34 @@ export function PlatformEntryFlowShellImpl({
|
||||
);
|
||||
|
||||
const leaveBigFishFlow = useCallback(() => {
|
||||
setBigFishError(null);
|
||||
setBigFishRun(null);
|
||||
setStreamingBigFishReplyText('');
|
||||
setIsStreamingBigFishReply(false);
|
||||
enterCreateTab();
|
||||
setSelectionStage('platform');
|
||||
}, [enterCreateTab, setSelectionStage]);
|
||||
bigFishFlow.leaveFlow();
|
||||
}, [bigFishFlow]);
|
||||
|
||||
const leavePuzzleFlow = useCallback(() => {
|
||||
setPuzzleError(null);
|
||||
setPuzzleOperation(null);
|
||||
setPuzzleRun(null);
|
||||
setStreamingPuzzleReplyText('');
|
||||
setIsStreamingPuzzleReply(false);
|
||||
enterCreateTab();
|
||||
setSelectionStage('platform');
|
||||
}, [enterCreateTab, setSelectionStage]);
|
||||
puzzleFlow.leaveFlow();
|
||||
}, [puzzleFlow]);
|
||||
|
||||
const submitBigFishMessage = useCallback(
|
||||
async (payload: SendBigFishMessageRequest) => {
|
||||
if (!bigFishSession || isStreamingBigFishReply) {
|
||||
return;
|
||||
}
|
||||
const submitBigFishMessage = bigFishFlow.submitMessage;
|
||||
|
||||
const optimisticMessage = {
|
||||
id: payload.clientMessageId,
|
||||
role: 'user',
|
||||
kind: 'chat',
|
||||
text: payload.text.trim(),
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
const submitPuzzleMessage = puzzleFlow.submitMessage;
|
||||
|
||||
setBigFishError(null);
|
||||
setStreamingBigFishReplyText('');
|
||||
setIsStreamingBigFishReply(true);
|
||||
setBigFishSession((current) =>
|
||||
current
|
||||
? {
|
||||
...current,
|
||||
messages: [...current.messages, optimisticMessage],
|
||||
updatedAt: optimisticMessage.createdAt,
|
||||
}
|
||||
: current,
|
||||
const executeBigFishAction = bigFishFlow.executeAction;
|
||||
|
||||
const executePuzzleAction = puzzleFlow.executeAction;
|
||||
|
||||
useEffect(() => {
|
||||
if (selectionStage === 'big-fish-result' && !bigFishSession?.draft) {
|
||||
setSelectionStage(
|
||||
bigFishSession ? 'big-fish-agent-workspace' : 'platform',
|
||||
);
|
||||
|
||||
try {
|
||||
const nextSession = await streamBigFishCreationMessage(
|
||||
bigFishSession.sessionId,
|
||||
payload,
|
||||
{
|
||||
onUpdate: setStreamingBigFishReplyText,
|
||||
},
|
||||
);
|
||||
setBigFishSession(nextSession);
|
||||
setStreamingBigFishReplyText('');
|
||||
} catch (error) {
|
||||
setBigFishError(
|
||||
resolveBigFishErrorMessage(error, '发送大鱼吃小鱼共创消息失败。'),
|
||||
);
|
||||
} finally {
|
||||
setIsStreamingBigFishReply(false);
|
||||
}
|
||||
},
|
||||
[bigFishSession, isStreamingBigFishReply, resolveBigFishErrorMessage],
|
||||
);
|
||||
|
||||
const submitPuzzleMessage = useCallback(
|
||||
async (payload: SendPuzzleAgentMessageRequest) => {
|
||||
if (!puzzleSession || isStreamingPuzzleReply) {
|
||||
return;
|
||||
}
|
||||
|
||||
const optimisticMessage = {
|
||||
id: payload.clientMessageId,
|
||||
role: 'user',
|
||||
kind: 'chat',
|
||||
text: payload.text.trim(),
|
||||
createdAt: new Date().toISOString(),
|
||||
} satisfies PuzzleAgentSessionSnapshot['messages'][number];
|
||||
|
||||
setPuzzleError(null);
|
||||
setStreamingPuzzleReplyText('');
|
||||
setIsStreamingPuzzleReply(true);
|
||||
setPuzzleSession((current) =>
|
||||
current
|
||||
? {
|
||||
...current,
|
||||
messages: [...current.messages, optimisticMessage],
|
||||
updatedAt: optimisticMessage.createdAt,
|
||||
}
|
||||
: current,
|
||||
);
|
||||
|
||||
try {
|
||||
const nextSession = await streamPuzzleAgentMessage(
|
||||
puzzleSession.sessionId,
|
||||
payload,
|
||||
{
|
||||
onUpdate: setStreamingPuzzleReplyText,
|
||||
},
|
||||
);
|
||||
setPuzzleSession(nextSession);
|
||||
setStreamingPuzzleReplyText('');
|
||||
} catch (error) {
|
||||
setPuzzleError(
|
||||
resolvePuzzleErrorMessage(error, '发送拼图共创消息失败。'),
|
||||
);
|
||||
} finally {
|
||||
setIsStreamingPuzzleReply(false);
|
||||
}
|
||||
},
|
||||
[isStreamingPuzzleReply, puzzleSession, resolvePuzzleErrorMessage],
|
||||
);
|
||||
|
||||
const executeBigFishAction = useCallback(
|
||||
async (payload: ExecuteBigFishActionRequest) => {
|
||||
if (!bigFishSession || isBigFishBusy) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsBigFishBusy(true);
|
||||
setBigFishError(null);
|
||||
|
||||
try {
|
||||
const { session } = await executeBigFishCreationAction(
|
||||
bigFishSession.sessionId,
|
||||
payload,
|
||||
);
|
||||
setBigFishSession(session);
|
||||
if (payload.action === 'big_fish_compile_draft') {
|
||||
setSelectionStage('big-fish-result');
|
||||
}
|
||||
} catch (error) {
|
||||
setBigFishError(
|
||||
resolveBigFishErrorMessage(error, '执行大鱼吃小鱼操作失败。'),
|
||||
);
|
||||
} finally {
|
||||
setIsBigFishBusy(false);
|
||||
}
|
||||
},
|
||||
[
|
||||
bigFishSession,
|
||||
isBigFishBusy,
|
||||
resolveBigFishErrorMessage,
|
||||
setSelectionStage,
|
||||
],
|
||||
);
|
||||
|
||||
const executePuzzleAction = useCallback(
|
||||
async (payload: PuzzleAgentActionRequest) => {
|
||||
if (!puzzleSession || isPuzzleBusy) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsPuzzleBusy(true);
|
||||
setPuzzleError(null);
|
||||
|
||||
try {
|
||||
const { operation } = await executePuzzleAgentAction(
|
||||
puzzleSession.sessionId,
|
||||
payload,
|
||||
);
|
||||
setPuzzleOperation(operation);
|
||||
|
||||
if (payload.action === 'publish_puzzle_work') {
|
||||
await refreshPuzzleShelf();
|
||||
}
|
||||
|
||||
const { session } = await getPuzzleAgentSession(
|
||||
puzzleSession.sessionId,
|
||||
);
|
||||
setPuzzleSession(session);
|
||||
|
||||
if (payload.action === 'compile_puzzle_draft') {
|
||||
setSelectionStage('puzzle-result');
|
||||
}
|
||||
if (
|
||||
payload.action === 'publish_puzzle_work' &&
|
||||
session.publishedProfileId
|
||||
) {
|
||||
const galleryDetail = await getPuzzleGalleryDetail(
|
||||
session.publishedProfileId,
|
||||
);
|
||||
setSelectedPuzzleDetail(galleryDetail.item);
|
||||
setSelectionStage('puzzle-gallery-detail');
|
||||
}
|
||||
} catch (error) {
|
||||
setPuzzleError(resolvePuzzleErrorMessage(error, '执行拼图操作失败。'));
|
||||
} finally {
|
||||
setIsPuzzleBusy(false);
|
||||
}
|
||||
},
|
||||
[
|
||||
isPuzzleBusy,
|
||||
puzzleSession,
|
||||
refreshPuzzleShelf,
|
||||
resolvePuzzleErrorMessage,
|
||||
setSelectionStage,
|
||||
],
|
||||
);
|
||||
}
|
||||
if (selectionStage === 'big-fish-runtime' && !bigFishRun) {
|
||||
setSelectionStage(bigFishSession?.draft ? 'big-fish-result' : 'platform');
|
||||
}
|
||||
}, [bigFishRun, bigFishSession, selectionStage, setSelectionStage]);
|
||||
|
||||
const startBigFishRun = useCallback(async () => {
|
||||
if (!bigFishSession || isBigFishBusy) {
|
||||
@@ -1327,6 +1271,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
void deletePuzzleWork(work.profileId)
|
||||
.then((response) => {
|
||||
setPuzzleWorks(response.items);
|
||||
void refreshPuzzleGallery();
|
||||
})
|
||||
.catch((error) => {
|
||||
setPuzzleError(resolvePuzzleErrorMessage(error, '删除拼图作品失败。'));
|
||||
@@ -1336,7 +1281,12 @@ export function PlatformEntryFlowShellImpl({
|
||||
});
|
||||
});
|
||||
},
|
||||
[deletingCreationWorkId, resolvePuzzleErrorMessage, runProtectedAction],
|
||||
[
|
||||
deletingCreationWorkId,
|
||||
refreshPuzzleGallery,
|
||||
resolvePuzzleErrorMessage,
|
||||
runProtectedAction,
|
||||
],
|
||||
);
|
||||
|
||||
const openPuzzleDetail = useCallback(
|
||||
@@ -1360,80 +1310,28 @@ export function PlatformEntryFlowShellImpl({
|
||||
|
||||
const openPuzzleDraft = useCallback(
|
||||
async (item: PuzzleWorkSummary) => {
|
||||
const sessionId = item.sourceSessionId?.trim();
|
||||
if (!sessionId) {
|
||||
setPuzzleError('这份拼图草稿缺少会话信息,请重新开始创作。');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsPuzzleBusy(true);
|
||||
setPuzzleError(null);
|
||||
setPuzzleOperation(null);
|
||||
setPuzzleRun(null);
|
||||
setSelectedPuzzleDetail(null);
|
||||
setStreamingPuzzleReplyText('');
|
||||
setIsStreamingPuzzleReply(false);
|
||||
|
||||
try {
|
||||
const { session } = await getPuzzleAgentSession(sessionId);
|
||||
setPuzzleSession(session);
|
||||
enterCreateTab();
|
||||
setSelectionStage(session.draft ? 'puzzle-result' : 'puzzle-agent-workspace');
|
||||
} catch (error) {
|
||||
const restoredSession = await puzzleFlow.restoreDraft(item.sourceSessionId);
|
||||
if (!restoredSession) {
|
||||
await refreshPuzzleShelf().catch(() => undefined);
|
||||
setPuzzleError(resolvePuzzleErrorMessage(error, '读取拼图创作草稿失败。'));
|
||||
enterCreateTab();
|
||||
setSelectionStage('platform');
|
||||
} finally {
|
||||
setIsPuzzleBusy(false);
|
||||
}
|
||||
},
|
||||
[
|
||||
enterCreateTab,
|
||||
refreshPuzzleShelf,
|
||||
resolvePuzzleErrorMessage,
|
||||
setSelectionStage,
|
||||
],
|
||||
[puzzleFlow, refreshPuzzleShelf],
|
||||
);
|
||||
|
||||
const openBigFishDraft = useCallback(
|
||||
async (item: BigFishWorkSummary) => {
|
||||
const sessionId = item.sourceSessionId?.trim();
|
||||
if (!sessionId) {
|
||||
setBigFishError('这份大鱼吃小鱼草稿缺少会话信息,请重新开始创作。');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsBigFishBusy(true);
|
||||
setBigFishError(null);
|
||||
setBigFishRun(null);
|
||||
setStreamingBigFishReplyText('');
|
||||
setIsStreamingBigFishReply(false);
|
||||
|
||||
try {
|
||||
const { session } = await getBigFishCreationSession(sessionId);
|
||||
setBigFishSession(session);
|
||||
enterCreateTab();
|
||||
setSelectionStage(
|
||||
session.draft ? 'big-fish-result' : 'big-fish-agent-workspace',
|
||||
);
|
||||
} catch (error) {
|
||||
const restoredSession = await bigFishFlow.restoreDraft(
|
||||
item.sourceSessionId,
|
||||
);
|
||||
if (!restoredSession) {
|
||||
await refreshBigFishShelf().catch(() => undefined);
|
||||
setBigFishError(
|
||||
resolveBigFishErrorMessage(error, '读取大鱼吃小鱼创作草稿失败。'),
|
||||
);
|
||||
enterCreateTab();
|
||||
setSelectionStage('platform');
|
||||
} finally {
|
||||
setIsBigFishBusy(false);
|
||||
}
|
||||
},
|
||||
[
|
||||
enterCreateTab,
|
||||
refreshBigFishShelf,
|
||||
resolveBigFishErrorMessage,
|
||||
setSelectionStage,
|
||||
],
|
||||
[bigFishFlow, refreshBigFishShelf],
|
||||
);
|
||||
|
||||
const startBigFishRunFromWork = useCallback(
|
||||
@@ -1450,7 +1348,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
try {
|
||||
const { session } = await getBigFishCreationSession(sessionId);
|
||||
const { run } = await startBigFishRuntimeRun(sessionId);
|
||||
setBigFishSession(session);
|
||||
bigFishFlow.setSession(session);
|
||||
setBigFishRun(run);
|
||||
setSelectionStage('big-fish-runtime');
|
||||
} catch (error) {
|
||||
@@ -1461,9 +1359,15 @@ export function PlatformEntryFlowShellImpl({
|
||||
setIsBigFishBusy(false);
|
||||
}
|
||||
},
|
||||
[resolveBigFishErrorMessage, setSelectionStage],
|
||||
[bigFishFlow, resolveBigFishErrorMessage, setSelectionStage],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectionStage === 'platform') {
|
||||
void refreshPuzzleGallery();
|
||||
}
|
||||
}, [refreshPuzzleGallery, selectionStage]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
(platformBootstrap.platformTab === 'create' ||
|
||||
@@ -1610,7 +1514,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
saveEntries={platformBootstrap.saveEntries}
|
||||
saveError={platformBootstrap.saveError}
|
||||
featuredEntries={featuredGalleryEntries}
|
||||
latestEntries={platformBootstrap.publishedGalleryEntries}
|
||||
latestEntries={latestGalleryEntries}
|
||||
myEntries={platformBootstrap.savedCustomWorldEntries}
|
||||
historyEntries={platformBootstrap.historyEntries}
|
||||
profileDashboard={platformBootstrap.profileDashboard}
|
||||
@@ -1636,6 +1540,11 @@ export function PlatformEntryFlowShellImpl({
|
||||
onOpenCreateWorld={openCreationTypePicker}
|
||||
onOpenCreateTypePicker={openCreationTypePicker}
|
||||
onOpenGalleryDetail={(entry) => {
|
||||
if (isPuzzleGalleryEntry(entry)) {
|
||||
void openPuzzleDetail(entry.profileId);
|
||||
return;
|
||||
}
|
||||
|
||||
runProtectedAction(() => {
|
||||
void detailNavigation.openGalleryDetail(entry);
|
||||
});
|
||||
@@ -1658,6 +1567,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
void platformBootstrap.refreshProfileDashboard();
|
||||
}
|
||||
}}
|
||||
onRechargeSuccess={platformBootstrap.refreshProfileDashboard}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
@@ -1788,6 +1698,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
<BigFishAgentWorkspace
|
||||
session={bigFishSession}
|
||||
streamingReplyText={streamingBigFishReplyText}
|
||||
isStreamingReply={isStreamingBigFishReply}
|
||||
isBusy={isBigFishBusy || isStreamingBigFishReply}
|
||||
error={bigFishError}
|
||||
onBack={leaveBigFishFlow}
|
||||
@@ -1871,6 +1782,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
session={puzzleSession}
|
||||
activeOperation={puzzleOperation}
|
||||
streamingReplyText={streamingPuzzleReplyText}
|
||||
isStreamingReply={isStreamingPuzzleReply}
|
||||
isBusy={isPuzzleBusy || isStreamingPuzzleReply}
|
||||
error={puzzleError}
|
||||
onBack={leavePuzzleFlow}
|
||||
@@ -1985,7 +1897,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
onInterrupt={undefined}
|
||||
backLabel="返回工作区"
|
||||
settingActionLabel={null}
|
||||
retryLabel="重新生成草稿"
|
||||
retryLabel="继续生成草稿"
|
||||
settingTitle="当前世界信息"
|
||||
settingDescription={null}
|
||||
progressTitle="世界草稿生成进度"
|
||||
@@ -2024,26 +1936,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
onBack={
|
||||
sessionController.isAgentDraftResultView
|
||||
? () => {
|
||||
void (async () => {
|
||||
const currentProfile =
|
||||
sessionController.generatedCustomWorldProfile;
|
||||
if (!currentProfile) {
|
||||
leaveAgentDraftResult();
|
||||
return;
|
||||
}
|
||||
|
||||
await autosaveCoordinator.syncAgentDraftResultProfile(
|
||||
currentProfile,
|
||||
);
|
||||
leaveAgentDraftResult();
|
||||
})().catch((error) => {
|
||||
sessionController.setCustomWorldError(
|
||||
resolveRpgCreationErrorMessage(
|
||||
error,
|
||||
'返回创作前同步草稿失败。',
|
||||
),
|
||||
);
|
||||
});
|
||||
leaveAgentDraftResult();
|
||||
}
|
||||
: leaveCustomWorldResult
|
||||
}
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import type { TextStreamOptions } from '../../services/aiTypes';
|
||||
import type { SelectionStage } from './platformEntryTypes';
|
||||
|
||||
type CreationAgentMessageLike = {
|
||||
clientMessageId: string;
|
||||
text: string;
|
||||
};
|
||||
|
||||
type CreationAgentSessionLike = {
|
||||
sessionId: string;
|
||||
draft?: unknown;
|
||||
messages: Array<{
|
||||
id: string;
|
||||
role: string;
|
||||
kind?: string;
|
||||
text: string;
|
||||
createdAt?: string;
|
||||
}>;
|
||||
updatedAt?: string;
|
||||
};
|
||||
|
||||
type CreationAgentClientAdapter<
|
||||
TSession extends CreationAgentSessionLike,
|
||||
TCreatePayload,
|
||||
TCreateResponse,
|
||||
TMessagePayload extends CreationAgentMessageLike,
|
||||
TActionPayload,
|
||||
TActionResponse,
|
||||
> = {
|
||||
createSession: (payload: TCreatePayload) => Promise<TCreateResponse>;
|
||||
getSession: (sessionId: string) => Promise<TCreateResponse>;
|
||||
streamMessage: (
|
||||
sessionId: string,
|
||||
payload: TMessagePayload,
|
||||
options?: TextStreamOptions,
|
||||
) => Promise<TSession>;
|
||||
executeAction: (
|
||||
sessionId: string,
|
||||
payload: TActionPayload,
|
||||
) => Promise<TActionResponse>;
|
||||
selectSession: (response: TCreateResponse) => TSession;
|
||||
};
|
||||
|
||||
type PlatformCreationAgentFlowControllerOptions<
|
||||
TSession extends CreationAgentSessionLike,
|
||||
TCreatePayload,
|
||||
TCreateResponse,
|
||||
TMessagePayload extends CreationAgentMessageLike,
|
||||
TActionPayload,
|
||||
TActionResponse,
|
||||
> = {
|
||||
client: CreationAgentClientAdapter<
|
||||
TSession,
|
||||
TCreatePayload,
|
||||
TCreateResponse,
|
||||
TMessagePayload,
|
||||
TActionPayload,
|
||||
TActionResponse
|
||||
>;
|
||||
createPayload: TCreatePayload;
|
||||
workspaceStage: SelectionStage;
|
||||
resultStage: SelectionStage;
|
||||
platformStage: SelectionStage;
|
||||
isCompileAction: (payload: TActionPayload) => boolean;
|
||||
resolveErrorMessage: (error: unknown, fallback: string) => string;
|
||||
errorMessages: {
|
||||
open: string;
|
||||
restoreMissingSession: string;
|
||||
restore: string;
|
||||
submit: string;
|
||||
execute: string;
|
||||
};
|
||||
enterCreateTab: () => void;
|
||||
setSelectionStage: (stage: SelectionStage) => void;
|
||||
onSessionOpened?: () => void;
|
||||
onActionComplete?: (params: {
|
||||
payload: TActionPayload;
|
||||
response: TActionResponse;
|
||||
session: TSession;
|
||||
setSession: (session: TSession) => void;
|
||||
}) => Promise<void> | void;
|
||||
};
|
||||
|
||||
function buildOptimisticMessage<TMessagePayload extends CreationAgentMessageLike>(
|
||||
payload: TMessagePayload,
|
||||
) {
|
||||
return {
|
||||
id: payload.clientMessageId,
|
||||
role: 'user',
|
||||
kind: 'chat',
|
||||
text: payload.text.trim(),
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 轻量作品 Agent 创作流程的通用前端控制器。
|
||||
* 这里只处理跨玩法一致的会话、流式消息、忙碌态与草稿恢复,玩法结果页和运行态动作留给外层。
|
||||
*/
|
||||
export function usePlatformCreationAgentFlowController<
|
||||
TSession extends CreationAgentSessionLike,
|
||||
TCreatePayload,
|
||||
TCreateResponse,
|
||||
TMessagePayload extends CreationAgentMessageLike,
|
||||
TActionPayload,
|
||||
TActionResponse,
|
||||
>(
|
||||
options: PlatformCreationAgentFlowControllerOptions<
|
||||
TSession,
|
||||
TCreatePayload,
|
||||
TCreateResponse,
|
||||
TMessagePayload,
|
||||
TActionPayload,
|
||||
TActionResponse
|
||||
>,
|
||||
) {
|
||||
const [session, setSession] = useState<TSession | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isBusy, setIsBusy] = useState(false);
|
||||
const [streamingReplyText, setStreamingReplyText] = useState('');
|
||||
const [isStreamingReply, setIsStreamingReply] = useState(false);
|
||||
|
||||
const openWorkspace = useCallback(async () => {
|
||||
if (isBusy) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsBusy(true);
|
||||
setError(null);
|
||||
setStreamingReplyText('');
|
||||
setIsStreamingReply(false);
|
||||
|
||||
try {
|
||||
const response = await options.client.createSession(options.createPayload);
|
||||
setSession(options.client.selectSession(response));
|
||||
options.enterCreateTab();
|
||||
options.onSessionOpened?.();
|
||||
options.setSelectionStage(options.workspaceStage);
|
||||
} catch (caughtError) {
|
||||
setError(
|
||||
options.resolveErrorMessage(caughtError, options.errorMessages.open),
|
||||
);
|
||||
} finally {
|
||||
setIsBusy(false);
|
||||
}
|
||||
}, [isBusy, options]);
|
||||
|
||||
const restoreDraft = useCallback(
|
||||
async (sessionId: string | null | undefined) => {
|
||||
const normalizedSessionId = sessionId?.trim();
|
||||
if (!normalizedSessionId) {
|
||||
setError(options.errorMessages.restoreMissingSession);
|
||||
return null;
|
||||
}
|
||||
|
||||
setIsBusy(true);
|
||||
setError(null);
|
||||
setStreamingReplyText('');
|
||||
setIsStreamingReply(false);
|
||||
|
||||
try {
|
||||
const response = await options.client.getSession(normalizedSessionId);
|
||||
const nextSession = options.client.selectSession(response);
|
||||
setSession(nextSession);
|
||||
options.enterCreateTab();
|
||||
options.setSelectionStage(
|
||||
nextSession.draft ? options.resultStage : options.workspaceStage,
|
||||
);
|
||||
return nextSession;
|
||||
} catch (caughtError) {
|
||||
setError(
|
||||
options.resolveErrorMessage(caughtError, options.errorMessages.restore),
|
||||
);
|
||||
options.enterCreateTab();
|
||||
options.setSelectionStage(options.platformStage);
|
||||
return null;
|
||||
} finally {
|
||||
setIsBusy(false);
|
||||
}
|
||||
},
|
||||
[options],
|
||||
);
|
||||
|
||||
const submitMessage = useCallback(
|
||||
async (payload: TMessagePayload) => {
|
||||
if (!session || isStreamingReply) {
|
||||
return;
|
||||
}
|
||||
|
||||
const optimisticMessage = buildOptimisticMessage(payload);
|
||||
|
||||
setError(null);
|
||||
setStreamingReplyText('');
|
||||
setIsStreamingReply(true);
|
||||
setSession((current) =>
|
||||
current
|
||||
? {
|
||||
...current,
|
||||
messages: [...current.messages, optimisticMessage],
|
||||
updatedAt: optimisticMessage.createdAt,
|
||||
}
|
||||
: current,
|
||||
);
|
||||
|
||||
try {
|
||||
const nextSession = await options.client.streamMessage(
|
||||
session.sessionId,
|
||||
payload,
|
||||
{
|
||||
onUpdate: setStreamingReplyText,
|
||||
},
|
||||
);
|
||||
setSession(nextSession);
|
||||
setStreamingReplyText('');
|
||||
} catch (caughtError) {
|
||||
setError(
|
||||
options.resolveErrorMessage(caughtError, options.errorMessages.submit),
|
||||
);
|
||||
} finally {
|
||||
setIsStreamingReply(false);
|
||||
}
|
||||
},
|
||||
[isStreamingReply, options, session],
|
||||
);
|
||||
|
||||
const executeAction = useCallback(
|
||||
async (payload: TActionPayload) => {
|
||||
if (!session || isBusy) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsBusy(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await options.client.executeAction(
|
||||
session.sessionId,
|
||||
payload,
|
||||
);
|
||||
await options.onActionComplete?.({
|
||||
payload,
|
||||
response,
|
||||
session,
|
||||
setSession,
|
||||
});
|
||||
if (options.isCompileAction(payload)) {
|
||||
options.setSelectionStage(options.resultStage);
|
||||
}
|
||||
} catch (caughtError) {
|
||||
setError(
|
||||
options.resolveErrorMessage(caughtError, options.errorMessages.execute),
|
||||
);
|
||||
} finally {
|
||||
setIsBusy(false);
|
||||
}
|
||||
},
|
||||
[isBusy, options, session],
|
||||
);
|
||||
|
||||
const leaveFlow = useCallback(() => {
|
||||
setError(null);
|
||||
setStreamingReplyText('');
|
||||
setIsStreamingReply(false);
|
||||
options.enterCreateTab();
|
||||
options.setSelectionStage(options.platformStage);
|
||||
}, [options]);
|
||||
|
||||
const resetTransientState = useCallback(() => {
|
||||
setError(null);
|
||||
setStreamingReplyText('');
|
||||
setIsStreamingReply(false);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
session,
|
||||
setSession,
|
||||
error,
|
||||
setError,
|
||||
isBusy,
|
||||
setIsBusy,
|
||||
streamingReplyText,
|
||||
setStreamingReplyText,
|
||||
isStreamingReply,
|
||||
setIsStreamingReply,
|
||||
openWorkspace,
|
||||
restoreDraft,
|
||||
submitMessage,
|
||||
executeAction,
|
||||
leaveFlow,
|
||||
resetTransientState,
|
||||
};
|
||||
}
|
||||
@@ -23,6 +23,7 @@ type PuzzleAgentWorkspaceProps = {
|
||||
session: PuzzleAgentSessionSnapshot | null;
|
||||
activeOperation?: PuzzleAgentOperationRecord | null;
|
||||
streamingReplyText?: string;
|
||||
isStreamingReply?: boolean;
|
||||
isBusy?: boolean;
|
||||
error?: string | null;
|
||||
onBack: () => void;
|
||||
@@ -87,6 +88,7 @@ export function PuzzleAgentWorkspace({
|
||||
session,
|
||||
activeOperation = null,
|
||||
streamingReplyText = '',
|
||||
isStreamingReply = false,
|
||||
isBusy = false,
|
||||
error = null,
|
||||
onBack,
|
||||
@@ -102,7 +104,7 @@ export function PuzzleAgentWorkspace({
|
||||
primaryActionLabel="生成结果页"
|
||||
activeOperation={mapPuzzleOperation(activeOperation)}
|
||||
streamingReplyText={streamingReplyText}
|
||||
isStreamingReply={Boolean(streamingReplyText)}
|
||||
isStreamingReply={isStreamingReply}
|
||||
isBusy={isBusy}
|
||||
error={error}
|
||||
quickActions={createCreationAgentChatQuickActions()}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { WorldFoundationEditor as default } from './RpgCreationEntityEditorShared';
|
||||
@@ -2,6 +2,7 @@ import type { CustomWorldProfile } from '../../types';
|
||||
|
||||
import CampSceneEditor from './CustomWorldCampEditorSection';
|
||||
import WorldCoverEditor from './CustomWorldCoverEditorSection';
|
||||
import WorldFoundationEditor from './CustomWorldFoundationEditorSection';
|
||||
import LandmarkEditor from './CustomWorldLandmarkEditorSection';
|
||||
import PlayableNpcEditor, {
|
||||
StoryNpcEditor,
|
||||
@@ -15,6 +16,7 @@ import {
|
||||
|
||||
export type RpgCreationEditorTarget =
|
||||
| { kind: 'world' }
|
||||
| { kind: 'foundation' }
|
||||
| { kind: 'cover' }
|
||||
| { kind: 'camp' }
|
||||
| { kind: 'playable'; mode: 'create' }
|
||||
@@ -55,6 +57,16 @@ export function RpgCreationEntityEditorModal({
|
||||
);
|
||||
}
|
||||
|
||||
if (target.kind === 'foundation') {
|
||||
return (
|
||||
<WorldFoundationEditor
|
||||
profile={profile}
|
||||
onSave={onProfileChange}
|
||||
onClose={onClose}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (target.kind === 'cover') {
|
||||
return (
|
||||
<WorldCoverEditor
|
||||
|
||||
@@ -32,6 +32,14 @@ import {
|
||||
buildDefaultCustomWorldCoverProfile,
|
||||
resolveCustomWorldCoverPresentation,
|
||||
} from '../../services/customWorldCover';
|
||||
import {
|
||||
getCustomWorldFoundationAnchorContent,
|
||||
parseFoundationTagText,
|
||||
type CustomWorldFoundationEntryId,
|
||||
} from '../../services/customWorldFoundationEntries';
|
||||
import {
|
||||
createEmptyCustomWorldCreatorIntent,
|
||||
} from '../../services/customWorldCreatorIntent';
|
||||
import {
|
||||
type CustomWorldCoverAssetResult,
|
||||
generateCustomWorldCoverImage,
|
||||
@@ -94,6 +102,7 @@ import {
|
||||
|
||||
export type RpgCreationEditorTarget =
|
||||
| { kind: 'world' }
|
||||
| { kind: 'foundation' }
|
||||
| { kind: 'cover' }
|
||||
| { kind: 'camp' }
|
||||
| { kind: 'playable'; mode: 'create' }
|
||||
@@ -4522,6 +4531,270 @@ export function WorldEditor({
|
||||
);
|
||||
}
|
||||
|
||||
type FoundationDraft = Record<CustomWorldFoundationEntryId, string>;
|
||||
|
||||
const FOUNDATION_EDITOR_FIELDS: Array<{
|
||||
id: CustomWorldFoundationEntryId;
|
||||
label: string;
|
||||
rows: number;
|
||||
}> = [
|
||||
{ id: 'world-promise', label: '世界承诺', rows: 4 },
|
||||
{ id: 'player-fantasy', label: '玩家幻想', rows: 4 },
|
||||
{ id: 'theme-boundary', label: '主题边界', rows: 4 },
|
||||
{ id: 'player-entry-point', label: '玩家切入口', rows: 4 },
|
||||
{ id: 'core-conflict', label: '核心冲突', rows: 4 },
|
||||
{ id: 'key-relationships', label: '关键关系', rows: 4 },
|
||||
{ id: 'hidden-lines', label: '暗线与揭示', rows: 4 },
|
||||
{ id: 'iconic-elements', label: '标志元素', rows: 4 },
|
||||
];
|
||||
|
||||
function buildFoundationDraft(profile: CustomWorldProfile): FoundationDraft {
|
||||
const anchorContent = getCustomWorldFoundationAnchorContent(profile);
|
||||
|
||||
return {
|
||||
'world-promise': [
|
||||
anchorContent.worldPromise?.hook || '',
|
||||
anchorContent.worldPromise?.differentiator || '',
|
||||
anchorContent.worldPromise?.desiredExperience || '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(';'),
|
||||
'player-fantasy': [
|
||||
anchorContent.playerFantasy?.playerRole || '',
|
||||
anchorContent.playerFantasy?.corePursuit || '',
|
||||
anchorContent.playerFantasy?.fearOfLoss || '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(';'),
|
||||
'theme-boundary': [
|
||||
anchorContent.themeBoundary?.toneKeywords.join('、') || '',
|
||||
anchorContent.themeBoundary?.aestheticDirectives.join('、') || '',
|
||||
anchorContent.themeBoundary?.forbiddenDirectives.length
|
||||
? `避免:${anchorContent.themeBoundary.forbiddenDirectives.join('、')}`
|
||||
: '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(';'),
|
||||
'player-entry-point': [
|
||||
anchorContent.playerEntryPoint?.openingIdentity || '',
|
||||
anchorContent.playerEntryPoint?.openingProblem || '',
|
||||
anchorContent.playerEntryPoint?.entryMotivation || '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(';'),
|
||||
'core-conflict': [
|
||||
anchorContent.coreConflict?.surfaceConflicts.join('、') || '',
|
||||
anchorContent.coreConflict?.hiddenCrisis || '',
|
||||
anchorContent.coreConflict?.firstTouchedConflict || '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(';'),
|
||||
'key-relationships': anchorContent.keyRelationships
|
||||
.map((entry) =>
|
||||
[entry.pairs, entry.relationshipType, entry.secretOrCost]
|
||||
.filter(Boolean)
|
||||
.join(';'),
|
||||
)
|
||||
.join('\n'),
|
||||
'hidden-lines': [
|
||||
anchorContent.hiddenLines?.hiddenTruths.join('、') || '',
|
||||
anchorContent.hiddenLines?.misdirectionHints.join('、') || '',
|
||||
anchorContent.hiddenLines?.revealPacing || '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(';'),
|
||||
'iconic-elements': [
|
||||
anchorContent.iconicElements?.iconicMotifs.join('、') || '',
|
||||
anchorContent.iconicElements?.institutionsOrArtifacts.join('、') || '',
|
||||
anchorContent.iconicElements?.hardRules.join('、') || '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(';'),
|
||||
};
|
||||
}
|
||||
|
||||
function splitCommaTags(value: string) {
|
||||
return value
|
||||
.split(/[、,,]/u)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function stripAvoidPrefix(value: string) {
|
||||
return value.replace(/^避免[::]\s*/u, '').trim();
|
||||
}
|
||||
|
||||
function applyFoundationDraftToProfile(
|
||||
profile: CustomWorldProfile,
|
||||
draft: FoundationDraft,
|
||||
): CustomWorldProfile {
|
||||
const worldPromiseTags = parseFoundationTagText(draft['world-promise']);
|
||||
const playerFantasyTags = parseFoundationTagText(draft['player-fantasy']);
|
||||
const themeBoundaryTags = parseFoundationTagText(draft['theme-boundary']);
|
||||
const playerEntryTags = parseFoundationTagText(draft['player-entry-point']);
|
||||
const coreConflictTags = parseFoundationTagText(draft['core-conflict']);
|
||||
const hiddenLineTags = parseFoundationTagText(draft['hidden-lines']);
|
||||
const iconicElementTags = parseFoundationTagText(draft['iconic-elements']);
|
||||
const relationshipLines = draft['key-relationships']
|
||||
.split(/\r?\n/u)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
const creatorIntent =
|
||||
profile.creatorIntent ?? createEmptyCustomWorldCreatorIntent('freeform');
|
||||
|
||||
return {
|
||||
...profile,
|
||||
summary: worldPromiseTags[0] || profile.summary,
|
||||
subtitle: worldPromiseTags[1] || profile.subtitle,
|
||||
tone: themeBoundaryTags[0] || profile.tone,
|
||||
playerGoal: playerFantasyTags[1] || profile.playerGoal,
|
||||
worldHook: worldPromiseTags[0] || profile.worldHook || null,
|
||||
playerPremise: playerFantasyTags[0] || profile.playerPremise || null,
|
||||
coreConflicts: coreConflictTags[0]
|
||||
? splitCommaTags(coreConflictTags[0])
|
||||
: profile.coreConflicts,
|
||||
creatorIntent: {
|
||||
...creatorIntent,
|
||||
worldHook: worldPromiseTags[0] || creatorIntent.worldHook,
|
||||
playerPremise: playerFantasyTags[0] || creatorIntent.playerPremise,
|
||||
openingSituation: playerEntryTags[1] || creatorIntent.openingSituation,
|
||||
themeKeywords: themeBoundaryTags[0]
|
||||
? splitCommaTags(themeBoundaryTags[0])
|
||||
: creatorIntent.themeKeywords,
|
||||
toneDirectives: themeBoundaryTags[1]
|
||||
? splitCommaTags(themeBoundaryTags[1])
|
||||
: creatorIntent.toneDirectives,
|
||||
coreConflicts: coreConflictTags[0]
|
||||
? splitCommaTags(coreConflictTags[0])
|
||||
: creatorIntent.coreConflicts,
|
||||
iconicElements: iconicElementTags[0]
|
||||
? splitCommaTags(iconicElementTags[0])
|
||||
: creatorIntent.iconicElements,
|
||||
forbiddenDirectives: themeBoundaryTags[2]
|
||||
? splitCommaTags(stripAvoidPrefix(themeBoundaryTags[2]))
|
||||
: creatorIntent.forbiddenDirectives,
|
||||
},
|
||||
anchorContent: {
|
||||
worldPromise: {
|
||||
hook: worldPromiseTags[0] || '',
|
||||
differentiator: worldPromiseTags[1] || '',
|
||||
desiredExperience: worldPromiseTags[2] || '',
|
||||
},
|
||||
playerFantasy: {
|
||||
playerRole: playerFantasyTags[0] || '',
|
||||
corePursuit: playerFantasyTags[1] || '',
|
||||
fearOfLoss: playerFantasyTags[2] || '',
|
||||
},
|
||||
themeBoundary: {
|
||||
toneKeywords: themeBoundaryTags[0]
|
||||
? splitCommaTags(themeBoundaryTags[0])
|
||||
: [],
|
||||
aestheticDirectives: themeBoundaryTags[1]
|
||||
? splitCommaTags(themeBoundaryTags[1])
|
||||
: [],
|
||||
forbiddenDirectives: themeBoundaryTags[2]
|
||||
? splitCommaTags(stripAvoidPrefix(themeBoundaryTags[2]))
|
||||
: [],
|
||||
},
|
||||
playerEntryPoint: {
|
||||
openingIdentity: playerEntryTags[0] || '',
|
||||
openingProblem: playerEntryTags[1] || '',
|
||||
entryMotivation: playerEntryTags[2] || '',
|
||||
},
|
||||
coreConflict: {
|
||||
surfaceConflicts: coreConflictTags[0]
|
||||
? splitCommaTags(coreConflictTags[0])
|
||||
: [],
|
||||
hiddenCrisis: coreConflictTags[1] || '',
|
||||
firstTouchedConflict: coreConflictTags[2] || '',
|
||||
},
|
||||
keyRelationships: relationshipLines.map((line) => {
|
||||
const tags = parseFoundationTagText(line);
|
||||
return {
|
||||
pairs: tags[0] || '',
|
||||
relationshipType: tags[1] || '',
|
||||
secretOrCost: tags[2] || '',
|
||||
};
|
||||
}),
|
||||
hiddenLines: {
|
||||
hiddenTruths: hiddenLineTags[0] ? splitCommaTags(hiddenLineTags[0]) : [],
|
||||
misdirectionHints: hiddenLineTags[1]
|
||||
? splitCommaTags(hiddenLineTags[1])
|
||||
: [],
|
||||
revealPacing: hiddenLineTags[2] || '',
|
||||
},
|
||||
iconicElements: {
|
||||
iconicMotifs: iconicElementTags[0]
|
||||
? splitCommaTags(iconicElementTags[0])
|
||||
: [],
|
||||
institutionsOrArtifacts: iconicElementTags[1]
|
||||
? splitCommaTags(iconicElementTags[1])
|
||||
: [],
|
||||
hardRules: iconicElementTags[2] ? splitCommaTags(iconicElementTags[2]) : [],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function WorldFoundationEditor({
|
||||
profile,
|
||||
onSave,
|
||||
onClose,
|
||||
}: {
|
||||
profile: CustomWorldProfile;
|
||||
onSave: (profile: CustomWorldProfile) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const initialDraft = useMemo(() => buildFoundationDraft(profile), [profile]);
|
||||
const [draft, setDraft] = useDraft(initialDraft);
|
||||
|
||||
return (
|
||||
<ModalShell
|
||||
title="编辑基本设定"
|
||||
onClose={onClose}
|
||||
panelClassName="sm:max-w-4xl"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{FOUNDATION_EDITOR_FIELDS.map((field) => (
|
||||
<Field key={field.id} label={field.label}>
|
||||
<div className="space-y-3">
|
||||
<TextArea
|
||||
value={draft[field.id]}
|
||||
onChange={(value) =>
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
[field.id]: value,
|
||||
}))
|
||||
}
|
||||
rows={field.rows}
|
||||
/>
|
||||
{draft[field.id].trim() ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{parseFoundationTagText(draft[field.id]).map((tag, index) => (
|
||||
<span
|
||||
key={`${field.id}-${index}-${tag}`}
|
||||
className="rounded-full border border-white/10 bg-white/[0.06] px-3 py-1 text-xs leading-5 text-zinc-100"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</Field>
|
||||
))}
|
||||
<SaveBar
|
||||
onClose={onClose}
|
||||
onSave={() => {
|
||||
onSave(applyFoundationDraftToProfile(profile, draft));
|
||||
onClose();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</ModalShell>
|
||||
);
|
||||
}
|
||||
|
||||
export function CampSceneEditor({
|
||||
profile,
|
||||
onSaveProfile,
|
||||
@@ -5921,6 +6194,16 @@ function RpgCreationEntityEditorModal({
|
||||
);
|
||||
}
|
||||
|
||||
if (target.kind === 'foundation') {
|
||||
return (
|
||||
<WorldFoundationEditor
|
||||
profile={profile}
|
||||
onSave={onProfileChange}
|
||||
onClose={onClose}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (target.kind === 'cover') {
|
||||
return (
|
||||
<WorldCoverEditor
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useState } from 'react';
|
||||
import { beforeEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import type { CustomWorldAgentSessionSnapshot } from '../../../packages/shared/src/contracts/customWorldAgent';
|
||||
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
|
||||
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
|
||||
import {
|
||||
createRpgCreationSession,
|
||||
@@ -41,6 +42,10 @@ import {
|
||||
createPuzzleAgentSession,
|
||||
getPuzzleAgentSession,
|
||||
} from '../../services/puzzle-agent';
|
||||
import {
|
||||
getPuzzleGalleryDetail,
|
||||
listPuzzleGallery,
|
||||
} from '../../services/puzzle-gallery';
|
||||
import { listPuzzleWorks } from '../../services/puzzle-works';
|
||||
import type { GameState } from '../../types';
|
||||
import {
|
||||
@@ -118,6 +123,11 @@ vi.mock('../../services/puzzle-works', () => ({
|
||||
listPuzzleWorks: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/puzzle-gallery', () => ({
|
||||
getPuzzleGalleryDetail: vi.fn(),
|
||||
listPuzzleGallery: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/big-fish-creation', () => ({
|
||||
createBigFishCreationSession: vi.fn(),
|
||||
executeBigFishCreationAction: vi.fn(),
|
||||
@@ -958,6 +968,9 @@ beforeEach(() => {
|
||||
vi.mocked(listPuzzleWorks).mockResolvedValue({
|
||||
items: [],
|
||||
});
|
||||
vi.mocked(listPuzzleGallery).mockResolvedValue({
|
||||
items: [],
|
||||
});
|
||||
vi.mocked(executeRpgCreationAction).mockResolvedValue({
|
||||
operation: {
|
||||
operationId: 'operation-draft-foundation-1',
|
||||
@@ -1137,6 +1150,53 @@ test('create tab resumes agent workspace when draft has no compiled result yet',
|
||||
expect(screen.queryByText('世界档案')).toBeNull();
|
||||
});
|
||||
|
||||
test('create tab resumes agent workspace when session has no draft profile even if summary counts look compiled', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
vi.mocked(listRpgCreationWorks).mockResolvedValue([
|
||||
{
|
||||
workId: 'draft:custom-world-agent-session-1',
|
||||
sourceType: 'agent_session',
|
||||
status: 'draft',
|
||||
title: '潮雾列岛',
|
||||
subtitle: '待完善草稿',
|
||||
summary: '作品卡摘要仍带着旧对象数量,但服务端还没有草稿 profile。',
|
||||
coverImageSrc: null,
|
||||
coverRenderMode: 'image',
|
||||
coverCharacterImageSrcs: [],
|
||||
updatedAt: '2026-04-20T10:00:00.000Z',
|
||||
publishedAt: null,
|
||||
stage: 'clarifying',
|
||||
stageLabel: '补齐关键锚点',
|
||||
playableNpcCount: 2,
|
||||
landmarkCount: 1,
|
||||
roleVisualReadyCount: 0,
|
||||
roleAnimationReadyCount: 0,
|
||||
roleAssetSummaryLabel: null,
|
||||
sessionId: 'custom-world-agent-session-1',
|
||||
profileId: null,
|
||||
canResume: true,
|
||||
canEnterWorld: false,
|
||||
},
|
||||
]);
|
||||
vi.mocked(getRpgCreationSession).mockResolvedValue({
|
||||
...mockSession,
|
||||
stage: 'clarifying',
|
||||
draftProfile: null,
|
||||
});
|
||||
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
await openCreationHub(user);
|
||||
expect(await screen.findByRole('button', { name: /继续完善/u })).toBeTruthy();
|
||||
await user.click(await screen.findByRole('button', { name: /继续完善/u }));
|
||||
|
||||
expect(
|
||||
await screen.findByText('Agent工作区:custom-world-agent-session-1'),
|
||||
).toBeTruthy();
|
||||
expect(screen.queryByText('世界档案')).toBeNull();
|
||||
});
|
||||
|
||||
test('opening a compiled draft with a missing agent session falls back to create hub', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
@@ -1241,6 +1301,48 @@ test('clicking a public work while logged out routes through requireAuth', async
|
||||
expect(getRpgEntryWorldGalleryDetail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('published puzzle works appear on home and category public shelves', async () => {
|
||||
const user = userEvent.setup();
|
||||
const publishedPuzzleWork = {
|
||||
workId: 'puzzle-work-public-1',
|
||||
profileId: 'puzzle-profile-public-1',
|
||||
ownerUserId: 'user-2',
|
||||
sourceSessionId: 'puzzle-session-public-1',
|
||||
authorDisplayName: '拼图作者',
|
||||
levelName: '星桥机关',
|
||||
summary: '旋转碎片并接通星桥机关。',
|
||||
themeTags: ['机关', '星桥'],
|
||||
coverImageSrc: null,
|
||||
coverAssetId: null,
|
||||
publicationStatus: 'published',
|
||||
updatedAt: '2026-04-25T09:00:00.000Z',
|
||||
publishedAt: '2026-04-25T09:00:00.000Z',
|
||||
playCount: 3,
|
||||
publishReady: true,
|
||||
} satisfies PuzzleWorkSummary;
|
||||
|
||||
vi.mocked(listPuzzleGallery).mockResolvedValue({
|
||||
items: [publishedPuzzleWork],
|
||||
});
|
||||
vi.mocked(getPuzzleGalleryDetail).mockResolvedValue({
|
||||
item: publishedPuzzleWork,
|
||||
});
|
||||
|
||||
render(<TestWrapper />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText('星桥机关').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '分类' }));
|
||||
|
||||
const categoryPanel = getPlatformTabPanel('category');
|
||||
expect(within(categoryPanel).getAllByText('星桥机关').length).toBeGreaterThan(0);
|
||||
expect(
|
||||
within(categoryPanel).getAllByRole('button', { name: /机关/u }).length,
|
||||
).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('selecting RPG creation while logged out routes through requireAuth', async () => {
|
||||
const user = userEvent.setup();
|
||||
const requireAuth = vi.fn();
|
||||
@@ -2011,30 +2113,9 @@ test('agent result view does not keep legacy publish blockers when preview uses
|
||||
expect((actionButton as HTMLButtonElement).disabled).toBe(false);
|
||||
});
|
||||
|
||||
test('agent draft result back button syncs result profile before returning to creation hub', async () => {
|
||||
test('agent draft result back button returns to creation hub without syncing result profile', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
vi.mocked(executeRpgCreationAction).mockResolvedValue({
|
||||
operation: {
|
||||
operationId: 'operation-sync-result-profile-1',
|
||||
type: 'sync_result_profile',
|
||||
status: 'queued',
|
||||
phaseLabel: '同步结果页快照',
|
||||
phaseDetail: '正在把结果页里的最新世界结构写回当前草稿。',
|
||||
progress: 24,
|
||||
error: null,
|
||||
},
|
||||
});
|
||||
vi.mocked(getRpgCreationOperation).mockResolvedValue({
|
||||
operationId: 'operation-sync-result-profile-1',
|
||||
type: 'sync_result_profile',
|
||||
status: 'completed',
|
||||
phaseLabel: '结果页快照已同步',
|
||||
phaseDetail: '当前结果页编辑内容已经并回 Agent 草稿主链。',
|
||||
progress: 100,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const resultSession = {
|
||||
...mockSession,
|
||||
stage: 'object_refining' as const,
|
||||
@@ -2198,34 +2279,13 @@ test('agent draft result back button syncs result profile before returning to cr
|
||||
sessionId === 'custom-world-agent-session-1' &&
|
||||
payload?.action === 'sync_result_profile',
|
||||
),
|
||||
).toBe(true);
|
||||
).toBe(false);
|
||||
expect(screen.queryByText('世界档案')).toBeNull();
|
||||
});
|
||||
|
||||
test('agent draft result auto-save persists the latest profile rebuilt from synced session', async () => {
|
||||
test('agent draft result auto-save persists the latest profile from session draft without result sync action', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
vi.mocked(executeRpgCreationAction).mockResolvedValue({
|
||||
operation: {
|
||||
operationId: 'operation-sync-result-profile-2',
|
||||
type: 'sync_result_profile',
|
||||
status: 'queued',
|
||||
phaseLabel: '同步结果页快照',
|
||||
phaseDetail: '正在把结果页里的最新世界结构写回当前草稿。',
|
||||
progress: 24,
|
||||
error: null,
|
||||
},
|
||||
});
|
||||
vi.mocked(getRpgCreationOperation).mockResolvedValue({
|
||||
operationId: 'operation-sync-result-profile-2',
|
||||
type: 'sync_result_profile',
|
||||
status: 'completed',
|
||||
phaseLabel: '结果页快照已同步',
|
||||
phaseDetail: '当前结果页编辑内容已经并回 Agent 草稿主链。',
|
||||
progress: 100,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const syncedSession = {
|
||||
...mockSession,
|
||||
stage: 'object_refining' as const,
|
||||
@@ -2353,6 +2413,15 @@ test('agent draft result auto-save persists the latest profile rebuilt from sync
|
||||
expect(latestSaveRequest).toEqual({
|
||||
sourceAgentSessionId: 'custom-world-agent-session-1',
|
||||
});
|
||||
expect(
|
||||
vi
|
||||
.mocked(executeRpgCreationAction)
|
||||
.mock.calls.some(
|
||||
([sessionId, payload]) =>
|
||||
sessionId === 'custom-world-agent-session-1' &&
|
||||
payload?.action === 'sync_result_profile',
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test('agent draft result can open from server result preview without embedded legacyResultProfile', async () => {
|
||||
|
||||
176
src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx
Normal file
176
src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx
Normal file
@@ -0,0 +1,176 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { afterEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import { AuthUiContext } from '../auth/AuthUiContext';
|
||||
import { RpgEntryHomeView } from './RpgEntryHomeView';
|
||||
|
||||
vi.mock('../../services/rpg-entry/rpgProfileClient', () => ({
|
||||
getRpgProfileRechargeCenter: vi.fn(async () => ({
|
||||
walletBalance: 0,
|
||||
membership: {
|
||||
status: 'normal',
|
||||
tier: 'normal',
|
||||
startedAt: null,
|
||||
expiresAt: null,
|
||||
updatedAt: null,
|
||||
},
|
||||
pointProducts: [
|
||||
{
|
||||
productId: 'points_10',
|
||||
title: '10积分',
|
||||
priceCents: 100,
|
||||
kind: 'points',
|
||||
pointsAmount: 10,
|
||||
bonusPoints: 19,
|
||||
durationDays: 0,
|
||||
badgeLabel: '首充送积分',
|
||||
description: '首充送19积分',
|
||||
tier: 'normal',
|
||||
},
|
||||
],
|
||||
membershipProducts: [
|
||||
{
|
||||
productId: 'member_month',
|
||||
title: '月卡',
|
||||
priceCents: 2800,
|
||||
kind: 'membership',
|
||||
pointsAmount: 0,
|
||||
bonusPoints: 0,
|
||||
durationDays: 30,
|
||||
badgeLabel: '',
|
||||
description: '30天会员',
|
||||
tier: 'month',
|
||||
},
|
||||
],
|
||||
benefits: [
|
||||
{
|
||||
benefitName: '免积分回合数',
|
||||
normalValue: '30',
|
||||
monthValue: '100',
|
||||
seasonValue: '100',
|
||||
yearValue: '100',
|
||||
},
|
||||
],
|
||||
latestOrder: null,
|
||||
hasPointsRecharged: false,
|
||||
})),
|
||||
createRpgProfileRechargeOrder: vi.fn(async () => ({
|
||||
order: {
|
||||
orderId: 'order-1',
|
||||
productId: 'points_10',
|
||||
productTitle: '10积分',
|
||||
kind: 'points',
|
||||
amountCents: 100,
|
||||
status: 'paid',
|
||||
paymentChannel: 'mock',
|
||||
paidAt: '2026-04-25T10:00:00Z',
|
||||
createdAt: '2026-04-25T10:00:00Z',
|
||||
pointsDelta: 29,
|
||||
membershipExpiresAt: null,
|
||||
},
|
||||
center: {
|
||||
walletBalance: 29,
|
||||
membership: {
|
||||
status: 'normal',
|
||||
tier: 'normal',
|
||||
startedAt: null,
|
||||
expiresAt: null,
|
||||
updatedAt: null,
|
||||
},
|
||||
pointProducts: [],
|
||||
membershipProducts: [],
|
||||
benefits: [],
|
||||
latestOrder: null,
|
||||
hasPointsRecharged: true,
|
||||
},
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('../ResolvedAssetImage', () => ({
|
||||
ResolvedAssetImage: () => null,
|
||||
}));
|
||||
|
||||
function renderProfileView(onRechargeSuccess = vi.fn()) {
|
||||
return render(
|
||||
<AuthUiContext.Provider
|
||||
value={{
|
||||
user: {
|
||||
id: 'user-1',
|
||||
username: 'tester',
|
||||
displayName: '测试玩家',
|
||||
loginMethod: 'password',
|
||||
bindingStatus: 'active',
|
||||
phone: null,
|
||||
createdAt: null,
|
||||
},
|
||||
canAccessProtectedData: true,
|
||||
openLoginModal: vi.fn(),
|
||||
requireAuth: (action) => action(),
|
||||
openSettingsModal: vi.fn(),
|
||||
openAccountModal: vi.fn(),
|
||||
logout: vi.fn(async () => undefined),
|
||||
musicVolume: 0.42,
|
||||
setMusicVolume: vi.fn(),
|
||||
platformTheme: 'light',
|
||||
setPlatformTheme: vi.fn(),
|
||||
isHydratingSettings: false,
|
||||
isPersistingSettings: false,
|
||||
settingsError: null,
|
||||
}}
|
||||
>
|
||||
<RpgEntryHomeView
|
||||
activeTab="profile"
|
||||
onTabChange={vi.fn()}
|
||||
hasSavedGame={false}
|
||||
savedSnapshot={null}
|
||||
saveEntries={[]}
|
||||
saveError={null}
|
||||
featuredEntries={[]}
|
||||
latestEntries={[]}
|
||||
myEntries={[]}
|
||||
historyEntries={[]}
|
||||
profileDashboard={{
|
||||
walletBalance: 0,
|
||||
totalPlayTimeMs: 0,
|
||||
playedWorldCount: 0,
|
||||
updatedAt: null,
|
||||
}}
|
||||
isLoadingPlatform={false}
|
||||
isLoadingDashboard={false}
|
||||
isResumingSaveWorldKey={null}
|
||||
platformError={null}
|
||||
dashboardError={null}
|
||||
onContinueGame={vi.fn()}
|
||||
onResumeSave={vi.fn()}
|
||||
onOpenCreateWorld={vi.fn()}
|
||||
onOpenCreateTypePicker={vi.fn()}
|
||||
onOpenGalleryDetail={vi.fn()}
|
||||
onOpenLibraryDetail={vi.fn()}
|
||||
onSearchPublicCode={vi.fn()}
|
||||
onRechargeSuccess={onRechargeSuccess}
|
||||
/>
|
||||
</AuthUiContext.Provider>,
|
||||
);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test('opens recharge modal and submits points product', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onRechargeSuccess = vi.fn();
|
||||
|
||||
renderProfileView(onRechargeSuccess);
|
||||
await user.click(screen.getByText('会员充值'));
|
||||
|
||||
expect(await screen.findByText('账户充值')).toBeTruthy();
|
||||
expect(await screen.findByText('10积分')).toBeTruthy();
|
||||
|
||||
await user.click(screen.getByText('首充送19积分'));
|
||||
|
||||
await waitFor(() => expect(onRechargeSuccess).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
@@ -29,15 +29,20 @@ import {
|
||||
} from 'react';
|
||||
|
||||
import type {
|
||||
CustomWorldGalleryCard,
|
||||
CustomWorldLibraryEntry,
|
||||
PlatformBrowseHistoryEntry,
|
||||
ProfileDashboardCardKey,
|
||||
ProfileDashboardSummary,
|
||||
ProfileRechargeCenterResponse,
|
||||
ProfileRechargeProduct,
|
||||
ProfileSaveArchiveSummary,
|
||||
} from '../../../packages/shared/src/contracts/runtime';
|
||||
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
|
||||
import type { AuthUser } from '../../services/authService';
|
||||
import {
|
||||
createRpgProfileRechargeOrder,
|
||||
getRpgProfileRechargeCenter,
|
||||
} from '../../services/rpg-entry/rpgProfileClient';
|
||||
import type { CustomWorldProfile } from '../../types';
|
||||
import { useAuthUi } from '../auth/AuthUiContext';
|
||||
import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
||||
@@ -46,12 +51,19 @@ import {
|
||||
buildPlatformWorldTags,
|
||||
describePlatformThemeLabel,
|
||||
formatPlatformWorldTime,
|
||||
isPuzzleGalleryEntry,
|
||||
type PlatformPublicGalleryCard,
|
||||
type PlatformWorldCardLike,
|
||||
resolvePlatformWorldCoverImage,
|
||||
resolvePlatformWorldLeadPortrait,
|
||||
} from './rpgEntryWorldPresentation';
|
||||
|
||||
export type PlatformHomeTab = 'home' | 'category' | 'create' | 'saves' | 'profile';
|
||||
export type PlatformHomeTab =
|
||||
| 'home'
|
||||
| 'category'
|
||||
| 'create'
|
||||
| 'saves'
|
||||
| 'profile';
|
||||
export interface RpgEntryHomeViewProps {
|
||||
activeTab: PlatformHomeTab;
|
||||
onTabChange: (tab: PlatformHomeTab) => void;
|
||||
@@ -59,8 +71,8 @@ export interface RpgEntryHomeViewProps {
|
||||
savedSnapshot: HydratedSavedGameSnapshot | null;
|
||||
saveEntries: ProfileSaveArchiveSummary[];
|
||||
saveError: string | null;
|
||||
featuredEntries: CustomWorldGalleryCard[];
|
||||
latestEntries: CustomWorldGalleryCard[];
|
||||
featuredEntries: PlatformPublicGalleryCard[];
|
||||
latestEntries: PlatformPublicGalleryCard[];
|
||||
myEntries: CustomWorldLibraryEntry<CustomWorldProfile>[];
|
||||
historyEntries: PlatformBrowseHistoryEntry[];
|
||||
profileDashboard: ProfileDashboardSummary | null;
|
||||
@@ -73,7 +85,7 @@ export interface RpgEntryHomeViewProps {
|
||||
onResumeSave: (entry: ProfileSaveArchiveSummary) => void;
|
||||
onOpenCreateWorld: () => void;
|
||||
onOpenCreateTypePicker: () => void;
|
||||
onOpenGalleryDetail: (entry: CustomWorldGalleryCard) => void;
|
||||
onOpenGalleryDetail: (entry: PlatformPublicGalleryCard) => void;
|
||||
onOpenLibraryDetail: (
|
||||
entry: CustomWorldLibraryEntry<CustomWorldProfile>,
|
||||
) => void;
|
||||
@@ -84,6 +96,7 @@ export interface RpgEntryHomeViewProps {
|
||||
onSearchPublicCode?: (keyword: string) => void | Promise<void>;
|
||||
isSearchingPublicCode?: boolean;
|
||||
onOpenProfileDashboardCard?: (cardKey: ProfileDashboardCardKey) => void;
|
||||
onRechargeSuccess?: () => void | Promise<void>;
|
||||
createTabContent?: ReactNode;
|
||||
}
|
||||
|
||||
@@ -296,7 +309,7 @@ function WorldCard({
|
||||
))
|
||||
) : (
|
||||
<span className="platform-pill platform-pill--neutral px-2.5">
|
||||
{describePlatformThemeLabel(entry.themeMode)}
|
||||
{describePublicGalleryCardKind(entry)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -553,7 +566,7 @@ function DesktopTrendingItem({
|
||||
rank,
|
||||
onClick,
|
||||
}: {
|
||||
entry: CustomWorldGalleryCard;
|
||||
entry: PlatformPublicGalleryCard;
|
||||
rank: number;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
@@ -600,7 +613,9 @@ function DesktopTrendingItem({
|
||||
))
|
||||
) : (
|
||||
<span className="platform-pill platform-pill--neutral px-2.5">
|
||||
{describePlatformThemeLabel(entry.themeMode)}
|
||||
{isPuzzleGalleryEntry(entry)
|
||||
? '拼图'
|
||||
: describePlatformThemeLabel(entry.themeMode)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -612,16 +627,16 @@ function DesktopTrendingItem({
|
||||
}
|
||||
|
||||
function buildPublicCategoryGroups(
|
||||
featuredEntries: CustomWorldGalleryCard[],
|
||||
latestEntries: CustomWorldGalleryCard[],
|
||||
featuredEntries: PlatformPublicGalleryCard[],
|
||||
latestEntries: PlatformPublicGalleryCard[],
|
||||
) {
|
||||
const publicEntryMap = new Map<string, CustomWorldGalleryCard>();
|
||||
const publicEntryMap = new Map<string, PlatformPublicGalleryCard>();
|
||||
|
||||
[...featuredEntries, ...latestEntries].forEach((entry) => {
|
||||
publicEntryMap.set(`${entry.ownerUserId}:${entry.profileId}`, entry);
|
||||
publicEntryMap.set(buildPublicGalleryCardKey(entry), entry);
|
||||
});
|
||||
|
||||
const categoryMap = new Map<string, CustomWorldGalleryCard[]>();
|
||||
const categoryMap = new Map<string, PlatformPublicGalleryCard[]>();
|
||||
Array.from(publicEntryMap.values()).forEach((entry) => {
|
||||
const tags = buildPlatformWorldTags(entry)
|
||||
.map((tag) => tag.trim())
|
||||
@@ -646,6 +661,16 @@ function buildPublicCategoryGroups(
|
||||
});
|
||||
}
|
||||
|
||||
function buildPublicGalleryCardKey(entry: PlatformPublicGalleryCard) {
|
||||
return `${isPuzzleGalleryEntry(entry) ? 'puzzle' : 'rpg'}:${entry.ownerUserId}:${entry.profileId}`;
|
||||
}
|
||||
|
||||
function describePublicGalleryCardKind(entry: PlatformPublicGalleryCard) {
|
||||
return isPuzzleGalleryEntry(entry)
|
||||
? '拼图'
|
||||
: describePlatformThemeLabel(entry.themeMode);
|
||||
}
|
||||
|
||||
function formatSnapshotTime(value: string | null | undefined) {
|
||||
if (!value) {
|
||||
return '刚刚保存';
|
||||
@@ -823,11 +848,181 @@ function ProfileShortcutButton({
|
||||
);
|
||||
}
|
||||
|
||||
function formatRechargePrice(priceCents: number) {
|
||||
const yuan = priceCents / 100;
|
||||
return Number.isInteger(yuan) ? `¥${yuan}` : `¥${yuan.toFixed(2)}`;
|
||||
}
|
||||
|
||||
function AccountRechargeModal({
|
||||
center,
|
||||
activeTab,
|
||||
isLoading,
|
||||
isSubmitting,
|
||||
error,
|
||||
onTabChange,
|
||||
onClose,
|
||||
onSelectProduct,
|
||||
}: {
|
||||
center: ProfileRechargeCenterResponse | null;
|
||||
activeTab: 'points' | 'membership';
|
||||
isLoading: boolean;
|
||||
isSubmitting: string | null;
|
||||
error: string | null;
|
||||
onTabChange: (tab: 'points' | 'membership') => void;
|
||||
onClose: () => void;
|
||||
onSelectProduct: (product: ProfileRechargeProduct) => void;
|
||||
}) {
|
||||
const visibleProducts =
|
||||
activeTab === 'points'
|
||||
? (center?.pointProducts ?? [])
|
||||
: (center?.membershipProducts ?? []);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[80] flex items-center justify-center bg-black/42 px-3 py-5">
|
||||
<div className="relative max-h-[min(92vh,46rem)] w-full max-w-[28rem] overflow-hidden rounded-[1.35rem] bg-white text-zinc-950 shadow-2xl">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="absolute right-3 top-2 z-10 flex h-8 w-8 items-center justify-center rounded-full text-[#ff4056]"
|
||||
aria-label="关闭账户充值"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
<div className="max-h-[min(92vh,46rem)] overflow-y-auto px-5 pb-5 pt-4">
|
||||
<div className="text-center text-2xl font-black">账户充值</div>
|
||||
<div className="mt-4 grid grid-cols-2 rounded-xl bg-zinc-100 p-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onTabChange('points')}
|
||||
className={`rounded-lg px-4 py-3 text-sm font-black transition ${
|
||||
activeTab === 'points'
|
||||
? 'bg-white text-[#ff4056] shadow'
|
||||
: 'text-zinc-500'
|
||||
}`}
|
||||
>
|
||||
积分充值
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onTabChange('membership')}
|
||||
className={`rounded-lg px-4 py-3 text-sm font-black transition ${
|
||||
activeTab === 'membership'
|
||||
? 'bg-white text-[#ff4056] shadow'
|
||||
: 'text-zinc-500'
|
||||
}`}
|
||||
>
|
||||
会员卡充值
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div className="mt-4 rounded-xl border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-700">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isLoading ? (
|
||||
<div className="mt-5 grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||
{Array.from({ length: activeTab === 'points' ? 6 : 3 }).map(
|
||||
(_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="h-24 animate-pulse rounded-xl bg-zinc-100"
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
) : activeTab === 'points' ? (
|
||||
<div className="mt-5 grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||
{visibleProducts.map((product) => (
|
||||
<button
|
||||
type="button"
|
||||
key={product.productId}
|
||||
disabled={Boolean(isSubmitting)}
|
||||
onClick={() => onSelectProduct(product)}
|
||||
className="relative min-h-[8.45rem] overflow-hidden rounded-xl border border-zinc-200 bg-white text-center shadow-sm transition hover:border-[#ff4056] disabled:opacity-70"
|
||||
>
|
||||
<div
|
||||
className={`h-8 px-2 py-1.5 text-xs font-black text-white ${
|
||||
product.productId === 'points_60'
|
||||
? 'bg-zinc-500'
|
||||
: 'bg-[#ff4056]'
|
||||
}`}
|
||||
>
|
||||
{product.badgeLabel}
|
||||
</div>
|
||||
<div className="px-2 py-3">
|
||||
<div className="text-xl font-black">
|
||||
{product.pointsAmount}积分
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-zinc-500">
|
||||
金额:{formatRechargePrice(product.priceCents)}
|
||||
</div>
|
||||
<div className="my-2 h-px bg-zinc-100" />
|
||||
<div className="text-sm text-zinc-800">
|
||||
{isSubmitting === product.productId
|
||||
? '处理中'
|
||||
: product.description}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="mt-5 grid grid-cols-3 gap-3">
|
||||
{visibleProducts.map((product) => (
|
||||
<button
|
||||
type="button"
|
||||
key={product.productId}
|
||||
disabled={Boolean(isSubmitting)}
|
||||
onClick={() => onSelectProduct(product)}
|
||||
className="min-h-[6rem] rounded-xl border border-zinc-200 bg-zinc-50 px-2 py-4 text-center transition hover:border-[#ff4056] disabled:opacity-70"
|
||||
>
|
||||
<div className="text-lg font-black">{product.title}</div>
|
||||
<div className="mt-2 text-xl font-black text-[#ff4056]">
|
||||
{formatRechargePrice(product.priceCents)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-5 overflow-hidden rounded-xl border border-zinc-200">
|
||||
<div className="border-b border-zinc-200 px-4 py-3 text-sm font-black">
|
||||
用户等级特权
|
||||
</div>
|
||||
<div className="grid grid-cols-5 text-center text-sm">
|
||||
{center?.benefits.map((benefit) => (
|
||||
<div key={benefit.benefitName} className="contents">
|
||||
<div className="border-b border-zinc-100 bg-zinc-50 px-2 py-3 text-left text-zinc-600">
|
||||
{benefit.benefitName}
|
||||
</div>
|
||||
<div className="border-b border-zinc-100 px-2 py-3 text-zinc-500">
|
||||
{benefit.normalValue}
|
||||
</div>
|
||||
<div className="border-b border-zinc-100 px-2 py-3 font-bold text-emerald-700">
|
||||
{benefit.monthValue}
|
||||
</div>
|
||||
<div className="border-b border-zinc-100 px-2 py-3 font-bold text-rose-500">
|
||||
{benefit.seasonValue}
|
||||
</div>
|
||||
<div className="border-b border-zinc-100 px-2 py-3 font-bold text-amber-600">
|
||||
{benefit.yearValue}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function RpgEntryHomeView({
|
||||
activeTab,
|
||||
onTabChange,
|
||||
hasSavedGame,
|
||||
savedSnapshot,
|
||||
saveEntries,
|
||||
saveError,
|
||||
featuredEntries,
|
||||
@@ -840,9 +1035,7 @@ export function RpgEntryHomeView({
|
||||
isResumingSaveWorldKey,
|
||||
platformError,
|
||||
dashboardError,
|
||||
onContinueGame,
|
||||
onResumeSave,
|
||||
onOpenCreateWorld,
|
||||
onOpenCreateTypePicker,
|
||||
onOpenGalleryDetail,
|
||||
onOpenLibraryDetail,
|
||||
@@ -851,10 +1044,21 @@ export function RpgEntryHomeView({
|
||||
onSearchPublicCode,
|
||||
isSearchingPublicCode = false,
|
||||
onOpenProfileDashboardCard,
|
||||
onRechargeSuccess,
|
||||
createTabContent,
|
||||
}: RpgEntryHomeViewProps) {
|
||||
const authUi = useAuthUi();
|
||||
const [desktopSearchKeyword, setDesktopSearchKeyword] = useState('');
|
||||
const [isRechargeOpen, setIsRechargeOpen] = useState(false);
|
||||
const [rechargeTab, setRechargeTab] = useState<'points' | 'membership'>(
|
||||
'points',
|
||||
);
|
||||
const [rechargeCenter, setRechargeCenter] =
|
||||
useState<ProfileRechargeCenterResponse | null>(null);
|
||||
const [rechargeError, setRechargeError] = useState<string | null>(null);
|
||||
const [isLoadingRecharge, setIsLoadingRecharge] = useState(false);
|
||||
const [submittingRechargeProductId, setSubmittingRechargeProductId] =
|
||||
useState<string | null>(null);
|
||||
const [selectedCategoryTag, setSelectedCategoryTag] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
@@ -879,19 +1083,6 @@ export function RpgEntryHomeView({
|
||||
: ['home', 'create', 'category'],
|
||||
[isAuthenticated],
|
||||
);
|
||||
const snapshotWorldName =
|
||||
savedSnapshot?.gameState.customWorldProfile?.name ??
|
||||
savedSnapshot?.gameState.currentScenePreset?.name ??
|
||||
'继续冒险';
|
||||
const snapshotCharacterName =
|
||||
savedSnapshot?.gameState.playerCharacter?.title ??
|
||||
savedSnapshot?.gameState.playerCharacter?.name ??
|
||||
'旅人';
|
||||
const snapshotDigest =
|
||||
savedSnapshot?.gameState.storyEngineMemory?.continueGameDigest ??
|
||||
savedSnapshot?.currentStory?.text ??
|
||||
savedSnapshot?.gameState.customWorldProfile?.summary ??
|
||||
'上一次冒险已经保存,可以从这里继续推进故事。';
|
||||
const publicUserCode = buildPublicUserCode(authUi?.user);
|
||||
const avatarLabel = getUserAvatarLabel(authUi?.user);
|
||||
const remainingNarrativeCoins = profileDashboard?.walletBalance ?? 0;
|
||||
@@ -941,6 +1132,36 @@ export function RpgEntryHomeView({
|
||||
}
|
||||
authUi?.openLoginModal();
|
||||
};
|
||||
const openRechargePanel = () => {
|
||||
setIsRechargeOpen(true);
|
||||
setRechargeError(null);
|
||||
setIsLoadingRecharge(true);
|
||||
void getRpgProfileRechargeCenter()
|
||||
.then(setRechargeCenter)
|
||||
.catch((error: unknown) => {
|
||||
setRechargeCenter(null);
|
||||
setRechargeError(
|
||||
error instanceof Error ? error.message : '读取账户充值失败',
|
||||
);
|
||||
})
|
||||
.finally(() => setIsLoadingRecharge(false));
|
||||
};
|
||||
const submitRechargeProduct = (product: ProfileRechargeProduct) => {
|
||||
if (submittingRechargeProductId) {
|
||||
return;
|
||||
}
|
||||
setSubmittingRechargeProductId(product.productId);
|
||||
setRechargeError(null);
|
||||
void createRpgProfileRechargeOrder(product.productId)
|
||||
.then((response) => {
|
||||
setRechargeCenter(response.center);
|
||||
void onRechargeSuccess?.();
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
setRechargeError(error instanceof Error ? error.message : '充值失败');
|
||||
})
|
||||
.finally(() => setSubmittingRechargeProductId(null));
|
||||
};
|
||||
const submitDesktopSearch = () => {
|
||||
const keyword = desktopSearchKeyword.trim();
|
||||
if (!keyword || !onSearchPublicCode || isSearchingPublicCode) {
|
||||
@@ -964,44 +1185,46 @@ export function RpgEntryHomeView({
|
||||
const categoryPageClass = isDesktopLayout
|
||||
? DESKTOP_PAGE_STAGE_CLASS
|
||||
: MOBILE_PAGE_STAGE_CLASS;
|
||||
const leadPublicEntry = featuredShelf[0] ?? latestEntries[0] ?? null;
|
||||
const openLeadPublicEntry = () => {
|
||||
if (leadPublicEntry) {
|
||||
onOpenGalleryDetail(leadPublicEntry);
|
||||
return;
|
||||
}
|
||||
|
||||
onTabChange('category');
|
||||
};
|
||||
|
||||
const mobileHomeContent: ReactNode = (
|
||||
<div className={`${MOBILE_PAGE_STAGE_CLASS} platform-mobile-home-stage`}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (hasSavedGame) {
|
||||
onContinueGame();
|
||||
return;
|
||||
}
|
||||
|
||||
onOpenCreateWorld();
|
||||
}}
|
||||
onClick={openLeadPublicEntry}
|
||||
className={`${HERO_SURFACE_CLASS} relative block w-full overflow-hidden px-4 py-4 text-left`}
|
||||
>
|
||||
<div className="absolute inset-0 bg-[var(--platform-hero-overlay-strong)]" />
|
||||
<div className="relative z-10 flex min-h-[10rem] flex-col justify-between">
|
||||
<div className="flex min-w-0 flex-wrap items-start justify-between gap-2">
|
||||
<span className="platform-pill platform-pill--warm shrink-0">
|
||||
{hasSavedGame ? 'CONTINUE' : 'CREATE'}
|
||||
作品
|
||||
</span>
|
||||
<div className="platform-mobile-hero-secondary platform-pill platform-pill--neutral max-w-full px-3 text-[11px] tracking-[0.08em]">
|
||||
{hasSavedGame ? '继续冒险' : '创建世界'}
|
||||
{leadPublicEntry
|
||||
? describePublicGalleryCardKind(leadPublicEntry)
|
||||
: '作品广场'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="break-all text-[clamp(1.6rem,7.4vw,1.92rem)] font-black leading-tight text-white">
|
||||
{hasSavedGame ? snapshotWorldName : '写下一个能被游玩的世界'}
|
||||
{leadPublicEntry?.worldName ?? '浏览玩家作品'}
|
||||
</div>
|
||||
<div className="mt-2 max-w-[28rem] break-all text-sm leading-6 text-zinc-200/88">
|
||||
{hasSavedGame
|
||||
? `${snapshotCharacterName} 的进度已保存,点这里回到上一次停下来的故事节点。`
|
||||
: '从设定、角色到场景网络,先生成一版可玩的世界底稿,再继续精修和发布。'}
|
||||
{leadPublicEntry?.summaryText ||
|
||||
leadPublicEntry?.subtitle ||
|
||||
'从公开广场进入作品详情,挑一个世界开始游玩。'}
|
||||
</div>
|
||||
<div className="mt-4 flex min-w-0 items-center gap-2 text-sm font-semibold text-white/90">
|
||||
<span className="min-w-0 break-all">
|
||||
{hasSavedGame ? '继续推进故事' : '进入创作工作台'}
|
||||
</span>
|
||||
<span className="min-w-0 break-all">查看作品</span>
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -1020,18 +1243,18 @@ export function RpgEntryHomeView({
|
||||
<EmptyShelf text="正在读取精选作品..." />
|
||||
) : featuredShelf.length > 0 ? (
|
||||
<div className="flex min-w-0 gap-3 overflow-x-auto pb-1 scrollbar-hide">
|
||||
{featuredShelf.map((entry: CustomWorldGalleryCard) => (
|
||||
{featuredShelf.map((entry: PlatformPublicGalleryCard) => (
|
||||
<WorldCard
|
||||
key={`${entry.ownerUserId}:${entry.profileId}:featured`}
|
||||
key={`${buildPublicGalleryCardKey(entry)}:featured`}
|
||||
entry={entry}
|
||||
badge="推荐"
|
||||
metaLabel={describePlatformThemeLabel(entry.themeMode)}
|
||||
metaLabel={describePublicGalleryCardKind(entry)}
|
||||
onClick={() => onOpenGalleryDetail(entry)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyShelf text="还没有公开作品,先创建你的第一个世界吧。" />
|
||||
<EmptyShelf text="公开广场暂时还没有精选作品。" />
|
||||
)}
|
||||
</section>
|
||||
|
||||
@@ -1041,9 +1264,9 @@ export function RpgEntryHomeView({
|
||||
<EmptyShelf text="正在读取最新发布..." />
|
||||
) : latestEntries.length > 0 ? (
|
||||
<div className="flex min-w-0 gap-3 overflow-x-auto pb-1 scrollbar-hide">
|
||||
{latestEntries.map((entry: CustomWorldGalleryCard) => (
|
||||
{latestEntries.map((entry: PlatformPublicGalleryCard) => (
|
||||
<WorldCard
|
||||
key={`${entry.ownerUserId}:${entry.profileId}:latest`}
|
||||
key={`${buildPublicGalleryCardKey(entry)}:latest`}
|
||||
entry={entry}
|
||||
badge={formatPlatformWorldTime(entry.publishedAt)}
|
||||
metaLabel={entry.authorDisplayName}
|
||||
@@ -1086,7 +1309,7 @@ export function RpgEntryHomeView({
|
||||
<div className="mt-4 grid grid-cols-2 gap-2.5 sm:gap-3 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{activeCategoryGroup.entries.map((entry) => (
|
||||
<WorldCard
|
||||
key={`${entry.ownerUserId}:${entry.profileId}:category:${activeCategoryGroup.tag}`}
|
||||
key={`${buildPublicGalleryCardKey(entry)}:category:${activeCategoryGroup.tag}`}
|
||||
entry={entry}
|
||||
badge={activeCategoryGroup.tag}
|
||||
metaLabel={entry.authorDisplayName}
|
||||
@@ -1142,7 +1365,11 @@ export function RpgEntryHomeView({
|
||||
key={`${entry.ownerUserId}:${entry.profileId}:mine`}
|
||||
entry={entry}
|
||||
onClick={() => onOpenLibraryDetail(entry)}
|
||||
onDelete={onDeleteLibraryEntry ? () => onDeleteLibraryEntry(entry) : undefined}
|
||||
onDelete={
|
||||
onDeleteLibraryEntry
|
||||
? () => onDeleteLibraryEntry(entry)
|
||||
: undefined
|
||||
}
|
||||
isDeleting={deletingLibraryEntryId === entry.profileId}
|
||||
/>
|
||||
),
|
||||
@@ -1267,12 +1494,17 @@ export function RpgEntryHomeView({
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={openRechargePanel}
|
||||
className="platform-profile-action flex shrink-0 items-center gap-2 rounded-[1.1rem] px-3 py-2 text-left"
|
||||
>
|
||||
<Crown className="h-4 w-4" />
|
||||
<div>
|
||||
<div className="text-xs font-bold">会员充值</div>
|
||||
<div className="text-[10px] opacity-80">普通用户</div>
|
||||
<div className="text-[10px] opacity-80">
|
||||
{rechargeCenter?.membership.status === 'active'
|
||||
? '叙世会员'
|
||||
: '普通用户'}
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRight className="h-4 w-4 opacity-80" />
|
||||
</button>
|
||||
@@ -1406,14 +1638,7 @@ export function RpgEntryHomeView({
|
||||
<div className="grid gap-5 xl:grid-cols-[minmax(0,1.55fr)_22rem]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (hasSavedGame) {
|
||||
onContinueGame();
|
||||
return;
|
||||
}
|
||||
|
||||
onOpenCreateWorld();
|
||||
}}
|
||||
onClick={openLeadPublicEntry}
|
||||
className={`${HERO_SURFACE_CLASS} relative block overflow-hidden px-7 py-6 text-left`}
|
||||
>
|
||||
{desktopHeroCover ? (
|
||||
@@ -1427,27 +1652,25 @@ export function RpgEntryHomeView({
|
||||
<div className="absolute inset-0 bg-[var(--platform-hero-overlay-strong)]" />
|
||||
<div className="relative z-10 flex min-h-[24rem] flex-col justify-between">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<span className="platform-pill platform-pill--warm">
|
||||
{hasSavedGame ? 'CONTINUE STORY' : 'CREATE WORLD'}
|
||||
</span>
|
||||
<span className="platform-pill platform-pill--warm">作品</span>
|
||||
<span className="platform-pill platform-pill--neutral px-3">
|
||||
{hasSavedGame ? '最近存档' : '创作入口'}
|
||||
{leadPublicEntry
|
||||
? describePublicGalleryCardKind(leadPublicEntry)
|
||||
: '作品广场'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="max-w-[35rem]">
|
||||
<div className="text-5xl font-semibold leading-[1.08] text-white">
|
||||
{hasSavedGame
|
||||
? snapshotWorldName
|
||||
: '把你的世界观直接变成可游玩的舞台'}
|
||||
{leadPublicEntry?.worldName ?? '浏览玩家作品'}
|
||||
</div>
|
||||
<div className="mt-4 text-base leading-8 text-zinc-200/86">
|
||||
{hasSavedGame
|
||||
? `${snapshotCharacterName} 的进度已经保存,桌面端可以直接从这里回到上一次停下来的关键节点。`
|
||||
: '从设定、角色、世界结构到可玩流程,一次生成创作底稿,再继续精修并发布到平台广场。'}
|
||||
{leadPublicEntry?.summaryText ||
|
||||
leadPublicEntry?.subtitle ||
|
||||
'从公开广场进入作品详情,挑一个世界开始游玩。'}
|
||||
</div>
|
||||
<div className="mt-5 inline-flex items-center gap-2 rounded-full border border-white/18 bg-white/18 px-4 py-2 text-sm font-semibold text-white/92">
|
||||
<span>{hasSavedGame ? '继续推进故事' : '进入创作工作台'}</span>
|
||||
<span>查看作品</span>
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -1499,7 +1722,7 @@ export function RpgEntryHomeView({
|
||||
<div className="space-y-3">
|
||||
{desktopTrendingEntries.map((entry, index) => (
|
||||
<DesktopTrendingItem
|
||||
key={`${entry.ownerUserId}:${entry.profileId}:desktop-trend`}
|
||||
key={`${buildPublicGalleryCardKey(entry)}:desktop-trend`}
|
||||
entry={entry}
|
||||
rank={index + 1}
|
||||
onClick={() => onOpenGalleryDetail(entry)}
|
||||
@@ -1521,70 +1744,39 @@ export function RpgEntryHomeView({
|
||||
<div className="grid gap-4 xl:grid-cols-2">
|
||||
{desktopFeaturedGrid.map((entry) => (
|
||||
<WorldCard
|
||||
key={`${entry.ownerUserId}:${entry.profileId}:desktop-featured`}
|
||||
key={`${buildPublicGalleryCardKey(entry)}:desktop-featured`}
|
||||
entry={entry}
|
||||
badge="推荐"
|
||||
metaLabel={describePlatformThemeLabel(entry.themeMode)}
|
||||
metaLabel={describePublicGalleryCardKind(entry)}
|
||||
onClick={() => onOpenGalleryDetail(entry)}
|
||||
className="h-[16rem] w-full min-w-0"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyShelf text="还没有公开作品,先创建你的第一个世界吧。" />
|
||||
<EmptyShelf text="公开广场暂时还没有精选作品。" />
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="platform-desktop-panel px-5 py-5">
|
||||
<SectionHeader
|
||||
title={hasSavedGame ? '继续进度' : '创作入口'}
|
||||
title={
|
||||
desktopLibraryPreview.length > 0
|
||||
? '最近作品'
|
||||
: historyEntries.length > 0
|
||||
? '最近浏览'
|
||||
: '作品广场'
|
||||
}
|
||||
detail="QUICK ACCESS"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (hasSavedGame) {
|
||||
onContinueGame();
|
||||
return;
|
||||
}
|
||||
|
||||
onOpenCreateWorld();
|
||||
}}
|
||||
className="platform-surface platform-surface--soft platform-interactive-card relative block w-full overflow-hidden px-5 py-5 text-left"
|
||||
>
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_right,rgba(255,164,198,0.18),transparent_28%),radial-gradient(circle_at_bottom_left,rgba(255,202,176,0.18),transparent_32%)]" />
|
||||
<div className="relative z-10">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="platform-pill platform-pill--cool">
|
||||
{hasSavedGame ? 'SAVE POINT' : 'START HERE'}
|
||||
</span>
|
||||
<ArrowRight className="h-4 w-4 text-[var(--platform-text-soft)]" />
|
||||
</div>
|
||||
<div className="mt-4 text-2xl font-semibold text-[var(--platform-text-strong)]">
|
||||
{hasSavedGame ? snapshotWorldName : '从这里开启新的创作'}
|
||||
</div>
|
||||
<div className="mt-2 text-sm leading-7 text-[color:color-mix(in_srgb,var(--platform-text-base)_84%,transparent)]">
|
||||
{hasSavedGame
|
||||
? `当前角色:${snapshotCharacterName}`
|
||||
: '快速进入自定义世界创作,继续补齐设定、角色与核心冲突。'}
|
||||
</div>
|
||||
<div className="mt-3 line-clamp-3 text-sm leading-6 text-[var(--platform-text-soft)]">
|
||||
{hasSavedGame
|
||||
? snapshotDigest
|
||||
: '先生成一版可玩的世界底稿,再继续编辑并发布。'}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div className="mt-5">
|
||||
<div>
|
||||
<div className="text-[10px] font-semibold tracking-[0.24em] text-[var(--platform-text-soft)]">
|
||||
{desktopLibraryPreview.length > 0
|
||||
? '最近作品'
|
||||
: historyEntries.length > 0
|
||||
? '最近浏览'
|
||||
: isAuthenticated
|
||||
? '创作状态'
|
||||
: '账户状态'}
|
||||
: '公开作品'}
|
||||
</div>
|
||||
|
||||
{desktopLibraryPreview.length > 0 ? (
|
||||
@@ -1655,9 +1847,7 @@ export function RpgEntryHomeView({
|
||||
</div>
|
||||
) : (
|
||||
<div className="platform-subpanel mt-3 rounded-[1.35rem] px-4 py-4 text-sm leading-6 text-[var(--platform-text-base)]">
|
||||
{isAuthenticated
|
||||
? '创建一个草稿后,这里会出现你最近保存的作品。'
|
||||
: '登录后可同步你的创作、游玩进度与平台资料。'}
|
||||
公开广场暂时还没有可展示的作品。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -1672,7 +1862,7 @@ export function RpgEntryHomeView({
|
||||
<div className="grid gap-4 xl:grid-cols-3">
|
||||
{desktopReleaseGrid.map((entry) => (
|
||||
<WorldCard
|
||||
key={`${entry.ownerUserId}:${entry.profileId}:desktop-latest`}
|
||||
key={`${buildPublicGalleryCardKey(entry)}:desktop-latest`}
|
||||
entry={entry}
|
||||
badge={formatPlatformWorldTime(entry.publishedAt)}
|
||||
metaLabel={entry.authorDisplayName}
|
||||
@@ -1722,7 +1912,7 @@ export function RpgEntryHomeView({
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={`platform-bottom-nav grid ${visibleTabs.length === 5 ? 'grid-cols-5' : 'grid-cols-3'}`}
|
||||
className={`platform-bottom-nav grid ${visibleTabs.length === 5 ? 'grid-cols-5' : visibleTabs.length === 4 ? 'grid-cols-4' : 'grid-cols-2'}`}
|
||||
>
|
||||
{visibleTabs.map((tab) => (
|
||||
<PlatformTabButton
|
||||
@@ -1736,6 +1926,18 @@ export function RpgEntryHomeView({
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{isRechargeOpen ? (
|
||||
<AccountRechargeModal
|
||||
center={rechargeCenter}
|
||||
activeTab={rechargeTab}
|
||||
isLoading={isLoadingRecharge}
|
||||
isSubmitting={submittingRechargeProductId}
|
||||
error={rechargeError}
|
||||
onTabChange={setRechargeTab}
|
||||
onClose={() => setIsRechargeOpen(false)}
|
||||
onSelectProduct={submitRechargeProduct}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1751,7 +1953,9 @@ export function RpgEntryHomeView({
|
||||
<Search className="h-4 w-4 shrink-0" />
|
||||
<input
|
||||
value={desktopSearchKeyword}
|
||||
onChange={(event) => setDesktopSearchKeyword(event.target.value)}
|
||||
onChange={(event) =>
|
||||
setDesktopSearchKeyword(event.target.value)
|
||||
}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
@@ -1804,7 +2008,7 @@ export function RpgEntryHomeView({
|
||||
{authUi?.user?.displayName || '进入账户'}
|
||||
</span>
|
||||
<span className="block truncate text-xs text-[var(--platform-text-soft)]">
|
||||
{authUi?.user ? publicUserCode : '登录后同步创作与进度'}
|
||||
{authUi?.user ? publicUserCode : '登录后同步作品与进度'}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
@@ -1831,6 +2035,18 @@ export function RpgEntryHomeView({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{isRechargeOpen ? (
|
||||
<AccountRechargeModal
|
||||
center={rechargeCenter}
|
||||
activeTab={rechargeTab}
|
||||
isLoading={isLoadingRecharge}
|
||||
isSubmitting={submittingRechargeProductId}
|
||||
error={rechargeError}
|
||||
onTabChange={setRechargeTab}
|
||||
onClose={() => setIsRechargeOpen(false)}
|
||||
onSelectProduct={submitRechargeProduct}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -48,8 +48,9 @@ export function createFailedRpgEntryAgentOperation(params: {
|
||||
status: 'failed',
|
||||
phaseLabel: params.phaseLabel,
|
||||
phaseDetail: params.error,
|
||||
progress: 100,
|
||||
progress: 0,
|
||||
error: params.error,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -2,13 +2,35 @@ import type {
|
||||
CustomWorldGalleryCard,
|
||||
CustomWorldLibraryEntry,
|
||||
} from '../../../packages/shared/src/contracts/runtime';
|
||||
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
|
||||
import { buildCustomWorldPlayableCharacters } from '../../data/characterPresets';
|
||||
import { resolveCustomWorldCampSceneImage } from '../../data/customWorldVisuals';
|
||||
import type { CustomWorldProfile } from '../../types';
|
||||
|
||||
export type PlatformWorldCardLike =
|
||||
| CustomWorldGalleryCard
|
||||
| CustomWorldLibraryEntry<CustomWorldProfile>;
|
||||
| CustomWorldLibraryEntry<CustomWorldProfile>
|
||||
| PlatformPuzzleGalleryCard;
|
||||
|
||||
export type PlatformPuzzleGalleryCard = {
|
||||
sourceType: 'puzzle';
|
||||
workId: string;
|
||||
profileId: string;
|
||||
ownerUserId: string;
|
||||
authorDisplayName: string;
|
||||
worldName: string;
|
||||
subtitle: string;
|
||||
summaryText: string;
|
||||
coverImageSrc: string | null;
|
||||
themeTags: string[];
|
||||
visibility: 'published';
|
||||
publishedAt: string | null;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type PlatformPublicGalleryCard =
|
||||
| CustomWorldGalleryCard
|
||||
| PlatformPuzzleGalleryCard;
|
||||
|
||||
export function isLibraryWorldEntry(
|
||||
entry: PlatformWorldCardLike,
|
||||
@@ -16,6 +38,32 @@ export function isLibraryWorldEntry(
|
||||
return 'profile' in entry;
|
||||
}
|
||||
|
||||
export function isPuzzleGalleryEntry(
|
||||
entry: PlatformWorldCardLike,
|
||||
): entry is PlatformPuzzleGalleryCard {
|
||||
return 'sourceType' in entry && entry.sourceType === 'puzzle';
|
||||
}
|
||||
|
||||
export function mapPuzzleWorkToPlatformGalleryCard(
|
||||
work: PuzzleWorkSummary,
|
||||
): PlatformPuzzleGalleryCard {
|
||||
return {
|
||||
sourceType: 'puzzle',
|
||||
workId: work.workId,
|
||||
profileId: work.profileId,
|
||||
ownerUserId: work.ownerUserId,
|
||||
authorDisplayName: work.authorDisplayName,
|
||||
worldName: work.levelName,
|
||||
subtitle: '拼图关卡',
|
||||
summaryText: work.summary,
|
||||
coverImageSrc: work.coverImageSrc,
|
||||
themeTags: work.themeTags,
|
||||
visibility: 'published',
|
||||
publishedAt: work.publishedAt,
|
||||
updatedAt: work.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolvePlatformWorldCoverImage(entry: PlatformWorldCardLike) {
|
||||
if (entry.coverImageSrc) {
|
||||
return entry.coverImageSrc;
|
||||
@@ -37,6 +85,10 @@ export function resolvePlatformWorldLeadPortrait(entry: PlatformWorldCardLike) {
|
||||
}
|
||||
|
||||
export function buildPlatformWorldTags(entry: PlatformWorldCardLike) {
|
||||
if (isPuzzleGalleryEntry(entry)) {
|
||||
return entry.themeTags.length > 0 ? entry.themeTags.slice(0, 3) : ['拼图'];
|
||||
}
|
||||
|
||||
if (!isLibraryWorldEntry(entry)) {
|
||||
return [
|
||||
describePlatformThemeLabel(entry.themeMode),
|
||||
@@ -71,7 +123,7 @@ export function formatPlatformWorldTime(value: string | null) {
|
||||
}
|
||||
|
||||
export function describePlatformThemeLabel(
|
||||
themeMode: PlatformWorldCardLike['themeMode'],
|
||||
themeMode: CustomWorldGalleryCard['themeMode'],
|
||||
) {
|
||||
switch (themeMode) {
|
||||
case 'martial':
|
||||
|
||||
@@ -218,60 +218,27 @@ export function useRpgCreationResultAutosave(
|
||||
} satisfies SyncedAgentDraftResult;
|
||||
}
|
||||
|
||||
const { operation } = await executeRpgCreationAction(
|
||||
activeAgentSessionId,
|
||||
{
|
||||
action: 'sync_result_profile',
|
||||
profile: normalizedProfile as unknown as Record<string, unknown>,
|
||||
},
|
||||
// Agent 结果页不再把前端 profile 回写到 session。
|
||||
// session.draftProfile 是真相源;这里只刷新后端最新快照,避免在采集/生成早期误触 sync_result_profile。
|
||||
const latestSession = await syncAgentSessionSnapshot(activeAgentSessionId);
|
||||
const latestProfile = normalizeAgentBackedProfile(
|
||||
buildDraftResultProfile(latestSession) ?? profile,
|
||||
);
|
||||
setAgentOperation(operation);
|
||||
persistAgentUiState(activeAgentSessionId, operation.operationId);
|
||||
|
||||
for (let attempt = 0; attempt < 60; attempt += 1) {
|
||||
const latestOperation = await getRpgCreationOperation(
|
||||
activeAgentSessionId,
|
||||
operation.operationId,
|
||||
);
|
||||
setAgentOperation(latestOperation);
|
||||
|
||||
if (latestOperation.status === 'failed') {
|
||||
throw new Error(
|
||||
latestOperation.error ||
|
||||
latestOperation.phaseDetail ||
|
||||
'同步结果页世界快照失败。',
|
||||
);
|
||||
}
|
||||
|
||||
if (latestOperation.status === 'completed') {
|
||||
persistAgentUiState(activeAgentSessionId, null);
|
||||
const latestSession = await syncAgentSessionSnapshot(
|
||||
activeAgentSessionId,
|
||||
);
|
||||
const latestProfile = normalizeAgentBackedProfile(
|
||||
buildDraftResultProfile(latestSession) ?? profile,
|
||||
);
|
||||
if (latestProfile) {
|
||||
setGeneratedCustomWorldProfile(latestProfile);
|
||||
}
|
||||
latestAgentResultSyncSignatureRef.current = profileSignature;
|
||||
return {
|
||||
session: latestSession,
|
||||
profile: latestProfile,
|
||||
} satisfies SyncedAgentDraftResult;
|
||||
}
|
||||
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 200));
|
||||
if (latestProfile) {
|
||||
setGeneratedCustomWorldProfile(latestProfile);
|
||||
}
|
||||
latestAgentResultSyncSignatureRef.current =
|
||||
stringifyAgentBackedProfile(latestProfile);
|
||||
|
||||
throw new Error('同步结果页世界快照超时。');
|
||||
return {
|
||||
session: latestSession,
|
||||
profile: latestProfile,
|
||||
} satisfies SyncedAgentDraftResult;
|
||||
},
|
||||
[
|
||||
activeAgentSessionId,
|
||||
agentSession,
|
||||
buildDraftResultProfile,
|
||||
persistAgentUiState,
|
||||
setAgentOperation,
|
||||
setGeneratedCustomWorldProfile,
|
||||
syncAgentSessionSnapshot,
|
||||
],
|
||||
|
||||
278
src/components/rpg-entry/useRpgEntryAgentDraftRestore.test.tsx
Normal file
278
src/components/rpg-entry/useRpgEntryAgentDraftRestore.test.tsx
Normal file
@@ -0,0 +1,278 @@
|
||||
/** @vitest-environment jsdom */
|
||||
|
||||
import { act, render } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { CustomWorldAgentSessionSnapshot } from '../../../packages/shared/src/contracts/customWorldAgent';
|
||||
import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldWorkSummary';
|
||||
import { WorldType, type CustomWorldProfile } from '../../types';
|
||||
import {
|
||||
executeRpgCreationAction,
|
||||
upsertRpgWorldProfile,
|
||||
} from '../../services/rpg-creation';
|
||||
import { useRpgCreationResultAutosave } from './useRpgCreationResultAutosave';
|
||||
import { useRpgEntryLibraryDetail } from './useRpgEntryLibraryDetail';
|
||||
|
||||
vi.mock('../../services/rpg-creation', () => ({
|
||||
executeRpgCreationAction: vi.fn(),
|
||||
getRpgCreationOperation: vi.fn(),
|
||||
upsertRpgWorldProfile: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/rpg-entry', () => ({
|
||||
deleteRpgEntryWorldProfile: vi.fn(),
|
||||
getRpgEntryWorldGalleryDetail: vi.fn(),
|
||||
listRpgEntryWorldLibrary: vi.fn(),
|
||||
publishRpgEntryWorldProfile: vi.fn(),
|
||||
unpublishRpgEntryWorldProfile: vi.fn(),
|
||||
}));
|
||||
|
||||
function buildProfile(name: string): CustomWorldProfile {
|
||||
return {
|
||||
id: `profile-${name}`,
|
||||
settingText: name,
|
||||
name,
|
||||
subtitle: name,
|
||||
summary: name,
|
||||
tone: '测试',
|
||||
playerGoal: '测试',
|
||||
templateWorldType: WorldType.WUXIA,
|
||||
compatibilityTemplateWorldType: WorldType.WUXIA,
|
||||
majorFactions: [],
|
||||
coreConflicts: [],
|
||||
attributeSchema: {
|
||||
id: `schema-${name}`,
|
||||
worldId: `profile-${name}`,
|
||||
schemaVersion: 1,
|
||||
generatedFrom: {
|
||||
worldType: WorldType.CUSTOM,
|
||||
worldName: name,
|
||||
settingSummary: name,
|
||||
tone: '测试',
|
||||
conflictCore: '测试',
|
||||
},
|
||||
slots: [],
|
||||
},
|
||||
playableNpcs: [],
|
||||
storyNpcs: [],
|
||||
items: [],
|
||||
landmarks: [],
|
||||
generationMode: 'full',
|
||||
generationStatus: 'complete',
|
||||
};
|
||||
}
|
||||
|
||||
function buildSession(
|
||||
overrides: Partial<CustomWorldAgentSessionSnapshot> = {},
|
||||
): CustomWorldAgentSessionSnapshot {
|
||||
return {
|
||||
sessionId: 'agent-session-1',
|
||||
currentTurn: 1,
|
||||
anchorContent: {
|
||||
worldPromise: null,
|
||||
playerFantasy: null,
|
||||
themeBoundary: null,
|
||||
playerEntryPoint: null,
|
||||
coreConflict: null,
|
||||
keyRelationships: [],
|
||||
hiddenLines: null,
|
||||
iconicElements: null,
|
||||
},
|
||||
progressPercent: 20,
|
||||
lastAssistantReply: '继续补齐世界草稿。',
|
||||
stage: 'clarifying',
|
||||
focusCardId: null,
|
||||
creatorIntent: null,
|
||||
creatorIntentReadiness: {
|
||||
isReady: false,
|
||||
completedKeys: [],
|
||||
missingKeys: [],
|
||||
},
|
||||
anchorPack: null,
|
||||
lockState: null,
|
||||
draftProfile: null,
|
||||
messages: [],
|
||||
draftCards: [],
|
||||
pendingClarifications: [],
|
||||
suggestedActions: [],
|
||||
recommendedReplies: [],
|
||||
qualityFindings: [],
|
||||
assetCoverage: {
|
||||
roleAssets: [],
|
||||
sceneAssets: [],
|
||||
allRoleAssetsReady: false,
|
||||
allSceneAssetsReady: false,
|
||||
},
|
||||
resultPreview: null,
|
||||
updatedAt: '2026-04-25T00:00:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('RPG Agent 草稿恢复', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('作品摘要已有对象数量但 session 没有 draftProfile 时恢复 Agent 页面', async () => {
|
||||
const syncAgentSessionSnapshot = vi.fn(async () =>
|
||||
buildSession({
|
||||
stage: 'clarifying',
|
||||
draftProfile: null,
|
||||
}),
|
||||
);
|
||||
const setSelectionStage = vi.fn();
|
||||
const persistAgentUiState = vi.fn();
|
||||
const setGeneratedCustomWorldProfile = vi.fn();
|
||||
const setCustomWorldResultViewSource = vi.fn();
|
||||
const suppressAgentDraftResultAutoOpen = vi.fn();
|
||||
let openWork:
|
||||
| ((work: CustomWorldWorkSummary) => Promise<void>)
|
||||
| null = null;
|
||||
|
||||
function Harness() {
|
||||
openWork = useRpgEntryLibraryDetail({
|
||||
userId: 'user-1',
|
||||
selectedDetailEntry: null,
|
||||
setSelectedDetailEntry: vi.fn(),
|
||||
savedCustomWorldEntries: [],
|
||||
setSavedCustomWorldEntries: vi.fn(),
|
||||
setGeneratedCustomWorldProfile,
|
||||
setCustomWorldError: vi.fn(),
|
||||
setCustomWorldAutoSaveError: vi.fn(),
|
||||
setCustomWorldAutoSaveState: vi.fn(),
|
||||
setCustomWorldGenerationViewSource: vi.fn(),
|
||||
setCustomWorldResultViewSource,
|
||||
setSelectionStage,
|
||||
setPlatformTabToCreate: vi.fn(),
|
||||
setPlatformError: vi.fn(),
|
||||
appendBrowseHistoryEntry: vi.fn(async () => {}),
|
||||
refreshCustomWorldWorks: vi.fn(async () => []),
|
||||
refreshPublishedGallery: vi.fn(async () => []),
|
||||
persistAgentUiState,
|
||||
syncAgentSessionSnapshot,
|
||||
buildDraftResultProfile: (session) =>
|
||||
(session?.draftProfile as CustomWorldProfile | null) ?? null,
|
||||
suppressAgentDraftResultAutoOpen,
|
||||
releaseAgentDraftResultAutoOpenSuppression: vi.fn(),
|
||||
resetAutoSaveTrackingToIdle: vi.fn(),
|
||||
markAutoSavedProfile: vi.fn(),
|
||||
}).handleOpenCreationWork;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
render(<Harness />);
|
||||
|
||||
await act(async () => {
|
||||
await openWork?.({
|
||||
workId: 'draft:agent-session-1',
|
||||
sourceType: 'agent_session',
|
||||
status: 'draft',
|
||||
title: '未生成草稿作品',
|
||||
subtitle: '',
|
||||
summary: '',
|
||||
updatedAt: '2026-04-25T00:00:00.000Z',
|
||||
stage: 'clarifying',
|
||||
stageLabel: '澄清中',
|
||||
playableNpcCount: 2,
|
||||
landmarkCount: 3,
|
||||
sessionId: 'agent-session-1',
|
||||
canResume: true,
|
||||
canEnterWorld: false,
|
||||
});
|
||||
});
|
||||
|
||||
expect(syncAgentSessionSnapshot).toHaveBeenCalledWith('agent-session-1');
|
||||
expect(suppressAgentDraftResultAutoOpen).toHaveBeenCalled();
|
||||
expect(persistAgentUiState).toHaveBeenCalledWith('agent-session-1', null);
|
||||
expect(setGeneratedCustomWorldProfile).toHaveBeenLastCalledWith(null);
|
||||
expect(setCustomWorldResultViewSource).toHaveBeenLastCalledWith(null);
|
||||
expect(setSelectionStage).toHaveBeenLastCalledWith('agent-workspace');
|
||||
expect(setSelectionStage).not.toHaveBeenCalledWith('custom-world-result');
|
||||
});
|
||||
|
||||
it('Agent 结果页自动保存只刷新 session draftProfile,不触发 sync_result_profile', async () => {
|
||||
const oldProfile = buildProfile('旧前端快照');
|
||||
const latestProfile = {
|
||||
...buildProfile('服务端草稿快照'),
|
||||
summary: '自动保存应保存这份 session 最新草稿。',
|
||||
};
|
||||
const latestSession = buildSession({
|
||||
stage: 'object_refining',
|
||||
draftProfile: latestProfile as unknown as Record<string, unknown>,
|
||||
});
|
||||
const syncAgentSessionSnapshot = vi.fn(async () => latestSession);
|
||||
|
||||
vi.mocked(upsertRpgWorldProfile).mockResolvedValue({
|
||||
entry: {
|
||||
ownerUserId: 'user-1',
|
||||
profileId: latestProfile.id,
|
||||
publicWorkCode: null,
|
||||
authorPublicUserCode: null,
|
||||
profile: latestProfile,
|
||||
visibility: 'draft',
|
||||
publishedAt: null,
|
||||
updatedAt: '2026-04-25T00:00:00.000Z',
|
||||
authorDisplayName: '测试玩家',
|
||||
worldName: latestProfile.name,
|
||||
subtitle: latestProfile.subtitle,
|
||||
summaryText: latestProfile.summary,
|
||||
coverImageSrc: null,
|
||||
themeMode: 'tide',
|
||||
playableNpcCount: 0,
|
||||
landmarkCount: 0,
|
||||
},
|
||||
entries: [],
|
||||
});
|
||||
|
||||
function Harness() {
|
||||
useRpgCreationResultAutosave({
|
||||
selectionStage: 'custom-world-result',
|
||||
activeAgentSessionId: 'agent-session-1',
|
||||
agentSession: buildSession({
|
||||
stage: 'object_refining',
|
||||
draftProfile: oldProfile as unknown as Record<string, unknown>,
|
||||
resultPreview: {
|
||||
publishReady: false,
|
||||
blockers: [],
|
||||
qualityFindings: [],
|
||||
sourceLabel: '旧预览',
|
||||
} as never,
|
||||
}),
|
||||
generatedCustomWorldProfile: oldProfile,
|
||||
isAgentDraftResultView: true,
|
||||
userId: 'user-1',
|
||||
setGeneratedCustomWorldProfile: vi.fn(),
|
||||
setAgentOperation: vi.fn(),
|
||||
setSavedCustomWorldEntries: vi.fn(),
|
||||
setSelectedDetailEntry: vi.fn(),
|
||||
refreshCustomWorldWorks: vi.fn(async () => []),
|
||||
persistAgentUiState: vi.fn(),
|
||||
syncAgentSessionSnapshot,
|
||||
buildDraftResultProfile: (session) =>
|
||||
(session?.draftProfile as CustomWorldProfile | null) ?? null,
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
vi.useFakeTimers();
|
||||
render(<Harness />);
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(650);
|
||||
});
|
||||
vi.useRealTimers();
|
||||
|
||||
expect(syncAgentSessionSnapshot).toHaveBeenCalledWith('agent-session-1');
|
||||
expect(upsertRpgWorldProfile).toHaveBeenCalledWith(latestProfile, {
|
||||
sourceAgentSessionId: 'agent-session-1',
|
||||
});
|
||||
expect(
|
||||
vi.mocked(executeRpgCreationAction).mock.calls.some(
|
||||
([, payload]) => payload?.action === 'sync_result_profile',
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -81,6 +81,14 @@ type UseRpgEntryLibraryDetailParams = {
|
||||
markAutoSavedProfile: (profile: CustomWorldProfile) => void;
|
||||
};
|
||||
|
||||
const AGENT_RESULT_STAGES = new Set([
|
||||
'object_refining',
|
||||
'visual_refining',
|
||||
'long_tail_review',
|
||||
'ready_to_publish',
|
||||
'published',
|
||||
]);
|
||||
|
||||
function isMissingRpgEntryAgentSessionError(error: unknown) {
|
||||
return (
|
||||
error instanceof ApiClientError &&
|
||||
@@ -241,12 +249,13 @@ export function useRpgEntryLibraryDetail(
|
||||
setCustomWorldResultViewSource(null);
|
||||
resetAutoSaveTrackingToIdle();
|
||||
|
||||
const shouldOpenAgentWorkspace =
|
||||
work.playableNpcCount <= 0 && work.landmarkCount <= 0;
|
||||
|
||||
try {
|
||||
const latestSession = await syncAgentSessionSnapshot(work.sessionId);
|
||||
const nextProfile = buildDraftResultProfile(latestSession);
|
||||
const shouldOpenAgentWorkspace =
|
||||
!latestSession?.draftProfile ||
|
||||
!latestSession.stage ||
|
||||
!AGENT_RESULT_STAGES.has(latestSession.stage);
|
||||
|
||||
const shouldResumeFailedGenerationView =
|
||||
!nextProfile &&
|
||||
@@ -268,8 +277,8 @@ export function useRpgEntryLibraryDetail(
|
||||
return;
|
||||
}
|
||||
|
||||
if (shouldOpenAgentWorkspace && !nextProfile) {
|
||||
// 仅八锚点未整理成底稿时才恢复 Agent 对话工作区。
|
||||
if (shouldOpenAgentWorkspace) {
|
||||
// 还没有服务端草稿真相源时只能恢复 Agent,对象数量等摘要字段不能决定结果页入口。
|
||||
suppressAgentDraftResultAutoOpen();
|
||||
persistAgentUiState(work.sessionId, null);
|
||||
setGeneratedCustomWorldProfile(null);
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { lazy, Suspense } from 'react';
|
||||
import { lazy, Suspense, useEffect } from 'react';
|
||||
|
||||
import { normalizePlayerProgressionState } from '../../data/playerProgression';
|
||||
import {
|
||||
APP_RUNTIME_ROUTES,
|
||||
pushAppHistoryPath,
|
||||
} from '../../routing/appPageRoutes';
|
||||
import { UI_CHROME } from '../../uiAssets';
|
||||
import { useAuthUi } from '../auth/AuthUiContext';
|
||||
import { RpgRuntimeCanvasStage } from './RpgRuntimeCanvasStage';
|
||||
import type { RpgRuntimeShellProps as RpgRuntimeShellComponentProps } from './types';
|
||||
import { RpgRuntimeStageRouter } from './RpgRuntimeStageRouter';
|
||||
import type { RpgRuntimeShellProps as RpgRuntimeShellComponentProps } from './types';
|
||||
import { useRpgRuntimeShellViewModel } from './useRpgRuntimeShellViewModel';
|
||||
|
||||
const RpgRuntimeOverlayHost = lazy(async () => {
|
||||
@@ -121,6 +125,22 @@ export function RpgRuntimeShell({
|
||||
),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (gameState.worldType && !gameState.playerCharacter) {
|
||||
pushAppHistoryPath(APP_RUNTIME_ROUTES['rpg-character-select']);
|
||||
return;
|
||||
}
|
||||
|
||||
if (visibleGameState.playerCharacter && visibleCurrentStory) {
|
||||
pushAppHistoryPath(APP_RUNTIME_ROUTES['rpg-adventure']);
|
||||
}
|
||||
}, [
|
||||
gameState.playerCharacter,
|
||||
gameState.worldType,
|
||||
visibleCurrentStory,
|
||||
visibleGameState.playerCharacter,
|
||||
]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${isPlatformShell ? `platform-ui-shell platform-theme ${platformThemeClass} text-[var(--platform-text-strong)]` : 'fusion-pixel-app pixel-root-shell text-zinc-100'} flex h-screen max-h-screen flex-col overflow-hidden font-sans`}
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import {
|
||||
pushAppHistoryPath,
|
||||
resolvePathForSelectionStage,
|
||||
resolveSelectionStageFromPath,
|
||||
} from '../../routing/appPageRoutes';
|
||||
import type { GameState } from '../../types';
|
||||
import type { GameCanvasEntitySelection } from '../GameCanvas';
|
||||
import type { SelectionStage } from '../platform-entry';
|
||||
@@ -34,11 +39,28 @@ export function useRpgRuntimeOverlayState(params: {
|
||||
characterChatModalOpen,
|
||||
hasNpcModalOpen,
|
||||
} = params;
|
||||
const [selectionStage, setSelectionStage] = useState<SelectionStage>('platform');
|
||||
const [selectionStage, setRawSelectionStage] = useState<SelectionStage>(() =>
|
||||
resolveSelectionStageFromPath(window.location.pathname),
|
||||
);
|
||||
const [overlayPanel, setOverlayPanel] = useState<OverlayPanel>(null);
|
||||
const [selectedSceneEntity, setSelectedSceneEntity] =
|
||||
useState<GameCanvasEntitySelection | null>(null);
|
||||
const [showTeamModal, setShowTeamModal] = useState(false);
|
||||
const setSelectionStage = useCallback((stage: SelectionStage) => {
|
||||
setRawSelectionStage(stage);
|
||||
pushAppHistoryPath(resolvePathForSelectionStage(stage));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const syncStageFromHistory = () => {
|
||||
setRawSelectionStage(
|
||||
resolveSelectionStageFromPath(window.location.pathname),
|
||||
);
|
||||
};
|
||||
|
||||
window.addEventListener('popstate', syncStageFromHistory);
|
||||
return () => window.removeEventListener('popstate', syncStageFromHistory);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedSceneEntity(null);
|
||||
|
||||
Reference in New Issue
Block a user