恢复平台账号顶栏与我的页签

按新版创作页原布局恢复泥点入口与账号身份胶囊。

在桌面侧栏补回我的页签及游客、登录态个人页面。

个人页面只复用账号、钱包、统计、设置和法律信息等平台能力。

补充壳层与个人页面回归测试并更新退役边界文档。
This commit is contained in:
2026-07-18 16:15:40 +08:00
parent c56da8dc26
commit f3ab2f2ec0
10 changed files with 549 additions and 42 deletions
@@ -0,0 +1,65 @@
/* @vitest-environment jsdom */
import { render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { PlatformActiveProfileView } from './PlatformActiveProfileView';
const callbacks = {
onLogin: vi.fn(),
onOpenAccount: vi.fn(),
onOpenRecharge: vi.fn(),
onOpenSettings: vi.fn(),
onOpenWalletLedger: vi.fn(),
};
describe('PlatformActiveProfileView', () => {
it('shows the login entry for guests', () => {
render(
<PlatformActiveProfileView
{...callbacks}
dashboard={null}
isLoadingDashboard={false}
user={null}
/>,
);
expect(screen.getByRole('main', { name: '我的' })).toBeTruthy();
expect(screen.getByText('尚未登录')).toBeTruthy();
expect(screen.getByRole('button', { name: '登录' })).toBeTruthy();
});
it('renders active account, wallet, and settings capabilities', () => {
render(
<PlatformActiveProfileView
{...callbacks}
dashboard={{
walletBalance: 108,
totalPlayTimeMs: 5_400_000,
playedWorldCount: 7,
updatedAt: '2026-07-18T00:00:00.000Z',
}}
isLoadingDashboard={false}
user={{
id: 'user-1',
publicUserCode: '100001',
displayName: '测试玩家',
avatarUrl: null,
phoneNumberMasked: null,
loginMethod: 'password',
bindingStatus: 'active',
wechatBound: false,
}}
/>,
);
expect(screen.getByText('测试玩家')).toBeTruthy();
expect(screen.getByText('陶泥号:100001')).toBeTruthy();
expect(screen.getByRole('button', { name: '泥点余额 108' })).toBeTruthy();
expect(screen.getByRole('button', { name: /泥点充值/u })).toBeTruthy();
expect(
screen.getAllByRole('button', { name: /账号与安全/u }),
).toHaveLength(2);
expect(screen.getByRole('button', { name: /通用设置/u })).toBeTruthy();
});
});
@@ -0,0 +1,244 @@
import {
Coins,
History,
Settings,
ShieldCheck,
UserRound,
} from 'lucide-react';
import { useState } from 'react';
import profileClockImage from '../../../media/profile/_Image (1).png';
import profileGamepadImage from '../../../media/profile/_Image (2).png';
import profileStillLifeImage from '../../../media/profile/_Image (3).png';
import profileCoinsImage from '../../../media/profile/_Image (4).png';
import profileMascotImage from '../../../media/profile/_Image (9).png';
import profilePointImage from '../../../media/profile/_Image.png';
import type { AuthUser } from '../../../packages/shared/src/contracts/auth';
import type { ProfileDashboardSummary } from '../../../packages/shared/src/contracts/runtime';
import { LegalDocumentModal } from '../common/LegalDocumentModal';
import {
getLegalDocument,
type LegalDocumentId,
} from '../common/legalDocuments';
import { PlatformActionButton } from '../common/PlatformActionButton';
import { resolveActivePublicUserCode } from './platformActiveProfileModel';
import {
ProfileLegalSection,
ProfileShortcutButton,
ProfileStatCard,
ProfileStatCardSkeleton,
} from './PlatformProfilePrimitives';
type PlatformActiveProfileViewProps = {
dashboard: ProfileDashboardSummary | null;
isLoadingDashboard: boolean;
onLogin: () => void;
onOpenAccount: () => void;
onOpenRecharge: () => void;
onOpenSettings: () => void;
onOpenWalletLedger: () => void;
user: AuthUser | null | undefined;
};
function formatDashboardCount(value: number) {
return Math.max(0, Math.round(value)).toLocaleString('zh-CN');
}
function formatTotalPlayTime(value: number) {
const hours = Math.max(0, Math.round(value / 360000) / 10);
return `${hours.toLocaleString('zh-CN', {
maximumFractionDigits: 1,
})}小时`;
}
export function PlatformActiveProfileView({
dashboard,
isLoadingDashboard,
onLogin,
onOpenAccount,
onOpenRecharge,
onOpenSettings,
onOpenWalletLedger,
user,
}: PlatformActiveProfileViewProps) {
const [activeLegalDocumentId, setActiveLegalDocumentId] =
useState<LegalDocumentId | null>(null);
const activeLegalDocument = activeLegalDocumentId
? getLegalDocument(activeLegalDocumentId)
: null;
if (!user) {
return (
<main
className="platform-profile-page platform-remap-surface mx-auto w-full max-w-4xl"
aria-label="我的"
>
<section className="platform-profile-header">
<img
src={profileStillLifeImage}
alt=""
aria-hidden="true"
className="platform-profile-scene-decor"
/>
<div className="platform-profile-header__identity">
<div className="platform-profile-header__identity-row flex min-w-0 items-center gap-4">
<img
src={profileMascotImage}
alt=""
aria-hidden="true"
className="platform-profile-avatar h-[5.15rem] w-[5.15rem] shrink-0 rounded-full object-cover"
/>
<div className="min-w-0">
<div className="text-base font-black text-[var(--platform-text-strong)]">
尚未登录
</div>
<PlatformActionButton className="mt-3" onClick={onLogin}>
登录
</PlatformActionButton>
</div>
</div>
</div>
</section>
</main>
);
}
const avatarUrl = user.avatarUrl?.trim() || null;
const publicUserCode = resolveActivePublicUserCode(user);
return (
<main
className="platform-profile-page platform-remap-surface mx-auto w-full max-w-4xl space-y-4 pb-2"
aria-label="我的"
>
<section className="platform-profile-header">
<img
src={profileStillLifeImage}
alt=""
aria-hidden="true"
className="platform-profile-scene-decor"
/>
<div className="platform-profile-header__identity">
<div className="platform-profile-header__identity-row flex min-w-0 items-center gap-4">
<button
type="button"
className="platform-profile-avatar h-[5.15rem] w-[5.15rem] shrink-0 overflow-hidden rounded-full"
aria-label="账号与安全"
onClick={onOpenAccount}
>
{avatarUrl ? (
<img
src={avatarUrl}
alt=""
className="h-full w-full object-cover"
/>
) : (
<img
src={profileMascotImage}
alt=""
className="h-full w-full object-cover"
/>
)}
</button>
<div className="platform-profile-header__text min-w-0">
<div className="truncate text-[18px] font-black leading-tight text-[var(--platform-text-strong)]">
{user.displayName}
</div>
<div className="mt-2 text-[12px] text-[var(--platform-text-base)]">
陶泥号:{publicUserCode}
</div>
</div>
</div>
</div>
</section>
<section className="platform-profile-stats-panel" aria-label="我的数据">
<div className="platform-profile-stats-grid grid grid-cols-3 gap-3">
{isLoadingDashboard ? (
<>
<ProfileStatCardSkeleton />
<ProfileStatCardSkeleton />
<ProfileStatCardSkeleton />
</>
) : (
<>
<ProfileStatCard
cardKey="wallet"
label="泥点余额"
value={
dashboard
? formatDashboardCount(dashboard.walletBalance)
: '暂不可用'
}
icon={Coins}
imageSrc={profilePointImage}
onClick={onOpenWalletLedger}
/>
<ProfileStatCard
cardKey="playTime"
label="累计游戏时长"
value={
dashboard
? formatTotalPlayTime(dashboard.totalPlayTimeMs)
: '暂不可用'
}
icon={History}
imageSrc={profileClockImage}
/>
<ProfileStatCard
cardKey="playedWorks"
label="已玩游戏数量"
value={
dashboard
? `${formatDashboardCount(dashboard.playedWorldCount)}个`
: '暂不可用'
}
icon={UserRound}
imageSrc={profileGamepadImage}
/>
</>
)}
</div>
</section>
<section className="platform-profile-shortcut-panel" aria-label="常用功能">
<div className="platform-profile-shortcut-grid grid w-full grid-cols-4">
<ProfileShortcutButton
label="泥点充值"
subLabel="充值泥点"
icon={Coins}
imageSrc={profileCoinsImage}
onClick={onOpenRecharge}
/>
<ProfileShortcutButton
label="使用详情"
subLabel="查看泥点账单"
icon={History}
onClick={onOpenWalletLedger}
/>
<ProfileShortcutButton
label="账号与安全"
subLabel="管理账号"
icon={ShieldCheck}
onClick={onOpenAccount}
/>
<ProfileShortcutButton
label="通用设置"
subLabel="偏好设置"
icon={Settings}
onClick={onOpenSettings}
/>
</div>
</section>
<ProfileLegalSection onOpenDocument={setActiveLegalDocumentId} />
<LegalDocumentModal
document={activeLegalDocument}
open={Boolean(activeLegalDocument)}
onClose={() => setActiveLegalDocumentId(null)}
/>
</main>
);
}
export default PlatformActiveProfileView;
@@ -0,0 +1,115 @@
/* @vitest-environment jsdom */
import { fireEvent, render, screen, within } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { PlatformEntryFlowShellImpl } from './PlatformEntryActiveFlowShell';
const authUiMock = vi.hoisted(() => ({
value: {
user: null,
canAccessProtectedData: false,
openLoginModal: vi.fn(),
openAccountModal: vi.fn(),
openSettingsModal: vi.fn(),
},
}));
vi.mock('../auth/AuthUiContext', () => ({
useAuthUi: () => authUiMock.value,
}));
vi.mock('../creation-home/CreationLandingView', () => ({
CreationLandingView: () => (
<main aria-label="陶泥儿创作主页">创作主页</main>
),
}));
vi.mock('../project/ProjectGalleryView', () => ({
ProjectGalleryView: () => <main aria-label="项目">项目</main>,
}));
vi.mock('../image-editor/ImageCanvasEditorView', () => ({
ImageCanvasEditorView: () => <main aria-label="图片画布编辑器" />,
}));
vi.mock('../../services/platform-entry/platformProfileClient', () => ({
getPlatformProfileDashboard: vi.fn(),
}));
vi.mock('./usePlatformProfileCenterController', () => ({
usePlatformProfileCenterController: () => ({
buyRechargeProduct: vi.fn(),
closeNativeWechatPayment: vi.fn(),
confirmNativeWechatPayment: vi.fn(),
isLoadingRechargeCenter: false,
isLoadingWalletLedger: false,
isRechargeOpen: false,
isWalletLedgerOpen: false,
loadRechargeCenter: vi.fn(),
nativeWechatPayment: null,
openWalletLedgerPanel: vi.fn(),
rechargeCenter: null,
rechargeError: null,
rechargePaymentResult: null,
setIsRechargeOpen: vi.fn(),
setIsWalletLedgerOpen: vi.fn(),
setRechargePaymentResult: vi.fn(),
submittingRechargeProductId: null,
walletLedger: null,
walletLedgerError: null,
wechatRechargeOrderConfirmationState: null,
}),
}));
describe('PlatformEntryActiveFlowShell', () => {
beforeEach(() => {
window.history.replaceState(null, '', '/creation');
authUiMock.value.openLoginModal.mockReset();
});
it('keeps the active desktop rail and the shared account capsule', async () => {
const setSelectionStage = vi.fn();
const { container } = render(
<PlatformEntryFlowShellImpl
selectionStage="creation-home"
setSelectionStage={setSelectionStage}
/>,
);
expect(
await screen.findByRole('main', { name: '陶泥儿创作主页' }),
).toBeTruthy();
const navigation = screen.getByRole('navigation', { name: '平台导航' });
expect(
within(navigation)
.getAllByRole('button')
.map((button) => button.getAttribute('aria-label')),
).toEqual(['创作', '项目', '我的']);
expect(
within(navigation)
.getByRole('button', { name: '创作' })
.getAttribute('aria-current'),
).toBe('page');
const topbar = container.querySelector('.platform-desktop-topbar');
expect(topbar).toBeTruthy();
expect(
topbar?.querySelectorAll('button.platform-desktop-search'),
).toHaveLength(1);
expect(
within(topbar as HTMLElement).queryByRole('button', { name: '设置' }),
).toBeNull();
fireEvent.click(within(navigation).getByRole('button', { name: '我的' }));
expect(await screen.findByRole('main', { name: '我的' })).toBeTruthy();
expect(window.location.pathname).toBe('/');
expect(setSelectionStage).toHaveBeenCalledWith('platform', { path: '/' });
expect(
within(navigation)
.getByRole('button', { name: '我的' })
.getAttribute('aria-current'),
).toBe('page');
});
});
@@ -1,8 +1,6 @@
import {
FolderKanban,
LogIn,
Palette,
Settings,
UserRound,
} from 'lucide-react';
import {
@@ -22,8 +20,11 @@ import {
} from '../../routing/activeAppPageRoutes';
import { getPlatformProfileDashboard } from '../../services/platform-entry/platformProfileClient';
import { useAuthUi } from '../auth/AuthUiContext';
import { PlatformActionButton } from '../common/PlatformActionButton';
import { PlatformIconButton } from '../common/PlatformIconButton';
import {
resolveActivePublicUserCode,
resolveActiveUserAvatarLabel,
} from './platformActiveProfileModel';
import { PlatformActiveProfileView } from './PlatformActiveProfileView';
import type { PlatformEntryFlowShellProps } from './platformEntryActiveTypes';
import { PlatformProfileRechargeModal } from './PlatformProfileRechargeModal';
import { PlatformProfileWalletLedgerModal } from './PlatformProfileWalletLedgerModal';
@@ -136,6 +137,7 @@ export function PlatformEntryFlowShellImpl({
null,
);
const [isLoadingDashboard, setIsLoadingDashboard] = useState(false);
const [isProfileStage, setIsProfileStage] = useState(false);
const refreshDashboard = useCallback(async () => {
if (!authUi?.user || !authUi.canAccessProtectedData) {
@@ -156,6 +158,12 @@ export function PlatformEntryFlowShellImpl({
void refreshDashboard();
}, [refreshDashboard]);
useEffect(() => {
if (selectionStage !== 'platform') {
setIsProfileStage(false);
}
}, [selectionStage]);
const profileCenter = usePlatformProfileCenterController({
activeTab: 'project',
isAuthenticated: Boolean(authUi?.user),
@@ -166,15 +174,23 @@ export function PlatformEntryFlowShellImpl({
});
const openCreation = useCallback(() => {
setIsProfileStage(false);
pushAppHistoryPath('/creation');
setSelectionStage('creation-home', { path: '/creation' });
}, [setSelectionStage]);
const openProjects = useCallback(() => {
setIsProfileStage(false);
pushAppHistoryPath('/project');
setSelectionStage('project', { path: '/project' });
}, [setSelectionStage]);
const openProfile = useCallback(() => {
setIsProfileStage(true);
pushAppHistoryPath('/');
setSelectionStage('platform', { path: '/' });
}, [setSelectionStage]);
const openEditorProject = useCallback(
(projectId: string, options?: { guide?: boolean; tool?: string }) => {
const params = new URLSearchParams();
@@ -211,12 +227,27 @@ export function PlatformEntryFlowShellImpl({
const isAuthenticated = Boolean(authUi?.user);
const balance =
dashboard?.walletBalance ??
profileCenter.rechargeCenter?.mudPointBalance?.totalPoints ??
profileCenter.rechargeCenter?.walletBalance ??
dashboard?.walletBalance ??
null;
const isCreationStage =
selectionStage === 'platform' || selectionStage === 'creation-home';
!isProfileStage &&
(selectionStage === 'platform' || selectionStage === 'creation-home');
const avatarUrl = authUi?.user?.avatarUrl?.trim() || null;
const avatarLabel = resolveActiveUserAvatarLabel(authUi?.user);
const publicUserCode = resolveActivePublicUserCode(authUi?.user);
const openRecharge = () => {
profileCenter.setIsRechargeOpen(true);
profileCenter.loadRechargeCenter();
};
const openUserSurface = () => {
if (isAuthenticated) {
authUi?.openAccountModal();
return;
}
authUi?.openLoginModal();
};
return (
<>
@@ -246,6 +277,13 @@ export function PlatformEntryFlowShellImpl({
label="项目"
onClick={openProjects}
/>
<ActiveRailButton
active={isProfileStage}
icon={UserRound}
iconSrc="/creation-home/nav-profile.png"
label="我的"
onClick={openProfile}
/>
</nav>
</aside>
@@ -255,10 +293,14 @@ export function PlatformEntryFlowShellImpl({
<ActivePlatformBrand />
</div>
<span className="hidden truncate text-base font-black text-[var(--platform-text-strong)] lg:block">
{isCreationStage ? '创作工具' : '项目'}
{isProfileStage
? '我的'
: isCreationStage
? '创作工具'
: '项目'}
</span>
<div className="platform-desktop-topbar__actions flex shrink-0 items-center gap-2">
<div className="platform-desktop-topbar__actions flex shrink-0 items-center gap-3">
{isAuthenticated ? (
<PlatformMudPointWalletEntry
variant="desktop"
@@ -271,40 +313,43 @@ export function PlatformEntryFlowShellImpl({
profileCenter.isLoadingRechargeCenter
}
error={profileCenter.rechargeError}
className="platform-desktop-create-wallet-chip"
onRequestDetails={profileCenter.loadRechargeCenter}
onRecharge={() => {
profileCenter.setIsRechargeOpen(true);
profileCenter.loadRechargeCenter();
}}
onRecharge={openRecharge}
onOpenLedger={profileCenter.openWalletLedgerPanel}
/>
) : (
<PlatformActionButton
size="xs"
onClick={() => authUi?.openLoginModal()}
) : null}
<button
type="button"
onClick={openUserSurface}
className="platform-desktop-search flex items-center gap-3 px-3 py-2.5 text-left"
>
<span
className="flex h-11 w-11 items-center justify-center overflow-hidden rounded-full text-base font-black text-white"
style={{
background: 'var(--platform-profile-avatar-fill)',
boxShadow: 'var(--platform-profile-avatar-shadow)',
}}
>
<LogIn className="h-4 w-4" aria-hidden="true" />
登录
</PlatformActionButton>
)}
<PlatformIconButton
label={isAuthenticated ? '账号' : '登录'}
icon={
<UserRound className="h-5 w-5" aria-hidden="true" />
}
onClick={() => {
if (isAuthenticated) {
authUi?.openAccountModal();
return;
}
authUi?.openLoginModal();
}}
/>
<PlatformIconButton
label="设置"
icon={<Settings className="h-5 w-5" aria-hidden="true" />}
onClick={() => authUi?.openSettingsModal()}
/>
{avatarUrl ? (
<img
src={avatarUrl}
alt=""
className="h-full w-full object-cover"
/>
) : (
avatarLabel
)}
</span>
<span className="min-w-0">
<span className="block truncate text-sm font-semibold text-[var(--platform-text-strong)]">
{authUi?.user?.displayName || '登录'}
</span>
<span className="block truncate text-xs text-[var(--platform-text-soft)]">
{authUi?.user ? publicUserCode : '账号入口'}
</span>
</span>
</button>
</div>
</header>
@@ -312,7 +357,20 @@ export function PlatformEntryFlowShellImpl({
id={isCreationStage ? 'platform-tab-panel-create' : undefined}
className={`platform-tab-panel min-h-0 min-w-0 flex-1 overflow-auto ${isCreationStage ? '' : 'px-3 py-4 sm:px-6 sm:py-6'}`}
>
{isCreationStage ? (
{isProfileStage ? (
<PlatformActiveProfileView
dashboard={dashboard}
isLoadingDashboard={isLoadingDashboard}
user={authUi?.user}
onLogin={() => authUi?.openLoginModal()}
onOpenAccount={() => authUi?.openAccountModal()}
onOpenRecharge={openRecharge}
onOpenSettings={() => authUi?.openSettingsModal()}
onOpenWalletLedger={
profileCenter.openWalletLedgerPanel
}
/>
) : isCreationStage ? (
<Suspense
fallback={<LoadingPanel label="正在加载创作主页..." />}
>
@@ -0,0 +1,19 @@
import type { AuthUser } from '../../../packages/shared/src/contracts/auth';
export function resolveActivePublicUserCode(
user: AuthUser | null | undefined,
) {
if (user?.publicUserCode?.trim()) {
return user.publicUserCode.trim();
}
const raw =
user?.id.replace(/[^a-zA-Z0-9]/gu, '').toUpperCase() || '00000000';
return `SY-${raw.slice(-8).padStart(8, '0')}`;
}
export function resolveActiveUserAvatarLabel(
user: AuthUser | null | undefined,
) {
return (user?.displayName || '叙').slice(0, 1).toUpperCase();
}