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

@@ -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',

View File

@@ -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('&lt;section&gt;&lt;h1&gt;自定义横幅&lt;/h1&gt;&lt;/section&gt;');
});
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

View File

@@ -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}

View File

@@ -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">
&nbsp;&nbsp;{banner.startsAtText}
</span>
<span className="shrink-0 text-[#c99373]">|</span>
<span className="min-w-0 truncate">
&nbsp;&nbsp;{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">
&nbsp;&nbsp;{banner.startsAtText}
</span>
<span className="shrink-0 text-[#c99373]">|</span>
<span className="min-w-0 truncate">
&nbsp;&nbsp;{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>
);

View File

@@ -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':

View File

@@ -36,9 +36,9 @@ const entryConfig = {
visible: true,
open: true,
sortOrder: 10,
categoryId: 'recent',
categoryLabel: '最近创作',
categorySortOrder: 10,
categoryId: 'recommended',
categoryLabel: '热门推荐',
categorySortOrder: 20,
updatedAtMicros: 1,
},
],

View File

@@ -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) => {

View File

@@ -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: '热门推荐',
}),
]);
});

View File

@@ -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[] {

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');

View File

@@ -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;
}

View File

@@ -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;');
});
});

View File

@@ -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',

View File

@@ -815,6 +815,7 @@ function resolveElapsedActiveStepProgressRatio(
);
}
/** 计算拼图生成总进度,后端里程碑决定跨步骤,当前步骤内使用平滑假进度。 */
function resolvePuzzleOverallProgress(
state: MiniGameDraftGenerationState,
activeStepProgressRatio: number,