Update Match3D/image-generation docs & code

Adds/updates documentation, assets and implementation for Match3D and puzzle image generation workflows. Key changes: decision logs and pitfalls updated to prefer VectorEngine Gemini for Match3D material sheets and to require edits (multipart) for 1:1 container reference images; guidance added for when to use APIMart vs VectorEngine. .env.example clarified APIMart/Responses config. Many new public assets and PPT visuals added. Code changes across frontend and backend: updated shared contracts, server-rs match3d/puzzle/image-generation handlers, VectorEngine/OpenAI image generation clients, and multiple React components/tests to handle UI/background/container image signing, edits workflow, and puzzle UI background resolution. Added src/services/puzzle-runtime/puzzleUiBackgroundSource.ts and related test updates. Includes notes about multipart HTTP/1.1 requirement and test/verification commands in docs.
This commit is contained in:
2026-05-14 20:34:45 +08:00
parent d33c937ebc
commit 548db78ca7
103 changed files with 6687 additions and 3270 deletions

View File

@@ -6,8 +6,8 @@ import { afterEach, expect, test, vi } from 'vitest';
import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldAgent';
import type { SquareHoleWorkSummary } from '../../../packages/shared/src/contracts/squareHoleWorks';
import { derivePlatformCreationTypes } from '../platform-entry/platformEntryCreationTypes';
import type { CreationEntryConfig } from '../../services/creationEntryConfigService';
import { derivePlatformCreationTypes } from '../platform-entry/platformEntryCreationTypes';
import { CustomWorldCreationHub } from './CustomWorldCreationHub';
const noopCreateType = () => {};
@@ -61,10 +61,10 @@ const testEntryConfig = {
id: 'visual-novel',
title: '视觉小说',
subtitle: '分支叙事体验',
badge: '可创建',
badge: '敬请期待',
imageSrc: '/creation-type-references/visual-novel.webp',
visible: true,
open: true,
visible: false,
open: false,
sortOrder: 60,
updatedAtMicros: 1,
},
@@ -443,7 +443,28 @@ test('creation hub shows RPG public work code from published library entry', ()
expect(screen.queryByText('CW-00000001')).toBeNull();
});
test('creation hub shows delete action for persisted rpg drafts', () => {
test('creation hub hides persisted draft delete action behind swipe underlay', () => {
const { container } = render(
<CustomWorldCreationHub
items={[{ ...baseDraftItem, profileId: 'profile-1' }]}
loading={false}
error={null}
onRetry={() => {}}
onCreateType={noopCreateType}
onOpenDraft={() => {}}
onEnterPublished={() => {}}
onDeletePublished={() => {}}
entryConfig={testEntryConfig}
creationTypes={testCreationTypes}
/>,
);
expect(container.querySelector('.creation-work-card__swipe-underlay')).toBeTruthy();
expect(screen.queryByRole('button', { name: '删除' })).toBeNull();
});
test('creation hub reveals persisted draft delete action from keyboard', async () => {
const user = userEvent.setup();
render(
<CustomWorldCreationHub
items={[{ ...baseDraftItem, profileId: 'profile-1' }]}
@@ -459,10 +480,13 @@ test('creation hub shows delete action for persisted rpg drafts', () => {
/>,
);
screen.getByRole('button', { name: //u }).focus();
await user.keyboard('{ArrowLeft}');
expect(screen.getByRole('button', { name: '删除' })).toBeTruthy();
});
test('creation hub published work delete action is available beside share without opening card', async () => {
test('creation hub published work delete action is revealed without opening card', async () => {
const user = userEvent.setup();
const onDeletePuzzle = vi.fn();
const onOpenPuzzleDetail = vi.fn();
@@ -502,6 +526,12 @@ test('creation hub published work delete action is available beside share withou
/>,
);
expect(screen.queryByRole('button', { name: '删除' })).toBeNull();
expect(screen.queryByRole('button', { name: '分享' })).toBeNull();
screen.getByRole('button', { name: //u }).focus();
await user.keyboard('{ArrowLeft}');
expect(screen.getByRole('button', { name: '删除' })).toBeTruthy();
expect(screen.getByRole('button', { name: '分享' })).toBeTruthy();
@@ -548,7 +578,7 @@ test('creation hub opens persisted rpg drafts by card click', async () => {
expect(openedItems).toEqual([persistedDraft]);
});
test('creation hub published share button copies share text without opening the card', async () => {
test('creation hub published swipe share button copies share text without opening the card', async () => {
const user = userEvent.setup();
const writeText = vi.fn(async () => undefined);
const onOpenPuzzleDetail = vi.fn();
@@ -591,6 +621,8 @@ test('creation hub published share button copies share text without opening the
/>,
);
screen.getByRole('button', { name: //u }).focus();
await user.keyboard('{ArrowLeft}');
await user.click(screen.getByRole('button', { name: '分享' }));
expect(writeText).toHaveBeenCalledWith(

View File

@@ -56,10 +56,10 @@ const testEntryConfig = {
id: 'visual-novel',
title: '视觉小说',
subtitle: '分支叙事体验',
badge: '可创建',
badge: '敬请期待',
imageSrc: '/creation-type-references/visual-novel.webp',
visible: true,
open: true,
visible: false,
open: false,
sortOrder: 60,
updatedAtMicros: 1,
},
@@ -217,9 +217,11 @@ test('creation hub marks generating and newly completed drafts', () => {
expect(html).toContain('生成中');
expect(html).toContain('aria-label="新生成完成"');
expect(html).toContain('生成中...');
expect(html).toContain('creation-work-card__spinner');
});
test('creation hub published work spans full mobile row', () => {
test('creation hub published work uses unified list card layout', () => {
const html = renderToStaticMarkup(
<CustomWorldCreationHub
items={[]}
@@ -253,9 +255,10 @@ test('creation hub published work spans full mobile row', () => {
/>,
);
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');
expect(html).toContain('creation-work-list');
expect(html).toContain('platform-category-game-item');
expect(html).toContain('creation-work-card__side-cover');
expect(html).not.toContain('col-span-2 sm:col-span-1');
});
test('creation hub draft cards use cover background and hide updated time', () => {
@@ -318,9 +321,109 @@ test('creation hub draft cards use cover background and hide updated time', () =
expect(html).toContain(
'class="absolute inset-0 h-full w-full object-cover" src="/covers/new-draft.webp"',
);
expect(html).toContain('creation-work-card__side-cover');
expect(html).toContain('src="/covers/new-draft.webp"');
expect(html).toContain(
'--creation-work-card-cover-fallback:url(/creation-type-references/puzzle.webp)',
);
expect(html).not.toContain('1778457601.234567Z');
expect(html).not.toContain('2026-05-07');
expect(html).not.toContain('更新于');
expect(html).not.toContain('最后修改');
});
test('creation hub draft cards fall back to creation type cover when cover is missing', () => {
const html = renderToStaticMarkup(
<CustomWorldCreationHub
mode="works-only"
items={[]}
puzzleItems={[
{
workId: 'puzzle:no-cover-draft',
profileId: 'puzzle-profile-no-cover',
ownerUserId: 'user-1',
authorDisplayName: '测试作者',
workTitle: '缺少封面的拼图草稿',
workDescription: '没有生成封面时也需要保留图像背景。',
levelName: '缺少封面的拼图草稿',
summary: '没有生成封面时也需要保留图像背景。',
themeTags: [],
coverImageSrc: null,
publicationStatus: 'draft',
updatedAt: '2026-05-07T00:00:00.000Z',
publishedAt: null,
publishReady: false,
},
]}
loading={false}
error={null}
onRetry={() => {}}
onCreateType={noopCreateType}
onOpenDraft={() => {}}
onEnterPublished={() => {}}
entryConfig={testEntryConfig}
creationTypes={testCreationTypes}
onOpenPuzzleDetail={() => {}}
/>,
);
expect(html).toContain('缺少封面的拼图草稿');
expect(html).toContain(
'class="absolute inset-0 h-full w-full object-cover" src="/creation-type-references/puzzle.webp"',
);
expect(html).toContain(
'--creation-work-card-cover-fallback:url(/creation-type-references/puzzle.webp)',
);
expect(html).not.toContain('>封面</div>');
});
test('creation hub published card keeps publish info without fixed action text', () => {
const html = renderToStaticMarkup(
<CustomWorldCreationHub
mode="works-only"
items={[]}
puzzleItems={[
{
workId: 'puzzle:published-card',
profileId: 'puzzle-profile-published',
ownerUserId: 'user-1',
authorDisplayName: '测试作者',
workTitle: '统一卡片作品',
workDescription: '作品卡仍要保留原有的积分与统计信息。',
levelName: '统一卡片作品',
summary: '作品卡仍要保留原有的积分与统计信息。',
themeTags: ['潮雾'],
coverImageSrc: '/covers/unified-card.webp',
publicationStatus: 'published',
updatedAt: '2026-05-07T00:00:00.000Z',
publishedAt: '2026-05-07T00:00:00.000Z',
playCount: 88,
remixCount: 9,
likeCount: 6,
publishReady: true,
pointIncentiveTotalPoints: 4,
pointIncentiveClaimablePoints: 1,
},
]}
loading={false}
error={null}
onRetry={() => {}}
onCreateType={noopCreateType}
onOpenDraft={() => {}}
onEnterPublished={() => {}}
entryConfig={testEntryConfig}
creationTypes={testCreationTypes}
onOpenPuzzleDetail={() => {}}
onClaimPuzzlePointIncentive={() => {}}
/>,
);
expect(html).toContain('积分激励');
expect(html).toContain('待领取');
expect(html).toContain('游玩');
expect(html).toContain('改造');
expect(html).toContain('点赞');
expect(html).toContain('creation-work-card__side-cover');
expect(html).not.toContain('creation-work-card__action');
expect(html).not.toContain('>查看详情<');
});

View File

@@ -27,9 +27,8 @@ import {
CustomWorldWorkTabs,
} from './CustomWorldWorkTabs';
// 中文注释:草稿在手机端保持双列,已发布卡片由卡片自身跨两列展示公开指标。
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';
'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';
type WorkMetricSnapshot = Record<
@@ -268,38 +267,6 @@ export function CustomWorldCreationHub({
[activeFilter, shelfItems],
);
function handleOpenShelfItem(item: CreationWorkShelfItem) {
switch (item.source.kind) {
case 'puzzle':
onOpenPuzzleDetail?.(item.source.item);
return;
case 'baby-object-match':
onOpenBabyObjectMatchDetail?.(item.source.item);
return;
case 'visual-novel':
onOpenVisualNovelDetail?.(item.source.item);
return;
case 'big-fish':
onOpenBigFishDetail?.(item.source.item);
return;
case 'match3d':
onOpenMatch3DDetail?.(item.source.item);
return;
case 'square-hole':
onOpenSquareHoleDetail?.(item.source.item);
return;
case 'rpg':
if (item.status === 'draft') {
onOpenDraft(item.source.item);
return;
}
if (item.source.item.profileId) {
onEnterPublished(item.source.item.profileId);
}
}
}
function buildDeleteAction(item: CreationWorkShelfItem) {
if (!item.canDelete) {
return null;

View File

@@ -1,5 +1,13 @@
import { Share2, Trash2 } from 'lucide-react';
import { useEffect, useMemo, useRef, useState } from 'react';
import {
type CSSProperties,
type KeyboardEvent as ReactKeyboardEvent,
type PointerEvent as ReactPointerEvent,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { copyTextToClipboard } from '../../services/clipboard';
import { CustomWorldCoverArtwork } from '../CustomWorldCoverArtwork';
@@ -10,6 +18,7 @@ import {
import {
type CreationWorkShelfBadgeTone,
type CreationWorkShelfItem,
type CreationWorkShelfKind,
type CreationWorkShelfMetric,
type CreationWorkShelfMetricId,
formatCreationMetricCount,
@@ -33,7 +42,20 @@ const BADGE_TONE_CLASS: Record<CreationWorkShelfBadgeTone, string> = {
};
const METRIC_ANIMATION_DURATION_MS = 820;
const SWIPE_ACTION_WIDTH_PX = 76;
const SWIPE_REVEAL_THRESHOLD_PX = 42;
const SWIPE_DIRECTION_LOCK_PX = 8;
const EMPTY_PUBLISHED_METRICS: CreationWorkShelfMetric[] = [];
const CREATION_WORK_KIND_FALLBACK_COVER: Record<CreationWorkShelfKind, string> =
{
rpg: '/creation-type-references/rpg.webp',
'big-fish': '/creation-type-references/big-fish.webp',
match3d: '/creation-type-references/match3d.webp',
'square-hole': '/creation-type-references/square-hole.webp',
puzzle: '/creation-type-references/puzzle.webp',
'baby-object-match': '/creation-type-references/creative-agent.webp',
'visual-novel': '/creation-type-references/visual-novel.webp',
};
function easeOutCubic(progress: number) {
return 1 - (1 - progress) ** 3;
@@ -68,6 +90,10 @@ function shouldAnimatePublishedMetrics() {
return !window.navigator.userAgent.toLowerCase().includes('jsdom');
}
function clampSwipeOffset(value: number, revealWidth: number) {
return Math.min(0, Math.max(-revealWidth, value));
}
function usePublishedMetricAnimation(
metrics: CreationWorkShelfMetric[],
previousMetricValues?: Partial<Record<CreationWorkShelfMetricId, number>>,
@@ -199,16 +225,82 @@ export function CustomWorldWorkCard({
'idle',
);
const shareResetTimerRef = useRef<number | null>(null);
const suppressOpenResetTimerRef = useRef<number | null>(null);
const suppressOpenRef = useRef(false);
const swipeGestureRef = useRef<{
pointerId: number;
startX: number;
startY: number;
startOffset: number;
isDragging: boolean;
} | null>(null);
const lastSwipeOffsetRef = useRef(0);
const [isSwipeDragging, setIsSwipeDragging] = useState(false);
const [isSwipeActionRevealed, setIsSwipeActionRevealed] = useState(false);
const [swipeOffset, setSwipeOffset] = useState(0);
const isPublished = item.status === 'published';
const canUseShareAction = isPublished && item.canShare && Boolean(item.sharePath);
const swipeActionCount = (canUseShareAction ? 1 : 0) + (onDelete ? 1 : 0);
const swipeRevealWidth = swipeActionCount * SWIPE_ACTION_WIDTH_PX;
const canClaimPointIncentive =
Boolean(onClaimPointIncentive) &&
(item.pointIncentive?.claimablePoints ?? 0) > 0;
const displayTitle = formatPlatformWorkDisplayName(item.title);
const fallbackCoverImageSrc = CREATION_WORK_KIND_FALLBACK_COVER[item.kind];
const { cardRef, deltas, displayValues, showGrowth } =
usePublishedMetricAnimation(
isPublished ? item.metrics : EMPTY_PUBLISHED_METRICS,
previousMetricValues,
);
const surfaceOffset = isSwipeDragging
? swipeOffset
: isSwipeActionRevealed
? -swipeRevealWidth
: 0;
const swipeActionOpacity =
swipeRevealWidth > 0 ? Math.min(1, Math.abs(surfaceOffset) / swipeRevealWidth) : 0;
const swipeSurfaceStyle = {
'--creation-work-card-swipe-offset': `${surfaceOffset}px`,
} as CSSProperties;
const swipeShellStyle = {
'--creation-work-card-action-opacity': `${swipeActionOpacity}`,
} as CSSProperties;
const sideCoverStyle = {
'--creation-work-card-cover-fallback': `url(${fallbackCoverImageSrc})`,
} as CSSProperties;
const closeSwipeActions = () => {
setIsSwipeActionRevealed(false);
setSwipeOffset(0);
lastSwipeOffsetRef.current = 0;
};
const revealSwipeActions = () => {
if (swipeRevealWidth <= 0) {
closeSwipeActions();
return;
}
setIsSwipeActionRevealed(true);
setSwipeOffset(-swipeRevealWidth);
lastSwipeOffsetRef.current = -swipeRevealWidth;
};
const scheduleOpenSuppressReset = () => {
if (typeof window === 'undefined') {
return;
}
if (suppressOpenResetTimerRef.current !== null) {
window.clearTimeout(suppressOpenResetTimerRef.current);
}
suppressOpenResetTimerRef.current = window.setTimeout(() => {
suppressOpenResetTimerRef.current = null;
suppressOpenRef.current = false;
}, 260);
};
const copyShareText = () => {
const publicWorkCode = item.publicWorkCode?.trim();
const sharePath = item.sharePath?.trim();
@@ -237,210 +329,399 @@ export function CustomWorldWorkCard({
if (shareResetTimerRef.current !== null) {
window.clearTimeout(shareResetTimerRef.current);
}
if (suppressOpenResetTimerRef.current !== null) {
window.clearTimeout(suppressOpenResetTimerRef.current);
}
},
[],
);
useEffect(() => {
if (swipeActionCount > 0) {
return;
}
closeSwipeActions();
}, [swipeActionCount]);
const beginSwipeGesture = (
event: ReactPointerEvent<HTMLDivElement>,
) => {
if (swipeRevealWidth <= 0) {
return;
}
if (event.pointerType === 'mouse' && event.button !== 0) {
return;
}
swipeGestureRef.current = {
pointerId: event.pointerId,
startX: event.clientX,
startY: event.clientY,
startOffset: isSwipeActionRevealed ? -swipeRevealWidth : 0,
isDragging: false,
};
event.currentTarget.setPointerCapture?.(event.pointerId);
};
const updateSwipeGesture = (
event: ReactPointerEvent<HTMLDivElement>,
) => {
const gesture = swipeGestureRef.current;
if (!gesture || gesture.pointerId !== event.pointerId) {
return;
}
const deltaX = event.clientX - gesture.startX;
const deltaY = event.clientY - gesture.startY;
if (!gesture.isDragging) {
if (
Math.abs(deltaX) < SWIPE_DIRECTION_LOCK_PX &&
Math.abs(deltaY) < SWIPE_DIRECTION_LOCK_PX
) {
return;
}
if (Math.abs(deltaY) > Math.abs(deltaX)) {
swipeGestureRef.current = null;
return;
}
gesture.isDragging = true;
setIsSwipeDragging(true);
}
// 中文注释:横向手势只移动卡片表层,删除动作保持在底层,避免列表滚动时误触。
event.preventDefault();
suppressOpenRef.current = true;
const nextOffset = clampSwipeOffset(
gesture.startOffset + deltaX,
swipeRevealWidth,
);
lastSwipeOffsetRef.current = nextOffset;
setSwipeOffset(nextOffset);
};
const endSwipeGesture = (
event: ReactPointerEvent<HTMLDivElement>,
) => {
const gesture = swipeGestureRef.current;
if (!gesture || gesture.pointerId !== event.pointerId) {
return;
}
event.currentTarget.releasePointerCapture?.(event.pointerId);
swipeGestureRef.current = null;
setIsSwipeDragging(false);
if (!gesture.isDragging) {
return;
}
const shouldReveal =
lastSwipeOffsetRef.current <=
-Math.min(SWIPE_REVEAL_THRESHOLD_PX, swipeRevealWidth * 0.45);
if (shouldReveal) {
revealSwipeActions();
} else {
closeSwipeActions();
}
suppressOpenRef.current = true;
scheduleOpenSuppressReset();
};
const cancelSwipeGesture = (
event: ReactPointerEvent<HTMLDivElement>,
) => {
const gesture = swipeGestureRef.current;
if (gesture?.pointerId === event.pointerId) {
event.currentTarget.releasePointerCapture?.(event.pointerId);
}
swipeGestureRef.current = null;
setIsSwipeDragging(false);
if (isSwipeActionRevealed) {
revealSwipeActions();
} else {
closeSwipeActions();
}
};
const handleCardOpen = () => {
if (isSwipeActionRevealed) {
closeSwipeActions();
return;
}
onOpen();
};
const handleCardKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>) => {
if (event.key === 'ArrowLeft' && swipeRevealWidth > 0) {
event.preventDefault();
revealSwipeActions();
return;
}
if (event.key === 'Escape' && isSwipeActionRevealed) {
event.preventDefault();
closeSwipeActions();
return;
}
if (event.key !== 'Enter' && event.key !== ' ') {
return;
}
event.preventDefault();
handleCardOpen();
};
return (
<div
ref={cardRef}
role="button"
tabIndex={0}
aria-label={`${item.openActionLabel}${item.title}`}
onClick={onOpen}
onKeyDown={(event) => {
if (event.key !== 'Enter' && event.key !== ' ') {
return;
}
event.preventDefault();
onOpen();
}}
className={`creation-work-card 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' : ''}`}
style={swipeShellStyle}
className={`creation-work-card-shell ${
isSwipeDragging || isSwipeActionRevealed
? 'creation-work-card-shell--actions-visible'
: ''
}`}
>
<CustomWorldCoverArtwork
imageSrc={item.coverImageSrc}
title={item.title}
fallbackLabel="封面"
renderMode={item.coverRenderMode}
characterImageSrcs={item.coverCharacterImageSrcs}
className="platform-cover-artwork absolute inset-0"
/>
<div className="creation-work-card__overlay absolute inset-0" />
{item.hasUnreadUpdate ? (
<span
aria-label="新生成完成"
className="pointer-events-none absolute right-2 top-2 z-30 h-2.5 w-2.5 rounded-full bg-red-500 shadow-[0_0_0_3px_rgba(255,255,255,0.26),0_0_14px_rgba(239,68,68,0.75)]"
/>
) : null}
<div className="pointer-events-none relative z-20 flex min-h-[8rem] flex-col sm:min-h-[10.5rem] xl:min-h-[9.75rem]">
<div className="pointer-events-auto absolute right-0 top-0 z-30 flex items-center gap-1">
{onDelete ? (
<button
type="button"
onClick={(event) => {
event.stopPropagation();
onDelete();
}}
onKeyDown={(event) => {
event.stopPropagation();
}}
disabled={deleteBusy}
aria-label={deleteBusy ? '删除中' : '删除'}
title={deleteBusy ? '删除中' : '删除作品'}
className="grid h-7 w-7 place-items-center rounded-full bg-black/22 text-white/78 transition hover:bg-red-500/22 hover:text-white 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'
{swipeActionCount > 0 ? (
<div
aria-hidden={!isSwipeActionRevealed}
className="creation-work-card__swipe-underlay"
>
<div className="creation-work-card__swipe-actions">
{canUseShareAction ? (
<button
type="button"
tabIndex={isSwipeActionRevealed ? 0 : -1}
onClick={(event) => {
event.stopPropagation();
suppressOpenRef.current = false;
copyShareText();
}}
onKeyDown={(event) => {
event.stopPropagation();
}}
title={
shareState === 'copied'
? '已复制'
: shareState === 'failed'
? '复制失败'
: '分享作品'
}
aria-label={
!item.canShare || !item.sharePath
? '暂不可分享'
: shareState === 'copied'
}
aria-label={
shareState === 'copied'
? '分享内容已复制'
: shareState === 'failed'
? '分享内容复制失败'
: '分享'
}
className="inline-flex h-7 min-w-7 items-center justify-center gap-1 whitespace-nowrap rounded-full bg-black/22 px-1.5 text-white/78 transition hover:bg-white/18 hover:text-white disabled:cursor-not-allowed disabled:opacity-55 sm:h-8 sm:min-w-8"
}
className="creation-work-card__swipe-button creation-work-card__swipe-button--share"
>
{shareState === 'idle' ? (
<Share2 aria-hidden="true" className="h-4 w-4" />
) : (
<span className="text-[10px] font-semibold leading-none">
{shareState === 'copied' ? '已复制' : '复制失败'}
</span>
)}
</button>
) : null}
{onDelete ? (
<button
type="button"
tabIndex={isSwipeActionRevealed ? 0 : -1}
onClick={(event) => {
event.stopPropagation();
suppressOpenRef.current = false;
closeSwipeActions();
onDelete();
}}
onKeyDown={(event) => {
event.stopPropagation();
}}
disabled={deleteBusy}
aria-label={deleteBusy ? '删除中' : '删除'}
title={deleteBusy ? '删除中' : '删除作品'}
className="creation-work-card__swipe-button creation-work-card__swipe-button--danger"
>
{deleteBusy ? (
<span className="text-xs leading-none">...</span>
) : (
<Trash2 aria-hidden="true" className="h-4 w-4" />
)}
</button>
) : null}
</div>
</div>
) : null}
<div
role="button"
tabIndex={0}
aria-label={`${item.openActionLabel}${item.title}${item.isGenerating ? ',生成中' : ''}`}
onClick={(event) => {
if (suppressOpenRef.current) {
event.preventDefault();
suppressOpenRef.current = false;
return;
}
handleCardOpen();
}}
onKeyDown={handleCardKeyDown}
onPointerDown={beginSwipeGesture}
onPointerMove={updateSwipeGesture}
onPointerUp={endSwipeGesture}
onPointerCancel={cancelSwipeGesture}
style={swipeSurfaceStyle}
className={`creation-work-card platform-category-game-item platform-interactive-card cursor-pointer overflow-hidden text-left ${isPublished ? 'creation-work-card--published' : 'creation-work-card--draft'} ${item.isGenerating ? 'creation-work-card--generating' : ''} ${isSwipeDragging ? 'creation-work-card--swiping' : ''}`}
>
<div className="creation-work-card__body platform-category-game-item__body">
<div className="creation-work-card__title-row platform-category-game-item__title-row">
<span className="creation-work-card__title platform-category-game-item__title">
{displayTitle}
</span>
<span
className={`creation-work-card__status-pill creation-work-card__status-pill--${
item.isGenerating ? 'generating' : item.status
}`}
>
{shareState === 'idle' ? (
<Share2 aria-hidden="true" className="h-3.5 w-3.5" />
) : (
<span className="text-[10px] font-semibold leading-none">
{shareState === 'copied' ? '已复制' : '复制失败'}
{item.isGenerating
? '生成中'
: item.status === 'published'
? '已发布'
: '草稿'}
</span>
</div>
<div className="creation-work-card__meta platform-category-game-item__meta">
{item.badges
.slice(1)
.map((badge) => (
<span
key={`${item.id}-${badge.id}`}
className={`creation-work-card__badge platform-pill ${BADGE_TONE_CLASS[badge.tone]}`}
>
{formatPlatformWorkDisplayTag(badge.label)}
</span>
)}
</button>
))}
</div>
<div className="creation-work-card__summary platform-category-game-item__summary">
{item.summary}
</div>
{isPublished ? (
<div className="creation-work-card__published-info">
{item.pointIncentive ? (
<div className="creation-work-card-incentive">
<div
aria-label={`积分激励总数 ${formatCreationPointIncentiveTotal(item.pointIncentive.totalPoints)} 泥点`}
className="creation-work-card-incentive__metric"
>
<span className="creation-work-card-incentive__label">
</span>
<span className="creation-work-card-incentive__value">
{formatCreationPointIncentiveTotal(
item.pointIncentive.totalPoints,
)}
</span>
</div>
<div
aria-label={`待领取积分 ${item.pointIncentive.claimablePoints} 泥点`}
className="creation-work-card-incentive__metric"
>
<span className="creation-work-card-incentive__label">
</span>
<span className="creation-work-card-incentive__value">
{formatCreationMetricCount(
item.pointIncentive.claimablePoints,
)}
</span>
</div>
<button
type="button"
disabled={!canClaimPointIncentive || pointIncentiveBusy}
onClick={(event) => {
event.stopPropagation();
onClaimPointIncentive?.();
}}
onKeyDown={(event) => {
event.stopPropagation();
}}
className="creation-work-card-incentive__button"
>
{pointIncentiveBusy ? '领取中' : '领取积分'}
</button>
</div>
) : null}
<div className="creation-work-card__metrics">
{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="creation-work-card-stat__label">
{metric.label}
</span>
<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>
) : null}
</div>
<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.isGenerating ? (
<span className="platform-pill platform-pill--cool max-w-full truncate px-2 py-0.5 text-[9px] sm:px-3 sm:py-1 sm:text-[10px]">
</span>
) : null}
{item.badges.map((badge) => (
<span
key={`${item.id}-${badge.id}`}
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]`}
>
{formatPlatformWorkDisplayTag(badge.label)}
</span>
))}
</div>
<div
className="creation-work-card__side-cover"
style={sideCoverStyle}
aria-hidden="true"
>
<CustomWorldCoverArtwork
imageSrc={item.coverImageSrc}
fallbackImageSrc={fallbackCoverImageSrc}
title={item.title}
fallbackLabel="封面"
renderMode={item.coverRenderMode}
characterImageSrcs={item.coverCharacterImageSrcs}
className="absolute inset-0"
/>
</div>
{item.hasUnreadUpdate ? (
<span
aria-label="新生成完成"
className="creation-work-card__unread-dot"
/>
) : null}
<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-white [text-shadow:0_2px_12px_rgba(0,0,0,0.52)] sm:text-2xl xl:text-xl">
{displayTitle}
</div>
<div className="mt-2 line-clamp-2 break-words text-[11px] leading-4 text-white/84 [text-shadow:0_1px_8px_rgba(0,0,0,0.5)] sm:mt-3 sm:text-sm sm:leading-6 xl:mt-2">
{item.summary}
</div>
</div>
{isPublished ? (
<div className="mt-auto space-y-2 pt-3 sm:pt-4 xl:pt-3">
{item.pointIncentive ? (
<div className="creation-work-card-incentive">
<div
aria-label={`积分激励总数 ${formatCreationPointIncentiveTotal(item.pointIncentive.totalPoints)} 泥点`}
className="creation-work-card-incentive__metric"
>
<span className="creation-work-card-incentive__label">
</span>
<span className="creation-work-card-incentive__value">
{formatCreationPointIncentiveTotal(
item.pointIncentive.totalPoints,
)}
</span>
</div>
<div
aria-label={`待领取积分 ${item.pointIncentive.claimablePoints} 泥点`}
className="creation-work-card-incentive__metric"
>
<span className="creation-work-card-incentive__label">
</span>
<span className="creation-work-card-incentive__value">
{formatCreationMetricCount(
item.pointIncentive.claimablePoints,
)}
</span>
</div>
<button
type="button"
disabled={!canClaimPointIncentive || pointIncentiveBusy}
onClick={(event) => {
event.stopPropagation();
onClaimPointIncentive?.();
}}
onKeyDown={(event) => {
event.stopPropagation();
}}
className="pointer-events-auto creation-work-card-incentive__button"
>
{pointIncentiveBusy ? '领取中' : '领取积分'}
</button>
</div>
) : null}
<div className="grid grid-cols-3 gap-1.5 sm:gap-2">
{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="creation-work-card-stat__label">
{metric.label}
</span>
<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>
{item.isGenerating ? (
<div className="creation-work-card__generating-mask" aria-hidden="true">
<span className="creation-work-card__spinner" />
<span>...</span>
</div>
) : null}
</div>

View File

@@ -290,6 +290,148 @@ test('buildCreationWorkShelfItems falls back to available gameplay images as cov
);
});
test('buildCreationWorkShelfItems uses generated object keys as cover sources', () => {
const items = buildCreationWorkShelfItems({
rpgItems: [],
bigFishItems: [],
puzzleItems: [
{
workId: 'puzzle:level-object-key',
profileId: 'puzzle-profile-level-object-key',
ownerUserId: 'user-1',
authorDisplayName: '测试作者',
levelName: '关卡对象拼图',
summary: '作品摘要带关卡图对象路径时用关卡图做卡片背景。',
themeTags: [],
coverImageSrc: null,
publicationStatus: 'draft',
updatedAt: '2026-05-08T00:00:00.000Z',
publishedAt: null,
publishReady: false,
levels: [
{
levelId: 'level-1',
levelName: '第一关',
pictureDescription: '港口雨夜。',
candidates: [
{
candidateId: 'candidate-1',
imageSrc: '',
assetId: 'asset-1',
prompt: '港口雨夜',
sourceType: 'generated',
selected: true,
},
],
selectedCandidateId: 'candidate-1',
coverImageSrc:
'generated-puzzle-assets/session/profile/level-cover.png',
coverAssetId: null,
generationStatus: 'ready',
},
],
},
],
match3dItems: [
{
workId: 'match3d:object-key-cover',
profileId: 'match3d-profile-object-key-cover',
ownerUserId: 'user-1',
gameName: '对象路径抓鹅',
themeText: '糖果厨房',
summary: '背景图或物品图只有 object key 时也应展示。',
tags: [],
coverImageSrc: null,
clearCount: 18,
difficulty: 1,
publicationStatus: 'draft',
playCount: 0,
updatedAt: '2026-05-07T00:00:00.000Z',
publishReady: false,
generatedBackgroundAsset: {
prompt: '糖果厨房背景',
imageObjectKey:
'generated-match3d-assets/session/profile/background/image.png',
containerImageObjectKey:
'generated-match3d-assets/session/profile/background/container.png',
status: 'ready',
},
generatedItemAssets: [
{
itemId: 'item-1',
itemName: '糖果',
imageObjectKey:
'generated-match3d-assets/session/profile/items/item-1/image.png',
imageViews: [
{
viewId: 'view-1',
viewIndex: 1,
imageObjectKey:
'generated-match3d-assets/session/profile/items/item-1/views/view-1.png',
},
],
status: 'image_ready',
},
],
},
],
});
expect(items.find((item) => item.kind === 'puzzle')?.coverImageSrc).toBe(
'generated-puzzle-assets/session/profile/level-cover.png',
);
expect(items.find((item) => item.kind === 'match3d')?.coverImageSrc).toBe(
'generated-match3d-assets/session/profile/background/image.png',
);
});
test('buildCreationWorkShelfItems falls back to match3d item object key without background', () => {
const items = buildCreationWorkShelfItems({
rpgItems: [],
bigFishItems: [],
puzzleItems: [],
match3dItems: [
{
workId: 'match3d:item-object-key-cover',
profileId: 'match3d-profile-item-object-key-cover',
ownerUserId: 'user-1',
gameName: '物品对象路径抓鹅',
themeText: '糖果厨房',
summary: '背景图缺失时用物品视角图对象路径。',
tags: [],
coverImageSrc: null,
clearCount: 18,
difficulty: 1,
publicationStatus: 'draft',
playCount: 0,
updatedAt: '2026-05-07T00:00:00.000Z',
publishReady: false,
generatedItemAssets: [
{
itemId: 'item-1',
itemName: '糖果',
imageObjectKey:
'generated-match3d-assets/session/profile/items/item-1/image.png',
imageViews: [
{
viewId: 'view-1',
viewIndex: 1,
imageObjectKey:
'generated-match3d-assets/session/profile/items/item-1/views/view-1.png',
},
],
status: 'image_ready',
},
],
},
],
});
expect(items.find((item) => item.kind === 'match3d')?.coverImageSrc).toBe(
'generated-match3d-assets/session/profile/items/item-1/views/view-1.png',
);
});
test('getCreationWorkShelfItemTime parses backend seconds.microsZ values', () => {
expect(getCreationWorkShelfItemTime('1778457601.234567Z')).toBe(
1778457601234.567,

View File

@@ -621,6 +621,11 @@ function resolvePuzzleWorkCoverImageSrc(item: PuzzleWorkSummary) {
}
for (const level of item.levels ?? []) {
const levelCoverImageSrc = normalizeCoverImageSrc(level.coverImageSrc);
if (levelCoverImageSrc) {
return levelCoverImageSrc;
}
const selectedCandidateImageSrc =
level.selectedCandidateId && level.candidates.length > 0
? normalizeCoverImageSrc(
@@ -632,13 +637,10 @@ function resolvePuzzleWorkCoverImageSrc(item: PuzzleWorkSummary) {
const fallbackCandidateImageSrc = normalizeCoverImageSrc(
level.candidates[level.candidates.length - 1]?.imageSrc,
);
const levelCoverImageSrc =
selectedCandidateImageSrc ||
normalizeCoverImageSrc(level.coverImageSrc) ||
fallbackCandidateImageSrc;
const candidateImageSrc = selectedCandidateImageSrc || fallbackCandidateImageSrc;
if (levelCoverImageSrc) {
return levelCoverImageSrc;
if (candidateImageSrc) {
return candidateImageSrc;
}
}
@@ -653,18 +655,27 @@ function resolveMatch3DWorkCoverImageSrc(item: Match3DWorkSummary) {
const backgroundImageSrc =
normalizeCoverImageSrc(item.backgroundImageSrc) ||
normalizeCoverImageSrc(item.backgroundImageObjectKey) ||
normalizeCoverImageSrc(item.generatedBackgroundAsset?.imageSrc) ||
normalizeCoverImageSrc(item.generatedBackgroundAsset?.containerImageSrc);
normalizeCoverImageSrc(item.generatedBackgroundAsset?.imageObjectKey) ||
normalizeCoverImageSrc(item.generatedBackgroundAsset?.containerImageSrc) ||
normalizeCoverImageSrc(item.generatedBackgroundAsset?.containerImageObjectKey);
if (backgroundImageSrc) {
return backgroundImageSrc;
}
for (const asset of item.generatedItemAssets ?? []) {
const imageViewSrc = normalizeCoverImageSrc(
asset.imageViews?.find((view) => normalizeCoverImageSrc(view.imageSrc))
?.imageSrc,
const imageView = asset.imageViews?.find(
(view) =>
normalizeCoverImageSrc(view.imageSrc) ||
normalizeCoverImageSrc(view.imageObjectKey),
);
const itemImageSrc = normalizeCoverImageSrc(asset.imageSrc);
const imageViewSrc =
normalizeCoverImageSrc(imageView?.imageSrc) ||
normalizeCoverImageSrc(imageView?.imageObjectKey);
const itemImageSrc =
normalizeCoverImageSrc(asset.imageSrc) ||
normalizeCoverImageSrc(asset.imageObjectKey);
if (imageViewSrc || itemImageSrc) {
return imageViewSrc || itemImageSrc;
}