This commit is contained in:
2026-04-29 20:56:59 +08:00
parent fb6f455530
commit 730f485f48
200 changed files with 9881 additions and 2221 deletions

View File

@@ -11,12 +11,62 @@ const noopCreateType = () => {};
const originalClipboard = navigator.clipboard;
afterEach(() => {
window.sessionStorage.clear();
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: originalClipboard,
});
});
test('creation hub shows published metric growth from cached page snapshot', async () => {
window.sessionStorage.setItem(
'genarrative.creationHub.publishedMetrics.v1',
JSON.stringify({
'puzzle:puzzle:work-growth': {
'play-count': 7,
'remix-count': 1,
'like-count': 2,
},
}),
);
render(
<CustomWorldCreationHub
items={[]}
puzzleItems={[
{
workId: 'puzzle:work-growth',
profileId: 'puzzle-profile-growth',
ownerUserId: 'user-1',
authorDisplayName: '拼图作者',
levelName: '涨潮拼图',
summary: '公开指标会从缓存快照涨到最新值。',
themeTags: ['涨潮'],
coverImageSrc: null,
publicationStatus: 'published',
updatedAt: new Date('2026-04-22T12:00:00.000Z').toISOString(),
publishedAt: new Date('2026-04-22T12:10:00.000Z').toISOString(),
playCount: 10,
remixCount: 4,
likeCount: 2,
publishReady: true,
},
]}
loading={false}
error={null}
onRetry={() => {}}
onCreateType={noopCreateType}
onOpenDraft={() => {}}
onEnterPublished={() => {}}
onOpenPuzzleDetail={() => {}}
/>,
);
expect(screen.getByLabelText('游玩 10次')).toBeTruthy();
expect(screen.getByLabelText('改造 4次')).toBeTruthy();
expect(await screen.findAllByText('↑')).toHaveLength(2);
});
const baseDraftItem: CustomWorldWorkSummary = {
workId: 'draft:session-1',
sourceType: 'agent_session',
@@ -52,10 +102,12 @@ test('creation hub reflects updated draft title summary and counts after rerende
expect(screen.getByText('潮雾列岛')).toBeTruthy();
expect(screen.getByText('玩家是失职返乡的守灯人。')).toBeTruthy();
expect(screen.getByText('角色 3')).toBeTruthy();
expect(screen.getByText('地点 4')).toBeTruthy();
expect(screen.getByRole('button', { name: / RPG/u })).toBeTruthy();
expect(screen.getByRole('button', { name: //u })).toBeTruthy();
expect(screen.queryByText('角色 3')).toBeNull();
expect(screen.queryByText('地点 4')).toBeNull();
expect(
screen.getByRole('button', { name: /.*/u }),
).toBeTruthy();
expect(screen.getByRole('button', { name: /.*/u })).toBeTruthy();
expect(screen.queryByRole('button', { name: //u })).toBeNull();
rerender(
@@ -83,8 +135,8 @@ test('creation hub reflects updated draft title summary and counts after rerende
expect(
screen.getByText('世界总卡和角色网已经继续长出了新的支线。'),
).toBeTruthy();
expect(screen.getByText('角色 5')).toBeTruthy();
expect(screen.getByText('地点 6')).toBeTruthy();
expect(screen.queryByText('角色 5')).toBeNull();
expect(screen.queryByText('地点 6')).toBeNull();
});
test('creation hub mixes puzzle works into the same grid and uses puzzle tag to distinguish', () => {
@@ -105,7 +157,8 @@ test('creation hub mixes puzzle works into the same grid and uses puzzle tag to
updatedAt: new Date('2026-04-22T12:00:00.000Z').toISOString(),
publishedAt: new Date('2026-04-22T12:10:00.000Z').toISOString(),
playCount: 8,
likeCount: 0,
remixCount: 2,
likeCount: 3,
publishReady: true,
},
]}
@@ -121,8 +174,14 @@ test('creation hub mixes puzzle works into the same grid and uses puzzle tag to
expect(screen.getByText('潮雾列岛')).toBeTruthy();
expect(screen.getByText('沉钟拼图')).toBeTruthy();
expect(screen.getByText('PZ-PROFILE1')).toBeTruthy();
expect(screen.getAllByText('拼图').length).toBeGreaterThan(0);
expect(screen.getByLabelText('游玩 8次')).toBeTruthy();
expect(screen.getByLabelText('改造 2次')).toBeTruthy();
expect(screen.getByLabelText('点赞 3赞')).toBeTruthy();
expect(screen.queryByText('Remix')).toBeNull();
expect(screen.queryByText('PZ-PROFILE1')).toBeNull();
expect(screen.queryByText('潮雾')).toBeNull();
expect(screen.queryByText('沉钟')).toBeNull();
expect(screen.queryByText('我的拼图作品')).toBeNull();
});
@@ -159,7 +218,9 @@ test('creation hub shows RPG public work code from published library entry', ()
themeMode: 'tide',
playableNpcCount: 3,
landmarkCount: 4,
likeCount: 0,
playCount: 12,
remixCount: 4,
likeCount: 5,
},
]}
loading={false}
@@ -172,7 +233,11 @@ test('creation hub shows RPG public work code from published library entry', ()
);
expect(screen.getByText('潮雾列岛已发布版')).toBeTruthy();
expect(screen.getByText('CW-00000001')).toBeTruthy();
expect(screen.getByLabelText('游玩 12次')).toBeTruthy();
expect(screen.getByLabelText('改造 4次')).toBeTruthy();
expect(screen.getByLabelText('点赞 5赞')).toBeTruthy();
expect(screen.queryByText('Remix')).toBeNull();
expect(screen.queryByText('CW-00000001')).toBeNull();
});
test('creation hub shows delete action for persisted rpg drafts', () => {
@@ -225,7 +290,7 @@ test('creation hub opens persisted rpg drafts by card click', async () => {
expect(openedItems).toEqual([persistedDraft]);
});
test('creation hub work code copy button copies without opening the card', async () => {
test('creation hub published share button copies share text without opening the card', async () => {
const user = userEvent.setup();
const writeText = vi.fn(async () => undefined);
const onOpenPuzzleDetail = vi.fn();
@@ -251,6 +316,7 @@ test('creation hub work code copy button copies without opening the card', async
updatedAt: new Date('2026-04-22T12:00:00.000Z').toISOString(),
publishedAt: new Date('2026-04-22T12:10:00.000Z').toISOString(),
playCount: 8,
remixCount: 2,
likeCount: 0,
publishReady: true,
},
@@ -265,11 +331,19 @@ test('creation hub work code copy button copies without opening the card', async
/>,
);
await user.click(
screen.getByRole('button', { name: '复制作品号 PZ-PROFILE1' }),
);
await user.click(screen.getByRole('button', { name: '分享' }));
expect(writeText).toHaveBeenCalledWith('PZ-PROFILE1');
expect(writeText).toHaveBeenCalledWith(
expect.stringContaining('邀请你来玩《沉钟拼图》'),
);
expect(writeText).toHaveBeenCalledWith(
expect.stringContaining('作品号PZ-PROFILE1'),
);
expect(writeText).toHaveBeenCalledWith(
expect.stringContaining('/gallery/puzzle/detail?work=PZ-PROFILE1'),
);
expect(onOpenPuzzleDetail).not.toHaveBeenCalled();
expect(await screen.findByText('已复制')).toBeTruthy();
expect(
await screen.findByRole('button', { name: '分享内容已复制' }),
).toBeTruthy();
});

View File

@@ -42,12 +42,59 @@ test('creation hub draft card renders compiled work summary fields', () => {
expect(html).toContain('一个被潮雾切开的列岛世界');
expect(html).toContain('玩家是失职返乡的守灯人');
expect(html).toContain('守灯会与沉船商盟争夺航道解释权');
expect(html).toContain('角色扮演 RPG');
expect(html).toContain('拼图玩法');
expect(html).toContain('角色扮演');
expect(html).toContain('剧情演绎,冒险成长');
expect(html).toContain('拼图');
expect(html).toContain('创意礼物,生活分享');
expect(html).not.toContain('大鱼吃小鱼');
});
test('creation hub renders puzzle works in the same unified list with puzzle tag', () => {
const html = renderToStaticMarkup(
<CustomWorldCreationHub
items={[]}
puzzleItems={[
{
workId: 'puzzle:work-1',
profileId: 'puzzle-profile-1',
ownerUserId: 'user-1',
authorDisplayName: '测试作者',
levelName: '潮雾拼图',
summary: '一张被切成拼图的潮雾港口主视觉。',
themeTags: ['潮雾', '港口'],
coverImageSrc: null,
publicationStatus: 'published',
updatedAt: new Date('2026-04-22T10:00:00.000Z').toISOString(),
publishedAt: new Date('2026-04-22T10:05:00.000Z').toISOString(),
playCount: 12,
remixCount: 3,
likeCount: 4,
publishReady: true,
},
]}
loading={false}
error={null}
onRetry={() => {}}
onCreateType={noopCreateType}
onOpenDraft={() => {}}
onEnterPublished={() => {}}
onOpenPuzzleDetail={() => {}}
/>,
);
expect(html).toContain('潮雾拼图');
expect(html).toContain('拼图');
expect(html).toContain('aria-label="游玩 12次"');
expect(html).toContain('aria-label="改造 3次"');
expect(html).toContain('aria-label="点赞 4赞"');
expect(html).not.toContain('作品号');
expect(html).not.toContain('PZ-PROFILE1');
expect(html).not.toContain('潮雾</span>');
expect(html).not.toContain('港口</span>');
expect(html).not.toContain('我的拼图作品');
});
test('creation hub published work spans full mobile row', () => {
const html = renderToStaticMarkup(
<CustomWorldCreationHub
items={[]}
@@ -79,9 +126,7 @@ test('creation hub renders puzzle works in the same unified list with puzzle tag
/>,
);
expect(html).toContain('潮雾拼图');
expect(html).toContain('拼图');
expect(html).toContain('作品号');
expect(html).toContain('PZ-PROFILE1');
expect(html).not.toContain('我的拼图作品');
expect(html).toContain('grid-cols-2');
expect(html).toContain('col-span-2 sm:col-span-1');
expect(html).not.toContain('grid-cols-1 gap-3 md:grid-cols-2');
});

View File

@@ -1,21 +1,32 @@
import { useMemo, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldAgent';
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldAgent';
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime';
import type { CustomWorldProfile } from '../../types';
import type { PlatformCreationTypeId } from '../platform-entry/platformEntryCreationTypes';
import {
buildCreationWorkShelfItems,
type CreationWorkShelfItem,
type CreationWorkShelfMetricId,
} from './creationWorkShelf';
import { CustomWorldCreationStartCard } from './CustomWorldCreationStartCard';
import { CustomWorldWorkCard } from './CustomWorldWorkCard';
import {
type CustomWorldWorkFilter,
CustomWorldWorkTabs,
} from './CustomWorldWorkTabs';
import type { PlatformCreationTypeId } from '../platform-entry/platformEntryCreationTypes';
import {
buildCreationWorkShelfItems,
type CreationWorkShelfItem,
} from './creationWorkShelf';
// 中文注释:草稿在手机端保持双列,已发布卡片由卡片自身跨两列展示公开指标。
const WORK_GRID_CLASS =
'grid grid-cols-2 gap-2.5 sm:gap-3 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4';
const WORK_METRIC_CACHE_KEY = 'genarrative.creationHub.publishedMetrics.v1';
type WorkMetricSnapshot = Record<
string,
Partial<Record<CreationWorkShelfMetricId, number>>
>;
type CustomWorldCreationHubProps = {
items: CustomWorldWorkSummary[];
@@ -29,15 +40,12 @@ type CustomWorldCreationHubProps = {
onEnterPublished: (profileId: string) => void;
onDeletePublished?: ((item: CustomWorldWorkSummary) => void) | null;
deletingWorkId?: string | null;
onExperienceRpg?: ((item: CustomWorldWorkSummary) => void) | null;
rpgLibraryEntries?: CustomWorldLibraryEntry<CustomWorldProfile>[];
bigFishItems?: BigFishWorkSummary[];
onOpenBigFishDetail?: (item: BigFishWorkSummary) => void;
onExperienceBigFish?: ((item: BigFishWorkSummary) => void) | null;
onDeleteBigFish?: ((item: BigFishWorkSummary) => void) | null;
puzzleItems?: PuzzleWorkSummary[];
onOpenPuzzleDetail?: (item: PuzzleWorkSummary) => void;
onExperiencePuzzle?: ((profileId: string) => void) | null;
onDeletePuzzle?: ((item: PuzzleWorkSummary) => void) | null;
};
@@ -51,6 +59,59 @@ function EmptyState({ title }: { title: string }) {
);
}
function buildWorkMetricCacheItemKey(item: CreationWorkShelfItem) {
return `${item.kind}:${item.id}`;
}
function readWorkMetricSnapshot(): WorkMetricSnapshot {
if (typeof window === 'undefined') {
return {};
}
try {
const rawSnapshot = window.sessionStorage.getItem(WORK_METRIC_CACHE_KEY);
if (!rawSnapshot) {
return {};
}
const parsed = JSON.parse(rawSnapshot) as WorkMetricSnapshot;
return parsed && typeof parsed === 'object' ? parsed : {};
} catch {
return {};
}
}
function writeWorkMetricSnapshot(items: CreationWorkShelfItem[]) {
if (typeof window === 'undefined') {
return;
}
const snapshot: WorkMetricSnapshot = {};
for (const item of items) {
if (item.status !== 'published' || item.metrics.length === 0) {
continue;
}
snapshot[buildWorkMetricCacheItemKey(item)] = Object.fromEntries(
item.metrics.map((metric) => [metric.id, metric.value]),
);
}
// 中文注释:缓存只作为下一次进入创作页的数字动画起点,真实展示值仍以接口返回为准。
if (Object.keys(snapshot).length === 0) {
return;
}
try {
window.sessionStorage.setItem(
WORK_METRIC_CACHE_KEY,
JSON.stringify(snapshot),
);
} catch {
// 中文注释:浏览器禁用 sessionStorage 时降级为无缓存动画,不影响作品列表使用。
}
}
export function CustomWorldCreationHub({
items,
loading,
@@ -63,15 +124,12 @@ export function CustomWorldCreationHub({
onEnterPublished,
onDeletePublished = null,
deletingWorkId = null,
onExperienceRpg = null,
rpgLibraryEntries = [],
bigFishItems = [],
onOpenBigFishDetail,
onExperienceBigFish = null,
onDeleteBigFish = null,
puzzleItems = [],
onOpenPuzzleDetail,
onExperiencePuzzle = null,
onDeletePuzzle = null,
}: CustomWorldCreationHubProps) {
const [activeFilter, setActiveFilter] =
@@ -97,6 +155,12 @@ export function CustomWorldCreationHub({
rpgLibraryEntries,
],
);
const [metricSnapshot] = useState<WorkMetricSnapshot>(() =>
readWorkMetricSnapshot(),
);
useEffect(() => {
writeWorkMetricSnapshot(shelfItems);
}, [shelfItems]);
const draftCount = shelfItems.filter(
(entry) => entry.status === 'draft',
).length;
@@ -131,33 +195,6 @@ export function CustomWorldCreationHub({
}
}
function buildExperienceAction(item: CreationWorkShelfItem) {
if (!item.canExperience) {
return null;
}
switch (item.source.kind) {
case 'puzzle': {
const sourceItem = item.source.item;
return () => {
onExperiencePuzzle?.(sourceItem.profileId);
};
}
case 'big-fish': {
const sourceItem = item.source.item;
return () => {
onExperienceBigFish?.(sourceItem);
};
}
case 'rpg': {
const sourceItem = item.source.item;
return () => {
onExperienceRpg?.(sourceItem);
};
}
}
}
function buildDeleteAction(item: CreationWorkShelfItem) {
if (!item.canDelete) {
return null;
@@ -215,31 +252,33 @@ export function CustomWorldCreationHub({
) : null}
{loading ? (
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
<div className={WORK_GRID_CLASS}>
{Array.from({ length: 3 }).map((_, index) => (
<div
key={`skeleton-${index}`}
className="platform-subpanel min-h-[12rem] rounded-[1.6rem] p-5"
className="platform-subpanel min-h-[10.5rem] rounded-[1.2rem] p-3 sm:min-h-[12rem] sm:rounded-[1.6rem] sm:p-5"
>
<div className="h-4 w-20 rounded-full bg-[var(--platform-track-fill)]" />
<div className="mt-6 h-8 w-36 rounded-full bg-[var(--platform-track-fill)]" />
<div className="mt-4 h-4 w-full rounded-full bg-[var(--platform-track-fill)]" />
<div className="mt-5 h-6 w-24 rounded-full bg-[var(--platform-track-fill)] sm:mt-6 sm:h-8 sm:w-36" />
<div className="mt-3 h-3 w-full rounded-full bg-[var(--platform-track-fill)] sm:mt-4 sm:h-4" />
<div className="mt-2 h-4 w-4/5 rounded-full bg-[var(--platform-track-fill)]" />
<div className="mt-8 flex gap-2">
<div className="h-7 w-20 rounded-full bg-[var(--platform-track-fill)]" />
<div className="h-7 w-20 rounded-full bg-[var(--platform-track-fill)]" />
<div className="mt-6 flex flex-col gap-2 sm:mt-8 sm:flex-row">
<div className="h-6 w-16 rounded-full bg-[var(--platform-track-fill)] sm:h-7 sm:w-20" />
<div className="h-6 w-16 rounded-full bg-[var(--platform-track-fill)] sm:h-7 sm:w-20" />
</div>
</div>
))}
</div>
) : filteredItems.length > 0 ? (
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
<div className={WORK_GRID_CLASS}>
{filteredItems.map((item) => (
<CustomWorldWorkCard
key={`${item.kind}-${item.id}`}
item={item}
previousMetricValues={
metricSnapshot[buildWorkMetricCacheItemKey(item)]
}
onOpen={() => handleOpenShelfItem(item)}
onExperience={buildExperienceAction(item)}
onDelete={buildDeleteAction(item)}
deleteBusy={deletingWorkId === item.id}
/>

View File

@@ -37,7 +37,7 @@ export function CustomWorldCreationStartCard({
</span>
</div>
<div className="-mx-1 flex snap-x gap-2 overflow-x-auto px-1 pb-1 sm:mx-0 sm:grid sm:gap-3 sm:overflow-visible sm:px-0 sm:pb-0 sm:grid-cols-2 xl:grid-cols-5 xl:gap-2.5">
<div className="-mx-1 flex snap-x gap-2 overflow-x-auto px-1 pb-1 scrollbar-hide sm:mx-0 sm:grid sm:gap-3 sm:overflow-visible sm:px-0 sm:pb-0 sm:grid-cols-2 xl:grid-cols-5 xl:gap-2.5">
{visibleCreationTypes.map((item) => {
const disabled = item.locked || busy;
@@ -49,22 +49,18 @@ export function CustomWorldCreationStartCard({
onClick={() => {
onCreateType(item.id);
}}
className={`platform-interactive-card relative min-h-[4rem] w-[11.25rem] shrink-0 snap-start overflow-hidden rounded-[1.15rem] border px-3 py-2.5 text-left transition sm:min-h-[8.5rem] sm:w-auto sm:rounded-[1.5rem] sm:px-4 sm:py-4 xl:min-h-[6.4rem] xl:px-3.5 xl:py-3 ${
className={`platform-interactive-card relative flex min-h-[4rem] w-[11.25rem] shrink-0 snap-start flex-col overflow-hidden rounded-[1.15rem] border px-3 py-2.5 text-left transition sm:min-h-[8.5rem] sm:w-auto sm:rounded-[1.5rem] sm:px-4 sm:py-4 xl:min-h-[6.4rem] xl:px-3.5 xl:py-3 ${
item.locked
? 'cursor-not-allowed border-white/10 bg-white/8 text-zinc-300/70'
: 'border-white/18 bg-[radial-gradient(circle_at_top_left,rgba(255,255,255,0.24),transparent_36%),linear-gradient(135deg,rgba(255,255,255,0.18),rgba(255,255,255,0.08))] text-white'
} ${busy && !item.locked ? 'opacity-70' : ''}`}
>
<div className="flex items-center justify-between gap-2 sm:items-start sm:gap-3">
<span
className={`platform-pill px-2.5 text-xs sm:px-3 sm:text-sm ${
item.locked
? 'platform-pill--neutral text-[var(--platform-text-soft)]'
: 'platform-pill--neutral border-white/30 bg-white/18 text-white'
}`}
>
{item.locked ? item.badge : busy ? '正在开启' : item.badge}
</span>
<div className="flex min-h-5 items-center justify-end gap-2 sm:items-start sm:gap-3">
{item.locked ? (
<span className="platform-pill platform-pill--neutral px-2.5 text-xs text-[var(--platform-text-soft)] sm:px-3 sm:text-sm">
{item.badge}
</span>
) : null}
{item.locked ? (
<span className="text-base leading-none text-white/40">·</span>
) : (
@@ -72,15 +68,17 @@ export function CustomWorldCreationStartCard({
)}
</div>
<div className="mt-2.5 truncate text-base font-black leading-tight text-inherit sm:mt-7 sm:text-lg xl:mt-4 xl:text-base">
{item.title}
</div>
<div
className={`mt-1 truncate text-xs sm:mt-2 sm:text-sm xl:mt-1 xl:text-xs ${
item.locked ? 'text-zinc-400' : 'text-zinc-200/82'
}`}
>
{item.subtitle}
<div className="mt-auto pt-1.5 sm:pt-4 xl:pt-2">
<div className="truncate text-base font-black leading-tight text-inherit sm:text-lg xl:text-base">
{item.title}
</div>
<div
className={`mt-1 truncate text-xs sm:mt-2 sm:text-sm xl:mt-1 xl:text-xs ${
item.locked ? 'text-zinc-400' : 'text-zinc-200/82'
}`}
>
{item.subtitle}
</div>
</div>
</button>
);

View File

@@ -1,64 +1,240 @@
import { Copy } from 'lucide-react';
import { useState } from 'react';
import { Share2, Trash2 } from 'lucide-react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { copyTextToClipboard } from '../../services/clipboard';
import { CustomWorldCoverArtwork } from '../CustomWorldCoverArtwork';
import type { CreationWorkShelfItem } from './creationWorkShelf';
function formatUpdatedAt(value: string) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return '最近更新';
}
return new Intl.DateTimeFormat('zh-CN', {
month: 'numeric',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
}).format(date);
}
import {
formatPlatformWorkDisplayName,
formatPlatformWorkDisplayTag,
} from '../rpg-entry/rpgEntryWorldPresentation';
import {
type CreationWorkShelfBadgeTone,
type CreationWorkShelfItem,
type CreationWorkShelfMetric,
type CreationWorkShelfMetricId,
formatCreationMetricCount,
} from './creationWorkShelf';
type CustomWorldWorkCardProps = {
item: CreationWorkShelfItem;
previousMetricValues?: Partial<Record<CreationWorkShelfMetricId, number>>;
onOpen: () => void;
onExperience?: (() => void) | null;
onDelete?: (() => void) | null;
deleteBusy?: boolean;
};
const BADGE_TONE_CLASS: Record<
CreationWorkShelfItem['badges'][number]['tone'],
string
> = {
const BADGE_TONE_CLASS: Record<CreationWorkShelfBadgeTone, string> = {
warm: 'platform-pill--warm',
success: 'platform-pill--success',
neutral: 'platform-pill--neutral',
};
export function CustomWorldWorkCard({
item,
onOpen,
onExperience = null,
onDelete = null,
deleteBusy = false,
}: CustomWorldWorkCardProps) {
const [copyState, setCopyState] = useState<'idle' | 'copied' | 'failed'>(
'idle',
const METRIC_ANIMATION_DURATION_MS = 820;
const EMPTY_PUBLISHED_METRICS: CreationWorkShelfMetric[] = [];
function easeOutCubic(progress: number) {
return 1 - (1 - progress) ** 3;
}
function resolveMetricStartValue(
metric: CreationWorkShelfMetric,
previousMetricValues?: Partial<Record<CreationWorkShelfMetricId, number>>,
) {
const previousValue = previousMetricValues?.[metric.id];
if (previousValue === undefined || previousValue >= metric.value) {
return metric.value;
}
return Math.max(0, Math.floor(previousValue));
}
function buildMetricValueMap(
metrics: CreationWorkShelfMetric[],
resolveValue: (metric: CreationWorkShelfMetric) => number,
) {
return Object.fromEntries(
metrics.map((metric) => [metric.id, resolveValue(metric)]),
) as Record<CreationWorkShelfMetricId, number>;
}
function shouldAnimatePublishedMetrics() {
if (typeof window === 'undefined') {
return false;
}
return !window.navigator.userAgent.toLowerCase().includes('jsdom');
}
function usePublishedMetricAnimation(
metrics: CreationWorkShelfMetric[],
previousMetricValues?: Partial<Record<CreationWorkShelfMetricId, number>>,
) {
const cardRef = useRef<HTMLDivElement | null>(null);
const [hasEnteredView, setHasEnteredView] = useState(false);
const startValues = useMemo(
() =>
buildMetricValueMap(metrics, (metric) =>
resolveMetricStartValue(metric, previousMetricValues),
),
[metrics, previousMetricValues],
);
const copyPublicWorkCode = () => {
if (!item.publicWorkCode) {
const endValues = useMemo(
() => buildMetricValueMap(metrics, (metric) => metric.value),
[metrics],
);
const deltas = useMemo(
() =>
buildMetricValueMap(metrics, (metric) =>
Math.max(0, metric.value - startValues[metric.id]),
),
[metrics, startValues],
);
const hasGrowth = useMemo(
() => Object.values(deltas).some((delta) => delta > 0),
[deltas],
);
const [displayValues, setDisplayValues] = useState(endValues);
const [showGrowth, setShowGrowth] = useState(false);
useEffect(() => {
setShowGrowth(false);
setHasEnteredView(false);
setDisplayValues(hasGrowth ? startValues : endValues);
}, [endValues, hasGrowth, startValues]);
useEffect(() => {
const element = cardRef.current;
if (!element || !hasGrowth) {
setHasEnteredView(true);
return;
}
void copyTextToClipboard(item.publicWorkCode).then((copied) => {
setCopyState(copied ? 'copied' : 'failed');
window.setTimeout(() => setCopyState('idle'), 1400);
if (typeof window === 'undefined' || !('IntersectionObserver' in window)) {
setHasEnteredView(true);
return;
}
// 中文注释:指标增长只在卡片进入视口后启动,避免列表刷新时离屏卡片提前播放。
const observer = new IntersectionObserver(
(entries) => {
if (entries.some((entry) => entry.isIntersecting)) {
setHasEnteredView(true);
observer.disconnect();
}
},
{ rootMargin: '0px 0px -10% 0px', threshold: 0.28 },
);
observer.observe(element);
return () => observer.disconnect();
}, [hasGrowth]);
useEffect(() => {
if (!hasEnteredView) {
return;
}
if (!hasGrowth || !shouldAnimatePublishedMetrics()) {
setDisplayValues(endValues);
if (hasGrowth) {
setShowGrowth(true);
}
return;
}
if (typeof window === 'undefined') {
setDisplayValues(endValues);
setShowGrowth(true);
return;
}
let animationFrameId = 0;
const startTime = window.performance.now();
const tick = (now: number) => {
const progress = Math.min(
1,
(now - startTime) / METRIC_ANIMATION_DURATION_MS,
);
const easedProgress = easeOutCubic(progress);
setDisplayValues(
buildMetricValueMap(metrics, (metric) => {
const startValue = startValues[metric.id];
const endValue = endValues[metric.id];
return Math.round(
startValue + (endValue - startValue) * easedProgress,
);
}),
);
if (progress < 1) {
animationFrameId = window.requestAnimationFrame(tick);
return;
}
setDisplayValues(endValues);
setShowGrowth(true);
};
animationFrameId = window.requestAnimationFrame(tick);
return () => {
window.cancelAnimationFrame(animationFrameId);
};
}, [endValues, hasEnteredView, hasGrowth, metrics, startValues]);
return { cardRef, deltas, displayValues, showGrowth };
}
export function CustomWorldWorkCard({
item,
previousMetricValues,
onOpen,
onDelete = null,
deleteBusy = false,
}: CustomWorldWorkCardProps) {
const [shareState, setShareState] = useState<'idle' | 'copied' | 'failed'>(
'idle',
);
const shareResetTimerRef = useRef<number | null>(null);
const isPublished = item.status === 'published';
const displayTitle = formatPlatformWorkDisplayName(item.title);
const { cardRef, deltas, displayValues, showGrowth } =
usePublishedMetricAnimation(
isPublished ? item.metrics : EMPTY_PUBLISHED_METRICS,
previousMetricValues,
);
const copyShareText = () => {
const publicWorkCode = item.publicWorkCode?.trim();
const sharePath = item.sharePath?.trim();
if (!publicWorkCode || !sharePath) {
return;
}
const shareUrl =
typeof window === 'undefined'
? sharePath
: new URL(sharePath, window.location.origin).href;
const shareText = `邀请你来玩《${item.title}\n作品号${publicWorkCode}\n${shareUrl}`;
void copyTextToClipboard(shareText).then((copied) => {
setShareState(copied ? 'copied' : 'failed');
if (shareResetTimerRef.current !== null) {
window.clearTimeout(shareResetTimerRef.current);
}
shareResetTimerRef.current = window.setTimeout(() => {
shareResetTimerRef.current = null;
setShareState('idle');
}, 1400);
});
};
useEffect(
() => () => {
if (shareResetTimerRef.current !== null) {
window.clearTimeout(shareResetTimerRef.current);
}
},
[],
);
return (
<div
ref={cardRef}
role="button"
tabIndex={0}
aria-label={`${item.openActionLabel}${item.title}`}
@@ -71,7 +247,7 @@ export function CustomWorldWorkCard({
event.preventDefault();
onOpen();
}}
className="platform-surface platform-interactive-card relative min-h-[16rem] cursor-pointer overflow-hidden px-4 py-4 text-left sm:min-h-[15.5rem] xl:min-h-[14rem] xl:px-4 xl:py-3.5"
className={`platform-surface platform-interactive-card relative min-h-[9.5rem] cursor-pointer overflow-hidden px-2.5 py-2.5 text-left sm:min-h-[12.5rem] sm:px-4 sm:py-4 xl:min-h-[11.25rem] xl:px-4 xl:py-3.5 ${isPublished ? 'col-span-2 sm:col-span-1' : ''}`}
>
<CustomWorldCoverArtwork
imageSrc={item.coverImageSrc}
@@ -79,126 +255,127 @@ export function CustomWorldWorkCard({
fallbackLabel="封面"
renderMode={item.coverRenderMode}
characterImageSrcs={item.coverCharacterImageSrcs}
className="platform-cover-artwork absolute inset-0"
className="platform-cover-artwork absolute inset-0 opacity-70 saturate-[1.08]"
/>
<div className="absolute inset-0 bg-[var(--platform-card-overlay-strong)]" />
<div className="pointer-events-none relative z-20 flex min-h-[14rem] flex-col sm:min-h-[13.5rem] xl:min-h-[12.5rem]">
<div className="flex items-start justify-between gap-3">
<div className="flex flex-wrap gap-2">
<div className="absolute inset-0 bg-[radial-gradient(circle_at_100%_100%,rgba(255,255,255,0.18),transparent_34%),linear-gradient(180deg,rgba(255,255,255,0.08),rgba(0,0,0,0.08))]" />
<div className="pointer-events-none relative z-20 flex min-h-[8rem] flex-col sm:min-h-[10.5rem] xl:min-h-[9.75rem]">
{!isPublished && onDelete ? (
<button
type="button"
onClick={(event) => {
event.stopPropagation();
onDelete();
}}
onKeyDown={(event) => {
event.stopPropagation();
}}
disabled={deleteBusy}
aria-label={deleteBusy ? '删除中' : '删除'}
title={deleteBusy ? '删除中' : '删除作品'}
className="pointer-events-auto absolute right-0 top-0 z-30 grid h-7 w-7 place-items-center text-[var(--platform-text-soft)] transition hover:text-[var(--platform-button-danger-text)] disabled:cursor-not-allowed disabled:opacity-55 sm:h-8 sm:w-8"
>
{deleteBusy ? (
<span className="text-xs leading-none"></span>
) : (
<Trash2 aria-hidden="true" className="h-3.5 w-3.5" />
)}
</button>
) : null}
{isPublished ? (
<button
type="button"
onClick={(event) => {
event.stopPropagation();
copyShareText();
}}
onKeyDown={(event) => {
event.stopPropagation();
}}
disabled={!item.canShare || !item.sharePath}
title={
!item.canShare || !item.sharePath
? '暂不可分享'
: shareState === 'copied'
? '已复制'
: shareState === 'failed'
? '复制失败'
: '分享作品'
}
aria-label={
!item.canShare || !item.sharePath
? '暂不可分享'
: shareState === 'copied'
? '分享内容已复制'
: shareState === 'failed'
? '分享内容复制失败'
: '分享'
}
className="pointer-events-auto absolute right-0 top-0 z-30 inline-flex h-7 min-w-7 items-center justify-center gap-1 whitespace-nowrap px-1.5 text-[var(--platform-text-soft)] transition hover:text-[var(--platform-cool-text)] disabled:cursor-not-allowed disabled:opacity-55 sm:h-8 sm:min-w-8"
>
{shareState === 'idle' ? (
<Share2 aria-hidden="true" className="h-3.5 w-3.5" />
) : (
<span className="text-[10px] font-semibold leading-none">
{shareState === 'copied' ? '已复制' : '复制失败'}
</span>
)}
</button>
) : null}
<div className="flex items-start justify-between gap-2 pr-12 sm:gap-3 sm:pr-14">
<div className="flex max-h-[3rem] min-w-0 flex-wrap gap-1 overflow-hidden sm:max-h-none sm:gap-2">
{item.badges.map((badge) => (
<span
key={`${item.id}-${badge.id}`}
className={`platform-pill ${BADGE_TONE_CLASS[badge.tone]} px-3 py-1 text-[10px]`}
className={`platform-pill ${BADGE_TONE_CLASS[badge.tone]} max-w-full truncate px-2 py-0.5 text-[9px] sm:px-3 sm:py-1 sm:text-[10px]`}
>
{badge.label}
{formatPlatformWorkDisplayTag(badge.label)}
</span>
))}
</div>
<div className="flex shrink-0 items-center gap-2">
<span className="text-[11px] text-[var(--platform-text-soft)]">
{formatUpdatedAt(item.updatedAt)}
</span>
{onDelete ? (
<button
type="button"
onClick={(event) => {
event.stopPropagation();
onDelete();
}}
disabled={deleteBusy}
aria-label={deleteBusy ? '删除中' : '删除'}
title={deleteBusy ? '删除中' : '删除作品'}
className="pointer-events-auto relative z-30 grid h-7 w-7 place-items-center text-[var(--platform-text-soft)] transition hover:text-[var(--platform-danger)] disabled:cursor-not-allowed disabled:opacity-55"
>
{deleteBusy ? (
<span className="text-xs leading-none"></span>
) : (
<svg
aria-hidden="true"
viewBox="0 0 24 24"
className="h-3.5 w-3.5"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
>
<path d="M3 6h18" />
<path d="M8 6V4h8v2" />
<path d="M19 6l-1 14H6L5 6" />
<path d="M10 11v5" />
<path d="M14 11v5" />
</svg>
)}
</button>
) : null}
</div>
</div>
<div className="mt-4 min-h-0 xl:mt-3">
<div className="text-2xl font-black text-[var(--platform-text-strong)] xl:text-xl">
{item.title}
<div className="mt-3 min-h-0 sm:mt-4 xl:mt-3">
<div className="line-clamp-1 break-words text-base font-black leading-tight text-[var(--platform-text-strong)] sm:text-2xl xl:text-xl">
{displayTitle}
</div>
<div className="mt-1 text-xs tracking-[0.12em] text-[var(--platform-text-soft)]">
{item.subtitle}
</div>
<div className="mt-3 line-clamp-2 text-sm leading-6 text-[color:color-mix(in_srgb,var(--platform-text-base)_92%,transparent)] xl:mt-2">
<div className="mt-2 line-clamp-2 break-words text-[11px] leading-4 text-[color:color-mix(in_srgb,var(--platform-text-base)_92%,transparent)] sm:mt-3 sm:text-sm sm:leading-6 xl:mt-2">
{item.summary}
</div>
</div>
<div className="mt-auto flex flex-col gap-3 pt-4 sm:flex-row sm:items-end sm:justify-between xl:gap-2 xl:pt-3">
<div className="min-w-0 space-y-2">
{item.publicWorkCode ? (
<button
type="button"
onClick={(event) => {
event.stopPropagation();
copyPublicWorkCode();
}}
onKeyDown={(event) => {
event.stopPropagation();
}}
className="platform-pill platform-pill--neutral pointer-events-auto relative z-30 inline-flex max-w-full items-center gap-1.5 px-3 py-1 text-[10px]"
aria-label={`复制作品号 ${item.publicWorkCode}`}
title="复制作品号"
{isPublished ? (
<div className="mt-auto grid grid-cols-3 gap-1.5 pt-3 sm:gap-2 sm:pt-4 xl:pt-3">
{item.metrics.map((metric) => (
<div
key={`${item.id}-${metric.id}`}
aria-label={`${metric.label} ${displayValues[metric.id] ?? metric.value}${metric.unit}`}
className={`creation-work-card-stat creation-work-card-stat--${metric.tone}`}
>
<span className="shrink-0"></span>
<span className="min-w-0 truncate">{item.publicWorkCode}</span>
<Copy className="h-3 w-3 shrink-0" />
{copyState !== 'idle' ? (
<span className="shrink-0">
{copyState === 'copied' ? '已复制' : '复制失败'}
</span>
) : null}
</button>
) : null}
<div className="flex flex-wrap gap-2">
{item.metrics.map((metric) => (
<span
key={`${item.id}-${metric.id}`}
className={`platform-pill ${BADGE_TONE_CLASS[metric.tone ?? 'neutral']} px-3 py-1 text-[10px]`}
>
<span className="creation-work-card-stat__label">
{metric.label}
</span>
))}
</div>
<span className="creation-work-card-stat__value">
<span className="creation-work-card-stat__number">
{formatCreationMetricCount(
displayValues[metric.id] ?? metric.value,
)}
</span>
<span className="creation-work-card-stat__unit">
{metric.unit}
</span>
</span>
{showGrowth && deltas[metric.id] > 0 ? (
<span className="creation-work-card-stat__growth">
<span aria-hidden="true"></span>
{formatCreationMetricCount(deltas[metric.id])}
</span>
) : null}
</div>
))}
</div>
<div className="flex flex-wrap gap-2 sm:justify-end xl:gap-1.5">
{onExperience ? (
<button
type="button"
onClick={(event) => {
event.stopPropagation();
onExperience();
}}
className="platform-button platform-button--secondary pointer-events-auto relative z-30 min-h-0 shrink-0 rounded-full px-4 py-2 text-sm xl:px-3.5 xl:py-1.5 xl:text-xs"
>
</button>
) : null}
</div>
</div>
) : null}
</div>
</div>
);

View File

@@ -2,7 +2,11 @@ import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/
import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldAgent';
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime';
import { buildPuzzlePublicWorkCode } from '../../services/publicWorkCode';
import { buildPublicWorkStagePath } from '../../routing/appPageRoutes';
import {
buildBigFishPublicWorkCode,
buildPuzzlePublicWorkCode,
} from '../../services/publicWorkCode';
import type { CustomWorldProfile } from '../../types';
export type CreationWorkShelfKind = 'rpg' | 'big-fish' | 'puzzle';
@@ -16,10 +20,19 @@ export type CreationWorkShelfBadge = {
tone: CreationWorkShelfBadgeTone;
};
export type CreationWorkShelfMetricId =
| 'play-count'
| 'remix-count'
| 'like-count';
export type CreationWorkShelfMetricTone = 'play' | 'remix' | 'like';
export type CreationWorkShelfMetric = {
id: string;
id: CreationWorkShelfMetricId;
label: string;
tone?: CreationWorkShelfBadgeTone;
value: number;
unit: string;
tone: CreationWorkShelfMetricTone;
};
export type CreationWorkShelfSource =
@@ -41,17 +54,16 @@ export type CreationWorkShelfItem = {
kind: CreationWorkShelfKind;
status: CreationWorkShelfStatus;
title: string;
subtitle: string;
summary: string;
updatedAt: string;
coverImageSrc: string | null;
coverRenderMode: 'image' | 'scene_with_roles';
coverCharacterImageSrcs: string[];
publicWorkCode: string | null;
typeLabel: string;
sharePath: string | null;
openActionLabel: string;
canExperience: boolean;
canDelete: boolean;
canShare: boolean;
badges: CreationWorkShelfBadge[];
metrics: CreationWorkShelfMetric[];
source: CreationWorkShelfSource;
@@ -101,67 +113,43 @@ function mapRpgWorkToShelfItem(
const libraryEntry = item.profileId
? libraryEntries.find((entry) => entry.profileId === item.profileId)
: null;
const publicWorkCode =
item.status === 'published' ? (libraryEntry?.publicWorkCode ?? null) : null;
const badges: CreationWorkShelfBadge[] = [
buildStatusBadge(item.status),
{ id: 'type', label: 'RPG', tone: 'neutral' },
];
if (item.stageLabel) {
badges.push({ id: 'stage', label: item.stageLabel, tone: 'neutral' });
}
const metrics: CreationWorkShelfMetric[] = [
{
id: 'playable-npc-count',
label: `${isDraft ? '角色' : '可扮演角色'} ${item.playableNpcCount}`,
},
{ id: 'landmark-count', label: `地点 ${item.landmarkCount}` },
];
if (item.roleVisualReadyCount) {
metrics.push({
id: 'role-visual-ready-count',
label: `主图 ${item.roleVisualReadyCount}`,
tone: 'warm',
});
}
if (item.roleAnimationReadyCount) {
metrics.push({
id: 'role-animation-ready-count',
label: `动作 ${item.roleAnimationReadyCount}`,
tone: 'success',
});
}
if (item.roleAssetSummaryLabel) {
metrics.push({
id: 'role-asset-summary',
label: item.roleAssetSummaryLabel,
});
}
const metrics = buildPublishedMetrics({
playCount: libraryEntry?.playCount,
remixCount: libraryEntry?.remixCount,
likeCount: libraryEntry?.likeCount,
});
return {
id: item.workId,
kind: 'rpg',
status: item.status,
title: item.title,
subtitle: item.subtitle,
summary: item.summary,
updatedAt: item.updatedAt,
coverImageSrc: item.coverImageSrc ?? null,
coverRenderMode: item.coverRenderMode ?? 'image',
coverCharacterImageSrcs: item.coverCharacterImageSrcs ?? [],
publicWorkCode:
item.status === 'published'
? (libraryEntry?.publicWorkCode ?? null)
publicWorkCode,
sharePath:
publicWorkCode && item.status === 'published'
? buildPublicWorkStagePath('work-detail', publicWorkCode)
: null,
typeLabel: 'RPG',
openActionLabel: isDraft
? item.playableNpcCount > 0 || item.landmarkCount > 0
? '继续完善'
: '继续创作'
: '查看详情',
canExperience: item.status === 'published' && item.canEnterWorld,
canDelete,
canShare: item.status === 'published' && Boolean(publicWorkCode),
badges,
metrics,
metrics: isDraft ? [] : metrics,
source: { kind: 'rpg', item },
};
}
@@ -170,47 +158,40 @@ function mapBigFishWorkToShelfItem(
item: BigFishWorkSummary,
canDelete: boolean,
): CreationWorkShelfItem {
const isPublished = item.status === 'published';
const publicWorkCode = isPublished
? buildBigFishPublicWorkCode(item.sourceSessionId)
: null;
return {
id: item.workId,
kind: 'big-fish',
status: item.status,
title: item.title,
subtitle: item.subtitle,
summary: item.summary,
updatedAt: item.updatedAt,
coverImageSrc: item.coverImageSrc ?? null,
coverRenderMode: 'image',
coverCharacterImageSrcs: [],
publicWorkCode: null,
typeLabel: '大鱼',
publicWorkCode,
sharePath:
publicWorkCode && isPublished
? buildPublicWorkStagePath('big-fish-runtime', publicWorkCode)
: null,
openActionLabel: item.status === 'draft' ? '继续创作' : '查看详情',
canExperience: item.status === 'published',
canDelete,
canShare: isPublished && Boolean(publicWorkCode),
badges: [
buildStatusBadge(item.status),
{ id: 'type', label: '大鱼', tone: 'neutral' },
],
metrics: [
{ id: 'level-count', label: `关卡 ${item.levelCount}` },
{
id: 'level-main-image-ready-count',
label: `主图 ${item.levelMainImageReadyCount}`,
},
{
id: 'level-motion-ready-count',
label: `动作 ${item.levelMotionReadyCount}`,
},
{ id: 'play-count', label: `游玩 ${item.playCount ?? 0}` },
...(item.backgroundReady
? [
{
id: 'background-ready',
label: '背景已就绪',
tone: 'success' as const,
},
]
: []),
],
metrics: isPublished
? buildPublishedMetrics({
playCount: item.playCount,
remixCount: item.remixCount,
likeCount: item.likeCount,
})
: [],
source: { kind: 'big-fish', item },
};
}
@@ -220,42 +201,88 @@ function mapPuzzleWorkToShelfItem(
canDelete: boolean,
): CreationWorkShelfItem {
const status = item.publicationStatus;
const publicWorkCode =
status === 'published' ? buildPuzzlePublicWorkCode(item.profileId) : null;
return {
id: item.workId,
kind: 'puzzle',
status,
title: item.levelName,
subtitle: item.authorDisplayName,
summary: item.summary,
updatedAt: item.updatedAt,
coverImageSrc: item.coverImageSrc ?? null,
coverRenderMode: 'image',
coverCharacterImageSrcs: [],
publicWorkCode:
status === 'published' ? buildPuzzlePublicWorkCode(item.profileId) : null,
typeLabel: '拼图',
publicWorkCode,
sharePath:
publicWorkCode && status === 'published'
? buildPublicWorkStagePath('puzzle-gallery-detail', publicWorkCode)
: null,
openActionLabel:
status === 'published' && !item.sourceSessionId ? '查看详情' : '继续创作',
canExperience: status === 'published',
canDelete,
canShare: status === 'published' && Boolean(publicWorkCode),
badges: [
buildStatusBadge(status),
{ id: 'type', label: '拼图', tone: 'neutral' },
...item.themeTags.slice(0, 2).map((tag) => ({
id: `tag:${tag}`,
label: tag,
tone: 'neutral' as const,
})),
],
metrics: [
{ id: 'author', label: `作者 ${item.authorDisplayName}` },
{ id: 'play-count', label: `游玩 ${item.playCount}` },
],
metrics:
status === 'published'
? buildPublishedMetrics({
playCount: item.playCount,
remixCount: item.remixCount,
likeCount: item.likeCount,
})
: [],
source: { kind: 'puzzle', item },
};
}
function buildPublishedMetrics(params: {
playCount?: number | null;
remixCount?: number | null;
likeCount?: number | null;
}): CreationWorkShelfMetric[] {
return [
{
id: 'play-count',
label: '游玩',
value: normalizeMetricCount(params.playCount),
unit: '次',
tone: 'play',
},
{
id: 'remix-count',
label: '改造',
value: normalizeMetricCount(params.remixCount),
unit: '次',
tone: 'remix',
},
{
id: 'like-count',
label: '点赞',
value: normalizeMetricCount(params.likeCount),
unit: '赞',
tone: 'like',
},
];
}
export function normalizeMetricCount(value?: number | null) {
return Math.max(0, Math.floor(value ?? 0));
}
export function formatCreationMetricCount(value?: number | null) {
const normalized = Math.max(0, Math.floor(value ?? 0));
if (normalized >= 10000) {
const wanValue = normalized / 10000;
return `${Number.isInteger(wanValue) ? wanValue.toFixed(0) : wanValue.toFixed(1)}`;
}
return `${normalized}`;
}
function buildStatusBadge(
status: CreationWorkShelfStatus,
): CreationWorkShelfBadge {