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

@@ -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<string, PlatformPublicGalleryCard>();
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<CustomWorldWorkSummary[]>(
() =>
@@ -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);
},

View File

@@ -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<string, PlatformPublicGalleryCard>();
[...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<string, PlatformPublicGalleryCard>();
[...featuredShelf, ...generalLatestEntries].forEach((entry) => {
entryMap.set(buildPublicGalleryCardKey(entry), entry);
});
return Array.from(entryMap.values());
}, [featuredShelf, generalLatestEntries]);
const discoverFeedEntries = useMemo(() => {
const entryMap = new Map<string, PlatformPublicGalleryCard>();
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<string, PlatformPublicGalleryCard>();
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<number | null>(null);
const recommendCardStageRef = useRef<HTMLDivElement | null>(null);
const recommendDragStartRef = useRef<{

View File

@@ -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',

View File

@@ -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<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(
featuredEntries: PlatformPublicGalleryCard[],
latestEntries: PlatformPublicGalleryCard[],
): PlatformPublicCategoryGroup[] {
const publicEntryMap = new Map<string, PlatformPublicGalleryCard>();
filterGeneralPublicWorks([...featuredEntries, ...latestEntries]).forEach(
(entry) => {
publicEntryMap.set(buildPublicGalleryCardKey(entry), entry);
},
const publicEntries = buildPlatformRecommendFeedEntries(
featuredEntries,
latestEntries,
);
const categoryMap = new Map<string, PlatformPublicGalleryCard[]>();
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<string, PlatformPublicGalleryCard>();
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<string, PlatformPublicGalleryCard>();
[...featuredEntries, ...latestEntries].forEach((entry) => {
entryMap.set(buildPublicGalleryCardKey(entry), entry);
});
return Array.from(entryMap.values());
return dedupePlatformPublicGalleryEntries([
...featuredEntries,
...latestEntries,
]);
}
function normalizePlatformSearchText(value: string | null | undefined) {