This commit is contained in:
2026-04-29 11:51:04 +08:00
parent e191619ab3
commit 412279ae11
89 changed files with 3966 additions and 491 deletions

View File

@@ -708,6 +708,7 @@ beforeEach(() => {
themeMode: 'tide',
playableNpcCount: 1,
landmarkCount: 1,
likeCount: 0,
},
entries: [],
});
@@ -1449,6 +1450,7 @@ test('clicking a public work while logged out routes through requireAuth', async
authorDisplayName: '潮汐作者',
playableNpcCount: 3,
landmarkCount: 4,
likeCount: 0,
},
]);
@@ -1543,6 +1545,7 @@ test('creation hub clears all private work shelves immediately after logout stat
updatedAt: '2026-04-25T10:10:00.000Z',
publishedAt: null,
playCount: 0,
likeCount: 0,
publishReady: false,
},
],
@@ -1583,6 +1586,7 @@ test('published puzzle works appear on home and category public shelves', async
updatedAt: '2026-04-25T09:00:00.000Z',
publishedAt: '2026-04-25T09:00:00.000Z',
playCount: 3,
likeCount: 0,
publishReady: true,
} satisfies PuzzleWorkSummary;
@@ -1666,6 +1670,7 @@ test('published puzzle detail returns to the source platform tab', async () => {
updatedAt: '2026-04-25T09:00:00.000Z',
publishedAt: '2026-04-25T09:00:00.000Z',
playCount: 3,
likeCount: 0,
publishReady: true,
} satisfies PuzzleWorkSummary;
@@ -1921,6 +1926,7 @@ test('puzzle draft card restores the bound agent session and opens the result vi
updatedAt: '2026-04-22T12:10:00.000Z',
publishedAt: null,
playCount: 0,
likeCount: 0,
publishReady: false,
},
],
@@ -1967,6 +1973,7 @@ test('published puzzle work card restores its source session for editing', async
updatedAt: '2026-04-25T12:10:00.000Z',
publishedAt: '2026-04-25T12:10:00.000Z',
playCount: 8,
likeCount: 0,
publishReady: true,
},
],
@@ -2007,6 +2014,7 @@ test('public code search opens a published puzzle by PZ code', async () => {
updatedAt: '2026-04-25T12:10:00.000Z',
publishedAt: '2026-04-25T12:10:00.000Z',
playCount: 8,
likeCount: 0,
publishReady: true,
};
@@ -3185,6 +3193,7 @@ test('creation hub published work can open detail view before deleting from deta
themeMode: 'tide' as const,
playableNpcCount: 0,
landmarkCount: 0,
likeCount: 0,
};
vi.mocked(listRpgCreationWorks)
@@ -3272,6 +3281,7 @@ test('creation hub published work enters existing detail view', async () => {
themeMode: 'tide',
playableNpcCount: 3,
landmarkCount: 4,
likeCount: 0,
},
]);
@@ -3345,6 +3355,7 @@ test('creation hub published work experience button enters world directly', asyn
themeMode: 'tide',
playableNpcCount: 3,
landmarkCount: 4,
likeCount: 0,
},
]);
@@ -3421,6 +3432,7 @@ test('creation hub published work delete button removes the work directly from c
themeMode: 'tide' as const,
playableNpcCount: 0,
landmarkCount: 0,
likeCount: 0,
};
vi.mocked(listRpgCreationWorks)

View File

