feat: 支持创作入口公告配置

This commit is contained in:
2026-06-03 03:31:45 +08:00
parent 1cb11bc1dd
commit 70ff18ad90
52 changed files with 3045 additions and 504 deletions

View File

@@ -172,6 +172,7 @@ import {
} from '../../services/square-hole-works';
import { listVisualNovelGallery } from '../../services/visual-novel-runtime';
import { listVisualNovelWorks } from '../../services/visual-novel-works';
import { woodenFishClient } from '../../services/wooden-fish/woodenFishClient';
import { type CustomWorldProfile, WorldType } from '../../types';
import {
AuthUiContext,
@@ -237,8 +238,12 @@ async function openCreateTemplateHub(user: ReturnType<typeof userEvent.setup>) {
expect(panel.getAttribute('aria-hidden')).toBe('false');
});
expect(
await within(panel).findByRole('tablist', { name: '玩法模板分类' }),
await within(panel).findByRole('tablist', { name: '创作入口页签' }),
).toBeTruthy();
// 中文注释:真实最近创作存在时会成为默认页签,模板入口用例需显式切回模板分类。
if (!within(panel).queryByRole('button', { name: //u })) {
await user.click(await within(panel).findByRole('tab', { name: '热门推荐' }));
}
expect(
await within(panel).findByRole('button', { name: //u }),
).toBeTruthy();
@@ -368,6 +373,26 @@ const testCreationEntryConfig = {
startsAtText: '2026-05-01',
endsAtText: '2026-05-31',
},
eventBanners: [
{
title: '后台拼图赛',
description: '后台配置的拼图横幅。',
coverImageSrc: '/creation-type-references/puzzle.webp',
prizePoolMudPoints: 1000,
startsAtText: '2026-05-01',
endsAtText: '2026-05-31',
renderMode: 'structured' as const,
},
{
title: '后台抓大鹅赛',
description: '后台配置的抓大鹅横幅。',
coverImageSrc: '/creation-type-references/match3d.webp',
prizePoolMudPoints: 1200,
startsAtText: '2026-06-01',
endsAtText: '2026-06-30',
renderMode: 'structured' as const,
},
],
creationTypes: [
{
id: 'rpg',
@@ -378,9 +403,9 @@ const testCreationEntryConfig = {
visible: true,
open: true,
sortOrder: 10,
categoryId: 'recent',
categoryLabel: '最近创作',
categorySortOrder: 10,
categoryId: 'recommended',
categoryLabel: '热门推荐',
categorySortOrder: 20,
updatedAtMicros: 1,
},
{
@@ -392,9 +417,9 @@ const testCreationEntryConfig = {
visible: true,
open: true,
sortOrder: 30,
categoryId: 'recent',
categoryLabel: '最近创作',
categorySortOrder: 10,
categoryId: 'recommended',
categoryLabel: '热门推荐',
categorySortOrder: 20,
updatedAtMicros: 1,
},
{
@@ -406,9 +431,9 @@ const testCreationEntryConfig = {
visible: true,
open: true,
sortOrder: 40,
categoryId: 'recent',
categoryLabel: '最近创作',
categorySortOrder: 10,
categoryId: 'recommended',
categoryLabel: '热门推荐',
categorySortOrder: 20,
updatedAtMicros: 1,
},
{
@@ -420,9 +445,9 @@ const testCreationEntryConfig = {
visible: true,
open: true,
sortOrder: 45,
categoryId: 'recent',
categoryLabel: '最近创作',
categorySortOrder: 10,
categoryId: 'recommended',
categoryLabel: '热门推荐',
categorySortOrder: 20,
updatedAtMicros: 1,
},
{
@@ -434,9 +459,9 @@ const testCreationEntryConfig = {
visible: false,
open: true,
sortOrder: 50,
categoryId: 'recent',
categoryLabel: '最近创作',
categorySortOrder: 10,
categoryId: 'recommended',
categoryLabel: '热门推荐',
categorySortOrder: 20,
updatedAtMicros: 1,
},
{
@@ -448,9 +473,9 @@ const testCreationEntryConfig = {
visible: false,
open: false,
sortOrder: 60,
categoryId: 'recent',
categoryLabel: '最近创作',
categorySortOrder: 10,
categoryId: 'recommended',
categoryLabel: '热门推荐',
categorySortOrder: 20,
updatedAtMicros: 1,
},
{
@@ -462,9 +487,9 @@ const testCreationEntryConfig = {
visible: true,
open: false,
sortOrder: 70,
categoryId: 'recent',
categoryLabel: '最近创作',
categorySortOrder: 10,
categoryId: 'recommended',
categoryLabel: '热门推荐',
categorySortOrder: 20,
updatedAtMicros: 1,
},
{
@@ -476,9 +501,9 @@ const testCreationEntryConfig = {
visible: false,
open: true,
sortOrder: 80,
categoryId: 'recent',
categoryLabel: '最近创作',
categorySortOrder: 10,
categoryId: 'recommended',
categoryLabel: '热门推荐',
categorySortOrder: 20,
updatedAtMicros: 1,
},
{
@@ -490,9 +515,9 @@ const testCreationEntryConfig = {
visible: true,
open: true,
sortOrder: 90,
categoryId: 'recent',
categoryLabel: '最近创作',
categorySortOrder: 10,
categoryId: 'recommended',
categoryLabel: '热门推荐',
categorySortOrder: 20,
updatedAtMicros: 1,
},
],
@@ -646,6 +671,22 @@ vi.mock('../../services/jump-hop/jumpHopClient', () => ({
},
}));
vi.mock('../../services/wooden-fish/woodenFishClient', () => ({
woodenFishClient: {
checkpointRun: vi.fn(),
createSession: vi.fn(),
executeAction: vi.fn(),
finishRun: vi.fn(),
getGalleryDetail: vi.fn(),
getSession: vi.fn(),
getWorkDetail: vi.fn(),
listGallery: vi.fn(),
listWorks: vi.fn(),
publishWork: vi.fn(),
startRun: vi.fn(),
},
}));
vi.mock('../../services/match3d-creation', () => ({
match3dCreationClient: {
createSession: vi.fn(),
@@ -2686,6 +2727,18 @@ beforeEach(() => {
vi.mocked(jumpHopClient.getWorkDetail).mockRejectedValue(
new Error('未找到跳一跳作品'),
);
vi.mocked(woodenFishClient.listGallery).mockResolvedValue({
items: [],
hasMore: false,
nextCursor: null,
});
vi.mocked(woodenFishClient.listWorks).mockResolvedValue({ items: [] });
vi.mocked(woodenFishClient.getSession).mockRejectedValue(
new Error('未找到敲木鱼会话'),
);
vi.mocked(woodenFishClient.getWorkDetail).mockRejectedValue(
new Error('未找到敲木鱼作品'),
);
vi.mocked(saveBabyObjectMatchDraft).mockImplementation(async (payload) => ({
draft: payload.draft,
}));
@@ -3648,14 +3701,17 @@ test('create tab shows template tabs and embeds puzzle form by default', async (
render(<TestWrapper withAuth />);
expect(screen.queryByText('后台拼图赛')).toBeNull();
await openCreateTemplateHub(user);
expect(screen.getByRole('tablist', { name: '玩法模板分类' })).toBeTruthy();
expect(screen.getByRole('tablist', { name: '创作入口页签' })).toBeTruthy();
expect(await screen.findByText('后台拼图赛')).toBeTruthy();
expect(screen.getByText('后台抓大鹅赛')).toBeTruthy();
expect(
screen.getByRole('tablist', { name: '玩法模板分类' }).className,
screen.getByRole('tablist', { name: '创作入口页签' }).className,
).toContain('scroll-px-2');
expect(
screen.getByRole('tab', { name: '最近创作' }).getAttribute('aria-selected'),
screen.getByRole('tab', { name: '热门推荐' }).getAttribute('aria-selected'),
).toBe('true');
expect(await findCreationTypeButton('拼图')).toBeTruthy();
expect(await findCreationTypeButton('文字冒险')).toBeTruthy();
@@ -3665,7 +3721,7 @@ test('create tab shows template tabs and embeds puzzle form by default', async (
expect(queryCreationTypeButton('智能创作')).toBeNull();
expect(
screen
.getByRole('tab', { name: '最近创作' })
.getByRole('tab', { name: '热门推荐' })
.querySelector('[class*="bg-[#d9793f]"]'),
).toBeTruthy();
expect(screen.queryByRole('button', { name: /智能创作/u })).toBeNull();
@@ -3676,6 +3732,69 @@ test('create tab shows template tabs and embeds puzzle form by default', async (
expect(createPuzzleAgentSession).not.toHaveBeenCalled();
});
test('create tab shows recent tab when backend returns failed drafts', async () => {
const user = userEvent.setup();
mockExistingRpgDraftShelf({
title: '入口可见的失败草稿',
summary: '失败草稿也要进入创作入口最近创作。',
stage: 'failed',
stageLabel: '生成失败待处理',
updatedAt: '2026-06-02T10:00:00.000Z',
});
render(<TestWrapper withAuth />);
await clickFirstButtonByName(user, '创作');
const panel = getPlatformTabPanel('create');
const tablist = await within(panel).findByRole('tablist', {
name: '创作入口页签',
});
expect(tablist).toBeTruthy();
expect(
within(panel)
.getByRole('tab', { name: '最近创作' })
.getAttribute('aria-selected'),
).toBe('true');
expect(await within(panel).findByText('入口可见的失败草稿')).toBeTruthy();
expect(
within(panel).getByText('失败草稿也要进入创作入口最近创作。'),
).toBeTruthy();
expect(within(panel).getByText('生成失败待处理')).toBeTruthy();
});
test('create tab refreshes recent works after opening from an empty draft shelf', async () => {
const user = userEvent.setup();
const failedDraft = buildExistingRpgDraftWork({
title: '点击创作后出现的失败草稿',
summary: '创作入口需要在进入时重新读取真实作品架。',
stage: 'error',
stageLabel: '发生错误',
updatedAt: '2026-06-02T10:30:00.000Z',
});
vi.mocked(listRpgCreationWorks)
.mockResolvedValueOnce([])
.mockResolvedValue([failedDraft]);
render(<TestWrapper withAuth />);
await openDraftHub(user);
expect(within(getPlatformTabPanel('saves')).getByText('还没有作品')).toBeTruthy();
await clickFirstButtonByName(user, '创作');
const panel = getPlatformTabPanel('create');
expect(await within(panel).findByText('点击创作后出现的失败草稿')).toBeTruthy();
expect(
within(panel).getByText('创作入口需要在进入时重新读取真实作品架。'),
).toBeTruthy();
expect(within(panel).getByText('发生错误')).toBeTruthy();
await waitFor(() => {
expect(
vi.mocked(listRpgCreationWorks).mock.calls.length,
).toBeGreaterThanOrEqual(2);
});
});
test('create tab opens match3d entry form from the template card', async () => {
const user = userEvent.setup();
@@ -3824,7 +3943,7 @@ test('bark battle form checks mud points before creating image assets', async ()
within(noticeDialog).getByText('本次需要 3 泥点,当前 2 泥点。'),
).toBeTruthy();
expect(screen.getByText('汪汪声浪配置表单')).toBeTruthy();
expect(screen.queryByRole('tablist', { name: '玩法模板分类' })).toBeNull();
expect(screen.queryByRole('tablist', { name: '创作入口页签' })).toBeNull();
expect((screen.getByLabelText('汪汪作品标题') as HTMLInputElement).value).toBe(
'自定义声浪杯',
);
@@ -3958,7 +4077,7 @@ test('running match3d form generation can return to draft tab and reopen progres
await user.click(screen.getByRole('button', { name: '返回创作中心' }));
await openDraftHub(user);
expect(await screen.findByText('抓大鹅草稿')).toBeTruthy();
expect((await screen.findAllByText('抓大鹅草稿')).length).toBeGreaterThan(0);
await expectDraftHubGeneratingBadgeCountAtLeast(1);
await user.click(
@@ -4611,7 +4730,7 @@ test('puzzle form checks mud points before creating a draft', async () => {
within(noticeDialog).getByText('本次需要 2 泥点,当前 1 泥点。'),
).toBeTruthy();
expect(screen.getByText('拼图工作区missing-session')).toBeTruthy();
expect(screen.queryByRole('tablist', { name: '玩法模板分类' })).toBeNull();
expect(screen.queryByRole('tablist', { name: '创作入口页签' })).toBeNull();
expect(createPuzzleAgentSession).not.toHaveBeenCalled();
expect(executePuzzleAgentAction).not.toHaveBeenCalled();
});
@@ -4638,7 +4757,7 @@ test('match3d form checks mud points before creating a draft', async () => {
within(noticeDialog).getByText('本次需要 10 泥点,当前 9 泥点。'),
).toBeTruthy();
expect(screen.getByText('抓大鹅工作区missing-session')).toBeTruthy();
expect(screen.queryByRole('tablist', { name: '玩法模板分类' })).toBeNull();
expect(screen.queryByRole('tablist', { name: '创作入口页签' })).toBeNull();
expect(match3dCreationClient.createSession).not.toHaveBeenCalled();
expect(match3dCreationClient.executeAction).not.toHaveBeenCalled();
});
@@ -7457,7 +7576,7 @@ test('embedded puzzle form maps raw bearer token errors to user-facing auth copy
expect(createPuzzleAgentSession).toHaveBeenCalledTimes(1);
expect(createCreativeAgentSession).not.toHaveBeenCalled();
expect(
await screen.findByText('当前登录状态已失效,请重新登录后继续。'),
(await screen.findAllByText('当前登录状态已失效,请重新登录后继续。')).length,
).toBeTruthy();
expect(screen.queryByText('缺少 Authorization Bearer Token')).toBeNull();
});
@@ -8748,11 +8867,11 @@ test('running custom world draft generation can return to creation center with s
await user.click(screen.getByRole('button', { name: '返回创作中心' }));
expect(
await screen.findByRole('tablist', { name: '玩法模板分类' }),
await screen.findByRole('tablist', { name: '创作入口页签' }),
).toBeTruthy();
await openDraftHub(user);
expect(await screen.findByText('潮雾列岛')).toBeTruthy();
expect((await screen.findAllByText('潮雾列岛')).length).toBeGreaterThan(0);
await expectDraftHubGeneratingBadgeCountAtLeast(1);
});
@@ -10142,7 +10261,7 @@ test('manual tab switch is preserved after platform bootstrap requests finish',
await clickFirstButtonByName(user, '创作');
expect(
await screen.findByRole('tablist', { name: '玩法模板分类' }),
await screen.findByRole('tablist', { name: '创作入口页签' }),
).toBeTruthy();
resolveGalleryRequest([]);
@@ -10150,7 +10269,7 @@ test('manual tab switch is preserved after platform bootstrap requests finish',
await waitFor(() => {
expect(
within(getPlatformTabPanel('create')).getByRole('tablist', {
name: '玩法模板分类',
name: '创作入口页签',
}),
).toBeTruthy();
});
@@ -10915,8 +11034,13 @@ test('creation hub published work card reveals delete action after card action r
publishedCard.focus();
await user.keyboard('{ArrowLeft}');
expect(screen.getByRole('button', { name: '删除' })).toBeTruthy();
await user.click(screen.getByRole('button', { name: '删除' }));
const deleteButtons = screen.getAllByRole('button', { name: '删除' });
expect(deleteButtons.length).toBeGreaterThan(0);
const deleteButton = deleteButtons[0];
if (!deleteButton) {
throw new Error('delete button should exist after swipe');
}
await user.click(deleteButton);
const dialog = await screen.findByRole('dialog', { name: '删除作品' });
expect(dialog.parentElement?.className).toContain('platform-theme--light');