This commit is contained in:
2026-04-30 18:18:05 +08:00
29 changed files with 1791 additions and 93 deletions

View File

@@ -1,4 +1,11 @@
import { lazy, Suspense, useCallback, useEffect, useRef, useState } from 'react';
import {
lazy,
Suspense,
useCallback,
useEffect,
useRef,
useState,
} from 'react';
import { useAuthUi } from './components/auth/AuthUiContext';
import { PlatformEntryFlowShell } from './components/platform-entry/PlatformEntryFlowShell';
@@ -133,7 +140,7 @@ export default function App() {
return (
<div
className={`platform-ui-shell platform-theme ${platformThemeClass} flex h-screen max-h-screen flex-col overflow-hidden bg-[image:var(--platform-body-fill)] p-2 font-sans text-[var(--platform-text-strong)] sm:p-4`}
className={`platform-ui-shell platform-viewport-shell platform-theme ${platformThemeClass} flex flex-col overflow-hidden bg-[image:var(--platform-body-fill)] p-2 font-sans text-[var(--platform-text-strong)] sm:p-4`}
>
<PlatformEntryFlowShell
selectionStage={selectionStage}

View File

@@ -5,6 +5,7 @@ import userEvent from '@testing-library/user-event';
import { useState } from 'react';
import { afterEach, expect, test, vi } from 'vitest';
import type { AuthUser } from '../../services/authService';
import { AuthUiContext } from '../auth/AuthUiContext';
import {
RpgEntryHomeView,
@@ -221,6 +222,7 @@ function renderProfileView(
profileDashboardOverrides: Partial<
NonNullable<RpgEntryHomeViewProps['profileDashboard']>
> = {},
userOverrides: Partial<AuthUser> = {},
) {
return render(
<AuthUiContext.Provider
@@ -235,6 +237,7 @@ function renderProfileView(
loginMethod: 'password',
bindingStatus: 'active',
wechatBound: false,
...userOverrides,
},
canAccessProtectedData: true,
openLoginModal: vi.fn(),
@@ -450,6 +453,18 @@ test('profile total play time card always uses hours', () => {
expect(within(playTimeCard).queryByText('90分')).toBeNull();
});
test('desktop account entry uses saved avatar image when available', () => {
mockDesktopLayout();
const avatarUrl = 'data:image/png;base64,AAAA';
renderProfileView(vi.fn(), {}, { avatarUrl });
const accountEntry = screen.getByRole('button', { name: //u });
const avatarImage = accountEntry.querySelector('img');
expect(avatarImage?.getAttribute('src')).toBe(avatarUrl);
expect(within(accountEntry).queryByText('测')).toBeNull();
});
test('wallet ledger modal shows empty and error states', async () => {
const user = userEvent.setup();
mockGetRpgProfileWalletLedger.mockResolvedValueOnce({ entries: [] });
@@ -466,6 +481,18 @@ test('wallet ledger modal shows empty and error states', async () => {
expect(screen.getByText('重新加载')).toBeTruthy();
});
test('opens reward code modal from profile action on mobile', async () => {
const user = userEvent.setup();
renderProfileView();
await user.click(screen.getByRole('button', { name: //u }));
const modal = await screen.findByPlaceholderText('输入兑换码');
expect(modal).toBeTruthy();
expect(screen.getByRole('button', { name: '兑换' })).toBeTruthy();
expect(screen.getByLabelText('关闭兑换码')).toBeTruthy();
});
test('shows a reachable login entry in logged out mobile shell', async () => {
const user = userEvent.setup();
const openLoginModal = vi.fn();

View File

@@ -1828,7 +1828,7 @@ function RewardCodeRedeemModal({
onClose: () => void;
}) {
return (
<div className="platform-modal-backdrop fixed inset-0 z-50 flex items-center justify-center px-4 py-6">
<div className="platform-modal-backdrop fixed inset-0 z-[80] flex items-center justify-center px-4 py-6">
<div className="platform-recharge-modal w-full max-w-sm overflow-hidden rounded-[1.4rem]">
<div className="flex items-center justify-between border-b border-white/10 px-5 py-4">
<div className="text-base font-black"></div>
@@ -3478,6 +3478,17 @@ export function RpgEntryHomeView({
) : null}
</>
);
const rewardCodeModal: ReactNode = isRewardCodeOpen ? (
<RewardCodeRedeemModal
value={rewardCodeInput}
isSubmitting={isSubmittingRewardCode}
error={rewardCodeError}
success={rewardCodeSuccess}
onChange={setRewardCodeInput}
onSubmit={submitRewardCode}
onClose={() => setIsRewardCodeOpen(false)}
/>
) : null;
if (!isDesktopLayout) {
return (
@@ -3501,10 +3512,9 @@ export function RpgEntryHomeView({
</div>
<div
className="mt-3 min-w-0 shrink-0 border-t pt-2"
className="platform-mobile-bottom-dock mt-3 min-w-0 shrink-0 border-t pt-2"
style={{
borderColor: 'var(--platform-line-soft)',
paddingBottom: 'calc(env(safe-area-inset-bottom) + 0.2rem)',
}}
>
<div
@@ -3537,6 +3547,7 @@ export function RpgEntryHomeView({
onSubmitRedeem={submitReferralInviteCode}
/>
) : null}
{rewardCodeModal}
{isProfilePlayStatsOpen ? (
<ProfilePlayedWorksModal
stats={profilePlayStats}
@@ -3594,13 +3605,21 @@ export function RpgEntryHomeView({
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 rounded-full text-base font-black text-white"
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)',
}}
>
{avatarLabel}
{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)]">
@@ -3634,17 +3653,7 @@ export function RpgEntryHomeView({
</div>
</div>
</div>
{isRewardCodeOpen ? (
<RewardCodeRedeemModal
value={rewardCodeInput}
isSubmitting={isSubmittingRewardCode}
error={rewardCodeError}
success={rewardCodeSuccess}
onChange={setRewardCodeInput}
onSubmit={submitRewardCode}
onClose={() => setIsRewardCodeOpen(false)}
/>
) : null}
{rewardCodeModal}
{profilePopupPanel ? (
<ProfileReferralModal
panel={profilePopupPanel}

View File

@@ -21,6 +21,10 @@
:root {
--ui-scale: clamp(0.78, 0.72 + 0.45vw, 1.06);
--platform-bottom-nav-height: 3.5rem;
--platform-bottom-dock-outer-height: calc(
var(--platform-bottom-nav-height) + env(safe-area-inset-bottom, 0px) +
1.15rem
);
--platform-bottom-nav-padding: 0.25rem;
--platform-bottom-nav-gap: 0.25rem;
--platform-bottom-nav-radius: 1.2rem;
@@ -50,6 +54,20 @@ body {
-webkit-font-smoothing: antialiased;
}
.platform-viewport-shell {
height: 100vh;
max-height: 100vh;
min-height: 100vh;
}
@supports (height: 100dvh) {
.platform-viewport-shell {
height: 100dvh;
max-height: 100dvh;
min-height: 100dvh;
}
}
@keyframes character-animator-portrait-death-fall {
0% {
transform: translateY(0) rotate(0deg) scaleX(1) scale(1);
@@ -754,6 +772,10 @@ body {
overflow: hidden;
}
.platform-mobile-bottom-dock {
flex: none;
}
.platform-tab-panel {
box-sizing: border-box;
width: 100%;
@@ -1762,6 +1784,20 @@ body {
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.16);
}
.platform-modal-backdrop {
background: var(--platform-overlay-fill);
color: var(--platform-text-strong);
backdrop-filter: blur(12px);
}
.platform-recharge-modal {
border: 1px solid var(--platform-modal-border);
background: var(--platform-modal-fill);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.1),
0 24px 80px rgba(0, 0, 0, 0.18);
}
.platform-overlay {
background: var(--platform-overlay-fill);
}
@@ -1845,6 +1881,20 @@ body {
min-width: 0;
}
.platform-mobile-entry-shell {
box-sizing: border-box;
padding-bottom: var(--platform-bottom-dock-outer-height);
}
.platform-mobile-bottom-dock {
position: fixed;
right: max(0.75rem, env(safe-area-inset-right, 0px));
bottom: 0;
left: max(0.75rem, env(safe-area-inset-left, 0px));
z-index: 60;
padding-bottom: calc(env(safe-area-inset-bottom, 0px) + 0.5rem);
}
.platform-mobile-home-stage {
box-sizing: border-box;
width: 100%;
@@ -2246,6 +2296,70 @@ body {
box-shadow: 0 0 0 3px var(--platform-input-focus-ring);
}
.platform-profile-input {
border: 1px solid var(--platform-subpanel-border);
background: var(--platform-input-fill);
color: var(--platform-text-strong);
outline: none;
box-shadow: inset 0 1px 0 var(--platform-input-highlight);
transition:
border-color 180ms ease,
background-color 180ms ease,
box-shadow 180ms ease;
}
.platform-profile-input::placeholder {
color: var(--platform-text-soft);
}
.platform-profile-input:focus {
border-color: var(--platform-nav-active-border);
background: var(--platform-input-fill-focus);
box-shadow: 0 0 0 3px var(--platform-input-focus-ring);
}
.platform-primary-button {
border: 1px solid var(--platform-button-primary-border);
background: var(--platform-button-primary-fill);
color: var(--platform-button-primary-text);
box-shadow: var(--platform-profile-action-shadow);
transition:
transform 180ms ease,
filter 180ms ease,
opacity 180ms ease;
}
.platform-primary-button:hover:not(:disabled) {
transform: translateY(-1px);
filter: brightness(1.02);
}
.platform-modal-close {
background: var(--platform-profile-chip-fill);
color: var(--platform-profile-chip-text);
transition:
background-color 180ms ease,
color 180ms ease,
transform 180ms ease;
}
.platform-modal-close:hover {
background: var(--platform-profile-chip-hover-fill);
color: var(--platform-text-strong);
}
.platform-profile-error {
border: 1px solid var(--platform-button-danger-border);
background: var(--platform-button-danger-fill);
color: var(--platform-button-danger-text);
}
.platform-profile-success {
border: 1px solid var(--platform-success-border);
background: var(--platform-success-bg);
color: var(--platform-success-text);
}
.platform-profile-hero {
overflow: hidden;
border: 1px solid var(--platform-profile-hero-border);