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

@@ -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}泥点`,
};
}