diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index 6ed5ece1..fb0bfb2f 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -1233,6 +1233,14 @@ - 验证方式:工作台首屏不再出现标题 / 简介 / 标签输入;结果页修改后点试玩或发布会先写回当前作品信息。 - 关联文档:`docs/prd/【玩法创作】敲木鱼玩法模板PRD-2026-05-20.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 +## 2026-06-03 Profile Dashboard Presentation 收口 + +- 背景:`RpgEntryHomeView.tsx` 同时承载个人数据卡、钱包 chip 与“玩过”弹窗,计数压缩、累计时长、单作品时长、玩法标签和作品号兜底散在页面 Implementation 内,修改展示口径时缺少稳定测试面。 +- 决策:新增 `src/components/rpg-entry/rpgEntryProfileDashboardPresentation.ts` 作为个人数据展示 Module,Interface 收口为 `buildProfileDashboardPresentation`、计数 / 时长格式化和“玩过”列表标签 / 作品号格式化函数;页面只消费结果并保留 UI 编排与点击处理。 +- 影响范围:RPG 首页“我的数据”卡片、移动端 / 桌面端钱包 chip、个人数据弹窗与“玩过”列表。 +- 验证方式:`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`。 + ## 2026-05-26 前端不外露图片模型名 - 背景:拼图与相关结果页、生成进度和错误提示里直接显示 `gpt-image-2`、`gemini-3.1-flash-image-preview`、`image-2` 等名称,会把内部模型路由暴露给普通用户。 diff --git a/docs/README.md b/docs/README.md index 25f41526..8f2a22f8 100644 --- a/docs/README.md +++ b/docs/README.md @@ -49,6 +49,8 @@ AI 文字游戏模板接入以 [AI_NATIVE_TEXT_GAME_TEMPLATE_MOKU_REFERENCE_PRD_ 每日任务卡片与任务中心弹窗的任务选择、进度、状态标签和按钮文案收口到 `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)。 + ## 推荐阅读顺序 1. 先看 [经验沉淀](./experience/README.md),快速建立这个项目的开发共识。 diff --git a/docs/technical/【前端架构】ProfileDashboardPresentation收口计划-2026-06-03.md b/docs/technical/【前端架构】ProfileDashboardPresentation收口计划-2026-06-03.md new file mode 100644 index 00000000..9f41bbba --- /dev/null +++ b/docs/technical/【前端架构】ProfileDashboardPresentation收口计划-2026-06-03.md @@ -0,0 +1,30 @@ +# 【前端架构】Profile Dashboard Presentation 收口计划 + +## 背景 + +`RpgEntryHomeView.tsx` 的“我的数据”、钱包 chip 和“玩过”弹窗共用一批展示规则:泥点数量压缩、累计时长固定小时展示、单作品游玩时长压缩、作品类型标签和作品 ID 兜底。原先这些规则散在页面 **Implementation** 内,导致格式口径只能靠 UI 集成测试间接保护。 + +## 决策 + +新增 `src/components/rpg-entry/rpgEntryProfileDashboardPresentation.ts`,作为个人数据展示 **Module**。该 **Module** 的 **Interface** 收口为: + +- `buildProfileDashboardPresentation(dashboard)`:统一生成钱包余额、钱包文案、累计时长文案和已玩数量文案。 +- `formatDashboardCount(value)`:统一泥点和计数压缩规则。 +- `formatTotalPlayTimeHours(playTimeMs)`:统一“累计游戏时长”固定小时口径。 +- `formatCompactPlayTime(playTimeMs)`:统一“玩过”单作品紧凑时长。 +- `formatPlayedWorkType(value)` 与 `formatPlayedWorkId(work)`:统一“玩过”列表里的玩法标签和作品号兜底。 + +`RpgEntryHomeView.tsx` 只消费这些 presentation 函数,保留卡片、弹窗和点击处理。个人数据展示规则的 **Locality** 转移到该 **Module** 与纯测试,后续修改计数、时长或作品类型标签不再穿透页面 JSX。 + +## 约定 + +- `formatDashboardCount` 与公开作品卡片的 `formatCompactCount` 不合并,二者展示口径不同。 +- “累计游戏时长”固定以小时展示,避免个人数据卡在分钟 / 天之间跳动。 +- “玩过”列表当前仍按历史契约用 `profileId || worldKey` 展示作品号;若后端未来下发 `publicWorkCode`,应在此 **Module** 改口径。 + +## 验证 + +- `npm run test -- src/components/rpg-entry/rpgEntryProfileDashboardPresentation.test.ts` +- `npm run typecheck` +- `npm run check:encoding` +- 针对变更文件执行 ESLint diff --git a/src/components/rpg-entry/RpgEntryHomeView.tsx b/src/components/rpg-entry/RpgEntryHomeView.tsx index 812c595a..35618a4b 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.tsx @@ -134,6 +134,13 @@ import { import { getInitialPlatformDesktopLayout } from '../platform-entry/platformEntryResponsive'; import { ResolvedAssetImage } from '../ResolvedAssetImage'; import { RpgEntryBrandLogo } from './RpgEntryBrandLogo'; +import { + buildProfileDashboardPresentation, + formatCompactPlayTime, + formatPlayedWorkId, + formatPlayedWorkType, + formatTotalPlayTimeHours, +} from './rpgEntryProfileDashboardPresentation'; import { buildProfileTaskCardSummary, buildProfileTaskProgressLabel, @@ -1758,46 +1765,6 @@ function formatSnapshotTime(value: string | null | undefined) { }); } -function formatCompactPlayTime(playTimeMs: number) { - const totalMinutes = Math.max(0, Math.floor(playTimeMs / 60000)); - const days = totalMinutes / 1440; - - if (days >= 10) { - return `${Math.floor(days)}天`; - } - if (days >= 1) { - return `${days.toFixed(days >= 3 ? 0 : 1)}天`; - } - - const hours = totalMinutes / 60; - if (hours >= 1) { - return `${hours.toFixed(hours >= 10 ? 0 : 1)}小时`; - } - - return `${Math.max(0, totalMinutes)}分`; -} - -// “游戏时长”固定使用小时,避免短时长切到分钟或长时长切到天。 -function formatTotalPlayTimeHours(playTimeMs: number) { - const roundedHours = Math.max(0, Math.round(playTimeMs / 360000) / 10); - - return `${roundedHours.toLocaleString('zh-CN', { - maximumFractionDigits: 1, - })}小时`; -} - -function formatDashboardCount(value: number) { - const normalizedValue = Math.max(0, Math.round(value)); - if (normalizedValue >= 100000000) { - return `${(normalizedValue / 100000000).toFixed(1)}亿`; - } - if (normalizedValue >= 10000) { - return `${(normalizedValue / 10000).toFixed(1)}万`; - } - - return normalizedValue.toLocaleString('zh-CN'); -} - function normalizeProfileInviteQueryCode(value: string | null | undefined) { return (value ?? '') .trim() @@ -1816,27 +1783,6 @@ function readProfileInviteCodeFromLocationSearch(search: string) { return ''; } -function formatPlayedWorkType(value: string | null | undefined) { - const normalizedValue = (value ?? '').toLowerCase(); - if (normalizedValue === 'puzzle') { - return '拼图'; - } - if (normalizedValue === 'match3d' || normalizedValue === 'match_3d') { - return '抓鹅'; - } - if (normalizedValue === 'square-hole' || normalizedValue === 'square_hole') { - return '方洞'; - } - if (normalizedValue === 'big_fish' || normalizedValue === 'big-fish') { - return '大鱼'; - } - return 'RPG'; -} - -function formatPlayedWorkId(work: ProfilePlayedWorkSummary) { - return work.profileId?.trim() || work.worldKey; -} - function buildPublicUserCode(user: AuthUser | null | undefined) { if (user?.publicUserCode?.trim()) { return user.publicUserCode.trim(); @@ -3839,11 +3785,11 @@ export function RpgEntryHomeView({ const activeLegalDocument = activeLegalDocumentId ? getLegalDocument(activeLegalDocumentId) : null; - const remainingNarrativeCoins = profileDashboard?.walletBalance ?? 0; - const totalPlayTime = formatTotalPlayTimeHours( - profileDashboard?.totalPlayTimeMs ?? 0, + const profileDashboardPresentation = useMemo( + () => buildProfileDashboardPresentation(profileDashboard), + [profileDashboard], ); - const playedWorkCount = profileDashboard?.playedWorldCount ?? 0; + const remainingNarrativeCoins = profileDashboardPresentation.walletBalance; const profileTaskCardSummary = useMemo( () => buildProfileTaskCardSummary(taskCenter), [taskCenter], @@ -5960,7 +5906,7 @@ export function RpgEntryHomeView({ - {formatDashboardCount(remainingNarrativeCoins)}泥点 + + {profileDashboardPresentation.walletBalanceWithUnitLabel} + ) : !isAuthenticated ? ( ) : null}