@@ -132,6 +132,7 @@ const puzzlePublicEntry = {
summaryText: '一张用于公开分享的拼图作品。',
coverImageSrc: null,
themeTags: ['奇幻'],
likeCount: 12,
visibility: 'published',
publishedAt: '1777110165.990127Z',
updatedAt: '2026-04-25T10:00:00.000Z',
@@ -404,7 +405,7 @@ test('public gallery cards hide work code until detail is opened', async () => {
screen.queryByRole('button', { name: '复制作品号 PZ-EPUBLIC1' }),
).toBeNull();
await user.click(screen.getByRole('button', { name: //u }));
await user.click(screen.getByRole('button', { name: //u }));
expect(onOpenGalleryDetail).toHaveBeenCalledWith(puzzlePublicEntry);
});

View File

@@ -8,6 +8,7 @@ import {
Clock3,
Coins,
Copy,
Heart,
House,
LogIn,
MessageCircle,
@@ -129,6 +130,18 @@ const PLATFORM_HOME_TABS: PlatformHomeTab[] = [
'profile',
];
type ProfilePopupPanel = 'invite' | 'redeem' | 'community';
type MobileHomeChannel = 'recommend' | 'today' | 'category' | 'pc' | 'instant';
const MOBILE_HOME_CHANNELS: Array<{
id: MobileHomeChannel;
label: string;
}> = [
{ id: 'recommend', label: '推荐' },
{ id: 'today', label: '今日游戏' },
{ id: 'category', label: '游戏分类' },
{ id: 'pc', label: 'PC游戏' },
{ id: 'instant', label: '即点即玩' },
];
function usePlatformDesktopLayout() {
const [isDesktopLayout, setIsDesktopLayout] = useState(() => {
@@ -303,7 +316,6 @@ function WorldCard({
className?: string;
}) {
const coverImage = resolvePlatformWorldCoverImage(entry);
const leadPortrait = resolvePlatformWorldLeadPortrait(entry);
const tags = [
...new Set(
buildPlatformWorldTags(entry)
@@ -311,66 +323,79 @@ function WorldCard({
.filter(Boolean),
),
].slice(0, 3);
const likeCount = getPlatformWorldLikeCount(entry);
const cardLabel = `${entry.worldName}${formatCompactCount(likeCount)}点赞`;
return (
<button
type="button"
onClick={onClick}
className={`platform-surface platform-interactive-card relative flex h-[15rem] w-[min(15.25rem,78vw)] shrink-0 flex-col overflow-hidden px-3.5 py-3.5 text-left ${className ?? ''}`}
aria-label={cardLabel}
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 ?? ''}`}
>
{coverImage ? (
<ResolvedAssetBackdrop
src={coverImage}
alt={entry.worldName}
className="absolute inset-0 h-full w-full object-cover opacity-40"
/>
) : null}
{leadPortrait ? (
<ResolvedAssetImage
src={leadPortrait}
alt=""
aria-hidden="true"
className="absolute bottom-2 right-2 h-24 w-24 object-contain opacity-25"
/>
) : null}
<div className="absolute inset-0 bg-[var(--platform-card-overlay-strong)]" />
<div className="relative z-10 flex h-full flex-col">
<div className="flex items-start justify-between gap-3">
<span className="platform-pill platform-pill--warm max-w-[8.5rem] truncate">
<div className="platform-public-work-card__cover relative aspect-video overflow-hidden">
{coverImage ? (
<ResolvedAssetBackdrop
src={coverImage}
alt={entry.worldName}
className="absolute inset-0 h-full w-full object-cover"
/>
) : (
<div className="absolute inset-0 bg-[radial-gradient(circle_at_18%_16%,rgba(255,255,255,0.28),transparent_30%),linear-gradient(135deg,rgba(255,118,117,0.42),rgba(89,164,255,0.34))]" />
)}
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(0,0,0,0.02),rgba(0,0,0,0.18))]" />
<div className="absolute left-3 top-3 flex min-w-0 max-w-[calc(100%-1.5rem)] flex-wrap gap-1.5">
<span className="platform-pill platform-pill--warm max-w-[9rem] truncate px-2.5">
{badge}
</span>
<span className="platform-pill platform-pill--neutral px-2.5">
<span className="platform-pill platform-pill--neutral max-w-[9rem] truncate px-2.5">
{metaLabel}
</span>
</div>
<div className="mt-auto">
<div className="line-clamp-1 text-xl font-black text-[var(--platform-text-strong)]">
{entry.worldName}
</div>
{entry.subtitle ? (
<div className="mt-1 line-clamp-1 text-[11px] tracking-[0.16em] text-[color:color-mix(in_srgb,var(--platform-text-base)_85%,transparent)]">
{entry.subtitle}
</div>
<div className="platform-public-work-card__body flex min-h-[7.25rem] flex-col gap-2 px-3.5 py-3">
<div className="flex min-w-0 items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="line-clamp-1 break-words text-base font-black leading-tight text-[var(--platform-text-strong)]">
{entry.worldName}
</div>
) : null}
<div className="mt-2 line-clamp-2 text-xs leading-5 text-[color:color-mix(in_srgb,var(--platform-text-base)_90%,transparent)]">
{entry.summaryText || '等待补充世界摘要。'}
{entry.subtitle ? (
<div className="mt-0.5 line-clamp-1 break-words text-[11px] font-medium text-[var(--platform-text-soft)]">
{entry.subtitle}
</div>
) : null}
</div>
<div className="mt-3 flex flex-wrap gap-2">
{tags.length > 0 ? (
tags.map((tag, index) => (
<span
key={`world-tag-${index}-${tag || 'empty'}`}
className="platform-pill platform-pill--neutral px-2.5"
>
{tag}
</span>
))
) : (
<span className="platform-pill platform-pill--neutral px-2.5">
{describePublicGalleryCardKind(entry)}
<div className="platform-public-work-card__likes shrink-0 text-right">
<div className="flex items-center justify-end gap-1 text-xs font-black text-[var(--platform-warm-text)]">
<Heart className="h-3.5 w-3.5 fill-current" />
<span>{formatCompactCount(likeCount)}</span>
</div>
<div className="mt-0.5 text-[10px] font-semibold text-[var(--platform-text-soft)]">
</div>
</div>
</div>
<div className="line-clamp-2 break-words text-xs leading-5 text-[color:color-mix(in_srgb,var(--platform-text-base)_88%,transparent)]">
{entry.summaryText || entry.subtitle || '等待补充世界摘要。'}
</div>
<div className="mt-auto flex min-w-0 flex-wrap gap-1.5">
{tags.length > 0 ? (
tags.map((tag, index) => (
<span
key={`world-tag-${index}-${tag || 'empty'}`}
className="platform-pill platform-pill--neutral max-w-full px-2.5"
>
<span className="truncate">{tag}</span>
</span>
)}
</div>
))
) : (
<span className="platform-pill platform-pill--neutral px-2.5">
{describePublicGalleryCardKind(entry)}
</span>
)}
</div>
</div>
</button>
@@ -740,6 +765,21 @@ function describePublicGalleryCardKind(entry: PlatformPublicGalleryCard) {
: describePlatformThemeLabel(entry.themeMode);
}
function getPlatformWorldLikeCount(entry: PlatformWorldCardLike) {
return Math.max(0, Math.round(entry.likeCount ?? 0));
}
function formatCompactCount(value: number) {
const normalizedValue = Math.max(0, Math.round(value));
if (normalizedValue >= 100000000) {
return `${(normalizedValue / 100000000).toFixed(1)}亿`;
}
if (normalizedValue >= 10000) {
return `${(normalizedValue / 10000).toFixed(1)}`;
}
return `${normalizedValue}`;
}
function formatSnapshotTime(value: string | null | undefined) {
if (!value) {
return '刚刚保存';
@@ -1435,6 +1475,8 @@ export function RpgEntryHomeView({
const [selectedCategoryTag, setSelectedCategoryTag] = useState<string | null>(
null,
);
const [mobileHomeChannel, setMobileHomeChannel] =
useState<MobileHomeChannel>('recommend');
const [visitedTabs, setVisitedTabs] = useState<Set<PlatformHomeTab>>(
() => new Set([activeTab]),
);
@@ -1644,6 +1686,19 @@ export function RpgEntryHomeView({
const desktopFeaturedGrid = featuredShelf.slice(0, 4);
const desktopReleaseGrid = latestEntries.slice(0, 6);
const desktopLibraryPreview = myEntries.slice(0, 2);
const mobileFeedEntries = useMemo(() => {
const entryMap = new Map<string, PlatformPublicGalleryCard>();
const sourceEntries =
mobileHomeChannel === 'recommend'
? [...featuredShelf, ...latestEntries]
: latestEntries;
sourceEntries.forEach((entry) => {
entryMap.set(buildPublicGalleryCardKey(entry), entry);
});
return Array.from(entryMap.values());
}, [featuredShelf, latestEntries, mobileHomeChannel]);
const categoryPageClass = isDesktopLayout
? DESKTOP_PAGE_STAGE_CLASS
: MOBILE_PAGE_STAGE_CLASS;
@@ -1666,39 +1721,21 @@ export function RpgEntryHomeView({
isSearching={isSearchingPublicCode}
/>
<button
type="button"
onClick={openLeadPublicEntry}
className={`${HERO_SURFACE_CLASS} relative block w-full overflow-hidden px-4 py-4 text-left`}
>
<div className="absolute inset-0 bg-[var(--platform-hero-overlay-strong)]" />
<div className="relative z-10 flex min-h-[10rem] flex-col justify-between">
<div className="flex min-w-0 flex-wrap items-start justify-between gap-2">
<span className="platform-pill platform-pill--warm shrink-0">
</span>
<div className="platform-mobile-hero-secondary platform-pill platform-pill--neutral max-w-full px-3 text-[11px] tracking-[0.08em]">
{leadPublicEntry
? describePublicGalleryCardKind(leadPublicEntry)
: '作品广场'}
</div>
</div>
<div className="min-w-0">
<div className="break-all text-[clamp(1.6rem,7.4vw,1.92rem)] font-black leading-tight text-white">
{leadPublicEntry?.worldName ?? '浏览玩家作品'}
</div>
<div className="mt-2 max-w-[28rem] break-all text-sm leading-6 text-zinc-200/88">
{leadPublicEntry?.summaryText ||
leadPublicEntry?.subtitle ||
'从公开广场进入作品详情,挑一个世界开始游玩。'}
</div>
<div className="mt-4 flex min-w-0 items-center gap-2 text-sm font-semibold text-white/90">
<span className="min-w-0 break-all"></span>
<ArrowRight className="h-4 w-4" />
</div>
</div>
</div>
</button>
<div className="platform-mobile-home-channelbar flex min-w-0 gap-4 overflow-x-auto pb-1 scrollbar-hide">
{MOBILE_HOME_CHANNELS.map((channel) => {
const active = mobileHomeChannel === channel.id;
return (
<button
key={channel.id}
type="button"
onClick={() => setMobileHomeChannel(channel.id)}
className={`platform-mobile-home-channel shrink-0 ${active ? 'platform-mobile-home-channel--active' : ''}`}
>
{channel.label}
</button>
);
})}
</div>
{platformError ? (
<div className="rounded-2xl border border-rose-400/20 bg-rose-500/10 px-4 py-3 text-sm leading-6 text-rose-700">
@@ -1706,45 +1743,28 @@ export function RpgEntryHomeView({
</div>
) : null}
<section>
<SectionHeader title="精选推荐" detail="为你挑选" />
<section className="platform-mobile-home-feed">
{isLoadingPlatform ? (
<EmptyShelf text="正在读取精选作品..." />
) : featuredShelf.length > 0 ? (
<div className="flex min-w-0 gap-3 overflow-x-auto pb-1 scrollbar-hide">
{featuredShelf.map((entry: PlatformPublicGalleryCard) => (
<EmptyShelf text="正在读取公开作品..." />
) : mobileFeedEntries.length > 0 ? (
<div className="grid min-w-0 gap-3">
{mobileFeedEntries.map((entry: PlatformPublicGalleryCard) => (
<WorldCard
key={`${buildPublicGalleryCardKey(entry)}:featured`}
key={`${buildPublicGalleryCardKey(entry)}:mobile-feed:${mobileHomeChannel}`}
entry={entry}
badge="推荐"
metaLabel={describePublicGalleryCardKind(entry)}
onClick={() => onOpenGalleryDetail(entry)}
/>
))}
</div>
) : (
<EmptyShelf text="公开广场暂时还没有精选作品。" />
)}
</section>
<section>
<SectionHeader title="最新发布" detail="玩家广场" />
{isLoadingPlatform ? (
<EmptyShelf text="正在读取最新发布..." />
) : latestEntries.length > 0 ? (
<div className="flex min-w-0 gap-3 overflow-x-auto pb-1 scrollbar-hide">
{latestEntries.map((entry: PlatformPublicGalleryCard) => (
<WorldCard
key={`${buildPublicGalleryCardKey(entry)}:latest`}
entry={entry}
badge={describePublicGalleryCardKind(entry)}
badge={
mobileHomeChannel === 'recommend'
? '推荐'
: describePublicGalleryCardKind(entry)
}
metaLabel={entry.authorDisplayName}
onClick={() => onOpenGalleryDetail(entry)}
className="w-full"
/>
))}
</div>
) : (
<EmptyShelf text="公开广场暂时还没有作品。" />
<EmptyShelf text="公开广场暂时还没有可展示的作品。" />
)}
</section>
</div>
@@ -1783,7 +1803,7 @@ export function RpgEntryHomeView({
badge={activeCategoryGroup.tag}
metaLabel={entry.authorDisplayName}
onClick={() => onOpenGalleryDetail(entry)}
className="h-[15rem] w-full min-w-0 sm:h-[16rem]"
className="w-full min-w-0"
/>
))}
</div>
@@ -2226,7 +2246,7 @@ export function RpgEntryHomeView({
badge="推荐"
metaLabel={describePublicGalleryCardKind(entry)}
onClick={() => onOpenGalleryDetail(entry)}
className="h-[16rem] w-full min-w-0"
className="w-full min-w-0"
/>
))}
</div>
@@ -2304,6 +2324,7 @@ export function RpgEntryHomeView({
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"
@@ -2344,7 +2365,7 @@ export function RpgEntryHomeView({
badge={describePublicGalleryCardKind(entry)}
metaLabel={entry.authorDisplayName}
onClick={() => onOpenGalleryDetail(entry)}
className="h-[17rem] w-full min-w-0"
className="w-full min-w-0"
/>
))}
</div>

View File

@@ -1,9 +1,9 @@
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
import type {
CustomWorldGalleryCard,
CustomWorldLibraryEntry,
} from '../../../packages/shared/src/contracts/runtime';
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
import { buildCustomWorldPlayableCharacters } from '../../data/characterPresets';
import { resolveCustomWorldCampSceneImage } from '../../data/customWorldVisuals';
import {
@@ -30,6 +30,9 @@ export type PlatformPuzzleGalleryCard = {
summaryText: string;
coverImageSrc: string | null;
themeTags: string[];
playCount?: number;
remixCount?: number;
likeCount?: number;
visibility: 'published';
publishedAt: string | null;
updatedAt: string;
@@ -47,6 +50,9 @@ export type PlatformBigFishGalleryCard = {
summaryText: string;
coverImageSrc: string | null;
themeTags: string[];
playCount?: number;
remixCount?: number;
likeCount?: number;
visibility: 'published';
publishedAt: string | null;
updatedAt: string;
@@ -90,6 +96,9 @@ export function mapPuzzleWorkToPlatformGalleryCard(
summaryText: work.summary,
coverImageSrc: work.coverImageSrc,
themeTags: work.themeTags,
playCount: work.playCount ?? 0,
remixCount: work.remixCount ?? 0,
likeCount: work.likeCount ?? 0,
visibility: 'published',
publishedAt: work.publishedAt,
updatedAt: work.updatedAt,
@@ -111,12 +120,24 @@ export function mapBigFishWorkToPlatformGalleryCard(
summaryText: work.summary,
coverImageSrc: work.coverImageSrc,
themeTags: ['大鱼', `${work.levelCount}`],
playCount: work.playCount ?? 0,
remixCount: work.remixCount ?? 0,
likeCount: work.likeCount ?? 0,
visibility: 'published',
publishedAt: work.updatedAt,
publishedAt: work.publishedAt ?? work.updatedAt,
updatedAt: work.updatedAt,
};
}
export function resolvePlatformWorldStats(entry: PlatformWorldCardLike) {
return {
playCount: 'playCount' in entry ? (entry.playCount ?? 0) : 0,
remixCount: 'remixCount' in entry ? (entry.remixCount ?? 0) : 0,
likeCount: 'likeCount' in entry ? (entry.likeCount ?? 0) : 0,
publishedAt: entry.publishedAt ?? null,
};
}
export function resolvePlatformWorldCoverImage(entry: PlatformWorldCardLike) {
if (entry.coverImageSrc) {
return entry.coverImageSrc;

View File

@@ -280,6 +280,7 @@ describe('RPG Agent 草稿恢复', () => {
themeMode: 'tide',
playableNpcCount: 0,
landmarkCount: 0,
likeCount: 0,
},
entries: [],
});

View File

@@ -161,6 +161,27 @@ export function useRpgEntryLibraryDetail(
[appendBrowseHistoryEntry, setSelectedDetailEntry, setSelectionStage],
);
const loadGalleryDetailEntry = useCallback(
async (entry: CustomWorldGalleryCard) => {
const detailEntry = await getRpgEntryWorldGalleryDetail(
entry.ownerUserId,
entry.profileId,
);
void appendBrowseHistoryEntry({
ownerUserId: detailEntry.ownerUserId,
profileId: detailEntry.profileId,
worldName: detailEntry.worldName,
subtitle: detailEntry.subtitle,
summaryText: detailEntry.summaryText,
coverImageSrc: detailEntry.coverImageSrc,
themeMode: detailEntry.themeMode,
authorDisplayName: detailEntry.authorDisplayName,
});
return detailEntry;
},
[appendBrowseHistoryEntry],
);
const openGalleryDetail = useCallback(
async (entry: CustomWorldGalleryCard) => {
setSelectionStage('detail');
@@ -168,26 +189,13 @@ export function useRpgEntryLibraryDetail(
setDetailError(null);
try {
const detailEntry = await getRpgEntryWorldGalleryDetail(
entry.ownerUserId,
entry.profileId,
);
const detailEntry = await loadGalleryDetailEntry(entry);
setSelectedDetailEntry(detailEntry);
if (detailEntry.publicWorkCode?.trim()) {
pushAppHistoryPath(
buildPublicWorkDetailPath(detailEntry.publicWorkCode),
);
}
void appendBrowseHistoryEntry({
ownerUserId: detailEntry.ownerUserId,
profileId: detailEntry.profileId,
worldName: detailEntry.worldName,
subtitle: detailEntry.subtitle,
summaryText: detailEntry.summaryText,
coverImageSrc: detailEntry.coverImageSrc,
themeMode: detailEntry.themeMode,
authorDisplayName: detailEntry.authorDisplayName,
});
} catch (error) {
setSelectedDetailEntry(null);
setDetailError(
@@ -197,7 +205,11 @@ export function useRpgEntryLibraryDetail(
setIsDetailLoading(false);
}
},
[appendBrowseHistoryEntry, setSelectedDetailEntry, setSelectionStage],
[
loadGalleryDetailEntry,
setSelectedDetailEntry,
setSelectionStage,
],
);
const openSavedCustomWorldEditor = useCallback(
@@ -489,6 +501,7 @@ export function useRpgEntryLibraryDetail(
isSelectedWorldOwned,
openLibraryDetail,
openGalleryDetail,
loadGalleryDetailEntry,
openSavedCustomWorldEditor,
handleOpenCreationWork,
handlePublishSelectedWorld,