Merge remote-tracking branch 'origin/master' into codex/public-work-readmodel-smooth-transition

This commit is contained in:
kdletters
2026-05-26 16:38:38 +08:00
130 changed files with 2966 additions and 511 deletions

View File

@@ -83,7 +83,10 @@ import {
saveBabyObjectMatchDraft,
} from '../../services/edutainment-baby-object';
import { match3dCreationClient } from '../../services/match3d-creation';
import { createServerMatch3DRuntimeAdapter } from '../../services/match3d-runtime';
import {
createLocalMatch3DRuntimeAdapter,
createServerMatch3DRuntimeAdapter,
} from '../../services/match3d-runtime';
import {
deleteMatch3DWork,
getMatch3DWorkDetail,
@@ -257,6 +260,13 @@ function queryCreationTypeButton(name: string | RegExp) {
});
}
async function openPuzzleFormFromCreateHub(
user: ReturnType<typeof userEvent.setup>,
) {
await user.click(await findCreationTypeButton('拼图'));
await screen.findByText(//u);
}
async function openDraftHub(user: ReturnType<typeof userEvent.setup>) {
await clickFirstButtonByName(user, '草稿');
const panel = getPlatformTabPanel('saves');
@@ -291,7 +301,9 @@ async function openProfilePlayedWorks(
user: ReturnType<typeof userEvent.setup>,
) {
await clickFirstButtonByName(user, '我的');
await user.click(await screen.findByRole('button', { name: //u }));
await user.click(
await screen.findByRole('button', { name: //u }),
);
expect(await screen.findByText('可继续')).toBeTruthy();
}
@@ -655,6 +667,7 @@ vi.mock('../../services/match3dGeneratedModelCache', () => ({
}));
const match3dRuntimeServiceMocks = vi.hoisted(() => ({
createLocalMatch3DRuntimeAdapter: vi.fn(),
createServerMatch3DRuntimeAdapter: vi.fn(),
}));
@@ -667,6 +680,15 @@ const match3dServerRuntimeAdapterMock = vi.hoisted(() => ({
stopRun: vi.fn(),
}));
const match3dLocalRuntimeAdapterMock = vi.hoisted(() => ({
clickItem: vi.fn(),
finishTimeUp: vi.fn(),
getRun: vi.fn(),
restartRun: vi.fn(),
startRun: vi.fn(),
stopRun: vi.fn(),
}));
vi.mock('../../services/match3d-runtime', async () => {
const actual = await vi.importActual<
typeof import('../../services/match3d-runtime')
@@ -2376,6 +2398,9 @@ beforeEach(() => {
vi.mocked(createServerMatch3DRuntimeAdapter).mockReturnValue(
match3dServerRuntimeAdapterMock,
);
vi.mocked(createLocalMatch3DRuntimeAdapter).mockReturnValue(
match3dLocalRuntimeAdapterMock,
);
match3dServerRuntimeAdapterMock.startRun.mockRejectedValue(
new Error('未启动抓大鹅运行态'),
);
@@ -2391,6 +2416,21 @@ beforeEach(() => {
match3dServerRuntimeAdapterMock.stopRun.mockResolvedValue({
run: buildMockMatch3DRun('match3d-profile-stopped'),
});
match3dLocalRuntimeAdapterMock.startRun.mockResolvedValue({
run: buildMockMatch3DRun('match3d-demo-20260525'),
});
match3dLocalRuntimeAdapterMock.clickItem.mockRejectedValue(
new Error('未执行本地抓大鹅点击'),
);
match3dLocalRuntimeAdapterMock.restartRun.mockResolvedValue({
run: buildMockMatch3DRun('match3d-demo-20260525'),
});
match3dLocalRuntimeAdapterMock.finishTimeUp.mockResolvedValue({
run: buildMockMatch3DRun('match3d-demo-20260525'),
});
match3dLocalRuntimeAdapterMock.stopRun.mockResolvedValue({
run: buildMockMatch3DRun('match3d-demo-20260525'),
});
window.history.replaceState(null, '', '/');
window.sessionStorage.clear();
window.localStorage.clear();
@@ -3469,7 +3509,7 @@ test('create tab shows template tabs and embeds puzzle form by default', async (
expect(screen.getByRole('tablist', { name: '玩法模板分类' })).toBeTruthy();
expect(
screen.getByRole('tablist', { name: '玩法模板分类' }).className,
).toContain('scroll-px-3');
).toContain('scroll-px-2');
expect(
screen.getByRole('tab', { name: '最近创作' }).getAttribute('aria-selected'),
).toBe('true');
@@ -3511,7 +3551,7 @@ test('create tab opens puzzle entry form from the template card', async () => {
render(<TestWrapper withAuth />);
await openCreateTemplateHub(user);
await user.click(await findCreationTypeButton('拼图'));
await openPuzzleFormFromCreateHub(user);
expect(await screen.findByText('拼图工作区missing-session')).toBeTruthy();
expect(createPuzzleAgentSession).not.toHaveBeenCalled();
@@ -3663,7 +3703,11 @@ test('bark battle draft is visible in draft shelf while image assets are generat
await user.click(await findCreationTypeButton('汪汪声浪'));
await user.click(await screen.findByRole('button', { name: '生成草稿' }));
expect(await screen.findByText('自动生成素材')).toBeTruthy();
expect(
await screen.findByRole('progressbar', {
name: '汪汪声浪素材生成进度',
}),
).toBeTruthy();
await user.click(screen.getByRole('button', { name: '返回编辑' }));
await openDraftHub(user);
@@ -3728,7 +3772,7 @@ test('published bark battle stays visible when refresh temporarily returns only
await openDraftHub(user);
const panel = getPlatformTabPanel('saves');
await user.click(within(panel).getByRole('button', { name: /已发布/u }));
await user.click(within(panel).getByRole('tab', { name: /已发布/u }));
expect(await within(panel).findByText('汪汪测试杯')).toBeTruthy();
expect(
@@ -3757,12 +3801,16 @@ test('running match3d form generation can return to draft tab and reopen progres
render(<TestWrapper withAuth />);
await openCreateTemplateHub(user);
await user.click(screen.getByRole('tab', { name: '抓大鹅' }));
await user.click(await findCreationTypeButton('抓大鹅'));
await user.click(
await screen.findByRole('button', { name: '生成抓大鹅草稿' }),
);
expect(await screen.findByText('抓大鹅草稿生成进度')).toBeTruthy();
expect(
await screen.findByRole('progressbar', {
name: '抓大鹅草稿生成进度',
}),
).toBeTruthy();
await user.click(screen.getByRole('button', { name: '返回创作中心' }));
await openDraftHub(user);
@@ -3772,7 +3820,11 @@ test('running match3d form generation can return to draft tab and reopen progres
await user.click(
screen.getByRole('button', { name: /继续创作《抓大鹅草稿》/u }),
);
expect(await screen.findByText('抓大鹅草稿生成进度')).toBeTruthy();
expect(
await screen.findByRole('progressbar', {
name: '抓大鹅草稿生成进度',
}),
).toBeTruthy();
await act(async () => {
resolveCompile({ session: buildMockMatch3DAgentSession() });
@@ -3841,11 +3893,15 @@ test('running match3d persisted draft reopens progress instead of unfinished res
render(<TestWrapper withAuth />);
await openCreateTemplateHub(user);
await user.click(screen.getByRole('tab', { name: '抓大鹅' }));
await user.click(await findCreationTypeButton('抓大鹅'));
await user.click(
await screen.findByRole('button', { name: '生成抓大鹅草稿' }),
);
expect(await screen.findByText('抓大鹅草稿生成进度')).toBeTruthy();
expect(
await screen.findByRole('progressbar', {
name: '抓大鹅草稿生成进度',
}),
).toBeTruthy();
expect(await screen.findAllByText('素材生成仍在后台处理')).not.toHaveLength(
0,
);
@@ -3859,7 +3915,11 @@ test('running match3d persisted draft reopens progress instead of unfinished res
await screen.findByRole('button', { name: /继续创作《赛博水果摊》/u }),
);
expect(await screen.findByText('抓大鹅草稿生成进度')).toBeTruthy();
expect(
await screen.findByRole('progressbar', {
name: '抓大鹅草稿生成进度',
}),
).toBeTruthy();
expect(screen.queryByText('抓大鹅结果页')).toBeNull();
expect(match3dCreationClient.getSession).toHaveBeenCalledWith(
'match3d-running-persisted-session',
@@ -4038,17 +4098,22 @@ test('running match3d form generation keeps other creation templates available',
render(<TestWrapper withAuth />);
await openCreateTemplateHub(user);
await user.click(screen.getByRole('tab', { name: '抓大鹅' }));
await user.click(await findCreationTypeButton('抓大鹅'));
await user.click(
await screen.findByRole('button', { name: '生成抓大鹅草稿' }),
);
expect(await screen.findByText('抓大鹅草稿生成进度')).toBeTruthy();
expect(
await screen.findByRole('progressbar', {
name: '抓大鹅草稿生成进度',
}),
).toBeTruthy();
await user.click(screen.getByRole('button', { name: '返回创作中心' }));
const puzzleTab = await screen.findByRole('tab', { name: '拼图' });
expect((puzzleTab as HTMLButtonElement).disabled).toBe(false);
await openCreateTemplateHub(user);
const puzzleCard = await findCreationTypeButton('拼图');
expect((puzzleCard as HTMLButtonElement).disabled).toBe(false);
await user.click(puzzleTab);
await user.click(puzzleCard);
const generatePuzzleButton = await screen.findByRole('button', {
name: '生成草稿',
});
@@ -4107,16 +4172,21 @@ test('running match3d form generation keeps same template generation available',
render(<TestWrapper withAuth />);
await openCreateTemplateHub(user);
await user.click(screen.getByRole('tab', { name: '抓大鹅' }));
await user.click(await findCreationTypeButton('抓大鹅'));
await user.click(
await screen.findByRole('button', { name: '生成抓大鹅草稿' }),
);
expect(await screen.findByText('抓大鹅草稿生成进度')).toBeTruthy();
expect(
await screen.findByRole('progressbar', {
name: '抓大鹅草稿生成进度',
}),
).toBeTruthy();
await user.click(screen.getByRole('button', { name: '返回创作中心' }));
const match3dTab = await screen.findByRole('tab', { name: '抓大鹅' });
expect((match3dTab as HTMLButtonElement).disabled).toBe(false);
await user.click(match3dTab);
await openCreateTemplateHub(user);
const match3dCard = await findCreationTypeButton('抓大鹅');
expect((match3dCard as HTMLButtonElement).disabled).toBe(false);
await user.click(match3dCard);
const secondGenerateButton = await screen.findByRole('button', {
name: '生成抓大鹅草稿',
@@ -4143,7 +4213,11 @@ test('running match3d form generation keeps same template generation available',
expect.objectContaining({ action: 'match3d_compile_draft' }),
);
expect(await screen.findByText('抓大鹅草稿生成进度')).toBeTruthy();
expect(
await screen.findByRole('progressbar', {
name: '抓大鹅草稿生成进度',
}),
).toBeTruthy();
await user.click(screen.getByRole('button', { name: '返回创作中心' }));
await openDraftHub(user);
await waitFor(() => {
@@ -4213,15 +4287,23 @@ test('running puzzle form generation creates a new puzzle draft on same template
render(<TestWrapper withAuth />);
await openCreateTemplateHub(user);
await user.click(await screen.findByRole('button', { name: '生成草稿' }));
expect(await screen.findByText('拼图草稿生成进度')).toBeTruthy();
await user.click(await findCreationTypeButton('拼图'));
await user.click(
await screen.findByRole('button', { name: '生成草稿' }),
);
expect(
await screen.findByRole('progressbar', {
name: '拼图草稿生成进度',
}),
).toBeTruthy();
await user.click(screen.getByRole('button', { name: '返回创作中心' }));
const puzzleTab = await screen.findByRole('tab', { name: '拼图' });
expect((puzzleTab as HTMLButtonElement).disabled).toBe(false);
await user.click(puzzleTab);
await openCreateTemplateHub(user);
const puzzleCard = await findCreationTypeButton('拼图');
expect((puzzleCard as HTMLButtonElement).disabled).toBe(false);
await user.click(puzzleCard);
expect(await screen.findByText('拼图工作区:missing-session')).toBeTruthy();
expect(await screen.findByText(/拼图工作区:/u)).toBeTruthy();
expect(screen.getByTestId('puzzle-workspace-busy-state')).toHaveProperty(
'textContent',
'idle',
@@ -4232,9 +4314,7 @@ test('running puzzle form generation creates a new puzzle draft on same template
expect((secondGenerateButton as HTMLButtonElement).disabled).toBe(false);
await user.click(secondGenerateButton);
await waitFor(() => {
expect(createPuzzleAgentSession).toHaveBeenCalledTimes(2);
});
expect(createPuzzleAgentSession).toHaveBeenCalledTimes(1);
expect(executePuzzleAgentAction).toHaveBeenCalledTimes(2);
expect(executePuzzleAgentAction).toHaveBeenNthCalledWith(
1,
@@ -4243,11 +4323,15 @@ test('running puzzle form generation creates a new puzzle draft on same template
);
expect(executePuzzleAgentAction).toHaveBeenNthCalledWith(
2,
'puzzle-parallel-session-2',
'puzzle-session-1',
expect.objectContaining({ action: 'compile_puzzle_draft' }),
);
expect(await screen.findByText('拼图草稿生成进度')).toBeTruthy();
expect(
await screen.findByRole('progressbar', {
name: '拼图草稿生成进度',
}),
).toBeTruthy();
await user.click(screen.getByRole('button', { name: '返回创作中心' }));
await openDraftHub(user);
await waitFor(() => {
@@ -4319,8 +4403,15 @@ test('running puzzle draft opens generation progress from draft tab', async () =
render(<TestWrapper withAuth />);
await openCreateTemplateHub(user);
await user.click(await screen.findByRole('button', { name: '生成草稿' }));
expect(await screen.findByText('拼图草稿生成进度')).toBeTruthy();
await user.click(await findCreationTypeButton('拼图'));
await user.click(
await screen.findByRole('button', { name: '生成草稿' }),
);
expect(
await screen.findByRole('progressbar', {
name: '拼图草稿生成进度',
}),
).toBeTruthy();
await user.click(screen.getByRole('button', { name: '返回创作中心' }));
await openDraftHub(user);
@@ -4330,7 +4421,11 @@ test('running puzzle draft opens generation progress from draft tab', async () =
screen.getByRole('button', { name: /继续创作《拼图草稿》/u }),
);
expect(await screen.findByText('拼图草稿生成进度')).toBeTruthy();
expect(
await screen.findByRole('progressbar', {
name: '拼图草稿生成进度',
}),
).toBeTruthy();
expect(screen.queryByText('拼图结果页')).toBeNull();
await act(async () => {
@@ -4363,7 +4458,9 @@ test('puzzle form checks mud points before creating a draft', async () => {
await openCreateTemplateHub(user);
await user.click(await findCreationTypeButton('拼图'));
await user.click(await screen.findByRole('button', { name: '生成草稿' }));
await user.click(
await screen.findByRole('button', { name: '生成草稿' }),
);
const noticeDialog = await screen.findByRole('dialog', { name: '泥点不足' });
expect(
@@ -4721,7 +4818,7 @@ test('match3d draft generation auto starts trial and runtime back opens draft re
render(<TestWrapper withAuth />);
await openCreateTemplateHub(user);
await user.click(screen.getByRole('tab', { name: '抓大鹅' }));
await user.click(await findCreationTypeButton('抓大鹅'));
await user.click(
await screen.findByRole('button', { name: '生成抓大鹅草稿' }),
);
@@ -4953,11 +5050,15 @@ test('completed match3d draft notice first opens trial then reopens result', asy
render(<TestWrapper withAuth />);
await openCreateTemplateHub(user);
await user.click(screen.getByRole('tab', { name: '抓大鹅' }));
await user.click(await findCreationTypeButton('抓大鹅'));
await user.click(
await screen.findByRole('button', { name: '生成抓大鹅草稿' }),
);
expect(await screen.findByText('抓大鹅草稿生成进度')).toBeTruthy();
expect(
await screen.findByRole('progressbar', {
name: '抓大鹅草稿生成进度',
}),
).toBeTruthy();
await user.click(screen.getByRole('button', { name: '返回创作中心' }));
await openDraftHub(user);
await expectDraftHubGeneratingBadgeCountAtLeast(1);
@@ -4974,7 +5075,11 @@ test('completed match3d draft notice first opens trial then reopens result', asy
);
expect(await screen.findByText(/抓大鹅运行态/u)).toBeTruthy();
expect(screen.queryByText('抓大鹅草稿生成进度')).toBeNull();
expect(
screen.queryByRole('progressbar', {
name: '抓大鹅草稿生成进度',
}),
).toBeNull();
expect(match3dServerRuntimeAdapterMock.startRun).toHaveBeenCalledTimes(1);
await waitFor(() => {
expect(
@@ -5016,14 +5121,7 @@ test('completed baby object match draft viewed immediately does not keep unread
render(<TestWrapper withAuth />);
await openCreateTemplateHub(user);
await user.click(screen.getByRole('tab', { name: '宝贝识物' }));
await waitFor(() => {
expect(
screen
.getByRole('tab', { name: '宝贝识物' })
.getAttribute('aria-selected'),
).toBe('true');
});
await user.click(await findCreationTypeButton('宝贝识物'));
await user.type(await screen.findByLabelText('物品 A'), '苹果');
await user.type(await screen.findByLabelText('物品 B'), '香蕉');
await user.click(screen.getByRole('button', { name: '生成宝贝识物草稿' }));
@@ -5074,12 +5172,16 @@ test('completed baby object match draft shows unread marker after leaving genera
render(<TestWrapper withAuth />);
await openCreateTemplateHub(user);
await user.click(screen.getByRole('tab', { name: '宝贝识物' }));
await user.click(await findCreationTypeButton('宝贝识物'));
await user.type(await screen.findByLabelText('物品 A'), '苹果');
await user.type(await screen.findByLabelText('物品 B'), '香蕉');
await user.click(screen.getByRole('button', { name: '生成宝贝识物草稿' }));
expect(await screen.findByText('宝贝识物草稿生成进度')).toBeTruthy();
expect(
await screen.findByRole('progressbar', {
name: '宝贝识物草稿生成进度',
}),
).toBeTruthy();
await user.click(screen.getByRole('button', { name: '返回创作中心' }));
await openDraftHub(user);
@@ -5173,7 +5275,9 @@ test('puzzle draft generation auto starts trial and runtime back opens draft res
await openCreateTemplateHub(user);
await user.click(await findCreationTypeButton('拼图'));
await user.click(await screen.findByRole('button', { name: '生成草稿' }));
await user.click(
await screen.findByRole('button', { name: '生成草稿' }),
);
await waitFor(() => {
expect(updatePuzzleWork).toHaveBeenCalledWith(
@@ -5259,7 +5363,10 @@ test('embedded puzzle form recovers when compile request times out after backend
render(<TestWrapper withAuth />);
await openCreateTemplateHub(user);
await user.click(screen.getByRole('button', { name: '生成草稿' }));
await user.click(await findCreationTypeButton('拼图'));
await user.click(
await screen.findByRole('button', { name: '生成草稿' }),
);
await waitFor(() => {
expect(getPuzzleAgentSession).toHaveBeenCalledWith(
@@ -5296,12 +5403,10 @@ test('embedded puzzle form routes through requireAuth while logged out', async (
);
await openCreateTemplateHub(user);
const generateButton = await screen.findByRole('button', {
name: /生成草稿/u,
});
await user.click(await findCreationTypeButton('拼图'));
await user.click(generateButton);
expect(requireAuth).toHaveBeenCalledTimes(1);
expect(screen.queryByText('拼图工作区missing-session')).toBeNull();
expect(createCreativeAgentSession).not.toHaveBeenCalled();
expect(streamCreativeAgentMessage).not.toHaveBeenCalled();
expect(createRpgCreationSession).not.toHaveBeenCalled();
@@ -7120,7 +7225,9 @@ test('embedded puzzle form maps raw bearer token errors to user-facing auth copy
render(<TestWrapper withAuth />);
await openCreateTemplateHub(user);
const generateButton = screen.getByRole('button', { name: /生成草稿/u });
await user.click(await findCreationTypeButton('拼图'));
await screen.findByText(/拼图工作区:/u);
const generateButton = screen.getByRole('button', { name: '生成草稿' });
expect((generateButton as HTMLButtonElement).disabled).toBe(false);
await user.click(generateButton);
@@ -7157,8 +7264,10 @@ test('embedded puzzle form timeout exits busy state and shows a readable error',
render(<TestWrapper withAuth />);
await openCreateTemplateHub(user);
await user.click(await findCreationTypeButton('拼图'));
await screen.findByText(/拼图工作区:/u);
const button = screen.getByRole('button', { name: /生成草稿/u });
const button = screen.getByRole('button', { name: '生成草稿' });
await user.click(button);
await waitFor(() => {
@@ -7188,7 +7297,7 @@ test('match3d creation tab stays usable even when public galleries fail', async
await openCreateTemplateHub(user);
expect(screen.queryByText('读取作品广场失败')).toBeNull();
expect(screen.queryByText('读取抓大鹅广场失败')).toBeNull();
expect(screen.getByRole('tab', { name: '抓大鹅' })).toBeTruthy();
expect(await findCreationTypeButton('抓大鹅')).toBeTruthy();
expect(match3dCreationClient.createSession).not.toHaveBeenCalled();
});
@@ -7236,7 +7345,7 @@ test('puzzle draft result back button returns to creation hub', async () => {
expect(
await screen.findByRole('tablist', { name: '玩法模板分类' }),
).toBeTruthy();
expect(await screen.findByText('拼图工作区missing-session')).toBeTruthy();
expect(await findCreationTypeButton('拼图')).toBeTruthy();
expect(
screen.queryByText('雨夜里有一只会发光的猫站在遗迹台阶上。'),
).toBeNull();
@@ -7616,10 +7725,7 @@ test('formal puzzle runtime uses frontend move merge logic and backend leaderboa
await user.click(within(dialog).getByRole('button', { name: '下一关' }));
await waitFor(() => {
expect(advancePuzzleNextLevel).toHaveBeenCalledWith(
clearedFirstLevel.runId,
{},
);
expect(advancePuzzleNextLevel).toHaveBeenCalledWith(clearedFirstLevel.runId);
});
expect(
(
@@ -7964,6 +8070,9 @@ test('recommend puzzle remix return restarts recommendation instead of stale loa
profileId: 'puzzle-profile-public-1',
levelId: null,
},
expect.objectContaining({
authImpact: 'local',
}),
);
});
expect(screen.queryByText('正在进入拼图关卡')).toBeNull();
@@ -8050,6 +8159,7 @@ test('missing puzzle public detail returns to platform home', async () => {
);
render(<TestWrapper />);
vi.mocked(startPuzzleRun).mockClear();
await openDiscoverHub(user);
const workCards = await screen.findAllByRole('button', { name: /失效拼图/u });
@@ -8061,7 +8171,6 @@ test('missing puzzle public detail returns to platform home', async () => {
expect(getPlatformTabPanel('home').getAttribute('aria-hidden')).toBe('false');
expect(screen.queryByText('详情')).toBeNull();
expect(screen.queryByText('资源不存在')).toBeNull();
expect(startPuzzleRun).toHaveBeenCalledTimes(0);
});
test('direct missing public work detail alert returns to platform home', async () => {
@@ -8197,6 +8306,38 @@ test('public code search opens a published Match3D work by M3 code and starts ru
expect(getRpgEntryWorldGalleryDetailByCode).not.toHaveBeenCalled();
});
test('public code search opens the local Match3D demo and starts local runtime', async () => {
const user = userEvent.setup();
vi.mocked(listMatch3DGallery).mockResolvedValue({ items: [] });
render(<TestWrapper withAuth />);
await openDiscoverHub(user);
const searchInput =
await screen.findByPlaceholderText('搜索作品号、名称、作者、描述');
await user.type(searchInput, 'M3-20260525');
await user.click(screen.getByRole('button', { name: '搜索' }));
expect(await screen.findByText('详情')).toBeTruthy();
expect(screen.getByText('海底糖果集市')).toBeTruthy();
await user.click(screen.getByRole('button', { name: '启动' }));
await waitFor(() => {
expect(match3dLocalRuntimeAdapterMock.startRun).toHaveBeenCalledWith(
'match3d-demo-20260525',
{},
);
});
expect(match3dServerRuntimeAdapterMock.startRun).not.toHaveBeenCalled();
expect(getMatch3DWorkDetail).not.toHaveBeenCalledWith(
'match3d-demo-20260525',
);
expect(
await screen.findByText('抓大鹅运行态match3d-run-match3d-demo-20260525'),
).toBeTruthy();
});
test('published Match3D runtime receives persisted generated models', async () => {
const user = userEvent.setup();
const match3dWork: Match3DWorkSummary = {
@@ -8320,9 +8461,12 @@ test('starting draft generation leaves the agent workspace and shows the generat
);
});
expect(await screen.findByText('世界草稿生成进度')).toBeTruthy();
expect(
await screen.findByRole('progressbar', {
name: '世界草稿生成进度',
}),
).toBeTruthy();
expect(screen.queryByText(/Agent工作区/u)).toBeNull();
expect(screen.getAllByText('生成世界底稿').length).toBeGreaterThan(0);
expect(screen.getByText('当前世界信息')).toBeTruthy();
expect(screen.queryByText('回到工作区')).toBeNull();
expect(screen.getByText('世界承诺')).toBeTruthy();
@@ -8355,7 +8499,11 @@ test('running custom world draft generation can return to creation center with s
).toBeTruthy();
await user.click(screen.getByRole('button', { name: '开始生成草稿' }));
expect(await screen.findByText('世界草稿生成进度')).toBeTruthy();
expect(
await screen.findByRole('progressbar', {
name: '世界草稿生成进度',
}),
).toBeTruthy();
await user.click(screen.getByRole('button', { name: '返回创作中心' }));
expect(
@@ -8385,9 +8533,12 @@ test('refresh restores running draft generation progress instead of agent worksp
render(<TestWrapper withAuth />);
expect(await screen.findByText('世界草稿生成进度')).toBeTruthy();
expect(
await screen.findByRole('progressbar', {
name: '世界草稿生成进度',
}),
).toBeTruthy();
expect(screen.queryByText(/Agent工作区/u)).toBeNull();
expect(screen.getAllByText('生成世界底稿').length).toBeGreaterThan(0);
});
test('failed draft work continues on generation progress view instead of agent workspace', async () => {
@@ -8436,7 +8587,11 @@ test('failed draft work continues on generation progress view instead of agent w
expect(await screen.findByText('失败中的潮雾列岛')).toBeTruthy();
await user.click(await screen.findByRole('button', { name: /继续创作/u }));
expect(await screen.findByText('世界草稿生成进度')).toBeTruthy();
expect(
await screen.findByRole('progressbar', {
name: '世界草稿生成进度',
}),
).toBeTruthy();
expect(screen.queryByText(/Agent工作区/u)).toBeNull();
});
@@ -9807,7 +9962,7 @@ test('save tab can resume a selected archive directly into the game', async () =
});
});
test('profile page exposes save archive picker as a direct entry', async () => {
test('profile page keeps save archives inside played stats panel', async () => {
const user = userEvent.setup();
const handleContinueGame = vi.fn();
@@ -9849,20 +10004,11 @@ test('profile page exposes save archive picker as a direct entry', async () => {
render(<TestWrapper withAuth onContinueGame={handleContinueGame} />);
await clickFirstButtonByName(user, '我的');
const shortcutRegion = await screen.findByRole('region', {
name: '常用功能',
});
await user.click(
within(shortcutRegion).getByRole('button', { name: /存档/u }),
);
await openProfilePlayedWorks(user);
const closeButton = await screen.findByLabelText('关闭存档');
const modal = closeButton.closest('.fixed') as HTMLElement;
expect(modal).toBeTruthy();
expect(within(modal).getByText('SAVES')).toBeTruthy();
await user.click(within(modal).getByRole('button', { name: /潮雾列岛/u }));
expect(screen.queryByLabelText('关闭存档')).toBeNull();
expect(screen.queryByText('SAVES')).toBeNull();
await clickFirstAsyncButtonByName(user, /潮雾列岛/u);
await waitFor(() => {
expect(resumeProfileSaveArchive).toHaveBeenCalledWith('custom:world-1');

View File

@@ -1039,6 +1039,15 @@ afterEach(() => {
vi.clearAllMocks();
vi.unstubAllEnvs();
vi.unstubAllGlobals();
Object.defineProperty(HTMLMediaElement.prototype, 'play', {
configurable: true,
value: vi.fn(async () => undefined),
});
Object.defineProperty(navigator, 'mediaDevices', {
configurable: true,
value: undefined,
});
Reflect.deleteProperty(globalThis as Record<string, unknown>, 'BarcodeDetector');
window.wx = undefined;
document
.querySelectorAll(
@@ -1836,10 +1845,68 @@ test('profile daily task shortcut opens task center and claims reward', async ()
});
expect(onRechargeSuccess).toHaveBeenCalledTimes(1);
expect(await screen.findByText('已领取 10 泥点')).toBeTruthy();
expect(
(screen.getByRole('button', { name: '已领取' }) as HTMLButtonElement)
.disabled,
).toBe(true);
expect(screen.queryByRole('button', { name: '已领取' })).toBeNull();
expect(screen.getByText('暂无任务')).toBeTruthy();
});
test('profile task center keeps only the highest priority actionable task', async () => {
const user = userEvent.setup();
mockGetRpgProfileTasks.mockResolvedValueOnce(
mockBuildTaskCenter({
tasks: [
{
taskId: 'claimed_low',
title: '低优先级已完成',
description: '',
eventKey: 'profile.task.claimed_low',
cycle: 'daily',
threshold: 1,
progressCount: 1,
rewardPoints: 5,
status: 'claimed',
dayKey: 20260503,
claimedAt: '2026-05-03T08:01:00Z',
updatedAt: '2026-05-03T08:01:00Z',
},
{
taskId: 'claimable_mid',
title: '中优先级可领取',
description: '',
eventKey: 'profile.task.claimable_mid',
cycle: 'daily',
threshold: 2,
progressCount: 2,
rewardPoints: 10,
status: 'claimable',
dayKey: 20260503,
claimedAt: null,
updatedAt: '2026-05-03T08:01:00Z',
},
{
taskId: 'incomplete_high',
title: '高优先级未完成',
description: '',
eventKey: 'profile.task.incomplete_high',
cycle: 'daily',
threshold: 3,
progressCount: 1,
rewardPoints: 20,
status: 'incomplete',
dayKey: 20260503,
claimedAt: null,
updatedAt: '2026-05-03T08:01:00Z',
},
],
}),
);
renderProfileView();
await user.click(screen.getByRole('button', { name: //u }));
expect(await screen.findByText('中优先级可领取')).toBeTruthy();
expect(screen.queryByText('高优先级未完成')).toBeNull();
expect(screen.queryByText('低优先级已完成')).toBeNull();
});
test('profile total play time card always uses hours', () => {
@@ -1886,21 +1953,35 @@ test('profile stats cards are centered without update timestamp', () => {
});
test('mobile profile page matches the reference layout sections', async () => {
mockWechatMobileLayout();
mockNarrowMobileLayout();
const { container } = renderProfileView(vi.fn(), {
walletBalance: 70,
totalPlayTimeMs: 0,
playedWorldCount: 0,
}, { createdAt: buildFreshProfileCreatedAt() });
});
const profilePage = container.querySelector('.platform-profile-page');
expect(profilePage).toBeTruthy();
expect(profilePage?.classList.contains('platform-page-stage')).toBe(true);
expect(profilePage?.classList.contains('platform-page-stage')).toBe(false);
expect(profilePage?.querySelector('.platform-profile-scene-decor')).toBeTruthy();
expect(profilePage?.classList.contains('platform-profile-page')).toBe(true);
expect(profilePage?.getAttribute('style') ?? '').not.toContain('overflow: hidden');
const topbar = container.querySelector('.platform-mobile-topbar');
expect(topbar).toBeTruthy();
expect(
within(topbar as HTMLElement).getByRole('button', { name: '扫码' }),
).toBeTruthy();
expect(
within(topbar as HTMLElement).getByRole('button', { name: '打开设置' }),
).toBeTruthy();
expect(
within(topbar as HTMLElement).queryByRole('button', {
name: //u,
}),
).toBeNull();
const membershipCard = screen.getByRole('button', { name: '查看权益' });
expect(membershipCard.className).toContain('platform-profile-membership-card');
expect(
@@ -1918,6 +1999,7 @@ test('mobile profile page matches the reference layout sections', async () => {
expect(
within(statPanel).getByRole('button', { name: /\s*70/u }).className,
).toContain('platform-profile-stat-card');
expect(statPanel.querySelectorAll('.platform-profile-stat-card__icon')).toHaveLength(3);
const dailyTask = screen.getByRole('button', { name: //u });
expect(dailyTask.className).toContain('platform-profile-daily-task-card');
@@ -1957,18 +2039,11 @@ test('mobile profile page matches the reference layout sections', async () => {
within(settingsRegion).getByRole('button', { name: new RegExp(label, 'u') }),
).toBeTruthy();
}
expect(
within(settingsRegion).queryByRole('button', { name: //u }),
).toBeNull();
const secondaryShortcuts = screen.getByRole('region', {
name: '次级入口',
});
expect(
within(secondaryShortcuts).getByRole('button', { name: //u }),
).toBeTruthy();
expect(
await within(secondaryShortcuts).findByRole('button', {
name: //u,
}),
).toBeTruthy();
expect(screen.queryByRole('region', { name: '次级入口' })).toBeNull();
const profileHeader = profilePage?.querySelector('.platform-profile-header');
expect(profileHeader).toBeTruthy();
@@ -1986,6 +2061,46 @@ test('mobile profile page matches the reference layout sections', async () => {
expect(legalRegion.querySelector('.platform-profile-legal-strip__link')).toBeTruthy();
});
test('profile scan action opens camera scanner instead of recharge panel', async () => {
const user = userEvent.setup();
const stopTrack = vi.fn();
const stream = {
getTracks: () => [{ stop: stopTrack }],
} as unknown as MediaStream;
const getUserMedia = vi.fn(async () => stream);
mockNarrowMobileLayout();
Object.defineProperty(globalThis, 'BarcodeDetector', {
configurable: true,
value: class {
async detect() {
return [];
}
},
});
Object.defineProperty(navigator, 'mediaDevices', {
configurable: true,
value: { getUserMedia },
});
renderProfileView();
const topbar = document.querySelector('.platform-mobile-topbar');
expect(topbar).toBeTruthy();
await user.click(
within(topbar as HTMLElement).getByRole('button', { name: '扫码' }),
);
expect(await screen.findByRole('dialog', { name: '扫码' })).toBeTruthy();
await waitFor(() => {
expect(getUserMedia).toHaveBeenCalledWith({
audio: false,
video: { facingMode: { ideal: 'environment' } },
});
});
expect(mockGetRpgProfileRechargeCenter).not.toHaveBeenCalled();
});
test('desktop account entry uses saved avatar image when available', () => {
mockDesktopLayout();
const avatarUrl = 'data:image/png;base64,AAAA';
@@ -2199,7 +2314,7 @@ test('opens reward code modal from profile action on mobile', async () => {
expect(screen.getByLabelText('关闭兑换码')).toBeTruthy();
});
test('profile page shows legal entries and ICP record link', async () => {
test('profile page shows legal entries and hides archive shortcuts', async () => {
const user = userEvent.setup();
renderProfileView();
@@ -2225,18 +2340,9 @@ test('profile page shows legal entries and ICP record link', async () => {
const settingsRegion = screen.getByRole('region', { name: '设置入口' });
expect(
within(settingsRegion).getByRole('button', { name: //u }),
).toBeTruthy();
const secondaryShortcuts = screen.getByRole('region', {
name: '次级入口',
});
expect(
within(secondaryShortcuts).getByRole('button', { name: //u }),
).toBeTruthy();
expect(
within(secondaryShortcuts).queryByRole('button', { name: //u }),
within(settingsRegion).queryByRole('button', { name: //u }),
).toBeNull();
expect(screen.queryByRole('region', { name: '次级入口' })).toBeNull();
const legalRegion = screen.getByRole('region', { name: '法律信息' });
expect(

View File

@@ -1,6 +1,5 @@
import {
AlertCircle,
Archive,
ArrowRight,
BookOpen,
Camera,
@@ -79,6 +78,7 @@ import type {
WechatMiniProgramPayParams,
WechatNativePayment,
} from '../../../packages/shared/src/contracts/runtime';
import { isMatch3DDemoProfileId } from '../../data/match3dDemoGalleryCard';
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
import { buildPublicWorkDetailUrl } from '../../routing/appPageRoutes';
import type { AuthUser } from '../../services/authService';
@@ -123,6 +123,7 @@ import {
SquareImageCropModal,
type SquareImageCropRect,
} from '../common/SquareImageCropModal';
import { CustomWorldCoverArtwork } from '../CustomWorldCoverArtwork';
import {
canExposePublicWork,
EDUTAINMENT_WORK_TAG,
@@ -132,7 +133,6 @@ import {
isEdutainmentEntryEnabled,
} from '../platform-entry/platformEdutainmentVisibility';
import { getInitialPlatformDesktopLayout } from '../platform-entry/platformEntryResponsive';
import { CustomWorldCoverArtwork } from '../CustomWorldCoverArtwork';
import { ResolvedAssetImage } from '../ResolvedAssetImage';
import { RpgEntryBrandLogo } from './RpgEntryBrandLogo';
import {
@@ -227,6 +227,8 @@ const HERO_SURFACE_CLASS =
'platform-surface platform-surface--hero platform-interactive-card min-w-0';
const MOBILE_PAGE_STAGE_CLASS =
'platform-page-stage platform-remap-surface min-w-0 space-y-4 overflow-hidden pb-2';
const MOBILE_PROFILE_PAGE_STAGE_CLASS =
'platform-remap-surface min-w-0 space-y-4 pb-2';
const MOBILE_RECOMMEND_PAGE_STAGE_CLASS =
'platform-page-stage min-w-0 space-y-4 overflow-hidden pb-2';
const MOBILE_DISCOVER_PAGE_STAGE_CLASS =
@@ -254,9 +256,36 @@ const RECOMMEND_ENTRY_DRAG_LIMIT_PX = 160;
const WECHAT_JS_SDK_URL = 'https://res.wx.qq.com/open/js/jweixin-1.6.0.js';
const WECHAT_PAY_CONFIRM_RETRY_DELAYS_MS = [800, 1600, 3000] as const;
const WECHAT_NATIVE_PAY_QR_IMAGE_SIZE = 180;
const PROFILE_TASK_STATUS_PRIORITY_RANK: Record<ProfileTaskItem['status'], number> = {
claimable: 2,
incomplete: 1,
disabled: 0,
claimed: -1,
};
const PROFILE_QR_SCAN_INTERVAL_MS = 360;
function selectProfileTaskCenterTasks(tasks: ProfileTaskItem[]) {
return tasks
.map((task, index) => ({ task, index }))
.filter(({ task }) => task.status === 'claimable' || task.status === 'incomplete')
.sort(
(left, right) =>
PROFILE_TASK_STATUS_PRIORITY_RANK[right.task.status] -
PROFILE_TASK_STATUS_PRIORITY_RANK[left.task.status] ||
left.index - right.index,
)
.slice(0, 1)
.map(({ task }) => task);
}
type ProfileReferralPanel = 'invite' | 'redeem' | 'community';
type ProfilePopupPanel = ProfileReferralPanel | 'saveArchives';
type BarcodeDetectorLike = {
detect: (source: CanvasImageSource) => Promise<Array<{ rawValue?: string }>>;
};
type BarcodeDetectorConstructorLike = new (options?: {
formats?: string[];
}) => BarcodeDetectorLike;
type RechargeTab = 'points' | 'membership';
type WechatMiniProgramPaymentStatus = 'success' | 'fail' | 'cancel';
type WechatPayResult = {
@@ -270,6 +299,13 @@ type RechargePaymentResult = {
title: string;
message: string;
};
function getBarcodeDetectorConstructor(): BarcodeDetectorConstructorLike | null {
const maybeDetector = (globalThis as unknown as {
BarcodeDetector?: BarcodeDetectorConstructorLike;
}).BarcodeDetector;
return typeof maybeDetector === 'function' ? maybeDetector : null;
}
type NativeWechatPaymentState = WechatNativePayment & {
orderId: string;
isConfirming: boolean;
@@ -717,69 +753,6 @@ function WorldCard({
);
}
function RecommendCoverOnlyCard({
entry,
authorAvatarUrl,
onClick,
}: {
entry: PlatformPublicGalleryCard;
authorAvatarUrl?: string | null;
onClick: () => void;
}) {
const coverImage = resolvePlatformWorldCoverImage(entry);
const fallbackCoverImage = resolvePlatformWorldFallbackCoverImage(entry);
const displayName = formatPlatformWorkDisplayName(entry.worldName);
const typeLabel = describePublicGalleryCardKind(entry);
const authorName = entry.authorDisplayName.trim() || '玩家';
const authorAvatarLabel = getPublicAuthorAvatarLabel(authorName);
const normalizedAuthorAvatarUrl = authorAvatarUrl?.trim() ?? '';
return (
<button
type="button"
onClick={onClick}
aria-label={`登录后游玩 ${entry.worldName}`}
className="platform-recommend-cover-only"
>
{coverImage ? (
<PlatformWorkCoverArtwork
entry={entry}
imageSrc={coverImage}
fallbackSrc={fallbackCoverImage}
alt={entry.worldName}
className="absolute inset-0 h-full w-full object-cover"
/>
) : (
<div className="absolute inset-0 bg-[radial-gradient(circle_at_18%_16%,rgba(255,255,255,0.28),transparent_30%),linear-gradient(135deg,rgba(255,118,117,0.42),rgba(89,164,255,0.34))]" />
)}
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(0,0,0,0.04),rgba(0,0,0,0.42))]" />
<div className="platform-recommend-cover-only__body">
<span className="platform-public-work-card__kind">{typeLabel}</span>
<span className="platform-recommend-cover-only__title">
{displayName}
</span>
<span className="platform-recommend-cover-only__author">
<span
aria-hidden="true"
className="platform-public-work-card__author-avatar"
>
{normalizedAuthorAvatarUrl ? (
<img
src={normalizedAuthorAvatarUrl}
alt=""
className="platform-public-work-card__author-avatar-image"
/>
) : (
authorAvatarLabel
)}
</span>
<span className="truncate">{authorName}</span>
</span>
</div>
</button>
);
}
function CreationLibraryCard({
entry,
onClick,
@@ -3244,7 +3217,7 @@ function ProfileTaskCenterModal({
onRetry: () => void;
onClaim: (taskId: string) => void;
}) {
const tasks = center?.tasks ?? [];
const tasks = selectProfileTaskCenterTasks(center?.tasks ?? []);
const walletBalance = center?.walletBalance ?? fallbackBalance;
return (
@@ -3420,6 +3393,160 @@ function RewardCodeRedeemModal({
);
}
function ProfileQrScannerModal({
error,
result,
onClose,
onError,
onResult,
}: {
error: string | null;
result: string | null;
onClose: () => void;
onError: (message: string) => void;
onResult: (value: string) => void;
}) {
const videoRef = useRef<HTMLVideoElement | null>(null);
const streamRef = useRef<MediaStream | null>(null);
useEffect(() => {
const videoElement = videoRef.current;
if (!videoElement) {
return;
}
let isMounted = true;
let scanTimer: number | null = null;
const detectorCtor = getBarcodeDetectorConstructor();
const detector = detectorCtor
? new detectorCtor({ formats: ['qr_code'] })
: null;
const clearScanTimer = () => {
if (scanTimer !== null) {
window.clearTimeout(scanTimer);
scanTimer = null;
}
};
const stopCamera = () => {
const stream = streamRef.current;
streamRef.current = null;
if (stream) {
stream.getTracks().forEach((track) => track.stop());
}
videoElement.srcObject = null;
};
const scanVideo = async () => {
if (!isMounted || !detector || videoElement.readyState < 2) {
if (isMounted && detector) {
scanTimer = window.setTimeout(scanVideo, PROFILE_QR_SCAN_INTERVAL_MS);
}
return;
}
try {
const codes = await detector.detect(videoElement);
const rawValue = codes[0]?.rawValue?.trim();
if (rawValue) {
clearScanTimer();
stopCamera();
onResult(rawValue);
return;
}
} catch {
onError('扫码识别失败,请调整二维码位置');
}
if (isMounted) {
scanTimer = window.setTimeout(scanVideo, PROFILE_QR_SCAN_INTERVAL_MS);
}
};
const startCamera = async () => {
if (typeof navigator === 'undefined' || !navigator.mediaDevices?.getUserMedia) {
onError('当前浏览器不支持摄像头扫码');
return;
}
try {
const stream = await navigator.mediaDevices.getUserMedia({
audio: false,
video: { facingMode: { ideal: 'environment' } },
});
if (!isMounted) {
stream.getTracks().forEach((track) => track.stop());
return;
}
streamRef.current?.getTracks().forEach((track) => track.stop());
streamRef.current = stream;
videoElement.srcObject = stream;
await videoElement.play();
if (!detector) {
onError('当前浏览器暂不支持二维码识别');
return;
}
scanTimer = window.setTimeout(scanVideo, PROFILE_QR_SCAN_INTERVAL_MS);
} catch {
onError('无法打开摄像头,请检查权限');
}
};
void startCamera();
return () => {
isMounted = false;
clearScanTimer();
stopCamera();
};
}, [onError, onResult]);
return (
<div
className="platform-modal-backdrop fixed inset-0 z-[80] flex items-center justify-center px-4 py-6"
role="dialog"
aria-modal="true"
aria-label="扫码"
>
<div className="platform-qr-scanner-modal w-full max-w-sm overflow-hidden rounded-[1.4rem]">
<div className="flex items-center justify-between border-b border-white/10 px-5 py-4">
<div className="text-base font-black"></div>
<button
type="button"
aria-label="关闭扫码"
onClick={onClose}
className="platform-modal-close flex h-9 w-9 items-center justify-center rounded-full"
>
×
</button>
</div>
<div className="space-y-3 px-5 py-5">
<div className="platform-qr-scanner-modal__viewport">
<video
ref={videoRef}
className="h-full w-full object-cover"
playsInline
muted
/>
<span className="platform-qr-scanner-modal__frame" />
</div>
{result ? (
<div className="platform-profile-success rounded-2xl px-3 py-2 text-xs font-semibold">
{result}
</div>
) : error ? (
<div className="platform-profile-error rounded-2xl px-3 py-2 text-xs font-semibold">
{error}
</div>
) : null}
</div>
</div>
</div>
);
}
function ProfileReferralModal({
panel,
center,
@@ -3898,6 +4025,9 @@ export function RpgEntryHomeView({
const [isLoadingTaskCenter, setIsLoadingTaskCenter] = useState(false);
const [claimingTaskId, setClaimingTaskId] = useState<string | null>(null);
const [taskClaimSuccess, setTaskClaimSuccess] = useState<string | null>(null);
const [isQrScannerOpen, setIsQrScannerOpen] = useState(false);
const [qrScannerError, setQrScannerError] = useState<string | null>(null);
const [qrScannerResult, setQrScannerResult] = useState<string | null>(null);
const [profilePopupPanel, setProfilePopupPanel] =
useState<ProfilePopupPanel | null>(null);
const [referralCenter, setReferralCenter] =
@@ -3935,6 +4065,7 @@ export function RpgEntryHomeView({
const [mobileCenteredCardKey, setMobileCenteredCardKey] = useState<
string | null
>(null);
const hasManualCategoryTagSelectionRef = useRef(false);
const pendingPublicAuthorKeysRef = useRef<Set<string>>(new Set());
const [publicAuthorSummariesByKey, setPublicAuthorSummariesByKey] = useState<
Record<string, PublicUserSummary | null>
@@ -4159,16 +4290,33 @@ export function RpgEntryHomeView({
useEffect(() => {
if (categoryGroups.length === 0) {
setSelectedCategoryTag(null);
hasManualCategoryTagSelectionRef.current = false;
return;
}
const firstCategoryGroup = categoryGroups[0];
const firstCategoryGroup =
categoryGroups.find((group) =>
group.entries.some((entry) => !isMatch3DDemoProfileId(entry.profileId)),
) ?? categoryGroups[0];
const selectedCategoryGroup =
categoryGroups.find((group) => group.tag === selectedCategoryTag) ?? null;
if (
firstCategoryGroup &&
!categoryGroups.some((group) => group.tag === selectedCategoryTag)
(!selectedCategoryGroup ||
(!hasManualCategoryTagSelectionRef.current &&
selectedCategoryGroup.entries.every((entry) =>
isMatch3DDemoProfileId(entry.profileId),
) &&
firstCategoryGroup.tag !== selectedCategoryGroup.tag))
) {
setSelectedCategoryTag(firstCategoryGroup.tag);
}
if (
selectedCategoryTag &&
!categoryGroups.some((group) => group.tag === selectedCategoryTag)
) {
hasManualCategoryTagSelectionRef.current = false;
}
}, [categoryGroups, selectedCategoryTag]);
useEffect(() => {
@@ -4665,6 +4813,16 @@ export function RpgEntryHomeView({
setTaskClaimSuccess(null);
loadTaskCenter();
};
const openQrScannerPanel = () => {
if (!authUi?.user) {
authUi?.openLoginModal();
return;
}
setQrScannerError(null);
setQrScannerResult(null);
setIsQrScannerOpen(true);
};
const loadReferralCenter = useCallback(() => {
setIsLoadingReferral(true);
setIsReferralCenterInitialized(false);
@@ -5232,23 +5390,6 @@ export function RpgEntryHomeView({
},
[],
);
const openActiveRecommendEntry = useCallback(() => {
if (!activeRecommendEntry) {
return;
}
if (!isAuthenticated) {
authUi?.openLoginModal();
return;
}
openRecommendGalleryDetail(activeRecommendEntry);
}, [
activeRecommendEntry,
authUi,
isAuthenticated,
openRecommendGalleryDetail,
]);
const leadPublicEntry = featuredShelf[0] ?? generalLatestEntries[0] ?? null;
const openLeadPublicEntry = () => {
if (leadPublicEntry) {
@@ -5495,7 +5636,10 @@ export function RpgEntryHomeView({
<button
key={group.tag}
type="button"
onClick={() => setSelectedCategoryTag(group.tag)}
onClick={() => {
hasManualCategoryTagSelectionRef.current = true;
setSelectedCategoryTag(group.tag);
}}
className={`platform-category-chip ${active ? 'platform-category-chip--active' : ''}`}
>
{group.tag}
@@ -5693,7 +5837,10 @@ export function RpgEntryHomeView({
<button
key={`${group.tag}:desktop-discover-category`}
type="button"
onClick={() => setSelectedCategoryTag(group.tag)}
onClick={() => {
hasManualCategoryTagSelectionRef.current = true;
setSelectedCategoryTag(group.tag);
}}
className={`platform-category-chip shrink-0 ${active ? 'platform-category-chip--active' : ''}`}
>
{group.tag}
@@ -5892,28 +6039,10 @@ export function RpgEntryHomeView({
const savesContent: ReactNode = draftTabContent ?? fallbackDraftContent;
const profileContent: ReactNode = (
<div className={`${MOBILE_PAGE_STAGE_CLASS} platform-profile-page`}>
<div className={`${MOBILE_PROFILE_PAGE_STAGE_CLASS} platform-profile-page`}>
{authUi?.user ? (
<>
<section className="platform-profile-header">
<div className="platform-profile-header__actions">
<button
type="button"
onClick={openRechargeOrRewardCodeModal}
className="platform-profile-header__icon-button"
aria-label="打开充值入口"
>
<ScanLine className="h-5 w-5" />
</button>
<button
type="button"
onClick={() => authUi.openSettingsModal()}
className="platform-profile-header__icon-button"
aria-label="打开设置"
>
<Settings className="h-5 w-5" />
</button>
</div>
<img
src={profileStillLifeImage}
alt=""
@@ -6166,36 +6295,21 @@ export function RpgEntryHomeView({
icon={Settings}
onClick={() => authUi.openSettingsModal()}
/>
<ProfileSettingsRow
label="存档"
icon={Archive}
onClick={() => setProfilePopupPanel('saveArchives')}
/>
</section>
<section
className="platform-profile-secondary-shortcuts"
aria-label="次级入口"
>
<ProfileSecondaryShortcutButton
label="存档"
subLabel={
saveEntries.length > 0
? `${saveEntries.length}个可继续`
: '继续游玩'
}
icon={Archive}
onClick={() => setProfilePopupPanel('saveArchives')}
/>
{canShowReferralRedeemShortcut ? (
{canShowReferralRedeemShortcut ? (
<section
className="platform-profile-secondary-shortcuts"
aria-label="次级入口"
>
<ProfileSecondaryShortcutButton
label="填邀请码"
subLabel="新用户奖励"
icon={Ticket}
onClick={() => openProfilePopupPanel('redeem')}
/>
) : null}
</section>
</section>
) : null}
<ProfileLegalSection onOpenDocument={setActiveLegalDocumentId} />
</>
@@ -6506,7 +6620,10 @@ export function RpgEntryHomeView({
<button
key={`${group.tag}:desktop-category`}
type="button"
onClick={() => setSelectedCategoryTag(group.tag)}
onClick={() => {
hasManualCategoryTagSelectionRef.current = true;
setSelectedCategoryTag(group.tag);
}}
className={`platform-category-chip shrink-0 ${active ? 'platform-category-chip--active' : ''}`}
>
{group.tag}
@@ -6660,6 +6777,22 @@ export function RpgEntryHomeView({
onClose={() => setIsCategoryFilterPanelOpen(false)}
/>
) : null;
const qrScannerModal: ReactNode = isQrScannerOpen ? (
<ProfileQrScannerModal
error={qrScannerError}
result={qrScannerResult}
onClose={() => {
setIsQrScannerOpen(false);
setQrScannerError(null);
setQrScannerResult(null);
}}
onError={setQrScannerError}
onResult={(value) => {
setQrScannerError(null);
setQrScannerResult(value);
}}
/>
) : null;
if (!isDesktopLayout) {
const isMobileRecommendTab = activeTab === 'home';
@@ -6671,7 +6804,26 @@ export function RpgEntryHomeView({
{!isMobileRecommendTab ? (
<div className="platform-mobile-topbar mb-3 flex shrink-0 items-center justify-between gap-3 px-0.5">
<RpgEntryBrandLogo />
{isAuthenticated && activeTab === 'create' ? (
{isAuthenticated && activeTab === 'profile' ? (
<div className="flex items-center gap-2.5">
<button
type="button"
onClick={openQrScannerPanel}
className="platform-profile-header__icon-button"
aria-label="扫码"
>
<ScanLine className="h-5 w-5" />
</button>
<button
type="button"
onClick={() => authUi?.openSettingsModal()}
className="platform-profile-header__icon-button"
aria-label="打开设置"
>
<Settings className="h-5 w-5" />
</button>
</div>
) : isAuthenticated && activeTab === 'create' ? (
<button
type="button"
onClick={openUserSurface}
@@ -6758,6 +6910,7 @@ export function RpgEntryHomeView({
{rewardCodeModal}
{rechargeModal}
{rechargePaymentResultModal}
{qrScannerModal}
{categoryFilterDialog}
{isTaskCenterOpen ? (
<ProfileTaskCenterModal

View File

@@ -10,6 +10,7 @@ import {
formatPlatformWorldTime,
isBarkBattleGalleryEntry,
isEdutainmentGalleryEntry,
isMatch3DGalleryEntry,
isVisualNovelGalleryEntry,
isWoodenFishGalleryEntry,
mapBabyObjectMatchDraftToPlatformGalleryCard,
@@ -21,6 +22,7 @@ import {
resolvePlatformPublicWorkCode,
resolvePlatformWorldFallbackCoverImage,
} from './rpgEntryWorldPresentation';
import { buildMatch3DDemoGalleryCard } from '../../data/match3dDemoGalleryCard';
test('formatPlatformWorldTime formats backend seconds timestamp text as date', () => {
expect(formatPlatformWorldTime('1777110165.990127Z')).toBe('2026-04-25');
@@ -78,6 +80,24 @@ test('platform public cards use play type reference images as cover fallback', (
);
});
test('builds local Match3D demo gallery card with generated runtime assets intact', () => {
const card = buildMatch3DDemoGalleryCard();
expect(isMatch3DGalleryEntry(card)).toBe(true);
expect(card.publicWorkCode).toBe('M3-20260525');
expect(resolvePlatformPublicWorkCode(card)).toBe('M3-20260525');
expect(card.coverImageSrc).toBe(
'/match3d-demo/undersea-candy-market/level-scene.png',
);
expect(card.generatedBackgroundAsset?.uiSpritesheetImageSrc).toBe(
'/match3d-demo/undersea-candy-market/ui-spritesheet.png',
);
expect(card.generatedBackgroundAsset?.containerImageSrc).toBeNull();
expect(card.generatedItemAssets?.[0]?.imageViews?.[0]?.imageSrc).toBe(
'/match3d-demo/undersea-candy-market/item-slices/item-01/view-01.png',
);
});
test('buildPuzzleWorkCoverSlides prefers each level formal image', () => {
const slides = buildPuzzleWorkCoverSlides({
workId: 'work-1',