This commit is contained in:
2026-05-10 22:20:54 +08:00
parent d6219f1a0c
commit 192accd796
92 changed files with 7045 additions and 1559 deletions
@@ -0,0 +1,170 @@
/* @vitest-environment jsdom */
import { fireEvent, render, screen } from '@testing-library/react';
import { expect, test, vi } from 'vitest';
import type { Match3DAgentSessionSnapshot } from '../../../packages/shared/src/contracts/match3dAgent';
import { Match3DAgentWorkspace } from './Match3DAgentWorkspace';
const baseSession: Match3DAgentSessionSnapshot = {
sessionId: 'match3d-session-1',
currentTurn: 0,
progressPercent: 0,
stage: 'collecting_config',
anchorPack: {
theme: {
key: 'theme',
label: '题材主题',
value: '水果摊',
status: 'confirmed',
},
clearCount: {
key: 'clearCount',
label: '需要消除次数',
value: '8',
status: 'confirmed',
},
difficulty: {
key: 'difficulty',
label: '难度',
value: '3',
status: 'confirmed',
},
},
config: {
themeText: '水果摊',
referenceImageSrc: null,
clearCount: 8,
difficulty: 3,
assetStyleId: 'low-poly',
assetStyleLabel: '低多边形',
assetStylePrompt:
'块面清晰、轮廓简洁、颜色分区明确的低多边形 3D 素材风格。',
},
draft: null,
messages: [
{
id: 'message-1',
role: 'assistant',
kind: 'chat',
text: '旧会话固定追问不再作为主入口。',
createdAt: '2026-05-10T10:00:00.000Z',
},
],
lastAssistantReply: '旧会话固定追问不再作为主入口。',
publishedProfileId: null,
updatedAt: '2026-05-10T10:00:00.000Z',
};
test('match3d workspace submits derived entry form payload instead of agent chat', () => {
const onCreateFromForm = vi.fn();
const onExecuteAction = vi.fn();
render(
<Match3DAgentWorkspace
session={null}
onBack={() => {}}
onExecuteAction={onExecuteAction}
onCreateFromForm={onCreateFromForm}
/>,
);
expect(screen.getByText('想做个什么玩法?')).toBeTruthy();
expect(screen.getByLabelText('想做一个什么题材的抓大鹅?')).toBeTruthy();
expect(screen.getByText('3D素材风格')).toBeTruthy();
expect(screen.getByRole('button', { name: '黏土手作' })).toBeTruthy();
expect(screen.getByRole('button', { name: '自定义' })).toBeTruthy();
expect(screen.getByText('消耗20光点')).toBeTruthy();
expect(screen.queryByText('参考图')).toBeNull();
expect(screen.queryByLabelText('上传抓大鹅参考图')).toBeNull();
expect(screen.queryByLabelText('需要消除次数')).toBeNull();
expect(screen.queryByLabelText('难度数值')).toBeNull();
expect(screen.queryByText('物品')).toBeNull();
expect(screen.queryByText('旧会话固定追问不再作为主入口。')).toBeNull();
fireEvent.change(screen.getByLabelText('想做一个什么题材的抓大鹅?'), {
target: { value: '赛博水果摊' },
});
fireEvent.click(screen.getByRole('button', { name: '进阶' }));
fireEvent.click(screen.getByRole('button', { name: /稿/u }));
expect(onCreateFromForm).toHaveBeenCalledWith({
seedText: '赛博水果摊题材,消除16次,难度6',
themeText: '赛博水果摊',
referenceImageSrc: null,
clearCount: 16,
difficulty: 6,
assetStyleId: 'clay-toy',
assetStyleLabel: '黏土手作',
assetStylePrompt: '圆润、哑光、带轻微手捏痕迹的黏土手作 3D 素材风格。',
});
expect(onExecuteAction).not.toHaveBeenCalled();
});
test('match3d workspace supports custom 3d asset style prompt', () => {
const onCreateFromForm = vi.fn();
render(
<Match3DAgentWorkspace
session={null}
onBack={() => {}}
onExecuteAction={() => {}}
onCreateFromForm={onCreateFromForm}
/>,
);
fireEvent.change(screen.getByLabelText('想做一个什么题材的抓大鹅?'), {
target: { value: '海底甜品店' },
});
fireEvent.click(screen.getByRole('button', { name: '自定义' }));
expect(screen.getByRole('dialog', { name: '自定义风格' })).toBeTruthy();
fireEvent.change(screen.getByLabelText('自定义3D素材风格描述'), {
target: { value: '透明果冻材质,边缘有柔和蓝色荧光' },
});
fireEvent.click(screen.getByRole('button', { name: '应用' }));
fireEvent.click(screen.getByRole('button', { name: /稿/u }));
expect(onCreateFromForm).toHaveBeenCalledWith(
expect.objectContaining({
seedText: '海底甜品店题材,消除12次,难度4',
themeText: '海底甜品店',
clearCount: 12,
difficulty: 4,
assetStyleId: 'custom',
assetStyleLabel: '自定义风格',
assetStylePrompt: '透明果冻材质,边缘有柔和蓝色荧光',
}),
);
});
test('match3d workspace falls back to compile action when restored from the legacy route', () => {
const onExecuteAction = vi.fn();
render(
<Match3DAgentWorkspace
session={baseSession}
onBack={() => {}}
onExecuteAction={onExecuteAction}
/>,
);
expect(
(screen.getByLabelText('想做一个什么题材的抓大鹅?') as HTMLTextAreaElement)
.value,
).toBe('水果摊');
expect(
screen.getByRole('button', { name: '轻松' }).getAttribute('aria-pressed'),
).toBe('true');
expect(
screen.getByRole('button', { name: '低多边形' }).getAttribute(
'aria-pressed',
),
).toBe('true');
fireEvent.click(screen.getByRole('button', { name: /稿/u }));
expect(onExecuteAction).toHaveBeenCalledWith({
action: 'match3d_compile_draft',
});
});
File diff suppressed because it is too large Load Diff
@@ -4,6 +4,8 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, describe, expect, test, vi } from 'vitest';
import type { Match3DWorkProfile } from '../../../packages/shared/src/contracts/match3dWorks';
import * as assetReadUrlService from '../../services/assetReadUrlService';
import * as hyper3dService from '../../services/hyper3dModelGenerationService';
import * as match3dWorksService from '../../services/match3d-works';
import { Match3DResultView } from './Match3DResultView';
@@ -19,13 +21,44 @@ vi.mock('../ResolvedAssetImage', () => ({
}) => (src ? <img src={src} alt={alt} className={className} /> : null),
}));
vi.mock('../../hooks/useResolvedAssetReadUrl', () => ({
useResolvedAssetReadUrl: (src?: string | null) => ({
resolvedUrl: src?.startsWith('/generated-')
? `https://signed.example.com${src}`
: (src ?? ''),
isResolving: false,
shouldResolve: Boolean(src?.startsWith('/generated-')),
}),
}));
vi.mock('../../services/assetReadUrlService', () => ({
readAssetBytes: vi.fn(() =>
Promise.resolve(
new Response(new Uint8Array([104, 101, 108, 108, 111]), {
status: 200,
headers: {
'Content-Type': 'image/png',
},
}),
),
),
}));
vi.mock('../../services/match3d-works', () => ({
publishMatch3DWork: vi.fn(),
updateMatch3DWork: vi.fn(),
}));
vi.mock('../../services/hyper3dModelGenerationService', () => ({
getHyper3dDownloads: vi.fn(),
getHyper3dTaskStatus: vi.fn(),
submitHyper3dImageToModel: vi.fn(),
submitHyper3dTextToModel: vi.fn(),
}));
afterEach(() => {
vi.clearAllMocks();
vi.unstubAllGlobals();
});
function createProfile(
@@ -100,4 +133,265 @@ describe('Match3DResultView', () => {
fireEvent.click(publishButton);
expect(match3dWorksService.publishMatch3DWork).not.toHaveBeenCalled();
});
test('结果页提供多 Tab,并能进入 Rodin 3D 素材详情', () => {
render(
<Match3DResultView
profile={createProfile({ themeText: '水果' })}
onBack={() => {}}
onStartTestRun={() => {}}
/>,
);
expect(screen.getByRole('button', { name: '作品信息' })).toBeTruthy();
expect(screen.getByRole('button', { name: '玩法配置' })).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '3D素材' }));
fireEvent.click(screen.getByRole('button', { name: //u }));
expect(screen.getByText('素材名称')).toBeTruthy();
expect(screen.getByRole('button', { name: '文生模型' })).toBeTruthy();
expect(screen.getByRole('button', { name: '图生模型' })).toBeTruthy();
});
test('Rodin 文生模型提交使用 Hyper3D 代理', async () => {
vi.mocked(hyper3dService.submitHyper3dTextToModel).mockResolvedValue({
ok: true,
provider: 'hyper3d-rodin',
mode: 'text-to-model',
taskUuid: 'task-1',
subscriptionKey: 'sub-1',
jobUuids: ['job-1'],
message: 'submitted',
tier: 'Gen-2',
});
render(
<Match3DResultView
profile={createProfile({ themeText: '水果' })}
onBack={() => {}}
onStartTestRun={() => {}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '3D素材' }));
fireEvent.click(screen.getByRole('button', { name: //u }));
fireEvent.click(screen.getByRole('button', { name: '生成' }));
await waitFor(() => {
expect(hyper3dService.submitHyper3dTextToModel).toHaveBeenCalledWith(
expect.objectContaining({
geometryFileFormat: 'glb',
material: 'PBR',
meshMode: 'Quad',
prompt: expect.stringContaining('水果核心物件'),
}),
);
});
await waitFor(() => {
expect(screen.getAllByText('排队中').length).toBeGreaterThan(0);
});
});
test('Rodin 图生模型没有参考图时阻止提交', async () => {
render(
<Match3DResultView
profile={createProfile({ themeText: '水果' })}
onBack={() => {}}
onStartTestRun={() => {}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '3D素材' }));
fireEvent.click(screen.getByRole('button', { name: //u }));
fireEvent.click(screen.getByRole('button', { name: '图生模型' }));
const generateButton = screen.getByRole('button', { name: '生成' });
expect(generateButton).toHaveProperty('disabled', true);
expect(hyper3dService.submitHyper3dImageToModel).not.toHaveBeenCalled();
});
test('结果页优先预览生成出来的物品图片和模型文件', () => {
render(
<Match3DResultView
profile={createProfile({
clearCount: 3,
generatedItemAssets: [
{
itemId: 'match3d-item-1',
itemName: '草莓',
imageSrc: '/generated-match3d-assets/session/profile/items/strawberry/image.png',
imageObjectKey:
'generated-match3d-assets/session/profile/items/strawberry/image.png',
modelSrc: '/generated-match3d-assets/session/profile/items/strawberry/model/model.glb',
modelObjectKey:
'generated-match3d-assets/session/profile/items/strawberry/model/model.glb',
modelFileName: 'strawberry.glb',
taskUuid: 'task-strawberry',
subscriptionKey: 'sub-strawberry',
status: 'model_ready',
error: null,
},
],
})}
onBack={() => {}}
onStartTestRun={() => {}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '3D素材' }));
fireEvent.click(screen.getByRole('button', { name: //u }));
expect(screen.getByDisplayValue('草莓')).toBeTruthy();
expect(screen.getAllByText('已完成').length).toBeGreaterThan(0);
const modelLink = screen.getByRole('link', { name: /strawberry\.glb/u });
expect(modelLink.getAttribute('href')).toBe(
'https://signed.example.com/generated-match3d-assets/session/profile/items/strawberry/model/model.glb',
);
});
test('草稿阶段仅有切割图片时展示图片已就绪,不要求模型文件', () => {
render(
<Match3DResultView
profile={createProfile({
clearCount: 3,
generatedItemAssets: [
{
itemId: 'match3d-item-1',
itemName: '草莓',
imageSrc: '/generated-match3d-assets/session/profile/items/strawberry/image.png',
imageObjectKey:
'generated-match3d-assets/session/profile/items/strawberry/image.png',
modelSrc: null,
modelObjectKey: null,
modelFileName: null,
taskUuid: null,
subscriptionKey: null,
status: 'image_ready',
error: null,
},
],
})}
onBack={() => {}}
onStartTestRun={() => {}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '3D素材' }));
fireEvent.click(screen.getByRole('button', { name: //u }));
expect(screen.getByDisplayValue('草莓')).toBeTruthy();
expect(screen.getAllByText('图片已就绪').length).toBeGreaterThan(0);
expect(screen.getByText('0 文件')).toBeTruthy();
expect(screen.queryByRole('link', { name: /\.glb/u })).toBeNull();
});
test('重进草稿页时从持久化 profile 素材恢复 3D 素材列表', () => {
render(
<Match3DResultView
profile={createProfile({
clearCount: 3,
generatedItemAssets: [
{
itemId: 'match3d-item-1',
itemName: '草莓',
imageSrc:
'/generated-match3d-assets/session/profile/items/match3d-item-1-item/image.png',
imageObjectKey:
'generated-match3d-assets/session/profile/items/match3d-item-1-item/image.png',
modelSrc: null,
modelObjectKey: null,
modelFileName: null,
taskUuid: null,
subscriptionKey: null,
status: 'image_ready',
error: null,
},
{
itemId: 'match3d-item-2',
itemName: '苹果',
imageSrc:
'/generated-match3d-assets/session/profile/items/match3d-item-2-item/image.png',
imageObjectKey:
'generated-match3d-assets/session/profile/items/match3d-item-2-item/image.png',
modelSrc: null,
modelObjectKey: null,
modelFileName: null,
taskUuid: null,
subscriptionKey: null,
status: 'image_ready',
error: null,
},
],
})}
draft={null}
onBack={() => {}}
onStartTestRun={() => {}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '3D素材' }));
expect(screen.getByRole('button', { name: //u })).toBeTruthy();
expect(screen.getByRole('button', { name: //u })).toBeTruthy();
expect(screen.queryByRole('button', { name: //u })).toBeNull();
});
test('Rodin 图生模型提交前会把 generated 参考图转成 data URL', async () => {
vi.mocked(hyper3dService.submitHyper3dImageToModel).mockResolvedValue({
ok: true,
provider: 'hyper3d-rodin',
mode: 'image-to-model',
taskUuid: 'task-image',
subscriptionKey: 'sub-image',
jobUuids: ['job-image'],
message: 'submitted',
tier: 'Gen-2',
});
vi.stubGlobal('fetch', vi.fn());
render(
<Match3DResultView
profile={createProfile({
clearCount: 3,
generatedItemAssets: [
{
itemId: 'match3d-item-1',
itemName: '草莓',
imageSrc: '/generated-match3d-assets/session/profile/items/match3d-item-1-item/image.png',
imageObjectKey:
'generated-match3d-assets/session/profile/items/match3d-item-1-item/image.png',
modelSrc: null,
modelObjectKey: null,
modelFileName: null,
taskUuid: null,
subscriptionKey: null,
status: 'image_ready',
error: null,
},
],
})}
onBack={() => {}}
onStartTestRun={() => {}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '3D素材' }));
fireEvent.click(screen.getByRole('button', { name: //u }));
fireEvent.click(screen.getByRole('button', { name: '生成' }));
await waitFor(() => {
expect(assetReadUrlService.readAssetBytes).toHaveBeenCalledWith(
'/generated-match3d-assets/session/profile/items/match3d-item-1-item/image.png',
expect.objectContaining({ expireSeconds: 300 }),
);
expect(globalThis.fetch).not.toHaveBeenCalled();
expect(hyper3dService.submitHyper3dImageToModel).toHaveBeenCalledWith(
expect.objectContaining({
imageDataUrls: ['data:image/png;base64,aGVsbG8='],
prompt: expect.stringContaining('草莓'),
}),
);
});
});
});
File diff suppressed because it is too large Load Diff
@@ -1219,6 +1219,17 @@ function disposeTrayPreview(runtime: TrayPreviewRuntime | null) {
runtime.renderer.domElement.remove();
}
export function applyMatch3DRendererCanvasLayout(
canvas: HTMLCanvasElement,
) {
// 中文注释:WebGL 绘图缓冲区会乘设备 DPR,CSS 尺寸必须单独锁住,否则手机端画布会放大溢出。
canvas.style.display = 'block';
canvas.style.height = '100%';
canvas.style.inset = '0';
canvas.style.position = 'absolute';
canvas.style.width = '100%';
}
function positionTrayPreviewObject(
runtime: TrayPreviewRuntime,
object: ThreeObject3D,
@@ -1280,11 +1291,7 @@ export function Match3DTrayPreviewBoard({
});
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 1.8));
renderer.outputColorSpace = three.SRGBColorSpace;
renderer.domElement.style.display = 'block';
renderer.domElement.style.height = '100%';
renderer.domElement.style.inset = '0';
renderer.domElement.style.position = 'absolute';
renderer.domElement.style.width = '100%';
applyMatch3DRendererCanvasLayout(renderer.domElement);
container.appendChild(renderer.domElement);
const handleContextLost = (event: Event) => {
event.preventDefault();
@@ -1529,6 +1536,7 @@ export function Match3DPhysicsBoard({
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 1.8));
renderer.shadowMap.enabled = true;
renderer.outputColorSpace = three.SRGBColorSpace;
applyMatch3DRendererCanvasLayout(renderer.domElement);
container.appendChild(renderer.domElement);
const handleContextLost = (event: Event) => {
event.preventDefault();
@@ -20,6 +20,7 @@ import {
MATCH3D_EXTRUDED_READABLE_SHAPES,
MATCH3D_TRAY_MODEL_MIN_RELATIVE_SIZE,
MATCH3D_TRAY_MODEL_TARGET_SIZE,
applyMatch3DRendererCanvasLayout,
buildMatch3DPhysicsEntrySignature,
createMatch3DCannonShape,
createMatch3DThreeGeometry,
@@ -190,6 +191,18 @@ test('3D 模式下备选栏使用共享模型预览层,避免挤占中心棋
expect(screen.getByTestId('match3d-tray-model-board')).toBeTruthy();
});
test('3D WebGL 画布锁定 CSS 尺寸,避免高 DPR 手机上溢出中心棋盘', () => {
const canvas = document.createElement('canvas');
applyMatch3DRendererCanvasLayout(canvas);
expect(canvas.style.position).toBe('absolute');
expect(canvas.style.inset).toBe('0');
expect(canvas.style.width).toBe('100%');
expect(canvas.style.height).toBe('100%');
expect(canvas.style.display).toBe('block');
});
test('3D 物理条目签名随 run 和视觉资源变化,避免旧模型复用到新局', () => {
const run = startLocalMatch3DRun(10);
const item = run.items[0]!;
File diff suppressed because it is too large Load Diff
@@ -24,6 +24,7 @@ export type SelectionStage =
| 'big-fish-result'
| 'big-fish-runtime'
| 'match3d-agent-workspace'
| 'match3d-generating'
| 'match3d-result'
| 'match3d-runtime'
| 'square-hole-agent-workspace'
@@ -32,6 +33,7 @@ export type SelectionStage =
| 'square-hole-runtime'
| 'creative-agent-workspace'
| 'visual-novel-agent-workspace'
| 'visual-novel-generating'
| 'visual-novel-result'
| 'visual-novel-gallery-detail'
| 'visual-novel-runtime'
@@ -1,11 +1,37 @@
/* @vitest-environment jsdom */
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import {
act,
fireEvent,
render,
screen,
waitFor,
within,
} from '@testing-library/react';
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
import type { PuzzleAgentSessionSnapshot } from '../../../packages/shared/src/contracts/puzzleAgentSession';
import { puzzleAssetClient } from '../../services/puzzle-works/puzzleAssetClient';
import { PuzzleAgentWorkspace } from './PuzzleAgentWorkspace';
vi.mock('../ResolvedAssetImage', () => ({
ResolvedAssetImage: ({
src,
alt,
className,
}: {
src?: string | null;
alt?: string;
className?: string;
}) => (src ? <img src={src} alt={alt} className={className} /> : null),
}));
vi.mock('../../services/puzzle-works/puzzleAssetClient', () => ({
puzzleAssetClient: {
listHistoryAssets: vi.fn(),
},
}));
const baseSession: PuzzleAgentSessionSnapshot = {
sessionId: 'puzzle-session-1',
currentTurn: 3,
@@ -70,6 +96,7 @@ afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
vi.restoreAllMocks();
vi.clearAllMocks();
});
function stubReferenceImageUpload(dataUrl: string, width = 512, height = 512) {
@@ -221,6 +248,66 @@ test('puzzle workspace keeps the reference image upload as a primary panel', ()
);
});
test('puzzle workspace selects a history image from the upload card', async () => {
const onCreateFromForm = vi.fn();
vi.mocked(puzzleAssetClient.listHistoryAssets).mockResolvedValue([
{
assetObjectId: 'asset-history-1',
assetKind: 'puzzle_cover_image',
imageSrc: '/generated-puzzle-assets/history/image.png',
ownerUserId: 'user-1',
ownerLabel: '账号 user-1',
profileId: null,
entityId: 'puzzle-session-1',
createdAt: '2026-04-27T10:00:00.000Z',
updatedAt: '2026-04-27T10:00:00.000Z',
},
]);
render(
<PuzzleAgentWorkspace
session={null}
onBack={() => {}}
onSubmitMessage={() => {}}
onExecuteAction={() => {}}
onCreateFromForm={onCreateFromForm}
/>,
);
const historyButton = screen.getByRole('button', { name: '选择历史图片' });
expect(historyButton.closest('.puzzle-image-upload-card')).toBeTruthy();
expect(screen.getByText('历史').closest('.puzzle-image-upload-card')).toBeTruthy();
fireEvent.click(historyButton);
const picker = await screen.findByRole('dialog', {
name: '选择历史图片',
});
fireEvent.click(
await within(picker).findByRole('button', { name: / user-1/u }),
);
await waitFor(() => {
expect(screen.queryByRole('dialog', { name: '选择历史图片' })).toBeNull();
});
expect(screen.getByAltText('拼图图片')).toHaveProperty(
'src',
expect.stringContaining('/generated-puzzle-assets/history/image.png'),
);
fireEvent.change(screen.getByLabelText('画面AI重绘要求(提示词)'), {
target: { value: '保留历史图里的主体,改成晴天花园。' },
});
fireEvent.click(screen.getByRole('button', { name: /稿/u }));
expect(onCreateFromForm).toHaveBeenCalledWith({
seedText: '保留历史图里的主体,改成晴天花园。',
pictureDescription: '保留历史图里的主体,改成晴天花园。',
referenceImageSrc: '/generated-puzzle-assets/history/image.png',
imageModel: 'gpt-image-2',
aiRedraw: true,
});
});
test('puzzle upload card stays light in light theme', () => {
const onCreateFromForm = vi.fn();
const { container } = render(
@@ -237,6 +324,9 @@ test('puzzle upload card stays light in light theme', () => {
const uploadLabel = screen.getByText('点击上传拼图图片');
expect(uploadLabel).toBeTruthy();
expect(uploadLabel.closest('.puzzle-image-upload-card')).toBeTruthy();
expect(uploadLabel.className).not.toContain('rounded-full');
expect(uploadLabel.className).not.toContain('bg-white/94');
expect(uploadLabel.className).not.toContain('border');
expect(screen.queryByText('AI重绘')).toBeNull();
expect(container.querySelector('.puzzle-image-upload-card')?.className).toContain(
'bg-white/90',
@@ -1,4 +1,11 @@
import { ArrowLeft, ImagePlus, Loader2, Sparkles, Trash2 } from 'lucide-react';
import {
ArrowLeft,
History,
ImagePlus,
Loader2,
Sparkles,
Trash2,
} from 'lucide-react';
import {
type ChangeEvent,
type CSSProperties,
@@ -20,6 +27,7 @@ import {
isPuzzleReferenceImageSquare,
readPuzzleReferenceImageForUpload,
} from '../../services/puzzleReferenceImage';
import PuzzleHistoryAssetPickerDialog from './PuzzleHistoryAssetPickerDialog';
import {
normalizePuzzleImageModel,
PUZZLE_IMAGE_MODEL_GPT_IMAGE_2,
@@ -535,6 +543,7 @@ export function PuzzleAgentWorkspace({
null,
);
const [cropState, setCropState] = useState<PuzzleImageCropState | null>(null);
const [isHistoryPickerOpen, setIsHistoryPickerOpen] = useState(false);
const [isRemoveImageConfirmOpen, setIsRemoveImageConfirmOpen] =
useState(false);
const previousSessionIdRef = useRef<string | null>(
@@ -565,6 +574,7 @@ export function PuzzleAgentWorkspace({
setFormState(resolveInitialFormState(session, initialFormPayload));
setReferenceImageError(null);
setCropState(null);
setIsHistoryPickerOpen(false);
setIsRemoveImageConfirmOpen(false);
}, [initialFormPayload, session]);
@@ -865,6 +875,17 @@ export function PuzzleAgentWorkspace({
</span>
)}
<div className="absolute inset-0 z-[1] bg-[linear-gradient(180deg,rgba(255,255,255,0.12)_0%,rgba(255,255,255,0.04)_42%,rgba(255,255,255,0.18)_100%)] pointer-events-none" />
<button
type="button"
disabled={isBusy}
onClick={() => setIsHistoryPickerOpen(true)}
className={`absolute bottom-3 right-3 z-10 inline-flex items-center gap-1.5 rounded-full border border-white/80 bg-white/94 px-3 py-2 text-[11px] font-black text-[var(--platform-text-strong)] shadow-sm backdrop-blur transition hover:text-[#ff4056] ${isBusy ? 'cursor-not-allowed opacity-55' : ''}`}
aria-label="选择历史图片"
title="选择历史图片"
>
<History className="h-3.5 w-3.5" />
<span></span>
</button>
{formState.referenceImageSrc ? (
<label className="absolute bottom-3 left-3 z-10 inline-flex cursor-pointer items-center gap-2 rounded-full border border-white/80 bg-white/94 px-3 py-2 text-xs font-black text-[var(--platform-text-strong)] shadow-sm backdrop-blur">
<span>AI重绘</span>
@@ -910,7 +931,7 @@ export function PuzzleAgentWorkspace({
) : (
<label
htmlFor="puzzle-image-upload-input"
className={`absolute bottom-3 left-1/2 z-10 inline-flex min-h-10 -translate-x-1/2 items-center justify-center whitespace-nowrap rounded-full border border-white/80 bg-white/94 px-4 text-sm font-black text-[var(--platform-text-strong)] shadow-sm backdrop-blur transition hover:text-[#ff4056] ${isBusy ? 'cursor-not-allowed' : 'cursor-pointer'}`}
className={`absolute bottom-9 left-1/2 z-10 -translate-x-1/2 whitespace-nowrap text-center text-sm font-black text-[var(--platform-text-strong)] drop-shadow-[0_1px_0_rgba(255,255,255,0.82)] transition hover:text-[#ff4056] sm:bottom-10 ${isBusy ? 'cursor-not-allowed opacity-55' : 'cursor-pointer'}`}
>
</label>
@@ -1003,6 +1024,22 @@ export function PuzzleAgentWorkspace({
}}
/>
) : null}
{isHistoryPickerOpen ? (
<PuzzleHistoryAssetPickerDialog
isBusy={isBusy}
onClose={() => setIsHistoryPickerOpen(false)}
onSelect={(asset) => {
setFormState((current) => ({
...current,
referenceImageSrc: asset.imageSrc,
referenceImageLabel: `历史素材 · ${asset.ownerLabel || '未记录账号'}`,
}));
setReferenceImageError(null);
setIsHistoryPickerOpen(false);
setIsRemoveImageConfirmOpen(false);
}}
/>
) : null}
{isRemoveImageConfirmOpen ? (
<div className="platform-modal-backdrop fixed inset-0 z-[80] flex items-center justify-center px-4 py-6">
<div
@@ -0,0 +1,164 @@
import { X } from 'lucide-react';
import { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import {
puzzleAssetClient,
type PuzzleHistoryAsset,
} from '../../services/puzzle-works/puzzleAssetClient';
import { useAuthUi } from '../auth/AuthUiContext';
import { ResolvedAssetImage } from '../ResolvedAssetImage';
type PuzzleHistoryAssetPickerDialogProps = {
isBusy: boolean;
onClose: () => void;
onSelect: (asset: PuzzleHistoryAsset) => void;
};
function formatHistoryAssetDate(value: string) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return '未知时间';
}
return new Intl.DateTimeFormat('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false,
}).format(date);
}
export function PuzzleHistoryAssetPickerDialog({
isBusy,
onClose,
onSelect,
}: PuzzleHistoryAssetPickerDialogProps) {
const platformTheme = useAuthUi()?.platformTheme ?? 'light';
const [assets, setAssets] = useState<PuzzleHistoryAsset[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
setIsLoading(true);
setError(null);
puzzleAssetClient
.listHistoryAssets({ limit: 120 })
.then((nextAssets) => {
if (!cancelled) {
setAssets(nextAssets);
}
})
.catch((loadError) => {
if (!cancelled) {
setError(
loadError instanceof Error
? loadError.message
: '历史拼图素材读取失败。',
);
}
})
.finally(() => {
if (!cancelled) {
setIsLoading(false);
}
});
return () => {
cancelled = true;
};
}, []);
if (typeof document === 'undefined') {
return null;
}
return createPortal(
<div
className={`platform-theme platform-theme--${platformTheme} platform-overlay fixed inset-0 z-[145] flex items-end justify-center p-3 backdrop-blur-sm sm:items-center sm:p-4`}
onClick={(event) => {
if (event.target === event.currentTarget) {
onClose();
}
}}
>
<div
role="dialog"
aria-modal="true"
aria-label="选择历史图片"
className="platform-modal-shell platform-remap-surface flex max-h-[min(92vh,46rem)] w-full max-w-5xl flex-col overflow-hidden rounded-t-[1.75rem] shadow-[0_24px_80px_rgba(0,0,0,0.55)] sm:rounded-[1.75rem]"
onClick={(event) => event.stopPropagation()}
>
<div className="flex items-center justify-between gap-3 border-b border-[var(--platform-subpanel-border)] px-5 py-4">
<div className="text-base font-semibold text-[var(--platform-text-strong)]">
</div>
<button
type="button"
onClick={onClose}
aria-label="关闭"
className="platform-icon-button"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-4">
{error ? (
<div className="platform-banner platform-banner--danger text-sm leading-6">
{error}
</div>
) : null}
{isLoading ? (
<div className="flex min-h-[14rem] items-center justify-center rounded-[1.35rem] border border-dashed border-[var(--platform-subpanel-border)] bg-white/52 px-6 text-center text-sm text-[var(--platform-text-base)]">
...
</div>
) : null}
{!isLoading && !error && assets.length <= 0 ? (
<div className="flex min-h-[14rem] items-center justify-center rounded-[1.35rem] border border-dashed border-[var(--platform-subpanel-border)] bg-white/52 px-6 text-center text-sm text-[var(--platform-text-base)]">
</div>
) : null}
{!isLoading && assets.length > 0 ? (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
{assets.map((asset) => (
<button
key={asset.assetObjectId}
type="button"
disabled={isBusy}
onClick={() => onSelect(asset)}
className={`overflow-hidden rounded-[1.25rem] border bg-white/82 text-left transition hover:border-amber-300/70 hover:bg-white ${isBusy ? 'cursor-not-allowed opacity-55' : 'border-[var(--platform-subpanel-border)]'}`}
>
<div className="aspect-square overflow-hidden bg-[var(--platform-subpanel-fill)]">
<ResolvedAssetImage
src={asset.imageSrc}
alt={asset.ownerLabel || '历史拼图素材'}
className="h-full w-full object-cover"
/>
</div>
<div className="space-y-1 px-4 py-4">
<div className="truncate text-sm font-black text-[var(--platform-text-strong)]">
{asset.ownerLabel || '未记录账号'}
</div>
<div className="text-xs leading-5 text-[var(--platform-text-base)]">
{formatHistoryAssetDate(asset.createdAt)}
</div>
</div>
</button>
))}
</div>
) : null}
</div>
</div>
</div>,
document.body,
);
}
export default PuzzleHistoryAssetPickerDialog;
@@ -249,6 +249,7 @@ describe('PuzzleResultView', () => {
promptText: '一只猫在雨夜灯牌下回头。',
referenceImageSrc: undefined,
imageModel: 'gpt-image-2',
aiRedraw: true,
candidateCount: 1,
workTitle: '暖灯猫街作品',
workDescription: '一套雨夜猫街主题拼图。',
@@ -263,8 +264,15 @@ describe('PuzzleResultView', () => {
levelId: 'puzzle-level-1',
levelName: '暖灯猫街',
pictureDescription: '一只猫在雨夜灯牌下回头。',
generationStatus: 'generating',
}),
]);
expect(within(dialog).getByText('预计剩余 90 秒')).toBeTruthy();
expect(
within(dialog).queryByPlaceholderText('参考图链接或资产ID'),
).toBeNull();
const levelList = screen.getByLabelText('拼图关卡列表');
expect(within(levelList).getAllByText('生成中').length).toBeGreaterThan(0);
const levelNameInput = within(dialog).getByLabelText('关卡名称');
const formalImageTitle = within(dialog).getByText('画面图');
@@ -314,7 +322,10 @@ describe('PuzzleResultView', () => {
within(dialog).getByRole('button', { name: //u }),
).toBeTruthy();
expect(within(dialog).getByText('消耗2光点')).toBeTruthy();
expect(within(dialog).queryByText('画面图')).toBeNull();
expect(
within(dialog).getByText('等待时间可以制作更多关卡哦~'),
).toBeTruthy();
expect(within(dialog).getByText('画面图')).toBeTruthy();
expect(
within(dialog).queryByRole('button', { name: //u }),
).toBeNull();
@@ -385,6 +396,7 @@ describe('PuzzleResultView', () => {
promptText: '新关卡里有一座发光钟楼。',
referenceImageSrc: undefined,
imageModel: 'gpt-image-2',
aiRedraw: true,
candidateCount: 1,
workTitle: '暖灯猫街作品',
workDescription: '一套雨夜猫街主题拼图。',
@@ -400,10 +412,90 @@ describe('PuzzleResultView', () => {
levelId: 'puzzle-level-1775000000000-2',
levelName: '',
pictureDescription: '新关卡里有一座发光钟楼。',
generationStatus: 'generating',
}),
]);
});
test('keeps generation progress visible after closing and reopening level dialog', () => {
const onExecuteAction = vi.fn();
render(
<PuzzleResultView
session={createSession()}
onBack={() => {}}
onExecuteAction={onExecuteAction}
/>,
);
fireEvent.click(screen.getByText('雨夜猫街'));
fireEvent.click(screen.getByRole('button', { name: //u }));
fireEvent.click(
within(screen.getByRole('dialog', { name: '确认消耗光点' })).getByRole(
'button',
{ name: '确定' },
),
);
fireEvent.click(screen.getByLabelText('关闭'));
expect(
within(screen.getByLabelText('拼图关卡列表')).getAllByText('生成中')
.length,
).toBeGreaterThan(0);
fireEvent.click(screen.getByText('雨夜猫街'));
const reopenedDialog = screen.getByRole('dialog', { name: '关卡详情' });
expect(
within(reopenedDialog).getByRole('progressbar', { name: '画面生成进度' }),
).toBeTruthy();
expect(within(reopenedDialog).getByText('预计剩余 90 秒')).toBeTruthy();
});
test('allows parallel draft editing while a level image is generating but blocks publish', () => {
const onExecuteAction = vi.fn();
const onStartTestRun = vi.fn();
render(
<PuzzleResultView
session={createSession()}
onBack={() => {}}
onExecuteAction={onExecuteAction}
onStartTestRun={onStartTestRun}
/>,
);
fireEvent.click(screen.getByText('雨夜猫街'));
fireEvent.click(screen.getByRole('button', { name: //u }));
fireEvent.click(
within(screen.getByRole('dialog', { name: '确认消耗光点' })).getByRole(
'button',
{ name: '确定' },
),
);
fireEvent.change(screen.getByLabelText('关卡名称'), {
target: { value: '继续编辑的猫街' },
});
fireEvent.click(screen.getByRole('button', { name: //u }));
expect(onStartTestRun).toHaveBeenCalledWith(
expect.objectContaining({
levelName: '继续编辑的猫街',
}),
);
fireEvent.click(screen.getByLabelText('关闭'));
fireEvent.click(screen.getByRole('button', { name: //u }));
expect(screen.getByRole('dialog', { name: '关卡详情' })).toBeTruthy();
fireEvent.click(screen.getByLabelText('关闭'));
fireEvent.click(screen.getByRole('button', { name: //u }));
const publishDialog = screen.getByRole('dialog', { name: '发布拼图作品' });
expect(within(publishDialog).getByText('还有关卡画面正在生成。')).toBeTruthy();
expect(
within(publishDialog).getByRole('button', { name: '发布到广场' }),
).toHaveProperty('disabled', true);
});
test('publishes with work info and serialized levels', () => {
const onExecuteAction = vi.fn();
@@ -552,19 +644,26 @@ describe('PuzzleResultView', () => {
);
fireEvent.click(screen.getByText('雨夜猫街'));
fireEvent.click(screen.getByLabelText('从历史拼图素材库选择'));
const dialog = screen.getByRole('dialog', { name: '关卡详情' });
const uploadInput = within(dialog).getByLabelText('上传参考图', {
selector: 'input',
});
expect(uploadInput.closest('.platform-subpanel')).toBeTruthy();
const historyButton = within(dialog).getByRole('button', {
name: '选择历史图片',
});
expect(within(historyButton).getByText('历史')).toBeTruthy();
fireEvent.click(historyButton);
const picker = await screen.findByRole('dialog', {
name: '选择历史拼图素材',
name: '选择历史图片',
});
fireEvent.click(
await within(picker).findByRole('button', { name: / user-1/u }),
);
await waitFor(() => {
expect(
screen.queryByRole('dialog', { name: '选择历史拼图素材' }),
).toBeNull();
expect(screen.queryByRole('dialog', { name: '选择历史图片' })).toBeNull();
});
fireEvent.click(screen.getByRole('button', { name: //u }));
@@ -580,6 +679,7 @@ describe('PuzzleResultView', () => {
promptText: '屋檐下的猫与暖灯街角。',
referenceImageSrc: '/generated-puzzle-assets/history/image.png',
imageModel: 'gpt-image-2',
aiRedraw: true,
candidateCount: 1,
workTitle: '暖灯猫街作品',
workDescription: '一套雨夜猫街主题拼图。',
@@ -624,8 +724,10 @@ describe('PuzzleResultView', () => {
expect.objectContaining({
action: 'generate_puzzle_images',
referenceImageSrc: '/generated-puzzle-assets/history/saved-reference.png',
aiRedraw: true,
}),
);
expect(screen.queryByPlaceholderText('参考图链接或资产ID')).toBeNull();
});
test('passes the selected image model when regenerating a level image', () => {
File diff suppressed because it is too large Load Diff
@@ -207,7 +207,8 @@ test('拼图界面在 mocap open_palm 时显示体感光标', () => {
const cursor = screen.getByTestId('puzzle-mocap-cursor');
expect(cursor).toBeTruthy();
expect(cursor).toHaveStyle({left: '42%', top: '58%'});
expect(cursor.style.left).toBe('42%');
expect(cursor.style.top).toBe('58%');
mocapMock.state = 'grab';
});
@@ -813,6 +814,7 @@ test('基础单块使用圆角裁剪图片', () => {
) as HTMLElement | null;
expect(basePiece?.className).toContain('overflow-hidden');
expect(basePiece?.className).toContain('rounded-[0.85rem]');
expect(basePiece?.querySelector('.puzzle-runtime-piece-overlay')).toBeNull();
});
test('移动端点击拼图片时立即触发一次震动反馈', () => {
@@ -1347,7 +1347,6 @@ export function PuzzleRuntimeShell({
) : (
<div className="absolute inset-0 bg-[linear-gradient(145deg,rgba(251,191,36,0.4),rgba(76,29,19,0.72))]" />
)}
<div className="puzzle-runtime-piece-overlay absolute inset-0" />
</div>
) : (
''
@@ -2000,7 +1999,7 @@ export function PuzzleRuntimeShell({
</div>
<div className="puzzle-runtime-dialog__line overflow-hidden rounded-[1rem] border">
<div className="puzzle-runtime-leaderboard-head grid grid-cols-[3.5rem_minmax(0,1fr)_6rem] px-3 py-2 text-[11px] font-bold">
<div className="puzzle-runtime-leaderboard-head grid grid-cols-[3rem_minmax(0,1fr)_5.75rem] px-3 py-2 text-[11px] font-bold">
<span></span>
<span></span>
<span className="text-right"></span>
@@ -2010,7 +2009,7 @@ export function PuzzleRuntimeShell({
leaderboardEntries.map((entry) => (
<div
key={`${entry.rank}:${entry.nickname}:${entry.elapsedMs}`}
className={`grid grid-cols-[3.5rem_minmax(0,1fr)_6rem] items-center px-3 py-2.5 text-sm ${
className={`grid min-h-[3.25rem] grid-cols-[3rem_minmax(0,1fr)_5.75rem] items-center gap-x-2 px-3 py-2.5 text-sm ${
entry.isCurrentPlayer
? 'puzzle-runtime-leaderboard-row--active'
: 'puzzle-runtime-leaderboard-row border-t'
@@ -2019,8 +2018,22 @@ export function PuzzleRuntimeShell({
<span className="font-mono font-black">
#{entry.rank}
</span>
<span className="truncate font-semibold">
{entry.nickname}
<span className="min-w-0">
<span className="block truncate font-semibold leading-tight">
{entry.nickname}
</span>
{entry.visibleTags?.length ? (
<span className="puzzle-runtime-leaderboard-tags">
{entry.visibleTags.map((tag) => (
<span
className="puzzle-runtime-leaderboard-tag"
key={tag}
>
{tag}
</span>
))}
</span>
) : null}
</span>
<span className="text-right font-mono text-xs font-bold">
{formatElapsedMs(entry.elapsedMs)}
@@ -2112,7 +2125,7 @@ function PuzzleNextWorkCard({
) : (
<div className="h-full w-full bg-[linear-gradient(145deg,rgba(20,184,166,0.34),rgba(15,23,42,0.88))]" />
)}
<div className="puzzle-runtime-piece-overlay absolute inset-0 transition group-hover:opacity-0" />
<div className="puzzle-runtime-next-card-overlay absolute inset-0 transition group-hover:opacity-0" />
</div>
<div className="min-w-0 px-3 py-2.5">
<div className="truncate text-sm font-black">
@@ -67,6 +67,7 @@ import {
} from '../../services/match3d-works';
import {
createPuzzleAgentSession,
executePuzzleAgentAction,
getPuzzleAgentSession,
} from '../../services/puzzle-agent';
import {
@@ -463,9 +464,17 @@ vi.mock('../puzzle-agent/PuzzleAgentWorkspace', () => ({
vi.mock('../puzzle-result/PuzzleResultView', () => ({
PuzzleResultView: ({
isBusy,
onExecuteAction,
session,
onBack,
}: {
isBusy?: boolean;
onExecuteAction: (payload: {
action: string;
levelId?: string;
promptText?: string;
}) => void;
session: { draft?: { levelName: string } | null };
onBack: () => void;
}) => (
@@ -475,6 +484,21 @@ vi.mock('../puzzle-result/PuzzleResultView', () => ({
<input readOnly value={session.draft?.levelName ?? ''} />
</label>
<button
type="button"
onClick={() => {
onExecuteAction({
action: 'generate_puzzle_images',
levelId: 'puzzle-level-1',
promptText: '重新生成猫街',
});
}}
>
</button>
<button type="button" disabled={isBusy}>
</button>
<button type="button" onClick={onBack}>
</button>
@@ -550,14 +574,36 @@ vi.mock('../big-fish-result/BigFishResultView', () => ({
vi.mock('../match3d-creation/Match3DAgentWorkspace', () => ({
Match3DAgentWorkspace: ({
session,
onCreateFromForm,
}: {
session: { sessionId: string; messages: Array<{ text: string }> } | null;
onCreateFromForm?: (payload: {
seedText: string;
themeText: string;
referenceImageSrc: string | null;
clearCount: number;
difficulty: number;
}) => void;
}) => (
<div className="match3d-agent-workspace-mock">
<div>{session?.sessionId ?? 'missing-session'}</div>
{session?.messages.map((message) => (
<div key={`${session.sessionId}-${message.text}`}>{message.text}</div>
))}
<button
type="button"
onClick={() => {
onCreateFromForm?.({
seedText: '赛博水果摊题材,消除9次,难度6',
themeText: '赛博水果摊',
referenceImageSrc: null,
clearCount: 9,
difficulty: 6,
});
}}
>
稿
</button>
</div>
),
}));
@@ -2355,6 +2401,9 @@ test('create tab shows template tabs and embeds puzzle form by default', async (
expect(
screen.getByRole('tab', { name: 'AIRP' }).querySelector('img')?.src,
).toContain('/creation-type-references/airp.webp');
expect(
screen.getByRole('tab', { name: '抓大鹅' }).querySelector('img')?.src,
).toContain('/creation-type-references/match3d.webp');
expect(
screen.getByRole('tab', { name: '拼图' }).querySelector('.text-white'),
).toBeTruthy();
@@ -2364,12 +2413,29 @@ test('create tab shows template tabs and embeds puzzle form by default', async (
expect(screen.queryByRole('button', { name: //u })).toBeNull();
expect(screen.queryByPlaceholderText('问一问百梦')).toBeNull();
expect(screen.queryByRole('button', { name: //u })).toBeNull();
expect(screen.queryByRole('tab', { name: //u })).toBeNull();
expect(createRpgCreationSession).not.toHaveBeenCalled();
expect(match3dCreationClient.createSession).not.toHaveBeenCalled();
expect(createPuzzleAgentSession).not.toHaveBeenCalled();
});
test('create tab switches match3d into the embedded entry form', async () => {
const user = userEvent.setup();
render(<TestWrapper withAuth />);
await openCreateTemplateHub(user);
await user.click(screen.getByRole('tab', { name: '抓大鹅' }));
expect(
screen.getByRole('tab', { name: '抓大鹅' }).getAttribute('aria-selected'),
).toBe('true');
expect(
await screen.findByText('抓大鹅工作区:missing-session'),
).toBeTruthy();
expect(createPuzzleAgentSession).not.toHaveBeenCalled();
expect(match3dCreationClient.createSession).not.toHaveBeenCalled();
});
test('embedded puzzle form routes through requireAuth while logged out', async () => {
const user = userEvent.setup();
const requireAuth = vi.fn();
@@ -2826,6 +2892,159 @@ test('owned public puzzle detail edits original draft instead of remixing', asyn
expect(getPuzzleAgentSession).toHaveBeenCalledWith('puzzle-session-1');
expect(remixPuzzleGalleryWork).not.toHaveBeenCalled();
expect(await screen.findByText('拼图结果页')).toBeTruthy();
vi.mocked(executePuzzleAgentAction).mockResolvedValueOnce({
operation: {
operationId: 'puzzle-image-generation-1',
type: 'generate_puzzle_images',
status: 'running',
phaseLabel: '生成中',
phaseDetail: '正在生成拼图画面',
progress: 0.3,
},
session: {
sessionId: 'puzzle-session-1',
currentTurn: 3,
progressPercent: 88,
stage: 'ready_to_publish',
anchorPack: {
themePromise: {
key: 'theme_promise',
label: '主题承诺',
value: '雨夜猫街',
status: 'confirmed',
},
visualSubject: {
key: 'visual_subject',
label: '视觉主体',
value: '屋檐下的猫',
status: 'confirmed',
},
visualMood: {
key: 'visual_mood',
label: '视觉气质',
value: '温暖',
status: 'confirmed',
},
compositionHooks: {
key: 'composition_hooks',
label: '构图钩子',
value: '雨滴与灯牌',
status: 'confirmed',
},
tagsAndForbidden: {
key: 'tags_and_forbidden',
label: '标签与禁区',
value: '猫咪、雨夜',
status: 'confirmed',
},
},
draft: {
workTitle: '暖灯猫街作品',
workDescription: '一套雨夜猫街主题拼图。',
levelName: '雨夜猫街',
summary: '屋檐下的猫与暖灯街角。',
themeTags: ['猫咪', '雨夜', '暖灯'],
forbiddenDirectives: [],
creatorIntent: null,
anchorPack: {
themePromise: {
key: 'theme_promise',
label: '主题承诺',
value: '雨夜猫街',
status: 'confirmed',
},
visualSubject: {
key: 'visual_subject',
label: '视觉主体',
value: '屋檐下的猫',
status: 'confirmed',
},
visualMood: {
key: 'visual_mood',
label: '视觉气质',
value: '温暖',
status: 'confirmed',
},
compositionHooks: {
key: 'composition_hooks',
label: '构图钩子',
value: '雨滴与灯牌',
status: 'confirmed',
},
tagsAndForbidden: {
key: 'tags_and_forbidden',
label: '标签与禁区',
value: '猫咪、雨夜',
status: 'confirmed',
},
},
candidates: [
{
candidateId: 'candidate-1',
imageSrc: '/puzzle/candidate-1.png',
assetId: 'asset-1',
prompt: '雨夜猫咪',
actualPrompt: null,
sourceType: 'generated',
selected: true,
},
],
selectedCandidateId: 'candidate-1',
coverImageSrc: '/puzzle/candidate-1.png',
coverAssetId: 'asset-1',
generationStatus: 'generating',
levels: [
{
levelId: 'puzzle-level-1',
levelName: '雨夜猫街',
pictureDescription: '屋檐下的猫与暖灯街角。',
pictureReference: null,
candidates: [
{
candidateId: 'candidate-1',
imageSrc: '/puzzle/candidate-1.png',
assetId: 'asset-1',
prompt: '雨夜猫咪',
actualPrompt: null,
sourceType: 'generated',
selected: true,
},
],
selectedCandidateId: 'candidate-1',
coverImageSrc: '/puzzle/candidate-1.png',
coverAssetId: 'asset-1',
generationStatus: 'generating',
},
],
metadata: null,
},
messages: [],
lastAssistantReply: '大鱼结果页草稿已经生成,可以补正式资产。',
publishedProfileId: null,
suggestedActions: [],
resultPreview: {
draft: null,
publishReady: false,
blockers: [],
qualityFindings: [],
},
updatedAt: '2026-04-26T10:10:00.000Z',
},
});
await user.click(screen.getByRole('button', { name: '重新生成画面' }));
expect(executePuzzleAgentAction).toHaveBeenCalledWith(
'puzzle-session-1',
expect.objectContaining({
action: 'generate_puzzle_images',
}),
);
expect(screen.getByRole('button', { name: '新增关卡' })).toHaveProperty(
'disabled',
false,
);
});
test('logged out public detail gates big fish start before local runtime', async () => {
@@ -3027,7 +3246,6 @@ test('published puzzle works appear on home and mobile game category channel', a
});
test('home recommendation starts embedded puzzle without global auth reset on local failure', async () => {
const user = userEvent.setup();
const publishedPuzzleWork = {
workId: 'puzzle-work-public-1',
profileId: 'puzzle-profile-public-1',
@@ -3396,7 +3614,7 @@ test('embedded puzzle form timeout exits busy state and shows a readable error',
expect(streamCreativeAgentMessage).not.toHaveBeenCalled();
});
test('hidden match3d creation card stays closed even when public galleries fail', async () => {
test('match3d creation tab stays usable even when public galleries fail', async () => {
const user = userEvent.setup();
vi.mocked(listRpgEntryWorldGallery).mockRejectedValueOnce(
@@ -3411,9 +3629,7 @@ test('hidden match3d creation card stays closed even when public galleries fail'
await openCreateTemplateHub(user);
expect(screen.queryByText('读取作品广场失败')).toBeNull();
expect(screen.queryByText('读取抓大鹅广场失败')).toBeNull();
expect(
screen.queryByRole('tab', { name: /.*/u }),
).toBeNull();
expect(screen.getByRole('tab', { name: '抓大鹅' })).toBeTruthy();
expect(match3dCreationClient.createSession).not.toHaveBeenCalled();
});
@@ -1312,6 +1312,8 @@ test('logged in recommend runtime preloads adjacent work previews and drag switc
vi.useFakeTimers();
const onSelectNextRecommendEntry = vi.fn();
const onSelectPreviousRecommendEntry = vi.fn();
const onLikeRecommendEntry = vi.fn();
const onRemixRecommendEntry = vi.fn();
const firstEntry = {
...puzzlePublicEntry,
workId: 'puzzle-work-feed-1',
@@ -1397,6 +1399,8 @@ test('logged in recommend runtime preloads adjacent work previews and drag switc
activeRecommendEntryKey="puzzle:user-feed-1:puzzle-profile-feed-1"
onSelectNextRecommendEntry={onSelectNextRecommendEntry}
onSelectPreviousRecommendEntry={onSelectPreviousRecommendEntry}
onLikeRecommendEntry={onLikeRecommendEntry}
onRemixRecommendEntry={onRemixRecommendEntry}
onOpenLibraryDetail={vi.fn()}
onSearchPublicCode={vi.fn()}
/>
@@ -1412,8 +1416,38 @@ test('logged in recommend runtime preloads adjacent work previews and drag switc
).toHaveLength(3);
expect(screen.getAllByText('下一拼图').length).toBeGreaterThanOrEqual(2);
expect(screen.getAllByText('上一拼图').length).toBeGreaterThanOrEqual(2);
expect(screen.queryByText('评论')).toBeNull();
expect(screen.queryByLabelText(//u)).toBeNull();
const clipboardWriteText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText: clipboardWriteText },
});
const meta = screen.getByLabelText('当前拼图 作品信息') as HTMLElement;
const activeRecommendCard = within(meta);
const likeButton = activeRecommendCard.getByRole('button', {
name: '点赞 12',
});
expect(likeButton).toBeTruthy();
expect(activeRecommendCard.getByLabelText('12 个赞')).toBeTruthy();
const shareButton = activeRecommendCard.getByRole('button', { name: '分享' });
const remixButton = activeRecommendCard.getByRole('button', {
name: '改造 5',
});
expect(shareButton).toBeTruthy();
expect(remixButton).toBeTruthy();
fireEvent.click(likeButton);
fireEvent.click(shareButton);
fireEvent.click(remixButton);
expect(onLikeRecommendEntry).toHaveBeenCalledWith(firstEntry);
expect(onRemixRecommendEntry).toHaveBeenCalledWith(firstEntry);
expect(clipboardWriteText).toHaveBeenCalledWith(
expect.stringContaining('作品号:PZ-FEED1'),
);
act(() => {
dispatchPointerEvent(meta, 'pointerdown', { pointerId: 1, clientY: 300 });
dispatchPointerEvent(meta, 'pointermove', { pointerId: 1, clientY: 210 });
+127 -20
View File
@@ -9,6 +9,7 @@ import {
Coins,
Compass,
Copy,
GitFork,
Gamepad2,
Heart,
LogIn,
@@ -16,10 +17,12 @@ import {
Pencil,
Plus,
Search,
Share2,
Settings,
SlidersHorizontal,
Sparkles,
Star,
ThumbsUp,
Ticket,
UserPlus,
UserRound,
@@ -54,6 +57,7 @@ import type {
RedeemProfileRewardCodeResponse,
} from '../../../packages/shared/src/contracts/runtime';
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
import { buildPublicWorkDetailUrl } from '../../routing/appPageRoutes';
import type { AuthUser } from '../../services/authService';
import {
getPublicAuthUserByCode,
@@ -86,6 +90,7 @@ import {
isVisualNovelGalleryEntry,
type PlatformPublicGalleryCard,
type PlatformWorldCardLike,
resolvePlatformPublicWorkCode,
resolvePlatformWorldCoverImage,
resolvePlatformWorldCoverSlides,
resolvePlatformWorldLeadPortrait,
@@ -126,6 +131,8 @@ export interface RpgEntryHomeViewProps {
recommendRuntimeError?: string | null;
onSelectNextRecommendEntry?: () => void;
onSelectPreviousRecommendEntry?: () => void;
onLikeRecommendEntry?: (entry: PlatformPublicGalleryCard) => void;
onRemixRecommendEntry?: (entry: PlatformPublicGalleryCard) => void;
onOpenLibraryDetail: (
entry: CustomWorldLibraryEntry<CustomWorldProfile>,
) => void;
@@ -772,19 +779,27 @@ function RecommendSwipeCard({
authorAvatarUrl,
isActive,
visual,
shareState,
onDragPointerDown,
onDragPointerMove,
onDragPointerUp,
onDragPointerCancel,
onLike,
onShare,
onRemix,
}: {
entry: PlatformPublicGalleryCard;
authorAvatarUrl?: string | null;
isActive: boolean;
visual: ReactNode;
shareState?: 'idle' | 'copied' | 'failed';
onDragPointerDown?: (event: PointerEvent<HTMLElement>) => void;
onDragPointerMove?: (event: PointerEvent<HTMLElement>) => void;
onDragPointerUp?: (event: PointerEvent<HTMLElement>) => void;
onDragPointerCancel?: (event: PointerEvent<HTMLElement>) => void;
onLike?: () => void;
onShare?: () => void;
onRemix?: () => void;
}) {
return (
<div
@@ -797,10 +812,14 @@ function RecommendSwipeCard({
entry={entry}
authorAvatarUrl={authorAvatarUrl}
isActive={isActive}
shareState={shareState}
onDragPointerDown={onDragPointerDown}
onDragPointerMove={onDragPointerMove}
onDragPointerUp={onDragPointerUp}
onDragPointerCancel={onDragPointerCancel}
onLike={onLike}
onShare={onShare}
onRemix={onRemix}
/>
</div>
</div>
@@ -814,6 +833,10 @@ function RecommendRuntimeMeta({
onDragPointerMove,
onDragPointerUp,
onDragPointerCancel,
shareState = 'idle',
onLike,
onShare,
onRemix,
isActive = true,
}: {
entry: PlatformPublicGalleryCard;
@@ -822,20 +845,21 @@ function RecommendRuntimeMeta({
onDragPointerMove?: (event: PointerEvent<HTMLElement>) => void;
onDragPointerUp?: (event: PointerEvent<HTMLElement>) => void;
onDragPointerCancel?: (event: PointerEvent<HTMLElement>) => void;
shareState?: 'idle' | 'copied' | 'failed';
onLike?: () => void;
onShare?: () => void;
onRemix?: () => void;
isActive?: boolean;
}) {
const playCount = getPlatformWorldPlayCount(entry);
const remixCount = getPlatformWorldRemixCount(entry);
const likeCount = getPlatformWorldLikeCount(entry);
const remixCount = getPlatformWorldRemixCount(entry);
const authorName = entry.authorDisplayName.trim() || '玩家';
const authorAvatarLabel = getPublicAuthorAvatarLabel(authorName);
const normalizedAuthorAvatarUrl = authorAvatarUrl?.trim() ?? '';
const displayName = formatPlatformWorkDisplayName(entry.worldName);
const statItems = [
{ label: '游玩', value: playCount, icon: Gamepad2 },
{ label: '点赞', value: likeCount, icon: Heart },
{ label: '改造', value: remixCount, icon: MessageCircle },
];
const stopActionPointer = (event: PointerEvent<HTMLButtonElement>) => {
event.stopPropagation();
};
return (
<section
@@ -854,19 +878,6 @@ function RecommendRuntimeMeta({
onPointerUp={onDragPointerUp}
onPointerCancel={onDragPointerCancel}
>
<div className="platform-recommend-work-meta__stats">
{statItems.map(({ label, value, icon: Icon }) => (
<span
key={label}
className="platform-recommend-work-meta__stat"
aria-label={`${label} ${formatCompactCount(value)}`}
>
<Icon className="h-4 w-4" aria-hidden="true" />
<span>{formatCompactCount(value)}</span>
</span>
))}
</div>
<div className="platform-recommend-work-meta__row">
<div
className="platform-recommend-work-meta__identity"
@@ -894,6 +905,62 @@ function RecommendRuntimeMeta({
</span>
</span>
</div>
<div className="platform-recommend-work-meta__actions">
<button
type="button"
className="platform-recommend-work-meta__action platform-recommend-work-meta__action--icon platform-recommend-work-meta__action--like"
onPointerDown={stopActionPointer}
onClick={(event) => {
event.stopPropagation();
onLike?.();
}}
disabled={!isActive || !onLike}
aria-label={`点赞 ${formatCompactCount(likeCount)}`}
title="点赞"
>
<ThumbsUp className="h-5 w-5" aria-hidden="true" />
</button>
<span
className="platform-recommend-work-meta__like-count"
aria-label={`${formatCompactCount(likeCount)} 个赞`}
>
{formatCompactCount(likeCount)}
</span>
<button
type="button"
className="platform-recommend-work-meta__action platform-recommend-work-meta__action--icon"
onPointerDown={stopActionPointer}
onClick={(event) => {
event.stopPropagation();
onShare?.();
}}
disabled={!isActive || !onShare}
aria-label={
shareState === 'copied'
? '分享内容已复制'
: shareState === 'failed'
? '分享内容复制失败'
: '分享'
}
title="分享"
>
<Share2 className="h-5 w-5" aria-hidden="true" />
</button>
<button
type="button"
className="platform-recommend-work-meta__action platform-recommend-work-meta__action--icon platform-recommend-work-meta__action--remix"
onPointerDown={stopActionPointer}
onClick={(event) => {
event.stopPropagation();
onRemix?.();
}}
disabled={!isActive || !onRemix}
aria-label={`改造 ${formatCompactCount(remixCount)}`}
title="改造"
>
<GitFork className="h-5 w-5" aria-hidden="true" />
</button>
</div>
</div>
</section>
);
@@ -2977,6 +3044,8 @@ export function RpgEntryHomeView({
recommendRuntimeError = null,
onSelectNextRecommendEntry,
onSelectPreviousRecommendEntry,
onLikeRecommendEntry,
onRemixRecommendEntry,
onOpenLibraryDetail,
onDeleteLibraryEntry,
deletingLibraryEntryId = null,
@@ -3863,6 +3932,10 @@ export function RpgEntryHomeView({
const [recommendDragOffsetY, setRecommendDragOffsetY] = useState(0);
const [recommendDragCommitDirection, setRecommendDragCommitDirection] =
useState<1 | -1 | null>(null);
const [recommendShareState, setRecommendShareState] = useState<
'idle' | 'copied' | 'failed'
>('idle');
const recommendShareResetTimerRef = useRef<number | null>(null);
const recommendCardStageRef = useRef<HTMLDivElement | null>(null);
const recommendDragStartRef = useRef<{
pointerId: number;
@@ -4005,6 +4078,36 @@ export function RpgEntryHomeView({
onSelectNextRecommendEntry,
recommendedFeedEntries.length,
]);
useEffect(
() => () => {
if (recommendShareResetTimerRef.current !== null) {
window.clearTimeout(recommendShareResetTimerRef.current);
}
},
[],
);
useEffect(() => {
setRecommendShareState('idle');
}, [activeRecommendEntryKey]);
const shareRecommendEntry = useCallback((entry: PlatformPublicGalleryCard) => {
const publicWorkCode = resolvePlatformPublicWorkCode(entry)?.trim();
if (!publicWorkCode) {
setRecommendShareState('failed');
return;
}
const shareText = `邀请你来玩《${entry.worldName}\n作品号:${publicWorkCode}\n${buildPublicWorkDetailUrl(publicWorkCode)}`;
void copyTextToClipboard(shareText).then((copied) => {
setRecommendShareState(copied ? 'copied' : 'failed');
if (recommendShareResetTimerRef.current !== null) {
window.clearTimeout(recommendShareResetTimerRef.current);
}
recommendShareResetTimerRef.current = window.setTimeout(() => {
recommendShareResetTimerRef.current = null;
setRecommendShareState('idle');
}, 1400);
});
}, []);
const openActiveRecommendEntry = useCallback(() => {
if (!activeRecommendEntry) {
return;
@@ -4168,6 +4271,10 @@ export function RpgEntryHomeView({
onDragPointerMove={moveRecommendDrag}
onDragPointerUp={endRecommendDrag}
onDragPointerCancel={cancelRecommendDrag}
shareState={recommendShareState}
onLike={() => onLikeRecommendEntry?.(activeRecommendEntry)}
onShare={() => shareRecommendEntry(activeRecommendEntry)}
onRemix={() => onRemixRecommendEntry?.(activeRecommendEntry)}
/>
</div>
@@ -1,123 +1,101 @@
/* @vitest-environment jsdom */
import type { ComponentProps } from 'react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { fireEvent, render, screen } from '@testing-library/react';
import { expect, test, vi } from 'vitest';
import { buildVisualNovelForbiddenCopyPattern } from '../visual-novel-runtime/visualNovelForbiddenCopy';
import { mockVisualNovelSession } from '../visual-novel-runtime/visualNovelMockData';
import { VisualNovelAgentWorkspace } from './VisualNovelAgentWorkspace';
import { AuthUiContext } from '../auth/AuthUiContext';
import {
buildVisualNovelEntryGenerationAnchorEntries,
buildVisualNovelEntryGenerationProgress,
VisualNovelAgentWorkspace,
} from './VisualNovelAgentWorkspace';
vi.mock('../../services/creation-agent/creationAgentDocumentInput', () => ({
parseCreationAgentDocumentInput: vi.fn(),
}));
vi.mock('../../services/visual-novel-creation', () => ({
uploadVisualNovelAsset: vi.fn(),
}));
const mockedParseCreationAgentDocumentInput = vi.mocked(
await import('../../services/creation-agent/creationAgentDocumentInput'),
);
const mockedVisualNovelAssetClient = vi.mocked(
await import('../../services/visual-novel-creation'),
);
function renderWorkspace(ui?: Partial<ComponentProps<typeof VisualNovelAgentWorkspace>>) {
function renderWorkspace(
ui?: Partial<ComponentProps<typeof VisualNovelAgentWorkspace>>,
) {
return render(
<AuthUiContext.Provider
value={
{
user: { id: 'user-1' },
platformTheme: 'light',
} as never
}
>
<VisualNovelAgentWorkspace
session={mockVisualNovelSession}
onBack={() => {}}
{...ui}
/>
</AuthUiContext.Provider>,
<VisualNovelAgentWorkspace onBack={() => {}} session={null} {...ui} />,
);
}
test('visual novel workspace renders mock creation shell without forbidden entry', async () => {
const user = userEvent.setup();
const onOpenResult = vi.fn();
test('visual novel workspace only exposes one-line input and visual style entry', () => {
const onCreateFromForm = vi.fn();
renderWorkspace({ onOpenResult });
renderWorkspace({ onCreateFromForm });
expect(screen.getByRole('heading', { name: '视觉小说' })).toBeTruthy();
expect(screen.getByRole('button', { name: '一句话' })).toBeTruthy();
expect(screen.getByLabelText('一句话创作')).toBeTruthy();
expect(screen.getByText('视觉画风')).toBeTruthy();
expect(screen.getByRole('button', { name: '映画动画' })).toBeTruthy();
expect(screen.getByRole('button', { name: '水彩绘本' })).toBeTruthy();
expect(screen.getByText('消耗20光点')).toBeTruthy();
expect(screen.queryByText(buildVisualNovelForbiddenCopyPattern())).toBeNull();
await user.click(screen.getByRole('button', { name: '进入结果页' }));
expect(onOpenResult).toHaveBeenCalledWith(mockVisualNovelSession);
expect(screen.queryByRole('button', { name: '文档' })).toBeNull();
expect(screen.queryByRole('button', { name: '空白' })).toBeNull();
expect(screen.queryByLabelText('上传文档')).toBeNull();
expect(screen.queryByText('进入结果页')).toBeNull();
expect(screen.queryByText('Agent')).toBeNull();
});
test('visual novel workspace opens editable blank draft from blank source', async () => {
const user = userEvent.setup();
const onOpenResult = vi.fn();
test('visual novel workspace submits idea and selected visual style as seed text', () => {
const onCreateFromForm = vi.fn();
renderWorkspace({ session: null, onOpenResult });
renderWorkspace({ onCreateFromForm });
await user.click(screen.getByRole('button', { name: '空白' }));
const openResultButtons = screen.getAllByRole('button', {
name: '进入结果页',
fireEvent.change(screen.getByLabelText('一句话创作'), {
target: { value: '失忆画师在雨夜剧场寻找旧胶片。' },
});
await user.click(openResultButtons[0]!);
fireEvent.click(screen.getByRole('button', { name: '像素霓虹' }));
fireEvent.click(
screen.getByRole('button', { name: /稿/u }),
);
expect(onOpenResult).toHaveBeenCalledTimes(1);
const session = onOpenResult.mock.calls[0]?.[0];
expect(session?.sourceMode).toBe('blank');
expect(session?.draft?.sourceMode).toBe('blank');
expect(session?.draft?.runtimeConfig.textModeEnabled).toBe(true);
expect(onCreateFromForm).toHaveBeenCalledWith({
sourceMode: 'idea',
sourceAssetIds: [],
ideaText: '失忆画师在雨夜剧场寻找旧胶片。',
visualStyleId: 'pixel-noir',
visualStyleLabel: '像素霓虹',
visualStylePrompt:
'高可读像素视觉小说画风,霓虹反差、硬朗轮廓和复古界面气质。',
seedText:
'失忆画师在雨夜剧场寻找旧胶片。\n视觉画风:像素霓虹\n画风要求:高可读像素视觉小说画风,霓虹反差、硬朗轮廓和复古界面气质。',
});
});
test('visual novel workspace uploads document asset and passes asset id to session', async () => {
const user = userEvent.setup();
const onCreateSession = vi.fn();
const parseMock = mockedParseCreationAgentDocumentInput.parseCreationAgentDocumentInput;
const uploadMock = mockedVisualNovelAssetClient.uploadVisualNovelAsset;
test('visual novel workspace restores idea text from existing session', () => {
renderWorkspace({ session: mockVisualNovelSession });
parseMock.mockResolvedValue({
document: {
fileName: '世界设定.docx',
contentType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
sizeBytes: 128,
text: '第一章\n雨夜书店\n第二章\n失踪乘客',
sourceAssetId: 'asset-doc-source',
},
});
uploadMock.mockResolvedValue({
assetObjectId: 'asset-doc-1',
assetKind: 'visual_novel_document',
objectKey: 'generated-character-drafts/visual-novel/draft/document/1.docx',
imageSrc: '/generated-character-drafts/visual-novel/draft/document/1.docx',
});
renderWorkspace({ session: null, onCreateSession });
await user.click(screen.getByRole('button', { name: '文档' }));
const fileInput = screen.getByLabelText('上传文档') as HTMLInputElement;
const file = new File(['first chapter'], '世界设定.docx', {
type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
});
await user.upload(fileInput, file);
await user.click(screen.getByRole('button', { name: '生成底稿' }));
expect(parseMock).toHaveBeenCalledTimes(1);
expect(uploadMock).toHaveBeenCalledTimes(1);
expect(onCreateSession).toHaveBeenCalledWith(
expect.objectContaining({
sourceMode: 'document',
sourceAssetIds: ['asset-doc-1'],
seedText: expect.stringContaining('第一章'),
}),
expect((screen.getByLabelText('一句话创作') as HTMLTextAreaElement).value).toBe(
'想做一个雪夜列车和旧电台有关的悬疑视觉小说。',
);
});
test('visual novel generation helpers build process page data', () => {
const payload = {
sourceMode: 'idea' as const,
seedText:
'雨夜书店\n视觉画风:水彩绘本\n画风要求:透明水彩与绘本质感。',
sourceAssetIds: [],
ideaText: '雨夜书店',
visualStyleId: 'watercolor' as const,
visualStyleLabel: '水彩绘本',
visualStylePrompt: '透明水彩与绘本质感。',
};
expect(buildVisualNovelEntryGenerationAnchorEntries(payload)).toEqual([
{ id: 'visual-novel-idea', label: '一句话', value: '雨夜书店' },
{ id: 'visual-novel-style', label: '视觉画风', value: '水彩绘本' },
]);
const progress = buildVisualNovelEntryGenerationProgress(
1_000,
'generating',
8_000,
);
expect(progress.phaseId).toBe('generating');
expect(progress.overallProgress).toBeGreaterThan(0);
expect(progress.steps.some((step) => step.status === 'active')).toBe(true);
});
File diff suppressed because it is too large Load Diff
+85 -13
View File
@@ -541,7 +541,7 @@ body {
--puzzle-runtime-piece-selected-fill: linear-gradient(135deg, #ff4f8b, #ff8a73);
--puzzle-runtime-piece-selected-text: #fff7fb;
--puzzle-runtime-piece-selected-border: rgba(255, 79, 139, 0.68);
--puzzle-runtime-piece-overlay: rgba(61, 24, 38, 0.06);
--puzzle-runtime-next-card-overlay: rgba(61, 24, 38, 0.06);
--puzzle-runtime-control-fill: rgba(255, 255, 255, 0.72);
--puzzle-runtime-control-hover-fill: rgba(255, 91, 132, 0.1);
--puzzle-runtime-primary-fill: var(--platform-button-primary-fill);
@@ -768,7 +768,7 @@ body {
--puzzle-runtime-piece-selected-fill: rgba(251, 191, 36, 0.84);
--puzzle-runtime-piece-selected-text: #020617;
--puzzle-runtime-piece-selected-border: rgba(253, 230, 138, 1);
--puzzle-runtime-piece-overlay: rgba(0, 0, 0, 0.1);
--puzzle-runtime-next-card-overlay: rgba(0, 0, 0, 0.1);
--puzzle-runtime-control-fill: rgba(0, 0, 0, 0.36);
--puzzle-runtime-control-hover-fill: rgba(255, 255, 255, 0.1);
--puzzle-runtime-primary-fill: #fde68a;
@@ -2095,8 +2095,31 @@ body {
color: var(--puzzle-runtime-piece-empty-text);
}
.puzzle-runtime-piece-overlay {
background: var(--puzzle-runtime-piece-overlay);
.puzzle-runtime-next-card-overlay {
background: var(--puzzle-runtime-next-card-overlay);
}
.puzzle-runtime-leaderboard-tags {
display: flex;
min-width: 0;
flex-wrap: wrap;
gap: 0.25rem;
padding-top: 0.28rem;
}
.puzzle-runtime-leaderboard-tag {
display: inline-flex;
max-width: 100%;
align-items: center;
border: 1px solid var(--puzzle-runtime-control-border);
border-radius: 9999px;
background: var(--puzzle-runtime-control-fill);
color: var(--puzzle-runtime-accent-text);
padding: 0.12rem 0.42rem;
font-size: 0.68rem;
font-weight: 850;
line-height: 1.2;
overflow-wrap: anywhere;
}
.puzzle-runtime-primary-button {
@@ -2603,32 +2626,81 @@ body {
user-select: none;
}
.platform-recommend-work-meta__stats {
.platform-recommend-work-meta__actions {
display: flex;
min-width: 0;
align-items: center;
gap: 1.05rem;
padding: 0 0.28rem;
gap: 0.55rem;
padding: 0;
}
.platform-recommend-work-meta__stat {
.platform-recommend-work-meta__action {
display: inline-flex;
min-width: 0;
min-height: 2.4rem;
align-items: center;
justify-content: center;
border: 1px solid rgba(121, 82, 109, 0.12);
border-radius: 9999px;
background: rgba(255, 255, 255, 0.72);
color: var(--platform-text-strong);
font-weight: 950;
line-height: 1;
box-shadow: 0 8px 18px rgba(15, 23, 42, 0.08);
transition:
transform 140ms ease,
background 140ms ease,
border-color 140ms ease;
}
.platform-recommend-work-meta__action:not(:disabled):active {
transform: scale(0.96);
}
.platform-recommend-work-meta__action:disabled {
opacity: 0.55;
}
.platform-recommend-work-meta__action--icon {
width: 2.4rem;
flex: 0 0 2.4rem;
}
.platform-recommend-work-meta__action--like {
border-color: rgba(255, 85, 126, 0.3);
background: rgba(255, 85, 126, 0.12);
color: #ff4f7d;
}
.platform-recommend-work-meta__action--remix {
border-color: rgba(121, 82, 109, 0.14);
background: rgba(255, 255, 255, 0.82);
color: var(--platform-text-strong);
}
.platform-recommend-work-meta__like-count {
display: inline-flex;
min-width: 0;
align-items: center;
gap: 0.28rem;
font-size: 1rem;
font-weight: 850;
gap: 0.22rem;
font-size: 0.84rem;
font-weight: 800;
line-height: 1;
color: var(--platform-text-strong);
}
.platform-recommend-work-meta__like-count {
flex: 0 0 auto;
min-width: 1rem;
}
.platform-recommend-work-meta__row {
margin-top: 0.36rem;
margin-top: 0.12rem;
display: flex;
min-width: 0;
align-items: center;
justify-content: space-between;
gap: 0.7rem;
gap: 0.6rem;
}
.platform-recommend-work-meta__identity {
+27
View File
@@ -8,6 +8,7 @@ import {
import {
clearSignedAssetReadUrlCache,
getSignedAssetReadUrl,
readAssetBytes,
resolveAssetReadUrl,
} from './assetReadUrlService';
@@ -301,4 +302,30 @@ describe('assetReadUrlService', () => {
expect(getStoredAccessToken()).toBe('test-access-token');
expect(window.dispatchEvent).not.toHaveBeenCalled();
});
test('readAssetBytes reads generated resources through same-origin bytes endpoint', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
new Response(new Uint8Array([104, 101, 108, 108, 111]), {
status: 200,
headers: {
'Content-Type': 'image/png',
},
}),
);
const response = await readAssetBytes(
'/generated-match3d-assets/session/profile/items/match3d-item-1-item/image.png',
{ expireSeconds: 300 },
);
const bytes = new Uint8Array(await response.arrayBuffer());
expect(Array.from(bytes)).toEqual([104, 101, 108, 108, 111]);
expect(response.headers.get('content-type')).toBe('image/png');
expect(String(vi.mocked(globalThis.fetch).mock.calls[0]?.[0])).toContain(
'/api/assets/read-bytes?',
);
expect(String(vi.mocked(globalThis.fetch).mock.calls[0]?.[0])).toContain(
'legacyPublicPath=%2Fgenerated-match3d-assets%2Fsession%2Fprofile%2Fitems%2Fmatch3d-item-1-item%2Fimage.png',
);
});
});
+73 -17
View File
@@ -1,7 +1,9 @@
import { parseApiErrorMessage } from '../../packages/shared/src/http';
import {
ApiClientError,
BACKGROUND_AUTH_REQUEST_OPTIONS,
type ApiRequestOptions,
fetchWithApiAuth,
requestJson,
} from './apiClient';
@@ -23,6 +25,11 @@ type AssetReadUrlResolveOptions = {
refreshKey?: string | number | null;
};
type AssetReadBytesOptions = {
signal?: AbortSignal;
expireSeconds?: number;
};
export type AssetReadUrlResponse = {
read?: {
objectKey?: string;
@@ -44,6 +51,7 @@ type CachedReadUrlFailureEntry = {
};
const ASSET_READ_URL_API_PATH = '/api/assets/read-url';
const ASSET_READ_BYTES_API_PATH = '/api/assets/read-bytes';
const DEFAULT_CACHE_SAFETY_WINDOW_MS = 30 * 1000;
const DEFAULT_FAILURE_CACHE_WINDOW_MS = 60 * 1000;
const ASSET_READ_URL_BACKGROUND_OPTIONS =
@@ -72,6 +80,27 @@ function buildCacheKey(request: AssetReadUrlRequest) {
return '';
}
function buildAssetReadSearchParams(request: AssetReadUrlRequest) {
const searchParams = new URLSearchParams();
if (request.objectKey?.trim()) {
searchParams.set('objectKey', request.objectKey.trim().replace(/^\/+/u, ''));
}
if (request.legacyPublicPath?.trim()) {
searchParams.set(
'legacyPublicPath',
normalizeLegacyPublicPath(request.legacyPublicPath),
);
}
if (
typeof request.expireSeconds === 'number' &&
Number.isFinite(request.expireSeconds) &&
request.expireSeconds > 0
) {
searchParams.set('expireSeconds', String(Math.floor(request.expireSeconds)));
}
return searchParams;
}
function resolveSignedReadPayload(response: AssetReadUrlResponse) {
const read = response.read ?? response;
const signedUrl = typeof read.signedUrl === 'string' ? read.signedUrl.trim() : '';
@@ -146,23 +175,7 @@ export async function getSignedAssetReadUrl(
}
const requestPromise = (async () => {
const searchParams = new URLSearchParams();
if (request.objectKey?.trim()) {
searchParams.set('objectKey', request.objectKey.trim().replace(/^\/+/u, ''));
}
if (request.legacyPublicPath?.trim()) {
searchParams.set(
'legacyPublicPath',
normalizeLegacyPublicPath(request.legacyPublicPath),
);
}
if (
typeof request.expireSeconds === 'number' &&
Number.isFinite(request.expireSeconds) &&
request.expireSeconds > 0
) {
searchParams.set('expireSeconds', String(Math.floor(request.expireSeconds)));
}
const searchParams = buildAssetReadSearchParams(request);
try {
const response = await requestJson<AssetReadUrlResponse>(
@@ -289,6 +302,49 @@ export async function resolveAssetReadUrl(
return appendCacheBustParam(value, options.refreshKey);
}
export async function readAssetBytes(
source: string | null | undefined,
options: AssetReadBytesOptions = {},
) {
const value = source?.trim() ?? '';
if (!value) {
throw new Error('资源路径不能为空');
}
if (!isGeneratedLegacyPath(value)) {
const response = await fetch(value, { signal: options.signal });
if (!response.ok) {
throw new Error('读取资源内容失败');
}
return response;
}
// 中文注释:这里要拿图片字节转 Data URL,不能直接 fetch OSS 签名 URL,否则浏览器会受 bucket CORS 限制。
const searchParams = buildAssetReadSearchParams({
legacyPublicPath: value,
expireSeconds: options.expireSeconds,
});
const response = await fetchWithApiAuth(
`${ASSET_READ_BYTES_API_PATH}?${searchParams.toString()}`,
{
method: 'GET',
signal: options.signal,
},
{
...ASSET_READ_URL_BACKGROUND_OPTIONS,
omitEnvelopeHeader: true,
},
);
if (!response.ok) {
const message = await response
.text()
.then((text) => parseApiErrorMessage(text, '读取资源内容失败'))
.catch(() => '');
throw new Error(message || '读取资源内容失败');
}
return response;
}
export function clearSignedAssetReadUrlCache() {
signedReadUrlCache.clear();
signedReadUrlFailureCache.clear();
@@ -1,8 +1,10 @@
import { describe, expect, test } from 'vitest';
import {
buildMatch3DGenerationAnchorEntries,
buildMiniGameDraftGenerationProgress,
buildPuzzleGenerationAnchorEntries,
createMiniGameDraftGenerationState,
type MiniGameDraftGenerationState,
} from './miniGameDraftGenerationProgress';
@@ -150,6 +152,47 @@ describe('miniGameDraftGenerationProgress', () => {
);
});
test('match3d draft generation exposes item sheet and image asset steps', () => {
const state = createMiniGameDraftGenerationState('match3d');
const progress = buildMiniGameDraftGenerationProgress(
state,
state.startedAtMs + 17_000,
);
expect(progress?.steps.map((step) => step.id)).toEqual([
'match3d-item-names',
'match3d-material-sheet',
'match3d-slice-images',
'match3d-upload-images',
]);
expect(progress?.phaseId).toBe('match3d-material-sheet');
expect(progress?.phaseLabel).toBe('生成素材图');
expect(progress?.estimatedRemainingMs).toBe(103_000);
});
test('match3d generation anchors show theme and fixed three items', () => {
const entries = buildMatch3DGenerationAnchorEntries(null, {
themeText: '水果',
clearCount: 20,
difficulty: 8,
referenceImageSrc: null,
});
expect(entries).toEqual([
{
id: 'match3d-theme',
label: '题材',
value: '水果',
},
{
id: 'match3d-items',
label: '物品数量',
value: '3 件',
},
]);
});
test('puzzle generation anchors expose form payload as the display source', () => {
const entries = buildPuzzleGenerationAnchorEntries({
sessionId: 'puzzle-session-1',
+114 -4
View File
@@ -3,6 +3,10 @@ import type {
CreatePuzzleAgentSessionRequest,
PuzzleAgentSessionSnapshot,
} from '../../packages/shared/src/contracts/puzzleAgentSession';
import type {
CreateMatch3DSessionRequest,
Match3DAgentSessionSnapshot,
} from '../../packages/shared/src/contracts/match3dAgent';
import type {
CustomWorldGenerationProgress,
CustomWorldGenerationStep,
@@ -10,7 +14,11 @@ import type {
import type { SquareHoleSessionSnapshot } from '../../packages/shared/src/contracts/squareHoleAgent';
import type { CustomWorldStructuredAnchorEntry } from './customWorldAgentGenerationProgress';
export type MiniGameDraftGenerationKind = 'puzzle' | 'big-fish' | 'square-hole';
export type MiniGameDraftGenerationKind =
| 'puzzle'
| 'big-fish'
| 'square-hole'
| 'match3d';
export type MiniGameDraftGenerationPhase =
| 'idle'
@@ -22,6 +30,11 @@ export type MiniGameDraftGenerationPhase =
| 'square-hole-cover'
| 'square-hole-shapes'
| 'square-hole-ready'
| 'match3d-item-names'
| 'match3d-material-sheet'
| 'match3d-slice-images'
| 'match3d-upload-images'
| 'match3d-ready'
| 'puzzle-images'
| 'puzzle-select-image'
| 'ready'
@@ -126,6 +139,33 @@ const SQUARE_HOLE_STEPS = [
},
] as const satisfies ReadonlyArray<MiniGameStepDefinition>;
const MATCH3D_STEPS = [
{
id: 'match3d-item-names',
label: '生成物品名称',
detail: '根据题材生成本局的 3 个物品名称。',
weight: 16,
},
{
id: 'match3d-material-sheet',
label: '生成素材图',
detail: '生成一张 1:1 的网格素材图。',
weight: 30,
},
{
id: 'match3d-slice-images',
label: '切割独立图片',
detail: '把素材图切成独立物品参考图。',
weight: 14,
},
{
id: 'match3d-upload-images',
label: '上传图片资产',
detail: '写入切割图片并准备进入草稿页。',
weight: 40,
},
] as const satisfies ReadonlyArray<MiniGameStepDefinition>;
function clampProgress(value: number) {
return Math.max(0, Math.min(100, Math.round(value)));
}
@@ -137,6 +177,9 @@ function getStepDefinitions(kind: MiniGameDraftGenerationKind) {
if (kind === 'square-hole') {
return SQUARE_HOLE_STEPS;
}
if (kind === 'match3d') {
return MATCH3D_STEPS;
}
return BIG_FISH_STEPS;
}
@@ -190,7 +233,9 @@ export function createMiniGameDraftGenerationState(
? 'big-fish-draft'
: kind === 'square-hole'
? 'square-hole-draft'
: 'compile',
: kind === 'match3d'
? 'match3d-item-names'
: 'compile',
startedAtMs: Date.now(),
completedAssetCount: 0,
totalAssetCount: 0,
@@ -222,6 +267,21 @@ function resolveSquareHolePhaseByElapsedMs(
return 'square-hole-draft';
}
function resolveMatch3DPhaseByElapsedMs(
elapsedMs: number,
): MiniGameDraftGenerationPhase {
if (elapsedMs >= 72_000) {
return 'match3d-upload-images';
}
if (elapsedMs >= 58_000) {
return 'match3d-slice-images';
}
if (elapsedMs >= 16_000) {
return 'match3d-material-sheet';
}
return 'match3d-item-names';
}
function resolvePuzzleTimelineByElapsedMs(elapsedMs: number) {
let elapsedBeforePhase = 0;
@@ -282,6 +342,13 @@ export function buildMiniGameDraftGenerationProgress(
...state,
phase: resolveSquareHolePhaseByElapsedMs(elapsedMs),
}
: state.kind === 'match3d' &&
state.phase !== 'failed' &&
state.phase !== 'ready'
? {
...state,
phase: resolveMatch3DPhaseByElapsedMs(elapsedMs),
}
: state;
const steps = getStepDefinitions(normalizedState.kind);
@@ -307,6 +374,8 @@ export function buildMiniGameDraftGenerationProgress(
? 0.55
: normalizedState.kind === 'square-hole'
? 0.42
: normalizedState.kind === 'match3d'
? 0.5
: 0;
const overallProgress =
normalizedState.phase === 'failed'
@@ -334,7 +403,9 @@ export function buildMiniGameDraftGenerationProgress(
(normalizedState.phase === 'ready'
? normalizedState.kind === 'big-fish'
? '玩法草稿已准备完成,可进入结果页继续生成主图、动作和背景。'
: '首关草稿与正式图已准备完成,可进入结果页补作品信息。'
: normalizedState.kind === 'match3d'
? '抓大鹅素材与草稿已准备完成,可进入结果页继续编辑。'
: '首关草稿与正式图已准备完成,可进入结果页补作品信息。'
: activeStep.detail),
batchLabel: activeStep.label,
overallProgress: clampProgress(cappedOverallProgress),
@@ -350,7 +421,9 @@ export function buildMiniGameDraftGenerationProgress(
? Math.max(0, 7_000 - elapsedMs)
: normalizedState.kind === 'square-hole'
? Math.max(0, 12_000 - elapsedMs)
: null,
: normalizedState.kind === 'match3d'
? Math.max(0, 120_000 - elapsedMs)
: null,
activeStepIndex,
steps: buildMiniGameProgressSteps(
steps,
@@ -439,6 +512,43 @@ export function buildBigFishGenerationAnchorEntries(
.filter((entry) => entry.value.trim());
}
export function buildMatch3DGenerationAnchorEntries(
session: Match3DAgentSessionSnapshot | null | undefined,
formPayload: CreateMatch3DSessionRequest | null | undefined = null,
): CustomWorldStructuredAnchorEntry[] {
if (!session && !formPayload) {
return [];
}
const config = session?.config;
const itemCount = 3;
const entries: Array<MiniGameAnchorSource | null> = [
{
key: 'match3d-theme',
label: '题材',
value:
formPayload?.themeText?.trim() ||
config?.themeText?.trim() ||
session?.anchorPack.theme.value ||
'',
},
{
key: 'match3d-items',
label: '物品数量',
value: `${itemCount}`,
},
];
return entries
.filter((entry): entry is MiniGameAnchorSource => Boolean(entry))
.map((entry) => ({
id: entry.key,
label: entry.label,
value: entry.value,
}))
.filter((entry) => entry.value.trim());
}
export function buildSquareHoleGenerationAnchorEntries(
session: SquareHoleSessionSnapshot | null | undefined,
): CustomWorldStructuredAnchorEntry[] {

Some files were not shown because too many files have changed in this diff Show More