refactor: 收口推荐流展示模型
This commit is contained in:
@@ -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` 等名称,会把内部模型路由暴露给普通用户。
|
||||||
|
|||||||
@@ -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)。
|
||||||
|
|||||||
@@ -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`
|
||||||
@@ -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);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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<{
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user