收口创作流程统一总计划并修复等待页窄屏裁切

This commit is contained in:
2026-05-31 05:57:34 +00:00
parent 551d436919
commit c193a352df
53 changed files with 2192 additions and 161 deletions

View File

@@ -142,12 +142,17 @@ describe('CustomWorldGenerationView', () => {
screen
.getByRole('progressbar', { name: progressTitle })
.className,
).toContain('w-[400px]');
).toContain('w-[min(400px,calc(100%_-_0.75rem))]');
expect(
screen
.getByRole('progressbar', { name: progressTitle })
.className,
).toContain('h-[400px]');
).toContain('max-w-full');
expect(
screen
.getByRole('progressbar', { name: progressTitle })
.className,
).toContain('aspect-square');
expect(
screen
.getByRole('progressbar', { name: progressTitle })

View File

@@ -133,44 +133,14 @@ export function GenerationProgressHero({
const ringFillDasharray = `${ringMetrics.progressLength.toFixed(2)} ${ringMetrics.circumference.toFixed(2)}`;
return (
<div className="relative mx-auto flex w-full max-w-[60rem] flex-col items-center px-1 pb-1 pt-1 sm:pt-4">
<div className="relative mx-auto flex w-full min-w-0 max-w-[60rem] flex-col items-center px-1 pb-1 pt-1 sm:pt-4">
<div className="sr-only">
{title}
{phaseLabel ? ` ${phaseLabel}` : ''}
</div>
<div className="relative w-full max-w-[56rem] sm:max-w-[60rem]">
<div className="relative w-full min-w-0 max-w-[56rem] sm:max-w-[60rem]">
<div
className="absolute left-0 top-1/2 z-20 w-[min(6.8rem,28vw)] -translate-y-1/2 rounded-[1.1rem] border border-white/58 bg-white/58 px-2.5 py-2 text-center shadow-[0_14px_30px_rgba(112,57,30,0.10)] backdrop-blur-md sm:w-[8rem] sm:px-3 sm:py-2.5"
data-testid="generation-hero-wait-card"
>
<div className="flex items-center justify-center gap-1.5 text-[#2a1c14]">
<Hourglass className="h-3.5 w-3.5 shrink-0" strokeWidth={2.2} />
<div className="text-[9px] font-black tracking-[0.1em] text-[#7e421f] sm:text-[10px]">
</div>
</div>
<div className="mt-1.5 break-keep text-[12px] font-black leading-tight text-[#161211] sm:text-[13px]">
{estimatedWaitText}
</div>
</div>
<div
className="absolute right-0 top-1/2 z-20 w-[min(6.8rem,28vw)] -translate-y-1/2 rounded-[1.1rem] border border-white/58 bg-white/58 px-2.5 py-2 text-center shadow-[0_14px_30px_rgba(112,57,30,0.10)] backdrop-blur-md sm:w-[8rem] sm:px-3 sm:py-2.5"
data-testid="generation-hero-elapsed-card"
>
<div className="flex items-center justify-center gap-1.5 text-[#2a1c14]">
<div className="text-[9px] font-black tracking-[0.1em] text-[#7e421f] sm:text-[10px]">
</div>
<Clock3 className="h-3.5 w-3.5 shrink-0" strokeWidth={2.2} />
</div>
<div className="mt-1.5 break-keep text-[12px] font-black leading-tight text-[#161211] sm:text-[13px]">
{elapsedText}
</div>
</div>
<div
className="relative mx-auto h-[400px] w-[400px] shrink-0 overflow-visible rounded-full"
className="relative mx-auto aspect-square w-[min(400px,calc(100%_-_0.75rem))] max-w-full shrink-0 overflow-visible rounded-full"
role="progressbar"
aria-label={title}
aria-valuemin={0}
@@ -244,6 +214,38 @@ export function GenerationProgressHero({
</div>
</div>
</div>
<div className="relative z-20 mt-[-0.3rem] grid w-full grid-cols-2 gap-2 px-0.5 sm:absolute sm:inset-0 sm:mt-0 sm:block sm:px-0">
<div
className="w-full rounded-[1.1rem] border border-white/58 bg-white/58 px-2.5 py-2 text-center shadow-[0_14px_30px_rgba(112,57,30,0.10)] backdrop-blur-md sm:absolute sm:left-0 sm:top-1/2 sm:w-[8rem] sm:-translate-y-1/2 sm:px-3 sm:py-2.5"
data-testid="generation-hero-wait-card"
>
<div className="flex items-center justify-center gap-1.5 text-[#2a1c14]">
<Hourglass className="h-3.5 w-3.5 shrink-0" strokeWidth={2.2} />
<div className="text-[9px] font-black tracking-[0.1em] text-[#7e421f] sm:text-[10px]">
</div>
</div>
<div className="mt-1.5 break-keep text-[12px] font-black leading-tight text-[#161211] sm:text-[13px]">
{estimatedWaitText}
</div>
</div>
<div
className="w-full rounded-[1.1rem] border border-white/58 bg-white/58 px-2.5 py-2 text-center shadow-[0_14px_30px_rgba(112,57,30,0.10)] backdrop-blur-md sm:absolute sm:right-0 sm:top-1/2 sm:w-[8rem] sm:-translate-y-1/2 sm:px-3 sm:py-2.5"
data-testid="generation-hero-elapsed-card"
>
<div className="flex items-center justify-center gap-1.5 text-[#2a1c14]">
<div className="text-[9px] font-black tracking-[0.1em] text-[#7e421f] sm:text-[10px]">
</div>
<Clock3 className="h-3.5 w-3.5 shrink-0" strokeWidth={2.2} />
</div>
<div className="mt-1.5 break-keep text-[12px] font-black leading-tight text-[#161211] sm:text-[13px]">
{elapsedText}
</div>
</div>
</div>
</div>
</div>
);

View File

@@ -130,12 +130,17 @@ describe('BarkBattleGeneratingView', () => {
screen
.getByRole('progressbar', { name: '汪汪声浪素材生成进度' })
.className,
).toContain('w-[400px]');
).toContain('w-[min(400px,calc(100%_-_0.75rem))]');
expect(
screen
.getByRole('progressbar', { name: '汪汪声浪素材生成进度' })
.className,
).toContain('h-[400px]');
).toContain('max-w-full');
expect(
screen
.getByRole('progressbar', { name: '汪汪声浪素材生成进度' })
.className,
).toContain('aspect-square');
expect(
screen
.getByRole('progressbar', { name: '汪汪声浪素材生成进度' })

View File

@@ -0,0 +1,92 @@
/* @vitest-environment jsdom */
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { beforeEach, expect, test, vi } from 'vitest';
import type { JumpHopSessionResponse } from '../../../packages/shared/src/contracts/jumpHop';
import { jumpHopClient } from '../../services/jump-hop/jumpHopClient';
import { JumpHopWorkspace } from './JumpHopWorkspace';
vi.mock('../../services/jump-hop/jumpHopClient', () => ({
jumpHopClient: {
createSession: vi.fn(),
},
}));
const mockCreateSession = vi.mocked(jumpHopClient.createSession);
beforeEach(() => {
mockCreateSession.mockReset();
});
function createSessionResponse(): JumpHopSessionResponse {
return {
session: {
sessionId: 'jump-session-1',
ownerUserId: 'user-1',
status: 'draft',
draft: null,
createdAt: '2026-05-30T10:00:00.000Z',
updatedAt: '2026-05-30T10:00:00.000Z',
},
};
}
test('jump hop workspace submits structured payload after required fields are filled', async () => {
const user = userEvent.setup();
const onSubmitted = vi.fn();
const sessionResponse = createSessionResponse();
mockCreateSession.mockResolvedValue(sessionResponse);
render(
<JumpHopWorkspace onBack={() => {}} onSubmitted={onSubmitted} />,
);
const submitButton = screen.getByRole('button', { name: '生成' });
expect(submitButton).toHaveProperty('disabled', true);
await user.type(screen.getByLabelText('作品标题'), '云朵跳台');
await user.type(screen.getByLabelText('作品简介'), '在云端一路跳到星星。');
await user.type(screen.getByLabelText('主题标签'), '云朵 星星');
await user.selectOptions(screen.getByLabelText('难度'), 'standard');
await user.selectOptions(screen.getByLabelText('风格'), 'paper-toy');
await user.type(screen.getByLabelText('角色提示词'), '一只纸片小兔');
await user.type(screen.getByLabelText('地块提示词'), '柔软云朵平台');
await user.type(screen.getByLabelText('终点氛围'), '星光门');
expect(submitButton).toHaveProperty('disabled', false);
await user.click(submitButton);
await waitFor(() => {
expect(mockCreateSession).toHaveBeenCalledWith({
templateId: 'jump-hop',
workTitle: '云朵跳台',
workDescription: '在云端一路跳到星星。',
themeTags: ['云朵', '星星'],
difficulty: 'standard',
stylePreset: 'paper-toy',
characterPrompt: '一只纸片小兔',
tilePrompt: '柔软云朵平台',
endMoodPrompt: '星光门',
});
});
expect(onSubmitted).toHaveBeenCalledWith(
sessionResponse,
expect.objectContaining({
templateId: 'jump-hop',
workTitle: '云朵跳台',
}),
);
});
test('jump hop workspace calls back when return button is clicked', async () => {
const user = userEvent.setup();
const onBack = vi.fn();
render(<JumpHopWorkspace onBack={onBack} onSubmitted={() => {}} />);
await user.click(screen.getByRole('button', { name: '返回' }));
expect(onBack).toHaveBeenCalledTimes(1);
});

View File

@@ -0,0 +1,144 @@
/* @vitest-environment jsdom */
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { expect, test, vi } from 'vitest';
import type { JumpHopDraftResponse } from '../../../packages/shared/src/contracts/jumpHop';
import { JumpHopResultView } from './JumpHopResultView';
const draft: JumpHopDraftResponse = {
templateId: 'jump-hop',
templateName: '跳一跳',
profileId: 'profile-1',
workTitle: '云端跳台',
workDescription: '一路跳到星星。',
themeTags: ['云朵', '星空'],
difficulty: 'standard',
stylePreset: 'paper-toy',
characterPrompt: '纸片小兔',
tilePrompt: '柔软云朵平台',
endMoodPrompt: '星光门',
characterAsset: {
assetId: 'character-1',
imageSrc: 'data:image/png;base64,character',
imageObjectKey: 'jump-hop/character.png',
assetObjectId: 'asset-character',
generationProvider: 'vector-engine-gpt-image-2',
prompt: '角色图',
width: 1024,
height: 1024,
},
tileAtlasAsset: {
assetId: 'tiles-1',
imageSrc: 'data:image/png;base64,tiles',
imageObjectKey: 'jump-hop/tiles.png',
assetObjectId: 'asset-tiles',
generationProvider: 'vector-engine-gpt-image-2',
prompt: '地块图',
width: 1024,
height: 1024,
},
tileAssets: [
{
tileType: 'start',
imageSrc: 'data:image/png;base64,tile-start',
imageObjectKey: 'jump-hop/tile-start.png',
assetObjectId: 'asset-tile-start',
sourceAtlasCell: 'A1',
visualWidth: 128,
visualHeight: 96,
topSurfaceRadius: 24,
landingRadius: 28,
},
{
tileType: 'finish',
imageSrc: 'data:image/png;base64,tile-finish',
imageObjectKey: 'jump-hop/tile-finish.png',
assetObjectId: 'asset-tile-finish',
sourceAtlasCell: 'A2',
visualWidth: 128,
visualHeight: 96,
topSurfaceRadius: 24,
landingRadius: 28,
},
],
path: {
seed: 'jump-hop-seed',
difficulty: 'standard',
platforms: [
{
platformId: 'platform-1',
tileType: 'start',
x: 0,
y: 0,
width: 48,
height: 36,
landingRadius: 22,
perfectRadius: 12,
scoreValue: 1,
},
{
platformId: 'platform-2',
tileType: 'finish',
x: 16,
y: 18,
width: 60,
height: 42,
landingRadius: 22,
perfectRadius: 12,
scoreValue: 2,
},
],
finishIndex: 1,
cameraPreset: 'default',
scoring: {
chargeToDistanceRatio: 1.2,
maxChargeMs: 1800,
hitBonus: 20,
perfectBonus: 50,
},
},
coverComposite: 'data:image/png;base64,cover',
generationStatus: 'ready',
};
test('jump hop result view exposes test run and publish actions', async () => {
const user = userEvent.setup();
const onBack = vi.fn();
const onEdit = vi.fn();
const onStartTestRun = vi.fn();
const onPublish = vi.fn();
const onRegenerateCharacter = vi.fn();
const onRegenerateTiles = vi.fn();
render(
<JumpHopResultView
profile={draft}
onBack={onBack}
onEdit={onEdit}
onStartTestRun={onStartTestRun}
onPublish={onPublish}
onRegenerateCharacter={onRegenerateCharacter}
onRegenerateTiles={onRegenerateTiles}
/>,
);
expect(screen.getByText('云端跳台')).toBeTruthy();
expect(screen.getByRole('button', { name: '试玩' })).toBeTruthy();
expect(screen.getByRole('button', { name: '发布' })).toBeTruthy();
await user.click(screen.getByRole('button', { name: '试玩' }));
await user.click(screen.getByRole('button', { name: '发布' }));
await user.click(screen.getByRole('button', { name: '返回' }));
await user.click(screen.getByRole('button', { name: '返回编辑' }));
await user.click(screen.getByRole('button', { name: '角色' }));
await user.click(screen.getByRole('button', { name: '地块' }));
expect(onStartTestRun).toHaveBeenCalledTimes(1);
expect(onPublish).toHaveBeenCalledTimes(1);
expect(onBack).toHaveBeenCalledTimes(1);
expect(onEdit).toHaveBeenCalledTimes(1);
expect(onRegenerateCharacter).toHaveBeenCalledTimes(1);
expect(onRegenerateTiles).toHaveBeenCalledTimes(1);
});

View File

@@ -0,0 +1,212 @@
/* @vitest-environment jsdom */
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { expect, test, vi } from 'vitest';
import type {
JumpHopRuntimeRunSnapshotResponse,
JumpHopWorkProfileResponse,
} from '../../../packages/shared/src/contracts/jumpHop';
import { JumpHopRuntimeShell } from './JumpHopRuntimeShell';
const profile: JumpHopWorkProfileResponse = {
summary: {
runtimeKind: 'jump-hop',
workId: 'work-1',
profileId: 'profile-1',
ownerUserId: 'user-1',
sourceSessionId: 'session-1',
workTitle: '云端跳台',
workDescription: '一路跳到星星。',
themeTags: ['云朵'],
difficulty: 'standard',
stylePreset: 'paper-toy',
coverImageSrc: 'data:image/png;base64,cover',
publicationStatus: 'draft',
playCount: 0,
updatedAt: '2026-05-30T10:00:00.000Z',
publishedAt: null,
publishReady: true,
generationStatus: 'ready',
},
draft: {
templateId: 'jump-hop',
templateName: '跳一跳',
profileId: 'profile-1',
workTitle: '云端跳台',
workDescription: '一路跳到星星。',
themeTags: ['云朵'],
difficulty: 'standard',
stylePreset: 'paper-toy',
characterPrompt: '纸片小兔',
tilePrompt: '云朵平台',
endMoodPrompt: '星光门',
characterAsset: {
assetId: 'character-1',
imageSrc: 'data:image/png;base64,character',
imageObjectKey: 'jump-hop/character.png',
assetObjectId: 'asset-character',
generationProvider: 'vector-engine-gpt-image-2',
prompt: '角色图',
width: 1024,
height: 1024,
},
tileAtlasAsset: {
assetId: 'tiles-1',
imageSrc: 'data:image/png;base64,tiles',
imageObjectKey: 'jump-hop/tiles.png',
assetObjectId: 'asset-tiles',
generationProvider: 'vector-engine-gpt-image-2',
prompt: '地块图',
width: 1024,
height: 1024,
},
tileAssets: [
{
tileType: 'start',
imageSrc: 'data:image/png;base64,tile-start',
imageObjectKey: 'jump-hop/tile-start.png',
assetObjectId: 'asset-tile-start',
sourceAtlasCell: 'A1',
visualWidth: 128,
visualHeight: 96,
topSurfaceRadius: 24,
landingRadius: 28,
},
],
path: {
seed: 'jump-hop-seed',
difficulty: 'standard',
platforms: [
{
platformId: 'platform-1',
tileType: 'start',
x: 0,
y: 0,
width: 48,
height: 36,
landingRadius: 22,
perfectRadius: 12,
scoreValue: 1,
},
],
finishIndex: 0,
cameraPreset: 'default',
scoring: {
chargeToDistanceRatio: 1.2,
maxChargeMs: 1800,
hitBonus: 20,
perfectBonus: 50,
},
},
coverComposite: 'data:image/png;base64,cover',
generationStatus: 'ready',
},
path: {
seed: 'jump-hop-seed',
difficulty: 'standard',
platforms: [
{
platformId: 'platform-1',
tileType: 'start',
x: 0,
y: 0,
width: 48,
height: 36,
landingRadius: 22,
perfectRadius: 12,
scoreValue: 1,
},
],
finishIndex: 0,
cameraPreset: 'default',
scoring: {
chargeToDistanceRatio: 1.2,
maxChargeMs: 1800,
hitBonus: 20,
perfectBonus: 50,
},
},
characterAsset: {
assetId: 'character-1',
imageSrc: 'data:image/png;base64,character',
imageObjectKey: 'jump-hop/character.png',
assetObjectId: 'asset-character',
generationProvider: 'vector-engine-gpt-image-2',
prompt: '角色图',
width: 1024,
height: 1024,
},
tileAtlasAsset: {
assetId: 'tiles-1',
imageSrc: 'data:image/png;base64,tiles',
imageObjectKey: 'jump-hop/tiles.png',
assetObjectId: 'asset-tiles',
generationProvider: 'vector-engine-gpt-image-2',
prompt: '地块图',
width: 1024,
height: 1024,
},
tileAssets: [
{
tileType: 'start',
imageSrc: 'data:image/png;base64,tile-start',
imageObjectKey: 'jump-hop/tile-start.png',
assetObjectId: 'asset-tile-start',
sourceAtlasCell: 'A1',
visualWidth: 128,
visualHeight: 96,
topSurfaceRadius: 24,
landingRadius: 28,
},
],
};
const run: JumpHopRuntimeRunSnapshotResponse = {
runId: 'run-1',
profileId: 'profile-1',
ownerUserId: 'user-1',
status: 'playing',
currentPlatformIndex: 0,
score: 0,
combo: 0,
path: profile.path,
lastJump: null,
startedAtMs: 1000,
finishedAtMs: null,
};
test('jump hop runtime shell supports jump, restart and exit actions', async () => {
const user = userEvent.setup();
const onJump = vi.fn().mockResolvedValue(undefined);
const onRestart = vi.fn();
const onExit = vi.fn();
render(
<JumpHopRuntimeShell
profile={profile}
run={run}
onJump={onJump}
onRestart={onRestart}
onExit={onExit}
/>,
);
await user.pointer([
{ target: screen.getByRole('button', { name: '起跳' }), keys: '[MouseLeft>]' },
]);
await user.pointer([
{ target: screen.getByRole('button', { name: '起跳' }), keys: '[/MouseLeft]' },
]);
await waitFor(() => {
expect(onJump).toHaveBeenCalledWith({ chargeMs: expect.any(Number) });
});
await user.click(screen.getByRole('button', { name: '重开' }));
await user.click(screen.getByRole('button', { name: '返回' }));
expect(onRestart).toHaveBeenCalledTimes(1);
expect(onExit).toHaveBeenCalledTimes(1);
});

View File

@@ -112,6 +112,28 @@ test('match3d workspace submits derived entry form payload instead of agent chat
expect(onExecuteAction).not.toHaveBeenCalled();
});
test('match3d workspace can defer visible chrome to the unified creation page', () => {
const { container } = render(
<Match3DAgentWorkspace
session={null}
onBack={() => {}}
onExecuteAction={() => {}}
onCreateFromForm={() => {}}
title={null}
unifiedChrome
/>,
);
const workspace = container.querySelector('.match3d-agent-workspace');
expect(workspace?.getAttribute('data-unified-chrome')).toBe('true');
expect(workspace?.className).toContain('max-w-none');
expect(workspace?.className).not.toContain('h-full');
expect(workspace?.className).not.toContain('overflow-hidden');
expect(workspace?.className).not.toContain('platform-remap-surface');
expect(screen.queryByRole('heading', { name: '想做个什么玩法?' })).toBeNull();
expect(screen.getByLabelText('想做一个什么题材的抓大鹅?')).toBeTruthy();
});
test('match3d workspace omits legacy asset style fields from entry payload', () => {
const onCreateFromForm = vi.fn();

View File

@@ -19,6 +19,7 @@ type Match3DAgentWorkspaceProps = {
initialFormPayload?: CreateMatch3DSessionRequest | null;
showBackButton?: boolean;
title?: string | null;
unifiedChrome?: boolean;
};
type Match3DFormState = {
@@ -116,6 +117,7 @@ export function Match3DAgentWorkspace({
initialFormPayload = null,
showBackButton = true,
title = '想做个什么玩法?',
unifiedChrome = false,
}: Match3DAgentWorkspaceProps) {
const [formState, setFormState] = useState<Match3DFormState>(() =>
resolveInitialFormState(session, initialFormPayload),
@@ -183,7 +185,14 @@ export function Match3DAgentWorkspace({
};
return (
<div className="platform-remap-surface mx-auto flex h-full min-h-0 w-full max-w-5xl flex-col overflow-hidden">
<div
className={
unifiedChrome
? 'match3d-agent-workspace mx-auto flex min-h-0 w-full max-w-none flex-col overflow-visible'
: 'match3d-agent-workspace platform-remap-surface mx-auto flex h-full min-h-0 w-full max-w-5xl flex-col overflow-hidden'
}
data-unified-chrome={unifiedChrome ? 'true' : 'false'}
>
{showBackButton ? (
<div className="mb-3 flex shrink-0 items-center justify-between gap-3 sm:mb-4">
<button
@@ -197,8 +206,14 @@ export function Match3DAgentWorkspace({
</div>
) : null}
<div className="flex min-h-0 flex-1 flex-col overflow-hidden pr-0">
{title ? (
<div
className={
unifiedChrome
? 'flex flex-col pr-0'
: 'flex min-h-0 flex-1 flex-col overflow-hidden pr-0'
}
>
{title && !unifiedChrome ? (
<div className="mb-3 shrink-0 sm:mb-5">
<div className="flex flex-wrap items-center gap-2">
<h1 className="m-0 text-3xl font-black leading-none tracking-normal text-[var(--platform-text-strong)] sm:text-7xl">
@@ -211,9 +226,19 @@ export function Match3DAgentWorkspace({
</div>
) : null}
<section className="flex min-h-0 flex-1 flex-col overflow-hidden">
<section
className={
unifiedChrome
? 'flex flex-col'
: 'flex min-h-0 flex-1 flex-col overflow-hidden'
}
>
<div
className={`grid min-h-0 flex-1 grid-rows-[minmax(0,1fr)_auto] gap-3 lg:grid-cols-[minmax(0,1.1fr)_minmax(16rem,0.9fr)] lg:grid-rows-1 ${isBusy ? 'opacity-55' : ''}`}
className={`grid gap-3 lg:grid-cols-[minmax(0,1.1fr)_minmax(16rem,0.9fr)] ${
unifiedChrome
? ''
: 'min-h-0 flex-1 grid-rows-[minmax(0,1fr)_auto] lg:grid-rows-1'
} ${isBusy ? 'opacity-55' : ''}`}
>
<label className="block min-h-0">
<span className="mb-2 block text-sm font-black text-[var(--platform-text-strong)]">

View File

@@ -3138,6 +3138,38 @@ function LazyPanelFallback({ label }: { label: string }) {
);
}
function CreationResultRecoveryPanel({
title,
message,
actionLabel,
onAction,
}: {
title: string;
message: string;
actionLabel: string;
onAction: () => void;
}) {
return (
<div className="flex h-full min-h-0 items-center justify-center px-3 py-6">
<div className="platform-subpanel w-full max-w-sm rounded-[1.5rem] p-5 text-center">
<div className="text-base font-black text-[var(--platform-text-strong)]">
{title}
</div>
<div className="mt-2 text-sm leading-6 text-[var(--platform-text-base)]">
{message}
</div>
<button
type="button"
onClick={onAction}
className="platform-button platform-button--primary mt-4 min-h-11 justify-center px-4 py-3 text-sm"
>
{actionLabel}
</button>
</div>
</div>
);
}
function mergePuzzleServiceRuntimeState(
currentRun: PuzzleRunSnapshot,
serviceRun: PuzzleRunSnapshot,
@@ -12930,15 +12962,26 @@ export function PlatformEntryFlowShellImpl({
}
if (path.startsWith('/creation/jump-hop')) {
if (!sessionId) {
return;
}
let session: JumpHopSessionSnapshotResponse | null = null;
let work: JumpHopWorkProfileResponse | null = null;
try {
const { session } = await jumpHopClient.getSession(sessionId);
let work: JumpHopWorkProfileResponse | null = null;
if (profileId) {
work = (await jumpHopClient.getWorkDetail(profileId)).item;
}
} catch {
work = null;
}
try {
if (sessionId) {
session = (await jumpHopClient.getSession(sessionId)).session;
}
} catch {
session = null;
}
if (!session && !work) {
return;
}
try {
setJumpHopSession(session);
setJumpHopWork(work);
writeCreationUrlState(
@@ -12948,7 +12991,7 @@ export function PlatformEntryFlowShellImpl({
setSelectionStage(
path.includes('/generating')
? 'jump-hop-generating'
: session.draft
: session?.draft || work
? 'jump-hop-result'
: 'jump-hop-workspace',
);
@@ -15689,7 +15732,7 @@ export function PlatformEntryFlowShellImpl({
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -12 }}
className="flex h-full min-h-0 flex-col"
className="flex h-full min-h-0 flex-col overflow-y-auto overflow-x-hidden"
>
<Suspense
fallback={
@@ -15711,6 +15754,8 @@ export function PlatformEntryFlowShellImpl({
void executeMatch3DAction(payload);
}}
initialFormPayload={match3dFormDraftPayload}
title={null}
unifiedChrome
onCreateFromForm={(payload) => {
runProtectedAction(() => {
void createMatch3DDraftFromForm(payload);
@@ -16411,36 +16456,55 @@ export function PlatformEntryFlowShellImpl({
</motion.div>
)}
{selectionStage === 'jump-hop-result' && jumpHopSession?.draft && (
<motion.div
key="jump-hop-result"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -12 }}
className="flex h-full min-h-0 flex-col"
>
<Suspense
fallback={<LazyPanelFallback label="正在加载跳一跳结果..." />}
>
<JumpHopResultView
profile={jumpHopWork ?? jumpHopSession.draft}
error={jumpHopError}
onBack={leaveJumpHopFlow}
onEdit={() => {
setSelectionStage('jump-hop-workspace');
}}
onStartTestRun={startJumpHopTestRunFromProfile}
onPublish={publishJumpHopDraft}
onRegenerateCharacter={() => {
void regenerateJumpHopAsset('regenerate-character');
}}
onRegenerateTiles={() => {
void regenerateJumpHopAsset('regenerate-tiles');
}}
/>
</Suspense>
</motion.div>
)}
{selectionStage === 'jump-hop-result' &&
(() => {
const activeJumpHopResultProfile =
jumpHopWork ?? jumpHopSession?.draft ?? null;
return (
<motion.div
key="jump-hop-result"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -12 }}
className="flex h-full min-h-0 flex-col"
>
{activeJumpHopResultProfile ? (
<Suspense
fallback={
<LazyPanelFallback label="正在加载跳一跳结果..." />
}
>
<JumpHopResultView
profile={activeJumpHopResultProfile}
error={jumpHopError}
onBack={leaveJumpHopFlow}
onEdit={() => {
setSelectionStage('jump-hop-workspace');
}}
onStartTestRun={startJumpHopTestRunFromProfile}
onPublish={publishJumpHopDraft}
onRegenerateCharacter={() => {
void regenerateJumpHopAsset('regenerate-character');
}}
onRegenerateTiles={() => {
void regenerateJumpHopAsset('regenerate-tiles');
}}
/>
</Suspense>
) : (
<CreationResultRecoveryPanel
title="跳一跳草稿未恢复"
message="当前链接缺少可恢复的跳一跳草稿信息。"
actionLabel="返回创作"
onAction={() => {
setSelectionStage('jump-hop-workspace');
}}
/>
)}
</motion.div>
);
})()}
{selectionStage === 'jump-hop-runtime' && (
<motion.div
@@ -16476,7 +16540,7 @@ export function PlatformEntryFlowShellImpl({
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -12 }}
className="flex h-full min-h-0 flex-col"
className="flex h-full min-h-0 flex-col overflow-y-auto overflow-x-hidden"
>
<Suspense
fallback={<LazyPanelFallback label="正在加载敲木鱼创作..." />}
@@ -16631,7 +16695,7 @@ export function PlatformEntryFlowShellImpl({
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -12 }}
className="flex h-full min-h-0 flex-col"
className="flex h-full min-h-0 flex-col overflow-y-auto overflow-x-hidden"
>
<Suspense
fallback={<LazyPanelFallback label="正在加载拼图创作..." />}
@@ -16654,6 +16718,8 @@ export function PlatformEntryFlowShellImpl({
executePuzzleWorkspaceAction(payload);
}}
initialFormPayload={puzzleFormDraftPayload}
title={null}
unifiedChrome
onCreateFromForm={(payload) => {
void createPuzzleDraftFromForm(payload);
}}

View File

@@ -215,6 +215,29 @@ test('puzzle workspace submits the work form instead of agent chat', () => {
expect(screen.queryByText('旧会话消息不再渲染为聊天入口。')).toBeNull();
});
test('puzzle workspace can defer visible chrome to the unified creation page', () => {
const { container } = render(
<PuzzleAgentWorkspace
session={null}
onBack={() => {}}
onSubmitMessage={() => {}}
onExecuteAction={() => {}}
onCreateFromForm={() => {}}
unifiedChrome
title={null}
/>,
);
const workspace = container.querySelector('.puzzle-agent-workspace');
expect(workspace?.getAttribute('data-unified-chrome')).toBe('true');
expect(workspace?.className).toContain('max-w-none');
expect(workspace?.className).not.toContain('h-full');
expect(workspace?.className).not.toContain('overflow-hidden');
expect(workspace?.className).not.toContain('platform-remap-surface');
expect(screen.queryByRole('heading', { name: '想做个什么玩法?' })).toBeNull();
expect(screen.getByLabelText('画面描述')).toBeTruthy();
});
test('puzzle workspace keeps the reference image upload as a primary panel', () => {
const onCreateFromForm = vi.fn();
const { container } = render(

View File

@@ -49,6 +49,7 @@ type PuzzleAgentWorkspaceProps = {
initialFormPayload?: CreatePuzzleAgentSessionRequest | null;
showBackButton?: boolean;
title?: string | null;
unifiedChrome?: boolean;
};
type PuzzleFormState = {
@@ -246,6 +247,7 @@ export function PuzzleAgentWorkspace({
initialFormPayload = null,
showBackButton = true,
title = '想做个什么玩法?',
unifiedChrome = false,
}: PuzzleAgentWorkspaceProps) {
const [formState, setFormState] = useState<PuzzleFormState>(() =>
resolveInitialFormState(session, initialFormPayload),
@@ -592,7 +594,14 @@ export function PuzzleAgentWorkspace({
};
return (
<div className="platform-remap-surface puzzle-agent-workspace mx-auto flex h-full min-h-0 w-full max-w-5xl flex-col overflow-hidden">
<div
className={
unifiedChrome
? 'puzzle-agent-workspace mx-auto flex min-h-0 w-full max-w-none flex-col overflow-visible'
: 'puzzle-agent-workspace platform-remap-surface mx-auto flex h-full min-h-0 w-full max-w-5xl flex-col overflow-hidden'
}
data-unified-chrome={unifiedChrome ? 'true' : 'false'}
>
{showBackButton ? (
<div className="mb-3 flex shrink-0 items-center justify-between gap-3 sm:mb-4">
<button
@@ -609,7 +618,7 @@ export function PuzzleAgentWorkspace({
</div>
) : null}
{title ? (
{title && !unifiedChrome ? (
<div className="mb-3 shrink-0 sm:mb-5">
<div className="flex flex-wrap items-center gap-2">
<h1 className="m-0 text-3xl font-black leading-none tracking-normal text-[var(--platform-text-strong)] sm:text-7xl">
@@ -623,6 +632,7 @@ export function PuzzleAgentWorkspace({
) : null}
<CreativeImageInputPanel
className={unifiedChrome ? 'min-h-0 flex-none' : ''}
disabled={isBusy}
isSubmitting={isBusy}
uploadedImageSrc={formState.referenceImageSrc}

View File

@@ -17,6 +17,10 @@ import type {
BabyObjectMatchDraft,
CreateBabyObjectMatchDraftRequest,
} from '../../../packages/shared/src/contracts/edutainmentBabyObject';
import type {
JumpHopWorkDetailResponse,
JumpHopWorkProfileResponse,
} from '../../../packages/shared/src/contracts/jumpHop';
import type { Match3DAgentSessionSnapshot } from '../../../packages/shared/src/contracts/match3dAgent';
import type { Match3DRunSnapshot } from '../../../packages/shared/src/contracts/match3dRuntime';
import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks';
@@ -82,6 +86,7 @@ import {
regenerateBabyObjectMatchDraftAssets,
saveBabyObjectMatchDraft,
} from '../../services/edutainment-baby-object';
import { jumpHopClient } from '../../services/jump-hop/jumpHopClient';
import { match3dCreationClient } from '../../services/match3d-creation';
import {
createServerMatch3DRuntimeAdapter,
@@ -625,6 +630,22 @@ vi.mock('../../services/edutainment-baby-object', () => ({
saveBabyObjectMatchDraft: vi.fn(),
}));
vi.mock('../../services/jump-hop/jumpHopClient', () => ({
jumpHopClient: {
createSession: vi.fn(),
executeAction: vi.fn(),
getGalleryDetail: vi.fn(),
getSession: vi.fn(),
getWorkDetail: vi.fn(),
listGallery: vi.fn(),
listWorks: vi.fn(),
publishWork: vi.fn(),
restartRun: vi.fn(),
startRun: vi.fn(),
submitJump: vi.fn(),
},
}));
vi.mock('../../services/match3d-creation', () => ({
match3dCreationClient: {
createSession: vi.fn(),
@@ -1465,6 +1486,139 @@ function buildMockBabyObjectMatchDraft(
};
}
function buildMockJumpHopWork(
overrides: Partial<JumpHopWorkProfileResponse> = {},
): JumpHopWorkProfileResponse {
const profileId = overrides.summary?.profileId ?? 'jump-hop-profile-1';
const path = overrides.path ?? {
seed: 'jump-hop-seed',
difficulty: 'standard' as const,
platforms: [
{
platformId: 'platform-start',
tileType: 'start' as const,
x: 0,
y: 0,
width: 48,
height: 36,
landingRadius: 22,
perfectRadius: 12,
scoreValue: 1,
},
{
platformId: 'platform-finish',
tileType: 'finish' as const,
x: 16,
y: 18,
width: 60,
height: 42,
landingRadius: 22,
perfectRadius: 12,
scoreValue: 2,
},
],
finishIndex: 1,
cameraPreset: 'default',
scoring: {
chargeToDistanceRatio: 1.2,
maxChargeMs: 1800,
hitBonus: 20,
perfectBonus: 50,
},
};
const characterAsset = overrides.characterAsset ?? {
assetId: 'jump-hop-character-1',
imageSrc: 'data:image/png;base64,character',
imageObjectKey: 'jump-hop/character.png',
assetObjectId: 'asset-jump-hop-character',
generationProvider: 'vector-engine-gpt-image-2',
prompt: '纸片小兔',
width: 1024,
height: 1024,
};
const tileAtlasAsset = overrides.tileAtlasAsset ?? {
assetId: 'jump-hop-tiles-1',
imageSrc: 'data:image/png;base64,tiles',
imageObjectKey: 'jump-hop/tiles.png',
assetObjectId: 'asset-jump-hop-tiles',
generationProvider: 'vector-engine-gpt-image-2',
prompt: '柔软云朵平台',
width: 1024,
height: 1024,
};
const tileAssets = overrides.tileAssets ?? [
{
tileType: 'start' as const,
imageSrc: 'data:image/png;base64,tile-start',
imageObjectKey: 'jump-hop/tile-start.png',
assetObjectId: 'asset-jump-hop-tile-start',
sourceAtlasCell: 'A1',
visualWidth: 128,
visualHeight: 96,
topSurfaceRadius: 24,
landingRadius: 28,
},
{
tileType: 'finish' as const,
imageSrc: 'data:image/png;base64,tile-finish',
imageObjectKey: 'jump-hop/tile-finish.png',
assetObjectId: 'asset-jump-hop-tile-finish',
sourceAtlasCell: 'A2',
visualWidth: 128,
visualHeight: 96,
topSurfaceRadius: 24,
landingRadius: 28,
},
];
const draft = overrides.draft ?? {
templateId: 'jump-hop',
templateName: '跳一跳',
profileId,
workTitle: '云端跳台',
workDescription: '一路跳到星星。',
themeTags: ['云朵', '星空'],
difficulty: 'standard' as const,
stylePreset: 'paper-toy' as const,
characterPrompt: '纸片小兔',
tilePrompt: '柔软云朵平台',
endMoodPrompt: '星光门',
characterAsset,
tileAtlasAsset,
tileAssets,
path,
coverComposite: 'data:image/png;base64,cover',
generationStatus: 'ready' as const,
};
return {
summary: {
runtimeKind: 'jump-hop',
workId: 'jump-hop-work-1',
profileId,
ownerUserId: 'user-1',
sourceSessionId: 'jump-hop-session-1',
workTitle: draft.workTitle,
workDescription: draft.workDescription,
themeTags: draft.themeTags,
difficulty: draft.difficulty,
stylePreset: draft.stylePreset,
coverImageSrc: draft.coverComposite,
publicationStatus: 'draft',
playCount: 0,
updatedAt: '2026-05-30T10:00:00.000Z',
publishedAt: null,
publishReady: true,
generationStatus: 'ready',
...overrides.summary,
},
draft,
path,
characterAsset,
tileAtlasAsset,
tileAssets,
};
}
function buildMockBarkBattleWork(
overrides: Partial<BarkBattleWorkSummary> = {},
): BarkBattleWorkSummary {
@@ -2520,6 +2674,18 @@ beforeEach(() => {
vi.mocked(listVisualNovelWorks).mockResolvedValue({ works: [] });
vi.mocked(listLocalBabyObjectMatchDrafts).mockResolvedValue([]);
vi.mocked(deleteLocalBabyObjectMatchDraft).mockResolvedValue([]);
vi.mocked(jumpHopClient.listGallery).mockResolvedValue({
items: [],
hasMore: false,
nextCursor: null,
});
vi.mocked(jumpHopClient.listWorks).mockResolvedValue({ items: [] });
vi.mocked(jumpHopClient.getSession).mockRejectedValue(
new Error('未找到跳一跳会话'),
);
vi.mocked(jumpHopClient.getWorkDetail).mockRejectedValue(
new Error('未找到跳一跳作品'),
);
vi.mocked(saveBabyObjectMatchDraft).mockImplementation(async (payload) => ({
draft: payload.draft,
}));
@@ -7215,6 +7381,58 @@ test('refreshing RPG agent path restores stored agent workspace pointer', async
).toBeTruthy();
});
test('direct jump hop result route shows recovery panel when no draft pointer exists', async () => {
window.history.replaceState(null, '', '/creation/jump-hop/result');
render(<TestWrapper withAuth />);
expect(await screen.findByText('跳一跳草稿未恢复')).toBeTruthy();
expect(screen.getByRole('button', { name: '返回创作' })).toBeTruthy();
expect(jumpHopClient.getWorkDetail).not.toHaveBeenCalled();
expect(jumpHopClient.getSession).not.toHaveBeenCalled();
});
test('direct jump hop result route restores work detail by profile id', async () => {
const work = buildMockJumpHopWork({
summary: {
runtimeKind: 'jump-hop',
workId: 'jump-hop-work-restore-1',
profileId: 'jump-hop-profile-restore-1',
ownerUserId: 'user-1',
sourceSessionId: null,
workTitle: '恢复后的云端跳台',
workDescription: '从 profileId 回读完整跳一跳结果。',
themeTags: ['云朵'],
difficulty: 'standard',
stylePreset: 'paper-toy',
coverImageSrc: null,
publicationStatus: 'draft',
playCount: 0,
updatedAt: '2026-05-30T10:00:00.000Z',
publishedAt: null,
publishReady: true,
generationStatus: 'ready',
},
});
vi.mocked(jumpHopClient.getWorkDetail).mockResolvedValueOnce({
item: work,
} satisfies JumpHopWorkDetailResponse);
window.history.replaceState(
null,
'',
'/creation/jump-hop/result?profileId=jump-hop-profile-restore-1',
);
render(<TestWrapper withAuth />);
expect(await screen.findByText('恢复后的云端跳台')).toBeTruthy();
expect(screen.queryByText('跳一跳草稿未恢复')).toBeNull();
expect(jumpHopClient.getWorkDetail).toHaveBeenCalledWith(
'jump-hop-profile-restore-1',
);
expect(jumpHopClient.getSession).not.toHaveBeenCalled();
});
test('embedded puzzle form maps raw bearer token errors to user-facing auth copy', async () => {
const user = userEvent.setup();

View File

@@ -6038,6 +6038,11 @@ export function RpgEntryHomeView({
<div className={MOBILE_PAGE_STAGE_CLASS}>
<section>
<SectionHeader title="我的创作" detail="草稿与已发布" />
{platformError ? (
<div className="rounded-2xl border border-rose-400/20 bg-rose-500/10 px-4 py-3 text-sm leading-6 text-rose-700">
{platformError}
</div>
) : null}
{isLoadingPlatform ? (
<EmptyShelf text="正在读取你的作品..." />
) : myEntries.length > 0 ? (
@@ -6074,7 +6079,16 @@ export function RpgEntryHomeView({
const createContent: ReactNode =
createTabContent ?? fallbackCreateStartContent;
const savesContent: ReactNode = draftTabContent ?? fallbackDraftContent;
const savesContent: ReactNode = (
<>
{platformError ? (
<div className="rounded-2xl border border-rose-400/20 bg-rose-500/10 px-4 py-3 text-sm leading-6 text-rose-700">
{platformError}
</div>
) : null}
{draftTabContent ?? fallbackDraftContent}
</>
);
const profileContent: ReactNode = (
<div className={`${MOBILE_PROFILE_PAGE_STAGE_CLASS} platform-profile-page`}>

View File

@@ -870,6 +870,10 @@ export function resolvePlatformWorkAuthorDisplayName(
const displayName = authorSummary?.displayName?.trim();
const publicUserCode = authorSummary?.publicUserCode?.trim();
if (displayName && publicUserCode) {
return `${displayName} · ${publicUserCode}`;
}
return displayName || publicUserCode || entry.authorDisplayName.trim() || '玩家';
}
@@ -1079,4 +1083,4 @@ function buildBarkBattleThemeTags(work: BarkBattleWorkSummary) {
.map((tag) => tag.trim())
.filter(Boolean)
.slice(0, 3);
}
}

View File

@@ -0,0 +1,102 @@
/* @vitest-environment jsdom */
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { expect, test, vi } from 'vitest';
import type { SquareHoleWorkProfile } from '../../../packages/shared/src/contracts/squareHoleWorks';
import { SquareHoleResultView } from './SquareHoleResultView';
vi.mock('../../services/square-hole-works', () => ({
publishSquareHoleWork: vi.fn(),
regenerateSquareHoleWorkImage: vi.fn(),
squareHoleAssetClient: {
listHistoryAssets: vi.fn(),
},
updateSquareHoleWork: vi.fn(),
}));
function createProfile(): SquareHoleWorkProfile {
return {
profileId: 'profile-1',
workId: 'work-1',
ownerUserId: 'user-1',
gameName: '方洞挑战',
themeText: '几何反差',
twistRule: '形状要投进对应洞口',
summary: '把所有形状投入正确洞口。',
tags: ['方洞', '反差'],
coverImageSrc: 'data:image/png;base64,cover',
backgroundPrompt: '几何场景',
backgroundImageSrc: 'data:image/png;base64,background',
shapeOptions: [
{
optionId: 'shape-1',
shapeKind: 'square',
label: '方块',
targetHoleId: 'hole-1',
imagePrompt: '方块贴图',
imageSrc: 'data:image/png;base64,shape-1',
},
],
holeOptions: [
{
holeId: 'hole-1',
holeKind: 'hole-1',
label: '洞口 1',
imagePrompt: '洞口 1 贴图',
imageSrc: 'data:image/png;base64,hole-1',
},
],
shapeCount: 6,
difficulty: 2,
publicationStatus: 'draft',
playCount: 0,
updatedAt: '2026-05-30T10:00:00.000Z',
publishedAt: null,
publishReady: true,
};
}
test('square hole result view exposes test run and publish actions', async () => {
const user = userEvent.setup();
const onBack = vi.fn();
const onStartTestRun = vi.fn();
const onPublished = vi.fn();
const { publishSquareHoleWork, updateSquareHoleWork } = await import(
'../../services/square-hole-works'
);
const mockUpdateSquareHoleWork = vi.mocked(updateSquareHoleWork);
const mockPublishSquareHoleWork = vi.mocked(publishSquareHoleWork);
const nextProfile = createProfile();
mockUpdateSquareHoleWork.mockResolvedValue({
item: nextProfile,
} as Awaited<ReturnType<typeof updateSquareHoleWork>>);
mockPublishSquareHoleWork.mockResolvedValue({
item: nextProfile,
} as Awaited<ReturnType<typeof publishSquareHoleWork>>);
render(
<SquareHoleResultView
profile={createProfile()}
onBack={onBack}
onStartTestRun={onStartTestRun}
onPublished={onPublished}
/>,
);
expect(screen.getByRole('button', { name: '试玩' })).toBeTruthy();
expect(screen.getByRole('button', { name: '发布' })).toBeTruthy();
await user.click(screen.getByRole('button', { name: '试玩' }));
await user.click(screen.getByRole('button', { name: '发布' }));
await user.click(screen.getByRole('button', { name: '返回' }));
await waitFor(() => {
expect(onStartTestRun).toHaveBeenCalledTimes(1);
});
await waitFor(() => {
expect(onPublished).toHaveBeenCalledTimes(1);
});
expect(onBack).toHaveBeenCalledTimes(1);
});

View File

@@ -38,5 +38,17 @@ describe('UnifiedCreationPage', () => {
]);
expect(fields[2]?.getAttribute('data-field-kind')).toBe('audio');
expect(fields[3]?.getAttribute('data-required')).toBe('true');
expect(screen.getByTestId('unified-creation-play-badge').textContent).toBe(
'wooden-fish',
);
expect(screen.queryByLabelText('创作字段')).toBeNull();
expect(screen.queryByTestId('unified-creation-visible-field')).toBeNull();
expect(
screen
.getByText('敲木鱼工作台')
.closest('.unified-creation-page__content')
?.className,
).not.toContain('overflow-y-auto');
expect(root?.className).not.toContain('overflow-hidden');
});
});

View File

@@ -13,13 +13,26 @@ export function UnifiedCreationPage({
}: UnifiedCreationPageProps) {
return (
<div
className="unified-creation-page flex h-full min-h-0 flex-col"
className="unified-creation-page platform-remap-surface mx-auto flex w-full max-w-5xl flex-col px-3 pt-2 sm:px-4 sm:pt-3"
data-play-id={spec.playId}
data-field-kinds={spec.fields.map((field) => field.kind).join(',')}
data-workspace-stage={spec.workspaceStage}
data-generation-stage={spec.generationStage}
data-result-stage={spec.resultStage}
>
<header className="unified-creation-page__header shrink-0 pb-3">
<div className="flex items-center justify-between gap-3">
<h1 className="m-0 min-w-0 truncate text-[1.35rem] font-black leading-tight tracking-normal text-[var(--platform-text-strong)] sm:text-[1.65rem]">
{spec.title}
</h1>
<span
className="unified-creation-page__play-badge shrink-0 rounded-full border border-[var(--platform-subpanel-border)] bg-white/80 px-3 py-1 text-[11px] font-black text-[var(--platform-text-soft)]"
data-testid="unified-creation-play-badge"
>
{spec.playId}
</span>
</div>
</header>
<div className="sr-only" data-testid="unified-creation-spec">
<h1>{spec.title}</h1>
<ul>
@@ -36,7 +49,9 @@ export function UnifiedCreationPage({
))}
</ul>
</div>
{children}
<div className="unified-creation-page__content pb-3 sm:pb-4">
{children}
</div>
</div>
);
}

View File

@@ -2,6 +2,7 @@ import type { CustomWorldGenerationProgress } from '../../../packages/shared/src
import type { CustomWorldStructuredAnchorEntry } from '../../services/customWorldAgentGenerationProgress';
import { CustomWorldGenerationView } from '../CustomWorldGenerationView';
import type { UnifiedCreationPlayId } from './unifiedCreationSpecs';
import { getUnifiedGenerationCopy } from './unifiedGenerationCopy';
type UnifiedGenerationPageProps = {
playId: UnifiedCreationPlayId;
@@ -16,39 +17,6 @@ type UnifiedGenerationPageProps = {
hideBatchModule?: boolean;
};
const UNIFIED_GENERATION_COPY = {
puzzle: {
retryLabel: '重新生成图片',
settingTitle: '当前拼图信息',
progressTitle: '拼图图片生成进度',
activeBadgeLabel: '图片生成中',
},
match3d: {
retryLabel: '重新生成草稿',
settingTitle: '当前抓大鹅信息',
progressTitle: '抓大鹅草稿生成进度',
activeBadgeLabel: '素材生成中',
},
'wooden-fish': {
retryLabel: '重新生成草稿',
settingTitle: '当前敲木鱼信息',
progressTitle: '敲木鱼草稿生成进度',
activeBadgeLabel: '素材生成中',
},
} as const satisfies Record<
UnifiedCreationPlayId,
{
retryLabel: string;
settingTitle: string;
progressTitle: string;
activeBadgeLabel: string;
}
>;
export function getUnifiedGenerationCopy(playId: UnifiedCreationPlayId) {
return UNIFIED_GENERATION_COPY[playId];
}
export function UnifiedGenerationPage({
playId,
settingText,

View File

@@ -0,0 +1,34 @@
import type { UnifiedCreationPlayId } from './unifiedCreationSpecs';
const UNIFIED_GENERATION_COPY = {
puzzle: {
retryLabel: '重新生成图片',
settingTitle: '当前拼图信息',
progressTitle: '拼图图片生成进度',
activeBadgeLabel: '图片生成中',
},
match3d: {
retryLabel: '重新生成草稿',
settingTitle: '当前抓大鹅信息',
progressTitle: '抓大鹅草稿生成进度',
activeBadgeLabel: '素材生成中',
},
'wooden-fish': {
retryLabel: '重新生成草稿',
settingTitle: '当前敲木鱼信息',
progressTitle: '敲木鱼草稿生成进度',
activeBadgeLabel: '素材生成中',
},
} as const satisfies Record<
UnifiedCreationPlayId,
{
retryLabel: string;
settingTitle: string;
progressTitle: string;
activeBadgeLabel: string;
}
>;
export function getUnifiedGenerationCopy(playId: UnifiedCreationPlayId) {
return UNIFIED_GENERATION_COPY[playId];
}

View File

@@ -118,6 +118,11 @@ test('visual novel generation helpers build process page data', () => {
expect(buildVisualNovelEntryGenerationAnchorEntries(payload)).toEqual([
{ id: 'visual-novel-idea', label: '一句话', value: '雨夜书店' },
{ id: 'visual-novel-style', label: '视觉画风', value: '水彩绘本' },
{
id: 'visual-novel-target',
label: '生成目标',
value: '可编辑并可试玩的视觉小说草稿',
},
]);
const progress = buildVisualNovelEntryGenerationProgress(
@@ -126,7 +131,8 @@ test('visual novel generation helpers build process page data', () => {
8_000,
);
expect(progress.phaseId).toBe('generating');
expect(progress.phaseId).toBe('visual-novel-world');
expect(progress.overallProgress).toBeGreaterThan(0);
expect(progress.phaseLabel).toBe('扩展世界观');
expect(progress.steps.some((step) => step.status === 'active')).toBe(true);
});

View File

@@ -107,6 +107,27 @@ test('敲击音效临时关闭提示词生成入口,仅保留上传和录音',
expect(within(section as HTMLElement).getByText('录音')).toBeTruthy();
});
test('敲击音效和功德词条不放进独立滚动窗', () => {
const { container } = render(
<WoodenFishWorkspace
onBack={() => {}}
onSubmitted={() => {}}
/>,
);
const audioSection = screen.getByText('敲击音效').closest('section');
const floatingWordsSection = screen.getByText('功德有什么').closest('section');
const sidePanel = audioSection?.parentElement;
expect(audioSection).not.toBeNull();
expect(floatingWordsSection).not.toBeNull();
expect(sidePanel).toBe(floatingWordsSection?.parentElement);
expect(sidePanel?.className).not.toContain('overflow-y-auto');
expect(sidePanel?.className).not.toContain('min-h-0');
expect(container.querySelector('.overflow-y-auto')).toBeNull();
expect(container.firstElementChild?.className).not.toContain('h-full');
});
test('工作台只保留一个生成按钮', () => {
render(
<WoodenFishWorkspace

View File

@@ -155,7 +155,7 @@ export function WoodenFishWorkspace({
};
return (
<div className="platform-remap-surface mx-auto flex h-full min-h-0 w-full max-w-5xl flex-col px-3 pb-3 pt-3 sm:px-4 sm:pt-4">
<div className="platform-remap-surface mx-auto flex w-full max-w-5xl flex-col px-3 pb-3 pt-3 sm:px-4 sm:pt-4">
<div className="mb-3 flex items-center justify-between gap-3">
<button
type="button"
@@ -167,7 +167,7 @@ export function WoodenFishWorkspace({
</button>
</div>
<div className="grid min-h-0 flex-1 gap-3 lg:grid-cols-[minmax(0,1.12fr)_minmax(19rem,0.88fr)]">
<div className="grid gap-3 lg:grid-cols-[minmax(0,1.12fr)_minmax(19rem,0.88fr)]">
<div className="flex min-h-[26rem] min-w-0 flex-col">
<CreativeImageInputPanel
disabled={isBusy || isSubmitting}
@@ -231,7 +231,7 @@ export function WoodenFishWorkspace({
/>
</div>
<div className="flex min-h-0 flex-col gap-3 overflow-y-auto pr-0 lg:pr-1">
<div className="flex flex-col gap-3 pr-0 lg:pr-1">
<CreativeAudioInputPanel<WoodenFishAudioAsset>
disabled={isBusy || isSubmitting}
title="敲击音效"
@@ -251,7 +251,7 @@ export function WoodenFishWorkspace({
<div className="mb-3 text-sm font-black text-[var(--platform-text-strong)]">
</div>
<div className="grid gap-2">
<div className="grid gap-2 sm:grid-cols-2">
{formState.floatingWords.map((word, index) => (
<div key={index} className="relative">
<input