refactor: 收口个人数据展示模型
This commit is contained in:
@@ -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` 等名称,会把内部模型路由暴露给普通用户。
|
||||
|
||||
@@ -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),快速建立这个项目的开发共识。
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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泥点',
|
||||
});
|
||||
});
|
||||
@@ -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}泥点`,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user