feat: add recommendation feed scoring
This commit is contained in:
@@ -175,7 +175,7 @@ RPG / 拼图等运行态存档仍以 `/api/profile/save-archives` 的后端列
|
||||
|
||||
跳一跳作品架删除入口必须走 `/api/creation/jump-hop/works/{profile_id}`,并通过 SpacetimeDB 同步删除 work profile、源 session、运行态 run 与事件,再刷新作品架和公开广场;不得只做前端本地隐藏。
|
||||
|
||||
推荐页匿名游玩不再限定为跳一跳。移动端一级 `推荐` Tab 是内嵌运行态刷卡流,会自动选择推荐作品并启动对应玩法;桌面端首页不启动这套移动推荐运行态,而是渲染桌面发现壳,展示 `今日游戏`、`推荐`、`作品分类` 等桌面内容。断点事实统一走 `platformEntryResponsive.ts` 的 `usePlatformDesktopLayout()`,平台壳和首页视图必须共用同一个判断,避免桌面发现页与移动推荐页同时挂载、重复触发请求或启动运行态。移动端推荐页启动或切换作品时先展示当前作品封面,嵌入 runtime 在封面下层加载;只有对应运行态 run / profile 已准备且 lazy runtime 组件完成挂载后,封面才渐隐,不在中途展示“加载中”文案。拼图下一关在同一个 run 内推进到相似作品时不视为推荐作品切换,不能重新显示启动封面。推荐页嵌入运行态启动时按真实身份分流:已登录用户或本地已有 access token 时继续使用账号 Bearer,但请求选项必须是 local auth impact,避免单卡 401 清空整站登录态;只有确认为匿名访客时才申请短期 Runtime Guest Token,并只把它作为局部请求头传给运行态客户端,不写入全局登录态、不触发 refresh,也不把匿名流量伪装成普通用户。当前覆盖矩阵为:跳一跳、视觉小说、抓大鹅 Match3D、方洞挑战、拼图、敲木鱼、大鱼吃小鱼、汪汪声浪。每个模板的启动请求、推荐页内后续运行态动作以及需要上报的 play/finish/leaderboard/next-level 类请求,都必须继续按该身份分流;公开读取入口仍可匿名读取,创作、个人作品、删除、发布、Remix 等账号/所有权动作仍保持普通用户鉴权。
|
||||
推荐页匿名游玩不再限定为跳一跳。移动端一级 `推荐` Tab 是内嵌运行态刷卡流,会自动选择推荐作品并启动对应玩法;桌面端首页不启动这套移动推荐运行态,而是渲染桌面发现壳,展示 `今日游戏`、`推荐`、`作品分类` 等桌面内容。推荐页候选顺序由前端轻量推荐算法 `platformRecommendation.ts` 统一生成:先按公开作品 key 去重,再使用公开读模型已有的精选来源、近 7 日游玩、点赞、改造、总游玩、发布时间新鲜度、封面和标签完整度做确定性评分,最后优先交错不同玩法类型;只要还有其它玩法候选,就不要连续推荐同一玩法,只有候选池已没有其它玩法时才允许同玩法相邻。该算法不得新增前端业务真相或绕过公开作品 read model。断点事实统一走 `platformEntryResponsive.ts` 的 `usePlatformDesktopLayout()`,平台壳和首页视图必须共用同一个判断,避免桌面发现页与移动推荐页同时挂载、重复触发请求或启动运行态。移动端推荐页启动或切换作品时先展示当前作品封面,嵌入 runtime 在封面下层加载;只有对应运行态 run / profile 已准备且 lazy runtime 组件完成挂载后,封面才渐隐,不在中途展示“加载中”文案。拼图下一关在同一个 run 内推进到相似作品时不视为推荐作品切换,不能重新显示启动封面。推荐页嵌入运行态启动时按真实身份分流:已登录用户或本地已有 access token 时继续使用账号 Bearer,但请求选项必须是 local auth impact,避免单卡 401 清空整站登录态;只有确认为匿名访客时才申请短期 Runtime Guest Token,并只把它作为局部请求头传给运行态客户端,不写入全局登录态、不触发 refresh,也不把匿名流量伪装成普通用户。当前覆盖矩阵为:跳一跳、视觉小说、抓大鹅 Match3D、方洞挑战、拼图、敲木鱼、大鱼吃小鱼、汪汪声浪。每个模板的启动请求、推荐页内后续运行态动作以及需要上报的 play/finish/leaderboard/next-level 类请求,都必须继续按该身份分流;公开读取入口仍可匿名读取,创作、个人作品、删除、发布、Remix 等账号/所有权动作仍保持普通用户鉴权。
|
||||
|
||||
## 敲木鱼
|
||||
|
||||
|
||||
@@ -438,6 +438,7 @@ import {
|
||||
EDUTAINMENT_HIDDEN_MESSAGE,
|
||||
filterGeneralPublicWorks,
|
||||
} from './platformEdutainmentVisibility';
|
||||
import { buildPlatformRecommendedEntries } from './platformRecommendation';
|
||||
import { PlatformEntryCreationTypeModal } from './PlatformEntryCreationTypeModal';
|
||||
import type { PlatformCreationTypeId } from './platformEntryCreationTypes';
|
||||
import {
|
||||
@@ -5408,14 +5409,10 @@ export function PlatformEntryFlowShellImpl({
|
||||
],
|
||||
);
|
||||
const recommendRuntimeEntries = useMemo(() => {
|
||||
const entryMap = new Map<string, PlatformPublicGalleryCard>();
|
||||
filterGeneralPublicWorks([
|
||||
...featuredGalleryEntries,
|
||||
...latestGalleryEntries,
|
||||
]).forEach((entry) => {
|
||||
entryMap.set(getPlatformPublicGalleryEntryKey(entry), entry);
|
||||
return buildPlatformRecommendedEntries({
|
||||
featuredEntries: filterGeneralPublicWorks(featuredGalleryEntries),
|
||||
latestEntries: filterGeneralPublicWorks(latestGalleryEntries),
|
||||
});
|
||||
return Array.from(entryMap.values());
|
||||
}, [featuredGalleryEntries, latestGalleryEntries]);
|
||||
|
||||
const creationHubItems = useMemo<CustomWorldWorkSummary[]>(
|
||||
|
||||
178
src/components/platform-entry/platformRecommendation.test.ts
Normal file
178
src/components/platform-entry/platformRecommendation.test.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import type { PlatformPublicGalleryCard } from '../rpg-entry/rpgEntryWorldPresentation';
|
||||
import { buildPlatformRecommendedEntries } from './platformRecommendation';
|
||||
|
||||
const NOW_MS = Date.parse('2026-06-07T12:00:00.000Z');
|
||||
|
||||
type PublicCardTestParams = {
|
||||
id: string;
|
||||
sourceType?: 'puzzle' | 'match3d' | 'jump-hop';
|
||||
subtitle?: string;
|
||||
summaryText?: string;
|
||||
coverImageSrc?: string | null;
|
||||
themeTags?: string[];
|
||||
playCount?: number;
|
||||
remixCount?: number;
|
||||
likeCount?: number;
|
||||
recentPlayCount7d?: number;
|
||||
publishedAt?: string | null;
|
||||
updatedAt?: string;
|
||||
};
|
||||
|
||||
function buildPublicCard(
|
||||
params: PublicCardTestParams,
|
||||
): PlatformPublicGalleryCard {
|
||||
const sourceType = params.sourceType ?? 'puzzle';
|
||||
|
||||
return {
|
||||
sourceType,
|
||||
workId: `${sourceType}-work-${params.id}`,
|
||||
profileId: `${sourceType}-profile-${params.id}`,
|
||||
publicWorkCode: `${sourceType.toUpperCase()}-${params.id}`,
|
||||
ownerUserId: `user-${params.id}`,
|
||||
authorDisplayName: `${params.id} 作者`,
|
||||
worldName: `${params.id} 作品`,
|
||||
subtitle: params.subtitle ?? '公开作品',
|
||||
summaryText: params.summaryText ?? '公开作品摘要。',
|
||||
coverImageSrc: params.coverImageSrc ?? `${params.id}.png`,
|
||||
themeTags: params.themeTags ?? ['推荐'],
|
||||
playCount: params.playCount ?? 0,
|
||||
remixCount: params.remixCount ?? 0,
|
||||
likeCount: params.likeCount ?? 0,
|
||||
recentPlayCount7d: params.recentPlayCount7d ?? 0,
|
||||
visibility: 'published',
|
||||
publishedAt: params.publishedAt ?? '2026-06-01T12:00:00.000Z',
|
||||
updatedAt:
|
||||
params.updatedAt ?? params.publishedAt ?? '2026-06-01T12:00:00.000Z',
|
||||
} satisfies PlatformPublicGalleryCard;
|
||||
}
|
||||
|
||||
describe('buildPlatformRecommendedEntries', () => {
|
||||
test('combines heat, freshness and featured boost after de-duplicating works', () => {
|
||||
const coldEntry = buildPublicCard({
|
||||
id: 'cold',
|
||||
playCount: 1,
|
||||
publishedAt: '2026-04-01T12:00:00.000Z',
|
||||
});
|
||||
const hotRecentEntry = buildPublicCard({
|
||||
id: 'hot',
|
||||
playCount: 8,
|
||||
likeCount: 4,
|
||||
recentPlayCount7d: 16,
|
||||
publishedAt: '2026-06-06T12:00:00.000Z',
|
||||
});
|
||||
const curatedEntry = buildPublicCard({
|
||||
id: 'curated',
|
||||
playCount: 0,
|
||||
likeCount: 0,
|
||||
publishedAt: '2026-05-10T12:00:00.000Z',
|
||||
});
|
||||
|
||||
const entries = buildPlatformRecommendedEntries(
|
||||
{
|
||||
featuredEntries: [curatedEntry],
|
||||
latestEntries: [coldEntry, hotRecentEntry, curatedEntry],
|
||||
},
|
||||
{ nowMs: NOW_MS },
|
||||
);
|
||||
|
||||
expect(entries.map((entry) => entry.profileId)).toEqual([
|
||||
hotRecentEntry.profileId,
|
||||
curatedEntry.profileId,
|
||||
coldEntry.profileId,
|
||||
]);
|
||||
});
|
||||
|
||||
test('interleaves close-score works from different play types', () => {
|
||||
const firstPuzzle = buildPublicCard({
|
||||
id: 'puzzle-a',
|
||||
sourceType: 'puzzle',
|
||||
likeCount: 2,
|
||||
});
|
||||
const secondPuzzle = buildPublicCard({
|
||||
id: 'puzzle-b',
|
||||
sourceType: 'puzzle',
|
||||
likeCount: 2,
|
||||
});
|
||||
const match3d = buildPublicCard({
|
||||
id: 'match3d-a',
|
||||
sourceType: 'match3d',
|
||||
likeCount: 2,
|
||||
});
|
||||
|
||||
const entries = buildPlatformRecommendedEntries(
|
||||
{
|
||||
featuredEntries: [],
|
||||
latestEntries: [firstPuzzle, secondPuzzle, match3d],
|
||||
},
|
||||
{ nowMs: NOW_MS },
|
||||
);
|
||||
|
||||
expect(entries.map((entry) => entry.profileId)).toEqual([
|
||||
firstPuzzle.profileId,
|
||||
match3d.profileId,
|
||||
secondPuzzle.profileId,
|
||||
]);
|
||||
});
|
||||
|
||||
test('separates same-type candidates while alternatives remain', () => {
|
||||
const hotPuzzle = buildPublicCard({
|
||||
id: 'hot-puzzle',
|
||||
sourceType: 'puzzle',
|
||||
recentPlayCount7d: 50,
|
||||
likeCount: 20,
|
||||
});
|
||||
const warmPuzzle = buildPublicCard({
|
||||
id: 'warm-puzzle',
|
||||
sourceType: 'puzzle',
|
||||
recentPlayCount7d: 32,
|
||||
likeCount: 12,
|
||||
});
|
||||
const coldMatch3d = buildPublicCard({
|
||||
id: 'cold-match3d',
|
||||
sourceType: 'match3d',
|
||||
publishedAt: '2026-04-01T12:00:00.000Z',
|
||||
});
|
||||
|
||||
const entries = buildPlatformRecommendedEntries(
|
||||
{
|
||||
featuredEntries: [],
|
||||
latestEntries: [hotPuzzle, warmPuzzle, coldMatch3d],
|
||||
},
|
||||
{ nowMs: NOW_MS },
|
||||
);
|
||||
|
||||
expect(entries.map((entry) => entry.profileId)).toEqual([
|
||||
hotPuzzle.profileId,
|
||||
coldMatch3d.profileId,
|
||||
warmPuzzle.profileId,
|
||||
]);
|
||||
});
|
||||
|
||||
test('falls back to same-type adjacency when no other type remains', () => {
|
||||
const firstPuzzle = buildPublicCard({
|
||||
id: 'only-puzzle-a',
|
||||
sourceType: 'puzzle',
|
||||
recentPlayCount7d: 8,
|
||||
});
|
||||
const secondPuzzle = buildPublicCard({
|
||||
id: 'only-puzzle-b',
|
||||
sourceType: 'puzzle',
|
||||
recentPlayCount7d: 4,
|
||||
});
|
||||
|
||||
const entries = buildPlatformRecommendedEntries(
|
||||
{
|
||||
featuredEntries: [],
|
||||
latestEntries: [firstPuzzle, secondPuzzle],
|
||||
},
|
||||
{ nowMs: NOW_MS },
|
||||
);
|
||||
|
||||
expect(entries.map((entry) => entry.profileId)).toEqual([
|
||||
firstPuzzle.profileId,
|
||||
secondPuzzle.profileId,
|
||||
]);
|
||||
});
|
||||
});
|
||||
231
src/components/platform-entry/platformRecommendation.ts
Normal file
231
src/components/platform-entry/platformRecommendation.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
import {
|
||||
buildPlatformPublicGalleryCardKey,
|
||||
type PlatformPublicGalleryCard,
|
||||
} from '../rpg-entry/rpgEntryWorldPresentation';
|
||||
|
||||
const MS_PER_DAY = 86_400_000;
|
||||
const FEATURED_BONUS = 14;
|
||||
const MAX_FRESHNESS_SCORE = 12;
|
||||
|
||||
export type PlatformRecommendationOptions = {
|
||||
nowMs?: number;
|
||||
limit?: number;
|
||||
};
|
||||
|
||||
type RecommendationCandidate = {
|
||||
entry: PlatformPublicGalleryCard;
|
||||
key: string;
|
||||
sourceType: string;
|
||||
firstSeenIndex: number;
|
||||
isFeatured: boolean;
|
||||
timestampMs: number;
|
||||
score: number;
|
||||
};
|
||||
|
||||
type PlatformRecommendationMetricKey =
|
||||
| 'playCount'
|
||||
| 'remixCount'
|
||||
| 'likeCount'
|
||||
| 'recentPlayCount7d';
|
||||
|
||||
function parseRecommendationTimestamp(value: string | null | undefined) {
|
||||
if (!value) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const normalized = value.trim();
|
||||
const numericTimestamp = normalized.match(/^(-?\d+(?:\.\d+)?)(?:Z)?$/u);
|
||||
if (numericTimestamp?.[1]) {
|
||||
const rawTimestamp = Number(numericTimestamp[1]);
|
||||
if (Number.isFinite(rawTimestamp)) {
|
||||
const absoluteTimestamp = Math.abs(rawTimestamp);
|
||||
if (absoluteTimestamp >= 1_000_000_000_000_000) {
|
||||
return rawTimestamp / 1000;
|
||||
}
|
||||
if (absoluteTimestamp >= 1_000_000_000_000) {
|
||||
return rawTimestamp;
|
||||
}
|
||||
if (absoluteTimestamp >= 1_000_000_000) {
|
||||
return rawTimestamp * 1000;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const timestamp = new Date(normalized).getTime();
|
||||
return Number.isNaN(timestamp) ? 0 : timestamp;
|
||||
}
|
||||
|
||||
function getRecommendationTimestamp(entry: PlatformPublicGalleryCard) {
|
||||
return parseRecommendationTimestamp(entry.publishedAt ?? entry.updatedAt);
|
||||
}
|
||||
|
||||
function getRecommendationMetric(
|
||||
entry: PlatformPublicGalleryCard,
|
||||
key: PlatformRecommendationMetricKey,
|
||||
) {
|
||||
const value = (
|
||||
entry as Partial<Record<PlatformRecommendationMetricKey, number>>
|
||||
)[key];
|
||||
return Math.max(0, Math.round(Number(value ?? 0) || 0));
|
||||
}
|
||||
|
||||
function getRecommendationSourceType(entry: PlatformPublicGalleryCard) {
|
||||
if ('sourceType' in entry) {
|
||||
if (
|
||||
entry.sourceType === 'edutainment' &&
|
||||
'templateId' in entry &&
|
||||
entry.templateId
|
||||
) {
|
||||
return `edutainment:${entry.templateId}`;
|
||||
}
|
||||
|
||||
return entry.sourceType;
|
||||
}
|
||||
|
||||
return 'rpg';
|
||||
}
|
||||
|
||||
function getRecommendationThemeTags(entry: PlatformPublicGalleryCard) {
|
||||
return 'themeTags' in entry && Array.isArray(entry.themeTags)
|
||||
? entry.themeTags
|
||||
: [];
|
||||
}
|
||||
|
||||
function scoreRecommendationCandidate(
|
||||
candidate: Omit<RecommendationCandidate, 'score'>,
|
||||
nowMs: number,
|
||||
) {
|
||||
const entry = candidate.entry;
|
||||
const ageDays =
|
||||
candidate.timestampMs > 0
|
||||
? Math.max(0, (nowMs - candidate.timestampMs) / MS_PER_DAY)
|
||||
: Number.POSITIVE_INFINITY;
|
||||
const freshnessScore = Number.isFinite(ageDays)
|
||||
? MAX_FRESHNESS_SCORE / (1 + ageDays / 7)
|
||||
: 0;
|
||||
const coverScore = entry.coverImageSrc ? 1.5 : 0;
|
||||
const tagScore = Math.min(3, getRecommendationThemeTags(entry).length) * 0.6;
|
||||
const summaryScore = entry.summaryText.trim() ? 0.8 : 0;
|
||||
|
||||
return (
|
||||
(candidate.isFeatured ? FEATURED_BONUS : 0) +
|
||||
Math.log1p(getRecommendationMetric(entry, 'recentPlayCount7d')) * 8 +
|
||||
Math.log1p(getRecommendationMetric(entry, 'likeCount')) * 5 +
|
||||
Math.log1p(getRecommendationMetric(entry, 'remixCount')) * 3 +
|
||||
Math.log1p(getRecommendationMetric(entry, 'playCount')) * 2 +
|
||||
freshnessScore +
|
||||
coverScore +
|
||||
tagScore +
|
||||
summaryScore
|
||||
);
|
||||
}
|
||||
|
||||
function compareRecommendationCandidates(
|
||||
left: RecommendationCandidate,
|
||||
right: RecommendationCandidate,
|
||||
) {
|
||||
const scoreDiff = right.score - left.score;
|
||||
if (scoreDiff !== 0) {
|
||||
return scoreDiff;
|
||||
}
|
||||
|
||||
const timeDiff = right.timestampMs - left.timestampMs;
|
||||
if (timeDiff !== 0) {
|
||||
return timeDiff;
|
||||
}
|
||||
|
||||
if (left.firstSeenIndex !== right.firstSeenIndex) {
|
||||
return left.firstSeenIndex - right.firstSeenIndex;
|
||||
}
|
||||
|
||||
return left.key.localeCompare(right.key, 'zh-CN');
|
||||
}
|
||||
|
||||
function diversifyAdjacentSourceTypes(candidates: RecommendationCandidate[]) {
|
||||
const remaining = [...candidates];
|
||||
const result: RecommendationCandidate[] = [];
|
||||
|
||||
while (remaining.length > 0) {
|
||||
const lastSourceType = result[result.length - 1]?.sourceType ?? null;
|
||||
let nextIndex = 0;
|
||||
|
||||
if (lastSourceType) {
|
||||
const alternativeIndex = remaining.findIndex(
|
||||
(candidate) => candidate.sourceType !== lastSourceType,
|
||||
);
|
||||
if (alternativeIndex > 0) {
|
||||
nextIndex = alternativeIndex;
|
||||
}
|
||||
}
|
||||
|
||||
const [nextCandidate] = remaining.splice(nextIndex, 1);
|
||||
if (nextCandidate) {
|
||||
result.push(nextCandidate);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function buildPlatformRecommendedEntries(
|
||||
params: {
|
||||
featuredEntries: PlatformPublicGalleryCard[];
|
||||
latestEntries: PlatformPublicGalleryCard[];
|
||||
},
|
||||
options: PlatformRecommendationOptions = {},
|
||||
) {
|
||||
const candidateMap = new Map<
|
||||
string,
|
||||
Omit<RecommendationCandidate, 'score'>
|
||||
>();
|
||||
let firstSeenIndex = 0;
|
||||
|
||||
const collectEntries = (
|
||||
entries: PlatformPublicGalleryCard[],
|
||||
source: 'featured' | 'latest',
|
||||
) => {
|
||||
entries.forEach((entry) => {
|
||||
const key = buildPlatformPublicGalleryCardKey(entry);
|
||||
const timestampMs = getRecommendationTimestamp(entry);
|
||||
const existing = candidateMap.get(key);
|
||||
if (existing) {
|
||||
existing.isFeatured = existing.isFeatured || source === 'featured';
|
||||
if (timestampMs >= existing.timestampMs) {
|
||||
existing.entry = entry;
|
||||
existing.timestampMs = timestampMs;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
candidateMap.set(key, {
|
||||
entry,
|
||||
key,
|
||||
sourceType: getRecommendationSourceType(entry),
|
||||
firstSeenIndex,
|
||||
isFeatured: source === 'featured',
|
||||
timestampMs,
|
||||
});
|
||||
firstSeenIndex += 1;
|
||||
});
|
||||
};
|
||||
|
||||
collectEntries(params.featuredEntries, 'featured');
|
||||
collectEntries(params.latestEntries, 'latest');
|
||||
|
||||
const nowMs = options.nowMs ?? Date.now();
|
||||
const rankedCandidates = Array.from(candidateMap.values())
|
||||
.map((candidate) => ({
|
||||
...candidate,
|
||||
score: scoreRecommendationCandidate(candidate, nowMs),
|
||||
}))
|
||||
.sort(compareRecommendationCandidates);
|
||||
const diversifiedCandidates = diversifyAdjacentSourceTypes(rankedCandidates);
|
||||
const limit =
|
||||
typeof options.limit === 'number' && options.limit > 0
|
||||
? Math.floor(options.limit)
|
||||
: diversifiedCandidates.length;
|
||||
|
||||
return diversifiedCandidates
|
||||
.slice(0, limit)
|
||||
.map((candidate) => candidate.entry);
|
||||
}
|
||||
@@ -11928,7 +11928,6 @@ test('creation hub gives jump hop wooden fish and bark battle cards the shared d
|
||||
profileId: 'jump-hop-profile-delete',
|
||||
ownerUserId: 'user-1',
|
||||
sourceSessionId: 'jump-hop-session-delete',
|
||||
themeText: '跳台删除草稿',
|
||||
workTitle: '跳台删除草稿',
|
||||
workDescription: '跳一跳草稿也应接入统一删除。',
|
||||
themeText: '跳台',
|
||||
|
||||
@@ -78,7 +78,6 @@ import type {
|
||||
WechatNativePayment,
|
||||
} from '../../../packages/shared/src/contracts/runtime';
|
||||
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
|
||||
import { buildPublicWorkDetailUrl } from '../../routing/appPageRoutes';
|
||||
import { refreshStoredAccessToken } from '../../services/apiClient';
|
||||
import type { AuthUser } from '../../services/authService';
|
||||
import {
|
||||
@@ -133,6 +132,7 @@ import {
|
||||
isEdutainmentEntryEnabled,
|
||||
} from '../platform-entry/platformEdutainmentVisibility';
|
||||
import { getInitialPlatformDesktopLayout } from '../platform-entry/platformEntryResponsive';
|
||||
import { buildPlatformRecommendedEntries } from '../platform-entry/platformRecommendation';
|
||||
import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
||||
import { RpgEntryBrandLogo } from './RpgEntryBrandLogo';
|
||||
import {
|
||||
@@ -154,7 +154,6 @@ import {
|
||||
isWoodenFishGalleryEntry,
|
||||
type PlatformPublicGalleryCard,
|
||||
type PlatformWorldCardLike,
|
||||
resolvePlatformPublicWorkCode,
|
||||
resolvePlatformWorkAuthorDisplayName,
|
||||
resolvePlatformWorldCoverImage,
|
||||
resolvePlatformWorldCoverSlides,
|
||||
@@ -5379,15 +5378,16 @@ export function RpgEntryHomeView({
|
||||
const desktopHeroStripEntries = (
|
||||
featuredShelf.length > 0 ? featuredShelf : generalLatestEntries
|
||||
).slice(0, 5);
|
||||
const recommendedFeedEntries = useMemo(
|
||||
() =>
|
||||
buildPlatformRecommendedEntries({
|
||||
featuredEntries: featuredShelf,
|
||||
latestEntries: generalLatestEntries,
|
||||
}),
|
||||
[featuredShelf, generalLatestEntries],
|
||||
);
|
||||
// 网页端保留原有宽屏布局,只把模块数据同步到移动端首页频道语义。
|
||||
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 desktopRecommendEntries = recommendedFeedEntries;
|
||||
const desktopTodayEntries = useMemo(
|
||||
() => filterTodayPublishedEntries(generalLatestEntries),
|
||||
[generalLatestEntries],
|
||||
@@ -5395,14 +5395,6 @@ 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 =
|
||||
|
||||
Reference in New Issue
Block a user