Merge branch 'master' into codex/ddd

This commit is contained in:
2026-05-02 20:43:35 +08:00
101 changed files with 4552 additions and 1517 deletions

View File

@@ -0,0 +1,65 @@
/* @vitest-environment jsdom */
import {
fireEvent,
render,
screen,
waitFor,
within,
} from '@testing-library/react';
import { afterEach, describe, expect, test, vi } from 'vitest';
import * as clipboardService from '../../services/clipboard';
import { PublishShareModal } from './PublishShareModal';
import {
buildPublishShareText,
type PublishShareModalPayload,
} from './publishShareModalModel';
vi.mock('../../services/clipboard', () => ({
copyTextToClipboard: vi.fn(),
}));
const payload: PublishShareModalPayload = {
title: '暖灯猫街',
publicWorkCode: 'PZ-00000001',
stage: 'puzzle-gallery-detail',
};
afterEach(() => {
vi.clearAllMocks();
});
describe('PublishShareModal', () => {
test('builds the publish share text with title, code and public url', () => {
const text = buildPublishShareText(payload);
expect(text).toContain('邀请你来玩《暖灯猫街》');
expect(text).toContain('作品号PZ-00000001');
expect(text).toContain('/gallery/puzzle/detail?work=PZ-00000001');
});
test('renders share text and channel icons, then copies from main button', async () => {
vi.mocked(clipboardService.copyTextToClipboard).mockResolvedValue(true);
render(
<PublishShareModal open payload={payload} onClose={() => {}} />,
);
const dialog = screen.getByRole('dialog', { name: '分享给朋友' });
expect(within(dialog).getByText(//u)).toBeTruthy();
expect(within(dialog).getByRole('button', { name: '分享' })).toBeTruthy();
expect(within(dialog).getByRole('button', { name: '分享到微信' })).toBeTruthy();
expect(within(dialog).getByRole('button', { name: '分享到QQ' })).toBeTruthy();
expect(within(dialog).getByRole('button', { name: '分享到抖音' })).toBeTruthy();
fireEvent.click(within(dialog).getByRole('button', { name: '分享' }));
expect(clipboardService.copyTextToClipboard).toHaveBeenCalledWith(
expect.stringContaining('作品号PZ-00000001'),
);
await waitFor(() => {
expect(within(dialog).getByRole('button', { name: '已复制' })).toBeTruthy();
});
});
});

View File

@@ -0,0 +1,145 @@
import { Check, Copy, MessageCircle, Music2 } from 'lucide-react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { copyTextToClipboard } from '../../services/clipboard';
import {
buildPublishShareText,
type PublishShareModalPayload,
} from './publishShareModalModel';
import { UnifiedModal } from './UnifiedModal';
type PublishShareModalProps = {
open: boolean;
payload: PublishShareModalPayload | null;
onClose: () => void;
};
const SHARE_CHANNELS = [
{
id: 'wechat',
label: '微信',
icon: MessageCircle,
className: 'bg-emerald-500 text-white',
},
{
id: 'qq',
label: 'QQ',
icon: MessageCircle,
className: 'bg-sky-500 text-white',
},
{
id: 'douyin',
label: '抖音',
icon: Music2,
className: 'bg-slate-950 text-white',
},
] as const;
/**
* 发布完成后的分享弹窗。
* 目前各渠道先统一复制分享文本,后续如接入微信/QQ/抖音 SDK可以只替换这里的渠道点击逻辑。
*/
export function PublishShareModal({
open,
payload,
onClose,
}: PublishShareModalProps) {
const [copyState, setCopyState] = useState<'idle' | 'copied' | 'failed'>(
'idle',
);
const resetTimerRef = useRef<number | null>(null);
const shareText = useMemo(
() => (payload ? buildPublishShareText(payload) : ''),
[payload],
);
useEffect(
() => () => {
if (resetTimerRef.current !== null) {
window.clearTimeout(resetTimerRef.current);
}
},
[],
);
useEffect(() => {
setCopyState('idle');
}, [payload?.publicWorkCode]);
const copyShareText = () => {
if (!shareText) {
return;
}
void copyTextToClipboard(shareText).then((copied) => {
setCopyState(copied ? 'copied' : 'failed');
if (resetTimerRef.current !== null) {
window.clearTimeout(resetTimerRef.current);
}
resetTimerRef.current = window.setTimeout(() => {
resetTimerRef.current = null;
setCopyState('idle');
}, 1400);
});
};
return (
<UnifiedModal
open={open && Boolean(payload)}
title="分享给朋友"
onClose={onClose}
size="sm"
panelClassName="platform-remap-surface"
bodyClassName="space-y-4 px-4 py-4 sm:px-5 sm:py-5"
footerClassName="justify-center border-t-0 px-4 pb-5 pt-0 sm:px-5"
footer={
<div className="grid w-full grid-cols-3 gap-3">
{SHARE_CHANNELS.map((channel) => {
const Icon = channel.icon;
return (
<button
key={channel.id}
type="button"
onClick={copyShareText}
className="flex min-w-0 flex-col items-center gap-2 rounded-[1rem] px-2 py-2.5 text-xs font-bold text-[var(--platform-text-base)] transition hover:bg-white/62"
aria-label={`分享到${channel.label}`}
title={channel.label}
>
<span
className={`inline-flex h-11 w-11 items-center justify-center rounded-full shadow-sm ${channel.className}`}
>
<Icon className="h-5 w-5" />
</span>
<span>{channel.label}</span>
</button>
);
})}
</div>
}
>
<div className="rounded-[1.25rem] border border-[var(--platform-subpanel-border)] bg-white/72 p-4">
<div className="whitespace-pre-wrap break-words text-sm leading-6 text-[var(--platform-text-strong)]">
{shareText}
</div>
</div>
<button
type="button"
onClick={copyShareText}
disabled={!shareText}
className="platform-button platform-button--primary w-full justify-center gap-2 disabled:cursor-not-allowed disabled:opacity-55"
>
{copyState === 'copied' ? (
<Check className="h-4 w-4" />
) : (
<Copy className="h-4 w-4" />
)}
{copyState === 'copied'
? '已复制'
: copyState === 'failed'
? '复制失败'
: '分享'}
</button>
</UnifiedModal>
);
}

View File

@@ -0,0 +1,30 @@
import { buildPublicWorkStagePath } from '../../routing/appPageRoutes';
import type { SelectionStage } from '../platform-entry/platformEntryTypes';
export type PublishShareModalPayload = {
title: string;
publicWorkCode: string;
stage: SelectionStage;
};
function buildShareUrl(payload: PublishShareModalPayload) {
const sharePath = buildPublicWorkStagePath(
payload.stage,
payload.publicWorkCode,
);
return typeof window === 'undefined'
? sharePath
: new URL(sharePath, window.location.origin).href;
}
export function buildPublishShareText(payload: PublishShareModalPayload) {
const publicWorkCode = payload.publicWorkCode.trim();
const title = payload.title.trim() || '我的作品';
return `邀请你来玩《${title}\n作品号${publicWorkCode}\n${buildShareUrl({
...payload,
publicWorkCode,
title,
})}`;
}

View File

@@ -108,11 +108,10 @@ test('creation hub reflects updated draft title summary and counts after rerende
const puzzleButton = screen.getByRole('button', { name: /.*/u });
const match3dButton = screen.getByRole('button', { name: //u });
expect(
puzzleButton.compareDocumentPosition(rpgButton) &
rpgButton.compareDocumentPosition(puzzleButton) &
Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
expect((rpgButton as HTMLButtonElement).disabled).toBe(true);
expect(within(rpgButton).getAllByText('敬请期待').length).toBeGreaterThan(0);
expect((rpgButton as HTMLButtonElement).disabled).toBe(false);
expect((match3dButton as HTMLButtonElement).disabled).toBe(false);
expect(
within(match3dButton).getAllByText('经典消除玩法').length,
@@ -320,6 +319,55 @@ test('creation hub shows delete action for persisted rpg drafts', () => {
expect(screen.getByRole('button', { name: '删除' })).toBeTruthy();
});
test('creation hub published work delete action is available beside share without opening card', async () => {
const user = userEvent.setup();
const onDeletePuzzle = vi.fn();
const onOpenPuzzleDetail = vi.fn();
render(
<CustomWorldCreationHub
items={[]}
puzzleItems={[
{
workId: 'puzzle:work-delete',
profileId: 'puzzle-profile-delete',
ownerUserId: 'user-1',
authorDisplayName: '拼图作者',
levelName: '待删拼图',
summary: '已发布作品也可以从创作页删除。',
themeTags: ['灯塔'],
coverImageSrc: null,
publicationStatus: 'published',
updatedAt: new Date('2026-05-02T12:00:00.000Z').toISOString(),
publishedAt: new Date('2026-05-02T12:10:00.000Z').toISOString(),
playCount: 8,
remixCount: 2,
likeCount: 1,
publishReady: true,
},
]}
loading={false}
error={null}
onRetry={() => {}}
onCreateType={noopCreateType}
onOpenDraft={() => {}}
onEnterPublished={() => {}}
onOpenPuzzleDetail={onOpenPuzzleDetail}
onDeletePuzzle={onDeletePuzzle}
/>,
);
expect(screen.getByRole('button', { name: '删除' })).toBeTruthy();
expect(screen.getByRole('button', { name: '分享' })).toBeTruthy();
await user.click(screen.getByRole('button', { name: '删除' }));
expect(onDeletePuzzle).toHaveBeenCalledWith(
expect.objectContaining({ profileId: 'puzzle-profile-delete' }),
);
expect(onOpenPuzzleDetail).not.toHaveBeenCalled();
});
test('creation hub opens persisted rpg drafts by card click', async () => {
const user = userEvent.setup();
const openedItems: CustomWorldWorkSummary[] = [];

View File

@@ -43,7 +43,6 @@ test('creation hub draft card renders compiled work summary fields', () => {
expect(html).toContain('玩家是失职返乡的守灯人');
expect(html).toContain('守灯会与沉船商盟争夺航道解释权');
expect(html).toContain('角色扮演');
expect(html).toContain('敬请期待');
expect(html).toContain('拼图');
expect(html).toContain('创意礼物,生活分享');
expect(html).not.toContain('大鱼吃小鱼');

View File

@@ -1,5 +1,6 @@
import { ArrowRight } from 'lucide-react';
import { NEW_WORK_ENTRY_CONFIG } from '../../config/newWorkEntryConfig';
import {
getVisiblePlatformCreationTypes,
type PlatformCreationTypeId,
@@ -27,13 +28,15 @@ export function CustomWorldCreationStartCard({
<div className="relative z-10 space-y-2.5 sm:space-y-4 xl:space-y-3">
<div className="flex items-center justify-between gap-3 xl:items-end">
<div className="text-xl font-black leading-none text-white sm:text-3xl xl:text-2xl">
{NEW_WORK_ENTRY_CONFIG.startCard.title}
</div>
<div className="hidden text-sm leading-6 text-zinc-200/88 sm:block xl:text-xs xl:leading-5">
{NEW_WORK_ENTRY_CONFIG.startCard.description}
</div>
<span className="platform-pill platform-pill--neutral shrink-0 border-white/25 bg-white/14 px-2.5 text-xs text-white sm:hidden">
{busy ? '正在开启' : '选择模板'}
{busy
? NEW_WORK_ENTRY_CONFIG.startCard.busyBadge
: NEW_WORK_ENTRY_CONFIG.startCard.idleBadge}
</span>
</div>

View File

@@ -268,68 +268,70 @@ export function CustomWorldWorkCard({
<div className="absolute inset-0 bg-[var(--platform-card-overlay-strong)]" />
<div className="absolute inset-0 bg-[radial-gradient(circle_at_100%_100%,rgba(255,255,255,0.18),transparent_34%),linear-gradient(180deg,rgba(255,255,255,0.08),rgba(0,0,0,0.08))]" />
<div className="pointer-events-none relative z-20 flex min-h-[8rem] flex-col sm:min-h-[10.5rem] xl:min-h-[9.75rem]">
{!isPublished && onDelete ? (
<button
type="button"
onClick={(event) => {
event.stopPropagation();
onDelete();
}}
onKeyDown={(event) => {
event.stopPropagation();
}}
disabled={deleteBusy}
aria-label={deleteBusy ? '删除中' : '删除'}
title={deleteBusy ? '删除中' : '删除作品'}
className="pointer-events-auto absolute right-0 top-0 z-30 grid h-7 w-7 place-items-center text-[var(--platform-text-soft)] transition hover:text-[var(--platform-button-danger-text)] disabled:cursor-not-allowed disabled:opacity-55 sm:h-8 sm:w-8"
>
{deleteBusy ? (
<span className="text-xs leading-none"></span>
) : (
<Trash2 aria-hidden="true" className="h-3.5 w-3.5" />
)}
</button>
) : null}
{isPublished ? (
<button
type="button"
onClick={(event) => {
event.stopPropagation();
copyShareText();
}}
onKeyDown={(event) => {
event.stopPropagation();
}}
disabled={!item.canShare || !item.sharePath}
title={
!item.canShare || !item.sharePath
? '暂不可分享'
: shareState === 'copied'
? '已复制'
: shareState === 'failed'
? '复制失败'
: '分享作品'
}
aria-label={
!item.canShare || !item.sharePath
? '暂不可分享'
: shareState === 'copied'
? '分享内容已复制'
: shareState === 'failed'
? '分享内容复制失败'
: '分享'
}
className="pointer-events-auto absolute right-0 top-0 z-30 inline-flex h-7 min-w-7 items-center justify-center gap-1 whitespace-nowrap px-1.5 text-[var(--platform-text-soft)] transition hover:text-[var(--platform-cool-text)] disabled:cursor-not-allowed disabled:opacity-55 sm:h-8 sm:min-w-8"
>
{shareState === 'idle' ? (
<Share2 aria-hidden="true" className="h-3.5 w-3.5" />
) : (
<span className="text-[10px] font-semibold leading-none">
{shareState === 'copied' ? '已复制' : '复制失败'}
</span>
)}
</button>
) : null}
<div className="pointer-events-auto absolute right-0 top-0 z-30 flex items-center gap-1">
{onDelete ? (
<button
type="button"
onClick={(event) => {
event.stopPropagation();
onDelete();
}}
onKeyDown={(event) => {
event.stopPropagation();
}}
disabled={deleteBusy}
aria-label={deleteBusy ? '删除中' : '删除'}
title={deleteBusy ? '删除中' : '删除作品'}
className="grid h-7 w-7 place-items-center text-[var(--platform-text-soft)] transition hover:text-[var(--platform-button-danger-text)] disabled:cursor-not-allowed disabled:opacity-55 sm:h-8 sm:w-8"
>
{deleteBusy ? (
<span className="text-xs leading-none"></span>
) : (
<Trash2 aria-hidden="true" className="h-3.5 w-3.5" />
)}
</button>
) : null}
{isPublished ? (
<button
type="button"
onClick={(event) => {
event.stopPropagation();
copyShareText();
}}
onKeyDown={(event) => {
event.stopPropagation();
}}
disabled={!item.canShare || !item.sharePath}
title={
!item.canShare || !item.sharePath
? '暂不可分享'
: shareState === 'copied'
? '已复制'
: shareState === 'failed'
? '复制失败'
: '分享作品'
}
aria-label={
!item.canShare || !item.sharePath
? '暂不可分享'
: shareState === 'copied'
? '分享内容已复制'
: shareState === 'failed'
? '分享内容复制失败'
: '分享'
}
className="inline-flex h-7 min-w-7 items-center justify-center gap-1 whitespace-nowrap px-1.5 text-[var(--platform-text-soft)] transition hover:text-[var(--platform-cool-text)] disabled:cursor-not-allowed disabled:opacity-55 sm:h-8 sm:min-w-8"
>
{shareState === 'idle' ? (
<Share2 aria-hidden="true" className="h-3.5 w-3.5" />
) : (
<span className="text-[10px] font-semibold leading-none">
{shareState === 'copied' ? '已复制' : '复制失败'}
</span>
)}
</button>
) : null}
</div>
<div className="flex items-start justify-between gap-2 pr-12 sm:gap-3 sm:pr-14">
<div className="flex max-h-[3rem] min-w-0 flex-wrap gap-1 overflow-hidden sm:max-h-none sm:gap-2">

View File

@@ -1,5 +1,6 @@
import { ArrowRight } from 'lucide-react';
import { NEW_WORK_ENTRY_CONFIG } from '../../config/newWorkEntryConfig';
import { UnifiedModal } from '../common/UnifiedModal';
import { getVisiblePlatformCreationTypes } from './platformEntryCreationTypes';
@@ -86,8 +87,8 @@ export function PlatformEntryCreationTypeModal({
return (
<UnifiedModal
open={isOpen}
title="选择创作类型"
description="先选玩法类型,再进入对应创作工作台。"
title={NEW_WORK_ENTRY_CONFIG.typeModal.title}
description={NEW_WORK_ENTRY_CONFIG.typeModal.description}
onClose={onClose}
closeDisabled={isBusy}
size="lg"

File diff suppressed because it is too large Load Diff

View File

@@ -122,6 +122,24 @@ test('PlatformWorkDetailView calls like handler', () => {
expect(onLike).toHaveBeenCalledTimes(1);
});
test('PlatformWorkDetailView switches remix action label for owned work edit', () => {
render(
<PlatformWorkDetailView
entry={createPuzzleEntry()}
actionMode="edit"
isBusy={false}
error={null}
onBack={vi.fn()}
onLike={vi.fn()}
onStart={vi.fn()}
onRemix={vi.fn()}
/>,
);
expect(screen.getByRole('button', { name: '作品编辑' })).toBeTruthy();
expect(screen.queryByRole('button', { name: '作品改造' })).toBeNull();
});
test('PlatformWorkDetailView cycles puzzle level cover slides', () => {
vi.useFakeTimers();
const { container } = render(

View File

@@ -8,6 +8,7 @@ import {
Gamepad2,
GitFork,
Heart,
PencilLine,
Play,
Share2,
} from 'lucide-react';
@@ -38,6 +39,7 @@ export interface PlatformWorkDetailViewProps {
onLike: () => void;
onStart: () => void;
onRemix: () => void;
actionMode?: 'remix' | 'edit';
}
function formatCompactCount(value: number) {
@@ -78,6 +80,7 @@ export function PlatformWorkDetailView({
onLike,
onStart,
onRemix,
actionMode = 'remix',
}: PlatformWorkDetailViewProps) {
const coverSlides = useMemo(
() => resolvePlatformWorldCoverSlides(entry),
@@ -111,6 +114,8 @@ export function PlatformWorkDetailView({
[entry],
);
const stats = resolvePlatformWorldStats(entry);
const workActionLabel = actionMode === 'edit' ? '作品编辑' : '作品改造';
const WorkActionIcon = actionMode === 'edit' ? PencilLine : GitFork;
const statItems = [
{
label: '游玩',
@@ -425,8 +430,8 @@ export function PlatformWorkDetailView({
onClick={onRemix}
disabled={isBusy}
>
<GitFork className="h-5 w-5" />
<WorkActionIcon className="h-5 w-5" />
{workActionLabel}
</button>
<button
type="button"

View File

@@ -0,0 +1,41 @@
import { expect, test } from 'vitest';
import { NEW_WORK_ENTRY_CONFIG } from '../../config/newWorkEntryConfig';
import {
getVisiblePlatformCreationTypes,
isPlatformCreationTypeVisible,
PLATFORM_CREATION_TYPES,
} from './platformEntryCreationTypes';
test('platform creation types are derived from new work entry config', () => {
const puzzleConfig = NEW_WORK_ENTRY_CONFIG.creationTypes.find(
(item) => item.id === 'puzzle',
);
expect(puzzleConfig).toBeTruthy();
expect(PLATFORM_CREATION_TYPES).toContainEqual(
expect.objectContaining({
id: 'puzzle',
title: puzzleConfig?.title,
subtitle: puzzleConfig?.subtitle,
badge: puzzleConfig?.badge,
locked: false,
hidden: false,
}),
);
});
test('new work entry config controls visibility and open order', () => {
const visibleIds = getVisiblePlatformCreationTypes().map((item) => item.id);
expect(isPlatformCreationTypeVisible('big-fish')).toBe(false);
expect(visibleIds).not.toContain('big-fish');
expect(visibleIds[0]).toBe('rpg');
expect(visibleIds).toEqual([
'rpg',
'puzzle',
'match3d',
'airp',
'visual-novel',
]);
});

View File

@@ -1,10 +1,9 @@
export type PlatformCreationTypeId =
| 'rpg'
| 'big-fish'
| 'match3d'
| 'puzzle'
| 'airp'
| 'visual-novel';
import {
NEW_WORK_ENTRY_CONFIG,
type NewWorkEntryCreationTypeId,
} from '../../config/newWorkEntryConfig';
export type PlatformCreationTypeId = NewWorkEntryCreationTypeId;
export type PlatformCreationTypeCard = {
id: PlatformCreationTypeId;
@@ -39,51 +38,15 @@ export function isPlatformCreationTypeVisible(id: PlatformCreationTypeId) {
}
/**
* 创作页与类型弹层共用同一份模板元数据,避免多入口文案和可用状态漂移。
* 创作页与类型弹层共用同一份新建作品入口配置,避免多入口文案和开放状态漂移。
* `hidden` 只控制平台入口是否展示,不影响既有玩法链路和路由能力。
*/
export const PLATFORM_CREATION_TYPES: PlatformCreationTypeCard[] = [
{
id: 'rpg',
title: '角色扮演',
subtitle: '敬请期待',
badge: '敬请期待',
locked: true,
},
{
id: 'big-fish',
title: '大鱼吃小鱼',
subtitle: '实时成长玩法',
badge: '可创建',
locked: false,
hidden: true,
},
{
id: 'puzzle',
title: '拼图',
subtitle: '创意礼物,生活分享',
badge: '可创建',
locked: false,
},
{
id: 'match3d',
title: '抓大鹅',
subtitle: '经典消除玩法',
badge: '可创建',
locked: false,
},
{
id: 'airp',
title: 'AIRP',
subtitle: '敬请期待',
badge: '敬请期待',
locked: true,
},
{
id: 'visual-novel',
title: '视觉小说',
subtitle: '敬请期待',
badge: '敬请期待',
locked: true,
},
];
export const PLATFORM_CREATION_TYPES: PlatformCreationTypeCard[] =
NEW_WORK_ENTRY_CONFIG.creationTypes.map((item) => ({
id: item.id,
title: item.title,
subtitle: item.subtitle,
badge: item.badge,
locked: !item.open,
hidden: !item.visible,
}));

View File

@@ -100,7 +100,9 @@ test('puzzle workspace submits the work form instead of agent chat', () => {
workDescription: '一套雨夜猫街主题拼图。',
pictureDescription: '一只猫在雨夜灯牌下回头。',
referenceImageSrc: null,
imageModel: 'gpt-image-2',
});
expect(screen.getByText('消耗2光点')).toBeTruthy();
expect(screen.queryByRole('button', { name: '补充剩余设定' })).toBeNull();
expect(screen.queryByText('旧会话消息不再渲染为聊天入口。')).toBeNull();
});
@@ -129,10 +131,45 @@ test('puzzle workspace falls back to compile action for restored sessions', () =
workDescription: '雾港遗迹拼图',
pictureDescription: '潮雾中的灯塔与断桥',
referenceImageSrc: null,
imageModel: 'gpt-image-2',
candidateCount: 1,
});
});
test('puzzle workspace switches the image model from the description box', () => {
const onCreateFromForm = vi.fn();
render(
<PuzzleAgentWorkspace
session={null}
onBack={() => {}}
onSubmitMessage={() => {}}
onExecuteAction={() => {}}
onCreateFromForm={onCreateFromForm}
/>,
);
fireEvent.change(screen.getByLabelText('作品名称'), {
target: { value: '暖灯猫街' },
});
fireEvent.change(screen.getByLabelText('作品描述'), {
target: { value: '一套雨夜猫街主题拼图。' },
});
fireEvent.change(screen.getByLabelText('画面描述'), {
target: { value: '一只猫在雨夜灯牌下回头。' },
});
fireEvent.click(screen.getByRole('button', { name: '图片模型' }));
expect(screen.queryByRole('menuitemradio', { name: '原模型' })).toBeNull();
fireEvent.click(screen.getByRole('menuitemradio', { name: 'nanobanana2' }));
fireEvent.click(screen.getByRole('button', { name: /稿/u }));
expect(onCreateFromForm).toHaveBeenCalledWith(
expect.objectContaining({
imageModel: 'gemini-3.1-flash-image-preview',
}),
);
});
test('puzzle workspace restores form draft fields and autosaves edits', () => {
vi.useFakeTimers();
const onAutoSaveForm = vi.fn();
@@ -208,5 +245,6 @@ test('puzzle workspace restores form draft fields and autosaves edits', () => {
workDescription: '旧街雨夜的拼图草稿。',
pictureDescription: '旧街灯牌下的猫和发光雨伞。',
referenceImageSrc: null,
imageModel: 'gpt-image-2',
});
});

View File

@@ -8,6 +8,12 @@ import type {
SendPuzzleAgentMessageRequest,
} from '../../../packages/shared/src/contracts/puzzleAgentSession';
import { readPuzzleReferenceImageAsDataUrl } from '../../services/puzzleReferenceImage';
import {
normalizePuzzleImageModel,
PUZZLE_IMAGE_MODEL_GPT_IMAGE_2,
type PuzzleImageModelId,
} from './puzzleImageModelOptions';
import { PuzzleImageModelPicker } from './PuzzleImageModelPicker';
type PuzzleAgentWorkspaceProps = {
session: PuzzleAgentSessionSnapshot | null;
@@ -27,6 +33,7 @@ type PuzzleFormState = {
pictureDescription: string;
referenceImageSrc: string;
referenceImageLabel: string;
imageModel: PuzzleImageModelId;
};
const EMPTY_FORM_STATE: PuzzleFormState = {
@@ -35,6 +42,7 @@ const EMPTY_FORM_STATE: PuzzleFormState = {
pictureDescription: '',
referenceImageSrc: '',
referenceImageLabel: '',
imageModel: PUZZLE_IMAGE_MODEL_GPT_IMAGE_2,
};
function resolveInitialFormState(
@@ -51,6 +59,7 @@ function resolveInitialFormState(
referenceImageLabel: initialFormPayload?.referenceImageSrc
? '已选择参考图'
: '',
imageModel: normalizePuzzleImageModel(initialFormPayload?.imageModel),
};
}
@@ -64,6 +73,7 @@ function resolveInitialFormState(
referenceImageLabel: initialFormPayload.referenceImageSrc
? '已选择参考图'
: '',
imageModel: normalizePuzzleImageModel(initialFormPayload.imageModel),
};
}
@@ -87,6 +97,7 @@ function resolveInitialFormState(
session.draft?.summary || session.anchorPack.visualSubject.value || '',
referenceImageSrc: '',
referenceImageLabel: '',
imageModel: PUZZLE_IMAGE_MODEL_GPT_IMAGE_2,
};
}
@@ -151,9 +162,11 @@ export function PuzzleAgentWorkspace({
workDescription,
pictureDescription,
referenceImageSrc: formState.referenceImageSrc || null,
imageModel: formState.imageModel,
}),
[
formState.referenceImageSrc,
formState.imageModel,
pictureDescription,
workDescription,
workTitle,
@@ -163,6 +176,7 @@ export function PuzzleAgentWorkspace({
autosavePayload.workTitle,
autosavePayload.workDescription,
autosavePayload.pictureDescription,
autosavePayload.imageModel,
]);
const lastAutosaveSignatureRef = useRef(autosaveSignature);
const autosaveSessionIdRef = useRef(session?.sessionId ?? null);
@@ -240,6 +254,7 @@ export function PuzzleAgentWorkspace({
workDescription,
pictureDescription,
referenceImageSrc: formState.referenceImageSrc || null,
imageModel: formState.imageModel,
};
if (!session && onCreateFromForm) {
@@ -254,6 +269,7 @@ export function PuzzleAgentWorkspace({
workDescription,
pictureDescription,
referenceImageSrc: formState.referenceImageSrc || null,
imageModel: formState.imageModel,
candidateCount: 1,
});
};
@@ -332,6 +348,16 @@ export function PuzzleAgentWorkspace({
className="w-full resize-none rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 py-3 pb-16 text-sm leading-6 text-[var(--platform-text-strong)] outline-none"
aria-label="画面描述"
/>
<PuzzleImageModelPicker
value={formState.imageModel}
disabled={isBusy}
onChange={(imageModel) =>
setFormState((current) => ({
...current,
imageModel,
}))
}
/>
<label
className={`absolute bottom-3 right-3 inline-flex h-10 w-10 cursor-pointer items-center justify-center rounded-full border border-amber-300/70 bg-white/96 text-amber-700 shadow-sm transition hover:bg-amber-50 ${isBusy ? 'cursor-not-allowed opacity-55' : ''}`}
title={
@@ -413,7 +439,10 @@ export function PuzzleAgentWorkspace({
<span className="inline-flex items-center gap-2">
{isBusy ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
<Sparkles className="h-4 w-4" />
稿
<span>稿</span>
<span className="rounded-full bg-white/24 px-2 py-0.5 text-[11px] font-bold">
2
</span>
</span>
</button>
</div>

View File

@@ -0,0 +1,84 @@
import { useEffect, useRef, useState } from 'react';
import {
getPuzzleImageModelLabel,
normalizePuzzleImageModel,
PUZZLE_IMAGE_MODEL_OPTIONS,
type PuzzleImageModelId,
} from './puzzleImageModelOptions';
type PuzzleImageModelPickerProps = {
value: PuzzleImageModelId;
disabled?: boolean;
onChange: (value: PuzzleImageModelId) => void;
};
export function PuzzleImageModelPicker({
value,
disabled = false,
onChange,
}: PuzzleImageModelPickerProps) {
const [isOpen, setIsOpen] = useState(false);
const rootRef = useRef<HTMLDivElement | null>(null);
const normalizedValue = normalizePuzzleImageModel(value);
useEffect(() => {
if (!isOpen) {
return;
}
const handlePointerDown = (event: PointerEvent) => {
if (!rootRef.current?.contains(event.target as Node)) {
setIsOpen(false);
}
};
window.addEventListener('pointerdown', handlePointerDown);
return () => window.removeEventListener('pointerdown', handlePointerDown);
}, [isOpen]);
return (
<div ref={rootRef} className="absolute bottom-3 left-3 z-10">
<button
type="button"
disabled={disabled}
onClick={() => setIsOpen((current) => !current)}
className={`inline-flex min-h-8 max-w-[10rem] items-center rounded-full border border-[var(--platform-subpanel-border)] bg-white/96 px-3 text-[11px] font-bold text-[var(--platform-text-strong)] shadow-sm transition hover:bg-[var(--platform-subpanel-fill)] ${disabled ? 'cursor-not-allowed opacity-55' : ''}`}
aria-haspopup="menu"
aria-expanded={isOpen}
aria-label="图片模型"
title="图片模型"
>
<span className="truncate">
{getPuzzleImageModelLabel(normalizedValue)}
</span>
</button>
{isOpen ? (
<div
role="menu"
className="absolute bottom-10 left-0 min-w-[11rem] overflow-hidden rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/98 p-1 shadow-[0_16px_40px_rgba(0,0,0,0.18)]"
>
{PUZZLE_IMAGE_MODEL_OPTIONS.map((option) => (
<button
key={option.id}
type="button"
role="menuitemradio"
aria-checked={option.id === normalizedValue}
onClick={() => {
onChange(option.id);
setIsOpen(false);
}}
className={`block min-h-9 w-full rounded-[0.8rem] px-3 text-left text-xs font-bold transition ${
option.id === normalizedValue
? 'bg-amber-100/80 text-amber-800'
: 'text-[var(--platform-text-base)] hover:bg-[var(--platform-subpanel-fill)]'
}`}
>
{option.label}
</button>
))}
</div>
) : null}
</div>
);
}

View File

@@ -0,0 +1,30 @@
export const PUZZLE_IMAGE_MODEL_GPT_IMAGE_2 = 'gpt-image-2';
export const PUZZLE_IMAGE_MODEL_NANOBANANA2 = 'gemini-3.1-flash-image-preview';
export type PuzzleImageModelId =
| typeof PUZZLE_IMAGE_MODEL_GPT_IMAGE_2
| typeof PUZZLE_IMAGE_MODEL_NANOBANANA2;
export const PUZZLE_IMAGE_MODEL_OPTIONS: Array<{
id: PuzzleImageModelId;
label: string;
}> = [
{ id: PUZZLE_IMAGE_MODEL_GPT_IMAGE_2, label: 'gpt-image-2' },
{ id: PUZZLE_IMAGE_MODEL_NANOBANANA2, label: 'nanobanana2' },
];
export function normalizePuzzleImageModel(
value: string | null | undefined,
): PuzzleImageModelId {
return (
PUZZLE_IMAGE_MODEL_OPTIONS.find((option) => option.id === value)?.id ??
PUZZLE_IMAGE_MODEL_GPT_IMAGE_2
);
}
export function getPuzzleImageModelLabel(model: PuzzleImageModelId) {
return (
PUZZLE_IMAGE_MODEL_OPTIONS.find((option) => option.id === model)?.label ??
'gpt-image-2'
);
}

View File

@@ -232,16 +232,29 @@ describe('PuzzleResultView', () => {
fireEvent.change(within(dialog).getByLabelText('画面描述'), {
target: { value: '一只猫在雨夜灯牌下回头。' },
});
fireEvent.click(within(dialog).getByRole('button', { name: //u }));
fireEvent.click(
within(dialog).getByRole('button', { name: //u }),
);
const confirmDialog = screen.getByRole('dialog', {
name: '确认消耗光点',
});
expect(within(confirmDialog).getByText('消耗 2 光点')).toBeTruthy();
fireEvent.click(within(confirmDialog).getByRole('button', { name: '确定' }));
expect(onExecuteAction).toHaveBeenCalledWith({
action: 'generate_puzzle_images',
levelId: 'puzzle-level-1',
promptText: '一只猫在雨夜灯牌下回头。',
referenceImageSrc: undefined,
imageModel: 'gpt-image-2',
candidateCount: 1,
workTitle: '暖灯猫街作品',
workDescription: '一套雨夜猫街主题拼图。',
summary: '一只猫在雨夜灯牌下回头。',
themeTags: ['猫咪', '雨夜', '暖灯'],
levelsJson: expect.any(String),
});
expect(screen.getByRole('progressbar', { name: '画面生成进度' })).toBeTruthy();
const generatePayload = onExecuteAction.mock.calls[0]![0];
expect(JSON.parse(generatePayload.levelsJson ?? '[]')).toEqual([
expect.objectContaining({
@@ -295,9 +308,14 @@ describe('PuzzleResultView', () => {
fireEvent.click(screen.getByRole('button', { name: //u }));
const dialog = screen.getByRole('dialog', { name: '关卡详情' });
expect(within(dialog).getByRole('button', { name: //u })).toBeTruthy();
expect(
within(dialog).getByRole('button', { name: //u }),
).toBeTruthy();
expect(within(dialog).getByText('消耗2光点')).toBeTruthy();
expect(within(dialog).queryByText('画面图')).toBeNull();
expect(within(dialog).queryByRole('button', { name: //u })).toBeNull();
expect(
within(dialog).queryByRole('button', { name: //u }),
).toBeNull();
fireEvent.click(screen.getByLabelText('关闭'));
expect(screen.getAllByText('第2关').length).toBeGreaterThan(0);
@@ -352,13 +370,24 @@ describe('PuzzleResultView', () => {
target: { value: '新关卡里有一座发光钟楼。' },
});
fireEvent.click(within(dialog).getByRole('button', { name: //u }));
fireEvent.click(
within(screen.getByRole('dialog', { name: '确认消耗光点' })).getByRole(
'button',
{ name: '确定' },
),
);
expect(onExecuteAction).toHaveBeenCalledWith({
action: 'generate_puzzle_images',
levelId: 'puzzle-level-1775000000000-2',
promptText: '新关卡里有一座发光钟楼。',
referenceImageSrc: undefined,
imageModel: 'gpt-image-2',
candidateCount: 1,
workTitle: '暖灯猫街作品',
workDescription: '一套雨夜猫街主题拼图。',
summary: '新关卡里有一座发光钟楼。',
themeTags: ['猫咪', '雨夜', '暖灯'],
levelsJson: expect.any(String),
});
@@ -452,13 +481,59 @@ describe('PuzzleResultView', () => {
});
fireEvent.click(screen.getByRole('button', { name: //u }));
fireEvent.click(
within(screen.getByRole('dialog', { name: '确认消耗光点' })).getByRole(
'button',
{ name: '确定' },
),
);
expect(onExecuteAction).toHaveBeenLastCalledWith({
action: 'generate_puzzle_images',
levelId: 'puzzle-level-1',
promptText: '屋檐下的猫与暖灯街角。',
referenceImageSrc: '/generated-puzzle-assets/history/image.png',
imageModel: 'gpt-image-2',
candidateCount: 1,
workTitle: '暖灯猫街作品',
workDescription: '一套雨夜猫街主题拼图。',
summary: '屋檐下的猫与暖灯街角。',
themeTags: ['猫咪', '雨夜', '暖灯'],
levelsJson: expect.any(String),
});
});
test('passes the selected image model when regenerating a level image', () => {
const onExecuteAction = vi.fn();
render(
<PuzzleResultView
session={createSession()}
onBack={() => {}}
onExecuteAction={onExecuteAction}
/>,
);
fireEvent.click(screen.getByText('雨夜猫街'));
const dialog = screen.getByRole('dialog', { name: '关卡详情' });
fireEvent.click(within(dialog).getByRole('button', { name: '图片模型' }));
fireEvent.click(
within(dialog).getByRole('menuitemradio', { name: 'gpt-image-2' }),
);
fireEvent.click(
within(dialog).getByRole('button', { name: //u }),
);
fireEvent.click(
within(screen.getByRole('dialog', { name: '确认消耗光点' })).getByRole(
'button',
{ name: '确定' },
),
);
expect(onExecuteAction).toHaveBeenCalledWith(
expect.objectContaining({
action: 'generate_puzzle_images',
imageModel: 'gpt-image-2',
}),
);
});
});

View File

@@ -26,6 +26,11 @@ import {
} from '../../services/puzzle-works/puzzleAssetClient';
import { readPuzzleReferenceImageAsDataUrl } from '../../services/puzzleReferenceImage';
import { useAuthUi } from '../auth/AuthUiContext';
import {
PUZZLE_IMAGE_MODEL_GPT_IMAGE_2,
type PuzzleImageModelId,
} from '../puzzle-agent/puzzleImageModelOptions';
import { PuzzleImageModelPicker } from '../puzzle-agent/PuzzleImageModelPicker';
import { ResolvedAssetImage } from '../ResolvedAssetImage';
type PuzzleResultViewProps = {
@@ -51,6 +56,8 @@ type DraftEditState = {
const PUZZLE_MIN_THEME_TAG_COUNT = 3;
const PUZZLE_MAX_THEME_TAG_COUNT = 6;
const PUZZLE_AUTOSAVE_DEBOUNCE_MS = 600;
const PUZZLE_IMAGE_GENERATION_POINT_COST = 2;
const PUZZLE_IMAGE_GENERATION_ESTIMATE_SECONDS = 30;
function normalizeThemeTagInput(value: string) {
return [
@@ -80,7 +87,9 @@ function resolveLevelFormalImageSrc(level: PuzzleDraftLevel) {
);
}
function buildFallbackLevelFromDraft(draft: PuzzleResultDraft): PuzzleDraftLevel {
function buildFallbackLevelFromDraft(
draft: PuzzleResultDraft,
): PuzzleDraftLevel {
return {
levelId: 'puzzle-level-1',
levelName: draft.levelName || '',
@@ -143,7 +152,9 @@ function createDraftEditState(draft: PuzzleResultDraft): DraftEditState {
};
}
function createBlankPuzzleLevel(existingLevels: PuzzleDraftLevel[]): PuzzleDraftLevel {
function createBlankPuzzleLevel(
existingLevels: PuzzleDraftLevel[],
): PuzzleDraftLevel {
const nextIndex = existingLevels.length + 1;
return {
levelId: `puzzle-level-${Date.now()}-${nextIndex}`,
@@ -200,7 +211,9 @@ function buildPublishReady(
...(levels.length > 0 ? [] : ['至少需要一个拼图关卡。']),
...levels.flatMap((level, index) => [
...(level.levelName.trim() ? [] : [`${index + 1}关名称不能为空。`]),
...(resolveLevelFormalImageSrc(level) ? [] : [`${index + 1}关缺少正式图。`]),
...(resolveLevelFormalImageSrc(level)
? []
: [`${index + 1}关缺少正式图。`]),
]),
];
@@ -574,6 +587,7 @@ function PuzzleLevelDetailDialog({
levelId: string,
promptText?: string | null,
referenceImageSrc?: string | null,
imageModel?: PuzzleImageModelId | null,
) => void;
onLevelChange: (nextLevel: PuzzleDraftLevel) => void;
onStartTestRun?: (level: PuzzleDraftLevel) => void;
@@ -581,10 +595,33 @@ function PuzzleLevelDetailDialog({
const platformTheme = useAuthUi()?.platformTheme ?? 'light';
const [referenceImageSrc, setReferenceImageSrc] = useState('');
const [referenceImageLabel, setReferenceImageLabel] = useState('');
const [referenceImageError, setReferenceImageError] = useState<string | null>(null);
const [referenceImageError, setReferenceImageError] = useState<string | null>(
null,
);
const [isHistoryPickerOpen, setIsHistoryPickerOpen] = useState(false);
const [isCostConfirmOpen, setIsCostConfirmOpen] = useState(false);
const [isGenerationProgressActive, setIsGenerationProgressActive] =
useState(false);
const [generationCountdown, setGenerationCountdown] = useState(0);
const generationBusySeenRef = useRef(false);
const [imageModel, setImageModel] = useState<PuzzleImageModelId>(
PUZZLE_IMAGE_MODEL_GPT_IMAGE_2,
);
const formalImageSrc = resolveLevelFormalImageSrc(level);
const hasFormalImage = Boolean(formalImageSrc);
const isGenerationProgressVisible = isGenerationProgressActive;
const generationSecondsLeft = isBusy
? Math.max(generationCountdown, 1)
: generationCountdown;
const generationProgressPercent = Math.max(
6,
Math.round(
((PUZZLE_IMAGE_GENERATION_ESTIMATE_SECONDS -
Math.max(generationSecondsLeft, 0)) /
PUZZLE_IMAGE_GENERATION_ESTIMATE_SECONDS) *
100,
),
);
const handleReferenceImageChange = async (
event: ChangeEvent<HTMLInputElement>,
@@ -609,6 +646,59 @@ function PuzzleLevelDetailDialog({
}
};
useEffect(() => {
if (!isGenerationProgressActive) {
return;
}
if (generationCountdown <= 0) {
if (!isBusy) {
setIsGenerationProgressActive(false);
}
return;
}
const timer = window.setTimeout(() => {
setGenerationCountdown((current) => Math.max(0, current - 1));
}, 1000);
return () => window.clearTimeout(timer);
}, [generationCountdown, isBusy, isGenerationProgressActive]);
useEffect(() => {
if (isGenerationProgressActive && isBusy) {
generationBusySeenRef.current = true;
return;
}
if (
isGenerationProgressActive &&
!isBusy &&
generationBusySeenRef.current
) {
generationBusySeenRef.current = false;
setIsGenerationProgressActive(false);
setGenerationCountdown(0);
}
if (!isBusy) {
setIsCostConfirmOpen(false);
}
}, [isBusy, isGenerationProgressActive]);
const executeGeneration = () => {
setIsCostConfirmOpen(false);
setIsGenerationProgressActive(true);
generationBusySeenRef.current = false;
setGenerationCountdown(PUZZLE_IMAGE_GENERATION_ESTIMATE_SECONDS);
onGenerate(
level.levelId,
level.pictureDescription.trim() || undefined,
referenceImageSrc || undefined,
imageModel,
);
};
if (typeof document === 'undefined') {
return null;
}
@@ -704,6 +794,11 @@ function PuzzleLevelDetailDialog({
className="w-full resize-none rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 py-3 pb-16 text-sm leading-6 text-[var(--platform-text-strong)] outline-none"
aria-label="画面描述"
/>
<PuzzleImageModelPicker
value={imageModel}
disabled={isBusy}
onChange={setImageModel}
/>
<label
className={`absolute bottom-3 right-3 inline-flex h-10 w-10 cursor-pointer items-center justify-center rounded-full border border-amber-300/70 bg-white/96 text-amber-700 shadow-sm transition hover:bg-amber-50 ${isBusy ? 'cursor-not-allowed opacity-55' : ''}`}
title={referenceImageSrc ? '更换参考图' : '添加参考图'}
@@ -779,24 +874,83 @@ function PuzzleLevelDetailDialog({
</button>
) : null}
<button
type="button"
disabled={isBusy}
onClick={() => {
onGenerate(
level.levelId,
level.pictureDescription.trim() || undefined,
referenceImageSrc || undefined,
);
}}
className="inline-flex w-full items-center justify-center gap-2 rounded-full bg-amber-600 px-4 py-3 text-sm font-bold text-white disabled:opacity-45"
>
{isBusy ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
<Sparkles className="h-4 w-4" />
{hasFormalImage ? '重新生成画面' : '生成画面'}
</button>
{isGenerationProgressVisible ? (
<div
role="progressbar"
aria-label="画面生成进度"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={generationProgressPercent}
className="platform-progress-track relative h-12 overflow-hidden rounded-full"
>
<div
className="h-full rounded-full bg-amber-600 transition-[width] duration-300"
style={{ width: `${generationProgressPercent}%` }}
/>
<div className="absolute inset-0 flex items-center justify-center gap-2 px-4 text-sm font-bold text-white">
<Loader2 className="h-4 w-4 animate-spin" />
{generationSecondsLeft}
</div>
</div>
) : (
<button
type="button"
disabled={isBusy}
onClick={() => setIsCostConfirmOpen(true)}
className="inline-flex w-full items-center justify-center gap-2 rounded-full bg-amber-600 px-4 py-3 text-sm font-bold text-white disabled:opacity-45"
>
<Sparkles className="h-4 w-4" />
<span>{hasFormalImage ? '重新生成画面' : '生成画面'}</span>
<span className="rounded-full bg-white/24 px-2 py-0.5 text-[11px] font-bold">
{PUZZLE_IMAGE_GENERATION_POINT_COST}
</span>
</button>
)}
</div>
{isCostConfirmOpen ? (
<div
className="absolute inset-0 z-20 flex items-center justify-center bg-black/45 p-4 backdrop-blur-sm"
onClick={() => setIsCostConfirmOpen(false)}
>
<section
role="dialog"
aria-modal="true"
aria-label="确认消耗光点"
className="platform-modal-shell platform-remap-surface w-full max-w-sm overflow-hidden rounded-[1.5rem] shadow-[0_24px_80px_rgba(0,0,0,0.45)]"
onClick={(event) => event.stopPropagation()}
>
<div className="flex items-center gap-3 border-b border-[var(--platform-subpanel-border)] px-5 py-4">
<span className="inline-flex h-9 w-9 items-center justify-center rounded-full bg-amber-100 text-amber-700">
<Sparkles className="h-4 w-4" />
</span>
<div className="text-base font-semibold text-[var(--platform-text-strong)]">
</div>
</div>
<div className="px-5 py-4 text-sm font-semibold text-[var(--platform-text-base)]">
{PUZZLE_IMAGE_GENERATION_POINT_COST}
</div>
<div className="flex items-center justify-end gap-3 border-t border-[var(--platform-subpanel-border)] px-5 py-4">
<button
type="button"
onClick={() => setIsCostConfirmOpen(false)}
className="platform-button platform-button--ghost min-h-10 px-4 py-2 text-sm"
>
</button>
<button
type="button"
onClick={executeGeneration}
className="platform-button platform-button--primary min-h-10 px-5 py-2 text-sm"
>
</button>
</div>
</section>
</div>
) : null}
{isHistoryPickerOpen ? (
<PuzzleHistoryAssetPickerDialog
isBusy={isBusy}
@@ -836,7 +990,9 @@ function PuzzlePublishDialog({
}) {
const platformTheme = useAuthUi()?.platformTheme ?? 'light';
const primaryLevel = editState.levels[0] ?? null;
const formalImageSrc = primaryLevel ? resolveLevelFormalImageSrc(primaryLevel) : '';
const formalImageSrc = primaryLevel
? resolveLevelFormalImageSrc(primaryLevel)
: '';
if (typeof document === 'undefined') {
return null;
@@ -1180,7 +1336,9 @@ export function PuzzleResultView({
return syncDraftFromEditState(draft, editState);
}, [draft, editState]);
const primaryLevel = editState?.levels[0] ?? null;
const primaryImageSrc = primaryLevel ? resolveLevelFormalImageSrc(primaryLevel) : '';
const primaryImageSrc = primaryLevel
? resolveLevelFormalImageSrc(primaryLevel)
: '';
const imageRefreshKey = `${session.updatedAt}:${primaryImageSrc}:${editState?.levels.length ?? 0}`;
const activeLevel =
editState?.levels.find((level) => level.levelId === activeLevelId) ?? null;
@@ -1201,7 +1359,8 @@ export function PuzzleResultView({
pictureDescription: level.pictureDescription.trim(),
})),
};
const originalState = savedEditStateRef.current ?? createDraftEditState(draft);
const originalState =
savedEditStateRef.current ?? createDraftEditState(draft);
const changed =
JSON.stringify(normalizedState) !== JSON.stringify(originalState);
@@ -1386,13 +1545,18 @@ export function PuzzleResultView({
isBusy={isBusy}
level={activeLevel}
onClose={() => setActiveLevelId(null)}
onGenerate={(levelId, promptText, referenceImageSrc) => {
onGenerate={(levelId, promptText, referenceImageSrc, imageModel) => {
onExecuteAction({
action: 'generate_puzzle_images',
levelId,
promptText,
referenceImageSrc,
imageModel: imageModel ?? PUZZLE_IMAGE_MODEL_GPT_IMAGE_2,
candidateCount: 1,
workTitle: editState.workTitle.trim(),
workDescription: editState.workDescription.trim(),
summary: activeLevel.pictureDescription.trim(),
themeTags: editState.themeTags,
levelsJson: JSON.stringify(editState.levels),
});
}}

View File

@@ -171,6 +171,75 @@ test('通关后显示结算弹窗、排行榜和下一关按钮', () => {
vi.useRealTimers();
});
test('首次点击左上返回弹出作品改造引导,保存并退出后不再重复弹出', () => {
const onBack = vi.fn();
const onRemodelWork = vi.fn();
window.localStorage.clear();
renderPuzzleRuntime(
<PuzzleRuntimeShell
run={clearedRun}
onBack={onBack}
onRemodelWork={onRemodelWork}
onSwapPieces={vi.fn()}
onDragPiece={vi.fn()}
onAdvanceNextLevel={vi.fn()}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '返回上一页' }));
const dialog = screen.getByRole('dialog', {
name: /\s*!/u,
});
expect(dialog).toBeTruthy();
expect(onBack).not.toHaveBeenCalled();
fireEvent.click(within(dialog).getByRole('button', { name: '保存并退出' }));
expect(onBack).toHaveBeenCalledTimes(1);
expect(
window.localStorage.getItem(
'genarrative.puzzle-runtime.exit-remodel-prompt.v1:profile-1',
),
).toBe('1');
fireEvent.click(screen.getByRole('button', { name: '返回上一页' }));
expect(screen.queryByRole('dialog')).toBeNull();
expect(onBack).toHaveBeenCalledTimes(2);
expect(onRemodelWork).not.toHaveBeenCalled();
});
test('首次退出引导的作品改造按钮进入改造流程', () => {
const onRemodelWork = vi.fn();
window.localStorage.clear();
renderPuzzleRuntime(
<PuzzleRuntimeShell
run={{
...clearedRun,
currentLevel: {
...clearedRun.currentLevel!,
profileId: 'profile-remodel',
},
}}
onBack={vi.fn()}
onRemodelWork={onRemodelWork}
onSwapPieces={vi.fn()}
onDragPiece={vi.fn()}
onAdvanceNextLevel={vi.fn()}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '返回上一页' }));
fireEvent.click(screen.getByRole('button', { name: '作品改造' }));
expect(onRemodelWork).toHaveBeenCalledTimes(1);
expect(onRemodelWork).toHaveBeenCalledWith('profile-remodel');
expect(screen.queryByRole('dialog')).toBeNull();
});
test('顶部作者显示头像昵称,底部功能居中放大且不显示等待候选', () => {
const runWithoutNext: PuzzleRunSnapshot = {
...clearedRun,

View File

@@ -41,6 +41,7 @@ type PuzzleRuntimeShellProps = {
isBusy?: boolean;
error?: string | null;
onBack: () => void;
onRemodelWork?: (profileId: string) => void | Promise<void>;
onSwapPieces: (payload: SwapPuzzlePiecesRequest) => void;
onDragPiece: (payload: DragPuzzlePieceRequest) => void;
onAdvanceNextLevel: (target?: PuzzleNextLevelTarget) => void;
@@ -208,6 +209,61 @@ const PUZZLE_CLEAR_DIALOG_DELAY_MS = 500;
const PUZZLE_MERGE_FLASH_DURATION_MS = 720;
const PUZZLE_HINT_DEMO_DURATION_MS = 1_250;
const PUZZLE_PIECE_PRESS_HAPTIC_PATTERN_MS = 12;
const PUZZLE_EXIT_REMODEL_PROMPT_STORAGE_PREFIX =
'genarrative.puzzle-runtime.exit-remodel-prompt.v1';
const shownExitRemodelPromptProfileIds = new Set<string>();
function buildExitRemodelPromptStorageKey(profileId: string) {
return `${PUZZLE_EXIT_REMODEL_PROMPT_STORAGE_PREFIX}:${encodeURIComponent(
profileId,
)}`;
}
function hasSeenExitRemodelPrompt(profileId: string) {
const normalizedProfileId = profileId.trim();
if (!normalizedProfileId) {
return true;
}
if (shownExitRemodelPromptProfileIds.has(normalizedProfileId)) {
if (typeof window === 'undefined') {
return true;
}
}
try {
const seen =
window.localStorage.getItem(
buildExitRemodelPromptStorageKey(normalizedProfileId),
) === '1';
if (seen) {
shownExitRemodelPromptProfileIds.add(normalizedProfileId);
}
return seen;
} catch {
return shownExitRemodelPromptProfileIds.has(normalizedProfileId);
}
}
function markExitRemodelPromptSeen(profileId: string) {
const normalizedProfileId = profileId.trim();
if (!normalizedProfileId) {
return;
}
shownExitRemodelPromptProfileIds.add(normalizedProfileId);
if (typeof window === 'undefined') {
return;
}
try {
window.localStorage.setItem(
buildExitRemodelPromptStorageKey(normalizedProfileId),
'1',
);
} catch {
// 中文注释:隐私模式下 localStorage 可能不可写,内存集合足够兜底本次挂载周期。
}
}
type PuzzlePropDialogState = {
propKind: PuzzleRuntimePropKind;
@@ -251,6 +307,7 @@ export function PuzzleRuntimeShell({
isBusy = false,
error = null,
onBack,
onRemodelWork,
onSwapPieces,
onDragPiece,
onAdvanceNextLevel,
@@ -263,6 +320,8 @@ export function PuzzleRuntimeShell({
const authUi = useAuthUi();
const [selectedPieceId, setSelectedPieceId] = useState<string | null>(null);
const [isSettingsPanelOpen, setIsSettingsPanelOpen] = useState(false);
const [isExitRemodelPromptOpen, setIsExitRemodelPromptOpen] =
useState(false);
const [propDialog, setPropDialog] = useState<PuzzlePropDialogState | null>(
null,
);
@@ -621,7 +680,10 @@ export function PuzzleRuntimeShell({
}, [onTimeExpired]);
const isUiPauseActive =
isSettingsPanelOpen || Boolean(propDialog) || isOriginalOverlayVisible;
isSettingsPanelOpen ||
isExitRemodelPromptOpen ||
Boolean(propDialog) ||
isOriginalOverlayVisible;
useEffect(() => {
if (previousUiPauseActiveRef.current === isUiPauseActive) {
@@ -898,6 +960,7 @@ export function PuzzleRuntimeShell({
const authorAvatarLabel = resolveAuthorAvatarLabel(
currentLevel.authorDisplayName,
);
const exitPromptProfileId = currentLevel.profileId.trim();
const leaderboardEntries =
(currentLevel.leaderboardEntries ?? []).length > 0
? currentLevel.leaderboardEntries
@@ -909,6 +972,20 @@ export function PuzzleRuntimeShell({
const isInteractionLocked =
isBusy || runtimeStatus !== 'playing' || Boolean(propDialog);
const handleBackRequest = () => {
if (
onRemodelWork &&
exitPromptProfileId &&
!hasSeenExitRemodelPrompt(exitPromptProfileId)
) {
markExitRemodelPromptSeen(exitPromptProfileId);
setIsExitRemodelPromptOpen(true);
return;
}
onBack();
};
const openPropDialog = (propKind: PuzzleRuntimePropKind, title: string) => {
const canOpen =
propKind === 'extendTime'
@@ -1016,7 +1093,7 @@ export function PuzzleRuntimeShell({
<div className="grid grid-cols-[2.75rem_minmax(0,1fr)_2.75rem] items-start gap-2 sm:gap-3">
<button
type="button"
onClick={onBack}
onClick={handleBackRequest}
aria-label="返回上一页"
className="inline-flex h-11 w-11 items-center justify-center rounded-full bg-black/30 backdrop-blur"
>
@@ -1664,6 +1741,54 @@ export function PuzzleRuntimeShell({
</div>
) : null}
{isExitRemodelPromptOpen ? (
<div
className="absolute inset-0 z-50 flex items-center justify-center bg-slate-950/72 px-4 py-6 backdrop-blur-sm"
>
<section
role="dialog"
aria-modal="true"
aria-labelledby="puzzle-exit-remodel-title"
className="flex w-full max-w-[22rem] flex-col overflow-hidden rounded-[1.5rem] border border-white/14 bg-slate-950/94 shadow-[0_28px_90px_rgba(0,0,0,0.5)]"
onClick={(event) => event.stopPropagation()}
>
<header className="px-5 pt-6 text-center">
<h2
id="puzzle-exit-remodel-title"
className="text-2xl font-black leading-tight text-white"
>
<br />
!
</h2>
</header>
<footer className="grid gap-3 px-5 py-5">
<button
type="button"
disabled={isBusy}
onClick={() => {
setIsExitRemodelPromptOpen(false);
void onRemodelWork?.(exitPromptProfileId);
}}
className="rounded-full bg-amber-200 px-5 py-3 text-sm font-black text-slate-950 transition hover:bg-amber-100 disabled:cursor-not-allowed disabled:opacity-50"
>
</button>
<button
type="button"
onClick={() => {
setIsExitRemodelPromptOpen(false);
onBack();
}}
className="rounded-full border border-white/14 bg-black/24 px-5 py-3 text-sm font-black text-white transition hover:bg-white/10"
>
退
</button>
</footer>
</section>
</div>
) : null}
{runtimeStatus === 'failed' ? (
<div className="absolute inset-0 z-40 flex items-center justify-center bg-slate-950/68 px-4 py-6 backdrop-blur-sm">
<section

View File

@@ -15,6 +15,13 @@ import type {
} from '../../../packages/shared/src/contracts/match3dAgent';
import type { Match3DRunSnapshot } from '../../../packages/shared/src/contracts/match3dRuntime';
import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks';
import type {
PuzzleAnchorPack,
PuzzleResultDraft,
} from '../../../packages/shared/src/contracts/puzzleAgentDraft';
import type {
PuzzleAgentSessionSnapshot,
} from '../../../packages/shared/src/contracts/puzzleAgentSession';
import type { PuzzleRunSnapshot } from '../../../packages/shared/src/contracts/puzzleRuntimeSession';
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
import type { RpgCreationResultView } from '../../../packages/shared/src/contracts/rpgCreationResultView';
@@ -621,6 +628,41 @@ function buildMockPuzzleRun(
};
}
function buildPuzzleAnchorPack(): PuzzleAnchorPack {
return {
themePromise: {
key: 'themePromise',
label: '题材承诺',
value: '雨夜拼图',
status: 'confirmed',
},
visualSubject: {
key: 'visualSubject',
label: '画面主体',
value: '雨夜猫塔',
status: 'confirmed',
},
visualMood: {
key: 'visualMood',
label: '视觉气质',
value: '暖灯',
status: 'confirmed',
},
compositionHooks: {
key: 'compositionHooks',
label: '拼图记忆点',
value: '灯塔与猫',
status: 'confirmed',
},
tagsAndForbidden: {
key: 'tagsAndForbidden',
label: '标签与禁忌',
value: '雨夜、猫咪、塔',
status: 'confirmed',
},
};
}
function buildClearedPuzzleRun(params: {
runId: string;
entryProfileId: string;
@@ -1810,7 +1852,7 @@ beforeEach(() => {
vi.mocked(streamRpgCreationMessage).mockResolvedValue(mockSession);
});
test('create hub keeps RPG, AIRP and visual novel locked', async () => {
test('create hub opens RPG while keeping AIRP and visual novel locked', async () => {
const user = userEvent.setup();
render(<TestWrapper withAuth />);
@@ -1826,9 +1868,13 @@ test('create hub keeps RPG, AIRP and visual novel locked', async () => {
expect((visualNovelButton as HTMLButtonElement).disabled).toBe(true);
const rpgButton = screen.getByRole('button', { name: //u });
expect((rpgButton as HTMLButtonElement).disabled).toBe(true);
expect(within(rpgButton).getAllByText('敬请期待').length).toBeGreaterThan(0);
expect(createRpgCreationSession).not.toHaveBeenCalled();
expect((rpgButton as HTMLButtonElement).disabled).toBe(false);
await user.click(rpgButton);
expect(createRpgCreationSession).toHaveBeenCalledTimes(1);
expect(
await screen.findByText('Agent工作区custom-world-agent-session-1'),
).toBeTruthy();
});
test('platform create hub does not prefetch hidden big fish platform data', async () => {
@@ -2225,6 +2271,54 @@ test('logged out public detail gates puzzle start and remix before real actions'
expect(remixPuzzleGalleryWork).not.toHaveBeenCalled();
});
test('owned public puzzle detail edits original draft instead of remixing', async () => {
const user = userEvent.setup();
const ownedPuzzleWork = {
workId: 'puzzle-work-owned-1',
profileId: 'puzzle-profile-owned-1',
ownerUserId: mockAuthUser.id,
sourceSessionId: 'puzzle-session-1',
authorDisplayName: mockAuthUser.displayName,
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,
remixCount: 0,
likeCount: 0,
publishReady: true,
} satisfies PuzzleWorkSummary;
vi.mocked(listPuzzleGallery).mockResolvedValue({
items: [ownedPuzzleWork],
});
vi.mocked(getPuzzleGalleryDetail).mockResolvedValue({
item: ownedPuzzleWork,
});
render(<TestWrapper withAuth />);
await waitFor(() => {
expect(screen.getAllByText('星桥机关').length).toBeGreaterThan(0);
});
const workCards = screen.getAllByRole('button', { name: //u });
await user.click(workCards[0]!);
expect(await screen.findByText('详情')).toBeTruthy();
expect(screen.getByRole('button', { name: '作品编辑' })).toBeTruthy();
expect(screen.queryByRole('button', { name: '作品改造' })).toBeNull();
await user.click(screen.getByRole('button', { name: '作品编辑' }));
expect(getPuzzleAgentSession).toHaveBeenCalledWith('puzzle-session-1');
expect(remixPuzzleGalleryWork).not.toHaveBeenCalled();
expect(await screen.findByText('拼图结果页')).toBeTruthy();
});
test('logged out public detail gates big fish start before local runtime', async () => {
const user = userEvent.setup();
const requireAuth = vi.fn();
@@ -2523,6 +2617,13 @@ test('published puzzle detail returns to the ranking platform tab', async () =>
expect(await screen.findByTestId('puzzle-board')).toBeTruthy();
await user.click(screen.getByRole('button', { name: '返回上一页' }));
await user.click(
within(
await screen.findByRole('dialog', {
name: /\s*!/u,
}),
).getByRole('button', { name: '保存并退出' }),
);
await waitFor(() => {
expect(screen.getByRole('button', { name: '启动' })).toBeTruthy();
@@ -2539,7 +2640,7 @@ test('published puzzle detail returns to the ranking platform tab', async () =>
});
});
test('selecting locked RPG creation while logged out does not route through requireAuth', async () => {
test('selecting RPG creation while logged out routes through requireAuth', async () => {
const user = userEvent.setup();
const requireAuth = vi.fn();
@@ -2556,9 +2657,9 @@ test('selecting locked RPG creation while logged out does not route through requ
await openCreationHub(user);
const rpgButton = await screen.findByRole('button', { name: //u });
expect((rpgButton as HTMLButtonElement).disabled).toBe(true);
expect((rpgButton as HTMLButtonElement).disabled).toBe(false);
await user.click(rpgButton);
expect(requireAuth).not.toHaveBeenCalled();
expect(requireAuth).toHaveBeenCalledTimes(1);
expect(createRpgCreationSession).not.toHaveBeenCalled();
});
@@ -2684,16 +2785,16 @@ test('new creation entry maps raw bearer token errors to user-facing auth copy',
await openCreationHub(user);
const rpgButton = screen.getByRole('button', { name: //u });
expect((rpgButton as HTMLButtonElement).disabled).toBe(true);
expect((rpgButton as HTMLButtonElement).disabled).toBe(false);
await user.click(rpgButton);
expect(listPuzzleWorks).toHaveBeenCalled();
expect(createRpgCreationSession).not.toHaveBeenCalled();
expect(createRpgCreationSession).toHaveBeenCalledTimes(1);
expect(
within(getPlatformTabPanel('create')).queryByText(
await within(getPlatformTabPanel('create')).findByText(
'当前登录状态已失效,请重新登录后继续。',
),
).toBeNull();
).toBeTruthy();
expect(screen.queryByText('缺少 Authorization Bearer Token')).toBeNull();
});
@@ -3016,6 +3117,98 @@ test('formal puzzle next level uses backend run and leaderboard keeps frontend l
expect(screen.getByText('测试玩家')).toBeTruthy();
});
test('first puzzle runtime back click can open remix result page', async () => {
const user = userEvent.setup();
const puzzleWork: PuzzleWorkSummary = {
workId: 'puzzle-work-public-1',
profileId: 'puzzle-profile-public-1',
ownerUserId: 'user-2',
sourceSessionId: null,
authorDisplayName: '拼图作者',
levelName: '雨夜猫塔',
summary: '一张聚焦发光猫咪与遗迹台阶的雨夜拼图。',
themeTags: ['雨夜', '猫咪', '遗迹'],
coverImageSrc: null,
coverAssetId: null,
publicationStatus: 'published',
updatedAt: '2026-04-25T12:10:00.000Z',
publishedAt: '2026-04-25T12:10:00.000Z',
playCount: 8,
remixCount: 0,
likeCount: 0,
publishReady: true,
};
const anchorPack = buildPuzzleAnchorPack();
const remixDraft: PuzzleResultDraft = {
workTitle: '改造后的雨夜猫塔',
workDescription: '准备改造的拼图草稿。',
levelName: '改造后的雨夜猫塔',
summary: '一只猫站在雨夜塔顶。',
themeTags: ['雨夜', '猫咪', '塔'],
forbiddenDirectives: [],
creatorIntent: null,
anchorPack,
candidates: [],
selectedCandidateId: null,
coverImageSrc: null,
coverAssetId: null,
generationStatus: 'idle',
levels: [],
metadata: null,
};
const remixSession: PuzzleAgentSessionSnapshot = {
sessionId: 'puzzle-session-remix-1',
currentTurn: 1,
progressPercent: 100,
stage: 'ready_to_publish',
anchorPack,
draft: remixDraft,
messages: [],
lastAssistantReply: null,
publishedProfileId: null,
suggestedActions: [],
resultPreview: null,
updatedAt: '2026-04-25T12:12:00.000Z',
};
vi.mocked(listPuzzleGallery).mockResolvedValue({
items: [puzzleWork],
});
vi.mocked(getPuzzleGalleryDetail).mockResolvedValue({
item: puzzleWork,
});
vi.mocked(startPuzzleRun).mockResolvedValue({
run: buildMockPuzzleRun(puzzleWork.profileId, puzzleWork.levelName),
});
vi.mocked(remixPuzzleGalleryWork).mockResolvedValue({
session: remixSession,
});
render(<TestWrapper withAuth />);
const searchInput = await screen.findByPlaceholderText(
'搜索作品号、名称、作者、描述',
);
await user.type(searchInput, 'PZ-EPUBLIC1');
await user.click(screen.getByRole('button', { name: '搜索' }));
await user.click(await screen.findByRole('button', { name: '启动' }));
expect(await screen.findByTestId('puzzle-board')).toBeTruthy();
await user.click(await screen.findByRole('button', { name: '返回上一页' }));
const dialog = await screen.findByRole('dialog', {
name: /\s*!/u,
});
await user.click(within(dialog).getByRole('button', { name: '作品改造' }));
await waitFor(() => {
expect(remixPuzzleGalleryWork).toHaveBeenCalledWith(
'puzzle-profile-public-1',
);
});
expect(await screen.findByText('拼图结果页')).toBeTruthy();
expect(screen.getByDisplayValue('改造后的雨夜猫塔')).toBeTruthy();
});
test('public code search opens a published puzzle by PZ code', async () => {
const user = userEvent.setup();
const puzzleWork: PuzzleWorkSummary = {

View File

@@ -29,6 +29,7 @@ export type PlatformPuzzleGalleryCard = {
sourceType: 'puzzle';
workId: string;
profileId: string;
sourceSessionId?: string | null;
publicWorkCode: string;
ownerUserId: string;
authorDisplayName: string;
@@ -78,6 +79,7 @@ export type PlatformMatch3DGalleryCard = {
sourceType: 'match3d';
workId: string;
profileId: string;
sourceSessionId?: string | null;
publicWorkCode: string;
ownerUserId: string;
authorDisplayName: string;
@@ -132,6 +134,7 @@ export function mapPuzzleWorkToPlatformGalleryCard(
sourceType: 'puzzle',
workId: work.workId,
profileId: work.profileId,
sourceSessionId: work.sourceSessionId ?? null,
publicWorkCode: buildPuzzlePublicWorkCode(work.profileId),
ownerUserId: work.ownerUserId,
authorDisplayName: work.authorDisplayName,
@@ -158,6 +161,7 @@ export function mapMatch3DWorkToPlatformGalleryCard(
sourceType: 'match3d',
workId: work.workId,
profileId: work.profileId,
sourceSessionId: work.sourceSessionId ?? null,
publicWorkCode: buildMatch3DPublicWorkCode(work.profileId),
ownerUserId: work.ownerUserId,
authorDisplayName: '玩家',

View File

@@ -0,0 +1,69 @@
/**
* 新建作品入口配置。
* 修改入口开放状态、隐藏状态和展示文案时,优先调整本文件,避免多入口文案漂移。
*/
export const NEW_WORK_ENTRY_CONFIG = {
startCard: {
title: '新建作品',
description: '直接选择游戏创作模板,立刻进入对应的共创工作台。',
idleBadge: '选择模板',
busyBadge: '正在开启',
},
typeModal: {
title: '选择创作类型',
description: '先选玩法类型,再进入对应创作工作台。',
},
creationTypes: [
{
id: 'rpg',
title: '角色扮演',
subtitle: '敬请期待',
badge: '敬请期待',
visible: true,
open: true,
},
{
id: 'big-fish',
title: '大鱼吃小鱼',
subtitle: '实时成长玩法',
badge: '可创建',
visible: false,
open: true,
},
{
id: 'puzzle',
title: '拼图',
subtitle: '创意礼物,生活分享',
badge: '可创建',
visible: true,
open: true,
},
{
id: 'match3d',
title: '抓大鹅',
subtitle: '经典消除玩法',
badge: '可创建',
visible: true,
open: true,
},
{
id: 'airp',
title: 'AIRP',
subtitle: '敬请期待',
badge: '敬请期待',
visible: true,
open: false,
},
{
id: 'visual-novel',
title: '视觉小说',
subtitle: '敬请期待',
badge: '敬请期待',
visible: true,
open: false,
},
],
} as const;
export type NewWorkEntryCreationTypeId =
(typeof NEW_WORK_ENTRY_CONFIG.creationTypes)[number]['id'];