refactor: 收口推荐流展示模型

This commit is contained in:
2026-06-03 17:48:47 +08:00
parent a178942033
commit d67abecc9e
7 changed files with 287 additions and 110 deletions

View File

@@ -1241,6 +1241,14 @@
- 验证方式:`npm run test -- src/components/rpg-entry/rpgEntryProfileDashboardPresentation.test.ts`、针对变更文件执行 ESLint、`npm run typecheck``npm run check:encoding` - 验证方式:`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` - 关联文档:`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 前端不外露图片模型名 ## 2026-05-26 前端不外露图片模型名
- 背景:拼图与相关结果页、生成进度和错误提示里直接显示 `gpt-image-2``gemini-3.1-flash-image-preview``image-2` 等名称,会把内部模型路由暴露给普通用户。 - 背景:拼图与相关结果页、生成进度和错误提示里直接显示 `gpt-image-2``gemini-3.1-flash-image-preview``image-2` 等名称,会把内部模型路由暴露给普通用户。

View File

@@ -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)。 公开作品分类、搜索、跨来源去重、今日筛选、排行排序和时间戳解析收口到 `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)。 每日任务卡片与任务中心弹窗的任务选择、进度、状态标签和按钮文案收口到 `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,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`

View File

@@ -359,6 +359,10 @@ import {
isPersistedPuzzleDraftGenerating, isPersistedPuzzleDraftGenerating,
resolvePuzzleWorkCoverImageSrc, resolvePuzzleWorkCoverImageSrc,
} from '../custom-world-home/creationWorkShelf'; } from '../custom-world-home/creationWorkShelf';
import {
buildPlatformRecommendFeedEntries,
selectAdjacentPlatformRecommendEntry,
} from '../rpg-entry/rpgEntryPublicGalleryViewModel';
import { import {
isBarkBattleGalleryEntry, isBarkBattleGalleryEntry,
isBigFishGalleryEntry, isBigFishGalleryEntry,
@@ -406,7 +410,6 @@ import {
import { import {
canExposePublicWork, canExposePublicWork,
EDUTAINMENT_HIDDEN_MESSAGE, EDUTAINMENT_HIDDEN_MESSAGE,
filterGeneralPublicWorks,
} from './platformEdutainmentVisibility'; } from './platformEdutainmentVisibility';
import { PlatformEntryCreationTypeModal } from './PlatformEntryCreationTypeModal'; import { PlatformEntryCreationTypeModal } from './PlatformEntryCreationTypeModal';
import type { PlatformCreationTypeId } from './platformEntryCreationTypes'; import type { PlatformCreationTypeId } from './platformEntryCreationTypes';
@@ -5004,16 +5007,14 @@ export function PlatformEntryFlowShellImpl({
woodenFishGalleryEntries, woodenFishGalleryEntries,
], ],
); );
const recommendRuntimeEntries = useMemo(() => { const recommendRuntimeEntries = useMemo(
const entryMap = new Map<string, PlatformPublicGalleryCard>(); () =>
filterGeneralPublicWorks([ buildPlatformRecommendFeedEntries(
...featuredGalleryEntries, featuredGalleryEntries,
...latestGalleryEntries, latestGalleryEntries,
]).forEach((entry) => { ),
entryMap.set(getPlatformPublicGalleryEntryKey(entry), entry); [featuredGalleryEntries, latestGalleryEntries],
}); );
return Array.from(entryMap.values());
}, [featuredGalleryEntries, latestGalleryEntries]);
const creationHubItems = useMemo<CustomWorldWorkSummary[]>( const creationHubItems = useMemo<CustomWorldWorkSummary[]>(
() => () =>
@@ -14429,29 +14430,16 @@ export function PlatformEntryFlowShellImpl({
); );
const selectAdjacentRecommendRuntimeEntry = useCallback( const selectAdjacentRecommendRuntimeEntry = useCallback(
(direction: 1 | -1, baseEntryKey?: string | null) => { (direction: 1 | -1, baseEntryKey?: string | null) => {
if (recommendRuntimeEntries.length === 0) {
return;
}
const normalizedBaseEntryKey = const normalizedBaseEntryKey =
baseEntryKey?.trim() || activeRecommendEntryKey; baseEntryKey?.trim() || activeRecommendEntryKey;
const activeIndex = recommendRuntimeEntries.findIndex( const nextEntry = selectAdjacentPlatformRecommendEntry(
(entry) => recommendRuntimeEntries,
getPlatformPublicGalleryEntryKey(entry) === normalizedBaseEntryKey, direction,
normalizedBaseEntryKey,
); );
const baseIndex = activeIndex >= 0 ? activeIndex : 0;
const nextIndex =
(baseIndex + direction + recommendRuntimeEntries.length) %
recommendRuntimeEntries.length;
const nextEntry = recommendRuntimeEntries[nextIndex];
if (!nextEntry) { if (!nextEntry) {
return; return;
} }
if (
getPlatformPublicGalleryEntryKey(nextEntry) === normalizedBaseEntryKey
) {
return;
}
void selectRecommendRuntimeEntry(nextEntry); void selectRecommendRuntimeEntry(nextEntry);
}, },

View File

@@ -150,8 +150,10 @@ import {
} from './rpgEntryProfileTaskViewModel'; } from './rpgEntryProfileTaskViewModel';
import { import {
buildPlatformRankingEntries, buildPlatformRankingEntries,
buildPlatformRecommendFeedEntries,
buildPublicCategoryGroups, buildPublicCategoryGroups,
buildPublicGalleryCardKey, buildPublicGalleryCardKey,
dedupePlatformPublicGalleryEntries,
filterPlatformWorkSearchResults, filterPlatformWorkSearchResults,
filterTodayPublishedEntries, filterTodayPublishedEntries,
getAllPlatformPublicEntries, getAllPlatformPublicEntries,
@@ -167,6 +169,7 @@ import {
type PlatformCategoryKindFilter, type PlatformCategoryKindFilter,
type PlatformCategorySortMode, type PlatformCategorySortMode,
type PlatformRankingTab, type PlatformRankingTab,
selectPlatformRecommendFeedWindow,
sortPlatformCategoryEntries, sortPlatformCategoryEntries,
} from './rpgEntryPublicGalleryViewModel'; } from './rpgEntryPublicGalleryViewModel';
import { import {
@@ -4742,14 +4745,11 @@ export function RpgEntryHomeView({
featuredShelf.length > 0 ? featuredShelf : generalLatestEntries featuredShelf.length > 0 ? featuredShelf : generalLatestEntries
).slice(0, 5); ).slice(0, 5);
// 网页端保留原有宽屏布局,只把模块数据同步到移动端首页频道语义。 // 网页端保留原有宽屏布局,只把模块数据同步到移动端首页频道语义。
const desktopRecommendEntries = useMemo(() => { const recommendedFeedEntries = useMemo(
const entryMap = new Map<string, PlatformPublicGalleryCard>(); () => buildPlatformRecommendFeedEntries(featuredShelf, generalLatestEntries),
[...featuredShelf, ...generalLatestEntries].forEach((entry) => { [featuredShelf, generalLatestEntries],
entryMap.set(buildPublicGalleryCardKey(entry), entry); );
}); const desktopRecommendEntries = recommendedFeedEntries;
return Array.from(entryMap.values());
}, [featuredShelf, generalLatestEntries]);
const desktopTodayEntries = useMemo( const desktopTodayEntries = useMemo(
() => filterTodayPublishedEntries(generalLatestEntries), () => filterTodayPublishedEntries(generalLatestEntries),
[generalLatestEntries], [generalLatestEntries],
@@ -4757,35 +4757,18 @@ export function RpgEntryHomeView({
const desktopFeaturedGrid = desktopRecommendEntries.slice(0, 4); const desktopFeaturedGrid = desktopRecommendEntries.slice(0, 4);
const desktopCategoryGrid = activeCategoryEntries.slice(0, 6); const desktopCategoryGrid = activeCategoryEntries.slice(0, 6);
const desktopLibraryPreview = myEntries.slice(0, 2); const desktopLibraryPreview = myEntries.slice(0, 2);
const recommendedFeedEntries = useMemo(() => {
const entryMap = new Map<string, PlatformPublicGalleryCard>();
[...featuredShelf, ...generalLatestEntries].forEach((entry) => {
entryMap.set(buildPublicGalleryCardKey(entry), entry);
});
return Array.from(entryMap.values());
}, [featuredShelf, generalLatestEntries]);
const discoverFeedEntries = useMemo(() => { const discoverFeedEntries = useMemo(() => {
const entryMap = new Map<string, PlatformPublicGalleryCard>();
const sourceEntries = const sourceEntries =
discoverChannel === 'recommend' discoverChannel === 'recommend'
? recommendedFeedEntries ? recommendedFeedEntries
: filterTodayPublishedEntries(generalLatestEntries); : filterTodayPublishedEntries(generalLatestEntries);
sourceEntries.forEach((entry) => { return dedupePlatformPublicGalleryEntries(sourceEntries);
entryMap.set(buildPublicGalleryCardKey(entry), entry);
});
return Array.from(entryMap.values());
}, [discoverChannel, generalLatestEntries, recommendedFeedEntries]); }, [discoverChannel, generalLatestEntries, recommendedFeedEntries]);
const edutainmentFeedEntries = useMemo(() => { const edutainmentFeedEntries = useMemo(
const entryMap = new Map<string, PlatformPublicGalleryCard>(); () => dedupePlatformPublicGalleryEntries(edutainmentEntries),
edutainmentEntries.forEach((entry) => { [edutainmentEntries],
entryMap.set(buildPublicGalleryCardKey(entry), entry); );
});
return Array.from(entryMap.values());
}, [edutainmentEntries]);
const mobileFeedCarouselEnabled = const mobileFeedCarouselEnabled =
!isDesktopLayout && !isDesktopLayout &&
activeTab === 'category' && activeTab === 'category' &&
@@ -4883,41 +4866,24 @@ export function RpgEntryHomeView({
buildPlatformRankingEntries(publicEntries, activeRankingTab).slice(0, 30), buildPlatformRankingEntries(publicEntries, activeRankingTab).slice(0, 30),
[activeRankingTab, publicEntries], [activeRankingTab, publicEntries],
); );
const activeRecommendEntry = const recommendFeedWindow = useMemo(
recommendedFeedEntries.find( () =>
(entry) => buildPublicGalleryCardKey(entry) === activeRecommendEntryKey, selectPlatformRecommendFeedWindow(
) ?? recommendedFeedEntries,
recommendedFeedEntries[0] ?? activeRecommendEntryKey,
null; ),
const activeRecommendIndex = activeRecommendEntry [activeRecommendEntryKey, recommendedFeedEntries],
? recommendedFeedEntries.findIndex( );
(entry) => const activeRecommendEntry = recommendFeedWindow.activeEntry;
buildPublicGalleryCardKey(entry) === const previousRecommendEntry = recommendFeedWindow.previousEntry;
buildPublicGalleryCardKey(activeRecommendEntry), const nextRecommendEntry = recommendFeedWindow.nextEntry;
)
: -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 [recommendDragOffsetY, setRecommendDragOffsetY] = useState(0); const [recommendDragOffsetY, setRecommendDragOffsetY] = useState(0);
const [recommendDragCommitDirection, setRecommendDragCommitDirection] = const [recommendDragCommitDirection, setRecommendDragCommitDirection] =
useState<1 | -1 | null>(null); useState<1 | -1 | null>(null);
const [recommendShareState, setRecommendShareState] = useState< const [recommendShareState, setRecommendShareState] = useState<
'idle' | 'copied' | 'failed' 'idle' | 'copied' | 'failed'
>('idle'); >('idle');
const activeRecommendEntryKeyForSelection = activeRecommendEntry const activeRecommendEntryKeyForSelection = recommendFeedWindow.activeEntryKey;
? buildPublicGalleryCardKey(activeRecommendEntry)
: null;
const recommendShareResetTimerRef = useRef<number | null>(null); const recommendShareResetTimerRef = useRef<number | null>(null);
const recommendCardStageRef = useRef<HTMLDivElement | null>(null); const recommendCardStageRef = useRef<HTMLDivElement | null>(null);
const recommendDragStartRef = useRef<{ const recommendDragStartRef = useRef<{

View File

@@ -3,8 +3,10 @@ import { expect, test } from 'vitest';
import type { CustomWorldGalleryCard } from '../../../packages/shared/src/contracts/runtime'; import type { CustomWorldGalleryCard } from '../../../packages/shared/src/contracts/runtime';
import { import {
buildPlatformRankingEntries, buildPlatformRankingEntries,
buildPlatformRecommendFeedEntries,
buildPublicCategoryGroups, buildPublicCategoryGroups,
buildPublicGalleryCardKey, buildPublicGalleryCardKey,
dedupePlatformPublicGalleryEntries,
filterPlatformWorkSearchResults, filterPlatformWorkSearchResults,
filterTodayPublishedEntries, filterTodayPublishedEntries,
getPlatformCategoryKindFilter, getPlatformCategoryKindFilter,
@@ -13,6 +15,8 @@ import {
getPlatformRankingMetricValue, getPlatformRankingMetricValue,
matchesPlatformCategoryKindFilter, matchesPlatformCategoryKindFilter,
parsePlatformEntryTimestamp, parsePlatformEntryTimestamp,
selectAdjacentPlatformRecommendEntry,
selectPlatformRecommendFeedWindow,
sortPlatformCategoryEntries, sortPlatformCategoryEntries,
} from './rpgEntryPublicGalleryViewModel'; } from './rpgEntryPublicGalleryViewModel';
import type { 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', () => { test('public gallery ViewModel searches compact work codes and ranks name prefix first', () => {
const nameMatch = buildPuzzleEntry({ const nameMatch = buildPuzzleEntry({
profileId: 'name-match', profileId: 'name-match',

View File

@@ -33,24 +33,119 @@ export type PlatformPublicCategoryGroup = {
entries: PlatformPublicGalleryCard[]; entries: PlatformPublicGalleryCard[];
}; };
export type PlatformRecommendFeedWindow = {
activeEntry: PlatformPublicGalleryCard | null;
activeEntryKey: string | null;
activeIndex: number;
nextEntry: PlatformPublicGalleryCard | null;
previousEntry: PlatformPublicGalleryCard | null;
};
export function buildPublicGalleryCardKey(entry: PlatformPublicGalleryCard) { export function buildPublicGalleryCardKey(entry: PlatformPublicGalleryCard) {
return getPlatformPublicGalleryEntryKey(entry); return getPlatformPublicGalleryEntryKey(entry);
} }
export function dedupePlatformPublicGalleryEntries(
entries: PlatformPublicGalleryCard[],
) {
const entryMap = new Map<string, PlatformPublicGalleryCard>();
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( export function buildPublicCategoryGroups(
featuredEntries: PlatformPublicGalleryCard[], featuredEntries: PlatformPublicGalleryCard[],
latestEntries: PlatformPublicGalleryCard[], latestEntries: PlatformPublicGalleryCard[],
): PlatformPublicCategoryGroup[] { ): PlatformPublicCategoryGroup[] {
const publicEntryMap = new Map<string, PlatformPublicGalleryCard>(); const publicEntries = buildPlatformRecommendFeedEntries(
featuredEntries,
filterGeneralPublicWorks([...featuredEntries, ...latestEntries]).forEach( latestEntries,
(entry) => {
publicEntryMap.set(buildPublicGalleryCardKey(entry), entry);
},
); );
const categoryMap = new Map<string, PlatformPublicGalleryCard[]>(); const categoryMap = new Map<string, PlatformPublicGalleryCard[]>();
Array.from(publicEntryMap.values()).forEach((entry) => { publicEntries.forEach((entry) => {
const tags = buildPlatformWorldDisplayTags(entry, 3); const tags = buildPlatformWorldDisplayTags(entry, 3);
const normalizedTags = tags.length > 0 ? tags : ['回响']; const normalizedTags = tags.length > 0 ? tags : ['回响'];
@@ -76,28 +171,17 @@ export function getPlatformPublicEntries(
featuredEntries: PlatformPublicGalleryCard[], featuredEntries: PlatformPublicGalleryCard[],
latestEntries: PlatformPublicGalleryCard[], latestEntries: PlatformPublicGalleryCard[],
) { ) {
const entryMap = new Map<string, PlatformPublicGalleryCard>(); return buildPlatformRecommendFeedEntries(featuredEntries, latestEntries);
filterGeneralPublicWorks([...featuredEntries, ...latestEntries]).forEach(
(entry) => {
entryMap.set(buildPublicGalleryCardKey(entry), entry);
},
);
return Array.from(entryMap.values());
} }
export function getAllPlatformPublicEntries( export function getAllPlatformPublicEntries(
featuredEntries: PlatformPublicGalleryCard[], featuredEntries: PlatformPublicGalleryCard[],
latestEntries: PlatformPublicGalleryCard[], latestEntries: PlatformPublicGalleryCard[],
) { ) {
const entryMap = new Map<string, PlatformPublicGalleryCard>(); return dedupePlatformPublicGalleryEntries([
...featuredEntries,
[...featuredEntries, ...latestEntries].forEach((entry) => { ...latestEntries,
entryMap.set(buildPublicGalleryCardKey(entry), entry); ]);
});
return Array.from(entryMap.values());
} }
function normalizePlatformSearchText(value: string | null | undefined) { function normalizePlatformSearchText(value: string | null | undefined) {