refactor: 收口排行配置模型

This commit is contained in:
2026-06-03 18:37:34 +08:00
parent 5fecceef4f
commit 685560ec07
6 changed files with 144 additions and 48 deletions

View File

@@ -1249,6 +1249,14 @@
- 验证方式:`npm run test -- src/components/rpg-entry/rpgEntryPublicGalleryViewModel.test.ts``npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "recommend|edutainment"``npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "logged out home recommendation next starts the next puzzle work"`、针对变更文件执行 ESLint、`npm run typecheck``npm run check:encoding` - 验证方式:`npm run test -- src/components/rpg-entry/rpgEntryPublicGalleryViewModel.test.ts``npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "recommend|edutainment"``npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "logged out home recommendation next starts the next puzzle work"`、针对变更文件执行 ESLint、`npm run typecheck``npm run check:encoding`
- 关联文档:`docs/technical/【前端架构】RecommendFeedViewModel收口计划-2026-06-03.md` - 关联文档:`docs/technical/【前端架构】RecommendFeedViewModel收口计划-2026-06-03.md`
## 2026-06-03 Ranking ViewModel 收口
- 背景:排行 tab 的文案、metric label 与空态文案在 `RpgEntryHomeView.tsx`,排序和 metric value 在 `rpgEntryPublicGalleryViewModel.ts`,同一 `PlatformRankingTab` 的 Interface 分散且页面需要类型断言取 active config。
- 决策:在 `src/components/rpg-entry/rpgEntryPublicGalleryViewModel.ts` 收口 `DEFAULT_PLATFORM_RANKING_TAB``PLATFORM_RANKING_TABS``getPlatformRankingTabConfig``getPlatformRankingMetric`;页面仅保留 active tab 状态和渲染。
- 影响范围:发现页排行频道 tab 顺序、tab 文案、空态文案、排行项指标 label/value。
- 验证方式:`npm run test -- src/components/rpg-entry/rpgEntryPublicGalleryViewModel.test.ts``npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "bottom category tab becomes ranking and switches ranking metrics|ranking"`、针对变更文件执行 ESLint、`npm run typecheck``npm run check:encoding`
- 关联文档:`docs/technical/【前端架构】RankingViewModel收口计划-2026-06-03.md`
## 2026-06-03 Public Work Presentation 收口 ## 2026-06-03 Public Work Presentation 收口
- 背景:作品卡、推荐 runtime meta、排行项、分类项、搜索结果和桌面 hero 共用玩法类型 label 与紧凑计数格式,但规则仍在 `RpgEntryHomeView.tsx` 页面 Implementation 内。 - 背景:作品卡、推荐 runtime meta、排行项、分类项、搜索结果和桌面 hero 共用玩法类型 label 与紧凑计数格式,但规则仍在 `RpgEntryHomeView.tsx` 页面 Implementation 内。

View File

