diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index fb0bfb2f..11ec2081 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -1241,6 +1241,14 @@ - 验证方式:`npm run test -- src/components/rpg-entry/rpgEntryProfileDashboardPresentation.test.ts`、针对变更文件执行 ESLint、`npm run typecheck`、`npm run check:encoding`。 - 关联文档:`docs/technical/【前端架构】ProfileDashboardPresentation收口计划-2026-06-03.md`。 +## 2026-06-03 Recommend Feed ViewModel 收口 + +- 背景:推荐 feed 与正式 runtime 的上一条 / 下一条选择分别在 `RpgEntryHomeView.tsx` 和 `PlatformEntryFlowShellImpl.tsx` 手写公开作品去重、隐藏内容过滤、active key 兜底和相邻回环,存在推荐预览与 runtime 口径漂移风险。 +- 决策:在 `src/components/rpg-entry/rpgEntryPublicGalleryViewModel.ts` 追加推荐 feed Module Interface:`dedupePlatformPublicGalleryEntries`、`buildPlatformRecommendFeedEntries`、`selectPlatformRecommendFeedWindow`、`selectAdjacentPlatformRecommendEntry`;首页与 FlowShell 均消费该 Interface。 +- 影响范围:移动端首页推荐 swipe、发现页推荐频道、桌面推荐格、推荐 runtime 队列与上一条 / 下一条跳转。 +- 验证方式:`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`。 + ## 2026-05-26 前端不外露图片模型名 - 背景:拼图与相关结果页、生成进度和错误提示里直接显示 `gpt-image-2`、`gemini-3.1-flash-image-preview`、`image-2` 等名称,会把内部模型路由暴露给普通用户。 diff --git a/docs/README.md b/docs/README.md index 8f2a22f8..39969faa 100644 --- a/docs/README.md +++ b/docs/README.md @@ -47,6 +47,8 @@ AI 文字游戏模板接入以 [AI_NATIVE_TEXT_GAME_TEMPLATE_MOKU_REFERENCE_PRD_ 公开作品分类、搜索、跨来源去重、今日筛选、排行排序和时间戳解析收口到 `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)。 +推荐 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)。 + 每日任务卡片与任务中心弹窗的任务选择、进度、状态标签和按钮文案收口到 `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)。 diff --git a/docs/technical/【前端架构】RecommendFeedViewModel收口计划-2026-06-03.md b/docs/technical/【前端架构】RecommendFeedViewModel收口计划-2026-06-03.md new file mode 100644 index 00000000..3ccbf7c4 --- /dev/null +++ b/docs/technical/【前端架构】RecommendFeedViewModel收口计划-2026-06-03.md @@ -0,0 +1,31 @@ +# 【前端架构】Recommend Feed ViewModel 收口计划 + +## 背景 + +平台首页推荐 feed、发现页推荐频道、桌面推荐格和正式 runtime 的上一条 / 下一条选择共用一批展示规则:公开作品跨来源去重、过滤寓教于乐隐藏内容、按精选优先再最新兜底、active key 失效时回到首项、前后相邻条目回环且单条目不自循环。原先这些规则分别散在 `RpgEntryHomeView.tsx` 与 `PlatformEntryFlowShellImpl.tsx` 的 **Implementation** 内,导致推荐预览与正式 runtime 之间存在口径漂移风险。 + +## 决策 + +在 `src/components/rpg-entry/rpgEntryPublicGalleryViewModel.ts` 追加推荐 feed **Interface**: + +- `dedupePlatformPublicGalleryEntries(entries)`:统一公开作品按 `buildPublicGalleryCardKey` 去重,后出现来源覆盖旧值。 +- `buildPlatformRecommendFeedEntries(featuredEntries, latestEntries)`:统一推荐 feed 的精选 + 最新合并、隐藏寓教于乐内容与去重顺序。 +- `selectPlatformRecommendFeedWindow(entries, activeEntryKey)`:统一推荐页当前项、上一项、下一项和 active key 失效兜底。 +- `selectAdjacentPlatformRecommendEntry(entries, direction, baseEntryKey)`:统一正式 runtime 上一条 / 下一条回环选择,并避免单作品自循环。 + +`RpgEntryHomeView.tsx` 不再自建 `Map` 或手写取模;`PlatformEntryFlowShellImpl.tsx` 的 runtime 推荐条目也改用同一 **Module**。推荐 feed 的 **Locality** 回到 PublicGallery ViewModel,页面与 runtime 只保留 UI、动画和启动副作用。 + +## 约定 + +- 推荐 feed 仍只展示普通公开作品;寓教于乐内容由独立频道控制,不进入推荐 runtime 队列。 +- 去重保留既有“后出现来源覆盖旧值、插入位置不变”的行为。 +- active key 缺失或失效时,展示窗口回到首个推荐作品;单个作品没有上一条 / 下一条预览。 + +## 验证 + +- `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` diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index 71489366..e7ee35c6 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -359,6 +359,10 @@ import { isPersistedPuzzleDraftGenerating, resolvePuzzleWorkCoverImageSrc, } from '../custom-world-home/creationWorkShelf'; +import { + buildPlatformRecommendFeedEntries, + selectAdjacentPlatformRecommendEntry, +} from '../rpg-entry/rpgEntryPublicGalleryViewModel'; import { isBarkBattleGalleryEntry, isBigFishGalleryEntry, @@ -406,7 +410,6 @@ import { import { canExposePublicWork, EDUTAINMENT_HIDDEN_MESSAGE, - filterGeneralPublicWorks, } from './platformEdutainmentVisibility'; import { PlatformEntryCreationTypeModal } from './PlatformEntryCreationTypeModal'; import type { PlatformCreationTypeId } from './platformEntryCreationTypes'; @@ -5004,16 +5007,14 @@ export function PlatformEntryFlowShellImpl({ woodenFishGalleryEntries, ], ); - const recommendRuntimeEntries = useMemo(() => { - const entryMap = new Map(); - filterGeneralPublicWorks([ - ...featuredGalleryEntries, - ...latestGalleryEntries, - ]).forEach((entry) => { - entryMap.set(getPlatformPublicGalleryEntryKey(entry), entry); - }); - return Array.from(entryMap.values()); - }, [featuredGalleryEntries, latestGalleryEntries]); + const recommendRuntimeEntries = useMemo( + () => + buildPlatformRecommendFeedEntries( + featuredGalleryEntries, + latestGalleryEntries, + ), + [featuredGalleryEntries, latestGalleryEntries], + ); const creationHubItems = useMemo( () => @@ -14429,29 +14430,16 @@ export function PlatformEntryFlowShellImpl({ ); const selectAdjacentRecommendRuntimeEntry = useCallback( (direction: 1 | -1, baseEntryKey?: string | null) => { - if (recommendRuntimeEntries.length === 0) { - return; - } - const normalizedBaseEntryKey = baseEntryKey?.trim() || activeRecommendEntryKey; - const activeIndex = recommendRuntimeEntries.findIndex( - (entry) => - getPlatformPublicGalleryEntryKey(entry) === normalizedBaseEntryKey, + const nextEntry = selectAdjacentPlatformRecommendEntry( + recommendRuntimeEntries, + direction, + normalizedBaseEntryKey, ); - const baseIndex = activeIndex >= 0 ? activeIndex : 0; - const nextIndex = - (baseIndex + direction + recommendRuntimeEntries.length) % - recommendRuntimeEntries.length; - const nextEntry = recommendRuntimeEntries[nextIndex]; if (!nextEntry) { return; } - if ( - getPlatformPublicGalleryEntryKey(nextEntry) === normalizedBaseEntryKey - ) { - return; - } void selectRecommendRuntimeEntry(nextEntry); }, diff --git a/src/components/rpg-entry/RpgEntryHomeView.tsx b/src/components/rpg-entry/RpgEntryHomeView.tsx index 35618a4b..3937c789 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.tsx @@ -150,8 +150,10 @@ import { } from './rpgEntryProfileTaskViewModel'; import { buildPlatformRankingEntries, + buildPlatformRecommendFeedEntries, buildPublicCategoryGroups, buildPublicGalleryCardKey, + dedupePlatformPublicGalleryEntries, filterPlatformWorkSearchResults, filterTodayPublishedEntries, getAllPlatformPublicEntries, @@ -167,6 +169,7 @@ import { type PlatformCategoryKindFilter, type PlatformCategorySortMode, type PlatformRankingTab, + selectPlatformRecommendFeedWindow, sortPlatformCategoryEntries, } from './rpgEntryPublicGalleryViewModel'; import { @@ -4742,14 +4745,11 @@ export function RpgEntryHomeView({ featuredShelf.length > 0 ? featuredShelf : generalLatestEntries ).slice(0, 5); // 网页端保留原有宽屏布局,只把模块数据同步到移动端首页频道语义。 - const desktopRecommendEntries = useMemo(() => { - const entryMap = new Map(); - [...featuredShelf, ...generalLatestEntries].forEach((entry) => { - entryMap.set(buildPublicGalleryCardKey(entry), entry); - }); - - return Array.from(entryMap.values()); - }, [featuredShelf, generalLatestEntries]); + const recommendedFeedEntries = useMemo( + () => buildPlatformRecommendFeedEntries(featuredShelf, generalLatestEntries), + [featuredShelf, generalLatestEntries], + ); + const desktopRecommendEntries = recommendedFeedEntries; const desktopTodayEntries = useMemo( () => filterTodayPublishedEntries(generalLatestEntries), [generalLatestEntries], @@ -4757,35 +4757,18 @@ export function RpgEntryHomeView({ const desktopFeaturedGrid = desktopRecommendEntries.slice(0, 4); const desktopCategoryGrid = activeCategoryEntries.slice(0, 6); const desktopLibraryPreview = myEntries.slice(0, 2); - const recommendedFeedEntries = useMemo(() => { - const entryMap = new Map(); - [...featuredShelf, ...generalLatestEntries].forEach((entry) => { - entryMap.set(buildPublicGalleryCardKey(entry), entry); - }); - - return Array.from(entryMap.values()); - }, [featuredShelf, generalLatestEntries]); const discoverFeedEntries = useMemo(() => { - const entryMap = new Map(); const sourceEntries = discoverChannel === 'recommend' ? recommendedFeedEntries : filterTodayPublishedEntries(generalLatestEntries); - sourceEntries.forEach((entry) => { - entryMap.set(buildPublicGalleryCardKey(entry), entry); - }); - - return Array.from(entryMap.values()); + return dedupePlatformPublicGalleryEntries(sourceEntries); }, [discoverChannel, generalLatestEntries, recommendedFeedEntries]); - const edutainmentFeedEntries = useMemo(() => { - const entryMap = new Map(); - edutainmentEntries.forEach((entry) => { - entryMap.set(buildPublicGalleryCardKey(entry), entry); - }); - - return Array.from(entryMap.values()); - }, [edutainmentEntries]); + const edutainmentFeedEntries = useMemo( + () => dedupePlatformPublicGalleryEntries(edutainmentEntries), + [edutainmentEntries], + ); const mobileFeedCarouselEnabled = !isDesktopLayout && activeTab === 'category' && @@ -4883,41 +4866,24 @@ export function RpgEntryHomeView({ buildPlatformRankingEntries(publicEntries, activeRankingTab).slice(0, 30), [activeRankingTab, publicEntries], ); - const activeRecommendEntry = - recommendedFeedEntries.find( - (entry) => buildPublicGalleryCardKey(entry) === activeRecommendEntryKey, - ) ?? - recommendedFeedEntries[0] ?? - null; - const activeRecommendIndex = activeRecommendEntry - ? recommendedFeedEntries.findIndex( - (entry) => - buildPublicGalleryCardKey(entry) === - buildPublicGalleryCardKey(activeRecommendEntry), - ) - : -1; - const previousRecommendEntry = - activeRecommendIndex >= 0 && recommendedFeedEntries.length > 1 - ? recommendedFeedEntries[ - (activeRecommendIndex - 1 + recommendedFeedEntries.length) % - recommendedFeedEntries.length - ] - : null; - const nextRecommendEntry = - activeRecommendIndex >= 0 && recommendedFeedEntries.length > 1 - ? recommendedFeedEntries[ - (activeRecommendIndex + 1) % recommendedFeedEntries.length - ] - : null; + const recommendFeedWindow = useMemo( + () => + selectPlatformRecommendFeedWindow( + recommendedFeedEntries, + activeRecommendEntryKey, + ), + [activeRecommendEntryKey, recommendedFeedEntries], + ); + const activeRecommendEntry = recommendFeedWindow.activeEntry; + const previousRecommendEntry = recommendFeedWindow.previousEntry; + const nextRecommendEntry = recommendFeedWindow.nextEntry; const [recommendDragOffsetY, setRecommendDragOffsetY] = useState(0); const [recommendDragCommitDirection, setRecommendDragCommitDirection] = useState<1 | -1 | null>(null); const [recommendShareState, setRecommendShareState] = useState< 'idle' | 'copied' | 'failed' >('idle'); - const activeRecommendEntryKeyForSelection = activeRecommendEntry - ? buildPublicGalleryCardKey(activeRecommendEntry) - : null; + const activeRecommendEntryKeyForSelection = recommendFeedWindow.activeEntryKey; const recommendShareResetTimerRef = useRef(null); const recommendCardStageRef = useRef(null); const recommendDragStartRef = useRef<{ diff --git a/src/components/rpg-entry/rpgEntryPublicGalleryViewModel.test.ts b/src/components/rpg-entry/rpgEntryPublicGalleryViewModel.test.ts index 729ca058..6ad55bcb 100644 --- a/src/components/rpg-entry/rpgEntryPublicGalleryViewModel.test.ts +++ b/src/components/rpg-entry/rpgEntryPublicGalleryViewModel.test.ts @@ -3,8 +3,10 @@ import { expect, test } from 'vitest'; import type { CustomWorldGalleryCard } from '../../../packages/shared/src/contracts/runtime'; import { buildPlatformRankingEntries, + buildPlatformRecommendFeedEntries, buildPublicCategoryGroups, buildPublicGalleryCardKey, + dedupePlatformPublicGalleryEntries, filterPlatformWorkSearchResults, filterTodayPublishedEntries, getPlatformCategoryKindFilter, @@ -13,6 +15,8 @@ import { getPlatformRankingMetricValue, matchesPlatformCategoryKindFilter, parsePlatformEntryTimestamp, + selectAdjacentPlatformRecommendEntry, + selectPlatformRecommendFeedWindow, sortPlatformCategoryEntries, } from './rpgEntryPublicGalleryViewModel'; import type { @@ -146,6 +150,100 @@ test('public gallery ViewModel dedupes merged public entries by latest source', }); }); +test('public gallery ViewModel builds recommend feed from general public entries', () => { + const featuredPuzzle = buildPuzzleEntry({ + profileId: 'shared', + worldName: '精选旧拼图', + }); + const latestPuzzle = buildPuzzleEntry({ + profileId: 'shared', + worldName: '最新拼图', + }); + const edutainmentPuzzle = buildPuzzleEntry({ + profileId: 'edutainment', + themeTags: ['寓教于乐'], + }); + const jumpHopEntry = buildJumpHopEntry({ profileId: 'jump-hop' }); + + expect( + buildPlatformRecommendFeedEntries( + [featuredPuzzle, edutainmentPuzzle], + [latestPuzzle, jumpHopEntry], + ), + ).toEqual([latestPuzzle, jumpHopEntry]); + expect( + dedupePlatformPublicGalleryEntries([featuredPuzzle, latestPuzzle]), + ).toEqual([latestPuzzle]); +}); + +test('public gallery ViewModel selects recommend feed window with wraparound neighbors', () => { + const firstEntry = buildPuzzleEntry({ profileId: 'first' }); + const secondEntry = buildJumpHopEntry({ profileId: 'second' }); + const thirdEntry = buildWoodenFishEntry({ profileId: 'third' }); + const entries = [firstEntry, secondEntry, thirdEntry]; + + expect(selectPlatformRecommendFeedWindow([], 'missing')).toEqual({ + activeEntry: null, + activeEntryKey: null, + activeIndex: -1, + nextEntry: null, + previousEntry: null, + }); + expect(selectPlatformRecommendFeedWindow([firstEntry], null)).toEqual({ + activeEntry: firstEntry, + activeEntryKey: buildPublicGalleryCardKey(firstEntry), + activeIndex: 0, + nextEntry: null, + previousEntry: null, + }); + expect( + selectPlatformRecommendFeedWindow( + entries, + buildPublicGalleryCardKey(secondEntry), + ), + ).toEqual({ + activeEntry: secondEntry, + activeEntryKey: buildPublicGalleryCardKey(secondEntry), + activeIndex: 1, + nextEntry: thirdEntry, + previousEntry: firstEntry, + }); + + expect(selectPlatformRecommendFeedWindow(entries, 'missing')).toEqual({ + activeEntry: firstEntry, + activeEntryKey: buildPublicGalleryCardKey(firstEntry), + activeIndex: 0, + nextEntry: secondEntry, + previousEntry: thirdEntry, + }); + expect(selectPlatformRecommendFeedWindow(entries, null)).toEqual({ + activeEntry: firstEntry, + activeEntryKey: buildPublicGalleryCardKey(firstEntry), + activeIndex: 0, + nextEntry: secondEntry, + previousEntry: thirdEntry, + }); +}); + +test('public gallery ViewModel selects adjacent recommend entry without self-loop', () => { + const onlyEntry = buildPuzzleEntry({ profileId: 'only' }); + const nextEntry = buildJumpHopEntry({ profileId: 'next' }); + + expect( + selectAdjacentPlatformRecommendEntry([onlyEntry], 1, 'missing'), + ).toBeNull(); + expect( + selectAdjacentPlatformRecommendEntry( + [onlyEntry, nextEntry], + 1, + buildPublicGalleryCardKey(onlyEntry), + ), + ).toBe(nextEntry); + expect( + selectAdjacentPlatformRecommendEntry([onlyEntry, nextEntry], -1, 'missing'), + ).toBe(nextEntry); +}); + test('public gallery ViewModel searches compact work codes and ranks name prefix first', () => { const nameMatch = buildPuzzleEntry({ profileId: 'name-match', diff --git a/src/components/rpg-entry/rpgEntryPublicGalleryViewModel.ts b/src/components/rpg-entry/rpgEntryPublicGalleryViewModel.ts index a59221f5..ceca4d6d 100644 --- a/src/components/rpg-entry/rpgEntryPublicGalleryViewModel.ts +++ b/src/components/rpg-entry/rpgEntryPublicGalleryViewModel.ts @@ -33,24 +33,119 @@ export type PlatformPublicCategoryGroup = { entries: PlatformPublicGalleryCard[]; }; +export type PlatformRecommendFeedWindow = { + activeEntry: PlatformPublicGalleryCard | null; + activeEntryKey: string | null; + activeIndex: number; + nextEntry: PlatformPublicGalleryCard | null; + previousEntry: PlatformPublicGalleryCard | null; +}; + export function buildPublicGalleryCardKey(entry: PlatformPublicGalleryCard) { return getPlatformPublicGalleryEntryKey(entry); } +export function dedupePlatformPublicGalleryEntries( + entries: PlatformPublicGalleryCard[], +) { + const entryMap = new Map(); + entries.forEach((entry) => { + entryMap.set(buildPublicGalleryCardKey(entry), entry); + }); + + return Array.from(entryMap.values()); +} + +export function buildPlatformRecommendFeedEntries( + featuredEntries: PlatformPublicGalleryCard[], + latestEntries: PlatformPublicGalleryCard[], +) { + return dedupePlatformPublicGalleryEntries( + filterGeneralPublicWorks([...featuredEntries, ...latestEntries]), + ); +} + +export function selectAdjacentPlatformRecommendEntry( + entries: PlatformPublicGalleryCard[], + direction: 1 | -1, + baseEntryKey?: string | null, +) { + if (entries.length <= 1) { + return null; + } + + const normalizedBaseEntryKey = baseEntryKey?.trim() ?? ''; + const activeIndex = normalizedBaseEntryKey + ? entries.findIndex( + (entry) => buildPublicGalleryCardKey(entry) === normalizedBaseEntryKey, + ) + : -1; + const baseIndex = activeIndex >= 0 ? activeIndex : 0; + const nextIndex = + (baseIndex + direction + entries.length) % entries.length; + const nextEntry = entries[nextIndex] ?? null; + if ( + nextEntry && + normalizedBaseEntryKey && + buildPublicGalleryCardKey(nextEntry) === normalizedBaseEntryKey + ) { + return null; + } + + return nextEntry; +} + +export function selectPlatformRecommendFeedWindow( + entries: PlatformPublicGalleryCard[], + activeEntryKey?: string | null, +): PlatformRecommendFeedWindow { + const normalizedActiveEntryKey = activeEntryKey?.trim() ?? ''; + const activeEntry = + (normalizedActiveEntryKey + ? entries.find( + (entry) => + buildPublicGalleryCardKey(entry) === normalizedActiveEntryKey, + ) + : null) ?? + entries[0] ?? + null; + const selectedActiveEntryKey = activeEntry + ? buildPublicGalleryCardKey(activeEntry) + : null; + const activeIndex = selectedActiveEntryKey + ? entries.findIndex( + (entry) => buildPublicGalleryCardKey(entry) === selectedActiveEntryKey, + ) + : -1; + + return { + activeEntry, + activeEntryKey: selectedActiveEntryKey, + activeIndex, + nextEntry: selectAdjacentPlatformRecommendEntry( + entries, + 1, + selectedActiveEntryKey, + ), + previousEntry: selectAdjacentPlatformRecommendEntry( + entries, + -1, + selectedActiveEntryKey, + ), + }; +} + export function buildPublicCategoryGroups( featuredEntries: PlatformPublicGalleryCard[], latestEntries: PlatformPublicGalleryCard[], ): PlatformPublicCategoryGroup[] { - const publicEntryMap = new Map(); - - filterGeneralPublicWorks([...featuredEntries, ...latestEntries]).forEach( - (entry) => { - publicEntryMap.set(buildPublicGalleryCardKey(entry), entry); - }, + const publicEntries = buildPlatformRecommendFeedEntries( + featuredEntries, + latestEntries, ); const categoryMap = new Map(); - Array.from(publicEntryMap.values()).forEach((entry) => { + publicEntries.forEach((entry) => { const tags = buildPlatformWorldDisplayTags(entry, 3); const normalizedTags = tags.length > 0 ? tags : ['回响']; @@ -76,28 +171,17 @@ export function getPlatformPublicEntries( featuredEntries: PlatformPublicGalleryCard[], latestEntries: PlatformPublicGalleryCard[], ) { - const entryMap = new Map(); - - filterGeneralPublicWorks([...featuredEntries, ...latestEntries]).forEach( - (entry) => { - entryMap.set(buildPublicGalleryCardKey(entry), entry); - }, - ); - - return Array.from(entryMap.values()); + return buildPlatformRecommendFeedEntries(featuredEntries, latestEntries); } export function getAllPlatformPublicEntries( featuredEntries: PlatformPublicGalleryCard[], latestEntries: PlatformPublicGalleryCard[], ) { - const entryMap = new Map(); - - [...featuredEntries, ...latestEntries].forEach((entry) => { - entryMap.set(buildPublicGalleryCardKey(entry), entry); - }); - - return Array.from(entryMap.values()); + return dedupePlatformPublicGalleryEntries([ + ...featuredEntries, + ...latestEntries, + ]); } function normalizePlatformSearchText(value: string | null | undefined) {