Merge branch 'master' into codex/puzzle-clear-template-runtime-fixes

# Conflicts:
#	.hermes/shared-memory/decision-log.md
#	.hermes/shared-memory/project-overview.md
#	docs/【开发运维】本地开发验证与生产运维-2026-05-15.md
#	scripts/dev.test.ts
#	server-rs/crates/api-server/src/creation_entry_config.rs
#	server-rs/crates/api-server/src/wooden_fish.rs
#	server-rs/crates/module-auth/src/lib.rs
#	server-rs/crates/spacetime-client/src/wooden_fish.rs
#	server-rs/crates/spacetime-module/src/auth/procedures.rs
#	src/components/custom-world-home/creationWorkShelf.ts
#	src/components/platform-entry/PlatformEntryFlowShellImpl.tsx
#	src/components/rpg-entry/rpgEntryWorldPresentation.ts
#	src/services/miniGameDraftGenerationProgress.test.ts
#	src/services/miniGameDraftGenerationProgress.ts
This commit is contained in:
2026-06-04 11:24:14 +08:00
451 changed files with 18452 additions and 5266 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',
@@ -560,7 +612,7 @@ test('creation hub shows RPG public work code from published library entry', ()
expect(screen.queryByText('CW-00000001')).toBeNull();
});
test('creation hub exposes persisted draft delete action directly on the card', () => {
test('creation hub keeps persisted draft delete action off the card header', () => {
const { container } = render(
<CustomWorldCreationHub
items={[{ ...baseDraftItem, profileId: 'profile-1' }]}
@@ -579,7 +631,7 @@ test('creation hub exposes persisted draft delete action directly on the card',
expect(
container.querySelector('.creation-work-card__swipe-underlay'),
).toBeTruthy();
expect(screen.getByRole('button', { name: '删除' })).toBeTruthy();
expect(screen.queryByRole('button', { name: '删除' })).toBeNull();
});
test('creation hub reveals persisted draft delete action from left swipe', () => {
@@ -641,6 +693,75 @@ test('creation hub reveals persisted draft delete action from keyboard', async (
expect(screen.queryByRole('button', { name: '分享' })).toBeNull();
});
test('creation hub reveals persisted draft delete action from long press menu', () => {
const { container } = render(
<CustomWorldCreationHub
items={[{ ...baseDraftItem, profileId: 'profile-1' }]}
loading={false}
error={null}
onRetry={() => {}}
onCreateType={noopCreateType}
onOpenDraft={() => {}}
onEnterPublished={() => {}}
onDeletePublished={() => {}}
entryConfig={testEntryConfig}
creationTypes={testCreationTypes}
/>,
);
const card = screen.getByRole('button', { name: //u });
fireEvent.contextMenu(card);
expect(
container.querySelector('.creation-work-card-shell--actions-visible'),
).toBeTruthy();
expect(screen.getByRole('button', { name: '删除' })).toBeTruthy();
});
test('creation hub gives every deletable work card a side delete action', () => {
const { container } = render(
<CustomWorldCreationHub
items={[{ ...baseDraftItem, profileId: 'profile-1' }]}
babyObjectMatchItems={[babyObjectMatchDraftItem]}
puzzleItems={[
{
workId: 'puzzle:side-delete',
profileId: 'puzzle-profile-side-delete',
ownerUserId: 'user-1',
authorDisplayName: '拼图作者',
levelName: '侧边删除拼图',
summary: '不同来源都应有侧边删除。',
themeTags: ['灯塔'],
coverImageSrc: null,
publicationStatus: 'draft',
updatedAt: new Date('2026-05-02T12:00:00.000Z').toISOString(),
publishedAt: null,
playCount: 0,
remixCount: 0,
likeCount: 0,
publishReady: true,
},
]}
loading={false}
error={null}
onRetry={() => {}}
onCreateType={noopCreateType}
onOpenDraft={() => {}}
onEnterPublished={() => {}}
onDeletePublished={() => {}}
onDeleteBabyObjectMatch={() => {}}
onDeletePuzzle={() => {}}
entryConfig={testEntryConfig}
creationTypes={testCreationTypes}
/>,
);
expect(screen.queryByRole('button', { name: '删除' })).toBeNull();
expect(
container.querySelectorAll('.creation-work-card__swipe-underlay'),
).toHaveLength(3);
});
test('creation hub shows delete action for baby object match drafts', async () => {
const user = userEvent.setup();
const onDeleteBabyObjectMatch = vi.fn();
@@ -719,7 +840,7 @@ test('creation hub works-only tab filters bark battle draft and published works'
expect(onOpenBarkBattleDetail).toHaveBeenCalledWith(barkBattlePublishedItem);
});
test('creation hub published work delete action is directly visible', async () => {
test('creation hub published work delete action stays in revealed side actions', async () => {
const user = userEvent.setup();
const onDeletePuzzle = vi.fn();
const onOpenPuzzleDetail = vi.fn();
@@ -759,9 +880,11 @@ test('creation hub published work delete action is directly visible', async () =
/>,
);
expect(screen.getByRole('button', { name: '删除' })).toBeTruthy();
expect(screen.queryByRole('button', { name: '删除' })).toBeNull();
expect(screen.getByRole('button', { name: '分享' })).toBeTruthy();
screen.getByRole('button', { name: //u }).focus();
await user.keyboard('{ArrowLeft}');
await user.click(screen.getByRole('button', { name: '删除' }));
expect(onDeletePuzzle).toHaveBeenCalledWith(
@@ -770,7 +893,7 @@ test('creation hub published work delete action is directly visible', async () =
expect(onOpenPuzzleDetail).not.toHaveBeenCalled();
});
test('creation hub exposes work delete action directly on card', async () => {
test('creation hub reveals draft work delete action from keyboard', async () => {
const user = userEvent.setup();
const onDeletePuzzle = vi.fn();
const onOpenPuzzleDetail = vi.fn();
@@ -810,6 +933,10 @@ test('creation hub exposes work delete action directly on card', async () => {
/>,
);
expect(screen.queryByRole('button', { name: '删除' })).toBeNull();
screen.getByRole('button', { name: //u }).focus();
await user.keyboard('{ArrowLeft}');
await user.click(screen.getByRole('button', { name: '删除' }));
expect(onDeletePuzzle).toHaveBeenCalledWith(
@@ -858,7 +985,9 @@ test('creation hub keeps swipe delete action available', async () => {
/>,
);
const card = screen.getByRole('button', { name: //u });
const card = screen.getByRole('button', {
name: //u,
});
fireEvent.touchStart(card, {
touches: [{ clientX: 180, clientY: 20 }],
});

View File

@@ -3,9 +3,15 @@ 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 = () => {};
const DAY_MS = 24 * 60 * 60 * 1000;
function buildUpdatedAtDaysAgo(daysAgo: number) {
return new Date(Date.now() - daysAgo * DAY_MS).toISOString();
}
const testEntryConfig = {
startCard: {
@@ -26,6 +32,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 +62,9 @@ const testEntryConfig = {
visible: true,
open: true,
sortOrder: 10,
categoryId: 'recent',
categoryLabel: '最近创作',
categorySortOrder: 10,
categoryId: 'recommended',
categoryLabel: '热门推荐',
categorySortOrder: 20,
updatedAtMicros: 1,
},
{
@@ -50,9 +76,9 @@ const testEntryConfig = {
visible: true,
open: true,
sortOrder: 30,
categoryId: 'recent',
categoryLabel: '最近创作',
categorySortOrder: 10,
categoryId: 'recommended',
categoryLabel: '热门推荐',
categorySortOrder: 20,
updatedAtMicros: 1,
},
{
@@ -64,9 +90,23 @@ const testEntryConfig = {
visible: true,
open: true,
sortOrder: 40,
categoryId: 'recent',
categoryLabel: '最近创作',
categorySortOrder: 10,
categoryId: 'recommended',
categoryLabel: '热门推荐',
categorySortOrder: 20,
updatedAtMicros: 1,
},
{
id: 'jump-hop',
title: '跳一跳',
subtitle: '节奏跳跃挑战',
badge: '可创建',
imageSrc: '/creation-type-references/jump-hop.webp',
visible: true,
open: true,
sortOrder: 45,
categoryId: 'recommended',
categoryLabel: '热门推荐',
categorySortOrder: 20,
updatedAtMicros: 1,
},
{
@@ -78,9 +118,9 @@ const testEntryConfig = {
visible: false,
open: true,
sortOrder: 50,
categoryId: 'recent',
categoryLabel: '最近创作',
categorySortOrder: 10,
categoryId: 'recommended',
categoryLabel: '热门推荐',
categorySortOrder: 20,
updatedAtMicros: 1,
},
{
@@ -92,9 +132,9 @@ const testEntryConfig = {
visible: false,
open: false,
sortOrder: 60,
categoryId: 'recent',
categoryLabel: '最近创作',
categorySortOrder: 10,
categoryId: 'recommended',
categoryLabel: '热门推荐',
categorySortOrder: 20,
updatedAtMicros: 1,
},
{
@@ -106,9 +146,9 @@ const testEntryConfig = {
visible: true,
open: false,
sortOrder: 70,
categoryId: 'recent',
categoryLabel: '最近创作',
categorySortOrder: 10,
categoryId: 'recommended',
categoryLabel: '热门推荐',
categorySortOrder: 20,
updatedAtMicros: 1,
},
],
@@ -186,11 +226,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,
);
@@ -199,6 +243,8 @@ test('creation start card renders reference-aligned banner and template metadata
expect(html).toContain('拼图关卡创作');
expect(html).toContain('10-20泥点数');
expect(html).toContain('即将开放');
expect(html).toContain('data-locked="true"');
expect(html).toContain('暂未开放');
expect(html).not.toContain('可创建');
expect(html).not.toContain('可创作');
expect(html).not.toContain('creation-event-banner__counter');
@@ -206,6 +252,403 @@ test('creation start card renders reference-aligned banner and template metadata
expect(html).not.toContain('platform-creation-reference-card');
});
test('locked creation template card replaces mud point cost with unavailable state', () => {
const lockedEntryConfig = {
...testEntryConfig,
creationTypes: [
{
id: 'airp',
title: 'AI RPG',
subtitle: '原生角色扮演',
badge: '即将开放',
imageSrc: '/creation-type-references/airp.webp',
visible: true,
open: false,
sortOrder: 70,
categoryId: 'recommended',
categoryLabel: '热门推荐',
categorySortOrder: 20,
updatedAtMicros: 1,
},
],
} satisfies CreationEntryConfig;
const html = renderToStaticMarkup(
<CustomWorldCreationHub
items={[]}
loading={false}
error={null}
onRetry={() => {}}
onCreateType={noopCreateType}
onOpenDraft={() => {}}
onEnterPublished={() => {}}
entryConfig={lockedEntryConfig}
creationTypes={derivePlatformCreationTypes(
lockedEntryConfig.creationTypes,
)}
mode="start-only"
/>,
);
expect(html).toContain('data-locked="true"');
expect(html).toContain('即将开放');
expect(html).toContain('暂未开放');
expect(html).not.toContain('10-20泥点数');
});
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 with the same template cards', () => {
const html = renderToStaticMarkup(
<CustomWorldCreationHub
items={[
{
workId: 'draft:recent-session',
sourceType: 'agent_session',
status: 'draft',
title: '后端返回的最近草稿',
subtitle: '待完善草稿',
summary: '这条内容来自作品架摘要。',
coverImageSrc: null,
updatedAt: buildUpdatedAtDaysAgo(1),
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}
mode="start-only"
/>,
);
expect(html).toContain('aria-label="创作入口页签"');
expect(html).toContain('role="tab"');
expect(html).toContain('aria-selected="true"');
expect(html).toContain('creation-template-list__grid');
expect(html).toContain('creation-template-card');
expect(html).toContain('最近创作');
expect(html).toContain('仅显示最近7天内使用过的模板');
expect(html).toContain('文字冒险');
expect(html).toContain('经典 RPG 体验');
expect(html).not.toContain('creation-recent-work-grid');
expect(html).not.toContain('打开最近创作');
expect(html).not.toContain('后端返回的最近草稿');
expect(html).not.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: buildUpdatedAtDaysAgo(1),
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: buildUpdatedAtDaysAgo(0),
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('经典 RPG 体验');
expect(html).not.toContain('后端最近草稿');
expect(html).not.toContain('最近创作应该只读取后端摘要');
expect(html).not.toContain('本地生成中占位');
});
test('creation start card excludes works older than the recent window', () => {
const html = renderToStaticMarkup(
<CustomWorldCreationHub
items={[
{
workId: 'draft:old-session',
sourceType: 'agent_session',
status: 'draft',
title: '八天前的草稿',
subtitle: '旧草稿',
summary: '这条草稿已经超过最近创作期限。',
coverImageSrc: null,
updatedAt: buildUpdatedAtDaysAgo(8),
publishedAt: null,
stage: 'clarifying',
stageLabel: '待完善草稿',
playableNpcCount: 0,
landmarkCount: 0,
sessionId: 'old-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).not.toContain('最近创作');
expect(html).not.toContain('仅显示最近7天内使用过的模板');
expect(html).not.toContain('八天前的草稿');
expect(html).not.toContain('这条草稿已经超过最近创作期限');
});
test('creation start card maps backend jump-hop draft to template card', () => {
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: buildUpdatedAtDaysAgo(1),
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('creation-template-card');
expect(html).not.toContain('跳一跳生成草稿');
expect(html).not.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: buildUpdatedAtDaysAgo(1),
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-template-list__grid');
expect(html).toContain('文字冒险');
expect(html).toContain('经典 RPG 体验');
expect(html).not.toContain('creation-recent-work-grid');
expect(html).not.toContain('失败但仍可恢复的草稿');
expect(html).not.toContain('失败草稿也来自真实作品架摘要');
});
test('creation start card maps failed mini-game drafts into recent template cards', () => {
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: buildUpdatedAtDaysAgo(1),
publishedAt: null,
publishReady: false,
generationStatus: 'failed',
},
]}
mode="start-only"
/>,
);
expect(html).toContain('最近创作');
expect(html).toContain('抓大鹅');
expect(html).toContain('3D 消除关卡');
expect(html).toContain('creation-template-card');
expect(html).not.toContain('失败抓大鹅草稿');
expect(html).not.toContain('失败的小玩法草稿也应该进入最近创作。');
});
test('creation start card keeps typography in compact UI scale', () => {
const html = renderToStaticMarkup(
<CustomWorldCreationHub

View File

@@ -21,10 +21,14 @@ import type {
import { isPlatformCreationTypeVisible } from '../platform-entry/platformEntryCreationTypes';
import {
buildCreationWorkShelfItems,
getCreationWorkShelfItemTime,
type CreationWorkShelfItem,
type CreationWorkShelfMetricId,
type CreationWorkShelfRuntimeState,
} from './creationWorkShelf';
import { CustomWorldCreationStartCard } from './CustomWorldCreationStartCard';
import {
CustomWorldCreationStartCard,
} from './CustomWorldCreationStartCard';
import { CustomWorldWorkCard } from './CustomWorldWorkCard';
import {
type CustomWorldWorkFilter,
@@ -34,6 +38,9 @@ import {
const WORK_GRID_CLASS =
'creation-work-list grid min-w-0 gap-3 sm:gap-3.5 xl:gap-4';
const WORK_METRIC_CACHE_KEY = 'genarrative.creationHub.publishedMetrics.v1';
const RECENT_CREATION_WINDOW_DAYS = 7;
const RECENT_CREATION_WINDOW_MS =
RECENT_CREATION_WINDOW_DAYS * 24 * 60 * 60 * 1000;
type WorkMetricSnapshot = Record<
string,
@@ -67,7 +74,9 @@ type CustomWorldCreationHubProps = {
onOpenJumpHopDetail?: (item: JumpHopWorkSummaryResponse) => void;
onDeleteJumpHop?: ((item: JumpHopWorkSummaryResponse) => void) | null;
woodenFishItems?: WoodenFishWorkSummaryResponse[];
onOpenWoodenFishDetail?: ((item: WoodenFishWorkSummaryResponse) => void) | null;
onOpenWoodenFishDetail?:
| ((item: WoodenFishWorkSummaryResponse) => void)
| null;
onDeleteWoodenFish?: ((item: WoodenFishWorkSummaryResponse) => void) | null;
puzzleClearItems?: PuzzleClearWorkSummaryResponse[];
onOpenPuzzleClearDetail?: ((item: PuzzleClearWorkSummaryResponse) => void) | null;
@@ -88,8 +97,10 @@ type CustomWorldCreationHubProps = {
onDeleteVisualNovel?: ((item: VisualNovelWorkSummary) => void) | null;
getWorkState?: (
item: CreationWorkShelfItem,
) => { isGenerating?: boolean; hasUnreadUpdate?: boolean } | null;
) => CreationWorkShelfRuntimeState | null;
onOpenShelfItem?: (item: CreationWorkShelfItem) => void;
// 中文注释:底部加号入口可传入后端作品架摘要,用于推导最近使用过的模板。
recentWorkItems?: CreationWorkShelfItem[];
mode?: 'full' | 'start-only' | 'works-only';
};
@@ -156,6 +167,7 @@ function writeWorkMetricSnapshot(items: CreationWorkShelfItem[]) {
}
}
/** 渲染底部加号创作入口页与草稿作品架,最近创作复用最近使用过的模板入口。 */
export function CustomWorldCreationHub({
items,
loading,
@@ -204,6 +216,7 @@ export function CustomWorldCreationHub({
onDeleteVisualNovel = null,
getWorkState,
onOpenShelfItem,
recentWorkItems: recentWorkSourceItems,
mode = 'full',
}: CustomWorldCreationHubProps) {
const [activeFilter, setActiveFilter] =
@@ -299,7 +312,6 @@ export function CustomWorldCreationHub({
puzzleClearItems,
puzzleItems,
rpgLibraryEntries,
onOpenSquareHoleDetail,
onOpenJumpHopDetail,
jumpHopItems,
woodenFishItems,
@@ -325,6 +337,21 @@ export function CustomWorldCreationHub({
),
[activeFilter, shelfItems],
);
// 中文注释:最近创作只取 7 天内作品架摘要,再推导模板 ID 复用模板入口卡片。
const recentCreationCutoffMs = Date.now() - RECENT_CREATION_WINDOW_MS;
const recentWorkItems =
mode === 'start-only'
? (recentWorkSourceItems ?? shelfItems)
.filter(
(item) =>
getCreationWorkShelfItemTime(item.updatedAt) >=
recentCreationCutoffMs,
)
.slice(0, 4)
: [];
const recentCreationTypeIds = [
...new Set(recentWorkItems.map((item) => item.kind)),
];
function handleOpenShelfItem(item: CreationWorkShelfItem) {
onOpenShelfItem?.(item);
@@ -394,6 +421,8 @@ export function CustomWorldCreationHub({
busy={createBusy}
entryConfig={entryConfig}
creationTypes={creationTypes}
recentCreationTypeIds={recentCreationTypeIds}
recentWindowDays={RECENT_CREATION_WINDOW_DAYS}
onCreateType={onCreateType}
/>
) : null}

View File

@@ -1,64 +1,160 @@
import { Coins, Trophy } from 'lucide-react';
import { type UIEvent,useMemo, useState } from 'react';
import { Coins, LockKeyhole, Trophy } from 'lucide-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[];
recentCreationTypeIds?: readonly PlatformCreationTypeId[];
recentWindowDays?: number;
onCreateType: (type: PlatformCreationTypeId) => void;
};
type CreationEventBannerCard = CreationEntryConfig['eventBanner'];
/** 创作入口公告卡兼容结构化和 HTML 两种后台配置。 */
type CreationEventBannerCard = CreationEntryEventBannerConfig;
const CREATION_ENTRY_BANNER_AUTOPLAY_MS = 4200;
const CREATION_ENTRY_RECENT_TAB_ID = '__recent_creation__';
/** 判断模板 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,
recentCreationTypeIds = [],
recentWindowDays = 7,
onCreateType,
}: CustomWorldCreationStartCardProps) {
const creationTypeGroups = useMemo(
() => groupVisiblePlatformCreationTypes(creationTypes),
[creationTypes],
);
const recentCreationTypes = useMemo(() => {
const creationTypeById = new Map(
creationTypes
.filter((item) => !item.hidden)
.map((item) => [item.id, item] as const),
);
return [...new Set(recentCreationTypeIds)]
.map((id) => creationTypeById.get(id))
.filter((item): item is PlatformCreationTypeCard => Boolean(item));
}, [creationTypes, recentCreationTypeIds]);
const [activeCategoryId, setActiveCategoryId] = useState<string | null>(null);
const activeGroup =
creationTypeGroups.find((group) => group.id === activeCategoryId) ??
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 hasRecentCreationTypes = recentCreationTypes.length > 0;
const activeTabId =
activeCategoryId ??
(hasRecentCreationTypes
? CREATION_ENTRY_RECENT_TAB_ID
: (creationTypeGroups[0]?.id ?? null));
const isRecentTabActive =
hasRecentCreationTypes && activeTabId === CREATION_ENTRY_RECENT_TAB_ID;
const activeGroup = isRecentTabActive
? null
: (creationTypeGroups.find((group) => group.id === activeTabId) ??
creationTypeGroups[0] ??
null);
const visibleCreationTypes = isRecentTabActive
? recentCreationTypes
: (activeGroup?.items ?? []);
const eventBanners = useMemo(
() => resolveCreationEntryEventBanners(entryConfig),
[entryConfig],
);
const [activeBannerIndex, setActiveBannerIndex] = useState(0);
const bannerTrackRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
setActiveBannerIndex(0);
}, [eventBanners.length]);
useEffect(() => {
if (hasRecentCreationTypes) {
return;
}
setActiveCategoryId((currentId) =>
currentId === CREATION_ENTRY_RECENT_TAB_ID ? null : currentId,
);
}, [hasRecentCreationTypes]);
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 +174,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 +265,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="创作入口页签"
>
{hasRecentCreationTypes ? (
<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,21 +309,29 @@ export function CustomWorldCreationStartCard({
})}
</div>
{isRecentTabActive ? (
<div className="creation-template-list__recent-window mt-2 text-[11px] font-bold leading-4 text-[#8b6654] sm:text-xs">
{recentWindowDays}使
</div>
) : null}
<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;
const lockedBadge = item.badge.trim() || '暂未开放';
return (
<button
key={item.id}
type="button"
disabled={disabled}
data-locked={item.locked ? 'true' : undefined}
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'
? 'cursor-not-allowed border-[#d9ccc2] text-[#725b4d] shadow-[inset_0_0_0_1px_rgba(111,78,61,0.08)]'
: 'border-[#eadbd3] text-[#2f211b] hover:border-[#dc9a72] hover:shadow-[0_16px_34px_rgba(174,111,73,0.14)]'
} ${busy && !item.locked ? 'opacity-70' : ''}`}
>
@@ -204,25 +339,70 @@ export function CustomWorldCreationStartCard({
<img
src={item.imageSrc}
alt=""
className="h-full w-full object-cover"
className={`h-full w-full object-cover ${
item.locked
? 'scale-[1.01] grayscale-[0.62] saturate-[0.55] brightness-[0.82]'
: ''
}`}
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}
{item.locked ? (
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(52,36,27,0.22)_0%,rgba(52,36,27,0.52)_100%)]" />
) : null}
{item.locked || shouldShowCreationBadge(item.badge) ? (
<span
className={`absolute left-2 top-2 inline-flex max-w-[calc(100%-1rem)] items-center gap-1 rounded-full px-2 py-0.5 text-xs font-black shadow-sm sm:left-3 sm:top-3 sm:px-2.5 sm:py-1 ${
item.locked
? 'bg-[#3f3129]/90 text-white'
: 'bg-[#b66a3e] text-white'
}`}
>
{item.locked ? <LockKeyhole className="h-3 w-3" /> : null}
{item.locked ? lockedBadge : 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>
{item.locked ? (
<span className="absolute left-1/2 top-1/2 inline-flex h-11 w-11 -translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-full bg-white/88 text-[#5d4639] shadow-[0_12px_24px_rgba(46,31,23,0.22)]">
<LockKeyhole className="h-5 w-5" />
</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 px-2 py-1 text-[11px] font-black leading-4 shadow-[0_8px_18px_rgba(119,72,44,0.16)] ${
item.locked
? 'bg-[#3f3129]/88 text-white'
: 'bg-[#fff7ec]/92 text-[#b65f2c]'
}`}
>
{item.locked ? (
<LockKeyhole className="h-3 w-3 shrink-0" />
) : (
<Coins className="h-3 w-3 shrink-0" />
)}
<span className="truncate">
{item.locked ? '暂未开放' : '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]">
<div
className={`creation-template-card__body flex min-h-[4.6rem] flex-1 flex-col px-2.5 pb-2.5 pt-2.5 sm:min-h-[5.4rem] sm:px-3.5 sm:pb-3.5 ${
item.locked
? 'bg-[#f3ece6] text-[#725b4d]'
: 'bg-white text-[#2f211b]'
}`}
>
<div
className={`creation-template-card__title line-clamp-1 text-sm font-black leading-5 ${
item.locked ? 'text-[#5d4639]' : '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">
<div
className={`creation-template-card__subtitle mt-1 line-clamp-2 text-xs font-semibold leading-4 sm:leading-5 ${
item.locked ? 'text-[#8a766a]' : 'text-[#6f5a4c]'
}`}
>
{item.subtitle}
</div>
</div>
@@ -230,7 +410,6 @@ export function CustomWorldCreationStartCard({
);
})}
</div>
</section>
</div>
);

View File

@@ -1,5 +1,6 @@
import {
BadgeCheck,
CircleAlert,
Clock3,
Loader2,
Share2,
@@ -441,11 +442,8 @@ export function CustomWorldWorkCard({
return;
}
updateSwipeOffset(
gesture,
event.clientX,
event.clientY,
() => event.preventDefault(),
updateSwipeOffset(gesture, event.clientX, event.clientY, () =>
event.preventDefault(),
);
};
@@ -475,9 +473,7 @@ export function CustomWorldWorkCard({
}
};
const beginTouchSwipeGesture = (
event: ReactTouchEvent<HTMLDivElement>,
) => {
const beginTouchSwipeGesture = (event: ReactTouchEvent<HTMLDivElement>) => {
if (swipeRevealWidth <= 0) {
return;
}
@@ -496,20 +492,15 @@ export function CustomWorldWorkCard({
};
};
const updateTouchSwipeGesture = (
event: ReactTouchEvent<HTMLDivElement>,
) => {
const updateTouchSwipeGesture = (event: ReactTouchEvent<HTMLDivElement>) => {
const gesture = swipeGestureRef.current;
const touch = event.touches[0];
if (!gesture || gesture.pointerId !== -1 || !touch) {
return;
}
updateSwipeOffset(
gesture,
touch.clientX,
touch.clientY,
() => event.preventDefault(),
updateSwipeOffset(gesture, touch.clientX, touch.clientY, () =>
event.preventDefault(),
);
};
@@ -678,8 +669,8 @@ export function CustomWorldWorkCard({
{displayTitle}
</span>
</div>
<div className="creation-work-card__quick-actions">
{canUseShareAction ? (
{canUseShareAction ? (
<div className="creation-work-card__quick-actions">
<button
type="button"
onClick={(event) => {
@@ -715,38 +706,8 @@ export function CustomWorldWorkCard({
>
<Share2 aria-hidden="true" className="h-4 w-4" />
</button>
) : null}
{onDelete ? (
<button
type="button"
onClick={(event) => {
event.stopPropagation();
suppressOpenRef.current = false;
closeSwipeActions();
onDelete();
}}
onKeyDown={(event) => {
event.stopPropagation();
}}
onPointerDown={(event) => {
event.stopPropagation();
}}
onTouchStart={(event) => {
event.stopPropagation();
}}
disabled={deleteBusy}
title={deleteBusy ? '删除中' : '删除作品'}
aria-label={deleteBusy ? '删除中' : '删除'}
className="creation-work-card__quick-action-button creation-work-card__quick-action-button--danger"
>
{deleteBusy ? (
<span className="text-xs leading-none">...</span>
) : (
<Trash2 aria-hidden="true" className="h-4 w-4" />
)}
</button>
) : null}
</div>
</div>
) : null}
</div>
<div className="creation-work-card__meta platform-category-game-item__meta">
@@ -764,6 +725,16 @@ export function CustomWorldWorkCard({
{item.summary}
</div>
{item.hasGenerationFailure ? (
<div
aria-label={item.generationFailureSummary ?? '生成失败'}
className="creation-work-card__failure-status"
>
<CircleAlert aria-hidden="true" className="h-3.5 w-3.5" />
<span>{item.generationFailureSummary ?? '生成失败'}</span>
</div>
) : null}
{isPublished ? (
<div className="creation-work-card__published-info">
{item.pointIncentive ? (

View File

@@ -93,7 +93,9 @@ test('buildCreationWorkShelfItems maps wooden fish items with WF public code', (
expect(items[0]?.sharePath).toContain('/works/detail?work=WF-12345678');
expect(items[0]?.openActionLabel).toBe('查看详情');
expect(items[0]?.badges.some((badge) => badge.label === '敲木鱼')).toBe(true);
expect(items[0]?.metrics.find((metric) => metric.id === 'play-count')?.value).toBe(9);
expect(
items[0]?.metrics.find((metric) => metric.id === 'play-count')?.value,
).toBe(9);
expect(onOpenWoodenFishDetail).toHaveBeenCalledWith(woodenFishWork);
});
@@ -252,9 +254,9 @@ test('buildCreationWorkShelfItems keeps separate bark battle draft and published
expect(items.find((item) => item.status === 'published')?.id).toBe(
'BB-PUB00001',
);
expect(items.find((item) => item.status === 'published')?.publicWorkCode).toBe(
'BB-PUB00001',
);
expect(
items.find((item) => item.status === 'published')?.publicWorkCode,
).toBe('BB-PUB00001');
});
test('buildCreationWorkShelfItems falls back to deterministic RPG public work code when library entry is missing', () => {
@@ -344,10 +346,9 @@ test('buildCreationWorkShelfItems gives bark battle draft cover from character o
expect(items.find((item) => item.id === 'BB-COVER001')?.coverImageSrc).toBe(
'/draft-player-cover.png',
);
expect(items.find((item) => item.id === 'BB-COVER001')?.coverCharacterImageSrcs).toEqual([
'/draft-player-cover.png',
'/draft-opponent-cover.png',
]);
expect(
items.find((item) => item.id === 'BB-COVER001')?.coverCharacterImageSrcs,
).toEqual(['/draft-player-cover.png', '/draft-opponent-cover.png']);
expect(items.find((item) => item.id === 'BB-COVER002')?.coverImageSrc).toBe(
'/creation-type-references/bark-battle.webp',
);
@@ -498,14 +499,76 @@ test('buildCreationWorkShelfItems restores persisted generation state for puzzle
],
});
expect(items.find((item) => item.kind === 'puzzle')?.isGenerating).toBe(
true,
);
expect(items.find((item) => item.kind === 'puzzle')?.isGenerating).toBe(true);
expect(items.find((item) => item.kind === 'match3d')?.isGenerating).toBe(
true,
);
});
test('buildCreationWorkShelfItems lets failure notice override persisted generating copy', () => {
const items = buildCreationWorkShelfItems({
rpgItems: [],
bigFishItems: [],
puzzleItems: [
{
workId: 'puzzle:failed-generating',
profileId: 'puzzle-profile-failed-generating',
ownerUserId: 'user-1',
sourceSessionId: 'puzzle-session-failed-generating',
authorDisplayName: '测试作者',
levelName: '失败拼图',
summary: '正在生成拼图草稿。',
themeTags: [],
coverImageSrc: null,
publicationStatus: 'draft',
updatedAt: '2026-05-08T00:00:00.000Z',
publishedAt: null,
publishReady: false,
generationStatus: 'generating',
},
],
getItemState: (item) =>
item.kind === 'puzzle'
? {
isGenerating: false,
suppressPersistedGenerating: true,
summaryOverride: '拼图草稿生成失败,可重新打开处理。',
}
: null,
});
expect(items[0]?.isGenerating).toBe(false);
expect(items[0]?.summary).toBe('拼图草稿生成失败,可重新打开处理。');
});
test('persisted failed puzzle draft is not treated as generating', () => {
const items = buildCreationWorkShelfItems({
rpgItems: [],
bigFishItems: [],
puzzleItems: [
{
workId: 'puzzle:failed',
profileId: 'puzzle-profile-failed',
ownerUserId: 'user-1',
sourceSessionId: 'puzzle-session-failed',
authorDisplayName: '测试作者',
levelName: '失败拼图',
summary: '服务端已回写失败。',
themeTags: [],
coverImageSrc: null,
publicationStatus: 'draft',
updatedAt: '2026-05-08T00:00:00.000Z',
publishedAt: null,
publishReady: false,
generationStatus: 'failed',
},
],
});
expect(items[0]?.isGenerating).toBeFalsy();
expect(items[0]?.summary).toBe('服务端已回写失败。');
});
test('buildCreationWorkShelfItems maps baby object match local drafts', () => {
const onOpenBabyObjectMatchDetail = vi.fn();
const onDeleteBabyObjectMatch = vi.fn();
@@ -1129,7 +1192,6 @@ test('bark battle draft generating state only follows pending assets', () => {
).toBe(false);
});
test('CustomWorldWorkCard hides author on shelf draft and published cards', () => {
const buildItem = (
status: CreationWorkShelfItem['status'],
@@ -1151,7 +1213,11 @@ test('CustomWorldWorkCard hides author on shelf draft and published cards', () =
canDelete: false,
canShare: false,
badges: [
{ id: 'status', label: status === 'draft' ? '草稿' : '已发布', tone: 'neutral' },
{
id: 'status',
label: status === 'draft' ? '草稿' : '已发布',
tone: 'neutral',
},
{ id: 'type', label: '汪汪', tone: 'neutral' },
],
metrics: [],

View File

@@ -132,6 +132,8 @@ export type CreationWorkShelfItem = {
kind: CreationWorkShelfKind;
status: CreationWorkShelfStatus;
isGenerating?: boolean;
hasGenerationFailure?: boolean;
generationFailureSummary?: string;
hasUnreadUpdate?: boolean;
title: string;
summary: string;
@@ -152,6 +154,16 @@ export type CreationWorkShelfItem = {
source: CreationWorkShelfSource;
};
export type CreationWorkShelfRuntimeState = {
isGenerating?: boolean;
hasGenerationFailure?: boolean;
generationFailureSummary?: string;
hasUnreadUpdate?: boolean;
suppressPersistedGenerating?: boolean;
titleOverride?: string;
summaryOverride?: string;
};
export function buildCreationWorkShelfItems(params: {
rpgItems: CustomWorldWorkSummary[];
rpgLibraryEntries?: CustomWorldLibraryEntry<CustomWorldProfile>[];
@@ -202,7 +214,7 @@ export function buildCreationWorkShelfItems(params: {
onDeleteVisualNovel?: (item: VisualNovelWorkSummary) => void;
getItemState?: (
item: CreationWorkShelfItem,
) => { isGenerating?: boolean; hasUnreadUpdate?: boolean } | null;
) => CreationWorkShelfRuntimeState | null;
}) {
const {
rpgItems,
@@ -328,18 +340,24 @@ export function buildCreationWorkShelfItems(params: {
.map((item) => {
const state = getItemState?.(item);
const persistedIsGenerating = isPersistedCreationWorkGenerating(item);
return state
const isGenerating = Boolean(
state?.isGenerating ||
(!state?.suppressPersistedGenerating && persistedIsGenerating),
);
return state || isGenerating
? {
...item,
isGenerating: Boolean(state.isGenerating || persistedIsGenerating),
hasUnreadUpdate: state.hasUnreadUpdate,
title: state?.titleOverride ?? item.title,
summary: state?.summaryOverride ?? item.summary,
isGenerating,
hasGenerationFailure:
state?.hasGenerationFailure ?? item.hasGenerationFailure,
generationFailureSummary:
state?.generationFailureSummary ??
item.generationFailureSummary,
hasUnreadUpdate: state?.hasUnreadUpdate,
}
: persistedIsGenerating
? {
...item,
isGenerating: true,
}
: item;
: item;
})
.sort(
(left, right) =>
@@ -348,7 +366,6 @@ export function buildCreationWorkShelfItems(params: {
);
}
function mergeBarkBattleShelfSourceItems(
items: readonly BarkBattleWorkSummary[],
): BarkBattleWorkSummary[] {
@@ -397,8 +414,8 @@ function mapRpgWorkToShelfItem(
: null;
const publicWorkCode =
item.status === 'published'
? (libraryEntry?.publicWorkCode?.trim() ||
(item.profileId ? buildCustomWorldPublicWorkCode(item.profileId) : null))
? libraryEntry?.publicWorkCode?.trim() ||
(item.profileId ? buildCustomWorldPublicWorkCode(item.profileId) : null)
: null;
const badges: CreationWorkShelfBadge[] = [
buildStatusBadge(item.status),
@@ -864,7 +881,9 @@ function mapWoodenFishWorkToShelfItem(
): CreationWorkShelfItem {
const status = item.publicationStatus === 'published' ? 'published' : 'draft';
const publicWorkCode =
status === 'published' ? buildWoodenFishPublicWorkCode(item.profileId) : null;
status === 'published'
? buildWoodenFishPublicWorkCode(item.profileId)
: null;
const title = item.workTitle.trim() || '敲木鱼';
const summary =
item.workDescription.trim() || (status === 'draft' ? '未填写作品描述' : '');
@@ -955,10 +974,7 @@ function mapPuzzleClearWorkToShelfItem(
};
}
function resolveAuthorDisplayName(
...sources: Array<unknown>
) {
function resolveAuthorDisplayName(...sources: Array<unknown>) {
for (const source of sources) {
const authorDisplayName =
source &&
@@ -1032,7 +1048,8 @@ export function resolvePuzzleLevelCoverImageSrc(
const fallbackCandidateImageSrc = normalizeCoverImageSrc(
level.candidates[level.candidates.length - 1]?.imageSrc,
);
const candidateImageSrc = selectedCandidateImageSrc || fallbackCandidateImageSrc;
const candidateImageSrc =
selectedCandidateImageSrc || fallbackCandidateImageSrc;
if (
candidateImageSrc &&
@@ -1055,7 +1072,9 @@ function resolveMatch3DWorkCoverImageSrc(item: Match3DWorkSummary) {
const topLevelContainerImageSrc =
normalizeCoverImageSrc(item.generatedBackgroundAsset?.containerImageSrc) ||
normalizeCoverImageSrc(item.generatedBackgroundAsset?.containerImageObjectKey);
normalizeCoverImageSrc(
item.generatedBackgroundAsset?.containerImageObjectKey,
);
if (topLevelContainerImageSrc) {
return topLevelContainerImageSrc;
}
@@ -1164,6 +1183,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':