This commit is contained in:
2026-05-01 00:33:39 +08:00
parent 61969c5116
commit fe02603ba1
68 changed files with 4586 additions and 748 deletions

View File

@@ -1,6 +1,6 @@
/* @vitest-environment jsdom */
import { render, screen, within } from '@testing-library/react';
import { act, render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { useState } from 'react';
import { afterEach, expect, test, vi } from 'vitest';
@@ -118,10 +118,24 @@ vi.mock('../../services/rpg-entry/rpgProfileClient', () => ({
}));
vi.mock('../ResolvedAssetImage', () => ({
ResolvedAssetImage: () => null,
ResolvedAssetImage: ({
src,
alt,
className,
...rest
}: {
src?: string | null;
alt?: string;
className?: string;
}) =>
src ? (
<img src={src} alt={alt ?? ''} className={className} {...rest} />
) : null,
}));
const originalMatchMedia = window.matchMedia;
const originalRequestAnimationFrame = window.requestAnimationFrame;
const originalCancelAnimationFrame = window.cancelAnimationFrame;
const puzzlePublicEntry = {
sourceType: 'puzzle',
workId: 'puzzle-work-public-1',
@@ -156,6 +170,33 @@ const remixRankEntry = {
updatedAt: '2026-04-25T11:00:00.000Z',
} satisfies PlatformPublicGalleryCard;
function buildCarouselPuzzleEntry(
id: string,
worldName: string,
coverPrefix: string,
) {
return {
...puzzlePublicEntry,
workId: `puzzle-work-${id}`,
profileId: `puzzle-profile-${id}`,
publicWorkCode: `PZ-${id.toUpperCase()}`,
worldName,
coverImageSrc: `${coverPrefix}-fallback.png`,
coverSlides: [
{
id: `${id}-cover-1`,
imageSrc: `${coverPrefix}-1.png`,
label: `${worldName} 1`,
},
{
id: `${id}-cover-2`,
imageSrc: `${coverPrefix}-2.png`,
label: `${worldName} 2`,
},
],
} satisfies PlatformPublicGalleryCard;
}
const hotRankEntry = {
...puzzlePublicEntry,
workId: 'puzzle-work-hot-rank',
@@ -414,12 +455,23 @@ function renderStatefulLoggedOutHomeView(
}
afterEach(() => {
vi.useRealTimers();
vi.clearAllMocks();
Object.defineProperty(window, 'matchMedia', {
configurable: true,
writable: true,
value: originalMatchMedia,
});
Object.defineProperty(window, 'requestAnimationFrame', {
configurable: true,
writable: true,
value: originalRequestAnimationFrame,
});
Object.defineProperty(window, 'cancelAnimationFrame', {
configurable: true,
writable: true,
value: originalCancelAnimationFrame,
});
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: undefined,
@@ -430,7 +482,7 @@ test('opens wallet ledger modal from narrative coin card', async () => {
const user = userEvent.setup();
renderProfileView();
await user.click(screen.getByText('剩余陶泥币'));
await user.click(screen.getByRole('button', { name: /\s*0/u }));
expect(await screen.findByText('陶泥币账单')).toBeTruthy();
expect(mockGetRpgProfileWalletLedger).toHaveBeenCalledTimes(1);
@@ -446,7 +498,7 @@ test('profile total play time card always uses hours', () => {
});
const playTimeCard = screen.getByRole('button', {
name: //u,
name: //u,
});
expect(within(playTimeCard).getByText('1.5小时')).toBeTruthy();
@@ -470,12 +522,12 @@ test('wallet ledger modal shows empty and error states', async () => {
mockGetRpgProfileWalletLedger.mockResolvedValueOnce({ entries: [] });
renderProfileView();
await user.click(screen.getByText('剩余陶泥币'));
await user.click(screen.getByRole('button', { name: /\s*0/u }));
expect(await screen.findByText('暂无账单记录')).toBeTruthy();
await user.click(screen.getByLabelText('关闭陶泥币账单'));
mockGetRpgProfileWalletLedger.mockRejectedValueOnce(new Error('加载失败'));
await user.click(screen.getByText('剩余陶泥币'));
await user.click(screen.getByRole('button', { name: /\s*0/u }));
expect(await screen.findByText('加载失败')).toBeTruthy();
expect(screen.getByText('重新加载')).toBeTruthy();
@@ -622,6 +674,126 @@ test('mobile public work cards render cover, author, kind and cover stats', () =
).toBe('推荐');
});
test('mobile home feed only rotates the card closest to screen center', () => {
vi.useFakeTimers();
Object.defineProperty(window, 'requestAnimationFrame', {
configurable: true,
writable: true,
value: (callback: FrameRequestCallback) =>
window.setTimeout(() => callback(0), 0),
});
Object.defineProperty(window, 'cancelAnimationFrame', {
configurable: true,
writable: true,
value: (handle: number) => window.clearTimeout(handle),
});
const firstEntry = buildCarouselPuzzleEntry('center1', '中心拼图一', 'center-one');
const secondEntry = buildCarouselPuzzleEntry(
'center2',
'中心拼图二',
'center-two',
);
const cardRects = new Map<string, DOMRect>();
renderLoggedOutHomeView(vi.fn(), {
latestEntries: [firstEntry, secondEntry],
});
const tabPanel = document.querySelector('.platform-tab-panel--active');
const firstCard = screen.getByRole('button', { name: //u });
const secondCard = screen.getByRole('button', { name: //u });
if (!tabPanel) {
throw new Error('缺少移动端首页滚动面板');
}
tabPanel.getBoundingClientRect = vi.fn(
() =>
({
top: 0,
bottom: 600,
height: 600,
left: 0,
right: 360,
width: 360,
}) as DOMRect,
);
firstCard.getBoundingClientRect = vi.fn(() => cardRects.get('first')!);
secondCard.getBoundingClientRect = vi.fn(() => cardRects.get('second')!);
cardRects.set('first', {
top: 170,
bottom: 370,
height: 200,
left: 0,
right: 320,
width: 320,
} as DOMRect);
cardRects.set('second', {
top: 420,
bottom: 620,
height: 200,
left: 0,
right: 320,
width: 320,
} as DOMRect);
act(() => {
vi.runOnlyPendingTimers();
});
expect(within(firstCard).getByRole('img').getAttribute('src')).toBe(
'center-one-1.png',
);
expect(within(secondCard).getByRole('img').getAttribute('src')).toBe(
'center-two-1.png',
);
act(() => {
vi.advanceTimersByTime(4200);
});
expect(within(firstCard).getByRole('img').getAttribute('src')).toBe(
'center-one-2.png',
);
expect(within(secondCard).getByRole('img').getAttribute('src')).toBe(
'center-two-1.png',
);
cardRects.set('first', {
top: -120,
bottom: 80,
height: 200,
left: 0,
right: 320,
width: 320,
} as DOMRect);
cardRects.set('second', {
top: 200,
bottom: 400,
height: 200,
left: 0,
right: 320,
width: 320,
} as DOMRect);
act(() => {
tabPanel.dispatchEvent(new Event('scroll'));
vi.runOnlyPendingTimers();
});
expect(within(firstCard).getByRole('img').getAttribute('src')).toBe(
'center-one-1.png',
);
act(() => {
vi.advanceTimersByTime(4200);
});
expect(within(secondCard).getByRole('img').getAttribute('src')).toBe(
'center-two-2.png',
);
});
test('mobile today channel only shows newly published works from today', async () => {
const user = userEvent.setup();
const now = new Date();
@@ -676,13 +848,31 @@ test('mobile today channel only shows newly published works from today', async (
expect(screen.queryByText('今日更新旧作')).toBeNull();
});
test('desktop trending list shows kind instead of work code or timestamp text', () => {
test('desktop home syncs mobile home modules without square or latest labels', () => {
mockDesktopLayout();
const todayPublishedAt = new Date().toISOString();
const todayEntry = {
...puzzlePublicEntry,
workId: 'puzzle-work-desktop-today',
profileId: 'puzzle-profile-desktop-today',
publicWorkCode: 'PZ-DTODAY',
worldName: '桌面今日新游',
publishedAt: todayPublishedAt,
updatedAt: todayPublishedAt,
} satisfies PlatformPublicGalleryCard;
renderLoggedOutHomeView(vi.fn(), {
latestEntries: [puzzlePublicEntry],
latestEntries: [puzzlePublicEntry, todayEntry],
});
expect(screen.getByText('今日游戏')).toBeTruthy();
expect(screen.getByText('推荐')).toBeTruthy();
expect(screen.getByText('作品分类')).toBeTruthy();
expect(screen.getAllByText('桌面今日新游').length).toBeGreaterThan(0);
expect(screen.queryByText('趋势关注')).toBeNull();
expect(screen.queryByText('最新发布')).toBeNull();
expect(screen.queryByText('作品广场')).toBeNull();
expect(screen.queryByText('公开作品')).toBeNull();
expect(screen.queryByText('PZ-EPUBLIC1')).toBeNull();
expect(screen.getAllByText('拼图').length).toBeGreaterThan(0);
expect(screen.queryByText('1777110165.990127Z')).toBeNull();

View File

@@ -76,6 +76,7 @@ import {
type PlatformPublicGalleryCard,
type PlatformWorldCardLike,
resolvePlatformWorldCoverImage,
resolvePlatformWorldCoverSlides,
resolvePlatformWorldLeadPortrait,
} from './rpgEntryWorldPresentation';
@@ -145,6 +146,7 @@ const PLATFORM_HOME_TABS: PlatformHomeTab[] = [
const AVATAR_MAX_FILE_SIZE = 5 * 1024 * 1024;
const AVATAR_OUTPUT_SIZE = 256;
const AVATAR_ALLOWED_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp']);
const PLATFORM_WORK_COVER_CAROUSEL_INTERVAL_MS = 4200;
type ProfilePopupPanel = 'invite' | 'redeem' | 'community';
type MobileHomeChannel = 'recommend' | 'today' | 'category';
type PlatformRankingTab = 'hot' | 'remix' | 'new' | 'like';
@@ -365,12 +367,38 @@ function WorldCard({
entry,
onClick,
className,
feedCardKey,
enableCoverCarousel = false,
isCoverCarouselActive = false,
}: {
entry: PlatformPublicGalleryCard;
onClick: () => void;
className?: string;
feedCardKey?: string;
enableCoverCarousel?: boolean;
isCoverCarouselActive?: boolean;
}) {
const coverImage = resolvePlatformWorldCoverImage(entry);
const fallbackCoverImage = resolvePlatformWorldCoverImage(entry);
const coverSlides = useMemo(() => {
if (!enableCoverCarousel) {
return fallbackCoverImage
? [
{
id: 'cover',
imageSrc: fallbackCoverImage,
label: entry.worldName,
},
]
: [];
}
return resolvePlatformWorldCoverSlides(entry);
}, [enableCoverCarousel, entry, fallbackCoverImage]);
const [activeCoverIndex, setActiveCoverIndex] = useState(0);
const visibleCoverIndex = isCoverCarouselActive ? activeCoverIndex : 0;
const activeCoverSlide =
coverSlides[visibleCoverIndex] ?? coverSlides[0] ?? null;
const coverImage = activeCoverSlide?.imageSrc ?? '';
const displayName = formatPlatformWorkDisplayName(entry.worldName);
const tags = buildPlatformWorldDisplayTags(entry, 3);
const playCount = getPlatformWorldPlayCount(entry);
@@ -398,11 +426,36 @@ function WorldCard({
},
];
useEffect(() => {
setActiveCoverIndex(0);
}, [entry.ownerUserId, entry.profileId, coverSlides.length]);
useEffect(() => {
if (!isCoverCarouselActive) {
setActiveCoverIndex(0);
}
}, [isCoverCarouselActive]);
useEffect(() => {
if (!isCoverCarouselActive || coverSlides.length <= 1) {
return undefined;
}
const timerId = window.setInterval(() => {
setActiveCoverIndex((current) => (current + 1) % coverSlides.length);
}, PLATFORM_WORK_COVER_CAROUSEL_INTERVAL_MS);
return () => {
window.clearInterval(timerId);
};
}, [coverSlides.length, isCoverCarouselActive]);
return (
<button
type="button"
onClick={onClick}
aria-label={cardLabel}
data-mobile-feed-card-key={feedCardKey}
className={`platform-public-work-card platform-surface platform-interactive-card relative flex w-[min(21rem,88vw)] shrink-0 flex-col overflow-hidden p-0 text-left ${className ?? ''}`}
>
<div className="platform-public-work-card__cover relative aspect-video overflow-hidden">
@@ -1191,7 +1244,7 @@ function formatCompactPlayTime(playTimeMs: number) {
return `${Math.max(0, totalMinutes)}`;
}
// “游戏时长”固定使用小时,避免短时长切到分钟或长时长切到天。
// “游戏时长”固定使用小时,避免短时长切到分钟或长时长切到天。
function formatTotalPlayTimeHours(playTimeMs: number) {
const roundedHours = Math.max(0, Math.round(playTimeMs / 360000) / 10);
@@ -2056,7 +2109,7 @@ function ProfilePlayedWorksModal({
type="button"
onClick={onClose}
className="absolute right-3 top-3 z-10 flex h-8 w-8 items-center justify-center rounded-full bg-white/80 text-[#ff4056] shadow-sm"
aria-label="关闭玩过作品"
aria-label="关闭玩过"
>
×
</button>
@@ -2065,7 +2118,7 @@ function ProfilePlayedWorksModal({
<div className="text-[10px] font-black tracking-[0.22em] text-[#ff4056]">
PLAYED
</div>
<div className="mt-1 text-2xl font-black"></div>
<div className="mt-1 text-2xl font-black"></div>
<div className="mt-2 inline-flex items-center gap-2 rounded-full border border-rose-100 bg-rose-50 px-3 py-1.5 text-xs font-bold text-zinc-600">
<Clock3 className="h-3.5 w-3.5 text-[#ff4056]" />
<span>
@@ -2129,7 +2182,7 @@ function ProfilePlayedWorksModal({
</div>
) : (
<div className="mt-5 rounded-xl border border-zinc-200 bg-zinc-50 px-4 py-4 text-sm text-zinc-600">
</div>
)}
</div>
@@ -2202,6 +2255,10 @@ export function RpgEntryHomeView({
);
const [mobileHomeChannel, setMobileHomeChannel] =
useState<MobileHomeChannel>('recommend');
const mobileFeedRef = useRef<HTMLElement | null>(null);
const [mobileCenteredCardKey, setMobileCenteredCardKey] = useState<
string | null
>(null);
const [activeRankingTab, setActiveRankingTab] =
useState<PlatformRankingTab>('hot');
const [visitedTabs, setVisitedTabs] = useState<Set<PlatformHomeTab>>(
@@ -2631,9 +2688,21 @@ export function RpgEntryHomeView({
const desktopHeroStripEntries = (
featuredShelf.length > 0 ? featuredShelf : latestEntries
).slice(0, 5);
const desktopTrendingEntries = latestEntries.slice(0, 3);
const desktopFeaturedGrid = featuredShelf.slice(0, 4);
const desktopReleaseGrid = latestEntries.slice(0, 6);
// 网页端保留原有宽屏布局,只把模块数据同步到移动端首页频道语义。
const desktopRecommendEntries = useMemo(() => {
const entryMap = new Map<string, PlatformPublicGalleryCard>();
[...featuredShelf, ...latestEntries].forEach((entry) => {
entryMap.set(buildPublicGalleryCardKey(entry), entry);
});
return Array.from(entryMap.values());
}, [featuredShelf, latestEntries]);
const desktopTodayEntries = useMemo(
() => filterTodayPublishedEntries(latestEntries),
[latestEntries],
);
const desktopFeaturedGrid = desktopRecommendEntries.slice(0, 4);
const desktopCategoryGrid = activeCategoryEntries.slice(0, 6);
const desktopLibraryPreview = myEntries.slice(0, 2);
const mobileFeedEntries = useMemo(() => {
const entryMap = new Map<string, PlatformPublicGalleryCard>();
@@ -2648,6 +2717,86 @@ export function RpgEntryHomeView({
return Array.from(entryMap.values());
}, [featuredShelf, latestEntries, mobileHomeChannel]);
const mobileFeedCarouselEnabled =
!isDesktopLayout && activeTab === 'home' && mobileHomeChannel !== 'category';
useEffect(() => {
if (!mobileFeedCarouselEnabled) {
setMobileCenteredCardKey(null);
return undefined;
}
const feedElement = mobileFeedRef.current;
const scrollElement = feedElement?.closest('.platform-tab-panel');
if (!feedElement || !scrollElement) {
setMobileCenteredCardKey(null);
return undefined;
}
let frameId: number | null = null;
const updateCenteredCard = () => {
frameId = null;
const cards = Array.from(
feedElement.querySelectorAll<HTMLElement>('[data-mobile-feed-card-key]'),
);
const viewportRect = scrollElement.getBoundingClientRect();
const viewportCenterY =
viewportRect.top + Math.max(0, viewportRect.height) / 2;
let closestKey: string | null = null;
let closestDistance = Number.POSITIVE_INFINITY;
cards.forEach((card) => {
const cardKey = card.dataset.mobileFeedCardKey;
if (!cardKey) {
return;
}
const cardRect = card.getBoundingClientRect();
if (
cardRect.bottom <= viewportRect.top ||
cardRect.top >= viewportRect.bottom
) {
return;
}
const cardCenterY = cardRect.top + cardRect.height / 2;
const distance = Math.abs(cardCenterY - viewportCenterY);
if (distance < closestDistance) {
closestDistance = distance;
closestKey = cardKey;
}
});
setMobileCenteredCardKey((current) =>
current === closestKey ? current : closestKey,
);
};
const scheduleUpdate = () => {
if (frameId !== null) {
return;
}
frameId =
typeof window.requestAnimationFrame === 'function'
? window.requestAnimationFrame(updateCenteredCard)
: window.setTimeout(updateCenteredCard, 0);
};
scheduleUpdate();
scrollElement.addEventListener('scroll', scheduleUpdate, { passive: true });
window.addEventListener('resize', scheduleUpdate);
return () => {
if (frameId !== null) {
if (typeof window.cancelAnimationFrame === 'function') {
window.cancelAnimationFrame(frameId);
} else {
window.clearTimeout(frameId);
}
}
scrollElement.removeEventListener('scroll', scheduleUpdate);
window.removeEventListener('resize', scheduleUpdate);
};
}, [mobileFeedCarouselEnabled, mobileFeedEntries, mobileHomeChannel]);
const activeRankingConfig = PLATFORM_RANKING_TABS.find(
(tab) => tab.id === activeRankingTab,
) as (typeof PLATFORM_RANKING_TABS)[number];
@@ -2757,19 +2906,26 @@ export function RpgEntryHomeView({
)}
</section>
) : (
<section className="platform-mobile-home-feed">
<section ref={mobileFeedRef} className="platform-mobile-home-feed">
{isLoadingPlatform ? (
<EmptyShelf text="正在读取公开作品..." />
) : mobileFeedEntries.length > 0 ? (
<div className="grid min-w-0 gap-3">
{mobileFeedEntries.map((entry: PlatformPublicGalleryCard) => (
<WorldCard
key={`${buildPublicGalleryCardKey(entry)}:mobile-feed:${mobileHomeChannel}`}
entry={entry}
onClick={() => onOpenGalleryDetail(entry)}
className="w-full"
/>
))}
{mobileFeedEntries.map((entry: PlatformPublicGalleryCard) => {
const cardKey = buildPublicGalleryCardKey(entry);
return (
<WorldCard
key={`${cardKey}:mobile-feed:${mobileHomeChannel}`}
entry={entry}
onClick={() => onOpenGalleryDetail(entry)}
className="w-full"
feedCardKey={cardKey}
enableCoverCarousel={mobileFeedCarouselEnabled}
isCoverCarouselActive={mobileCenteredCardKey === cardKey}
/>
);
})}
</div>
) : (
<EmptyShelf text="公开广场暂时还没有可展示的作品。" />
@@ -3040,21 +3196,21 @@ export function RpgEntryHomeView({
<>
<ProfileStatCard
cardKey="wallet"
label="剩余陶泥币"
label="陶泥币"
value="暂不可用"
icon={Coins}
onClick={openWalletLedgerPanel}
/>
<ProfileStatCard
cardKey="playTime"
label="游戏时长"
label="游戏时长"
value="暂不可用"
icon={Clock3}
onClick={onOpenProfileDashboardCard}
/>
<ProfileStatCard
cardKey="playedWorks"
label="玩过作品"
label="玩过"
value="暂不可用"
icon={BookOpen}
onClick={onOpenProfileDashboardCard}
@@ -3064,21 +3220,21 @@ export function RpgEntryHomeView({
<>
<ProfileStatCard
cardKey="wallet"
label="剩余陶泥币"
label="陶泥币"
value={formatDashboardCount(remainingNarrativeCoins)}
icon={Coins}
onClick={openWalletLedgerPanel}
/>
<ProfileStatCard
cardKey="playTime"
label="游戏时长"
label="游戏时长"
value={totalPlayTime}
icon={Clock3}
onClick={onOpenProfileDashboardCard}
/>
<ProfileStatCard
cardKey="playedWorks"
label="玩过作品"
label="玩过"
value={formatDashboardCount(playedWorkCount)}
icon={BookOpen}
onClick={onOpenProfileDashboardCard}
@@ -3185,7 +3341,7 @@ export function RpgEntryHomeView({
<span className="platform-pill platform-pill--neutral px-3">
{leadPublicEntry
? describePublicGalleryCardKind(leadPublicEntry)
: '作品广场'}
: '作品'}
</span>
</div>
@@ -3196,7 +3352,7 @@ export function RpgEntryHomeView({
<div className="mt-4 text-base leading-8 text-zinc-200/86">
{leadPublicEntry?.summaryText ||
leadPublicEntry?.subtitle ||
'从公开广场进入作品详情,挑一个世界开始游玩。'}
'挑一个玩家作品,开始今天的游玩。'}
</div>
<div className="mt-5 inline-flex items-center gap-2 rounded-full border border-white/18 bg-white/18 px-4 py-2 text-sm font-semibold text-white/92">
<span></span>
@@ -3243,18 +3399,18 @@ export function RpgEntryHomeView({
<section className="platform-desktop-panel px-5 py-5">
<div className="mb-4 flex items-start justify-between gap-3">
<SectionHeader title="趋势关注" detail="TRENDING NOW" />
<SectionHeader title="今日游戏" detail="TODAY GAMES" />
<span className="platform-pill platform-pill--neutral px-3">
LIVE
TODAY
</span>
</div>
{isLoadingPlatform ? (
<EmptyShelf text="正在整理趋势作品..." />
) : desktopTrendingEntries.length > 0 ? (
<EmptyShelf text="正在读取今日游戏..." />
) : desktopTodayEntries.length > 0 ? (
<div className="space-y-3">
{desktopTrendingEntries.map((entry, index) => (
{desktopTodayEntries.slice(0, 3).map((entry, index) => (
<DesktopTrendingItem
key={`${buildPublicGalleryCardKey(entry)}:desktop-trend`}
key={`${buildPublicGalleryCardKey(entry)}:desktop-today`}
entry={entry}
rank={index + 1}
onClick={() => onOpenGalleryDetail(entry)}
@@ -3262,16 +3418,18 @@ export function RpgEntryHomeView({
))}
</div>
) : (
<EmptyShelf text="公开广场暂时还没有趋势作品。" />
<EmptyShelf text="今天暂时还没有新游戏。" />
)}
</section>
</div>
<div className="grid gap-5 2xl:grid-cols-[minmax(0,1.2fr)_minmax(22rem,0.8fr)]">
<div
className={`grid gap-5 ${desktopLibraryPreview.length > 0 || historyEntries.length > 0 ? '2xl:grid-cols-[minmax(0,1.2fr)_minmax(22rem,0.8fr)]' : ''}`}
>
<section className="platform-desktop-panel px-5 py-5">
<SectionHeader title="精选推荐" detail="CURATED WORLDS" />
<SectionHeader title="推荐" detail="RECOMMENDED" />
{isLoadingPlatform ? (
<EmptyShelf text="正在读取精选作品..." />
<EmptyShelf text="正在读取推荐作品..." />
) : desktopFeaturedGrid.length > 0 ? (
<div className="grid gap-4 xl:grid-cols-2">
{desktopFeaturedGrid.map((entry) => (
@@ -3284,136 +3442,142 @@ export function RpgEntryHomeView({
))}
</div>
) : (
<EmptyShelf text="公开广场暂时还没有精选作品。" />
<EmptyShelf text="暂时还没有推荐作品。" />
)}
</section>
<section className="platform-desktop-panel px-5 py-5">
<SectionHeader
title={
desktopLibraryPreview.length > 0
? '最近作品'
: historyEntries.length > 0
? '最近浏览'
: '作品广场'
}
detail="QUICK ACCESS"
/>
{desktopLibraryPreview.length > 0 || historyEntries.length > 0 ? (
<section className="platform-desktop-panel px-5 py-5">
<SectionHeader
title={desktopLibraryPreview.length > 0 ? '最近作品' : '最近浏览'}
detail="QUICK ACCESS"
/>
<div>
<div className="text-[10px] font-semibold tracking-[0.24em] text-[var(--platform-text-soft)]">
{desktopLibraryPreview.length > 0
? '最近作品'
: historyEntries.length > 0
? '最近浏览'
: '公开作品'}
<div>
<div className="text-[10px] font-semibold tracking-[0.24em] text-[var(--platform-text-soft)]">
{desktopLibraryPreview.length > 0 ? '最近作品' : '最近浏览'}
</div>
{desktopLibraryPreview.length > 0 ? (
<div className="mt-3 space-y-3">
{desktopLibraryPreview.map((entry) => {
const displayName = formatPlatformWorkDisplayName(
entry.worldName,
);
return (
<button
key={`${entry.ownerUserId}:${entry.profileId}:desktop-mine`}
type="button"
onClick={() => onOpenLibraryDetail(entry)}
className="platform-desktop-trending-item flex w-full items-center justify-between gap-3 px-4 py-4 text-left"
>
<div className="min-w-0">
<div className="line-clamp-1 text-base font-semibold text-[var(--platform-text-strong)]">
{displayName}
</div>
<div className="mt-1 text-sm text-[var(--platform-text-soft)]">
{entry.visibility === 'published'
? `发布于 ${formatPlatformWorldTime(entry.publishedAt)}`
: '草稿待完善'}
</div>
</div>
<span className="platform-pill platform-pill--neutral px-3">
{entry.visibility === 'published' ? '已发布' : '草稿'}
</span>
</button>
);
})}
</div>
) : (
<div className="mt-3 space-y-3">
{historyEntries.slice(0, 2).map((entry) => {
const displayName = formatPlatformWorkDisplayName(
entry.worldName,
);
return (
<button
key={`${entry.ownerUserId}:${entry.profileId}:desktop-history`}
type="button"
onClick={() =>
onOpenGalleryDetail({
ownerUserId: entry.ownerUserId,
profileId: entry.profileId,
publicWorkCode: null,
authorPublicUserCode: null,
visibility: 'published',
publishedAt: entry.visitedAt,
updatedAt: entry.visitedAt,
worldName: entry.worldName,
subtitle: entry.subtitle,
summaryText: entry.summaryText,
coverImageSrc: entry.coverImageSrc,
themeMode: entry.themeMode,
authorDisplayName: entry.authorDisplayName,
playableNpcCount: 0,
landmarkCount: 0,
likeCount: 0,
})
}
className="platform-desktop-trending-item flex w-full items-center justify-between gap-3 px-4 py-4 text-left"
>
<div className="min-w-0">
<div className="line-clamp-1 text-base font-semibold text-[var(--platform-text-strong)]">
{displayName}
</div>
<div className="mt-1 text-sm text-[var(--platform-text-soft)]">
{entry.authorDisplayName}
</div>
</div>
<span className="platform-pill platform-pill--neutral px-3">
</span>
</button>
);
})}
</div>
)}
</div>
{desktopLibraryPreview.length > 0 ? (
<div className="mt-3 space-y-3">
{desktopLibraryPreview.map((entry) => {
const displayName = formatPlatformWorkDisplayName(
entry.worldName,
);
return (
<button
key={`${entry.ownerUserId}:${entry.profileId}:desktop-mine`}
type="button"
onClick={() => onOpenLibraryDetail(entry)}
className="platform-desktop-trending-item flex w-full items-center justify-between gap-3 px-4 py-4 text-left"
>
<div className="min-w-0">
<div className="line-clamp-1 text-base font-semibold text-[var(--platform-text-strong)]">
{displayName}
</div>
<div className="mt-1 text-sm text-[var(--platform-text-soft)]">
{entry.visibility === 'published'
? `发布于 ${formatPlatformWorldTime(entry.publishedAt)}`
: '草稿待完善'}
</div>
</div>
<span className="platform-pill platform-pill--neutral px-3">
{entry.visibility === 'published' ? '已发布' : '草稿'}
</span>
</button>
);
})}
</div>
) : historyEntries.length > 0 ? (
<div className="mt-3 space-y-3">
{historyEntries.slice(0, 2).map((entry) => {
const displayName = formatPlatformWorkDisplayName(
entry.worldName,
);
return (
<button
key={`${entry.ownerUserId}:${entry.profileId}:desktop-history`}
type="button"
onClick={() =>
onOpenGalleryDetail({
ownerUserId: entry.ownerUserId,
profileId: entry.profileId,
publicWorkCode: null,
authorPublicUserCode: null,
visibility: 'published',
publishedAt: entry.visitedAt,
updatedAt: entry.visitedAt,
worldName: entry.worldName,
subtitle: entry.subtitle,
summaryText: entry.summaryText,
coverImageSrc: entry.coverImageSrc,
themeMode: entry.themeMode,
authorDisplayName: entry.authorDisplayName,
playableNpcCount: 0,
landmarkCount: 0,
likeCount: 0,
})
}
className="platform-desktop-trending-item flex w-full items-center justify-between gap-3 px-4 py-4 text-left"
>
<div className="min-w-0">
<div className="line-clamp-1 text-base font-semibold text-[var(--platform-text-strong)]">
{displayName}
</div>
<div className="mt-1 text-sm text-[var(--platform-text-soft)]">
{entry.authorDisplayName}
</div>
</div>
<span className="platform-pill platform-pill--neutral px-3">
</span>
</button>
);
})}
</div>
) : (
<div className="platform-subpanel mt-3 rounded-[1.35rem] px-4 py-4 text-sm leading-6 text-[var(--platform-text-base)]">
广
</div>
)}
</div>
</section>
</section>
) : null}
</div>
<section className="platform-desktop-panel px-5 py-5">
<SectionHeader title="最新发布" detail="PLAYER SQUARE" />
<SectionHeader title="作品分类" detail="GAME CATEGORY" />
{isLoadingPlatform ? (
<EmptyShelf text="正在读取最新发布..." />
) : desktopReleaseGrid.length > 0 ? (
<div className="grid gap-4 xl:grid-cols-3">
{desktopReleaseGrid.map((entry) => (
<WorldCard
key={`${buildPublicGalleryCardKey(entry)}:desktop-latest`}
entry={entry}
onClick={() => onOpenGalleryDetail(entry)}
className="w-full min-w-0"
/>
))}
</div>
<EmptyShelf text="正在读取作品分类..." />
) : activeCategoryGroup && desktopCategoryGrid.length > 0 ? (
<>
<div className="mb-4 flex min-w-0 items-center gap-2 overflow-x-auto pb-1 scrollbar-hide">
{categoryGroups.map((group) => {
const active = group.tag === activeCategoryGroup.tag;
return (
<button
key={`${group.tag}:desktop-category`}
type="button"
onClick={() => setSelectedCategoryTag(group.tag)}
className={`platform-category-chip shrink-0 ${active ? 'platform-category-chip--active' : ''}`}
>
{group.tag}
</button>
);
})}
</div>
<div className="grid gap-4 xl:grid-cols-3">
{desktopCategoryGrid.map((entry) => (
<WorldCard
key={`${buildPublicGalleryCardKey(entry)}:desktop-category:${activeCategoryGroup.tag}`}
entry={entry}
onClick={() => onOpenGalleryDetail(entry)}
className="w-full min-w-0"
/>
))}
</div>
</>
) : (
<EmptyShelf text="公开广场暂时还没有作品。" />
<EmptyShelf text="暂时还没有可分类的作品。" />
)}
</section>
</div>

View File

@@ -1,6 +1,7 @@
import { expect, test } from 'vitest';
import {
buildPuzzleWorkCoverSlides,
formatPlatformWorkDisplayName,
formatPlatformWorkDisplayTags,
formatPlatformWorldTime,
@@ -36,3 +37,72 @@ test('platform work display text limits names and tags by character count', () =
'星桥',
]);
});
test('buildPuzzleWorkCoverSlides prefers each level formal image', () => {
const slides = buildPuzzleWorkCoverSlides({
workId: 'work-1',
profileId: 'profile-1',
ownerUserId: 'user-1',
authorDisplayName: '玩家',
levelName: '第一关',
summary: '拼图摘要',
themeTags: ['拼图'],
coverImageSrc: '/cover.png',
publicationStatus: 'published',
updatedAt: '2026-04-25T00:00:00.000Z',
publishedAt: '2026-04-25T00:00:00.000Z',
publishReady: true,
levels: [
{
levelId: 'level-1',
levelName: '石桥',
pictureDescription: '石桥画面',
selectedCandidateId: 'candidate-2',
coverImageSrc: '/level-1-cover.png',
coverAssetId: null,
generationStatus: 'ready',
candidates: [
{
candidateId: 'candidate-1',
imageSrc: '/level-1-a.png',
assetId: 'asset-1',
prompt: '',
sourceType: 'generated',
selected: false,
},
{
candidateId: 'candidate-2',
imageSrc: '/level-1-b.png',
assetId: 'asset-2',
prompt: '',
sourceType: 'generated',
selected: false,
},
],
},
{
levelId: 'level-2',
levelName: '星港',
pictureDescription: '星港画面',
selectedCandidateId: null,
coverImageSrc: '/level-2-cover.png',
coverAssetId: null,
generationStatus: 'ready',
candidates: [],
},
],
});
expect(slides).toEqual([
{
id: 'level-1',
imageSrc: '/level-1-b.png',
label: '石桥',
},
{
id: 'level-2',
imageSrc: '/level-2-cover.png',
label: '星港',
},
]);
});

View File

@@ -1,4 +1,5 @@
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
import type { PuzzleDraftLevel } from '../../../packages/shared/src/contracts/puzzleAgentDraft';
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
import type {
CustomWorldGalleryCard,
@@ -32,6 +33,7 @@ export type PlatformPuzzleGalleryCard = {
subtitle: string;
summaryText: string;
coverImageSrc: string | null;
coverSlides?: PlatformPuzzleCoverSlide[];
themeTags: string[];
playCount?: number;
remixCount?: number;
@@ -42,6 +44,12 @@ export type PlatformPuzzleGalleryCard = {
updatedAt: string;
};
export type PlatformPuzzleCoverSlide = {
id: string;
imageSrc: string;
label: string;
};
export type PlatformBigFishGalleryCard = {
sourceType: 'big-fish';
workId: string;
@@ -100,6 +108,7 @@ export function mapPuzzleWorkToPlatformGalleryCard(
subtitle: '拼图关卡',
summaryText: work.workDescription || work.summary,
coverImageSrc: work.coverImageSrc,
coverSlides: buildPuzzleWorkCoverSlides(work),
themeTags: work.themeTags,
playCount: work.playCount ?? 0,
remixCount: work.remixCount ?? 0,
@@ -160,6 +169,89 @@ export function resolvePlatformWorldCoverImage(entry: PlatformWorldCardLike) {
return '';
}
export function resolvePlatformWorldCoverSlides(
entry: PlatformWorldCardLike,
): PlatformPuzzleCoverSlide[] {
const fallbackCoverImage = resolvePlatformWorldCoverImage(entry).trim();
const puzzleCoverSlides = isPuzzleGalleryEntry(entry)
? (entry.coverSlides ?? [])
: [];
const normalizedSlides = puzzleCoverSlides
.map((slide, index) => ({
id: slide.id.trim() || `cover-${index + 1}`,
imageSrc: slide.imageSrc.trim(),
label: slide.label.trim() || entry.worldName,
}))
.filter((slide) => Boolean(slide.imageSrc));
if (normalizedSlides.length > 0) {
return normalizedSlides;
}
return fallbackCoverImage
? [
{
id: 'cover',
imageSrc: fallbackCoverImage,
label: entry.worldName,
},
]
: [];
}
export function resolvePuzzleLevelFormalImageSrc(level: PuzzleDraftLevel) {
const selectedCandidate =
level.candidates.find(
(candidate) =>
candidate.selected ||
(level.selectedCandidateId
? candidate.candidateId === level.selectedCandidateId
: false),
) ??
level.candidates[level.candidates.length - 1] ??
null;
return (
selectedCandidate?.imageSrc?.trim() || level.coverImageSrc?.trim() || ''
);
}
export function buildPuzzleWorkCoverSlides(
work: PuzzleWorkSummary,
): PlatformPuzzleCoverSlide[] {
const slides: PlatformPuzzleCoverSlide[] = [];
const usedImageSrcSet = new Set<string>();
work.levels?.forEach((level, index) => {
const imageSrc = resolvePuzzleLevelFormalImageSrc(level);
if (!imageSrc || usedImageSrcSet.has(imageSrc)) {
return;
}
usedImageSrcSet.add(imageSrc);
slides.push({
id: level.levelId?.trim() || `puzzle-level-${index + 1}`,
imageSrc,
label: level.levelName?.trim() || `${index + 1}`,
});
});
if (slides.length > 0) {
return slides;
}
const fallbackImageSrc = work.coverImageSrc?.trim() ?? '';
return fallbackImageSrc
? [
{
id: 'cover',
imageSrc: fallbackImageSrc,
label: work.levelName,
},
]
: [];
}
export function resolvePlatformWorldLeadPortrait(entry: PlatformWorldCardLike) {
if (!isLibraryWorldEntry(entry)) {
return '';