feat: 支持创作入口公告配置
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { afterEach, expect, test, vi } from 'vitest';
|
||||
|
||||
@@ -43,9 +43,9 @@ const testEntryConfig = {
|
||||
visible: true,
|
||||
open: true,
|
||||
sortOrder: 10,
|
||||
categoryId: 'recent',
|
||||
categoryLabel: '最近创作',
|
||||
categorySortOrder: 10,
|
||||
categoryId: 'recommended',
|
||||
categoryLabel: '热门推荐',
|
||||
categorySortOrder: 20,
|
||||
updatedAtMicros: 1,
|
||||
},
|
||||
{
|
||||
@@ -57,9 +57,9 @@ const testEntryConfig = {
|
||||
visible: true,
|
||||
open: true,
|
||||
sortOrder: 30,
|
||||
categoryId: 'recent',
|
||||
categoryLabel: '最近创作',
|
||||
categorySortOrder: 10,
|
||||
categoryId: 'recommended',
|
||||
categoryLabel: '热门推荐',
|
||||
categorySortOrder: 20,
|
||||
updatedAtMicros: 1,
|
||||
},
|
||||
{
|
||||
@@ -71,9 +71,9 @@ const testEntryConfig = {
|
||||
visible: true,
|
||||
open: true,
|
||||
sortOrder: 40,
|
||||
categoryId: 'recent',
|
||||
categoryLabel: '最近创作',
|
||||
categorySortOrder: 10,
|
||||
categoryId: 'recommended',
|
||||
categoryLabel: '热门推荐',
|
||||
categorySortOrder: 20,
|
||||
updatedAtMicros: 1,
|
||||
},
|
||||
{
|
||||
@@ -85,9 +85,9 @@ const testEntryConfig = {
|
||||
visible: false,
|
||||
open: true,
|
||||
sortOrder: 50,
|
||||
categoryId: 'recent',
|
||||
categoryLabel: '最近创作',
|
||||
categorySortOrder: 10,
|
||||
categoryId: 'recommended',
|
||||
categoryLabel: '热门推荐',
|
||||
categorySortOrder: 20,
|
||||
updatedAtMicros: 1,
|
||||
},
|
||||
{
|
||||
@@ -99,9 +99,9 @@ const testEntryConfig = {
|
||||
visible: false,
|
||||
open: false,
|
||||
sortOrder: 60,
|
||||
categoryId: 'recent',
|
||||
categoryLabel: '最近创作',
|
||||
categorySortOrder: 10,
|
||||
categoryId: 'recommended',
|
||||
categoryLabel: '热门推荐',
|
||||
categorySortOrder: 20,
|
||||
updatedAtMicros: 1,
|
||||
},
|
||||
{
|
||||
@@ -113,9 +113,9 @@ const testEntryConfig = {
|
||||
visible: true,
|
||||
open: false,
|
||||
sortOrder: 70,
|
||||
categoryId: 'recent',
|
||||
categoryLabel: '最近创作',
|
||||
categorySortOrder: 10,
|
||||
categoryId: 'recommended',
|
||||
categoryLabel: '热门推荐',
|
||||
categorySortOrder: 20,
|
||||
updatedAtMicros: 1,
|
||||
},
|
||||
],
|
||||
@@ -128,6 +128,7 @@ const testCreationTypes = derivePlatformCreationTypes(
|
||||
const originalClipboard = navigator.clipboard;
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
window.sessionStorage.clear();
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
@@ -135,6 +136,57 @@ afterEach(() => {
|
||||
});
|
||||
});
|
||||
|
||||
test('creation entry banner automatically rotates through backend configured banners', () => {
|
||||
vi.useFakeTimers();
|
||||
render(
|
||||
<CustomWorldCreationHub
|
||||
items={[]}
|
||||
loading={false}
|
||||
error={null}
|
||||
onRetry={() => {}}
|
||||
onCreateType={noopCreateType}
|
||||
onOpenDraft={() => {}}
|
||||
onEnterPublished={() => {}}
|
||||
entryConfig={{
|
||||
...testEntryConfig,
|
||||
eventBanners: [
|
||||
{
|
||||
...testEntryConfig.eventBanner,
|
||||
title: '第一张后台 Banner',
|
||||
},
|
||||
{
|
||||
...testEntryConfig.eventBanner,
|
||||
title: '第二张后台 Banner',
|
||||
coverImageSrc: '/creation-type-references/match3d.webp',
|
||||
},
|
||||
],
|
||||
}}
|
||||
creationTypes={testCreationTypes}
|
||||
mode="start-only"
|
||||
/>,
|
||||
);
|
||||
|
||||
const bannerTrack = screen.getByLabelText('创作公告横幅');
|
||||
Object.defineProperty(bannerTrack, 'clientWidth', {
|
||||
configurable: true,
|
||||
value: 360,
|
||||
});
|
||||
const scrollTo = vi.fn();
|
||||
Object.defineProperty(bannerTrack, 'scrollTo', {
|
||||
configurable: true,
|
||||
value: scrollTo,
|
||||
});
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(4200);
|
||||
});
|
||||
|
||||
expect(scrollTo).toHaveBeenCalledWith({
|
||||
left: 360,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
});
|
||||
|
||||
test('creation hub shows published metric growth from cached page snapshot', async () => {
|
||||
window.sessionStorage.setItem(
|
||||
'genarrative.creationHub.publishedMetrics.v1',
|
||||
|
||||
@@ -3,6 +3,7 @@ import { expect, test } from 'vitest';
|
||||
|
||||
import type { CreationEntryConfig } from '../../services/creationEntryConfigService';
|
||||
import { derivePlatformCreationTypes } from '../platform-entry/platformEntryCreationTypes';
|
||||
import { buildCreationWorkShelfItems } from './creationWorkShelf';
|
||||
import { CustomWorldCreationHub } from './CustomWorldCreationHub';
|
||||
|
||||
const noopCreateType = () => {};
|
||||
@@ -26,6 +27,26 @@ const testEntryConfig = {
|
||||
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',
|
||||
},
|
||||
{
|
||||
title: '后台抓大鹅赛',
|
||||
description: '后台配置的抓大鹅横幅。',
|
||||
coverImageSrc: '/creation-type-references/match3d.webp',
|
||||
prizePoolMudPoints: 1200,
|
||||
startsAtText: '2026-06-01',
|
||||
endsAtText: '2026-06-30',
|
||||
renderMode: 'structured',
|
||||
},
|
||||
],
|
||||
creationTypes: [
|
||||
{
|
||||
id: 'rpg',
|
||||
@@ -36,9 +57,9 @@ const testEntryConfig = {
|
||||
visible: true,
|
||||
open: true,
|
||||
sortOrder: 10,
|
||||
categoryId: 'recent',
|
||||
categoryLabel: '最近创作',
|
||||
categorySortOrder: 10,
|
||||
categoryId: 'recommended',
|
||||
categoryLabel: '热门推荐',
|
||||
categorySortOrder: 20,
|
||||
updatedAtMicros: 1,
|
||||
},
|
||||
{
|
||||
@@ -50,9 +71,9 @@ const testEntryConfig = {
|
||||
visible: true,
|
||||
open: true,
|
||||
sortOrder: 30,
|
||||
categoryId: 'recent',
|
||||
categoryLabel: '最近创作',
|
||||
categorySortOrder: 10,
|
||||
categoryId: 'recommended',
|
||||
categoryLabel: '热门推荐',
|
||||
categorySortOrder: 20,
|
||||
updatedAtMicros: 1,
|
||||
},
|
||||
{
|
||||
@@ -64,9 +85,9 @@ const testEntryConfig = {
|
||||
visible: true,
|
||||
open: true,
|
||||
sortOrder: 40,
|
||||
categoryId: 'recent',
|
||||
categoryLabel: '最近创作',
|
||||
categorySortOrder: 10,
|
||||
categoryId: 'recommended',
|
||||
categoryLabel: '热门推荐',
|
||||
categorySortOrder: 20,
|
||||
updatedAtMicros: 1,
|
||||
},
|
||||
{
|
||||
@@ -78,9 +99,9 @@ const testEntryConfig = {
|
||||
visible: false,
|
||||
open: true,
|
||||
sortOrder: 50,
|
||||
categoryId: 'recent',
|
||||
categoryLabel: '最近创作',
|
||||
categorySortOrder: 10,
|
||||
categoryId: 'recommended',
|
||||
categoryLabel: '热门推荐',
|
||||
categorySortOrder: 20,
|
||||
updatedAtMicros: 1,
|
||||
},
|
||||
{
|
||||
@@ -92,9 +113,9 @@ const testEntryConfig = {
|
||||
visible: false,
|
||||
open: false,
|
||||
sortOrder: 60,
|
||||
categoryId: 'recent',
|
||||
categoryLabel: '最近创作',
|
||||
categorySortOrder: 10,
|
||||
categoryId: 'recommended',
|
||||
categoryLabel: '热门推荐',
|
||||
categorySortOrder: 20,
|
||||
updatedAtMicros: 1,
|
||||
},
|
||||
{
|
||||
@@ -106,9 +127,9 @@ const testEntryConfig = {
|
||||
visible: true,
|
||||
open: false,
|
||||
sortOrder: 70,
|
||||
categoryId: 'recent',
|
||||
categoryLabel: '最近创作',
|
||||
categorySortOrder: 10,
|
||||
categoryId: 'recommended',
|
||||
categoryLabel: '热门推荐',
|
||||
categorySortOrder: 20,
|
||||
updatedAtMicros: 1,
|
||||
},
|
||||
],
|
||||
@@ -186,11 +207,15 @@ test('creation start card renders reference-aligned banner and template metadata
|
||||
expect(html).toContain('creation-event-banner__track');
|
||||
expect(html).toContain('creation-event-banner__slide');
|
||||
expect(html).toContain('creation-event-banner__timebar');
|
||||
expect(html).toContain('拼图主题创作赛');
|
||||
expect(html).toContain('抓大鹅主题创作赛');
|
||||
expect(html).toContain('后台拼图赛');
|
||||
expect(html).toContain('后台抓大鹅赛');
|
||||
expect(html).toContain('1,000');
|
||||
expect(html).toContain('1,200');
|
||||
expect(html).toContain('泥点数');
|
||||
expect(html).not.toContain('泥点挑战');
|
||||
expect(html).not.toContain('拼图主题创作赛');
|
||||
expect(html).not.toContain('抓大鹅主题创作赛');
|
||||
expect(html).not.toContain('最近创作');
|
||||
expect(html).toMatch(
|
||||
/creation-event-banner__timebar[\s\S]*creation-event-banner__pager[\s\S]*creation-template-card/u,
|
||||
);
|
||||
@@ -206,6 +231,305 @@ test('creation start card renders reference-aligned banner and template metadata
|
||||
expect(html).not.toContain('platform-creation-reference-card');
|
||||
});
|
||||
|
||||
test('creation start card falls back to legacy single banner when eventBanners is empty', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<CustomWorldCreationHub
|
||||
items={[]}
|
||||
loading={false}
|
||||
error={null}
|
||||
onRetry={() => {}}
|
||||
onCreateType={noopCreateType}
|
||||
onOpenDraft={() => {}}
|
||||
onEnterPublished={() => {}}
|
||||
entryConfig={{
|
||||
...testEntryConfig,
|
||||
eventBanners: [],
|
||||
}}
|
||||
creationTypes={testCreationTypes}
|
||||
mode="start-only"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain('泥点挑战');
|
||||
expect(html).not.toContain('后台拼图赛');
|
||||
});
|
||||
|
||||
test('creation start card renders html banner in an empty-permission sandbox', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<CustomWorldCreationHub
|
||||
items={[]}
|
||||
loading={false}
|
||||
error={null}
|
||||
onRetry={() => {}}
|
||||
onCreateType={noopCreateType}
|
||||
onOpenDraft={() => {}}
|
||||
onEnterPublished={() => {}}
|
||||
entryConfig={{
|
||||
...testEntryConfig,
|
||||
eventBanners: [
|
||||
{
|
||||
...testEntryConfig.eventBanner,
|
||||
title: 'HTML 后台横幅',
|
||||
renderMode: 'html',
|
||||
htmlCode: '<section><h1>自定义横幅</h1></section>',
|
||||
},
|
||||
],
|
||||
}}
|
||||
creationTypes={testCreationTypes}
|
||||
mode="start-only"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain('title="HTML 后台横幅"');
|
||||
expect(html).toContain('sandbox=""');
|
||||
expect(html).toContain('<section><h1>自定义横幅</h1></section>');
|
||||
});
|
||||
|
||||
test('creation start card renders recent tab from real shelf summaries', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<CustomWorldCreationHub
|
||||
items={[
|
||||
{
|
||||
workId: 'draft:recent-session',
|
||||
sourceType: 'agent_session',
|
||||
status: 'draft',
|
||||
title: '后端返回的最近草稿',
|
||||
subtitle: '待完善草稿',
|
||||
summary: '这条内容来自作品架摘要。',
|
||||
coverImageSrc: null,
|
||||
updatedAt: new Date('2026-06-01T12:00:00.000Z').toISOString(),
|
||||
publishedAt: null,
|
||||
stage: 'clarifying',
|
||||
stageLabel: '待完善草稿',
|
||||
playableNpcCount: 0,
|
||||
landmarkCount: 0,
|
||||
sessionId: 'recent-session',
|
||||
profileId: null,
|
||||
canResume: true,
|
||||
canEnterWorld: false,
|
||||
},
|
||||
]}
|
||||
loading={false}
|
||||
error={null}
|
||||
onRetry={() => {}}
|
||||
onCreateType={noopCreateType}
|
||||
onOpenDraft={() => {}}
|
||||
onEnterPublished={() => {}}
|
||||
entryConfig={testEntryConfig}
|
||||
creationTypes={testCreationTypes}
|
||||
getWorkState={() => ({ isGenerating: true })}
|
||||
mode="start-only"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain('aria-label="创作入口页签"');
|
||||
expect(html).toContain('role="tab"');
|
||||
expect(html).toContain('aria-selected="true"');
|
||||
expect(html).toContain('creation-recent-work-grid');
|
||||
expect(html).toContain('aria-label="打开最近创作 1"');
|
||||
expect(html).toContain('最近创作');
|
||||
expect(html).toContain('后端返回的最近草稿');
|
||||
expect(html).toContain('这条内容来自作品架摘要');
|
||||
expect(html).toContain('生成中');
|
||||
});
|
||||
|
||||
test('creation start card prefers backend recent summaries over local pending placeholders', () => {
|
||||
const recentWorkItems = buildCreationWorkShelfItems({
|
||||
rpgItems: [
|
||||
{
|
||||
workId: 'draft:backend-session',
|
||||
sourceType: 'agent_session',
|
||||
status: 'draft',
|
||||
title: '后端最近草稿',
|
||||
subtitle: '真实作品架摘要',
|
||||
summary: '最近创作应该只读取后端摘要。',
|
||||
coverImageSrc: null,
|
||||
updatedAt: new Date('2026-06-03T12:00:00.000Z').toISOString(),
|
||||
publishedAt: null,
|
||||
stage: 'failed',
|
||||
stageLabel: '生成失败',
|
||||
playableNpcCount: 0,
|
||||
landmarkCount: 0,
|
||||
sessionId: 'backend-session',
|
||||
profileId: null,
|
||||
canResume: true,
|
||||
canEnterWorld: false,
|
||||
},
|
||||
],
|
||||
bigFishItems: [],
|
||||
puzzleItems: [],
|
||||
});
|
||||
const html = renderToStaticMarkup(
|
||||
<CustomWorldCreationHub
|
||||
items={[
|
||||
{
|
||||
workId: 'draft:local-pending-session',
|
||||
sourceType: 'agent_session',
|
||||
status: 'draft',
|
||||
title: '本地生成中占位',
|
||||
subtitle: '本地占位',
|
||||
summary: '这条占位不应该进入最近创作。',
|
||||
coverImageSrc: null,
|
||||
updatedAt: new Date('2026-06-04T12:00:00.000Z').toISOString(),
|
||||
publishedAt: null,
|
||||
stage: 'generating',
|
||||
stageLabel: '生成中',
|
||||
playableNpcCount: 0,
|
||||
landmarkCount: 0,
|
||||
sessionId: 'local-pending-session',
|
||||
profileId: null,
|
||||
canResume: true,
|
||||
canEnterWorld: false,
|
||||
},
|
||||
]}
|
||||
recentWorkItems={recentWorkItems}
|
||||
loading={false}
|
||||
error={null}
|
||||
onRetry={() => {}}
|
||||
onCreateType={noopCreateType}
|
||||
onOpenDraft={() => {}}
|
||||
onEnterPublished={() => {}}
|
||||
entryConfig={testEntryConfig}
|
||||
creationTypes={testCreationTypes}
|
||||
mode="start-only"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain('最近创作');
|
||||
expect(html).toContain('后端最近草稿');
|
||||
expect(html).toContain('最近创作应该只读取后端摘要');
|
||||
expect(html).not.toContain('本地生成中占位');
|
||||
});
|
||||
|
||||
test('creation start card marks backend jump-hop generating draft in recent tab', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<CustomWorldCreationHub
|
||||
items={[]}
|
||||
jumpHopItems={[
|
||||
{
|
||||
runtimeKind: 'jump-hop',
|
||||
workId: 'jump-hop-work-1',
|
||||
profileId: 'jump-hop-profile-1',
|
||||
ownerUserId: 'user-1',
|
||||
sourceSessionId: 'jump-hop-session-1',
|
||||
workTitle: '跳一跳生成草稿',
|
||||
workDescription: '后端仍在生成跳一跳玩法。',
|
||||
themeTags: ['跳一跳'],
|
||||
difficulty: 'standard',
|
||||
stylePreset: 'minimal-blocks',
|
||||
coverImageSrc: null,
|
||||
publicationStatus: 'draft',
|
||||
playCount: 0,
|
||||
updatedAt: new Date('2026-06-03T13:00:00.000Z').toISOString(),
|
||||
publishedAt: null,
|
||||
publishReady: false,
|
||||
generationStatus: 'generating',
|
||||
},
|
||||
]}
|
||||
loading={false}
|
||||
error={null}
|
||||
onRetry={() => {}}
|
||||
onCreateType={noopCreateType}
|
||||
onOpenDraft={() => {}}
|
||||
onEnterPublished={() => {}}
|
||||
entryConfig={testEntryConfig}
|
||||
creationTypes={testCreationTypes}
|
||||
onOpenJumpHopDetail={() => {}}
|
||||
mode="start-only"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain('最近创作');
|
||||
expect(html).toContain('跳一跳生成草稿');
|
||||
expect(html).toContain('后端仍在生成跳一跳玩法');
|
||||
expect(html).toContain('生成中');
|
||||
});
|
||||
|
||||
test('creation start card includes failed drafts in the recent tab', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<CustomWorldCreationHub
|
||||
items={[
|
||||
{
|
||||
workId: 'draft:failed-session',
|
||||
sourceType: 'agent_session',
|
||||
status: 'draft',
|
||||
title: '失败但仍可恢复的草稿',
|
||||
subtitle: '生成失败',
|
||||
summary: '失败草稿也来自真实作品架摘要。',
|
||||
coverImageSrc: null,
|
||||
updatedAt: new Date('2026-06-02T12:00:00.000Z').toISOString(),
|
||||
publishedAt: null,
|
||||
stage: 'failed',
|
||||
stageLabel: '生成失败',
|
||||
playableNpcCount: 0,
|
||||
landmarkCount: 0,
|
||||
sessionId: 'failed-session',
|
||||
profileId: null,
|
||||
canResume: true,
|
||||
canEnterWorld: false,
|
||||
},
|
||||
]}
|
||||
loading={false}
|
||||
error={null}
|
||||
onRetry={() => {}}
|
||||
onCreateType={noopCreateType}
|
||||
onOpenDraft={() => {}}
|
||||
onEnterPublished={() => {}}
|
||||
entryConfig={testEntryConfig}
|
||||
creationTypes={testCreationTypes}
|
||||
mode="start-only"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain('最近创作');
|
||||
expect(html).toContain('creation-recent-work-grid');
|
||||
expect(html).toContain('失败但仍可恢复的草稿');
|
||||
expect(html).toContain('失败草稿也来自真实作品架摘要');
|
||||
expect(html).toContain('生成失败');
|
||||
});
|
||||
|
||||
test('creation start card maps failed mini-game drafts into recent status labels', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<CustomWorldCreationHub
|
||||
items={[]}
|
||||
loading={false}
|
||||
error={null}
|
||||
onRetry={() => {}}
|
||||
onCreateType={noopCreateType}
|
||||
onOpenDraft={() => {}}
|
||||
onEnterPublished={() => {}}
|
||||
entryConfig={testEntryConfig}
|
||||
creationTypes={testCreationTypes}
|
||||
match3dItems={[
|
||||
{
|
||||
workId: 'match3d-failed-work',
|
||||
profileId: 'match3d-failed-profile',
|
||||
ownerUserId: 'user-1',
|
||||
gameName: '失败抓大鹅草稿',
|
||||
themeText: '水果',
|
||||
summary: '失败的小玩法草稿也应该进入最近创作。',
|
||||
tags: [],
|
||||
coverImageSrc: null,
|
||||
clearCount: 0,
|
||||
difficulty: 1,
|
||||
publicationStatus: 'draft',
|
||||
playCount: 0,
|
||||
updatedAt: '2026-06-02T13:00:00.000Z',
|
||||
publishedAt: null,
|
||||
publishReady: false,
|
||||
generationStatus: 'failed',
|
||||
},
|
||||
]}
|
||||
mode="start-only"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain('最近创作');
|
||||
expect(html).toContain('失败抓大鹅草稿');
|
||||
expect(html).toContain('失败的小玩法草稿也应该进入最近创作。');
|
||||
expect(html).toContain('生成失败');
|
||||
});
|
||||
|
||||
test('creation start card keeps typography in compact UI scale', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<CustomWorldCreationHub
|
||||
|
||||
@@ -23,7 +23,10 @@ import {
|
||||
type CreationWorkShelfItem,
|
||||
type CreationWorkShelfMetricId,
|
||||
} from './creationWorkShelf';
|
||||
import { CustomWorldCreationStartCard } from './CustomWorldCreationStartCard';
|
||||
import {
|
||||
CustomWorldCreationStartCard,
|
||||
type CreationEntryRecentWorkCard,
|
||||
} from './CustomWorldCreationStartCard';
|
||||
import { CustomWorldWorkCard } from './CustomWorldWorkCard';
|
||||
import {
|
||||
type CustomWorldWorkFilter,
|
||||
@@ -86,6 +89,8 @@ type CustomWorldCreationHubProps = {
|
||||
item: CreationWorkShelfItem,
|
||||
) => { isGenerating?: boolean; hasUnreadUpdate?: boolean } | null;
|
||||
onOpenShelfItem?: (item: CreationWorkShelfItem) => void;
|
||||
// 中文注释:底部加号入口的最近创作可传入后端作品架摘要,避免混入本地 pending 占位。
|
||||
recentWorkItems?: CreationWorkShelfItem[];
|
||||
mode?: 'full' | 'start-only' | 'works-only';
|
||||
};
|
||||
|
||||
@@ -152,6 +157,35 @@ function writeWorkMetricSnapshot(items: CreationWorkShelfItem[]) {
|
||||
}
|
||||
}
|
||||
|
||||
/** 格式化入口页最近创作状态,失败草稿和生成中草稿都保留真实后端摘要语义。 */
|
||||
function formatRecentWorkStatusLabel(item: CreationWorkShelfItem) {
|
||||
if (item.isGenerating) {
|
||||
return '生成中';
|
||||
}
|
||||
|
||||
if (item.status === 'published') {
|
||||
return '已发布';
|
||||
}
|
||||
|
||||
switch (item.source.kind) {
|
||||
case 'rpg':
|
||||
return item.source.item.stageLabel?.trim() || '草稿';
|
||||
case 'match3d':
|
||||
case 'jump-hop':
|
||||
case 'wooden-fish':
|
||||
return item.source.item.generationStatus === 'failed'
|
||||
? '生成失败'
|
||||
: '草稿';
|
||||
case 'bark-battle':
|
||||
return item.source.item.generationStatus === 'partial_failed'
|
||||
? '生成失败'
|
||||
: '草稿';
|
||||
default:
|
||||
return '草稿';
|
||||
}
|
||||
}
|
||||
|
||||
/** 渲染底部加号创作入口页与草稿作品架,入口页最近创作只来自后端作品摘要。 */
|
||||
export function CustomWorldCreationHub({
|
||||
items,
|
||||
loading,
|
||||
@@ -197,6 +231,7 @@ export function CustomWorldCreationHub({
|
||||
onDeleteVisualNovel = null,
|
||||
getWorkState,
|
||||
onOpenShelfItem,
|
||||
recentWorkItems: recentWorkSourceItems,
|
||||
mode = 'full',
|
||||
}: CustomWorldCreationHubProps) {
|
||||
const [activeFilter, setActiveFilter] =
|
||||
@@ -285,7 +320,6 @@ export function CustomWorldCreationHub({
|
||||
getWorkState,
|
||||
puzzleItems,
|
||||
rpgLibraryEntries,
|
||||
onOpenSquareHoleDetail,
|
||||
onOpenJumpHopDetail,
|
||||
jumpHopItems,
|
||||
woodenFishItems,
|
||||
@@ -311,6 +345,19 @@ export function CustomWorldCreationHub({
|
||||
),
|
||||
[activeFilter, shelfItems],
|
||||
);
|
||||
// 中文注释:最近创作只来自作品架摘要;平台入口会传入不含本地 pending 占位的后端摘要。
|
||||
const recentWorkItems =
|
||||
mode === 'start-only'
|
||||
? (recentWorkSourceItems ?? shelfItems).slice(0, 4)
|
||||
: [];
|
||||
const recentWorkCards: CreationEntryRecentWorkCard[] = recentWorkItems.map(
|
||||
(item) => ({
|
||||
id: `${item.kind}:${item.id}`,
|
||||
title: item.title,
|
||||
summary: item.summary,
|
||||
statusLabel: formatRecentWorkStatusLabel(item),
|
||||
}),
|
||||
);
|
||||
|
||||
function handleOpenShelfItem(item: CreationWorkShelfItem) {
|
||||
onOpenShelfItem?.(item);
|
||||
@@ -377,7 +424,14 @@ export function CustomWorldCreationHub({
|
||||
busy={createBusy}
|
||||
entryConfig={entryConfig}
|
||||
creationTypes={creationTypes}
|
||||
recentWorks={recentWorkCards}
|
||||
onCreateType={onCreateType}
|
||||
onOpenRecentWork={(index) => {
|
||||
const item = recentWorkItems[index];
|
||||
if (item) {
|
||||
handleOpenShelfItem(item);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
|
||||
@@ -1,64 +1,156 @@
|
||||
import { Coins, Trophy } from 'lucide-react';
|
||||
import { type UIEvent,useMemo, useState } from 'react';
|
||||
import { type UIEvent, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import type { CreationEntryConfig } from '../../services/creationEntryConfigService';
|
||||
import type {
|
||||
CreationEntryConfig,
|
||||
CreationEntryEventBannerConfig,
|
||||
} from '../../services/creationEntryConfigService';
|
||||
import {
|
||||
groupVisiblePlatformCreationTypes,
|
||||
type PlatformCreationTypeCard,
|
||||
type PlatformCreationTypeId,
|
||||
} from '../platform-entry/platformEntryCreationTypes';
|
||||
|
||||
/** 底部加号创作入口页的渲染参数,最近创作只接受作品架真实摘要。 */
|
||||
type CustomWorldCreationStartCardProps = {
|
||||
busy?: boolean;
|
||||
entryConfig: CreationEntryConfig;
|
||||
creationTypes: readonly PlatformCreationTypeCard[];
|
||||
recentWorks?: readonly CreationEntryRecentWorkCard[];
|
||||
onCreateType: (type: PlatformCreationTypeId) => void;
|
||||
onOpenRecentWork?: (index: number) => void;
|
||||
};
|
||||
|
||||
type CreationEventBannerCard = CreationEntryConfig['eventBanner'];
|
||||
/** 创作入口公告卡兼容结构化和 HTML 两种后台配置。 */
|
||||
type CreationEventBannerCard = CreationEntryEventBannerConfig;
|
||||
const CREATION_ENTRY_BANNER_AUTOPLAY_MS = 4200;
|
||||
const CREATION_ENTRY_RECENT_TAB_ID = '__recent_creation__';
|
||||
|
||||
/** 底部加号创作入口页最近创作页签的展示数据,只来自后端作品架摘要。 */
|
||||
export type CreationEntryRecentWorkCard = {
|
||||
id: string;
|
||||
title: string;
|
||||
summary: string;
|
||||
statusLabel: string;
|
||||
};
|
||||
|
||||
/** 判断模板 badge 是否需要展示,普通可创建态不额外占用卡片空间。 */
|
||||
function shouldShowCreationBadge(badge: string) {
|
||||
const normalizedBadge = badge.trim();
|
||||
return normalizedBadge !== '可创建' && normalizedBadge !== '可创作';
|
||||
}
|
||||
|
||||
/** 从后端入口配置中解析创作入口公告位,保留旧单条字段兜底。 */
|
||||
export function resolveCreationEntryEventBanners(
|
||||
entryConfig: CreationEntryConfig,
|
||||
): CreationEventBannerCard[] {
|
||||
const configuredBanners = Array.isArray(entryConfig.eventBanners)
|
||||
? entryConfig.eventBanners.filter((banner) => banner.title.trim())
|
||||
: [];
|
||||
return configuredBanners.length > 0
|
||||
? configuredBanners
|
||||
: [entryConfig.eventBanner];
|
||||
}
|
||||
|
||||
/** 渲染创作入口公告位当前页指示点。 */
|
||||
function CreationEntryBannerPager({
|
||||
banners,
|
||||
activeBannerIndex,
|
||||
}: {
|
||||
banners: readonly CreationEventBannerCard[];
|
||||
activeBannerIndex: number;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="creation-event-banner__pager flex items-center justify-center gap-1.5"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{banners.map((dotBanner, dotIndex) => (
|
||||
<span
|
||||
key={`${dotBanner.title}:dot:${dotIndex}`}
|
||||
className={
|
||||
dotIndex === activeBannerIndex
|
||||
? 'h-1.5 w-5 rounded-full bg-[#d9793f]'
|
||||
: 'h-1.5 w-1.5 rounded-full bg-[#eadfd7]'
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 渲染底部加号进入的创作入口页,包括后台公告位和模板分类。 */
|
||||
export function CustomWorldCreationStartCard({
|
||||
busy = false,
|
||||
entryConfig,
|
||||
creationTypes,
|
||||
recentWorks = [],
|
||||
onCreateType,
|
||||
onOpenRecentWork,
|
||||
}: CustomWorldCreationStartCardProps) {
|
||||
const creationTypeGroups = useMemo(
|
||||
() => groupVisiblePlatformCreationTypes(creationTypes),
|
||||
[creationTypes],
|
||||
);
|
||||
const [activeCategoryId, setActiveCategoryId] = useState<string | null>(null);
|
||||
const activeGroup =
|
||||
creationTypeGroups.find((group) => group.id === activeCategoryId) ??
|
||||
creationTypeGroups[0] ??
|
||||
null;
|
||||
const hasRecentWorks = recentWorks.length > 0;
|
||||
const activeTabId =
|
||||
activeCategoryId ??
|
||||
(hasRecentWorks
|
||||
? CREATION_ENTRY_RECENT_TAB_ID
|
||||
: creationTypeGroups[0]?.id ?? null);
|
||||
const isRecentTabActive =
|
||||
hasRecentWorks && activeTabId === CREATION_ENTRY_RECENT_TAB_ID;
|
||||
const activeGroup = isRecentTabActive
|
||||
? null
|
||||
: creationTypeGroups.find((group) => group.id === activeTabId) ??
|
||||
creationTypeGroups[0] ??
|
||||
null;
|
||||
const visibleCreationTypes = activeGroup?.items ?? [];
|
||||
const eventBanners = useMemo<CreationEventBannerCard[]>(
|
||||
() => [
|
||||
{
|
||||
...entryConfig.eventBanner,
|
||||
title: '拼图主题创作赛',
|
||||
description: '用拼图关卡接住本周主题。',
|
||||
coverImageSrc: '/creation-type-references/puzzle.webp',
|
||||
prizePoolMudPoints: 1000,
|
||||
},
|
||||
{
|
||||
...entryConfig.eventBanner,
|
||||
title: '抓大鹅主题创作赛',
|
||||
description: '把抓大鹅关卡做成主题挑战。',
|
||||
coverImageSrc: '/creation-type-references/match3d.webp',
|
||||
prizePoolMudPoints: 1000,
|
||||
},
|
||||
],
|
||||
[entryConfig.eventBanner],
|
||||
const eventBanners = useMemo(
|
||||
() => resolveCreationEntryEventBanners(entryConfig),
|
||||
[entryConfig],
|
||||
);
|
||||
const [activeBannerIndex, setActiveBannerIndex] = useState(0);
|
||||
const bannerTrackRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setActiveBannerIndex(0);
|
||||
}, [eventBanners.length]);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasRecentWorks) {
|
||||
return;
|
||||
}
|
||||
|
||||
setActiveCategoryId((currentId) =>
|
||||
currentId === CREATION_ENTRY_RECENT_TAB_ID ? null : currentId,
|
||||
);
|
||||
}, [hasRecentWorks]);
|
||||
|
||||
useEffect(() => {
|
||||
if (eventBanners.length <= 1) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const intervalId = window.setInterval(() => {
|
||||
setActiveBannerIndex((currentIndex) => {
|
||||
const nextIndex = (currentIndex + 1) % eventBanners.length;
|
||||
const track = bannerTrackRef.current;
|
||||
if (track && typeof track.scrollTo === 'function') {
|
||||
track.scrollTo({
|
||||
left: track.clientWidth * nextIndex,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}
|
||||
return nextIndex;
|
||||
});
|
||||
}, CREATION_ENTRY_BANNER_AUTOPLAY_MS);
|
||||
|
||||
return () => window.clearInterval(intervalId);
|
||||
}, [eventBanners.length]);
|
||||
|
||||
/** 同步手势滑动后的 banner 页码,避免自动轮播和手动滑动状态错位。 */
|
||||
function handleBannerScroll(event: UIEvent<HTMLDivElement>) {
|
||||
const { clientWidth, scrollLeft } = event.currentTarget;
|
||||
if (clientWidth <= 0) {
|
||||
@@ -78,74 +170,87 @@ export function CustomWorldCreationStartCard({
|
||||
<div className="mx-auto flex w-full max-w-5xl flex-col gap-3 sm:gap-5">
|
||||
<section className="creation-event-banner relative overflow-hidden rounded-[1.35rem] border border-[#f0d8ca] bg-[#fff8f3] shadow-[0_16px_36px_rgba(174,111,73,0.12)] sm:rounded-[1.65rem]">
|
||||
<div
|
||||
ref={bannerTrackRef}
|
||||
className="creation-event-banner__track flex snap-x snap-mandatory overflow-x-auto overscroll-x-contain touch-pan-x scrollbar-hide"
|
||||
onScroll={handleBannerScroll}
|
||||
aria-label="创作赛事横幅"
|
||||
aria-label="创作公告横幅"
|
||||
>
|
||||
{eventBanners.map((banner, index) => {
|
||||
const prizePoolText =
|
||||
banner.prizePoolMudPoints.toLocaleString('zh-CN');
|
||||
const shouldRenderHtmlBanner =
|
||||
banner.renderMode === 'html' && Boolean(banner.htmlCode?.trim());
|
||||
|
||||
return (
|
||||
<article
|
||||
key={`${banner.title}:${index}`}
|
||||
className="creation-event-banner__slide relative w-full shrink-0 snap-center overflow-hidden"
|
||||
>
|
||||
<img
|
||||
src={banner.coverImageSrc}
|
||||
alt=""
|
||||
className="absolute inset-y-0 right-0 h-full w-[58%] object-cover object-[70%_center] opacity-95"
|
||||
loading={index === 0 ? 'eager' : 'lazy'}
|
||||
/>
|
||||
<div className="absolute inset-0 bg-[linear-gradient(90deg,#fff8f3_0%,rgba(255,248,243,0.94)_34%,rgba(255,248,243,0.36)_68%,rgba(255,248,243,0.08)_100%)]" />
|
||||
<div className="relative z-10 flex min-h-[12rem] flex-col justify-between px-4 py-4 sm:min-h-[15rem] sm:px-7 sm:py-6">
|
||||
<div className="w-[68%] min-w-0 sm:w-[56%]">
|
||||
<div className="inline-flex max-w-full items-center gap-1.5 rounded-full border border-[#df7949] bg-white/72 px-2.5 py-1 text-xs font-black text-[#cf6332] shadow-sm">
|
||||
<Trophy className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="truncate">{banner.title}</span>
|
||||
</div>
|
||||
<div className="mt-3 line-clamp-2 text-sm font-semibold leading-5 text-[#695143] sm:mt-5 sm:leading-6">
|
||||
{banner.description}
|
||||
</div>
|
||||
<div className="mt-3 inline-flex max-w-full items-center gap-1.5 rounded-full bg-white/72 px-2.5 py-1.5 text-xs font-bold text-[#6f5140] shadow-sm">
|
||||
<span className="grid h-5 w-5 place-items-center rounded-full bg-[#ffb64c] text-white">
|
||||
<Coins className="h-3 w-3" />
|
||||
</span>
|
||||
<span className="shrink-0">奖池</span>
|
||||
<span className="text-sm font-black text-[#d36b2f]">
|
||||
{prizePoolText}
|
||||
</span>
|
||||
<span className="shrink-0">泥点数</span>
|
||||
</div>
|
||||
{shouldRenderHtmlBanner ? (
|
||||
<div className="relative min-h-[12rem] sm:min-h-[15rem]">
|
||||
<iframe
|
||||
title={banner.title}
|
||||
sandbox=""
|
||||
srcDoc={banner.htmlCode ?? ''}
|
||||
className="absolute inset-0 h-full w-full border-0 bg-transparent"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<img
|
||||
src={banner.coverImageSrc}
|
||||
alt=""
|
||||
className="absolute inset-y-0 right-0 h-full w-[58%] object-cover object-[70%_center] opacity-95"
|
||||
loading={index === 0 ? 'eager' : 'lazy'}
|
||||
/>
|
||||
<div className="absolute inset-0 bg-[linear-gradient(90deg,#fff8f3_0%,rgba(255,248,243,0.94)_34%,rgba(255,248,243,0.36)_68%,rgba(255,248,243,0.08)_100%)]" />
|
||||
<div className="relative z-10 flex min-h-[12rem] flex-col justify-between px-4 py-4 sm:min-h-[15rem] sm:px-7 sm:py-6">
|
||||
<div className="w-[68%] min-w-0 sm:w-[56%]">
|
||||
<div className="inline-flex max-w-full items-center gap-1.5 rounded-full border border-[#df7949] bg-white/72 px-2.5 py-1 text-xs font-black text-[#cf6332] shadow-sm">
|
||||
<Trophy className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="truncate">{banner.title}</span>
|
||||
</div>
|
||||
<div className="mt-3 line-clamp-2 text-sm font-semibold leading-5 text-[#695143] sm:mt-5 sm:leading-6">
|
||||
{banner.description}
|
||||
</div>
|
||||
<div className="mt-3 inline-flex max-w-full items-center gap-1.5 rounded-full bg-white/72 px-2.5 py-1.5 text-xs font-bold text-[#6f5140] shadow-sm">
|
||||
<span className="grid h-5 w-5 place-items-center rounded-full bg-[#ffb64c] text-white">
|
||||
<Coins className="h-3 w-3" />
|
||||
</span>
|
||||
<span className="shrink-0">奖池</span>
|
||||
<span className="text-sm font-black text-[#d36b2f]">
|
||||
{prizePoolText}
|
||||
</span>
|
||||
<span className="shrink-0">泥点数</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-col gap-2">
|
||||
<div className="creation-event-banner__timebar flex min-w-0 items-center justify-center gap-1.5 rounded-full border border-[#e9c5b0] bg-white/72 px-2.5 py-1.5 text-center text-[10px] font-bold text-[#9a5a39] shadow-[0_8px_18px_rgba(174,111,73,0.1)] sm:gap-2 sm:px-5 sm:py-2.5 sm:text-[11px]">
|
||||
<span className="min-w-0 truncate">
|
||||
开始时间 {banner.startsAtText}
|
||||
</span>
|
||||
<span className="shrink-0 text-[#c99373]">|</span>
|
||||
<span className="min-w-0 truncate">
|
||||
结束时间 {banner.endsAtText}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className="creation-event-banner__pager flex items-center justify-center gap-1.5"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{eventBanners.map((dotBanner, dotIndex) => (
|
||||
<span
|
||||
key={`${dotBanner.title}:dot:${dotIndex}`}
|
||||
className={
|
||||
dotIndex === activeBannerIndex
|
||||
? 'h-1.5 w-5 rounded-full bg-[#d9793f]'
|
||||
: 'h-1.5 w-1.5 rounded-full bg-[#eadfd7]'
|
||||
}
|
||||
<div className="mt-4 flex flex-col gap-2">
|
||||
<div className="creation-event-banner__timebar flex min-w-0 items-center justify-center gap-1.5 rounded-full border border-[#e9c5b0] bg-white/72 px-2.5 py-1.5 text-center text-[10px] font-bold text-[#9a5a39] shadow-[0_8px_18px_rgba(174,111,73,0.1)] sm:gap-2 sm:px-5 sm:py-2.5 sm:text-[11px]">
|
||||
<span className="min-w-0 truncate">
|
||||
开始时间 {banner.startsAtText}
|
||||
</span>
|
||||
<span className="shrink-0 text-[#c99373]">|</span>
|
||||
<span className="min-w-0 truncate">
|
||||
结束时间 {banner.endsAtText}
|
||||
</span>
|
||||
</div>
|
||||
<CreationEntryBannerPager
|
||||
banners={eventBanners}
|
||||
activeBannerIndex={activeBannerIndex}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{shouldRenderHtmlBanner ? (
|
||||
<div className="absolute inset-x-0 bottom-3 z-10">
|
||||
<CreationEntryBannerPager
|
||||
banners={eventBanners}
|
||||
activeBannerIndex={activeBannerIndex}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
@@ -156,8 +261,26 @@ export function CustomWorldCreationStartCard({
|
||||
<div
|
||||
className="-mx-0.5 flex snap-x items-center gap-2 overflow-x-auto px-0.5 pb-1 scrollbar-hide scroll-px-2 sm:gap-3"
|
||||
role="tablist"
|
||||
aria-label="玩法模板分类"
|
||||
aria-label="创作入口页签"
|
||||
>
|
||||
{hasRecentWorks ? (
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={isRecentTabActive}
|
||||
onClick={() => setActiveCategoryId(CREATION_ENTRY_RECENT_TAB_ID)}
|
||||
className={`relative min-h-8 shrink-0 rounded-full px-2.5 text-xs font-black transition sm:min-h-9 sm:px-3.5 sm:text-sm ${
|
||||
isRecentTabActive
|
||||
? 'text-[#6f2f21]'
|
||||
: 'text-[#7a6558] hover:text-[#6f2f21]'
|
||||
}`}
|
||||
>
|
||||
<span>最近创作</span>
|
||||
{isRecentTabActive ? (
|
||||
<span className="absolute bottom-0 left-3 right-3 h-1 rounded-full bg-[#d9793f]" />
|
||||
) : null}
|
||||
</button>
|
||||
) : null}
|
||||
{creationTypeGroups.map((group) => {
|
||||
const selected = group.id === activeGroup?.id;
|
||||
return (
|
||||
@@ -182,55 +305,78 @@ export function CustomWorldCreationStartCard({
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="creation-template-list__grid mt-2 grid grid-cols-2 gap-2 sm:mt-3 sm:gap-3">
|
||||
{visibleCreationTypes.map((item) => {
|
||||
const disabled = item.locked || busy;
|
||||
|
||||
return (
|
||||
{isRecentTabActive ? (
|
||||
<div className="creation-recent-work-grid mt-2 grid grid-cols-2 gap-2 sm:mt-3 sm:gap-3">
|
||||
{recentWorks.map((item, index) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
onCreateType(item.id);
|
||||
}}
|
||||
className={`creation-template-card platform-interactive-card relative flex min-h-[12.5rem] flex-col overflow-hidden rounded-[1rem] border bg-white p-0 text-left transition sm:min-h-[15rem] sm:rounded-[1.2rem] ${
|
||||
item.locked
|
||||
? 'cursor-not-allowed border-[#eadbd3] text-[#725b4d] opacity-72'
|
||||
: 'border-[#eadbd3] text-[#2f211b] hover:border-[#dc9a72] hover:shadow-[0_16px_34px_rgba(174,111,73,0.14)]'
|
||||
} ${busy && !item.locked ? 'opacity-70' : ''}`}
|
||||
aria-label={`打开最近创作 ${index + 1}`}
|
||||
className="creation-recent-work-card min-h-[7.5rem] rounded-[1rem] border border-[#eadbd3] bg-white p-3 text-left shadow-[0_10px_22px_rgba(174,111,73,0.1)]"
|
||||
onClick={() => onOpenRecentWork?.(index)}
|
||||
>
|
||||
<div className="creation-template-card__media relative aspect-[1.32/1] w-full overflow-hidden bg-[#f7ebe3]">
|
||||
<img
|
||||
src={item.imageSrc}
|
||||
alt=""
|
||||
className="h-full w-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
{shouldShowCreationBadge(item.badge) ? (
|
||||
<span className="absolute left-2 top-2 max-w-[calc(100%-1rem)] rounded-full bg-[#b66a3e] px-2 py-0.5 text-xs font-black text-white shadow-sm sm:left-3 sm:top-3 sm:px-2.5 sm:py-1">
|
||||
{item.badge}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="creation-template-card__cost-badge absolute bottom-2 right-2 inline-flex max-w-[calc(100%-1rem)] items-center gap-1 rounded-full bg-[#fff7ec]/92 px-2 py-1 text-[11px] font-black leading-4 text-[#b65f2c] shadow-[0_8px_18px_rgba(119,72,44,0.16)]">
|
||||
<Coins className="h-3 w-3 shrink-0" />
|
||||
<span className="truncate">10-20泥点数</span>
|
||||
</span>
|
||||
<div className="line-clamp-1 text-sm font-black text-[#2f211b]">
|
||||
{item.title}
|
||||
</div>
|
||||
|
||||
<div className="creation-template-card__body flex min-h-[4.6rem] flex-1 flex-col bg-white px-2.5 pb-2.5 pt-2.5 text-[#2f211b] sm:min-h-[5.4rem] sm:px-3.5 sm:pb-3.5">
|
||||
<div className="creation-template-card__title line-clamp-1 text-sm font-black leading-5 text-[#2f211b]">
|
||||
{item.title}
|
||||
</div>
|
||||
<div className="creation-template-card__subtitle mt-1 line-clamp-2 text-xs font-semibold leading-4 text-[#6f5a4c] sm:leading-5">
|
||||
{item.subtitle}
|
||||
</div>
|
||||
<div className="mt-1 line-clamp-3 text-xs font-semibold leading-4 text-[#6f5a4c]">
|
||||
{item.summary}
|
||||
</div>
|
||||
<div className="mt-2 text-[11px] font-bold text-[#b65f2c]">
|
||||
{item.statusLabel}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="creation-template-list__grid mt-2 grid grid-cols-2 gap-2 sm:mt-3 sm:gap-3">
|
||||
{visibleCreationTypes.map((item) => {
|
||||
const disabled = item.locked || busy;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
onCreateType(item.id);
|
||||
}}
|
||||
className={`creation-template-card platform-interactive-card relative flex min-h-[12.5rem] flex-col overflow-hidden rounded-[1rem] border bg-white p-0 text-left transition sm:min-h-[15rem] sm:rounded-[1.2rem] ${
|
||||
item.locked
|
||||
? 'cursor-not-allowed border-[#eadbd3] text-[#725b4d] opacity-72'
|
||||
: 'border-[#eadbd3] text-[#2f211b] hover:border-[#dc9a72] hover:shadow-[0_16px_34px_rgba(174,111,73,0.14)]'
|
||||
} ${busy && !item.locked ? 'opacity-70' : ''}`}
|
||||
>
|
||||
<div className="creation-template-card__media relative aspect-[1.32/1] w-full overflow-hidden bg-[#f7ebe3]">
|
||||
<img
|
||||
src={item.imageSrc}
|
||||
alt=""
|
||||
className="h-full w-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
{shouldShowCreationBadge(item.badge) ? (
|
||||
<span className="absolute left-2 top-2 max-w-[calc(100%-1rem)] rounded-full bg-[#b66a3e] px-2 py-0.5 text-xs font-black text-white shadow-sm sm:left-3 sm:top-3 sm:px-2.5 sm:py-1">
|
||||
{item.badge}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="creation-template-card__cost-badge absolute bottom-2 right-2 inline-flex max-w-[calc(100%-1rem)] items-center gap-1 rounded-full bg-[#fff7ec]/92 px-2 py-1 text-[11px] font-black leading-4 text-[#b65f2c] shadow-[0_8px_18px_rgba(119,72,44,0.16)]">
|
||||
<Coins className="h-3 w-3 shrink-0" />
|
||||
<span className="truncate">10-20泥点数</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="creation-template-card__body flex min-h-[4.6rem] flex-1 flex-col bg-white px-2.5 pb-2.5 pt-2.5 text-[#2f211b] sm:min-h-[5.4rem] sm:px-3.5 sm:pb-3.5">
|
||||
<div className="creation-template-card__title line-clamp-1 text-sm font-black leading-5 text-[#2f211b]">
|
||||
{item.title}
|
||||
</div>
|
||||
<div className="creation-template-card__subtitle mt-1 line-clamp-2 text-xs font-semibold leading-4 text-[#6f5a4c] sm:leading-5">
|
||||
{item.subtitle}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1093,6 +1093,9 @@ function isPersistedCreationWorkGenerating(item: CreationWorkShelfItem) {
|
||||
switch (item.source.kind) {
|
||||
case 'match3d':
|
||||
return item.source.item.generationStatus === 'generating';
|
||||
case 'jump-hop':
|
||||
// 中文注释:跳一跳后端生成中草稿也要同步到作品架与最近创作状态。
|
||||
return item.source.item.generationStatus === 'generating';
|
||||
case 'puzzle':
|
||||
return isPersistedPuzzleDraftGenerating(item.source.item);
|
||||
case 'wooden-fish':
|
||||
|
||||
@@ -36,9 +36,9 @@ const entryConfig = {
|
||||
visible: true,
|
||||
open: true,
|
||||
sortOrder: 10,
|
||||
categoryId: 'recent',
|
||||
categoryLabel: '最近创作',
|
||||
categorySortOrder: 10,
|
||||
categoryId: 'recommended',
|
||||
categoryLabel: '热门推荐',
|
||||
categorySortOrder: 20,
|
||||
updatedAtMicros: 1,
|
||||
},
|
||||
],
|
||||
|
||||
@@ -353,6 +353,7 @@ import type { PublishShareModalPayload } from '../common/publishShareModalModel'
|
||||
import { UnifiedModal } from '../common/UnifiedModal';
|
||||
import { resolveCreativeAgentTargetSelectionStage } from '../creative-agent/creativeAgentViewModel';
|
||||
import {
|
||||
buildCreationWorkShelfItems,
|
||||
type CreationWorkShelfItem,
|
||||
isPersistedBarkBattleDraftGenerating,
|
||||
isPersistedPuzzleDraftGenerating,
|
||||
@@ -2162,35 +2163,24 @@ function buildDraftCompletionDialogSource(
|
||||
return formatPlatformTaskCompletionSource('创作草稿', sourceId);
|
||||
}
|
||||
|
||||
/** 为恢复的小游戏草稿重建生成态,保留后端开始时间作为进度事实源。 */
|
||||
function createMiniGameDraftGenerationStateForRestoredDraft(
|
||||
kind: MiniGameDraftGenerationKind,
|
||||
metadata?: MiniGameDraftGenerationState['metadata'],
|
||||
startedAtMs = Date.now(),
|
||||
): MiniGameDraftGenerationState {
|
||||
return {
|
||||
...createMiniGameDraftGenerationState(kind),
|
||||
...createMiniGameDraftGenerationState(kind, startedAtMs),
|
||||
...(metadata ? { metadata } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** 清理生成态完成时间,避免返回生成页后继续沿用结束态计时。 */
|
||||
function rebaseMiniGameDraftGenerationStateForDisplay(
|
||||
state: MiniGameDraftGenerationState,
|
||||
): MiniGameDraftGenerationState {
|
||||
const rebasedStartedAtMs = Date.now();
|
||||
|
||||
if (state.kind === 'puzzle') {
|
||||
const puzzleAiRedraw = state.metadata?.puzzleAiRedraw;
|
||||
return {
|
||||
...state,
|
||||
startedAtMs: rebasedStartedAtMs,
|
||||
finishedAtMs: undefined,
|
||||
metadata:
|
||||
typeof puzzleAiRedraw === 'boolean' ? { puzzleAiRedraw } : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
startedAtMs: rebasedStartedAtMs,
|
||||
finishedAtMs: undefined,
|
||||
};
|
||||
}
|
||||
@@ -14920,6 +14910,19 @@ export function PlatformEntryFlowShellImpl({
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
platformBootstrap.platformTab === 'create' &&
|
||||
platformBootstrap.canReadProtectedData
|
||||
) {
|
||||
// 中文注释:底部加号创作入口的“最近创作”依赖 RPG works 摘要;
|
||||
// 失败草稿也必须随进入创作页刷新,不能只等草稿页刷新后才可见。
|
||||
void platformBootstrap.refreshCustomWorldWorks().catch((error) => {
|
||||
platformBootstrap.setPlatformError(
|
||||
resolveRpgCreationErrorMessage(error, '读取创作作品列表失败。'),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
(platformBootstrap.platformTab === 'create' ||
|
||||
selectionStage === 'platform') &&
|
||||
@@ -14942,6 +14945,8 @@ export function PlatformEntryFlowShellImpl({
|
||||
isVisualNovelCreationOpen,
|
||||
platformBootstrap.canReadProtectedData,
|
||||
platformBootstrap.platformTab,
|
||||
platformBootstrap.refreshCustomWorldWorks,
|
||||
platformBootstrap.setPlatformError,
|
||||
refreshBabyObjectMatchShelf,
|
||||
refreshBarkBattleShelf,
|
||||
refreshMatch3DShelf,
|
||||
@@ -14969,6 +14974,46 @@ export function PlatformEntryFlowShellImpl({
|
||||
selectionStage,
|
||||
]);
|
||||
|
||||
// 中文注释:最近创作必须由真实作品架/后端草稿摘要决定,不能混入本地生成中占位。
|
||||
const backendRecentCreationShelfItems = useMemo(
|
||||
() =>
|
||||
buildCreationWorkShelfItems({
|
||||
rpgItems: creationHubItems,
|
||||
rpgLibraryEntries: platformBootstrap.savedCustomWorldEntries,
|
||||
bigFishItems: isBigFishCreationVisible ? bigFishWorks : [],
|
||||
match3dItems: match3dWorks,
|
||||
squareHoleItems: isSquareHoleCreationVisible ? squareHoleWorks : [],
|
||||
jumpHopItems: isJumpHopCreationVisible ? jumpHopWorks : [],
|
||||
woodenFishItems: woodenFishWorks,
|
||||
puzzleItems: puzzleWorks,
|
||||
babyObjectMatchItems: isBabyObjectMatchVisible
|
||||
? babyObjectMatchDrafts
|
||||
: [],
|
||||
barkBattleItems: barkBattleWorks,
|
||||
visualNovelItems: isVisualNovelCreationOpen ? visualNovelWorks : [],
|
||||
getItemState: getCreationWorkShelfState,
|
||||
}),
|
||||
[
|
||||
barkBattleWorks,
|
||||
babyObjectMatchDrafts,
|
||||
bigFishWorks,
|
||||
creationHubItems,
|
||||
getCreationWorkShelfState,
|
||||
isBabyObjectMatchVisible,
|
||||
isBigFishCreationVisible,
|
||||
isJumpHopCreationVisible,
|
||||
isSquareHoleCreationVisible,
|
||||
isVisualNovelCreationOpen,
|
||||
jumpHopWorks,
|
||||
match3dWorks,
|
||||
platformBootstrap.savedCustomWorldEntries,
|
||||
puzzleWorks,
|
||||
squareHoleWorks,
|
||||
visualNovelWorks,
|
||||
woodenFishWorks,
|
||||
],
|
||||
);
|
||||
|
||||
const renderCreationHubContent = (
|
||||
mode: 'start-only' | 'works-only',
|
||||
fallbackLabel: string,
|
||||
@@ -15065,6 +15110,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
}
|
||||
entryConfig={creationEntryConfig}
|
||||
creationTypes={creationEntryTypes}
|
||||
recentWorkItems={backendRecentCreationShelfItems}
|
||||
onCreateType={handleCreationHubCreateType}
|
||||
getWorkState={getCreationWorkShelfState}
|
||||
onOpenShelfItem={(item) => {
|
||||
|
||||
@@ -306,7 +306,7 @@ test('groups visible platform creation types by backend category metadata', () =
|
||||
const groups = groupVisiblePlatformCreationTypes(cards);
|
||||
|
||||
expect(groups.map((group) => group.label)).toEqual([
|
||||
'最近创作',
|
||||
'热门推荐',
|
||||
'节日主题',
|
||||
]);
|
||||
expect(groups[0]?.items.map((item) => item.id)).toEqual([
|
||||
@@ -337,14 +337,14 @@ test('falls back when backend creation type category metadata is missing', () =>
|
||||
expect(cards[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
id: 'legacy-entry',
|
||||
categoryId: 'recent',
|
||||
categoryLabel: '最近创作',
|
||||
categoryId: 'recommended',
|
||||
categoryLabel: '热门推荐',
|
||||
}),
|
||||
);
|
||||
expect(groupVisiblePlatformCreationTypes(cards)).toEqual([
|
||||
expect.objectContaining({
|
||||
id: 'recent',
|
||||
label: '最近创作',
|
||||
id: 'recommended',
|
||||
label: '热门推荐',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -24,8 +24,9 @@ export type PlatformCreationTypeGroup = {
|
||||
items: PlatformCreationTypeCard[];
|
||||
};
|
||||
|
||||
const FALLBACK_CREATION_CATEGORY_ID = 'recent';
|
||||
const FALLBACK_CREATION_CATEGORY_LABEL = '最近创作';
|
||||
const RECENT_CREATION_CATEGORY_ID = 'recent';
|
||||
const FALLBACK_CREATION_CATEGORY_ID = 'recommended';
|
||||
const FALLBACK_CREATION_CATEGORY_LABEL = '热门推荐';
|
||||
|
||||
export function getVisiblePlatformCreationTypes(
|
||||
creationTypes: readonly PlatformCreationTypeCard[],
|
||||
@@ -55,16 +56,25 @@ export function isPlatformCreationTypeOpen(
|
||||
);
|
||||
}
|
||||
|
||||
/** 归一化模板分类 ID,历史 recent 分类会并入推荐分类。 */
|
||||
function normalizeCategoryId(value: string | null | undefined) {
|
||||
const normalized = typeof value === 'string' ? value.trim() : '';
|
||||
if (normalized === RECENT_CREATION_CATEGORY_ID) {
|
||||
return FALLBACK_CREATION_CATEGORY_ID;
|
||||
}
|
||||
return normalized || FALLBACK_CREATION_CATEGORY_ID;
|
||||
}
|
||||
|
||||
/** 归一化模板分类名,避免最近创作被误当作模板页签。 */
|
||||
function normalizeCategoryLabel(value: string | null | undefined) {
|
||||
const normalized = typeof value === 'string' ? value.trim() : '';
|
||||
if (normalized === '最近创作') {
|
||||
return FALLBACK_CREATION_CATEGORY_LABEL;
|
||||
}
|
||||
return normalized || FALLBACK_CREATION_CATEGORY_LABEL;
|
||||
}
|
||||
|
||||
/** 按玩法模板分类分组,旧 recent 分类不再作为模板页签展示。 */
|
||||
export function groupVisiblePlatformCreationTypes(
|
||||
creationTypes: readonly PlatformCreationTypeCard[],
|
||||
): PlatformCreationTypeGroup[] {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -4544,6 +4544,20 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
|
||||
padding: 0.58rem 0.58rem 0.58rem 0.68rem;
|
||||
}
|
||||
|
||||
/* 草稿页移动端整体禁止长按选择文字,避免误触系统选区。 */
|
||||
#platform-tab-panel-saves,
|
||||
#platform-tab-panel-saves * {
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
-webkit-touch-callout: none;
|
||||
}
|
||||
|
||||
#platform-tab-panel-saves :is(input, textarea, [contenteditable='true']) {
|
||||
-webkit-user-select: text;
|
||||
user-select: text;
|
||||
-webkit-touch-callout: default;
|
||||
}
|
||||
|
||||
.creation-work-card-shell {
|
||||
border-radius: 0.86rem;
|
||||
}
|
||||
|
||||
@@ -52,3 +52,31 @@ describe('index stylesheet unread dots', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('index stylesheet draft mobile cards', () => {
|
||||
it('disables long-press text selection on the whole mobile draft tab', () => {
|
||||
const css = readIndexCss();
|
||||
const mobileQueryIndex = css.indexOf('@media (max-width: 639px)');
|
||||
expect(mobileQueryIndex).toBeGreaterThanOrEqual(0);
|
||||
|
||||
const draftPanelSelector =
|
||||
'#platform-tab-panel-saves,\n #platform-tab-panel-saves *';
|
||||
const draftPanelSelectorIndex = css.indexOf(draftPanelSelector, mobileQueryIndex);
|
||||
expect(draftPanelSelectorIndex).toBeGreaterThanOrEqual(0);
|
||||
|
||||
const draftPanelBlock = getCssBlock(css, draftPanelSelector);
|
||||
expect(draftPanelBlock).toContain('-webkit-user-select: none;');
|
||||
expect(draftPanelBlock).toContain('user-select: none;');
|
||||
expect(draftPanelBlock).toContain('-webkit-touch-callout: none;');
|
||||
|
||||
const editableSelector =
|
||||
"#platform-tab-panel-saves :is(input, textarea, [contenteditable='true'])";
|
||||
const editableSelectorIndex = css.indexOf(editableSelector, mobileQueryIndex);
|
||||
expect(editableSelectorIndex).toBeGreaterThanOrEqual(0);
|
||||
|
||||
const editableBlock = getCssBlock(css, editableSelector);
|
||||
expect(editableBlock).toContain('-webkit-user-select: text;');
|
||||
expect(editableBlock).toContain('user-select: text;');
|
||||
expect(editableBlock).toContain('-webkit-touch-callout: default;');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { requestJson } from './apiClient';
|
||||
|
||||
/** 后端下发的单个创作类型入口配置,前端只据此展示和分流。 */
|
||||
export type CreationEntryTypeConfig = {
|
||||
id: string;
|
||||
title: string;
|
||||
@@ -16,6 +17,7 @@ export type CreationEntryTypeConfig = {
|
||||
unifiedCreationSpec?: UnifiedCreationSpec | null;
|
||||
};
|
||||
|
||||
/** 统一创作工作台字段契约,用于表单型玩法的最小输入描述。 */
|
||||
export type UnifiedCreationField = {
|
||||
id: string;
|
||||
kind: 'text' | 'select' | 'image' | 'audio';
|
||||
@@ -23,6 +25,7 @@ export type UnifiedCreationField = {
|
||||
required: boolean;
|
||||
};
|
||||
|
||||
/** 统一创作工作台契约,把入口类型映射到工作台、生成页和结果页阶段。 */
|
||||
export type UnifiedCreationSpec = {
|
||||
playId: 'puzzle' | 'match3d' | 'jump-hop' | 'wooden-fish';
|
||||
title: string;
|
||||
@@ -44,6 +47,19 @@ export type UnifiedCreationSpec = {
|
||||
fields: UnifiedCreationField[];
|
||||
};
|
||||
|
||||
/** 创作入口公告位配置,HTML 模式仅用于沙箱预览,结构化字段保留旧数据兼容。 */
|
||||
export type CreationEntryEventBannerConfig = {
|
||||
title: string;
|
||||
description: string;
|
||||
coverImageSrc: string;
|
||||
prizePoolMudPoints: number;
|
||||
startsAtText: string;
|
||||
endsAtText: string;
|
||||
renderMode?: 'structured' | 'html';
|
||||
htmlCode?: string | null;
|
||||
};
|
||||
|
||||
/** 创作入口页完整配置;前端只展示后端事实源,不内置入口默认值。 */
|
||||
export type CreationEntryConfig = {
|
||||
startCard: {
|
||||
title: string;
|
||||
@@ -55,17 +71,14 @@ export type CreationEntryConfig = {
|
||||
title: string;
|
||||
description: string;
|
||||
};
|
||||
eventBanner: {
|
||||
title: string;
|
||||
description: string;
|
||||
coverImageSrc: string;
|
||||
prizePoolMudPoints: number;
|
||||
startsAtText: string;
|
||||
endsAtText: string;
|
||||
};
|
||||
/** 旧单条公告位兼容字段,新代码优先读取 eventBanners。 */
|
||||
eventBanner: CreationEntryEventBannerConfig;
|
||||
/** 底部加号创作入口页的多公告轮播配置。 */
|
||||
eventBanners?: CreationEntryEventBannerConfig[];
|
||||
creationTypes: CreationEntryTypeConfig[];
|
||||
};
|
||||
|
||||
/** 拉取底部加号创作入口配置;所有入口和公告都以后端事实源为准。 */
|
||||
export async function fetchCreationEntryConfig(): Promise<CreationEntryConfig> {
|
||||
return requestJson<CreationEntryConfig>(
|
||||
'/api/creation-entry/config',
|
||||
|
||||
@@ -815,6 +815,7 @@ function resolveElapsedActiveStepProgressRatio(
|
||||
);
|
||||
}
|
||||
|
||||
/** 计算拼图生成总进度,后端里程碑决定跨步骤,当前步骤内使用平滑假进度。 */
|
||||
function resolvePuzzleOverallProgress(
|
||||
state: MiniGameDraftGenerationState,
|
||||
activeStepProgressRatio: number,
|
||||
|
||||
Reference in New Issue
Block a user