refactor: 收口公开作品 ViewModel

This commit is contained in:
2026-06-03 16:23:11 +08:00
parent 4a185ac8c2
commit e9534baace
6 changed files with 833 additions and 413 deletions

View File

@@ -14,9 +14,9 @@ import {
Gamepad2,
GitFork,
Heart,
Loader2,
LogIn,
MessageCircle,
Loader2,
Palette,
Pencil,
Plus,
@@ -135,6 +135,27 @@ import {
import { getInitialPlatformDesktopLayout } from '../platform-entry/platformEntryResponsive';
import { ResolvedAssetImage } from '../ResolvedAssetImage';
import { RpgEntryBrandLogo } from './RpgEntryBrandLogo';
import {
buildPlatformRankingEntries,
buildPublicCategoryGroups,
buildPublicGalleryCardKey,
filterPlatformWorkSearchResults,
filterTodayPublishedEntries,
getAllPlatformPublicEntries,
getPlatformCategoryPrimaryMetric,
getPlatformPublicEntries,
getPlatformRankingMetricValue,
getPlatformSearchableWorkIds,
getPlatformWorldLikeCount,
getPlatformWorldPlayCount,
getPlatformWorldRemixCount,
isExactPublicWorkCodeSearch,
matchesPlatformCategoryKindFilter,
type PlatformCategoryKindFilter,
type PlatformCategorySortMode,
type PlatformRankingTab,
sortPlatformCategoryEntries,
} from './rpgEntryPublicGalleryViewModel';
import {
buildPlatformWorldDisplayTags,
describePlatformThemeLabel,
@@ -151,9 +172,8 @@ import {
isVisualNovelGalleryEntry,
isWoodenFishGalleryEntry,
type PlatformPublicGalleryCard,
type PlatformWorldCardLike,
resolvePlatformWorkAuthorDisplayName,
resolvePlatformPublicWorkCode,
resolvePlatformWorkAuthorDisplayName,
resolvePlatformWorldCoverImage,
resolvePlatformWorldCoverSlides,
resolvePlatformWorldFallbackCoverImage,
@@ -361,17 +381,6 @@ type DiscoverChannel =
| 'category'
| 'ranking'
| 'edutainment';
type PlatformRankingTab = 'hot' | 'remix' | 'new' | 'like';
type PlatformCategoryKindFilter =
| 'all'
| 'puzzle'
| 'match3d'
| 'square-hole'
| 'visual-novel'
| 'bark-battle'
| 'big-fish'
| 'custom-world';
type PlatformCategorySortMode = 'composite' | 'latest' | 'play' | 'like';
const COMMUNITY_QR_CODES = [
{
@@ -410,6 +419,8 @@ const PLATFORM_CATEGORY_KIND_FILTERS: Array<{
{ id: 'visual-novel', label: '视觉' },
{ id: 'bark-battle', label: '汪汪' },
{ id: 'big-fish', label: '大鱼' },
{ id: 'jump-hop', label: '跳跃' },
{ id: 'wooden-fish', label: '木鱼' },
{ id: 'custom-world', label: 'RPG' },
];
const PLATFORM_CATEGORY_SORT_OPTIONS: Array<{
@@ -1639,186 +1650,6 @@ function PlatformCategoryFilterDialog({
);
}
function buildPublicCategoryGroups(
featuredEntries: PlatformPublicGalleryCard[],
latestEntries: PlatformPublicGalleryCard[],
) {
const publicEntryMap = new Map<string, PlatformPublicGalleryCard>();
filterGeneralPublicWorks([...featuredEntries, ...latestEntries]).forEach(
(entry) => {
publicEntryMap.set(buildPublicGalleryCardKey(entry), entry);
},
);
const categoryMap = new Map<string, PlatformPublicGalleryCard[]>();
Array.from(publicEntryMap.values()).forEach((entry) => {
const tags = buildPlatformWorldDisplayTags(entry, 3);
const normalizedTags = tags.length > 0 ? tags : ['回响'];
normalizedTags.forEach((tag) => {
const entries = categoryMap.get(tag) ?? [];
entries.push(entry);
categoryMap.set(tag, entries);
});
});
return Array.from(categoryMap.entries())
.map(([tag, entries]) => ({ tag, entries }))
.sort((left, right) => {
if (right.entries.length !== left.entries.length) {
return right.entries.length - left.entries.length;
}
return left.tag.localeCompare(right.tag, 'zh-CN');
});
}
function getPlatformPublicEntries(
featuredEntries: PlatformPublicGalleryCard[],
latestEntries: PlatformPublicGalleryCard[],
) {
const entryMap = new Map<string, PlatformPublicGalleryCard>();
filterGeneralPublicWorks([...featuredEntries, ...latestEntries]).forEach(
(entry) => {
entryMap.set(buildPublicGalleryCardKey(entry), entry);
},
);
return Array.from(entryMap.values());
}
function getAllPlatformPublicEntries(
featuredEntries: PlatformPublicGalleryCard[],
latestEntries: PlatformPublicGalleryCard[],
) {
const entryMap = new Map<string, PlatformPublicGalleryCard>();
[...featuredEntries, ...latestEntries].forEach((entry) => {
entryMap.set(buildPublicGalleryCardKey(entry), entry);
});
return Array.from(entryMap.values());
}
function normalizePlatformSearchText(value: string | null | undefined) {
return (value ?? '').trim().toLocaleLowerCase('zh-CN');
}
function normalizePlatformCompactSearchText(value: string | null | undefined) {
return normalizePlatformSearchText(value).replace(/[\s_-]+/gu, '');
}
function getPlatformSearchableWorkIds(entry: PlatformPublicGalleryCard) {
const ids = [entry.publicWorkCode, entry.profileId];
if ('workId' in entry) {
ids.push(entry.workId);
}
return ids.filter((value): value is string => Boolean(value?.trim()));
}
function buildPlatformWorkSearchText(entry: PlatformPublicGalleryCard) {
return [
...getPlatformSearchableWorkIds(entry),
entry.worldName,
entry.authorDisplayName,
entry.summaryText,
entry.subtitle,
].join(' ');
}
function matchesPlatformWorkSearch(
entry: PlatformPublicGalleryCard,
keyword: string,
) {
const normalizedKeyword = normalizePlatformSearchText(keyword);
const compactKeyword = normalizePlatformCompactSearchText(keyword);
if (!normalizedKeyword) {
return false;
}
const normalizedSearchText = normalizePlatformSearchText(
buildPlatformWorkSearchText(entry),
);
if (normalizedSearchText.includes(normalizedKeyword)) {
return true;
}
return (
Boolean(compactKeyword) &&
normalizePlatformCompactSearchText(
buildPlatformWorkSearchText(entry),
).includes(compactKeyword)
);
}
function filterPlatformWorkSearchResults(
entries: PlatformPublicGalleryCard[],
keyword: string,
) {
return entries
.filter((entry) => matchesPlatformWorkSearch(entry, keyword))
.sort((left, right) => {
const leftCode = getPlatformSearchableWorkIds(left)[0] ?? '';
const rightCode = getPlatformSearchableWorkIds(right)[0] ?? '';
const normalizedKeyword = normalizePlatformSearchText(keyword);
const leftNameStarts = normalizePlatformSearchText(
left.worldName,
).startsWith(normalizedKeyword);
const rightNameStarts = normalizePlatformSearchText(
right.worldName,
).startsWith(normalizedKeyword);
if (leftNameStarts !== rightNameStarts) {
return leftNameStarts ? -1 : 1;
}
const leftCodeStarts = normalizePlatformCompactSearchText(
leftCode,
).startsWith(normalizePlatformCompactSearchText(keyword));
const rightCodeStarts = normalizePlatformCompactSearchText(
rightCode,
).startsWith(normalizePlatformCompactSearchText(keyword));
if (leftCodeStarts !== rightCodeStarts) {
return leftCodeStarts ? -1 : 1;
}
return getPlatformWorldTimestamp(right) - getPlatformWorldTimestamp(left);
});
}
function isExactPublicWorkCodeSearch(
entries: PlatformPublicGalleryCard[],
keyword: string,
) {
const normalizedKeyword = normalizePlatformSearchText(keyword);
return entries.some(
(entry) =>
Boolean(entry.publicWorkCode?.trim()) &&
normalizePlatformSearchText(entry.publicWorkCode) === normalizedKeyword,
);
}
function buildPublicGalleryCardKey(entry: PlatformPublicGalleryCard) {
const kind = isBigFishGalleryEntry(entry)
? 'big-fish'
: isPuzzleGalleryEntry(entry)
? 'puzzle'
: isMatch3DGalleryEntry(entry)
? 'match3d'
: isSquareHoleGalleryEntry(entry)
? 'square-hole'
: isVisualNovelGalleryEntry(entry)
? 'visual-novel'
: isBarkBattleGalleryEntry(entry)
? 'bark-battle'
: isEdutainmentGalleryEntry(entry)
? `edutainment:${entry.templateId}`
: 'rpg';
return `${kind}:${entry.ownerUserId}:${entry.profileId}`;
}
function PlatformWorkSearchResults({
keyword,
entries,
@@ -1950,225 +1781,6 @@ function getPublicAuthorAvatarLabel(authorDisplayName: string) {
return Array.from(authorDisplayName.trim() || '玩')[0] ?? '玩';
}
function getPlatformWorldLikeCount(entry: PlatformWorldCardLike) {
return Math.max(0, Math.round(entry.likeCount ?? 0));
}
function getPlatformWorldPlayCount(entry: PlatformWorldCardLike) {
return Math.max(
0,
Math.round(('playCount' in entry && entry.playCount) || 0),
);
}
function getPlatformWorldRemixCount(entry: PlatformWorldCardLike) {
return Math.max(
0,
Math.round(('remixCount' in entry && entry.remixCount) || 0),
);
}
function getPlatformWorldRecentPlayCount(entry: PlatformWorldCardLike) {
return Math.max(
0,
Math.round(('recentPlayCount7d' in entry && entry.recentPlayCount7d) || 0),
);
}
function getPlatformWorldTimestamp(entry: PlatformWorldCardLike) {
const rawTime = entry.publishedAt ?? entry.updatedAt;
return parsePlatformEntryTimestamp(rawTime);
}
// 首页“今日游戏”只看作品首次发布时间,按玩家浏览器本地自然日判断。
function parsePlatformEntryTimestamp(value: string | null | undefined) {
if (!value) {
return 0;
}
const normalized = value.trim();
const numericTimestamp = normalized.match(/^(-?\d+(?:\.\d+)?)(?:Z)?$/u);
if (numericTimestamp?.[1]) {
const rawTimestamp = Number(numericTimestamp[1]);
if (Number.isFinite(rawTimestamp)) {
const absoluteTimestamp = Math.abs(rawTimestamp);
const timestampMs =
absoluteTimestamp >= 1_000_000_000_000_000
? rawTimestamp / 1000
: absoluteTimestamp >= 1_000_000_000_000
? rawTimestamp
: absoluteTimestamp >= 1_000_000_000
? rawTimestamp * 1000
: Number.NaN;
return Number.isNaN(timestampMs) ? 0 : timestampMs;
}
}
const timestamp = new Date(normalized).getTime();
return Number.isNaN(timestamp) ? 0 : timestamp;
}
function isSameLocalCalendarDay(left: Date, right: Date) {
return (
left.getFullYear() === right.getFullYear() &&
left.getMonth() === right.getMonth() &&
left.getDate() === right.getDate()
);
}
function isPublishedToday(entry: PlatformPublicGalleryCard, now = new Date()) {
const publishedAtTimestamp = parsePlatformEntryTimestamp(entry.publishedAt);
if (publishedAtTimestamp <= 0) {
return false;
}
const publishedAt = new Date(publishedAtTimestamp);
return isSameLocalCalendarDay(publishedAt, now);
}
function filterTodayPublishedEntries(entries: PlatformPublicGalleryCard[]) {
const now = new Date();
return entries.filter((entry) => isPublishedToday(entry, now));
}
function sortEntriesByMetric(
entries: PlatformPublicGalleryCard[],
getMetric: (entry: PlatformPublicGalleryCard) => number,
) {
return [...entries].sort((left, right) => {
const metricDiff = getMetric(right) - getMetric(left);
if (metricDiff !== 0) {
return metricDiff;
}
return getPlatformWorldTimestamp(right) - getPlatformWorldTimestamp(left);
});
}
function buildPlatformRankingEntries(
entries: PlatformPublicGalleryCard[],
tab: PlatformRankingTab,
) {
if (tab === 'hot') {
return sortEntriesByMetric(entries, getPlatformWorldPlayCount);
}
if (tab === 'remix') {
return sortEntriesByMetric(entries, getPlatformWorldRemixCount);
}
if (tab === 'like') {
return sortEntriesByMetric(entries, getPlatformWorldLikeCount);
}
return sortEntriesByMetric(entries, getPlatformWorldRecentPlayCount);
}
function getPlatformRankingMetricValue(
entry: PlatformPublicGalleryCard,
tab: PlatformRankingTab,
) {
if (tab === 'remix') {
return getPlatformWorldRemixCount(entry);
}
if (tab === 'like') {
return getPlatformWorldLikeCount(entry);
}
if (tab === 'new') {
return getPlatformWorldRecentPlayCount(entry);
}
return getPlatformWorldPlayCount(entry);
}
function getPlatformCategoryCompositeScore(entry: PlatformPublicGalleryCard) {
// 分类频道只使用公开读模型已经返回的指标做前端排序,不在 UI 层伪造评分数据。
return (
getPlatformWorldPlayCount(entry) +
getPlatformWorldRemixCount(entry) +
getPlatformWorldLikeCount(entry) +
getPlatformWorldRecentPlayCount(entry)
);
}
function getPlatformCategoryKindFilter(entry: PlatformPublicGalleryCard) {
if (isPuzzleGalleryEntry(entry)) {
return 'puzzle';
}
if (isMatch3DGalleryEntry(entry)) {
return 'match3d';
}
if (isSquareHoleGalleryEntry(entry)) {
return 'square-hole';
}
if (isVisualNovelGalleryEntry(entry)) {
return 'visual-novel';
}
if (isBarkBattleGalleryEntry(entry)) {
return 'bark-battle';
}
if (isBigFishGalleryEntry(entry)) {
return 'big-fish';
}
return 'custom-world';
}
function matchesPlatformCategoryKindFilter(
entry: PlatformPublicGalleryCard,
kindFilter: PlatformCategoryKindFilter,
) {
return (
kindFilter === 'all' || getPlatformCategoryKindFilter(entry) === kindFilter
);
}
function sortPlatformCategoryEntries(
entries: PlatformPublicGalleryCard[],
sortMode: PlatformCategorySortMode,
) {
return [...entries].sort((left, right) => {
if (sortMode === 'latest') {
return getPlatformWorldTimestamp(right) - getPlatformWorldTimestamp(left);
}
const metricDiff =
sortMode === 'play'
? getPlatformWorldPlayCount(right) - getPlatformWorldPlayCount(left)
: sortMode === 'like'
? getPlatformWorldLikeCount(right) - getPlatformWorldLikeCount(left)
: getPlatformCategoryCompositeScore(right) -
getPlatformCategoryCompositeScore(left);
if (metricDiff !== 0) {
return metricDiff;
}
return getPlatformWorldTimestamp(right) - getPlatformWorldTimestamp(left);
});
}
function getPlatformCategoryPrimaryMetric(entry: PlatformPublicGalleryCard) {
const likeCount = getPlatformWorldLikeCount(entry);
if (likeCount > 0) {
return { label: '点赞', value: likeCount };
}
const recentPlayCount = getPlatformWorldRecentPlayCount(entry);
if (recentPlayCount > 0) {
return { label: '近7日', value: recentPlayCount };
}
return { label: '游玩', value: getPlatformWorldPlayCount(entry) };
}
function formatCompactCount(value: number) {
const normalizedValue = Math.max(0, Math.round(value));
if (normalizedValue >= 100000000) {

View File

@@ -0,0 +1,327 @@
import { expect, test } from 'vitest';
import type { CustomWorldGalleryCard } from '../../../packages/shared/src/contracts/runtime';
import {
buildPlatformRankingEntries,
buildPublicCategoryGroups,
buildPublicGalleryCardKey,
filterPlatformWorkSearchResults,
filterTodayPublishedEntries,
getPlatformCategoryKindFilter,
getPlatformCategoryPrimaryMetric,
getPlatformPublicEntries,
getPlatformRankingMetricValue,
matchesPlatformCategoryKindFilter,
parsePlatformEntryTimestamp,
sortPlatformCategoryEntries,
} from './rpgEntryPublicGalleryViewModel';
import type {
PlatformJumpHopGalleryCard,
PlatformPuzzleGalleryCard,
PlatformWoodenFishGalleryCard,
} from './rpgEntryWorldPresentation';
function buildPuzzleEntry(
overrides: Partial<PlatformPuzzleGalleryCard> = {},
): PlatformPuzzleGalleryCard {
return {
sourceType: 'puzzle',
workId: 'puzzle-work',
profileId: 'shared-profile',
publicWorkCode: 'PZ-SHARED',
ownerUserId: 'user-1',
authorDisplayName: '拼图作者',
worldName: '星桥拼图',
subtitle: '拼图副标题',
summaryText: '星桥机关摘要',
coverImageSrc: null,
themeTags: ['星桥', '机关'],
visibility: 'published',
publishedAt: '2026-05-01T00:00:00.000Z',
updatedAt: '2026-05-01T00:00:00.000Z',
...overrides,
};
}
function buildJumpHopEntry(
overrides: Partial<PlatformJumpHopGalleryCard> = {},
): PlatformJumpHopGalleryCard {
return {
sourceType: 'jump-hop',
workId: 'jump-hop-work',
profileId: 'shared-profile',
publicWorkCode: 'JH-SHARED',
ownerUserId: 'user-1',
authorDisplayName: '跳一跳作者',
worldName: '星桥跳一跳',
subtitle: '跳一跳副标题',
summaryText: '跳一跳摘要',
coverImageSrc: null,
themeTags: ['跳跃'],
visibility: 'published',
publishedAt: '2026-05-02T00:00:00.000Z',
updatedAt: '2026-05-02T00:00:00.000Z',
...overrides,
};
}
function buildWoodenFishEntry(
overrides: Partial<PlatformWoodenFishGalleryCard> = {},
): PlatformWoodenFishGalleryCard {
return {
sourceType: 'wooden-fish',
workId: 'wooden-fish-work',
profileId: 'shared-profile',
publicWorkCode: 'WF-SHARED',
ownerUserId: 'user-1',
authorDisplayName: '木鱼作者',
worldName: '星桥木鱼',
subtitle: '木鱼副标题',
summaryText: '木鱼摘要',
coverImageSrc: null,
themeTags: ['敲木鱼'],
visibility: 'published',
publishedAt: '2026-05-03T00:00:00.000Z',
updatedAt: '2026-05-03T00:00:00.000Z',
...overrides,
};
}
function buildRpgEntry(
overrides: Partial<CustomWorldGalleryCard> = {},
): CustomWorldGalleryCard {
return {
ownerUserId: 'user-1',
profileId: 'shared-profile',
publicWorkCode: 'CW-SHARED',
authorPublicUserCode: null,
visibility: 'published',
publishedAt: '2026-05-04T00:00:00.000Z',
updatedAt: '2026-05-04T00:00:00.000Z',
authorDisplayName: 'RPG 作者',
worldName: '星桥 RPG',
subtitle: 'RPG 副标题',
summaryText: 'RPG 摘要',
coverImageSrc: null,
themeMode: 'martial',
playableNpcCount: 1,
landmarkCount: 1,
...overrides,
};
}
test('public gallery ViewModel keeps play kinds distinct in card keys', () => {
expect(buildPublicGalleryCardKey(buildPuzzleEntry())).toBe(
'puzzle:user-1:shared-profile',
);
expect(buildPublicGalleryCardKey(buildJumpHopEntry())).toBe(
'jump-hop:user-1:shared-profile',
);
expect(buildPublicGalleryCardKey(buildWoodenFishEntry())).toBe(
'wooden-fish:user-1:shared-profile',
);
expect(buildPublicGalleryCardKey(buildRpgEntry())).toBe(
'rpg:user-1:shared-profile',
);
});
test('public gallery ViewModel dedupes merged public entries by latest source', () => {
const oldPuzzle = buildPuzzleEntry({
worldName: '旧拼图',
updatedAt: '2026-05-01T00:00:00.000Z',
});
const latestPuzzle = buildPuzzleEntry({
worldName: '新拼图',
updatedAt: '2026-05-02T00:00:00.000Z',
});
expect(getPlatformPublicEntries([oldPuzzle], [latestPuzzle])).toEqual([
latestPuzzle,
]);
const categoryGroups = buildPublicCategoryGroups([oldPuzzle], [latestPuzzle]);
expect(categoryGroups.find((group) => group.tag === '星桥')).toEqual({
tag: '星桥',
entries: [latestPuzzle],
});
});
test('public gallery ViewModel searches compact work codes and ranks name prefix first', () => {
const nameMatch = buildPuzzleEntry({
profileId: 'name-match',
publicWorkCode: 'PZ-OLDER',
worldName: '星桥拼图',
updatedAt: '2026-05-01T00:00:00.000Z',
});
const codeMatch = buildPuzzleEntry({
profileId: 'code-match',
publicWorkCode: 'PZ-XING-QIAO',
worldName: '海雾机关',
updatedAt: '2026-05-03T00:00:00.000Z',
});
const jumpHopCodeMatch = buildJumpHopEntry({
profileId: 'jump-code-match',
publicWorkCode: 'JH-XING-QIAO',
worldName: '海雾跳跃',
});
const woodenFishCodeMatch = buildWoodenFishEntry({
profileId: 'wooden-code-match',
publicWorkCode: 'WF-DEEP-CALM',
worldName: '静心木鱼',
});
expect(filterPlatformWorkSearchResults([codeMatch, nameMatch], '星桥')).toEqual(
[nameMatch, codeMatch],
);
expect(filterPlatformWorkSearchResults([codeMatch], 'pz xing_qiao')).toEqual([
codeMatch,
]);
expect(
filterPlatformWorkSearchResults([jumpHopCodeMatch], 'jh xing-qiao'),
).toEqual([jumpHopCodeMatch]);
expect(
filterPlatformWorkSearchResults([woodenFishCodeMatch], 'wf deep_calm'),
).toEqual([woodenFishCodeMatch]);
});
test('public gallery ViewModel keeps source kinds behind one category filter seam', () => {
const jumpHopEntry = buildJumpHopEntry();
const woodenFishEntry = buildWoodenFishEntry();
const rpgEntry = buildRpgEntry();
expect(getPlatformCategoryKindFilter(jumpHopEntry)).toBe('jump-hop');
expect(getPlatformCategoryKindFilter(woodenFishEntry)).toBe('wooden-fish');
expect(getPlatformCategoryKindFilter(rpgEntry)).toBe('custom-world');
expect(matchesPlatformCategoryKindFilter(jumpHopEntry, 'jump-hop')).toBe(
true,
);
expect(matchesPlatformCategoryKindFilter(woodenFishEntry, 'wooden-fish')).toBe(
true,
);
expect(matchesPlatformCategoryKindFilter(jumpHopEntry, 'custom-world')).toBe(
false,
);
expect(matchesPlatformCategoryKindFilter(woodenFishEntry, 'custom-world')).toBe(
false,
);
});
test('public gallery ViewModel ranks entries by selected metric', () => {
const playWinner = buildJumpHopEntry({
profileId: 'play-winner',
playCount: 100,
remixCount: 1,
likeCount: 1,
recentPlayCount7d: 1,
});
const remixWinner = buildPuzzleEntry({
profileId: 'remix-winner',
playCount: 2,
remixCount: 50,
likeCount: 2,
recentPlayCount7d: 2,
});
const recentWinner = buildPuzzleEntry({
profileId: 'recent-winner',
playCount: 3,
remixCount: 3,
likeCount: 3,
recentPlayCount7d: 30,
});
const likeWinner = buildWoodenFishEntry({
profileId: 'like-winner',
playCount: 4,
remixCount: 4,
likeCount: 40,
recentPlayCount7d: 4,
});
const entries = [recentWinner, remixWinner, likeWinner, playWinner];
expect(buildPlatformRankingEntries(entries, 'hot')[0]).toBe(playWinner);
expect(buildPlatformRankingEntries(entries, 'remix')[0]).toBe(remixWinner);
expect(buildPlatformRankingEntries(entries, 'new')[0]).toBe(recentWinner);
expect(buildPlatformRankingEntries(entries, 'like')[0]).toBe(likeWinner);
expect(getPlatformRankingMetricValue(likeWinner, 'like')).toBe(40);
});
test('public gallery ViewModel sorts category entries and exposes primary metric', () => {
const latestEntry = buildWoodenFishEntry({
profileId: 'latest',
playCount: 1,
likeCount: 0,
recentPlayCount7d: 0,
publishedAt: '2026-05-05T00:00:00.000Z',
updatedAt: '2026-05-05T00:00:00.000Z',
});
const playEntry = buildJumpHopEntry({
profileId: 'play',
playCount: 100,
likeCount: 0,
recentPlayCount7d: 0,
publishedAt: '2026-05-03T00:00:00.000Z',
updatedAt: '2026-05-03T00:00:00.000Z',
});
const likeEntry = buildPuzzleEntry({
profileId: 'like',
playCount: 1,
likeCount: 20,
recentPlayCount7d: 0,
publishedAt: '2026-05-02T00:00:00.000Z',
updatedAt: '2026-05-02T00:00:00.000Z',
});
const compositeEntry = buildPuzzleEntry({
profileId: 'composite',
playCount: 30,
remixCount: 30,
likeCount: 30,
recentPlayCount7d: 30,
publishedAt: '2026-05-01T00:00:00.000Z',
updatedAt: '2026-05-01T00:00:00.000Z',
});
const entries = [likeEntry, latestEntry, compositeEntry, playEntry];
expect(sortPlatformCategoryEntries(entries, 'latest')[0]).toBe(latestEntry);
expect(sortPlatformCategoryEntries(entries, 'play')[0]).toBe(playEntry);
expect(sortPlatformCategoryEntries(entries, 'like')[0]).toBe(compositeEntry);
expect(sortPlatformCategoryEntries(entries, 'composite')[0]).toBe(
compositeEntry,
);
expect(getPlatformCategoryPrimaryMetric(likeEntry)).toEqual({
label: '点赞',
value: 20,
});
expect(
getPlatformCategoryPrimaryMetric(
buildPuzzleEntry({ likeCount: 0, recentPlayCount7d: 8, playCount: 2 }),
),
).toEqual({ label: '近7日', value: 8 });
});
test('public gallery ViewModel filters entries published on the local day', () => {
const now = new Date(2026, 5, 3, 12);
const todayEntry = buildPuzzleEntry({
profileId: 'today',
publishedAt: new Date(2026, 5, 3, 8).toISOString(),
});
const yesterdayEntry = buildPuzzleEntry({
profileId: 'yesterday',
publishedAt: new Date(2026, 5, 2, 8).toISOString(),
});
const unpublishedEntry = buildPuzzleEntry({
profileId: 'unpublished',
publishedAt: null,
});
expect(
filterTodayPublishedEntries(
[yesterdayEntry, todayEntry, unpublishedEntry],
now,
),
).toEqual([todayEntry]);
});
test('public gallery ViewModel parses backend numeric timestamps', () => {
expect(parsePlatformEntryTimestamp('1778457601.234567Z')).toBe(
1778457601234.567,
);
});

View File

@@ -0,0 +1,433 @@
import { filterGeneralPublicWorks } from '../platform-entry/platformEdutainmentVisibility';
import { getPlatformPublicGalleryEntryKey } from '../platform-entry/platformPublicGalleryFlow';
import {
buildPlatformWorldDisplayTags,
isBarkBattleGalleryEntry,
isBigFishGalleryEntry,
isJumpHopGalleryEntry,
isMatch3DGalleryEntry,
isPuzzleGalleryEntry,
isSquareHoleGalleryEntry,
isVisualNovelGalleryEntry,
isWoodenFishGalleryEntry,
type PlatformPublicGalleryCard,
type PlatformWorldCardLike,
} from './rpgEntryWorldPresentation';
export type PlatformRankingTab = 'hot' | 'remix' | 'new' | 'like';
export type PlatformCategoryKindFilter =
| 'all'
| 'puzzle'
| 'match3d'
| 'square-hole'
| 'visual-novel'
| 'bark-battle'
| 'big-fish'
| 'jump-hop'
| 'wooden-fish'
| 'custom-world';
export type PlatformCategorySortMode = 'composite' | 'latest' | 'play' | 'like';
export type PlatformPublicCategoryGroup = {
tag: string;
entries: PlatformPublicGalleryCard[];
};
export function buildPublicGalleryCardKey(entry: PlatformPublicGalleryCard) {
return getPlatformPublicGalleryEntryKey(entry);
}
export function buildPublicCategoryGroups(
featuredEntries: PlatformPublicGalleryCard[],
latestEntries: PlatformPublicGalleryCard[],
): PlatformPublicCategoryGroup[] {
const publicEntryMap = new Map<string, PlatformPublicGalleryCard>();
filterGeneralPublicWorks([...featuredEntries, ...latestEntries]).forEach(
(entry) => {
publicEntryMap.set(buildPublicGalleryCardKey(entry), entry);
},
);
const categoryMap = new Map<string, PlatformPublicGalleryCard[]>();
Array.from(publicEntryMap.values()).forEach((entry) => {
const tags = buildPlatformWorldDisplayTags(entry, 3);
const normalizedTags = tags.length > 0 ? tags : ['回响'];
normalizedTags.forEach((tag) => {
const entries = categoryMap.get(tag) ?? [];
entries.push(entry);
categoryMap.set(tag, entries);
});
});
return Array.from(categoryMap.entries())
.map(([tag, entries]) => ({ tag, entries }))
.sort((left, right) => {
if (right.entries.length !== left.entries.length) {
return right.entries.length - left.entries.length;
}
return left.tag.localeCompare(right.tag, 'zh-CN');
});
}
export function getPlatformPublicEntries(
featuredEntries: PlatformPublicGalleryCard[],
latestEntries: PlatformPublicGalleryCard[],
) {
const entryMap = new Map<string, PlatformPublicGalleryCard>();
filterGeneralPublicWorks([...featuredEntries, ...latestEntries]).forEach(
(entry) => {
entryMap.set(buildPublicGalleryCardKey(entry), entry);
},
);
return Array.from(entryMap.values());
}
export function getAllPlatformPublicEntries(
featuredEntries: PlatformPublicGalleryCard[],
latestEntries: PlatformPublicGalleryCard[],
) {
const entryMap = new Map<string, PlatformPublicGalleryCard>();
[...featuredEntries, ...latestEntries].forEach((entry) => {
entryMap.set(buildPublicGalleryCardKey(entry), entry);
});
return Array.from(entryMap.values());
}
function normalizePlatformSearchText(value: string | null | undefined) {
return (value ?? '').trim().toLocaleLowerCase('zh-CN');
}
function normalizePlatformCompactSearchText(value: string | null | undefined) {
return normalizePlatformSearchText(value).replace(/[\s_-]+/gu, '');
}
export function getPlatformSearchableWorkIds(
entry: PlatformPublicGalleryCard,
) {
const ids = [entry.publicWorkCode, entry.profileId];
if ('workId' in entry) {
ids.push(entry.workId);
}
return ids.filter((value): value is string => Boolean(value?.trim()));
}
function buildPlatformWorkSearchText(entry: PlatformPublicGalleryCard) {
return [
...getPlatformSearchableWorkIds(entry),
entry.worldName,
entry.authorDisplayName,
entry.summaryText,
entry.subtitle,
].join(' ');
}
function matchesPlatformWorkSearch(
entry: PlatformPublicGalleryCard,
keyword: string,
) {
const normalizedKeyword = normalizePlatformSearchText(keyword);
const compactKeyword = normalizePlatformCompactSearchText(keyword);
if (!normalizedKeyword) {
return false;
}
const normalizedSearchText = normalizePlatformSearchText(
buildPlatformWorkSearchText(entry),
);
if (normalizedSearchText.includes(normalizedKeyword)) {
return true;
}
return (
Boolean(compactKeyword) &&
normalizePlatformCompactSearchText(
buildPlatformWorkSearchText(entry),
).includes(compactKeyword)
);
}
export function filterPlatformWorkSearchResults(
entries: PlatformPublicGalleryCard[],
keyword: string,
) {
return entries
.filter((entry) => matchesPlatformWorkSearch(entry, keyword))
.sort((left, right) => {
const leftCode = getPlatformSearchableWorkIds(left)[0] ?? '';
const rightCode = getPlatformSearchableWorkIds(right)[0] ?? '';
const normalizedKeyword = normalizePlatformSearchText(keyword);
const leftNameStarts = normalizePlatformSearchText(
left.worldName,
).startsWith(normalizedKeyword);
const rightNameStarts = normalizePlatformSearchText(
right.worldName,
).startsWith(normalizedKeyword);
if (leftNameStarts !== rightNameStarts) {
return leftNameStarts ? -1 : 1;
}
const compactKeyword = normalizePlatformCompactSearchText(keyword);
const leftCodeStarts =
normalizePlatformCompactSearchText(leftCode).startsWith(compactKeyword);
const rightCodeStarts =
normalizePlatformCompactSearchText(rightCode).startsWith(
compactKeyword,
);
if (leftCodeStarts !== rightCodeStarts) {
return leftCodeStarts ? -1 : 1;
}
return getPlatformWorldTimestamp(right) - getPlatformWorldTimestamp(left);
});
}
export function isExactPublicWorkCodeSearch(
entries: PlatformPublicGalleryCard[],
keyword: string,
) {
const normalizedKeyword = normalizePlatformSearchText(keyword);
return entries.some(
(entry) =>
Boolean(entry.publicWorkCode?.trim()) &&
normalizePlatformSearchText(entry.publicWorkCode) === normalizedKeyword,
);
}
export function getPlatformWorldTimestamp(entry: PlatformWorldCardLike) {
const rawTime = entry.publishedAt ?? entry.updatedAt;
return parsePlatformEntryTimestamp(rawTime);
}
function isSameLocalCalendarDay(left: Date, right: Date) {
return (
left.getFullYear() === right.getFullYear() &&
left.getMonth() === right.getMonth() &&
left.getDate() === right.getDate()
);
}
function isPlatformEntryPublishedToday(
entry: PlatformPublicGalleryCard,
now = new Date(),
) {
const publishedAtTimestamp = parsePlatformEntryTimestamp(entry.publishedAt);
if (publishedAtTimestamp <= 0) {
return false;
}
return isSameLocalCalendarDay(new Date(publishedAtTimestamp), now);
}
export function filterTodayPublishedEntries(
entries: PlatformPublicGalleryCard[],
now = new Date(),
) {
return entries.filter((entry) => isPlatformEntryPublishedToday(entry, now));
}
export function getPlatformWorldLikeCount(entry: PlatformWorldCardLike) {
return Math.max(0, Math.round(('likeCount' in entry && entry.likeCount) || 0));
}
export function getPlatformWorldPlayCount(entry: PlatformWorldCardLike) {
return Math.max(0, Math.round(('playCount' in entry && entry.playCount) || 0));
}
export function getPlatformWorldRemixCount(entry: PlatformWorldCardLike) {
return Math.max(
0,
Math.round(('remixCount' in entry && entry.remixCount) || 0),
);
}
function getPlatformWorldRecentPlayCount(entry: PlatformWorldCardLike) {
return Math.max(
0,
Math.round(('recentPlayCount7d' in entry && entry.recentPlayCount7d) || 0),
);
}
function sortEntriesByMetric(
entries: PlatformPublicGalleryCard[],
getMetric: (entry: PlatformPublicGalleryCard) => number,
) {
return [...entries].sort((left, right) => {
const metricDiff = getMetric(right) - getMetric(left);
if (metricDiff !== 0) {
return metricDiff;
}
return getPlatformWorldTimestamp(right) - getPlatformWorldTimestamp(left);
});
}
export function buildPlatformRankingEntries(
entries: PlatformPublicGalleryCard[],
tab: PlatformRankingTab,
) {
if (tab === 'hot') {
return sortEntriesByMetric(entries, getPlatformWorldPlayCount);
}
if (tab === 'remix') {
return sortEntriesByMetric(entries, getPlatformWorldRemixCount);
}
if (tab === 'like') {
return sortEntriesByMetric(entries, getPlatformWorldLikeCount);
}
return sortEntriesByMetric(entries, getPlatformWorldRecentPlayCount);
}
export function getPlatformRankingMetricValue(
entry: PlatformPublicGalleryCard,
tab: PlatformRankingTab,
) {
if (tab === 'remix') {
return getPlatformWorldRemixCount(entry);
}
if (tab === 'like') {
return getPlatformWorldLikeCount(entry);
}
if (tab === 'new') {
return getPlatformWorldRecentPlayCount(entry);
}
return getPlatformWorldPlayCount(entry);
}
function getPlatformCategoryCompositeScore(entry: PlatformPublicGalleryCard) {
// 分类频道只使用公开读模型已经返回的指标做前端排序,不在 UI 层伪造评分数据。
return (
getPlatformWorldPlayCount(entry) +
getPlatformWorldRemixCount(entry) +
getPlatformWorldLikeCount(entry) +
getPlatformWorldRecentPlayCount(entry)
);
}
export function getPlatformCategoryKindFilter(
entry: PlatformPublicGalleryCard,
): Exclude<PlatformCategoryKindFilter, 'all'> {
if (isPuzzleGalleryEntry(entry)) {
return 'puzzle';
}
if (isMatch3DGalleryEntry(entry)) {
return 'match3d';
}
if (isSquareHoleGalleryEntry(entry)) {
return 'square-hole';
}
if (isVisualNovelGalleryEntry(entry)) {
return 'visual-novel';
}
if (isBarkBattleGalleryEntry(entry)) {
return 'bark-battle';
}
if (isBigFishGalleryEntry(entry)) {
return 'big-fish';
}
if (isJumpHopGalleryEntry(entry)) {
return 'jump-hop';
}
if (isWoodenFishGalleryEntry(entry)) {
return 'wooden-fish';
}
return 'custom-world';
}
export function matchesPlatformCategoryKindFilter(
entry: PlatformPublicGalleryCard,
kindFilter: PlatformCategoryKindFilter,
) {
return (
kindFilter === 'all' || getPlatformCategoryKindFilter(entry) === kindFilter
);
}
export function sortPlatformCategoryEntries(
entries: PlatformPublicGalleryCard[],
sortMode: PlatformCategorySortMode,
) {
return [...entries].sort((left, right) => {
if (sortMode === 'latest') {
return getPlatformWorldTimestamp(right) - getPlatformWorldTimestamp(left);
}
const metricDiff =
sortMode === 'play'
? getPlatformWorldPlayCount(right) - getPlatformWorldPlayCount(left)
: sortMode === 'like'
? getPlatformWorldLikeCount(right) - getPlatformWorldLikeCount(left)
: getPlatformCategoryCompositeScore(right) -
getPlatformCategoryCompositeScore(left);
if (metricDiff !== 0) {
return metricDiff;
}
return getPlatformWorldTimestamp(right) - getPlatformWorldTimestamp(left);
});
}
export function getPlatformCategoryPrimaryMetric(
entry: PlatformPublicGalleryCard,
) {
const likeCount = getPlatformWorldLikeCount(entry);
if (likeCount > 0) {
return { label: '点赞', value: likeCount };
}
const recentPlayCount = getPlatformWorldRecentPlayCount(entry);
if (recentPlayCount > 0) {
return { label: '近7日', value: recentPlayCount };
}
return { label: '游玩', value: getPlatformWorldPlayCount(entry) };
}
export function parsePlatformEntryTimestamp(value: string | null | undefined) {
if (!value) {
return 0;
}
const normalized = value.trim();
const numericTimestamp = normalized.match(/^(-?\d+(?:\.\d+)?)(?:Z)?$/u);
if (numericTimestamp?.[1]) {
const rawTimestamp = Number(numericTimestamp[1]);
if (Number.isFinite(rawTimestamp)) {
const absoluteTimestamp = Math.abs(rawTimestamp);
const timestampMs =
absoluteTimestamp >= 1_000_000_000_000_000
? rawTimestamp / 1000
: absoluteTimestamp >= 1_000_000_000_000
? rawTimestamp
: absoluteTimestamp >= 1_000_000_000
? rawTimestamp * 1000
: Number.NaN;
return Number.isNaN(timestampMs) ? 0 : timestampMs;
}
}
const timestamp = new Date(normalized).getTime();
return Number.isNaN(timestamp) ? 0 : timestamp;
}