@@ -51,6 +51,8 @@ AI 文字游戏模板接入以 [AI_NATIVE_TEXT_GAME_TEMPLATE_MOKU_REFERENCE_PRD_
推荐 feed 的公开作品去重、普通内容过滤、active 窗口与上一条 / 下一条回环选择也收口到 `src/components/rpg-entry/rpgEntryPublicGalleryViewModel.ts`,规则见 [【前端架构】RecommendFeedViewModel收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91RecommendFeedViewModel%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。 推荐 feed 的公开作品去重、普通内容过滤、active 窗口与上一条 / 下一条回环选择也收口到 `src/components/rpg-entry/rpgEntryPublicGalleryViewModel.ts`,规则见 [【前端架构】RecommendFeedViewModel收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91RecommendFeedViewModel%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。
排行频道的默认 tab、tab 文案、空态文案、排序字段与指标 label/value 收口到 `src/components/rpg-entry/rpgEntryPublicGalleryViewModel.ts`,规则见 [【前端架构】RankingViewModel收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91RankingViewModel%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。
每日任务卡片与任务中心弹窗的任务选择、进度、状态标签和按钮文案收口到 `src/components/rpg-entry/rpgEntryProfileTaskViewModel.ts`,规则见 [【前端架构】ProfileTaskViewModel收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91ProfileTaskViewModel%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。 每日任务卡片与任务中心弹窗的任务选择、进度、状态标签和按钮文案收口到 `src/components/rpg-entry/rpgEntryProfileTaskViewModel.ts`,规则见 [【前端架构】ProfileTaskViewModel收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91ProfileTaskViewModel%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。
个人数据卡、钱包 chip 与“玩过”弹窗的计数、时长、作品类型和作品号展示收口到 `src/components/rpg-entry/rpgEntryProfileDashboardPresentation.ts`,规则见 [【前端架构】ProfileDashboardPresentation收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91ProfileDashboardPresentation%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。 个人数据卡、钱包 chip 与“玩过”弹窗的计数、时长、作品类型和作品号展示收口到 `src/components/rpg-entry/rpgEntryProfileDashboardPresentation.ts`,规则见 [【前端架构】ProfileDashboardPresentation收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91ProfileDashboardPresentation%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。

View File

@@ -0,0 +1,32 @@
# 【前端架构】Ranking ViewModel 收口计划
## 背景
平台发现页排行频道以 `PlatformRankingTab` 决定 tab 文案、空态文案、排序字段和指标展示。原先排序与指标取值在 `rpgEntryPublicGalleryViewModel.ts`,而 tab label、metric label 与 empty text 留在 `RpgEntryHomeView.tsx`,页面还用类型断言寻找 active config导致同一个排行语义的 **Interface** 分散。
## 决策
`src/components/rpg-entry/rpgEntryPublicGalleryViewModel.ts` 收口排行 **Interface**
- `DEFAULT_PLATFORM_RANKING_TAB``PLATFORM_RANKING_TABS`:统一 tab 顺序、tab label、metric label 与空态文案。
- `getPlatformRankingTabConfig(tab)`:统一 active tab 配置兜底。
- `getPlatformRankingMetric(entry, tab)`:统一 metric label 与 value避免 label/value 漂移。
- `buildPlatformRankingEntries(entries, tab)` 继续承载排序规则。
`RpgEntryHomeView.tsx` 只保留 active tab 状态、点击与渲染,不再理解“热门榜=游玩值”“新品榜=近 7 日值”等映射。排行规则的 **Locality** 收口到 PublicGallery ViewModel。
## 约定
- 默认排行 tab 保持 `hot`
- tab 顺序保持“热门榜 / 改造榜 / 新品榜 / 点赞榜”。
- 排序口径保持:`hot=playCount``remix=remixCount``new=recentPlayCount7d``like=likeCount`
- “新品榜”仍按近 7 日游玩数排序,不改为发布时间排序。
- 页面层继续保留最多显示 30 条的展示限制。
## 验证
- `npm run test -- src/components/rpg-entry/rpgEntryPublicGalleryViewModel.test.ts`
- `npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "bottom category tab becomes ranking and switches ranking metrics|ranking"`
- 针对变更文件执行 ESLint
- `npm run typecheck`
- `npm run check:encoding`

View File

@@ -160,20 +160,24 @@ import {
buildPublicCategoryGroups, buildPublicCategoryGroups,
buildPublicGalleryCardKey, buildPublicGalleryCardKey,
dedupePlatformPublicGalleryEntries, dedupePlatformPublicGalleryEntries,
DEFAULT_PLATFORM_RANKING_TAB,
filterPlatformWorkSearchResults, filterPlatformWorkSearchResults,
filterTodayPublishedEntries, filterTodayPublishedEntries,
getAllPlatformPublicEntries, getAllPlatformPublicEntries,
getPlatformCategoryPrimaryMetric, getPlatformCategoryPrimaryMetric,
getPlatformPublicEntries, getPlatformPublicEntries,
getPlatformRankingMetricValue, getPlatformRankingMetric,
getPlatformRankingTabConfig,
getPlatformSearchableWorkIds, getPlatformSearchableWorkIds,
getPlatformWorldLikeCount, getPlatformWorldLikeCount,
getPlatformWorldPlayCount, getPlatformWorldPlayCount,
getPlatformWorldRemixCount, getPlatformWorldRemixCount,
isExactPublicWorkCodeSearch, isExactPublicWorkCodeSearch,
matchesPlatformCategoryKindFilter, matchesPlatformCategoryKindFilter,
PLATFORM_RANKING_TABS,
type PlatformCategoryKindFilter, type PlatformCategoryKindFilter,
type PlatformCategorySortMode, type PlatformCategorySortMode,
type PlatformRankingMetric,
type PlatformRankingTab, type PlatformRankingTab,
selectPlatformRecommendFeedWindow, selectPlatformRecommendFeedWindow,
sortPlatformCategoryEntries, sortPlatformCategoryEntries,
@@ -405,37 +409,6 @@ const CHILD_MOTION_DEMO_DEFAULT_CARD = {
summary: '站位、招手和左右手活动。', summary: '站位、招手和左右手活动。',
}; };
const PLATFORM_RANKING_TABS: Array<{
id: PlatformRankingTab;
label: string;
metricLabel: string;
emptyText: string;
}> = [
{
id: 'hot',
label: '热门榜',
metricLabel: '游玩',
emptyText: '公开广场暂时还没有热门作品。',
},
{
id: 'remix',
label: '改造榜',
metricLabel: '改造',
emptyText: '公开广场暂时还没有改造作品。',
},
{
id: 'new',
label: '新品榜',
metricLabel: '近7日',
emptyText: '近 7 日暂时还没有新品。',
},
{
id: 'like',
label: '点赞榜',
metricLabel: '点赞',
emptyText: '公开广场暂时还没有点赞作品。',
},
];
function ResolvedAssetBackdrop({ function ResolvedAssetBackdrop({
src, src,
fallbackSrc, fallbackSrc,
@@ -1362,14 +1335,12 @@ function DesktopTrendingItem({
function PlatformRankingItem({ function PlatformRankingItem({
entry, entry,
rank, rank,
metricLabel, metric,
metricValue,
onClick, onClick,
}: { }: {
entry: PlatformPublicGalleryCard; entry: PlatformPublicGalleryCard;
rank: number; rank: number;
metricLabel: string; metric: PlatformRankingMetric;
metricValue: number;
onClick: () => void; onClick: () => void;
}) { }) {
const coverImage = resolvePlatformWorldCoverImage(entry); const coverImage = resolvePlatformWorldCoverImage(entry);
@@ -1405,9 +1376,9 @@ function PlatformRankingItem({
</div> </div>
<div className="mt-1 flex min-w-0 flex-wrap items-center gap-x-1.5 gap-y-1 text-xs font-semibold text-[var(--platform-text-soft)]"> <div className="mt-1 flex min-w-0 flex-wrap items-center gap-x-1.5 gap-y-1 text-xs font-semibold text-[var(--platform-text-soft)]">
<span className="text-[var(--platform-warm-text)]"> <span className="text-[var(--platform-warm-text)]">
{formatPlatformCompactCount(metricValue)} {formatPlatformCompactCount(metric.value)}
</span> </span>
<span>{metricLabel}</span> <span>{metric.label}</span>
<span>·</span> <span>·</span>
<span>{describePlatformPublicWorkKind(entry)}</span> <span>{describePlatformPublicWorkKind(entry)}</span>
</div> </div>
@@ -3567,8 +3538,9 @@ export function RpgEntryHomeView({
const [publicAuthorSummariesByKey, setPublicAuthorSummariesByKey] = useState< const [publicAuthorSummariesByKey, setPublicAuthorSummariesByKey] = useState<
Record<string, PublicUserSummary | null> Record<string, PublicUserSummary | null>
>({}); >({});
const [activeRankingTab, setActiveRankingTab] = const [activeRankingTab, setActiveRankingTab] = useState<PlatformRankingTab>(
useState<PlatformRankingTab>('hot'); DEFAULT_PLATFORM_RANKING_TAB,
);
const [visitedTabs, setVisitedTabs] = useState<Set<PlatformHomeTab>>( const [visitedTabs, setVisitedTabs] = useState<Set<PlatformHomeTab>>(
() => new Set([activeTab]), () => new Set([activeTab]),
); );
@@ -4794,9 +4766,7 @@ export function RpgEntryHomeView({
activeTab, activeTab,
mobileFeedCarouselEnabled, mobileFeedCarouselEnabled,
]); ]);
const activeRankingConfig = PLATFORM_RANKING_TABS.find( const activeRankingConfig = getPlatformRankingTabConfig(activeRankingTab);
(tab) => tab.id === activeRankingTab,
) as (typeof PLATFORM_RANKING_TABS)[number];
const rankingEntries = useMemo( const rankingEntries = useMemo(
() => () =>
buildPlatformRankingEntries(publicEntries, activeRankingTab).slice(0, 30), buildPlatformRankingEntries(publicEntries, activeRankingTab).slice(0, 30),
@@ -5037,11 +5007,7 @@ export function RpgEntryHomeView({
key={`${buildPublicGalleryCardKey(entry)}:ranking:${activeRankingTab}`} key={`${buildPublicGalleryCardKey(entry)}:ranking:${activeRankingTab}`}
entry={entry} entry={entry}
rank={index + 1} rank={index + 1}
metricLabel={activeRankingConfig.metricLabel} metric={getPlatformRankingMetric(entry, activeRankingTab)}
metricValue={getPlatformRankingMetricValue(
entry,
activeRankingTab,
)}
onClick={() => onOpenGalleryDetail(entry)} onClick={() => onOpenGalleryDetail(entry)}
/> />
))} ))}

View File

@@ -7,14 +7,18 @@ import {
buildPublicCategoryGroups, buildPublicCategoryGroups,
buildPublicGalleryCardKey, buildPublicGalleryCardKey,
dedupePlatformPublicGalleryEntries, dedupePlatformPublicGalleryEntries,
DEFAULT_PLATFORM_RANKING_TAB,
filterPlatformWorkSearchResults, filterPlatformWorkSearchResults,
filterTodayPublishedEntries, filterTodayPublishedEntries,
getPlatformCategoryKindFilter, getPlatformCategoryKindFilter,
getPlatformCategoryPrimaryMetric, getPlatformCategoryPrimaryMetric,
getPlatformPublicEntries, getPlatformPublicEntries,
getPlatformRankingMetric,
getPlatformRankingMetricValue, getPlatformRankingMetricValue,
getPlatformRankingTabConfig,
matchesPlatformCategoryKindFilter, matchesPlatformCategoryKindFilter,
parsePlatformEntryTimestamp, parsePlatformEntryTimestamp,
PLATFORM_RANKING_TABS,
selectAdjacentPlatformRecommendEntry, selectAdjacentPlatformRecommendEntry,
selectPlatformRecommendFeedWindow, selectPlatformRecommendFeedWindow,
sortPlatformCategoryEntries, sortPlatformCategoryEntries,
@@ -335,11 +339,32 @@ test('public gallery ViewModel ranks entries by selected metric', () => {
}); });
const entries = [recentWinner, remixWinner, likeWinner, playWinner]; const entries = [recentWinner, remixWinner, likeWinner, playWinner];
expect(DEFAULT_PLATFORM_RANKING_TAB).toBe('hot');
expect(PLATFORM_RANKING_TABS.map((tab) => tab.label)).toEqual([
'热门榜',
'改造榜',
'新品榜',
'点赞榜',
]);
expect(getPlatformRankingTabConfig('new')).toEqual({
id: 'new',
label: '新品榜',
metricLabel: '近7日',
emptyText: '近 7 日暂时还没有新品。',
});
expect(buildPlatformRankingEntries(entries, 'hot')[0]).toBe(playWinner); expect(buildPlatformRankingEntries(entries, 'hot')[0]).toBe(playWinner);
expect(buildPlatformRankingEntries(entries, 'remix')[0]).toBe(remixWinner); expect(buildPlatformRankingEntries(entries, 'remix')[0]).toBe(remixWinner);
expect(buildPlatformRankingEntries(entries, 'new')[0]).toBe(recentWinner); expect(buildPlatformRankingEntries(entries, 'new')[0]).toBe(recentWinner);
expect(buildPlatformRankingEntries(entries, 'like')[0]).toBe(likeWinner); expect(buildPlatformRankingEntries(entries, 'like')[0]).toBe(likeWinner);
expect(getPlatformRankingMetricValue(likeWinner, 'like')).toBe(40); expect(getPlatformRankingMetricValue(likeWinner, 'like')).toBe(40);
expect(getPlatformRankingMetric(recentWinner, 'new')).toEqual({
label: '近7日',
value: 30,
});
expect(getPlatformRankingMetric(playWinner, 'hot')).toEqual({
label: '游玩',
value: 100,
});
}); });
test('public gallery ViewModel sorts category entries and exposes primary metric', () => { test('public gallery ViewModel sorts category entries and exposes primary metric', () => {

View File

@@ -15,6 +15,16 @@ import {
} from './rpgEntryWorldPresentation'; } from './rpgEntryWorldPresentation';
export type PlatformRankingTab = 'hot' | 'remix' | 'new' | 'like'; export type PlatformRankingTab = 'hot' | 'remix' | 'new' | 'like';
export type PlatformRankingTabConfig = {
emptyText: string;
id: PlatformRankingTab;
label: string;
metricLabel: string;
};
export type PlatformRankingMetric = {
label: string;
value: number;
};
export type PlatformCategoryKindFilter = export type PlatformCategoryKindFilter =
| 'all' | 'all'
| 'puzzle' | 'puzzle'
@@ -33,6 +43,40 @@ export type PlatformPublicCategoryGroup = {
entries: PlatformPublicGalleryCard[]; entries: PlatformPublicGalleryCard[];
}; };
export const DEFAULT_PLATFORM_RANKING_TAB: PlatformRankingTab = 'hot';
export const PLATFORM_RANKING_TABS: PlatformRankingTabConfig[] = [
{
id: 'hot',
label: '热门榜',
metricLabel: '游玩',
emptyText: '公开广场暂时还没有热门作品。',
},
{
id: 'remix',
label: '改造榜',
metricLabel: '改造',
emptyText: '公开广场暂时还没有改造作品。',
},
{
id: 'new',
label: '新品榜',
metricLabel: '近7日',
emptyText: '近 7 日暂时还没有新品。',
},
{
id: 'like',
label: '点赞榜',
metricLabel: '点赞',
emptyText: '公开广场暂时还没有点赞作品。',
},
];
const DEFAULT_PLATFORM_RANKING_CONFIG =
PLATFORM_RANKING_TABS.find(
(config) => config.id === DEFAULT_PLATFORM_RANKING_TAB,
) ?? PLATFORM_RANKING_TABS[0]!;
export type PlatformRecommendFeedWindow = { export type PlatformRecommendFeedWindow = {
activeEntry: PlatformPublicGalleryCard | null; activeEntry: PlatformPublicGalleryCard | null;
activeEntryKey: string | null; activeEntryKey: string | null;
@@ -372,6 +416,15 @@ export function buildPlatformRankingEntries(
return sortEntriesByMetric(entries, getPlatformWorldRecentPlayCount); return sortEntriesByMetric(entries, getPlatformWorldRecentPlayCount);
} }
export function getPlatformRankingTabConfig(
tab: PlatformRankingTab,
): PlatformRankingTabConfig {
return (
PLATFORM_RANKING_TABS.find((config) => config.id === tab) ??
DEFAULT_PLATFORM_RANKING_CONFIG
);
}
export function getPlatformRankingMetricValue( export function getPlatformRankingMetricValue(
entry: PlatformPublicGalleryCard, entry: PlatformPublicGalleryCard,
tab: PlatformRankingTab, tab: PlatformRankingTab,
@@ -391,6 +444,16 @@ export function getPlatformRankingMetricValue(
return getPlatformWorldPlayCount(entry); return getPlatformWorldPlayCount(entry);
} }
export function getPlatformRankingMetric(
entry: PlatformPublicGalleryCard,
tab: PlatformRankingTab,
): PlatformRankingMetric {
return {
label: getPlatformRankingTabConfig(tab).metricLabel,
value: getPlatformRankingMetricValue(entry, tab),
};
}
function getPlatformCategoryCompositeScore(entry: PlatformPublicGalleryCard) { function getPlatformCategoryCompositeScore(entry: PlatformPublicGalleryCard) {
// 分类频道只使用公开读模型已经返回的指标做前端排序,不在 UI 层伪造评分数据。 // 分类频道只使用公开读模型已经返回的指标做前端排序,不在 UI 层伪造评分数据。
return ( return (