feat: unify creation entry templates

This commit is contained in:
2026-06-03 10:24:03 +08:00
parent b0865cfa19
commit 3f742fbaca
25 changed files with 820 additions and 346 deletions

View File

@@ -7,6 +7,11 @@ 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: {
@@ -90,6 +95,20 @@ const testEntryConfig = {
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,
},
{
id: 'square-hole',
title: '方洞',
@@ -285,7 +304,7 @@ test('creation start card renders html banner in an empty-permission sandbox', (
expect(html).toContain('<section><h1>自定义横幅</h1></section>');
});
test('creation start card renders recent tab from real shelf summaries', () => {
test('creation start card renders recent tab with the same template cards', () => {
const html = renderToStaticMarkup(
<CustomWorldCreationHub
items={[
@@ -297,7 +316,7 @@ test('creation start card renders recent tab from real shelf summaries', () => {
subtitle: '待完善草稿',
summary: '这条内容来自作品架摘要。',
coverImageSrc: null,
updatedAt: new Date('2026-06-01T12:00:00.000Z').toISOString(),
updatedAt: buildUpdatedAtDaysAgo(1),
publishedAt: null,
stage: 'clarifying',
stageLabel: '待完善草稿',
@@ -317,7 +336,6 @@ test('creation start card renders recent tab from real shelf summaries', () => {
onEnterPublished={() => {}}
entryConfig={testEntryConfig}
creationTypes={testCreationTypes}
getWorkState={() => ({ isGenerating: true })}
mode="start-only"
/>,
);
@@ -325,12 +343,16 @@ test('creation start card renders recent tab from real shelf summaries', () => {
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('creation-template-list__grid');
expect(html).toContain('creation-template-card');
expect(html).toContain('最近创作');
expect(html).toContain('后端返回的最近草稿');
expect(html).toContain('这条内容来自作品架摘要');
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', () => {
@@ -344,7 +366,7 @@ test('creation start card prefers backend recent summaries over local pending pl
subtitle: '真实作品架摘要',
summary: '最近创作应该只读取后端摘要。',
coverImageSrc: null,
updatedAt: new Date('2026-06-03T12:00:00.000Z').toISOString(),
updatedAt: buildUpdatedAtDaysAgo(1),
publishedAt: null,
stage: 'failed',
stageLabel: '生成失败',
@@ -370,7 +392,7 @@ test('creation start card prefers backend recent summaries over local pending pl
subtitle: '本地占位',
summary: '这条占位不应该进入最近创作。',
coverImageSrc: null,
updatedAt: new Date('2026-06-04T12:00:00.000Z').toISOString(),
updatedAt: buildUpdatedAtDaysAgo(0),
publishedAt: null,
stage: 'generating',
stageLabel: '生成中',
@@ -396,12 +418,56 @@ test('creation start card prefers backend recent summaries over local pending pl
);
expect(html).toContain('最近创作');
expect(html).toContain('后端最近草稿');
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 marks backend jump-hop generating draft in recent tab', () => {
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={[]}
@@ -420,7 +486,7 @@ test('creation start card marks backend jump-hop generating draft in recent tab'
coverImageSrc: null,
publicationStatus: 'draft',
playCount: 0,
updatedAt: new Date('2026-06-03T13:00:00.000Z').toISOString(),
updatedAt: buildUpdatedAtDaysAgo(1),
publishedAt: null,
publishReady: false,
generationStatus: 'generating',
@@ -440,9 +506,11 @@ test('creation start card marks backend jump-hop generating draft in recent tab'
);
expect(html).toContain('最近创作');
expect(html).toContain('跳一跳生成草稿');
expect(html).toContain('后端仍在生成跳一跳玩法');
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', () => {
@@ -457,7 +525,7 @@ test('creation start card includes failed drafts in the recent tab', () => {
subtitle: '生成失败',
summary: '失败草稿也来自真实作品架摘要。',
coverImageSrc: null,
updatedAt: new Date('2026-06-02T12:00:00.000Z').toISOString(),
updatedAt: buildUpdatedAtDaysAgo(1),
publishedAt: null,
stage: 'failed',
stageLabel: '生成失败',
@@ -482,13 +550,15 @@ test('creation start card includes failed drafts in the recent tab', () => {
);
expect(html).toContain('最近创作');
expect(html).toContain('creation-recent-work-grid');
expect(html).toContain('失败但仍可恢复的草稿');
expect(html).toContain('失败草稿也来自真实作品架摘要');
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 status labels', () => {
test('creation start card maps failed mini-game drafts into recent template cards', () => {
const html = renderToStaticMarkup(
<CustomWorldCreationHub
items={[]}
@@ -514,7 +584,7 @@ test('creation start card maps failed mini-game drafts into recent status labels
difficulty: 1,
publicationStatus: 'draft',
playCount: 0,
updatedAt: '2026-06-02T13:00:00.000Z',
updatedAt: buildUpdatedAtDaysAgo(1),
publishedAt: null,
publishReady: false,
generationStatus: 'failed',
@@ -525,9 +595,11 @@ test('creation start card maps failed mini-game drafts into recent status labels
);
expect(html).toContain('最近创作');
expect(html).toContain('失败抓大鹅草稿');
expect(html).toContain('失败的小玩法草稿也应该进入最近创作。');
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', () => {

View File

@@ -20,13 +20,13 @@ import type {
import { isPlatformCreationTypeVisible } from '../platform-entry/platformEntryCreationTypes';
import {
buildCreationWorkShelfItems,
getCreationWorkShelfItemTime,
type CreationWorkShelfItem,
type CreationWorkShelfMetricId,
type CreationWorkShelfRuntimeState,
} from './creationWorkShelf';
import {
CustomWorldCreationStartCard,
type CreationEntryRecentWorkCard,
} from './CustomWorldCreationStartCard';
import { CustomWorldWorkCard } from './CustomWorldWorkCard';
import {
@@ -37,6 +37,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,
@@ -92,7 +95,7 @@ type CustomWorldCreationHubProps = {
item: CreationWorkShelfItem,
) => CreationWorkShelfRuntimeState | null;
onOpenShelfItem?: (item: CreationWorkShelfItem) => void;
// 中文注释:底部加号入口的最近创作可传入后端作品架摘要,避免混入本地 pending 占位
// 中文注释:底部加号入口可传入后端作品架摘要,用于推导最近使用过的模板
recentWorkItems?: CreationWorkShelfItem[];
mode?: 'full' | 'start-only' | 'works-only';
};
@@ -160,35 +163,7 @@ 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,
@@ -348,19 +323,21 @@ export function CustomWorldCreationHub({
),
[activeFilter, shelfItems],
);
// 中文注释:最近创作只来自作品架摘要;平台入口会传入不含本地 pending 占位的后端摘要
// 中文注释:最近创作只取 7 天内作品架摘要,再推导模板 ID 复用模板入口卡片
const recentCreationCutoffMs = Date.now() - RECENT_CREATION_WINDOW_MS;
const recentWorkItems =
mode === 'start-only'
? (recentWorkSourceItems ?? shelfItems).slice(0, 4)
? (recentWorkSourceItems ?? shelfItems)
.filter(
(item) =>
getCreationWorkShelfItemTime(item.updatedAt) >=
recentCreationCutoffMs,
)
.slice(0, 4)
: [];
const recentWorkCards: CreationEntryRecentWorkCard[] = recentWorkItems.map(
(item) => ({
id: `${item.kind}:${item.id}`,
title: item.title,
summary: item.summary,
statusLabel: formatRecentWorkStatusLabel(item),
}),
);
const recentCreationTypeIds = [
...new Set(recentWorkItems.map((item) => item.kind)),
];
function handleOpenShelfItem(item: CreationWorkShelfItem) {
onOpenShelfItem?.(item);
@@ -427,14 +404,9 @@ export function CustomWorldCreationHub({
busy={createBusy}
entryConfig={entryConfig}
creationTypes={creationTypes}
recentWorks={recentWorkCards}
recentCreationTypeIds={recentCreationTypeIds}
recentWindowDays={RECENT_CREATION_WINDOW_DAYS}
onCreateType={onCreateType}
onOpenRecentWork={(index) => {
const item = recentWorkItems[index];
if (item) {
handleOpenShelfItem(item);
}
}}
/>
) : null}

View File

@@ -11,14 +11,14 @@ import {
type PlatformCreationTypeId,
} from '../platform-entry/platformEntryCreationTypes';
/** 底部加号创作入口页的渲染参数,最近创作只接受作品架真实摘要。 */
/** 底部加号创作入口页的渲染参数,最近创作用作品架摘要推导模板入口。 */
type CustomWorldCreationStartCardProps = {
busy?: boolean;
entryConfig: CreationEntryConfig;
creationTypes: readonly PlatformCreationTypeCard[];
recentWorks?: readonly CreationEntryRecentWorkCard[];
recentCreationTypeIds?: readonly PlatformCreationTypeId[];
recentWindowDays?: number;
onCreateType: (type: PlatformCreationTypeId) => void;
onOpenRecentWork?: (index: number) => void;
};
/** 创作入口公告卡兼容结构化和 HTML 两种后台配置。 */
@@ -26,14 +26,6 @@ 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();
@@ -84,29 +76,41 @@ export function CustomWorldCreationStartCard({
busy = false,
entryConfig,
creationTypes,
recentWorks = [],
recentCreationTypeIds = [],
recentWindowDays = 7,
onCreateType,
onOpenRecentWork,
}: 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 hasRecentWorks = recentWorks.length > 0;
const hasRecentCreationTypes = recentCreationTypes.length > 0;
const activeTabId =
activeCategoryId ??
(hasRecentWorks
(hasRecentCreationTypes
? CREATION_ENTRY_RECENT_TAB_ID
: creationTypeGroups[0]?.id ?? null);
const isRecentTabActive =
hasRecentWorks && activeTabId === CREATION_ENTRY_RECENT_TAB_ID;
hasRecentCreationTypes && activeTabId === CREATION_ENTRY_RECENT_TAB_ID;
const activeGroup = isRecentTabActive
? null
: creationTypeGroups.find((group) => group.id === activeTabId) ??
creationTypeGroups[0] ??
null;
const visibleCreationTypes = activeGroup?.items ?? [];
const visibleCreationTypes = isRecentTabActive
? recentCreationTypes
: activeGroup?.items ?? [];
const eventBanners = useMemo(
() => resolveCreationEntryEventBanners(entryConfig),
[entryConfig],
@@ -119,14 +123,14 @@ export function CustomWorldCreationStartCard({
}, [eventBanners.length]);
useEffect(() => {
if (hasRecentWorks) {
if (hasRecentCreationTypes) {
return;
}
setActiveCategoryId((currentId) =>
currentId === CREATION_ENTRY_RECENT_TAB_ID ? null : currentId,
);
}, [hasRecentWorks]);
}, [hasRecentCreationTypes]);
useEffect(() => {
if (eventBanners.length <= 1) {
@@ -263,7 +267,7 @@ export function CustomWorldCreationStartCard({
role="tablist"
aria-label="创作入口页签"
>
{hasRecentWorks ? (
{hasRecentCreationTypes ? (
<button
type="button"
role="tab"
@@ -306,77 +310,59 @@ export function CustomWorldCreationStartCard({
</div>
{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) => (
<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;
return (
<button
key={item.id}
type="button"
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)}
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="line-clamp-1 text-sm font-black text-[#2f211b]">
{item.title}
<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="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 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>
) : (
<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>
)}
);
})}
</div>
</section>
</div>
);

View File

@@ -1113,7 +1113,7 @@ function isPersistedCreationWorkGenerating(item: CreationWorkShelfItem) {
case 'match3d':
return item.source.item.generationStatus === 'generating';
case 'jump-hop':
// 中文注释:跳一跳后端生成中草稿也要同步到作品架与最近创作状态
// 中文注释:跳一跳后端生成中草稿也要同步到作品架,并参与最近模板推导
return item.source.item.generationStatus === 'generating';
case 'puzzle':
return isPersistedPuzzleDraftGenerating(item.source.item);