diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index b4b810f9..74ff2d23 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -1257,6 +1257,14 @@ - 验证方式:`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 Category Option ViewModel 收口 + +- 背景:分类频道的筛选选项、排序选项、默认值、active label fallback 和排序循环仍留在 `RpgEntryHomeView.tsx` 页面 Implementation 内,而玩法过滤、排序和主指标已经在 `rpgEntryPublicGalleryViewModel.ts`,同一分类 Interface 被拆成两处。 +- 决策:在 `src/components/rpg-entry/rpgEntryPublicGalleryViewModel.ts` 收口 `DEFAULT_PLATFORM_CATEGORY_KIND_FILTER`、`DEFAULT_PLATFORM_CATEGORY_SORT_MODE`、`PLATFORM_CATEGORY_KIND_FILTERS`、`PLATFORM_CATEGORY_SORT_OPTIONS`、`getPlatformCategoryKindFilterOption`、`getPlatformCategorySortOption` 与 `getNextPlatformCategorySortMode`;页面仅保留当前筛选 / 排序状态和渲染。 +- 影响范围:发现页分类频道筛选弹窗、筛选按钮 label、排序按钮 label 与排序循环。 +- 验证方式:`npm run test -- src/components/rpg-entry/rpgEntryPublicGalleryViewModel.test.ts`、`npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "category"`、针对变更文件执行 ESLint、`npm run typecheck`、`npm run check:encoding`。 +- 关联文档:`docs/technical/【前端架构】PublicGalleryViewModel收口计划-2026-06-03.md`。 + ## 2026-06-03 Public Work Presentation 收口 - 背景:作品卡、推荐 runtime meta、排行项、分类项、搜索结果和桌面 hero 共用玩法类型 label 与紧凑计数格式,但规则仍在 `RpgEntryHomeView.tsx` 页面 Implementation 内。 diff --git a/docs/README.md b/docs/README.md index b286166d..aceaeae2 100644 --- a/docs/README.md +++ b/docs/README.md @@ -45,7 +45,7 @@ AI 文字游戏模板接入以 [AI_NATIVE_TEXT_GAME_TEMPLATE_MOKU_REFERENCE_PRD_ 小游戏 runtime client 的路径编码、JSON 请求、runtime guest auth 与 retry 选项收口到 `src/services/runtimeRequest.ts`,Match3D、SquareHole、Big Fish、Bark Battle、Puzzle 公开 / 推荐运行态请求、Jump Hop / Wooden Fish 正式 run 请求和 Visual Novel 局部 JSON runtime 请求已先迁移,规则见 [【前端架构】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)。 +公开作品分类选项、搜索、跨来源去重、今日筛选、排行排序和时间戳解析收口到 `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)。 公开作品的玩法类型 label 与游玩 / 改造 / 点赞等紧凑计数格式收口到 `src/components/rpg-entry/rpgEntryWorldPresentation.ts`,规则见 [【前端架构】PublicWorkPresentation收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91PublicWorkPresentation%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。 diff --git a/docs/technical/【前端架构】PublicGalleryViewModel收口计划-2026-06-03.md b/docs/technical/【前端架构】PublicGalleryViewModel收口计划-2026-06-03.md index 814cb807..57c1de53 100644 --- a/docs/technical/【前端架构】PublicGalleryViewModel收口计划-2026-06-03.md +++ b/docs/technical/【前端架构】PublicGalleryViewModel收口计划-2026-06-03.md @@ -15,6 +15,7 @@ - `parsePlatformEntryTimestamp(value)` / `getPlatformWorldTimestamp(entry)`:统一兼容 ISO 与后端 seconds.microsZ 时间戳。 - `filterTodayPublishedEntries(entries)`:统一“今日游戏”本地自然日筛选。 - `getPlatformWorldLikeCount(entry)` / `getPlatformWorldPlayCount(entry)` / `getPlatformWorldRemixCount(entry)`、`buildPlatformRankingEntries(entries, tab)` 与 `getPlatformRankingMetricValue(entry, tab)`:统一公开卡片指标读取、排行 Tab 排序与取值。 +- `DEFAULT_PLATFORM_CATEGORY_KIND_FILTER`、`DEFAULT_PLATFORM_CATEGORY_SORT_MODE`、`PLATFORM_CATEGORY_KIND_FILTERS`、`PLATFORM_CATEGORY_SORT_OPTIONS`、`getPlatformCategoryKindFilterOption(kindFilter)`、`getPlatformCategorySortOption(sortMode)` 与 `getNextPlatformCategorySortMode(sortMode)`:统一分类频道的筛选 / 排序选项、默认值、label 兜底和排序循环。 - `getPlatformCategoryKindFilter(entry)`、`matchesPlatformCategoryKindFilter(entry, kindFilter)`、`sortPlatformCategoryEntries(entries, sortMode)` 与 `getPlatformCategoryPrimaryMetric(entry)`:统一分类频道的玩法过滤、排序和主指标展示。 `RpgEntryHomeView.tsx` 只消费这些 ViewModel 函数,保留渲染、事件处理和账号状态。公开作品规则的 **Locality** 转移到 ViewModel **Module** 与其测试,页面不再持有这批纯规则。 @@ -25,6 +26,7 @@ - 搜索应同时匹配作品号、`profileId`、`workId`、标题、作者、摘要和副标题。 - 搜索排序先看标题前缀,再看作品号 compact 前缀,最后按发布时间 / 更新时间倒序。 - 时间解析必须保留后端 `seconds.microsZ` 兼容。 +- 分类筛选与排序的选项顺序、默认值、中文 label 和“综合 -> 最新 -> 游玩 -> 点赞 -> 综合”循环属于 ViewModel **Interface**;页面只能消费该 **Interface**,不得在 `RpgEntryHomeView.tsx` 复写数组或 fallback 文案。 ## 后续深化 diff --git a/src/components/rpg-entry/RpgEntryHomeView.tsx b/src/components/rpg-entry/RpgEntryHomeView.tsx index 3bb1eb3d..cc5b4509 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.tsx @@ -160,11 +160,16 @@ import { buildPublicCategoryGroups, buildPublicGalleryCardKey, dedupePlatformPublicGalleryEntries, + DEFAULT_PLATFORM_CATEGORY_KIND_FILTER, + DEFAULT_PLATFORM_CATEGORY_SORT_MODE, DEFAULT_PLATFORM_RANKING_TAB, filterPlatformWorkSearchResults, filterTodayPublishedEntries, getAllPlatformPublicEntries, + getNextPlatformCategorySortMode, + getPlatformCategoryKindFilterOption, getPlatformCategoryPrimaryMetric, + getPlatformCategorySortOption, getPlatformPublicEntries, getPlatformRankingMetric, getPlatformRankingTabConfig, @@ -174,6 +179,8 @@ import { getPlatformWorldRemixCount, isExactPublicWorkCodeSearch, matchesPlatformCategoryKindFilter, + PLATFORM_CATEGORY_KIND_FILTERS, + PLATFORM_CATEGORY_SORT_OPTIONS, PLATFORM_RANKING_TABS, type PlatformCategoryKindFilter, type PlatformCategorySortMode, @@ -374,30 +381,6 @@ const EDUTAINMENT_DISCOVER_CHANNEL = { id: 'edutainment', label: EDUTAINMENT_WORK_TAG, } as const; -const PLATFORM_CATEGORY_KIND_FILTERS: Array<{ - id: PlatformCategoryKindFilter; - label: string; -}> = [ - { id: 'all', label: '全部' }, - { id: 'puzzle', label: '拼图' }, - { id: 'match3d', label: '抓鹅' }, - { id: 'square-hole', label: '方洞' }, - { 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<{ - id: PlatformCategorySortMode; - label: string; -}> = [ - { id: 'composite', label: '综合' }, - { id: 'latest', label: '最新' }, - { id: 'play', label: '游玩' }, - { id: 'like', label: '点赞' }, -]; const BABY_LOVE_DRAWING_DEFAULT_CARD = { title: '宝贝爱画', subtitle: '空白画板', @@ -3522,9 +3505,11 @@ export function RpgEntryHomeView({ null, ); const [categoryKindFilter, setCategoryKindFilter] = - useState('all'); + useState( + DEFAULT_PLATFORM_CATEGORY_KIND_FILTER, + ); const [categorySortMode, setCategorySortMode] = - useState('composite'); + useState(DEFAULT_PLATFORM_CATEGORY_SORT_MODE); const [isCategoryFilterPanelOpen, setIsCategoryFilterPanelOpen] = useState(false); const [discoverChannel, setDiscoverChannel] = @@ -3674,15 +3659,12 @@ export function RpgEntryHomeView({ }, [activeCategoryGroup, categoryKindFilter, categorySortMode]); const activeCategoryRawCount = activeCategoryGroup?.entries.length ?? 0; const activeCategoryFilterLabel = - PLATFORM_CATEGORY_KIND_FILTERS.find( - (option) => option.id === categoryKindFilter, - )?.label ?? '全部'; + getPlatformCategoryKindFilterOption(categoryKindFilter).label; const activeCategorySortLabel = - PLATFORM_CATEGORY_SORT_OPTIONS.find( - (option) => option.id === categorySortMode, - )?.label ?? '综合'; + getPlatformCategorySortOption(categorySortMode).label; const activeCategoryFilterCount = activeCategoryEntries.length; - const categoryFilterApplied = categoryKindFilter !== 'all'; + const categoryFilterApplied = + categoryKindFilter !== DEFAULT_PLATFORM_CATEGORY_KIND_FILTER; const visibleTabs = useMemo( () => isAuthenticated @@ -4633,16 +4615,7 @@ export function RpgEntryHomeView({ submitWorkSearch(mobileSearchKeyword); }; const cycleCategorySortMode = () => { - const currentIndex = PLATFORM_CATEGORY_SORT_OPTIONS.findIndex( - (option) => option.id === categorySortMode, - ); - const nextIndex = - currentIndex >= 0 - ? (currentIndex + 1) % PLATFORM_CATEGORY_SORT_OPTIONS.length - : 0; - setCategorySortMode( - PLATFORM_CATEGORY_SORT_OPTIONS[nextIndex]?.id ?? 'composite', - ); + setCategorySortMode(getNextPlatformCategorySortMode(categorySortMode)); }; const desktopHeroEntry = featuredShelf[0] ?? generalLatestEntries[0] ?? myEntries[0] ?? null; diff --git a/src/components/rpg-entry/rpgEntryPublicGalleryViewModel.test.ts b/src/components/rpg-entry/rpgEntryPublicGalleryViewModel.test.ts index 146fa079..3071ce05 100644 --- a/src/components/rpg-entry/rpgEntryPublicGalleryViewModel.test.ts +++ b/src/components/rpg-entry/rpgEntryPublicGalleryViewModel.test.ts @@ -7,18 +7,27 @@ import { buildPublicCategoryGroups, buildPublicGalleryCardKey, dedupePlatformPublicGalleryEntries, + DEFAULT_PLATFORM_CATEGORY_KIND_FILTER, + DEFAULT_PLATFORM_CATEGORY_SORT_MODE, DEFAULT_PLATFORM_RANKING_TAB, filterPlatformWorkSearchResults, filterTodayPublishedEntries, + getNextPlatformCategorySortMode, getPlatformCategoryKindFilter, + getPlatformCategoryKindFilterOption, getPlatformCategoryPrimaryMetric, + getPlatformCategorySortOption, getPlatformPublicEntries, getPlatformRankingMetric, getPlatformRankingMetricValue, getPlatformRankingTabConfig, matchesPlatformCategoryKindFilter, parsePlatformEntryTimestamp, + PLATFORM_CATEGORY_KIND_FILTERS, + PLATFORM_CATEGORY_SORT_OPTIONS, PLATFORM_RANKING_TABS, + type PlatformCategoryKindFilter, + type PlatformCategorySortMode, selectAdjacentPlatformRecommendEntry, selectPlatformRecommendFeedWindow, sortPlatformCategoryEntries, @@ -308,6 +317,52 @@ test('public gallery ViewModel keeps source kinds behind one category filter sea ); }); +test('public gallery ViewModel exposes category filter and sort option interface', () => { + expect(DEFAULT_PLATFORM_CATEGORY_KIND_FILTER).toBe('all'); + expect(DEFAULT_PLATFORM_CATEGORY_SORT_MODE).toBe('composite'); + expect(PLATFORM_CATEGORY_KIND_FILTERS.map((option) => option.label)).toEqual([ + '全部', + '拼图', + '抓鹅', + '方洞', + '视觉', + '汪汪', + '大鱼', + '跳跃', + '木鱼', + 'RPG', + ]); + expect(PLATFORM_CATEGORY_SORT_OPTIONS.map((option) => option.label)).toEqual([ + '综合', + '最新', + '游玩', + '点赞', + ]); + expect(getPlatformCategoryKindFilterOption('match3d')).toEqual({ + id: 'match3d', + label: '抓鹅', + }); + expect(getPlatformCategorySortOption('latest')).toEqual({ + id: 'latest', + label: '最新', + }); + expect( + getPlatformCategoryKindFilterOption( + 'unknown' as PlatformCategoryKindFilter, + ), + ).toEqual({ id: 'all', label: '全部' }); + expect( + getPlatformCategorySortOption('unknown' as PlatformCategorySortMode), + ).toEqual({ id: 'composite', label: '综合' }); + expect(getNextPlatformCategorySortMode('composite')).toBe('latest'); + expect(getNextPlatformCategorySortMode('latest')).toBe('play'); + expect(getNextPlatformCategorySortMode('play')).toBe('like'); + expect(getNextPlatformCategorySortMode('like')).toBe('composite'); + expect( + getNextPlatformCategorySortMode('unknown' as PlatformCategorySortMode), + ).toBe('composite'); +}); + test('public gallery ViewModel ranks entries by selected metric', () => { const playWinner = buildJumpHopEntry({ profileId: 'play-winner', diff --git a/src/components/rpg-entry/rpgEntryPublicGalleryViewModel.ts b/src/components/rpg-entry/rpgEntryPublicGalleryViewModel.ts index 0ff23cb1..6c2a4e85 100644 --- a/src/components/rpg-entry/rpgEntryPublicGalleryViewModel.ts +++ b/src/components/rpg-entry/rpgEntryPublicGalleryViewModel.ts @@ -37,6 +37,14 @@ export type PlatformCategoryKindFilter = | 'wooden-fish' | 'custom-world'; export type PlatformCategorySortMode = 'composite' | 'latest' | 'play' | 'like'; +export type PlatformCategoryKindFilterOption = { + id: PlatformCategoryKindFilter; + label: string; +}; +export type PlatformCategorySortOption = { + id: PlatformCategorySortMode; + label: string; +}; export type PlatformPublicCategoryGroup = { tag: string; @@ -44,6 +52,10 @@ export type PlatformPublicCategoryGroup = { }; export const DEFAULT_PLATFORM_RANKING_TAB: PlatformRankingTab = 'hot'; +export const DEFAULT_PLATFORM_CATEGORY_KIND_FILTER: PlatformCategoryKindFilter = + 'all'; +export const DEFAULT_PLATFORM_CATEGORY_SORT_MODE: PlatformCategorySortMode = + 'composite'; export const PLATFORM_RANKING_TABS: PlatformRankingTabConfig[] = [ { @@ -77,6 +89,36 @@ const DEFAULT_PLATFORM_RANKING_CONFIG = (config) => config.id === DEFAULT_PLATFORM_RANKING_TAB, ) ?? PLATFORM_RANKING_TABS[0]!; +export const PLATFORM_CATEGORY_KIND_FILTERS: PlatformCategoryKindFilterOption[] = + [ + { id: 'all', label: '全部' }, + { id: 'puzzle', label: '拼图' }, + { id: 'match3d', label: '抓鹅' }, + { id: 'square-hole', label: '方洞' }, + { 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' }, + ]; + +export const PLATFORM_CATEGORY_SORT_OPTIONS: PlatformCategorySortOption[] = [ + { id: 'composite', label: '综合' }, + { id: 'latest', label: '最新' }, + { id: 'play', label: '游玩' }, + { id: 'like', label: '点赞' }, +]; + +const DEFAULT_PLATFORM_CATEGORY_KIND_FILTER_OPTION = + PLATFORM_CATEGORY_KIND_FILTERS.find( + (option) => option.id === DEFAULT_PLATFORM_CATEGORY_KIND_FILTER, + ) ?? PLATFORM_CATEGORY_KIND_FILTERS[0]!; +const DEFAULT_PLATFORM_CATEGORY_SORT_OPTION = + PLATFORM_CATEGORY_SORT_OPTIONS.find( + (option) => option.id === DEFAULT_PLATFORM_CATEGORY_SORT_MODE, + ) ?? PLATFORM_CATEGORY_SORT_OPTIONS[0]!; + export type PlatformRecommendFeedWindow = { activeEntry: PlatformPublicGalleryCard | null; activeEntryKey: string | null; @@ -552,6 +594,41 @@ export function getPlatformCategoryPrimaryMetric( return { label: '游玩', value: getPlatformWorldPlayCount(entry) }; } +export function getPlatformCategoryKindFilterOption( + kindFilter: PlatformCategoryKindFilter, +): PlatformCategoryKindFilterOption { + return ( + PLATFORM_CATEGORY_KIND_FILTERS.find((option) => option.id === kindFilter) ?? + DEFAULT_PLATFORM_CATEGORY_KIND_FILTER_OPTION + ); +} + +export function getPlatformCategorySortOption( + sortMode: PlatformCategorySortMode, +): PlatformCategorySortOption { + return ( + PLATFORM_CATEGORY_SORT_OPTIONS.find((option) => option.id === sortMode) ?? + DEFAULT_PLATFORM_CATEGORY_SORT_OPTION + ); +} + +export function getNextPlatformCategorySortMode( + sortMode: PlatformCategorySortMode, +): PlatformCategorySortMode { + const currentIndex = PLATFORM_CATEGORY_SORT_OPTIONS.findIndex( + (option) => option.id === sortMode, + ); + const nextIndex = + currentIndex >= 0 + ? (currentIndex + 1) % PLATFORM_CATEGORY_SORT_OPTIONS.length + : 0; + + return ( + PLATFORM_CATEGORY_SORT_OPTIONS[nextIndex]?.id ?? + DEFAULT_PLATFORM_CATEGORY_SORT_MODE + ); +} + export function parsePlatformEntryTimestamp(value: string | null | undefined) { if (!value) { return 0;