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

@@ -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>