Merge branch 'master' into codex/ddd
This commit is contained in:
65
src/components/common/PublishShareModal.test.tsx
Normal file
65
src/components/common/PublishShareModal.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
145
src/components/common/PublishShareModal.tsx
Normal file
145
src/components/common/PublishShareModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
30
src/components/common/publishShareModalModel.ts
Normal file
30
src/components/common/publishShareModalModel.ts
Normal 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,
|
||||
})}`;
|
||||
}
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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('大鱼吃小鱼');
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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',
|
||||
]);
|
||||
});
|
||||
@@ -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,
|
||||
}));
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
84
src/components/puzzle-agent/PuzzleImageModelPicker.tsx
Normal file
84
src/components/puzzle-agent/PuzzleImageModelPicker.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
30
src/components/puzzle-agent/puzzleImageModelOptions.ts
Normal file
30
src/components/puzzle-agent/puzzleImageModelOptions.ts
Normal 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'
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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: '玩家',
|
||||
|
||||
69
src/config/newWorkEntryConfig.ts
Normal file
69
src/config/newWorkEntryConfig.ts
Normal 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'];
|
||||
Reference in New Issue
Block a user