Merge branch 'codex/feature-1'
# Conflicts: # docs/【玩法创作】平台入口与玩法链路-2026-05-15.md # src/components/platform-entry/PlatformEntryFlowShellImpl.tsx # src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx # src/services/miniGameDraftGenerationProgress.ts
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { afterEach, expect, test, vi } from 'vitest';
|
||||
|
||||
@@ -43,9 +43,9 @@ const testEntryConfig = {
|
||||
visible: true,
|
||||
open: true,
|
||||
sortOrder: 10,
|
||||
categoryId: 'recent',
|
||||
categoryLabel: '最近创作',
|
||||
categorySortOrder: 10,
|
||||
categoryId: 'recommended',
|
||||
categoryLabel: '热门推荐',
|
||||
categorySortOrder: 20,
|
||||
updatedAtMicros: 1,
|
||||
},
|
||||
{
|
||||
@@ -57,9 +57,9 @@ const testEntryConfig = {
|
||||
visible: true,
|
||||
open: true,
|
||||
sortOrder: 30,
|
||||
categoryId: 'recent',
|
||||
categoryLabel: '最近创作',
|
||||
categorySortOrder: 10,
|
||||
categoryId: 'recommended',
|
||||
categoryLabel: '热门推荐',
|
||||
categorySortOrder: 20,
|
||||
updatedAtMicros: 1,
|
||||
},
|
||||
{
|
||||
@@ -71,9 +71,9 @@ const testEntryConfig = {
|
||||
visible: true,
|
||||
open: true,
|
||||
sortOrder: 40,
|
||||
categoryId: 'recent',
|
||||
categoryLabel: '最近创作',
|
||||
categorySortOrder: 10,
|
||||
categoryId: 'recommended',
|
||||
categoryLabel: '热门推荐',
|
||||
categorySortOrder: 20,
|
||||
updatedAtMicros: 1,
|
||||
},
|
||||
{
|
||||
@@ -85,9 +85,9 @@ const testEntryConfig = {
|
||||
visible: false,
|
||||
open: true,
|
||||
sortOrder: 50,
|
||||
categoryId: 'recent',
|
||||
categoryLabel: '最近创作',
|
||||
categorySortOrder: 10,
|
||||
categoryId: 'recommended',
|
||||
categoryLabel: '热门推荐',
|
||||
categorySortOrder: 20,
|
||||
updatedAtMicros: 1,
|
||||
},
|
||||
{
|
||||
@@ -99,9 +99,9 @@ const testEntryConfig = {
|
||||
visible: false,
|
||||
open: false,
|
||||
sortOrder: 60,
|
||||
categoryId: 'recent',
|
||||
categoryLabel: '最近创作',
|
||||
categorySortOrder: 10,
|
||||
categoryId: 'recommended',
|
||||
categoryLabel: '热门推荐',
|
||||
categorySortOrder: 20,
|
||||
updatedAtMicros: 1,
|
||||
},
|
||||
{
|
||||
@@ -113,9 +113,9 @@ const testEntryConfig = {
|
||||
visible: true,
|
||||
open: false,
|
||||
sortOrder: 70,
|
||||
categoryId: 'recent',
|
||||
categoryLabel: '最近创作',
|
||||
categorySortOrder: 10,
|
||||
categoryId: 'recommended',
|
||||
categoryLabel: '热门推荐',
|
||||
categorySortOrder: 20,
|
||||
updatedAtMicros: 1,
|
||||
},
|
||||
],
|
||||
@@ -128,6 +128,7 @@ const testCreationTypes = derivePlatformCreationTypes(
|
||||
const originalClipboard = navigator.clipboard;
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
window.sessionStorage.clear();
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
@@ -135,6 +136,57 @@ afterEach(() => {
|
||||
});
|
||||
});
|
||||
|
||||
test('creation entry banner automatically rotates through backend configured banners', () => {
|
||||
vi.useFakeTimers();
|
||||
render(
|
||||
<CustomWorldCreationHub
|
||||
items={[]}
|
||||
loading={false}
|
||||
error={null}
|
||||
onRetry={() => {}}
|
||||
onCreateType={noopCreateType}
|
||||
onOpenDraft={() => {}}
|
||||
onEnterPublished={() => {}}
|
||||
entryConfig={{
|
||||
...testEntryConfig,
|
||||
eventBanners: [
|
||||
{
|
||||
...testEntryConfig.eventBanner,
|
||||
title: '第一张后台 Banner',
|
||||
},
|
||||
{
|
||||
...testEntryConfig.eventBanner,
|
||||
title: '第二张后台 Banner',
|
||||
coverImageSrc: '/creation-type-references/match3d.webp',
|
||||
},
|
||||
],
|
||||
}}
|
||||
creationTypes={testCreationTypes}
|
||||
mode="start-only"
|
||||
/>,
|
||||
);
|
||||
|
||||
const bannerTrack = screen.getByLabelText('创作公告横幅');
|
||||
Object.defineProperty(bannerTrack, 'clientWidth', {
|
||||
configurable: true,
|
||||
value: 360,
|
||||
});
|
||||
const scrollTo = vi.fn();
|
||||
Object.defineProperty(bannerTrack, 'scrollTo', {
|
||||
configurable: true,
|
||||
value: scrollTo,
|
||||
});
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(4200);
|
||||
});
|
||||
|
||||
expect(scrollTo).toHaveBeenCalledWith({
|
||||
left: 360,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
});
|
||||
|
||||
test('creation hub shows published metric growth from cached page snapshot', async () => {
|
||||
window.sessionStorage.setItem(
|
||||
'genarrative.creationHub.publishedMetrics.v1',
|
||||
|
||||
@@ -3,6 +3,7 @@ import { expect, test } from 'vitest';
|
||||
|
||||
import type { CreationEntryConfig } from '../../services/creationEntryConfigService';
|
||||
import { derivePlatformCreationTypes } from '../platform-entry/platformEntryCreationTypes';
|
||||
import { buildCreationWorkShelfItems } from './creationWorkShelf';
|
||||
import { CustomWorldCreationHub } from './CustomWorldCreationHub';
|
||||
|
||||
const noopCreateType = () => {};
|
||||
@@ -26,6 +27,26 @@ const testEntryConfig = {
|
||||
startsAtText: '2026-05-01',
|
||||
endsAtText: '2026-05-31',
|
||||
},
|
||||
eventBanners: [
|
||||
{
|
||||
title: '后台拼图赛',
|
||||
description: '后台配置的拼图横幅。',
|
||||
coverImageSrc: '/creation-type-references/puzzle.webp',
|
||||
prizePoolMudPoints: 1000,
|
||||
startsAtText: '2026-05-01',
|
||||
endsAtText: '2026-05-31',
|
||||
renderMode: 'structured',
|
||||
},
|
||||
{
|
||||
title: '后台抓大鹅赛',
|
||||
description: '后台配置的抓大鹅横幅。',
|
||||
coverImageSrc: '/creation-type-references/match3d.webp',
|
||||
prizePoolMudPoints: 1200,
|
||||
startsAtText: '2026-06-01',
|
||||
endsAtText: '2026-06-30',
|
||||
renderMode: 'structured',
|
||||
},
|
||||
],
|
||||
creationTypes: [
|
||||
{
|
||||
id: 'rpg',
|
||||
@@ -36,9 +57,9 @@ const testEntryConfig = {
|
||||
visible: true,
|
||||
open: true,
|
||||
sortOrder: 10,
|
||||
categoryId: 'recent',
|
||||
categoryLabel: '最近创作',
|
||||
categorySortOrder: 10,
|
||||
categoryId: 'recommended',
|
||||
categoryLabel: '热门推荐',
|
||||
categorySortOrder: 20,
|
||||
updatedAtMicros: 1,
|
||||
},
|
||||
{
|
||||
@@ -50,9 +71,9 @@ const testEntryConfig = {
|
||||
visible: true,
|
||||
open: true,
|
||||
sortOrder: 30,
|
||||
categoryId: 'recent',
|
||||
categoryLabel: '最近创作',
|
||||
categorySortOrder: 10,
|
||||
categoryId: 'recommended',
|
||||
categoryLabel: '热门推荐',
|
||||
categorySortOrder: 20,
|
||||
updatedAtMicros: 1,
|
||||
},
|
||||
{
|
||||
@@ -64,9 +85,9 @@ const testEntryConfig = {
|
||||
visible: true,
|
||||
open: true,
|
||||
sortOrder: 40,
|
||||
categoryId: 'recent',
|
||||
categoryLabel: '最近创作',
|
||||
categorySortOrder: 10,
|
||||
categoryId: 'recommended',
|
||||
categoryLabel: '热门推荐',
|
||||
categorySortOrder: 20,
|
||||
updatedAtMicros: 1,
|
||||
},
|
||||
{
|
||||
@@ -78,9 +99,9 @@ const testEntryConfig = {
|
||||
visible: false,
|
||||
open: true,
|
||||
sortOrder: 50,
|
||||
categoryId: 'recent',
|
||||
categoryLabel: '最近创作',
|
||||
categorySortOrder: 10,
|
||||
categoryId: 'recommended',
|
||||
categoryLabel: '热门推荐',
|
||||
categorySortOrder: 20,
|
||||
updatedAtMicros: 1,
|
||||
},
|
||||
{
|
||||
@@ -92,9 +113,9 @@ const testEntryConfig = {
|
||||
visible: false,
|
||||
open: false,
|
||||
sortOrder: 60,
|
||||
categoryId: 'recent',
|
||||
categoryLabel: '最近创作',
|
||||
categorySortOrder: 10,
|
||||
categoryId: 'recommended',
|
||||
categoryLabel: '热门推荐',
|
||||
categorySortOrder: 20,
|
||||
updatedAtMicros: 1,
|
||||
},
|
||||
{
|
||||
@@ -106,9 +127,9 @@ const testEntryConfig = {
|
||||
visible: true,
|
||||
open: false,
|
||||
sortOrder: 70,
|
||||
categoryId: 'recent',
|
||||
categoryLabel: '最近创作',
|
||||
categorySortOrder: 10,
|
||||
categoryId: 'recommended',
|
||||
categoryLabel: '热门推荐',
|
||||
categorySortOrder: 20,
|
||||
updatedAtMicros: 1,
|
||||
},
|
||||
],
|
||||
@@ -186,11 +207,15 @@ test('creation start card renders reference-aligned banner and template metadata
|
||||
expect(html).toContain('creation-event-banner__track');
|
||||
expect(html).toContain('creation-event-banner__slide');
|
||||
expect(html).toContain('creation-event-banner__timebar');
|
||||
expect(html).toContain('拼图主题创作赛');
|
||||
expect(html).toContain('抓大鹅主题创作赛');
|
||||
expect(html).toContain('后台拼图赛');
|
||||
expect(html).toContain('后台抓大鹅赛');
|
||||
expect(html).toContain('1,000');
|
||||
expect(html).toContain('1,200');
|
||||
expect(html).toContain('泥点数');
|
||||
expect(html).not.toContain('泥点挑战');
|
||||
expect(html).not.toContain('拼图主题创作赛');
|
||||
expect(html).not.toContain('抓大鹅主题创作赛');
|
||||
expect(html).not.toContain('最近创作');
|
||||
expect(html).toMatch(
|
||||
/creation-event-banner__timebar[\s\S]*creation-event-banner__pager[\s\S]*creation-template-card/u,
|
||||
);
|
||||
@@ -206,6 +231,305 @@ test('creation start card renders reference-aligned banner and template metadata
|
||||
expect(html).not.toContain('platform-creation-reference-card');
|
||||
});
|
||||
|
||||
test('creation start card falls back to legacy single banner when eventBanners is empty', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<CustomWorldCreationHub
|
||||
items={[]}
|
||||
loading={false}
|
||||
error={null}
|
||||
onRetry={() => {}}
|
||||
onCreateType={noopCreateType}
|
||||
onOpenDraft={() => {}}
|
||||
onEnterPublished={() => {}}
|
||||
entryConfig={{
|
||||
...testEntryConfig,
|
||||
eventBanners: [],
|
||||
}}
|
||||
creationTypes={testCreationTypes}
|
||||
mode="start-only"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain('泥点挑战');
|
||||
expect(html).not.toContain('后台拼图赛');
|
||||
});
|
||||
|
||||
test('creation start card renders html banner in an empty-permission sandbox', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<CustomWorldCreationHub
|
||||
items={[]}
|
||||
loading={false}
|
||||
error={null}
|
||||
onRetry={() => {}}
|
||||
onCreateType={noopCreateType}
|
||||
onOpenDraft={() => {}}
|
||||
onEnterPublished={() => {}}
|
||||
entryConfig={{
|
||||
...testEntryConfig,
|
||||
eventBanners: [
|
||||
{
|
||||
...testEntryConfig.eventBanner,
|
||||
title: 'HTML 后台横幅',
|
||||
renderMode: 'html',
|
||||
htmlCode: '<section><h1>自定义横幅</h1></section>',
|
||||
},
|
||||
],
|
||||
}}
|
||||
creationTypes={testCreationTypes}
|
||||
mode="start-only"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain('title="HTML 后台横幅"');
|
||||
expect(html).toContain('sandbox=""');
|
||||
expect(html).toContain('<section><h1>自定义横幅</h1></section>');
|
||||
});
|
||||
|
||||
test('creation start card renders recent tab from real shelf summaries', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<CustomWorldCreationHub
|
||||
items={[
|
||||
{
|
||||
workId: 'draft:recent-session',
|
||||
sourceType: 'agent_session',
|
||||
status: 'draft',
|
||||
title: '后端返回的最近草稿',
|
||||
subtitle: '待完善草稿',
|
||||
summary: '这条内容来自作品架摘要。',
|
||||
coverImageSrc: null,
|
||||
updatedAt: new Date('2026-06-01T12:00:00.000Z').toISOString(),
|
||||
publishedAt: null,
|
||||
stage: 'clarifying',
|
||||
stageLabel: '待完善草稿',
|
||||
playableNpcCount: 0,
|
||||
landmarkCount: 0,
|
||||
sessionId: 'recent-session',
|
||||
profileId: null,
|
||||
canResume: true,
|
||||
canEnterWorld: false,
|
||||
},
|
||||
]}
|
||||
loading={false}
|
||||
error={null}
|
||||
onRetry={() => {}}
|
||||
onCreateType={noopCreateType}
|
||||
onOpenDraft={() => {}}
|
||||
onEnterPublished={() => {}}
|
||||
entryConfig={testEntryConfig}
|
||||
creationTypes={testCreationTypes}
|
||||
getWorkState={() => ({ isGenerating: true })}
|
||||
mode="start-only"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain('aria-label="创作入口页签"');
|
||||
expect(html).toContain('role="tab"');
|
||||
expect(html).toContain('aria-selected="true"');
|
||||
expect(html).toContain('creation-recent-work-grid');
|
||||
expect(html).toContain('aria-label="打开最近创作 1"');
|
||||
expect(html).toContain('最近创作');
|
||||
expect(html).toContain('后端返回的最近草稿');
|
||||
expect(html).toContain('这条内容来自作品架摘要');
|
||||
expect(html).toContain('生成中');
|
||||
});
|
||||
|
||||
test('creation start card prefers backend recent summaries over local pending placeholders', () => {
|
||||
const recentWorkItems = buildCreationWorkShelfItems({
|
||||
rpgItems: [
|
||||
{
|
||||
workId: 'draft:backend-session',
|
||||
sourceType: 'agent_session',
|
||||
status: 'draft',
|
||||
title: '后端最近草稿',
|
||||
subtitle: '真实作品架摘要',
|
||||
summary: '最近创作应该只读取后端摘要。',
|
||||
coverImageSrc: null,
|
||||
updatedAt: new Date('2026-06-03T12:00:00.000Z').toISOString(),
|
||||
publishedAt: null,
|
||||
stage: 'failed',
|
||||
stageLabel: '生成失败',
|
||||
playableNpcCount: 0,
|
||||
landmarkCount: 0,
|
||||
sessionId: 'backend-session',
|
||||
profileId: null,
|
||||
canResume: true,
|
||||
canEnterWorld: false,
|
||||
},
|
||||
],
|
||||
bigFishItems: [],
|
||||
puzzleItems: [],
|
||||
});
|
||||
const html = renderToStaticMarkup(
|
||||
<CustomWorldCreationHub
|
||||
items={[
|
||||
{
|
||||
workId: 'draft:local-pending-session',
|
||||
sourceType: 'agent_session',
|
||||
status: 'draft',
|
||||
title: '本地生成中占位',
|
||||
subtitle: '本地占位',
|
||||
summary: '这条占位不应该进入最近创作。',
|
||||
coverImageSrc: null,
|
||||
updatedAt: new Date('2026-06-04T12:00:00.000Z').toISOString(),
|
||||
publishedAt: null,
|
||||
stage: 'generating',
|
||||
stageLabel: '生成中',
|
||||
playableNpcCount: 0,
|
||||
landmarkCount: 0,
|
||||
sessionId: 'local-pending-session',
|
||||
profileId: null,
|
||||
canResume: true,
|
||||
canEnterWorld: false,
|
||||
},
|
||||
]}
|
||||
recentWorkItems={recentWorkItems}
|
||||
loading={false}
|
||||
error={null}
|
||||
onRetry={() => {}}
|
||||
onCreateType={noopCreateType}
|
||||
onOpenDraft={() => {}}
|
||||
onEnterPublished={() => {}}
|
||||
entryConfig={testEntryConfig}
|
||||
creationTypes={testCreationTypes}
|
||||
mode="start-only"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain('最近创作');
|
||||
expect(html).toContain('后端最近草稿');
|
||||
expect(html).toContain('最近创作应该只读取后端摘要');
|
||||
expect(html).not.toContain('本地生成中占位');
|
||||
});
|
||||
|
||||
test('creation start card marks backend jump-hop generating draft in recent tab', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<CustomWorldCreationHub
|
||||
items={[]}
|
||||
jumpHopItems={[
|
||||
{
|
||||
runtimeKind: 'jump-hop',
|
||||
workId: 'jump-hop-work-1',
|
||||
profileId: 'jump-hop-profile-1',
|
||||
ownerUserId: 'user-1',
|
||||
sourceSessionId: 'jump-hop-session-1',
|
||||
workTitle: '跳一跳生成草稿',
|
||||
workDescription: '后端仍在生成跳一跳玩法。',
|
||||
themeTags: ['跳一跳'],
|
||||
difficulty: 'standard',
|
||||
stylePreset: 'minimal-blocks',
|
||||
coverImageSrc: null,
|
||||
publicationStatus: 'draft',
|
||||
playCount: 0,
|
||||
updatedAt: new Date('2026-06-03T13:00:00.000Z').toISOString(),
|
||||
publishedAt: null,
|
||||
publishReady: false,
|
||||
generationStatus: 'generating',
|
||||
},
|
||||
]}
|
||||
loading={false}
|
||||
error={null}
|
||||
onRetry={() => {}}
|
||||
onCreateType={noopCreateType}
|
||||
onOpenDraft={() => {}}
|
||||
onEnterPublished={() => {}}
|
||||
entryConfig={testEntryConfig}
|
||||
creationTypes={testCreationTypes}
|
||||
onOpenJumpHopDetail={() => {}}
|
||||
mode="start-only"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain('最近创作');
|
||||
expect(html).toContain('跳一跳生成草稿');
|
||||
expect(html).toContain('后端仍在生成跳一跳玩法');
|
||||
expect(html).toContain('生成中');
|
||||
});
|
||||
|
||||
test('creation start card includes failed drafts in the recent tab', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<CustomWorldCreationHub
|
||||
items={[
|
||||
{
|
||||
workId: 'draft:failed-session',
|
||||
sourceType: 'agent_session',
|
||||
status: 'draft',
|
||||
title: '失败但仍可恢复的草稿',
|
||||
subtitle: '生成失败',
|
||||
summary: '失败草稿也来自真实作品架摘要。',
|
||||
coverImageSrc: null,
|
||||
updatedAt: new Date('2026-06-02T12:00:00.000Z').toISOString(),
|
||||
publishedAt: null,
|
||||
stage: 'failed',
|
||||
stageLabel: '生成失败',
|
||||
playableNpcCount: 0,
|
||||
landmarkCount: 0,
|
||||
sessionId: 'failed-session',
|
||||
profileId: null,
|
||||
canResume: true,
|
||||
canEnterWorld: false,
|
||||
},
|
||||
]}
|
||||
loading={false}
|
||||
error={null}
|
||||
onRetry={() => {}}
|
||||
onCreateType={noopCreateType}
|
||||
onOpenDraft={() => {}}
|
||||
onEnterPublished={() => {}}
|
||||
entryConfig={testEntryConfig}
|
||||
creationTypes={testCreationTypes}
|
||||
mode="start-only"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain('最近创作');
|
||||
expect(html).toContain('creation-recent-work-grid');
|
||||
expect(html).toContain('失败但仍可恢复的草稿');
|
||||
expect(html).toContain('失败草稿也来自真实作品架摘要');
|
||||
expect(html).toContain('生成失败');
|
||||
});
|
||||
|
||||
test('creation start card maps failed mini-game drafts into recent status labels', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<CustomWorldCreationHub
|
||||
items={[]}
|
||||
loading={false}
|
||||
error={null}
|
||||
onRetry={() => {}}
|
||||
onCreateType={noopCreateType}
|
||||
onOpenDraft={() => {}}
|
||||
onEnterPublished={() => {}}
|
||||
entryConfig={testEntryConfig}
|
||||
creationTypes={testCreationTypes}
|
||||
match3dItems={[
|
||||
{
|
||||
workId: 'match3d-failed-work',
|
||||
profileId: 'match3d-failed-profile',
|
||||
ownerUserId: 'user-1',
|
||||
gameName: '失败抓大鹅草稿',
|
||||
themeText: '水果',
|
||||
summary: '失败的小玩法草稿也应该进入最近创作。',
|
||||
tags: [],
|
||||
coverImageSrc: null,
|
||||
clearCount: 0,
|
||||
difficulty: 1,
|
||||
publicationStatus: 'draft',
|
||||
playCount: 0,
|
||||
updatedAt: '2026-06-02T13:00:00.000Z',
|
||||
publishedAt: null,
|
||||
publishReady: false,
|
||||
generationStatus: 'failed',
|
||||
},
|
||||
]}
|
||||
mode="start-only"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain('最近创作');
|
||||
expect(html).toContain('失败抓大鹅草稿');
|
||||
expect(html).toContain('失败的小玩法草稿也应该进入最近创作。');
|
||||
expect(html).toContain('生成失败');
|
||||
});
|
||||
|
||||
test('creation start card keeps typography in compact UI scale', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<CustomWorldCreationHub
|
||||
|
||||
@@ -24,7 +24,10 @@ import {
|
||||
type CreationWorkShelfMetricId,
|
||||
type CreationWorkShelfRuntimeState,
|
||||
} from './creationWorkShelf';
|
||||
import { CustomWorldCreationStartCard } from './CustomWorldCreationStartCard';
|
||||
import {
|
||||
CustomWorldCreationStartCard,
|
||||
type CreationEntryRecentWorkCard,
|
||||
} from './CustomWorldCreationStartCard';
|
||||
import { CustomWorldWorkCard } from './CustomWorldWorkCard';
|
||||
import {
|
||||
type CustomWorldWorkFilter,
|
||||
@@ -89,6 +92,8 @@ type CustomWorldCreationHubProps = {
|
||||
item: CreationWorkShelfItem,
|
||||
) => CreationWorkShelfRuntimeState | null;
|
||||
onOpenShelfItem?: (item: CreationWorkShelfItem) => void;
|
||||
// 中文注释:底部加号入口的最近创作可传入后端作品架摘要,避免混入本地 pending 占位。
|
||||
recentWorkItems?: CreationWorkShelfItem[];
|
||||
mode?: 'full' | 'start-only' | 'works-only';
|
||||
};
|
||||
|
||||
@@ -155,6 +160,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,
|
||||
@@ -200,6 +234,7 @@ export function CustomWorldCreationHub({
|
||||
onDeleteVisualNovel = null,
|
||||
getWorkState,
|
||||
onOpenShelfItem,
|
||||
recentWorkItems: recentWorkSourceItems,
|
||||
mode = 'full',
|
||||
}: CustomWorldCreationHubProps) {
|
||||
const [activeFilter, setActiveFilter] =
|
||||
@@ -288,7 +323,6 @@ export function CustomWorldCreationHub({
|
||||
getWorkState,
|
||||
puzzleItems,
|
||||
rpgLibraryEntries,
|
||||
onOpenSquareHoleDetail,
|
||||
onOpenJumpHopDetail,
|
||||
jumpHopItems,
|
||||
woodenFishItems,
|
||||
@@ -314,6 +348,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);
|
||||
@@ -380,7 +427,14 @@ export function CustomWorldCreationHub({
|
||||
busy={createBusy}
|
||||
entryConfig={entryConfig}
|
||||
creationTypes={creationTypes}
|
||||
recentWorks={recentWorkCards}
|
||||
onCreateType={onCreateType}
|
||||
onOpenRecentWork={(index) => {
|
||||
const item = recentWorkItems[index];
|
||||
if (item) {
|
||||
handleOpenShelfItem(item);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
|
||||
@@ -1,64 +1,156 @@
|
||||
import { Coins, Trophy } from 'lucide-react';
|
||||
import { type UIEvent,useMemo, useState } from 'react';
|
||||
import { type UIEvent, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import type { CreationEntryConfig } from '../../services/creationEntryConfigService';
|
||||
import type {
|
||||
CreationEntryConfig,
|
||||
CreationEntryEventBannerConfig,
|
||||
} from '../../services/creationEntryConfigService';
|
||||
import {
|
||||
groupVisiblePlatformCreationTypes,
|
||||
type PlatformCreationTypeCard,
|
||||
type PlatformCreationTypeId,
|
||||
} from '../platform-entry/platformEntryCreationTypes';
|
||||
|
||||
/** 底部加号创作入口页的渲染参数,最近创作只接受作品架真实摘要。 */
|
||||
type CustomWorldCreationStartCardProps = {
|
||||
busy?: boolean;
|
||||
entryConfig: CreationEntryConfig;
|
||||
creationTypes: readonly PlatformCreationTypeCard[];
|
||||
recentWorks?: readonly CreationEntryRecentWorkCard[];
|
||||
onCreateType: (type: PlatformCreationTypeId) => void;
|
||||
onOpenRecentWork?: (index: number) => void;
|
||||
};
|
||||
|
||||
type CreationEventBannerCard = CreationEntryConfig['eventBanner'];
|
||||
/** 创作入口公告卡兼容结构化和 HTML 两种后台配置。 */
|
||||
type CreationEventBannerCard = CreationEntryEventBannerConfig;
|
||||
const CREATION_ENTRY_BANNER_AUTOPLAY_MS = 4200;
|
||||
const CREATION_ENTRY_RECENT_TAB_ID = '__recent_creation__';
|
||||
|
||||
/** 底部加号创作入口页最近创作页签的展示数据,只来自后端作品架摘要。 */
|
||||
export type CreationEntryRecentWorkCard = {
|
||||
id: string;
|
||||
title: string;
|
||||
summary: string;
|
||||
statusLabel: string;
|
||||
};
|
||||
|
||||
/** 判断模板 badge 是否需要展示,普通可创建态不额外占用卡片空间。 */
|
||||
function shouldShowCreationBadge(badge: string) {
|
||||
const normalizedBadge = badge.trim();
|
||||
return normalizedBadge !== '可创建' && normalizedBadge !== '可创作';
|
||||
}
|
||||
|
||||
/** 从后端入口配置中解析创作入口公告位,保留旧单条字段兜底。 */
|
||||
export function resolveCreationEntryEventBanners(
|
||||
entryConfig: CreationEntryConfig,
|
||||
): CreationEventBannerCard[] {
|
||||
const configuredBanners = Array.isArray(entryConfig.eventBanners)
|
||||
? entryConfig.eventBanners.filter((banner) => banner.title.trim())
|
||||
: [];
|
||||
return configuredBanners.length > 0
|
||||
? configuredBanners
|
||||
: [entryConfig.eventBanner];
|
||||
}
|
||||
|
||||
/** 渲染创作入口公告位当前页指示点。 */
|
||||
function CreationEntryBannerPager({
|
||||
banners,
|
||||
activeBannerIndex,
|
||||
}: {
|
||||
banners: readonly CreationEventBannerCard[];
|
||||
activeBannerIndex: number;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="creation-event-banner__pager flex items-center justify-center gap-1.5"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{banners.map((dotBanner, dotIndex) => (
|
||||
<span
|
||||
key={`${dotBanner.title}:dot:${dotIndex}`}
|
||||
className={
|
||||
dotIndex === activeBannerIndex
|
||||
? 'h-1.5 w-5 rounded-full bg-[#d9793f]'
|
||||
: 'h-1.5 w-1.5 rounded-full bg-[#eadfd7]'
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 渲染底部加号进入的创作入口页,包括后台公告位和模板分类。 */
|
||||
export function CustomWorldCreationStartCard({
|
||||
busy = false,
|
||||
entryConfig,
|
||||
creationTypes,
|
||||
recentWorks = [],
|
||||
onCreateType,
|
||||
onOpenRecentWork,
|
||||
}: CustomWorldCreationStartCardProps) {
|
||||
const creationTypeGroups = useMemo(
|
||||
() => groupVisiblePlatformCreationTypes(creationTypes),
|
||||
[creationTypes],
|
||||
);
|
||||
const [activeCategoryId, setActiveCategoryId] = useState<string | null>(null);
|
||||
const activeGroup =
|
||||
creationTypeGroups.find((group) => group.id === activeCategoryId) ??
|
||||
creationTypeGroups[0] ??
|
||||
null;
|
||||
const hasRecentWorks = recentWorks.length > 0;
|
||||
const activeTabId =
|
||||
activeCategoryId ??
|
||||
(hasRecentWorks
|
||||
? CREATION_ENTRY_RECENT_TAB_ID
|
||||
: creationTypeGroups[0]?.id ?? null);
|
||||
const isRecentTabActive =
|
||||
hasRecentWorks && activeTabId === CREATION_ENTRY_RECENT_TAB_ID;
|
||||
const activeGroup = isRecentTabActive
|
||||
? null
|
||||
: creationTypeGroups.find((group) => group.id === activeTabId) ??
|
||||
creationTypeGroups[0] ??
|
||||
null;
|
||||
const visibleCreationTypes = activeGroup?.items ?? [];
|
||||
const eventBanners = useMemo<CreationEventBannerCard[]>(
|
||||
() => [
|
||||
{
|
||||
...entryConfig.eventBanner,
|
||||
title: '拼图主题创作赛',
|
||||
description: '用拼图关卡接住本周主题。',
|
||||
coverImageSrc: '/creation-type-references/puzzle.webp',
|
||||
prizePoolMudPoints: 1000,
|
||||
},
|
||||
{
|
||||
...entryConfig.eventBanner,
|
||||
title: '抓大鹅主题创作赛',
|
||||
description: '把抓大鹅关卡做成主题挑战。',
|
||||
coverImageSrc: '/creation-type-references/match3d.webp',
|
||||
prizePoolMudPoints: 1000,
|
||||
},
|
||||
],
|
||||
[entryConfig.eventBanner],
|
||||
const eventBanners = useMemo(
|
||||
() => resolveCreationEntryEventBanners(entryConfig),
|
||||
[entryConfig],
|
||||
);
|
||||
const [activeBannerIndex, setActiveBannerIndex] = useState(0);
|
||||
const bannerTrackRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setActiveBannerIndex(0);
|
||||
}, [eventBanners.length]);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasRecentWorks) {
|
||||
return;
|
||||
}
|
||||
|
||||
setActiveCategoryId((currentId) =>
|
||||
currentId === CREATION_ENTRY_RECENT_TAB_ID ? null : currentId,
|
||||
);
|
||||
}, [hasRecentWorks]);
|
||||
|
||||
useEffect(() => {
|
||||
if (eventBanners.length <= 1) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const intervalId = window.setInterval(() => {
|
||||
setActiveBannerIndex((currentIndex) => {
|
||||
const nextIndex = (currentIndex + 1) % eventBanners.length;
|
||||
const track = bannerTrackRef.current;
|
||||
if (track && typeof track.scrollTo === 'function') {
|
||||
track.scrollTo({
|
||||
left: track.clientWidth * nextIndex,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}
|
||||
return nextIndex;
|
||||
});
|
||||
}, CREATION_ENTRY_BANNER_AUTOPLAY_MS);
|
||||
|
||||
return () => window.clearInterval(intervalId);
|
||||
}, [eventBanners.length]);
|
||||
|
||||
/** 同步手势滑动后的 banner 页码,避免自动轮播和手动滑动状态错位。 */
|
||||
function handleBannerScroll(event: UIEvent<HTMLDivElement>) {
|
||||
const { clientWidth, scrollLeft } = event.currentTarget;
|
||||
if (clientWidth <= 0) {
|
||||
@@ -78,74 +170,87 @@ export function CustomWorldCreationStartCard({
|
||||
<div className="mx-auto flex w-full max-w-5xl flex-col gap-3 sm:gap-5">
|
||||
<section className="creation-event-banner relative overflow-hidden rounded-[1.35rem] border border-[#f0d8ca] bg-[#fff8f3] shadow-[0_16px_36px_rgba(174,111,73,0.12)] sm:rounded-[1.65rem]">
|
||||
<div
|
||||
ref={bannerTrackRef}
|
||||
className="creation-event-banner__track flex snap-x snap-mandatory overflow-x-auto overscroll-x-contain touch-pan-x scrollbar-hide"
|
||||
onScroll={handleBannerScroll}
|
||||
aria-label="创作赛事横幅"
|
||||
aria-label="创作公告横幅"
|
||||
>
|
||||
{eventBanners.map((banner, index) => {
|
||||
const prizePoolText =
|
||||
banner.prizePoolMudPoints.toLocaleString('zh-CN');
|
||||
const shouldRenderHtmlBanner =
|
||||
banner.renderMode === 'html' && Boolean(banner.htmlCode?.trim());
|
||||
|
||||
return (
|
||||
<article
|
||||
key={`${banner.title}:${index}`}
|
||||
className="creation-event-banner__slide relative w-full shrink-0 snap-center overflow-hidden"
|
||||
>
|
||||
<img
|
||||
src={banner.coverImageSrc}
|
||||
alt=""
|
||||
className="absolute inset-y-0 right-0 h-full w-[58%] object-cover object-[70%_center] opacity-95"
|
||||
loading={index === 0 ? 'eager' : 'lazy'}
|
||||
/>
|
||||
<div className="absolute inset-0 bg-[linear-gradient(90deg,#fff8f3_0%,rgba(255,248,243,0.94)_34%,rgba(255,248,243,0.36)_68%,rgba(255,248,243,0.08)_100%)]" />
|
||||
<div className="relative z-10 flex min-h-[12rem] flex-col justify-between px-4 py-4 sm:min-h-[15rem] sm:px-7 sm:py-6">
|
||||
<div className="w-[68%] min-w-0 sm:w-[56%]">
|
||||
<div className="inline-flex max-w-full items-center gap-1.5 rounded-full border border-[#df7949] bg-white/72 px-2.5 py-1 text-xs font-black text-[#cf6332] shadow-sm">
|
||||
<Trophy className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="truncate">{banner.title}</span>
|
||||
</div>
|
||||
<div className="mt-3 line-clamp-2 text-sm font-semibold leading-5 text-[#695143] sm:mt-5 sm:leading-6">
|
||||
{banner.description}
|
||||
</div>
|
||||
<div className="mt-3 inline-flex max-w-full items-center gap-1.5 rounded-full bg-white/72 px-2.5 py-1.5 text-xs font-bold text-[#6f5140] shadow-sm">
|
||||
<span className="grid h-5 w-5 place-items-center rounded-full bg-[#ffb64c] text-white">
|
||||
<Coins className="h-3 w-3" />
|
||||
</span>
|
||||
<span className="shrink-0">奖池</span>
|
||||
<span className="text-sm font-black text-[#d36b2f]">
|
||||
{prizePoolText}
|
||||
</span>
|
||||
<span className="shrink-0">泥点数</span>
|
||||
</div>
|
||||
{shouldRenderHtmlBanner ? (
|
||||
<div className="relative min-h-[12rem] sm:min-h-[15rem]">
|
||||
<iframe
|
||||
title={banner.title}
|
||||
sandbox=""
|
||||
srcDoc={banner.htmlCode ?? ''}
|
||||
className="absolute inset-0 h-full w-full border-0 bg-transparent"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<img
|
||||
src={banner.coverImageSrc}
|
||||
alt=""
|
||||
className="absolute inset-y-0 right-0 h-full w-[58%] object-cover object-[70%_center] opacity-95"
|
||||
loading={index === 0 ? 'eager' : 'lazy'}
|
||||
/>
|
||||
<div className="absolute inset-0 bg-[linear-gradient(90deg,#fff8f3_0%,rgba(255,248,243,0.94)_34%,rgba(255,248,243,0.36)_68%,rgba(255,248,243,0.08)_100%)]" />
|
||||
<div className="relative z-10 flex min-h-[12rem] flex-col justify-between px-4 py-4 sm:min-h-[15rem] sm:px-7 sm:py-6">
|
||||
<div className="w-[68%] min-w-0 sm:w-[56%]">
|
||||
<div className="inline-flex max-w-full items-center gap-1.5 rounded-full border border-[#df7949] bg-white/72 px-2.5 py-1 text-xs font-black text-[#cf6332] shadow-sm">
|
||||
<Trophy className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="truncate">{banner.title}</span>
|
||||
</div>
|
||||
<div className="mt-3 line-clamp-2 text-sm font-semibold leading-5 text-[#695143] sm:mt-5 sm:leading-6">
|
||||
{banner.description}
|
||||
</div>
|
||||
<div className="mt-3 inline-flex max-w-full items-center gap-1.5 rounded-full bg-white/72 px-2.5 py-1.5 text-xs font-bold text-[#6f5140] shadow-sm">
|
||||
<span className="grid h-5 w-5 place-items-center rounded-full bg-[#ffb64c] text-white">
|
||||
<Coins className="h-3 w-3" />
|
||||
</span>
|
||||
<span className="shrink-0">奖池</span>
|
||||
<span className="text-sm font-black text-[#d36b2f]">
|
||||
{prizePoolText}
|
||||
</span>
|
||||
<span className="shrink-0">泥点数</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-col gap-2">
|
||||
<div className="creation-event-banner__timebar flex min-w-0 items-center justify-center gap-1.5 rounded-full border border-[#e9c5b0] bg-white/72 px-2.5 py-1.5 text-center text-[10px] font-bold text-[#9a5a39] shadow-[0_8px_18px_rgba(174,111,73,0.1)] sm:gap-2 sm:px-5 sm:py-2.5 sm:text-[11px]">
|
||||
<span className="min-w-0 truncate">
|
||||
开始时间 {banner.startsAtText}
|
||||
</span>
|
||||
<span className="shrink-0 text-[#c99373]">|</span>
|
||||
<span className="min-w-0 truncate">
|
||||
结束时间 {banner.endsAtText}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className="creation-event-banner__pager flex items-center justify-center gap-1.5"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{eventBanners.map((dotBanner, dotIndex) => (
|
||||
<span
|
||||
key={`${dotBanner.title}:dot:${dotIndex}`}
|
||||
className={
|
||||
dotIndex === activeBannerIndex
|
||||
? 'h-1.5 w-5 rounded-full bg-[#d9793f]'
|
||||
: 'h-1.5 w-1.5 rounded-full bg-[#eadfd7]'
|
||||
}
|
||||
<div className="mt-4 flex flex-col gap-2">
|
||||
<div className="creation-event-banner__timebar flex min-w-0 items-center justify-center gap-1.5 rounded-full border border-[#e9c5b0] bg-white/72 px-2.5 py-1.5 text-center text-[10px] font-bold text-[#9a5a39] shadow-[0_8px_18px_rgba(174,111,73,0.1)] sm:gap-2 sm:px-5 sm:py-2.5 sm:text-[11px]">
|
||||
<span className="min-w-0 truncate">
|
||||
开始时间 {banner.startsAtText}
|
||||
</span>
|
||||
<span className="shrink-0 text-[#c99373]">|</span>
|
||||
<span className="min-w-0 truncate">
|
||||
结束时间 {banner.endsAtText}
|
||||
</span>
|
||||
</div>
|
||||
<CreationEntryBannerPager
|
||||
banners={eventBanners}
|
||||
activeBannerIndex={activeBannerIndex}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{shouldRenderHtmlBanner ? (
|
||||
<div className="absolute inset-x-0 bottom-3 z-10">
|
||||
<CreationEntryBannerPager
|
||||
banners={eventBanners}
|
||||
activeBannerIndex={activeBannerIndex}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
@@ -156,8 +261,26 @@ export function CustomWorldCreationStartCard({
|
||||
<div
|
||||
className="-mx-0.5 flex snap-x items-center gap-2 overflow-x-auto px-0.5 pb-1 scrollbar-hide scroll-px-2 sm:gap-3"
|
||||
role="tablist"
|
||||
aria-label="玩法模板分类"
|
||||
aria-label="创作入口页签"
|
||||
>
|
||||
{hasRecentWorks ? (
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={isRecentTabActive}
|
||||
onClick={() => setActiveCategoryId(CREATION_ENTRY_RECENT_TAB_ID)}
|
||||
className={`relative min-h-8 shrink-0 rounded-full px-2.5 text-xs font-black transition sm:min-h-9 sm:px-3.5 sm:text-sm ${
|
||||
isRecentTabActive
|
||||
? 'text-[#6f2f21]'
|
||||
: 'text-[#7a6558] hover:text-[#6f2f21]'
|
||||
}`}
|
||||
>
|
||||
<span>最近创作</span>
|
||||
{isRecentTabActive ? (
|
||||
<span className="absolute bottom-0 left-3 right-3 h-1 rounded-full bg-[#d9793f]" />
|
||||
) : null}
|
||||
</button>
|
||||
) : null}
|
||||
{creationTypeGroups.map((group) => {
|
||||
const selected = group.id === activeGroup?.id;
|
||||
return (
|
||||
@@ -182,55 +305,78 @@ export function CustomWorldCreationStartCard({
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="creation-template-list__grid mt-2 grid grid-cols-2 gap-2 sm:mt-3 sm:gap-3">
|
||||
{visibleCreationTypes.map((item) => {
|
||||
const disabled = item.locked || busy;
|
||||
|
||||
return (
|
||||
{isRecentTabActive ? (
|
||||
<div className="creation-recent-work-grid mt-2 grid grid-cols-2 gap-2 sm:mt-3 sm:gap-3">
|
||||
{recentWorks.map((item, index) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
onCreateType(item.id);
|
||||
}}
|
||||
className={`creation-template-card platform-interactive-card relative flex min-h-[12.5rem] flex-col overflow-hidden rounded-[1rem] border bg-white p-0 text-left transition sm:min-h-[15rem] sm:rounded-[1.2rem] ${
|
||||
item.locked
|
||||
? 'cursor-not-allowed border-[#eadbd3] text-[#725b4d] opacity-72'
|
||||
: 'border-[#eadbd3] text-[#2f211b] hover:border-[#dc9a72] hover:shadow-[0_16px_34px_rgba(174,111,73,0.14)]'
|
||||
} ${busy && !item.locked ? 'opacity-70' : ''}`}
|
||||
aria-label={`打开最近创作 ${index + 1}`}
|
||||
className="creation-recent-work-card min-h-[7.5rem] rounded-[1rem] border border-[#eadbd3] bg-white p-3 text-left shadow-[0_10px_22px_rgba(174,111,73,0.1)]"
|
||||
onClick={() => onOpenRecentWork?.(index)}
|
||||
>
|
||||
<div className="creation-template-card__media relative aspect-[1.32/1] w-full overflow-hidden bg-[#f7ebe3]">
|
||||
<img
|
||||
src={item.imageSrc}
|
||||
alt=""
|
||||
className="h-full w-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
{shouldShowCreationBadge(item.badge) ? (
|
||||
<span className="absolute left-2 top-2 max-w-[calc(100%-1rem)] rounded-full bg-[#b66a3e] px-2 py-0.5 text-xs font-black text-white shadow-sm sm:left-3 sm:top-3 sm:px-2.5 sm:py-1">
|
||||
{item.badge}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="creation-template-card__cost-badge absolute bottom-2 right-2 inline-flex max-w-[calc(100%-1rem)] items-center gap-1 rounded-full bg-[#fff7ec]/92 px-2 py-1 text-[11px] font-black leading-4 text-[#b65f2c] shadow-[0_8px_18px_rgba(119,72,44,0.16)]">
|
||||
<Coins className="h-3 w-3 shrink-0" />
|
||||
<span className="truncate">10-20泥点数</span>
|
||||
</span>
|
||||
<div className="line-clamp-1 text-sm font-black text-[#2f211b]">
|
||||
{item.title}
|
||||
</div>
|
||||
|
||||
<div className="creation-template-card__body flex min-h-[4.6rem] flex-1 flex-col bg-white px-2.5 pb-2.5 pt-2.5 text-[#2f211b] sm:min-h-[5.4rem] sm:px-3.5 sm:pb-3.5">
|
||||
<div className="creation-template-card__title line-clamp-1 text-sm font-black leading-5 text-[#2f211b]">
|
||||
{item.title}
|
||||
</div>
|
||||
<div className="creation-template-card__subtitle mt-1 line-clamp-2 text-xs font-semibold leading-4 text-[#6f5a4c] sm:leading-5">
|
||||
{item.subtitle}
|
||||
</div>
|
||||
<div className="mt-1 line-clamp-3 text-xs font-semibold leading-4 text-[#6f5a4c]">
|
||||
{item.summary}
|
||||
</div>
|
||||
<div className="mt-2 text-[11px] font-bold text-[#b65f2c]">
|
||||
{item.statusLabel}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="creation-template-list__grid mt-2 grid grid-cols-2 gap-2 sm:mt-3 sm:gap-3">
|
||||
{visibleCreationTypes.map((item) => {
|
||||
const disabled = item.locked || busy;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
onCreateType(item.id);
|
||||
}}
|
||||
className={`creation-template-card platform-interactive-card relative flex min-h-[12.5rem] flex-col overflow-hidden rounded-[1rem] border bg-white p-0 text-left transition sm:min-h-[15rem] sm:rounded-[1.2rem] ${
|
||||
item.locked
|
||||
? 'cursor-not-allowed border-[#eadbd3] text-[#725b4d] opacity-72'
|
||||
: 'border-[#eadbd3] text-[#2f211b] hover:border-[#dc9a72] hover:shadow-[0_16px_34px_rgba(174,111,73,0.14)]'
|
||||
} ${busy && !item.locked ? 'opacity-70' : ''}`}
|
||||
>
|
||||
<div className="creation-template-card__media relative aspect-[1.32/1] w-full overflow-hidden bg-[#f7ebe3]">
|
||||
<img
|
||||
src={item.imageSrc}
|
||||
alt=""
|
||||
className="h-full w-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
{shouldShowCreationBadge(item.badge) ? (
|
||||
<span className="absolute left-2 top-2 max-w-[calc(100%-1rem)] rounded-full bg-[#b66a3e] px-2 py-0.5 text-xs font-black text-white shadow-sm sm:left-3 sm:top-3 sm:px-2.5 sm:py-1">
|
||||
{item.badge}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="creation-template-card__cost-badge absolute bottom-2 right-2 inline-flex max-w-[calc(100%-1rem)] items-center gap-1 rounded-full bg-[#fff7ec]/92 px-2 py-1 text-[11px] font-black leading-4 text-[#b65f2c] shadow-[0_8px_18px_rgba(119,72,44,0.16)]">
|
||||
<Coins className="h-3 w-3 shrink-0" />
|
||||
<span className="truncate">10-20泥点数</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="creation-template-card__body flex min-h-[4.6rem] flex-1 flex-col bg-white px-2.5 pb-2.5 pt-2.5 text-[#2f211b] sm:min-h-[5.4rem] sm:px-3.5 sm:pb-3.5">
|
||||
<div className="creation-template-card__title line-clamp-1 text-sm font-black leading-5 text-[#2f211b]">
|
||||
{item.title}
|
||||
</div>
|
||||
<div className="creation-template-card__subtitle mt-1 line-clamp-2 text-xs font-semibold leading-4 text-[#6f5a4c] sm:leading-5">
|
||||
{item.subtitle}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1112,6 +1112,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':
|
||||
|
||||
Reference in New Issue
Block a user