refactor: 收口个人数据展示模型
This commit is contained in:
@@ -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