This commit is contained in:
2026-05-14 01:11:58 +08:00
parent b13870f71b
commit 5a55180b78
61 changed files with 5050 additions and 1057 deletions

View File

@@ -20,11 +20,22 @@ vi.mock('../ResolvedAssetImage', () => ({
src,
alt,
className,
'data-testid': dataTestId,
}: {
src?: string | null;
alt?: string;
className?: string;
}) => (src ? <img src={src} alt={alt} className={className} /> : null),
'data-testid'?: string;
}) => (
src ? (
<img
src={src}
alt={alt}
className={className}
data-testid={dataTestId}
/>
) : null
),
}));
vi.mock('../../services/puzzle-works/puzzleAssetClient', () => ({
@@ -37,6 +48,16 @@ vi.mock('../../services/puzzle-works', () => ({
updatePuzzleWork: vi.fn(),
}));
vi.mock('../../hooks/useResolvedAssetReadUrl', () => ({
useResolvedAssetReadUrl: (src?: string | null) => ({
resolvedUrl: src
? `https://signed.example.com/${src.replace(/^\/+/u, '')}`
: '',
isResolving: false,
shouldResolve: Boolean(src?.trim().startsWith('/generated-')),
}),
}));
afterEach(() => {
vi.useRealTimers();
vi.clearAllMocks();
@@ -157,6 +178,8 @@ describe('PuzzleResultView', () => {
expect(screen.getByRole('button', { name: '拼图关卡' })).toBeTruthy();
expect(screen.getByRole('button', { name: '作品信息' })).toBeTruthy();
expect(screen.getByRole('button', { name: '素材配置' })).toBeTruthy();
expect(screen.queryByRole('button', { name: '音乐' })).toBeNull();
expect(screen.getByText('雨夜猫街')).toBeTruthy();
expect(screen.getByText('获得更多积分激励')).toBeTruthy();
@@ -171,6 +194,33 @@ describe('PuzzleResultView', () => {
);
});
test('result action bar restores draft trial entry', () => {
const onStartTestRun = vi.fn();
render(
<PuzzleResultView
session={createSession()}
onBack={() => {}}
onExecuteAction={() => {}}
onStartTestRun={onStartTestRun}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '试玩' }));
expect(onStartTestRun).toHaveBeenCalledWith(
expect.objectContaining({
workTitle: '暖灯猫街作品',
levels: [
expect.objectContaining({
levelId: 'puzzle-level-1',
levelName: '雨夜猫街',
}),
],
}),
);
});
test('auto saves work info and levels through one payload', async () => {
vi.useFakeTimers();
vi.mocked(puzzleWorksService.updatePuzzleWork).mockResolvedValue({
@@ -645,7 +695,7 @@ describe('PuzzleResultView', () => {
/>,
);
fireEvent.click(screen.getByRole('button', { name: 'UI' }));
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
expect(screen.getByAltText('拼图UI背景图').getAttribute('src')).toBe(
'/generated-puzzle-assets/session/ui/background.png',
@@ -657,6 +707,11 @@ describe('PuzzleResultView', () => {
fireEvent.click(screen.getByRole('button', { name: '预览UI' }));
const preview = screen.getByRole('dialog', { name: 'UI预览' });
expect(
within(preview)
.getByTestId('puzzle-ui-runtime-preview-background')
.getAttribute('src'),
).toBe('/generated-puzzle-assets/session/ui/background.png');
expect(within(preview).getByLabelText('拼图区边界')).toBeTruthy();
});
@@ -671,11 +726,12 @@ describe('PuzzleResultView', () => {
/>,
);
fireEvent.click(screen.getByRole('button', { name: 'UI' }));
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
fireEvent.change(screen.getByLabelText('拼图UI背景提示词'), {
target: { value: '新拼图UI背景提示词' },
});
fireEvent.click(screen.getByRole('button', { name: '生成UI背景' }));
expect(screen.getByRole('button', { name: /生成UI背景 · 2光点/u })).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: /生成UI背景/u }));
expect(onExecuteAction).toHaveBeenCalledWith({
action: 'generate_puzzle_ui_background',
@@ -696,6 +752,46 @@ describe('PuzzleResultView', () => {
]);
});
test('素材配置背景音乐试听使用签名地址', () => {
const base = createSession();
const level = base.draft!.levels![0]!;
render(
<PuzzleResultView
session={createSession({
draft: {
...base.draft!,
levels: [
{
...level,
backgroundMusic: {
taskId: 'music-task-1',
provider: 'vector-engine-suno',
assetObjectId: 'asset-music-1',
assetKind: 'puzzle_background_music',
audioSrc: '/generated-puzzle-assets/session/audio/music.mp3',
prompt: '',
title: '雨夜轻响',
updatedAt: '2026-05-12T10:00:00.000Z',
},
},
],
},
})}
onBack={() => {}}
onExecuteAction={() => {}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
fireEvent.click(screen.getByRole('button', { name: '背景音乐' }));
expect(screen.getByRole('button', { name: /重新生成音乐 · 5光点/u })).toBeTruthy();
expect(screen.getByLabelText('拼图背景音乐').getAttribute('src')).toBe(
'https://signed.example.com/generated-puzzle-assets/session/audio/music.mp3',
);
});
test('auto saves UI background prompt edits through levels', async () => {
vi.useFakeTimers();
vi.mocked(puzzleWorksService.updatePuzzleWork).mockResolvedValue({
@@ -711,7 +807,7 @@ describe('PuzzleResultView', () => {
/>,
);
fireEvent.click(screen.getByRole('button', { name: 'UI' }));
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
fireEvent.change(screen.getByLabelText('拼图UI背景提示词'), {
target: { value: '新的自动保存UI背景提示词' },
});

View File

@@ -18,8 +18,8 @@ import {
import { type ChangeEvent, useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import type { CreativeDraftEditResult } from '../../../packages/shared/src/contracts/creativeAgent';
import type { CreationAudioAsset } from '../../../packages/shared/src/contracts/creationAudio';
import type { CreativeDraftEditResult } from '../../../packages/shared/src/contracts/creativeAgent';
import type { PuzzleAgentActionRequest } from '../../../packages/shared/src/contracts/puzzleAgentActions';
import type {
PuzzleDraftLevel,
@@ -33,6 +33,7 @@ import {
} from '../../services/creation-audio';
import { updatePuzzleWork } from '../../services/puzzle-works';
import { readPuzzleReferenceImageAsDataUrl } from '../../services/puzzleReferenceImage';
import { useResolvedAssetReadUrl } from '../../hooks/useResolvedAssetReadUrl';
import { useAuthUi } from '../auth/AuthUiContext';
import PuzzleHistoryAssetPickerDialog from '../puzzle-agent/PuzzleHistoryAssetPickerDialog';
import {
@@ -61,7 +62,8 @@ type PuzzleResultViewProps = {
};
type PuzzleAutoSaveState = 'idle' | 'saving' | 'saved' | 'error';
type PuzzleResultTab = 'levels' | 'work' | 'ui' | 'music';
type PuzzleResultTab = 'levels' | 'work' | 'assets';
type PuzzleAssetConfigTabId = 'ui' | 'music';
type DraftEditState = {
workTitle: string;
@@ -74,12 +76,27 @@ 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_BACKGROUND_MUSIC_POINT_COST = 5;
const PUZZLE_IMAGE_GENERATION_ESTIMATE_SECONDS = 90;
const PUZZLE_BACKGROUND_MUSIC_ASSET_KIND = 'puzzle_background_music';
const PUZZLE_BACKGROUND_MUSIC_SLOT = 'background_music';
const PUZZLE_UI_BACKGROUND_REFERENCE_SRC =
'/ui-previews/puzzle-image-compact-ui-2026-05-08.png';
const PUZZLE_RESULT_TABS: Array<{ id: PuzzleResultTab; label: string }> = [
{ id: 'levels', label: '拼图关卡' },
{ id: 'work', label: '作品信息' },
{ id: 'assets', label: '素材配置' },
];
const PUZZLE_ASSET_CONFIG_TABS: Array<{
id: PuzzleAssetConfigTabId;
label: string;
}> = [
{ id: 'ui', label: 'UI' },
{ id: 'music', label: '背景音乐' },
];
type PuzzleLevelGenerationRuntime = {
startedAtMs: number;
estimateSeconds: number;
@@ -419,13 +436,8 @@ function PuzzleResultTabs({
onChange: (tab: PuzzleResultTab) => void;
}) {
return (
<div className="mb-3 grid grid-cols-4 gap-2 rounded-[1.25rem] border border-[var(--platform-subpanel-border)] bg-white/62 p-1">
{[
{ id: 'levels' as const, label: '拼图关卡' },
{ id: 'work' as const, label: '作品信息' },
{ id: 'ui' as const, label: 'UI' },
{ id: 'music' as const, label: '音乐' },
].map((tab) => (
<div className="mb-3 grid grid-cols-3 gap-2 rounded-[1.25rem] border border-[var(--platform-subpanel-border)] bg-white/62 p-1">
{PUZZLE_RESULT_TABS.map((tab) => (
<button
key={tab.id}
type="button"
@@ -444,6 +456,34 @@ function PuzzleResultTabs({
);
}
function PuzzleAssetConfigTabs({
activeTab,
onChange,
}: {
activeTab: PuzzleAssetConfigTabId;
onChange: (tab: PuzzleAssetConfigTabId) => void;
}) {
return (
<div className="mb-3 grid grid-cols-2 gap-2 rounded-[1.1rem] border border-[var(--platform-subpanel-border)] bg-white/58 p-1">
{PUZZLE_ASSET_CONFIG_TABS.map((tab) => (
<button
key={tab.id}
type="button"
onClick={() => onChange(tab.id)}
className={`min-h-10 rounded-[0.9rem] px-3 text-sm font-bold transition ${
activeTab === tab.id
? 'bg-white text-[var(--platform-text-strong)] shadow-sm'
: 'text-[var(--platform-text-base)] hover:bg-white/60'
}`}
aria-pressed={activeTab === tab.id}
>
{tab.label}
</button>
))}
</div>
);
}
function PuzzleThemeTagEditor({
editState,
isBusy,
@@ -1467,7 +1507,7 @@ function PuzzleUiAssetsTab({
) : (
<Wand2 className="h-4 w-4" />
)}
{firstLevel?.uiBackgroundImageSrc ? '重新生成' : '生成UI背景'}
{firstLevel?.uiBackgroundImageSrc ? '重新生成' : '生成UI背景'} · {PUZZLE_IMAGE_GENERATION_POINT_COST}
</button>
</div>
</div>
@@ -1543,6 +1583,7 @@ function PuzzleUiRuntimePreviewPanel({
src={backgroundPreviewSrc}
refreshKey={`${imageRefreshKey}:ui-runtime-preview`}
alt=""
data-testid="puzzle-ui-runtime-preview-background"
aria-hidden="true"
className="pointer-events-none absolute inset-0 h-full w-full object-cover"
/>
@@ -1632,6 +1673,10 @@ function PuzzleMusicTab({
const [statusText, setStatusText] = useState<string | null>(null);
const [errorText, setErrorText] = useState<string | null>(null);
const [isGenerating, setIsGenerating] = useState(false);
const { resolvedUrl: resolvedMusicSrc } = useResolvedAssetReadUrl(
currentMusic?.audioSrc,
{ expireSeconds: 300 },
);
const canGenerate = title.trim().length > 0;
const writeMusic = (music: CreationAudioAsset) => {
@@ -1708,12 +1753,17 @@ function PuzzleMusicTab({
</span>
) : null}
</div>
{currentMusic?.audioSrc ? (
{currentMusic?.audioSrc && resolvedMusicSrc ? (
<audio
className="mt-3 w-full"
controls
src={currentMusic.audioSrc}
src={resolvedMusicSrc}
aria-label="拼图背景音乐"
/>
) : currentMusic?.audioSrc ? (
<div className="mt-3 rounded-[0.9rem] border border-[var(--platform-subpanel-border)] bg-white/62 px-3 py-3 text-sm font-semibold text-[var(--platform-text-soft)]">
</div>
) : (
<div className="mt-3 flex h-12 items-center gap-2 rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/62 px-3 text-sm font-semibold text-[var(--platform-text-soft)]">
<Music className="h-4 w-4" />
@@ -1758,7 +1808,7 @@ function PuzzleMusicTab({
) : (
<Music className="h-4 w-4" />
)}
{currentMusic ? '重新生成音乐' : '生成音乐'}
{currentMusic ? '重新生成音乐' : '生成音乐'} · {PUZZLE_BACKGROUND_MUSIC_POINT_COST}
</button>
</section>
@@ -1771,22 +1821,75 @@ function PuzzleMusicTab({
);
}
function PuzzleAssetConfigTab({
activeAssetConfigTab,
editState,
imageRefreshKey,
isBusy,
profileId,
sessionId,
onAssetConfigTabChange,
onChange,
onGenerateUiBackground,
}: {
activeAssetConfigTab: PuzzleAssetConfigTabId;
editState: DraftEditState;
imageRefreshKey: string;
isBusy: boolean;
profileId: string | null;
sessionId: string;
onAssetConfigTabChange: (tab: PuzzleAssetConfigTabId) => void;
onChange: (nextState: DraftEditState) => void;
onGenerateUiBackground: (prompt: string) => void;
}) {
return (
<div className="min-h-0">
<PuzzleAssetConfigTabs
activeTab={activeAssetConfigTab}
onChange={onAssetConfigTabChange}
/>
{activeAssetConfigTab === 'ui' ? (
<PuzzleUiAssetsTab
editState={editState}
imageRefreshKey={imageRefreshKey}
isBusy={isBusy}
onChange={onChange}
onGenerate={onGenerateUiBackground}
/>
) : null}
{activeAssetConfigTab === 'music' ? (
<PuzzleMusicTab
editState={editState}
profileId={profileId}
sessionId={sessionId}
isBusy={isBusy}
onChange={onChange}
/>
) : null}
</div>
);
}
function PuzzleResultActionBar({
actionError,
editState,
imageRefreshKey,
isBusy,
canStartTestRun,
publishReady,
publishBlockers,
onPublish,
onStartTestRun,
}: {
actionError: string | null;
editState: DraftEditState;
imageRefreshKey: string;
isBusy: boolean;
canStartTestRun: boolean;
publishReady: boolean;
publishBlockers: string[];
onPublish: () => void;
onStartTestRun?: () => void;
}) {
const [showPublishDialog, setShowPublishDialog] = useState(false);
const [hasAttemptedPublish, setHasAttemptedPublish] = useState(false);
@@ -1798,6 +1901,19 @@ function PuzzleResultActionBar({
return (
<div className="mt-4 flex items-center justify-end gap-3 pb-[max(0.25rem,env(safe-area-inset-bottom))]">
{onStartTestRun ? (
<button
type="button"
onClick={onStartTestRun}
disabled={isBusy || !canStartTestRun}
className={`platform-button platform-button--ghost ${isBusy || !canStartTestRun ? 'cursor-not-allowed opacity-55' : ''}`}
>
<span className="inline-flex items-center gap-2">
<Play className="h-4 w-4" />
</span>
</button>
) : null}
<button
type="button"
onClick={() => {
@@ -1844,6 +1960,8 @@ export function PuzzleResultView({
}: PuzzleResultViewProps) {
const draft = session.draft;
const [activeTab, setActiveTab] = useState<PuzzleResultTab>('levels');
const [activeAssetConfigTab, setActiveAssetConfigTab] =
useState<PuzzleAssetConfigTabId>('ui');
const [activeLevelId, setActiveLevelId] = useState<string | null>(null);
const [editState, setEditState] = useState<DraftEditState | null>(
draft ? createDraftEditState(draft) : null,
@@ -2093,6 +2211,7 @@ export function PuzzleResultView({
generationStatus: level.generationStatus,
levels: [level],
});
const canStartTestRun = Boolean(onStartTestRun && primaryImageSrc);
return (
<div className="platform-remap-surface mx-auto flex h-full min-h-0 w-full flex-col xl:max-w-[min(100%,98rem)] xl:px-1 2xl:max-w-[min(100%,112rem)]">
@@ -2174,13 +2293,17 @@ export function PuzzleResultView({
}}
/>
) : null}
{activeTab === 'ui' ? (
<PuzzleUiAssetsTab
{activeTab === 'assets' ? (
<PuzzleAssetConfigTab
activeAssetConfigTab={activeAssetConfigTab}
editState={editState}
imageRefreshKey={imageRefreshKey}
isBusy={isBusy}
profileId={profileId ?? null}
sessionId={session.sessionId}
onAssetConfigTabChange={setActiveAssetConfigTab}
onChange={setEditState}
onGenerate={(prompt) => {
onGenerateUiBackground={(prompt) => {
const firstLevel = editState.levels[0] ?? null;
if (!firstLevel) {
return;
@@ -2207,15 +2330,6 @@ export function PuzzleResultView({
}}
/>
) : null}
{activeTab === 'music' ? (
<PuzzleMusicTab
editState={editState}
profileId={profileId ?? null}
sessionId={session.sessionId}
isBusy={isBusy}
onChange={setEditState}
/>
) : null}
</div>
{error ? (
@@ -2234,8 +2348,14 @@ export function PuzzleResultView({
editState={editState}
imageRefreshKey={imageRefreshKey}
isBusy={isBusy}
canStartTestRun={canStartTestRun}
publishReady={publishState.publishReady}
publishBlockers={publishState.blockers}
onStartTestRun={
onStartTestRun
? () => onStartTestRun(syncedDraft)
: undefined
}
onPublish={() => {
if (!publishState.publishReady) {
return;