From 0b71b79e7acf0ae63213fbd06815cae95e0cbd60 Mon Sep 17 00:00:00 2001 From: kdletters Date: Wed, 3 Jun 2026 19:05:00 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E6=94=B6=E5=8F=A3=E5=85=AC?= =?UTF-8?q?=E5=BC=80=E4=BD=9C=E8=80=85=E5=B1=95=E7=A4=BA=E6=A8=A1=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .hermes/shared-memory/decision-log.md | 4 +- docs/README.md | 2 +- ...】PublicWorkPresentation收口计划-2026-06-03.md | 7 +- src/components/rpg-entry/RpgEntryHomeView.tsx | 82 ++++++++----------- .../rpgEntryWorldPresentation.test.ts | 54 ++++++++++++ .../rpg-entry/rpgEntryWorldPresentation.ts | 36 ++++++++ 6 files changed, 133 insertions(+), 52 deletions(-) diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index 74ff2d23..32626c48 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -1268,8 +1268,8 @@ ## 2026-06-03 Public Work Presentation 收口 - 背景:作品卡、推荐 runtime meta、排行项、分类项、搜索结果和桌面 hero 共用玩法类型 label 与紧凑计数格式,但规则仍在 `RpgEntryHomeView.tsx` 页面 Implementation 内。 -- 决策:在 `src/components/rpg-entry/rpgEntryWorldPresentation.ts` 追加单作品展示 Interface:`describePlatformPublicWorkKind` 与 `formatPlatformCompactCount`;页面删除本地实现。集合筛选、排序和指标选择继续留在 `rpgEntryPublicGalleryViewModel.ts`。 -- 影响范围:公开作品卡片 aria label、推荐点赞 / 改造文案、排行数值、分类主指标、搜索结果和桌面 hero 玩法 label。 +- 决策:在 `src/components/rpg-entry/rpgEntryWorldPresentation.ts` 追加单作品展示 Interface:`describePlatformPublicWorkKind`、`formatPlatformCompactCount`、`resolvePlatformPublicWorkAuthorLookup` 与 `formatPlatformPublicAuthorAvatarLabel`;页面删除本地玩法类型、紧凑计数、公开作者 lookup 和头像首字实现。集合筛选、排序和指标选择继续留在 `rpgEntryPublicGalleryViewModel.ts`。 +- 影响范围:公开作品卡片 aria label、推荐点赞 / 改造文案、排行数值、分类主指标、搜索结果、桌面 hero 玩法 label、公开作者摘要缓存 key 与无头像首字兜底。 - 验证方式:`npm run test -- src/components/rpg-entry/rpgEntryWorldPresentation.test.ts`、`npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "recommend|ranking|category"`、针对变更文件执行 ESLint、`npm run typecheck`、`npm run check:encoding`。 - 关联文档:`docs/technical/【前端架构】PublicWorkPresentation收口计划-2026-06-03.md`。 diff --git a/docs/README.md b/docs/README.md index aceaeae2..8f3b7c0e 100644 --- a/docs/README.md +++ b/docs/README.md @@ -47,7 +47,7 @@ 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)。 -公开作品的玩法类型 label 与游玩 / 改造 / 点赞等紧凑计数格式收口到 `src/components/rpg-entry/rpgEntryWorldPresentation.ts`,规则见 [【前端架构】PublicWorkPresentation收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91PublicWorkPresentation%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。 +公开作品的玩法类型 label、公开作者 lookup 与游玩 / 改造 / 点赞等紧凑计数格式收口到 `src/components/rpg-entry/rpgEntryWorldPresentation.ts`,规则见 [【前端架构】PublicWorkPresentation收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91PublicWorkPresentation%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)。 diff --git a/docs/technical/【前端架构】PublicWorkPresentation收口计划-2026-06-03.md b/docs/technical/【前端架构】PublicWorkPresentation收口计划-2026-06-03.md index 4feea2e9..9d1d1d78 100644 --- a/docs/technical/【前端架构】PublicWorkPresentation收口计划-2026-06-03.md +++ b/docs/technical/【前端架构】PublicWorkPresentation收口计划-2026-06-03.md @@ -2,7 +2,7 @@ ## 背景 -`RpgEntryHomeView.tsx` 的作品卡、推荐 runtime meta、排行项、分类项、搜索结果和桌面 hero 共用公开作品玩法类型 label 与紧凑计数格式。原先 `describePublicGalleryCardKind` 与 `formatCompactCount` 放在页面 **Implementation** 内,导致新增玩法或调整数字展示时需要穿过多段 JSX。 +`RpgEntryHomeView.tsx` 的作品卡、推荐 runtime meta、排行项、分类项、搜索结果和桌面 hero 共用公开作品玩法类型 label 与紧凑计数格式。原先 `describePublicGalleryCardKind` 与 `formatCompactCount` 放在页面 **Implementation** 内,导致新增玩法或调整数字展示时需要穿过多段 JSX。公开作者 lookup key 与头像首字也曾由页面手写,页面既要知道公开作品作者来源优先级,又要知道 `code:` / `id:` 前缀约定。 ## 决策 @@ -10,13 +10,16 @@ - `describePlatformPublicWorkKind(entry)`:统一公开作品玩法类型 label,并继续复用 `formatPlatformWorkDisplayTag` 的 4 字截断口径。 - `formatPlatformCompactCount(value)`:统一游玩、改造、点赞、排行和分类指标的紧凑数字展示。 +- `resolvePlatformPublicWorkAuthorLookup(entry)`:统一公开作者查询 lookup,优先使用 `authorPublicUserCode`,否则回退 `ownerUserId`,并用结构化 `{ key, source, value }` 避免页面复写前缀规则。 +- `formatPlatformPublicAuthorAvatarLabel(authorDisplayName)`:统一公开作者头像无图时的首字兜底。 -`RpgEntryHomeView.tsx` 删除本地类型 label 与紧凑计数 **Implementation**,仅消费 `rpgEntryWorldPresentation.ts`。集合筛选、排序和指标选择仍留在 `rpgEntryPublicGalleryViewModel.ts`,避免单作品展示 **Module** 与集合 **Module** 混杂。 +`RpgEntryHomeView.tsx` 删除本地类型 label、紧凑计数、公开作者 lookup 与头像首字 **Implementation**,仅消费 `rpgEntryWorldPresentation.ts`。认证请求、缓存和失败兜底仍留页面侧 Adapter;集合筛选、排序和指标选择仍留在 `rpgEntryPublicGalleryViewModel.ts`,避免单作品展示 **Module** 与集合 **Module** 混杂。 ## 约定 - 紧凑计数保留既有口径:`10000` 显示 `1.0万`,`100000000` 显示 `1.0亿`,一万以下不加千分位。 - 玩法类型 label 继续遵循 4 字展示限制,例如“大鱼吃小鱼”外显为“大鱼吃小”。 +- 公开作者 lookup 的 `key` 只用于缓存索引;真正调用公开用户 Adapter 时以 `source` 和 `value` 分发,页面不得解析 `code:` / `id:` 前缀。 - 本次不迁移排行 metric label / value 配对;该规则属于集合排序 **Module** 的后续切片。 ## 验证 diff --git a/src/components/rpg-entry/RpgEntryHomeView.tsx b/src/components/rpg-entry/RpgEntryHomeView.tsx index cc5b4509..0ce8e13f 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.tsx @@ -194,6 +194,7 @@ import { describePlatformPublicWorkKind, describePlatformThemeLabel, formatPlatformCompactCount, + formatPlatformPublicAuthorAvatarLabel, formatPlatformWorkDisplayName, formatPlatformWorkDisplayTag, formatPlatformWorldTime, @@ -203,6 +204,8 @@ import { isPuzzleGalleryEntry, isVisualNovelGalleryEntry, type PlatformPublicGalleryCard, + type PlatformPublicWorkAuthorLookup, + resolvePlatformPublicWorkAuthorLookup, resolvePlatformPublicWorkCode, resolvePlatformWorkAuthorDisplayName, resolvePlatformWorldCoverImage, @@ -597,7 +600,7 @@ function WorldCard({ entry, authorSummary, ); - const authorAvatarLabel = getPublicAuthorAvatarLabel(authorName); + const authorAvatarLabel = formatPlatformPublicAuthorAvatarLabel(authorName); const normalizedAuthorAvatarUrl = authorAvatarUrl?.trim() ?? ''; const cardLabel = `${entry.worldName},${typeLabel},${formatPlatformCompactCount(playCount)}游玩,${formatPlatformCompactCount(remixCount)}改造,${formatPlatformCompactCount(likeCount)}点赞`; const coverStats = [ @@ -966,7 +969,7 @@ function RecommendRuntimeMeta({ entry, authorSummary, ); - const authorAvatarLabel = getPublicAuthorAvatarLabel(authorName); + const authorAvatarLabel = formatPlatformPublicAuthorAvatarLabel(authorName); const normalizedAuthorAvatarUrl = authorAvatarUrl?.trim() ?? ''; const displayName = formatPlatformWorkDisplayName(entry.worldName); const stopActionPointer = (event: PointerEvent) => { @@ -1637,36 +1640,20 @@ function PlatformWorkSearchResults({ ); } -function buildPublicWorkAuthorLookupKey(entry: PlatformPublicGalleryCard) { - if ('authorPublicUserCode' in entry) { - const authorPublicUserCode = entry.authorPublicUserCode?.trim(); - if (authorPublicUserCode) { - return `code:${authorPublicUserCode}`; - } - } - - const ownerUserId = entry.ownerUserId.trim(); - return ownerUserId ? `id:${ownerUserId}` : null; -} - async function getPublicWorkAuthorSummary( - authorLookupKey: string, + authorLookup: PlatformPublicWorkAuthorLookup, ): Promise { - if (authorLookupKey.startsWith('code:')) { - return getPublicAuthUserByCode(authorLookupKey.slice('code:'.length)); + if (authorLookup.source === 'publicUserCode') { + return getPublicAuthUserByCode(authorLookup.value); } - if (authorLookupKey.startsWith('id:')) { - return getPublicAuthUserById(authorLookupKey.slice('id:'.length)); + if (authorLookup.source === 'ownerUserId') { + return getPublicAuthUserById(authorLookup.value); } return null; } -function getPublicAuthorAvatarLabel(authorDisplayName: string) { - return Array.from(authorDisplayName.trim() || '玩')[0] ?? '玩'; -} - function formatSnapshotTime(value: string | null | undefined) { if (!value) { return '刚刚保存'; @@ -3619,25 +3606,25 @@ export function RpgEntryHomeView({ ); const getPublicEntryAuthorAvatarUrl = useCallback( (entry: PlatformPublicGalleryCard) => { - const authorLookupKey = buildPublicWorkAuthorLookupKey(entry); - if (!authorLookupKey) { + const authorLookup = resolvePlatformPublicWorkAuthorLookup(entry); + if (!authorLookup) { return null; } return ( - publicAuthorSummariesByKey[authorLookupKey]?.avatarUrl?.trim() || null + publicAuthorSummariesByKey[authorLookup.key]?.avatarUrl?.trim() || null ); }, [publicAuthorSummariesByKey], ); const getPublicEntryAuthorSummary = useCallback( (entry: PlatformPublicGalleryCard) => { - const authorLookupKey = buildPublicWorkAuthorLookupKey(entry); - if (!authorLookupKey) { + const authorLookup = resolvePlatformPublicWorkAuthorLookup(entry); + if (!authorLookup) { return null; } - return publicAuthorSummariesByKey[authorLookupKey] ?? null; + return publicAuthorSummariesByKey[authorLookup.key] ?? null; }, [publicAuthorSummariesByKey], ); @@ -3775,37 +3762,38 @@ export function RpgEntryHomeView({ }, [categoryGroups, selectedCategoryTag]); useEffect(() => { - const missingAuthorKeys = [ - ...new Set( - publicEntries - .map(buildPublicWorkAuthorLookupKey) - .filter((key): key is string => Boolean(key)), - ), - ].filter( - (key) => - !(key in publicAuthorSummariesByKey) && - !pendingPublicAuthorKeysRef.current.has(key), + const authorLookupsByKey = new Map(); + publicEntries.forEach((entry) => { + const authorLookup = resolvePlatformPublicWorkAuthorLookup(entry); + if (authorLookup) { + authorLookupsByKey.set(authorLookup.key, authorLookup); + } + }); + const missingAuthorLookups = Array.from(authorLookupsByKey.values()).filter( + (authorLookup) => + !(authorLookup.key in publicAuthorSummariesByKey) && + !pendingPublicAuthorKeysRef.current.has(authorLookup.key), ); - if (missingAuthorKeys.length === 0) { + if (missingAuthorLookups.length === 0) { return undefined; } let cancelled = false; - missingAuthorKeys.forEach((key) => { - pendingPublicAuthorKeysRef.current.add(key); + missingAuthorLookups.forEach((authorLookup) => { + pendingPublicAuthorKeysRef.current.add(authorLookup.key); }); // 中文注释:头像来自公开用户摘要,失败时缓存空值,避免首页滚动时反复打公开用户接口。 void Promise.all( - missingAuthorKeys.map(async (authorLookupKey) => { + missingAuthorLookups.map(async (authorLookup) => { try { - const author = await getPublicWorkAuthorSummary(authorLookupKey); - return [authorLookupKey, author] as const; + const author = await getPublicWorkAuthorSummary(authorLookup); + return [authorLookup.key, author] as const; } catch { - return [authorLookupKey, null] as const; + return [authorLookup.key, null] as const; } finally { - pendingPublicAuthorKeysRef.current.delete(authorLookupKey); + pendingPublicAuthorKeysRef.current.delete(authorLookup.key); } }), ).then((results) => { diff --git a/src/components/rpg-entry/rpgEntryWorldPresentation.test.ts b/src/components/rpg-entry/rpgEntryWorldPresentation.test.ts index ba1361b1..980f8a29 100644 --- a/src/components/rpg-entry/rpgEntryWorldPresentation.test.ts +++ b/src/components/rpg-entry/rpgEntryWorldPresentation.test.ts @@ -7,6 +7,7 @@ import { EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_ID, EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_NAME, formatPlatformCompactCount, + formatPlatformPublicAuthorAvatarLabel, formatPlatformWorkDisplayName, formatPlatformWorkDisplayTags, formatPlatformWorldTime, @@ -18,9 +19,11 @@ import { mapBarkBattleWorkToPlatformGalleryCard, mapVisualNovelWorkToPlatformGalleryCard, mapWoodenFishWorkToPlatformGalleryCard, + type PlatformBarkBattleGalleryCard, type PlatformBigFishGalleryCard, type PlatformEdutainmentGalleryCard, type PlatformPuzzleGalleryCard, + resolvePlatformPublicWorkAuthorLookup, resolvePlatformPublicWorkCode, resolvePlatformWorkAuthorDisplayName, resolvePlatformWorldFallbackCoverImage, @@ -312,6 +315,57 @@ test('public work author display hides phone masks and public user codes on card expect(resolvePlatformWorkAuthorDisplayName(card, null)).toBe('玩家'); }); +test('public work author lookup keeps public user code priority and avatar labels', () => { + const barkBattleCard: PlatformBarkBattleGalleryCard = { + sourceType: 'bark-battle', + workId: 'bark-battle-work-author', + profileId: 'bark-battle-profile-author', + sourceSessionId: null, + publicWorkCode: 'BB-AUTHOR', + ownerUserId: 'user-author-id', + authorPublicUserCode: ' SY-00012345 ', + authorDisplayName: '声浪玩家', + worldName: '声浪擂台', + subtitle: '汪汪声浪', + summaryText: '公开作品', + coverImageSrc: null, + coverRenderMode: 'image', + coverCharacterImageSrcs: [], + themeTags: ['声浪'], + themeMode: 'martial', + playableNpcCount: 0, + landmarkCount: 0, + visibility: 'published', + publishedAt: '2026-05-22T00:00:00.000Z', + updatedAt: '2026-05-22T00:00:00.000Z', + }; + + expect(resolvePlatformPublicWorkAuthorLookup(barkBattleCard)).toEqual({ + key: 'code:SY-00012345', + source: 'publicUserCode', + value: 'SY-00012345', + }); + expect( + resolvePlatformPublicWorkAuthorLookup({ + ...barkBattleCard, + authorPublicUserCode: ' ', + }), + ).toEqual({ + key: 'id:user-author-id', + source: 'ownerUserId', + value: 'user-author-id', + }); + expect( + resolvePlatformPublicWorkAuthorLookup({ + ...barkBattleCard, + authorPublicUserCode: null, + ownerUserId: ' ', + }), + ).toBeNull(); + expect(formatPlatformPublicAuthorAvatarLabel(' 声浪玩家')).toBe('声'); + expect(formatPlatformPublicAuthorAvatarLabel('')).toBe('玩'); +}); + test('keeps baby object match public card code and template label intact', () => { const card: PlatformEdutainmentGalleryCard = { sourceType: 'edutainment', diff --git a/src/components/rpg-entry/rpgEntryWorldPresentation.ts b/src/components/rpg-entry/rpgEntryWorldPresentation.ts index 245b955c..63a3f670 100644 --- a/src/components/rpg-entry/rpgEntryWorldPresentation.ts +++ b/src/components/rpg-entry/rpgEntryWorldPresentation.ts @@ -300,6 +300,12 @@ export type PlatformPublicGalleryCard = | PlatformBarkBattleGalleryCard | PlatformEdutainmentGalleryCard; +export type PlatformPublicWorkAuthorLookup = { + key: string; + source: 'publicUserCode' | 'ownerUserId'; + value: string; +}; + export function isLibraryWorldEntry( entry: PlatformWorldCardLike, ): entry is CustomWorldLibraryEntry { @@ -923,6 +929,36 @@ export function resolvePlatformWorkAuthorDisplayName( return displayName || entryAuthorName || '玩家'; } +export function resolvePlatformPublicWorkAuthorLookup( + entry: PlatformPublicGalleryCard, +): PlatformPublicWorkAuthorLookup | null { + if ('authorPublicUserCode' in entry) { + const authorPublicUserCode = entry.authorPublicUserCode?.trim(); + if (authorPublicUserCode) { + return { + key: `code:${authorPublicUserCode}`, + source: 'publicUserCode', + value: authorPublicUserCode, + }; + } + } + + const ownerUserId = entry.ownerUserId.trim(); + return ownerUserId + ? { + key: `id:${ownerUserId}`, + source: 'ownerUserId', + value: ownerUserId, + } + : null; +} + +export function formatPlatformPublicAuthorAvatarLabel( + authorDisplayName: string, +) { + return Array.from(authorDisplayName.trim() || '玩')[0] ?? '玩'; +} + function normalizePlatformPublicAuthorName(value: string | null | undefined) { const normalized = value?.trim() ?? ''; if (!normalized || normalized === 'null' || normalized === 'undefined') {