1
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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: '星港',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -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 '';
|
||||
|
||||
Reference in New Issue
Block a user