refactor: 收口个人数据展示模型

This commit is contained in:
2026-06-03 17:30:28 +08:00
parent 4f59a0e791
commit a178942033
6 changed files with 250 additions and 72 deletions

View File

@@ -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` 作为个人数据展示 ModuleInterface 收口为 `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` 等名称,会把内部模型路由暴露给普通用户。

View File

@@ -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),快速建立这个项目的开发共识。

View File

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

View File

@@ -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({
<ProfileStatCard
cardKey="wallet"
label="泥点余额"
value={formatDashboardCount(remainingNarrativeCoins)}
value={profileDashboardPresentation.walletBalanceLabel}
icon={Coins}
imageSrc={profilePointImage}
onClick={openWalletLedgerPanel}
@@ -5968,7 +5914,7 @@ export function RpgEntryHomeView({
<ProfileStatCard
cardKey="playTime"
label="累计游戏时长"
value={totalPlayTime}
value={profileDashboardPresentation.totalPlayTimeLabel}
icon={Clock3}
imageSrc={profileClockImage}
onClick={onOpenProfileDashboardCard}
@@ -5976,7 +5922,7 @@ export function RpgEntryHomeView({
<ProfileStatCard
cardKey="playedWorks"
label="已玩游戏数量"
value={`${formatDashboardCount(playedWorkCount)}`}
value={profileDashboardPresentation.playedWorkCountLabel}
icon={BookOpen}
imageSrc={profileGamepadImage}
onClick={onOpenProfileDashboardCard}
@@ -6612,12 +6558,16 @@ export function RpgEntryHomeView({
type="button"
onClick={openUserSurface}
className="platform-mobile-create-wallet-chip inline-flex shrink-0 items-center gap-1.5 rounded-full border border-[#f0cfae] bg-[#fff5eb] px-2.5 py-1.5 text-xs font-black text-[#b65f2c] shadow-[0_10px_22px_rgba(174,111,73,0.12)]"
aria-label={`${formatDashboardCount(remainingNarrativeCoins)}泥点`}
aria-label={
profileDashboardPresentation.walletBalanceWithUnitLabel
}
>
<span className="grid h-6 w-6 place-items-center rounded-full bg-[#ffe0ab] text-[#cf7b34]">
<Coins className="h-3.5 w-3.5" />
</span>
<span>{formatDashboardCount(remainingNarrativeCoins)}</span>
<span>
{profileDashboardPresentation.walletBalanceWithUnitLabel}
</span>
</button>
) : !isAuthenticated ? (
<button
@@ -6778,12 +6728,16 @@ export function RpgEntryHomeView({
type="button"
onClick={openUserSurface}
className="platform-desktop-create-wallet-chip platform-desktop-search inline-flex items-center gap-2 px-3 py-2.5 text-xs font-black text-[#b65f2c]"
aria-label={`${formatDashboardCount(remainingNarrativeCoins)}泥点`}
aria-label={
profileDashboardPresentation.walletBalanceWithUnitLabel
}
>
<span className="grid h-7 w-7 place-items-center rounded-full bg-[#ffe0ab] text-[#cf7b34]">
<Coins className="h-3.5 w-3.5" />
</span>
<span>{formatDashboardCount(remainingNarrativeCoins)}</span>
<span>
{profileDashboardPresentation.walletBalanceWithUnitLabel}
</span>
</button>
) : null}
<button

View File

@@ -0,0 +1,89 @@
import { expect, test } from 'vitest';
import type {
ProfileDashboardSummary,
ProfilePlayedWorkSummary,
} from '../../../packages/shared/src/contracts/runtime';
import {
buildProfileDashboardPresentation,
formatCompactPlayTime,
formatDashboardCount,
formatPlayedWorkId,
formatPlayedWorkType,
formatTotalPlayTimeHours,
} from './rpgEntryProfileDashboardPresentation';
function buildDashboard(
overrides: Partial<ProfileDashboardSummary> = {},
): ProfileDashboardSummary {
return {
walletBalance: 12345,
totalPlayTimeMs: 3_780_000,
playedWorldCount: 7,
updatedAt: '2026-06-03T00:00:00.000Z',
...overrides,
};
}
function buildPlayedWork(
overrides: Partial<ProfilePlayedWorkSummary> = {},
): ProfilePlayedWorkSummary {
return {
worldKey: 'rpg:world-1',
ownerUserId: 'user-1',
profileId: 'profile-1',
worldType: 'custom-world',
worldTitle: '星桥',
worldSubtitle: '',
firstPlayedAt: '2026-06-03T00:00:00.000Z',
lastPlayedAt: '2026-06-03T01:00:00.000Z',
lastObservedPlayTimeMs: 60_000,
...overrides,
};
}
test('profile dashboard presentation formats compact counts', () => {
expect(formatDashboardCount(-1)).toBe('0');
expect(formatDashboardCount(9999.4)).toBe('9,999');
expect(formatDashboardCount(12000)).toBe('1.2万');
expect(formatDashboardCount(230000000)).toBe('2.3亿');
});
test('profile dashboard presentation formats play time for cards and modal rows', () => {
expect(formatTotalPlayTimeHours(0)).toBe('0小时');
expect(formatTotalPlayTimeHours(3_780_000)).toBe('1.1小时');
expect(formatCompactPlayTime(59_000)).toBe('0分');
expect(formatCompactPlayTime(3_600_000)).toBe('1.0小时');
expect(formatCompactPlayTime(3 * 24 * 60 * 60 * 1000)).toBe('3天');
expect(formatCompactPlayTime(12 * 24 * 60 * 60 * 1000)).toBe('12天');
});
test('profile dashboard presentation normalizes played work labels and ids', () => {
expect(formatPlayedWorkType('match_3d')).toBe('抓鹅');
expect(formatPlayedWorkType('square-hole')).toBe('方洞');
expect(formatPlayedWorkType('big_fish')).toBe('大鱼');
expect(formatPlayedWorkType('unknown')).toBe('RPG');
expect(formatPlayedWorkId(buildPlayedWork({ profileId: ' ' }))).toBe(
'rpg:world-1',
);
});
test('profile dashboard presentation builds stat labels from dashboard summary', () => {
expect(buildProfileDashboardPresentation(buildDashboard())).toEqual({
playedWorkCount: 7,
playedWorkCountLabel: '7个',
totalPlayTimeLabel: '1.1小时',
walletBalance: 12345,
walletBalanceLabel: '1.2万',
walletBalanceWithUnitLabel: '1.2万泥点',
});
expect(buildProfileDashboardPresentation(null)).toEqual({
playedWorkCount: 0,
playedWorkCountLabel: '0个',
totalPlayTimeLabel: '0小时',
walletBalance: 0,
walletBalanceLabel: '0',
walletBalanceWithUnitLabel: '0泥点',
});
});

View File

@@ -0,0 +1,95 @@
import type {
ProfileDashboardSummary,
ProfilePlayedWorkSummary,
} from '../../../packages/shared/src/contracts/runtime';
export type ProfileDashboardPresentation = {
playedWorkCount: number;
playedWorkCountLabel: string;
totalPlayTimeLabel: string;
walletBalance: number;
walletBalanceLabel: string;
walletBalanceWithUnitLabel: string;
};
export 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)}`;
}
// “累计游戏时长”卡片固定用小时口径,避免卡片在分钟 / 天之间跳变。
export function formatTotalPlayTimeHours(playTimeMs: number) {
const roundedHours = Math.max(0, Math.round(playTimeMs / 360000) / 10);
return `${roundedHours.toLocaleString('zh-CN', {
maximumFractionDigits: 1,
})}小时`;
}
export 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');
}
// 玩法标签沿用首页既有外显口径,未知类型暂归入 RPG。
export 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';
}
// 现有契约尚未下发公开作品码,“玩过”列表先沿用 profileId再兜底 worldKey。
export function formatPlayedWorkId(work: ProfilePlayedWorkSummary) {
return work.profileId?.trim() || work.worldKey;
}
export function buildProfileDashboardPresentation(
dashboard: ProfileDashboardSummary | null,
): ProfileDashboardPresentation {
const walletBalance = dashboard?.walletBalance ?? 0;
const walletBalanceLabel = formatDashboardCount(walletBalance);
const playedWorkCount = dashboard?.playedWorldCount ?? 0;
return {
playedWorkCount,
playedWorkCountLabel: `${formatDashboardCount(playedWorkCount)}`,
totalPlayTimeLabel: formatTotalPlayTimeHours(
dashboard?.totalPlayTimeMs ?? 0,
),
walletBalance,
walletBalanceLabel,
walletBalanceWithUnitLabel: `${walletBalanceLabel}泥点`,
};
}