refactor: 收口公开作品 ViewModel
This commit is contained in:
@@ -48,6 +48,14 @@
|
|||||||
- 验证方式:`npm run test -- src/services/runtimeRequest.test.ts src/services/recommendedRuntimeGuestLaunch.test.ts src/services/match3d-runtime/match3dRuntimeAdapter.test.ts`、`npm run typecheck`、`npm run check:encoding`、相关文件 ESLint 通过。
|
- 验证方式:`npm run test -- src/services/runtimeRequest.test.ts src/services/recommendedRuntimeGuestLaunch.test.ts src/services/match3d-runtime/match3dRuntimeAdapter.test.ts`、`npm run typecheck`、`npm run check:encoding`、相关文件 ESLint 通过。
|
||||||
- 关联文档:`docs/technical/【前端架构】RuntimeClientFamily收口计划-2026-06-03.md`。
|
- 关联文档:`docs/technical/【前端架构】RuntimeClientFamily收口计划-2026-06-03.md`。
|
||||||
|
|
||||||
|
## 2026-06-03 Public Gallery ViewModel 收口
|
||||||
|
|
||||||
|
- 背景:`RpgEntryHomeView.tsx` 巨型页面内混合了公开作品分类、跨来源去重、搜索归一化、作品号匹配、时间戳解析和排序规则,新增玩法时页面与 ViewModel 规则容易纠缠。
|
||||||
|
- 决策:新增 `src/components/rpg-entry/rpgEntryPublicGalleryViewModel.ts`,把 `buildPublicGalleryCardKey`、`buildPublicCategoryGroups`、`getPlatformPublicEntries`、`getAllPlatformPublicEntries`、`getPlatformSearchableWorkIds`、`filterPlatformWorkSearchResults`、`isExactPublicWorkCodeSearch`、`filterTodayPublishedEntries`、公开卡片指标 getter、`buildPlatformRankingEntries`、`getPlatformRankingMetricValue`、`getPlatformCategoryKindFilter`、`matchesPlatformCategoryKindFilter`、`sortPlatformCategoryEntries`、`getPlatformCategoryPrimaryMetric`、`parsePlatformEntryTimestamp` 和 `getPlatformWorldTimestamp` 收口为公开作品 ViewModel Interface。公开作品 key 复用平台入口身份规则,补齐 jump-hop / wooden-fish 等玩法区分。
|
||||||
|
- 影响范围:RPG 首页公开作品发现、分类、搜索、排行数据准备,以及后续新增玩法公开卡片接入。
|
||||||
|
- 验证方式:`npm run test -- src/components/rpg-entry/rpgEntryPublicGalleryViewModel.test.ts`、`npm run typecheck`、`npm run check:encoding`、相关文件 ESLint 通过。
|
||||||
|
- 关联文档:`docs/technical/【前端架构】PublicGalleryViewModel收口计划-2026-06-03.md`。
|
||||||
|
|
||||||
## 2026-06-03 最近创作只复用创作模板入口
|
## 2026-06-03 最近创作只复用创作模板入口
|
||||||
|
|
||||||
- 背景:底部加号创作入口的“最近创作”最初由真实作品架摘要驱动,但页面曾按作品标题、摘要和生成状态渲染独立最近创作卡,和其它模板页签的卡片样式及点击语义不一致。
|
- 背景:底部加号创作入口的“最近创作”最初由真实作品架摘要驱动,但页面曾按作品标题、摘要和生成状态渲染独立最近创作卡,和其它模板页签的卡片样式及点击语义不一致。
|
||||||
|
|||||||
@@ -45,6 +45,8 @@ AI 文字游戏模板接入以 [AI_NATIVE_TEXT_GAME_TEMPLATE_MOKU_REFERENCE_PRD_
|
|||||||
|
|
||||||
小游戏 runtime client 的路径编码、JSON 请求、runtime guest auth 与 retry 选项收口到 `src/services/runtimeRequest.ts`,Match3D 与 SquareHole 已先迁移,规则见 [【前端架构】RuntimeClientFamily收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91RuntimeClientFamily%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。
|
小游戏 runtime client 的路径编码、JSON 请求、runtime guest auth 与 retry 选项收口到 `src/services/runtimeRequest.ts`,Match3D 与 SquareHole 已先迁移,规则见 [【前端架构】RuntimeClientFamily收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91RuntimeClientFamily%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。
|
||||||
|
|
||||||
|
公开作品分类、搜索、跨来源去重、今日筛选、排行排序和时间戳解析收口到 `src/components/rpg-entry/rpgEntryPublicGalleryViewModel.ts`,规则见 [【前端架构】PublicGalleryViewModel收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91PublicGalleryViewModel%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。
|
||||||
|
|
||||||
## 推荐阅读顺序
|
## 推荐阅读顺序
|
||||||
|
|
||||||
1. 先看 [经验沉淀](./experience/README.md),快速建立这个项目的开发共识。
|
1. 先看 [经验沉淀](./experience/README.md),快速建立这个项目的开发共识。
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# 【前端架构】Public Gallery ViewModel 收口计划
|
||||||
|
|
||||||
|
## 背景
|
||||||
|
|
||||||
|
`RpgEntryHomeView.tsx` 同时承担首页、发现、分类、排行、搜索和公开作品卡片渲染。公开作品的 category 分组、跨来源去重、搜索归一化、作品号匹配、时间戳解析和列表排序原本都放在页面巨型 **Implementation** 中,导致公开作品规则与 JSX 交错,新增玩法时难以判断该改页面、卡片还是平台入口规则。
|
||||||
|
|
||||||
|
## 决策
|
||||||
|
|
||||||
|
新增 `src/components/rpg-entry/rpgEntryPublicGalleryViewModel.ts`,作为公开作品 ViewModel **Module**。该 **Module** 的 **Interface** 收口为:
|
||||||
|
|
||||||
|
- `buildPublicGalleryCardKey(entry)`:复用平台公开作品身份规则,补齐 jump-hop / wooden-fish 等玩法 key。
|
||||||
|
- `buildPublicCategoryGroups(featuredEntries, latestEntries)`:统一去重、标签兜底和分类排序。
|
||||||
|
- `getPlatformPublicEntries(featuredEntries, latestEntries)` / `getAllPlatformPublicEntries(featuredEntries, latestEntries)`:统一公开作品合并规则。
|
||||||
|
- `getPlatformSearchableWorkIds(entry)`、`filterPlatformWorkSearchResults(entries, keyword)` 与 `isExactPublicWorkCodeSearch(entries, keyword)`:统一搜索归一化、compact code 匹配和排序。
|
||||||
|
- `parsePlatformEntryTimestamp(value)` / `getPlatformWorldTimestamp(entry)`:统一兼容 ISO 与后端 seconds.microsZ 时间戳。
|
||||||
|
- `filterTodayPublishedEntries(entries)`:统一“今日游戏”本地自然日筛选。
|
||||||
|
- `getPlatformWorldLikeCount(entry)` / `getPlatformWorldPlayCount(entry)` / `getPlatformWorldRemixCount(entry)`、`buildPlatformRankingEntries(entries, tab)` 与 `getPlatformRankingMetricValue(entry, tab)`:统一公开卡片指标读取、排行 Tab 排序与取值。
|
||||||
|
- `getPlatformCategoryKindFilter(entry)`、`matchesPlatformCategoryKindFilter(entry, kindFilter)`、`sortPlatformCategoryEntries(entries, sortMode)` 与 `getPlatformCategoryPrimaryMetric(entry)`:统一分类频道的玩法过滤、排序和主指标展示。
|
||||||
|
|
||||||
|
`RpgEntryHomeView.tsx` 只消费这些 ViewModel 函数,保留渲染、事件处理和账号状态。公开作品规则的 **Locality** 转移到 ViewModel **Module** 与其测试,页面不再持有这批纯规则。
|
||||||
|
|
||||||
|
## 约定
|
||||||
|
|
||||||
|
- 公开作品身份 key 与平台入口推荐流保持一致,优先复用 `platformPublicGalleryFlow`。
|
||||||
|
- 搜索应同时匹配作品号、`profileId`、`workId`、标题、作者、摘要和副标题。
|
||||||
|
- 搜索排序先看标题前缀,再看作品号 compact 前缀,最后按发布时间 / 更新时间倒序。
|
||||||
|
- 时间解析必须保留后端 `seconds.microsZ` 兼容。
|
||||||
|
|
||||||
|
## 后续深化
|
||||||
|
|
||||||
|
下一步可把移动 / 桌面 discover feed 的数据准备继续迁入 ViewModel,但卡片 JSX 与交互状态仍留页面内。
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
|
||||||
|
- `npm run test -- src/components/rpg-entry/rpgEntryPublicGalleryViewModel.test.ts`
|
||||||
|
- `npm run typecheck`
|
||||||
|
- `npm run check:encoding`
|
||||||
|
- 针对变更文件执行 ESLint
|
||||||
@@ -14,9 +14,9 @@ import {
|
|||||||
Gamepad2,
|
Gamepad2,
|
||||||
GitFork,
|
GitFork,
|
||||||
Heart,
|
Heart,
|
||||||
|
Loader2,
|
||||||
LogIn,
|
LogIn,
|
||||||
MessageCircle,
|
MessageCircle,
|
||||||
Loader2,
|
|
||||||
Palette,
|
Palette,
|
||||||
Pencil,
|
Pencil,
|
||||||
Plus,
|
Plus,
|
||||||
@@ -135,6 +135,27 @@ import {
|
|||||||
import { getInitialPlatformDesktopLayout } from '../platform-entry/platformEntryResponsive';
|
import { getInitialPlatformDesktopLayout } from '../platform-entry/platformEntryResponsive';
|
||||||
import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
||||||
import { RpgEntryBrandLogo } from './RpgEntryBrandLogo';
|
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 {
|
import {
|
||||||
buildPlatformWorldDisplayTags,
|
buildPlatformWorldDisplayTags,
|
||||||
describePlatformThemeLabel,
|
describePlatformThemeLabel,
|
||||||
@@ -151,9 +172,8 @@ import {
|
|||||||
isVisualNovelGalleryEntry,
|
isVisualNovelGalleryEntry,
|
||||||
isWoodenFishGalleryEntry,
|
isWoodenFishGalleryEntry,
|
||||||
type PlatformPublicGalleryCard,
|
type PlatformPublicGalleryCard,
|
||||||
type PlatformWorldCardLike,
|
|
||||||
resolvePlatformWorkAuthorDisplayName,
|
|
||||||
resolvePlatformPublicWorkCode,
|
resolvePlatformPublicWorkCode,
|
||||||
|
resolvePlatformWorkAuthorDisplayName,
|
||||||
resolvePlatformWorldCoverImage,
|
resolvePlatformWorldCoverImage,
|
||||||
resolvePlatformWorldCoverSlides,
|
resolvePlatformWorldCoverSlides,
|
||||||
resolvePlatformWorldFallbackCoverImage,
|
resolvePlatformWorldFallbackCoverImage,
|
||||||
@@ -361,17 +381,6 @@ type DiscoverChannel =
|
|||||||
| 'category'
|
| 'category'
|
||||||
| 'ranking'
|
| 'ranking'
|
||||||
| 'edutainment';
|
| '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 = [
|
const COMMUNITY_QR_CODES = [
|
||||||
{
|
{
|
||||||
@@ -410,6 +419,8 @@ const PLATFORM_CATEGORY_KIND_FILTERS: Array<{
|
|||||||
{ id: 'visual-novel', label: '视觉' },
|
{ id: 'visual-novel', label: '视觉' },
|
||||||
{ id: 'bark-battle', label: '汪汪' },
|
{ id: 'bark-battle', label: '汪汪' },
|
||||||
{ id: 'big-fish', label: '大鱼' },
|
{ id: 'big-fish', label: '大鱼' },
|
||||||
|
{ id: 'jump-hop', label: '跳跃' },
|
||||||
|
{ id: 'wooden-fish', label: '木鱼' },
|
||||||
{ id: 'custom-world', label: 'RPG' },
|
{ id: 'custom-world', label: 'RPG' },
|
||||||
];
|
];
|
||||||
const PLATFORM_CATEGORY_SORT_OPTIONS: Array<{
|
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({
|
function PlatformWorkSearchResults({
|
||||||
keyword,
|
keyword,
|
||||||
entries,
|
entries,
|
||||||
@@ -1950,225 +1781,6 @@ function getPublicAuthorAvatarLabel(authorDisplayName: string) {
|
|||||||
return Array.from(authorDisplayName.trim() || '玩')[0] ?? '玩';
|
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) {
|
function formatCompactCount(value: number) {
|
||||||
const normalizedValue = Math.max(0, Math.round(value));
|
const normalizedValue = Math.max(0, Math.round(value));
|
||||||
if (normalizedValue >= 100000000) {
|
if (normalizedValue >= 100000000) {
|
||||||
|
|||||||
327
src/components/rpg-entry/rpgEntryPublicGalleryViewModel.test.ts
Normal file
327
src/components/rpg-entry/rpgEntryPublicGalleryViewModel.test.ts
Normal 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,
|
||||||
|
);
|
||||||
|
});
|
||||||
433
src/components/rpg-entry/rpgEntryPublicGalleryViewModel.ts
Normal file
433
src/components/rpg-entry/rpgEntryPublicGalleryViewModel.ts
Normal 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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user