修复推荐页封面遮罩与登录态刷新

推荐页运行态封面增加加载条并隔离层级,避免 runtime 内容穿透封面

登录态从未登录到已登录或退出后刷新当前页面,退出等待 token 清理完成后再刷新

补充推荐页封面、认证刷新与样式回归测试

同步平台链路、项目基线和 Hermes 决策文档
This commit is contained in:
2026-06-07 17:59:11 +08:00
parent ea4706daa6
commit 56a9075582
9 changed files with 189 additions and 39 deletions

View File

@@ -3,11 +3,11 @@
import { act, render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { useState } from 'react';
import { beforeEach, expect, test, vi } from 'vitest';
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
import type { AuthSessionSummary, AuthUser } from '../../services/authService';
import { LEGAL_CONSENT_STORAGE_KEY } from '../common/legalDocuments';
import { AuthGate } from './AuthGate';
import { AuthGate, setAuthGateReloadForTest } from './AuthGate';
import { useAuthUi } from './AuthUiContext';
const authMocks = vi.hoisted(() => ({
@@ -107,6 +107,7 @@ beforeEach(() => {
vi.clearAllMocks();
window.localStorage.clear();
window.history.replaceState(null, '', '/');
setAuthGateReloadForTest(vi.fn());
authMocks.consumeAuthCallbackResult.mockReturnValue(null);
authMocks.ensureStoredAccessToken.mockResolvedValue('jwt-existing-token');
authMocks.getStoredAccessToken.mockReturnValue('');
@@ -158,6 +159,10 @@ beforeEach(() => {
authMocks.requestWechatMiniProgramPhoneLogin.mockResolvedValue(true);
});
afterEach(() => {
setAuthGateReloadForTest(null);
});
async function acceptLegalConsent(
user: ReturnType<typeof userEvent.setup>,
dialog: HTMLElement,
@@ -382,6 +387,8 @@ test('auth gate keeps sms and password entries available when login options requ
test('auth gate opens a login modal for protected actions and resumes after login', async () => {
const user = userEvent.setup();
const onAuthenticated = vi.fn();
const reload = vi.fn();
setAuthGateReloadForTest(reload);
authMocks.getAuthLoginOptions.mockResolvedValue({
availableLoginMethods: ['phone'],
@@ -411,6 +418,7 @@ test('auth gate opens a login modal for protected actions and resumes after logi
);
expect(authMocks.getCurrentAuthUser).toHaveBeenCalledTimes(1);
expect(onAuthenticated).toHaveBeenCalledTimes(1);
expect(reload).toHaveBeenCalledTimes(1);
});
expect(screen.queryByRole('dialog', { name: '账号入口' })).toBeNull();
@@ -636,6 +644,8 @@ test('registration invite modal can skip when invite code is empty', async () =>
test('auth state refresh keeps mounted platform content and local tab state', async () => {
const user = userEvent.setup();
const reload = vi.fn();
setAuthGateReloadForTest(reload);
authMocks.getCurrentAuthUser.mockResolvedValue({
user: mockUser,
availableLoginMethods: ['phone'],
@@ -674,10 +684,13 @@ test('auth state refresh keeps mounted platform content and local tab state', as
expect(authMocks.getCurrentAuthUser).toHaveBeenCalledTimes(2);
});
expect(screen.getByText('当前Tab创作')).toBeTruthy();
expect(reload).not.toHaveBeenCalled();
});
test('logout withdraws user context before backend request finishes', async () => {
const user = userEvent.setup();
const reload = vi.fn();
setAuthGateReloadForTest(reload);
authMocks.getCurrentAuthUser.mockResolvedValue({
user: mockUser,
availableLoginMethods: ['phone'],
@@ -703,11 +716,14 @@ test('logout withdraws user context before backend request finishes', async () =
expect(await screen.findByText('当前用户:未登录')).toBeTruthy();
expect(screen.getByText('私有数据:不可读取')).toBeTruthy();
expect(authMocks.logoutAuthUser).toHaveBeenCalledTimes(1);
expect(reload).not.toHaveBeenCalled();
await act(async () => {
resolveLogout();
await logoutPromise;
});
expect(reload).toHaveBeenCalledTimes(1);
});
test('auth gate shows sms send feedback in the login modal', async () => {

View File

@@ -65,6 +65,18 @@ type AuthStatus =
const REQUIRED_LOGIN_METHODS: AuthLoginMethod[] = ['phone', 'password'];
let reloadCurrentPageForAuthStateChange = () => {
window.location.reload();
};
export function setAuthGateReloadForTest(handler: (() => void) | null) {
reloadCurrentPageForAuthStateChange =
handler ??
(() => {
window.location.reload();
});
}
function readInviteCodeFromLocation(): string {
const params = new URLSearchParams(window.location.search || '');
return (params.get('inviteCode') || params.get('invite_code') || '')
@@ -140,6 +152,8 @@ export function AuthGate({ children }: AuthGateProps) {
const autoOpenedInviteCodeRef = useRef<string | null>(null);
const hasRenderedPlatformContentRef = useRef(false);
const authHydrateVersionRef = useRef(0);
const lastStableAuthPresenceRef = useRef<boolean | null>(null);
const pendingAuthStateReloadRef = useRef(false);
const canKeepPlatformContentMounted =
hasRenderedPlatformContentRef.current &&
(status === 'checking' || status === 'recovering');
@@ -152,36 +166,64 @@ export function AuthGate({ children }: AuthGateProps) {
hasRenderedPlatformContentRef.current = true;
}
const markAuthStateReloadIfChanged = useCallback(
(
nextUser: AuthUser | null,
options: { reloadOnChange?: boolean } = {},
) => {
const nextHasUser = Boolean(nextUser);
const previousHasUser = lastStableAuthPresenceRef.current;
if (previousHasUser === null) {
lastStableAuthPresenceRef.current = nextHasUser;
return;
}
lastStableAuthPresenceRef.current = nextHasUser;
if (
previousHasUser !== nextHasUser &&
options.reloadOnChange !== false
) {
pendingAuthStateReloadRef.current = true;
}
},
[],
);
const activateReadyUser = useCallback((nextUser: AuthUser) => {
// 受保护业务 hook 只在 readyUser 暴露后启动,必须先保证请求层能带 Bearer token。
authHydrateVersionRef.current += 1;
markAuthStateReloadIfChanged(nextUser);
setUser(nextUser);
setStatus('ready');
}, []);
}, [markAuthStateReloadIfChanged]);
const clearLocalAuthenticatedState = useCallback(() => {
// 退出动作必须先收回前端鉴权上下文,再等待后端吊销完成。
// 否则平台壳层会在无刷新状态下继续暴露旧用户的私有作品缓存
authHydrateVersionRef.current += 1;
pendingProtectedActionRef.current = null;
setUser(null);
setStatus('unauthenticated');
setShowLoginModal(false);
setShowRegistrationInviteModal(false);
setShowSettingsModal(false);
setSettingsEntryMode('settings');
setInitialSettingsSection(null);
setSessions([]);
setRevokingSessionIds([]);
setAuditLogs([]);
setRiskBlocks([]);
setLoginCaptchaChallenge(null);
setBindCaptchaChallenge(null);
setChangePhoneCaptchaChallenge(null);
setPendingInviteCode('');
setRegistrationInviteError('');
setError('');
}, []);
const clearLocalAuthenticatedState = useCallback(
(options: { reloadOnChange?: boolean } = {}) => {
// 退出动作必须先收回前端鉴权上下文,再等待后端吊销完成
// 否则平台壳层会在无刷新状态下继续暴露旧用户的私有作品缓存。
authHydrateVersionRef.current += 1;
markAuthStateReloadIfChanged(null, options);
pendingProtectedActionRef.current = null;
setUser(null);
setStatus('unauthenticated');
setShowLoginModal(false);
setShowRegistrationInviteModal(false);
setShowSettingsModal(false);
setSettingsEntryMode('settings');
setInitialSettingsSection(null);
setSessions([]);
setRevokingSessionIds([]);
setAuditLogs([]);
setRiskBlocks([]);
setLoginCaptchaChallenge(null);
setBindCaptchaChallenge(null);
setChangePhoneCaptchaChallenge(null);
setPendingInviteCode('');
setRegistrationInviteError('');
setError('');
},
[markAuthStateReloadIfChanged],
);
const restoreAuthSession = useCallback(async () => {
const hadLocalAccessToken = Boolean(getStoredAccessToken());
@@ -234,7 +276,7 @@ export function AuthGate({ children }: AuthGateProps) {
}, []);
const logoutCurrentSession = useCallback(async () => {
clearLocalAuthenticatedState();
clearLocalAuthenticatedState({ reloadOnChange: false });
try {
await logoutAuthUser();
} catch (logoutError) {
@@ -243,11 +285,13 @@ export function AuthGate({ children }: AuthGateProps) {
? logoutError.message
: '退出登录失败,请刷新页面确认状态。',
);
} finally {
reloadCurrentPageForAuthStateChange();
}
}, [clearLocalAuthenticatedState]);
const logoutAllSessions = useCallback(async () => {
clearLocalAuthenticatedState();
clearLocalAuthenticatedState({ reloadOnChange: false });
try {
await logoutAllAuthSessions();
} catch (logoutError) {
@@ -256,6 +300,8 @@ export function AuthGate({ children }: AuthGateProps) {
? logoutError.message
: '退出全部设备失败,请刷新页面确认状态。',
);
} finally {
reloadCurrentPageForAuthStateChange();
}
}, [clearLocalAuthenticatedState]);
@@ -386,6 +432,7 @@ export function AuthGate({ children }: AuthGateProps) {
return;
}
markAuthStateReloadIfChanged(null);
setUser(null);
setStatus('unauthenticated');
} catch (optionsError) {
@@ -394,6 +441,7 @@ export function AuthGate({ children }: AuthGateProps) {
}
setAvailableLoginMethods(REQUIRED_LOGIN_METHODS);
markAuthStateReloadIfChanged(null);
setUser(null);
// 中文注释:登录方式接口失败时按产品约定保留验证码和密码登录入口;
// 这里不展示接口读取错误,避免用户误以为登录本身不可用。
@@ -413,6 +461,7 @@ export function AuthGate({ children }: AuthGateProps) {
return;
}
if (restoredSession.kind === 'guest') {
markAuthStateReloadIfChanged(null);
setAvailableLoginMethods(
normalizeAvailableLoginMethods(
restoredSession.session?.availableLoginMethods,
@@ -423,6 +472,7 @@ export function AuthGate({ children }: AuthGateProps) {
}
const nextSession = restoredSession.session;
markAuthStateReloadIfChanged(nextSession.user);
setUser(nextSession.user);
setAvailableLoginMethods(
normalizeAvailableLoginMethods(nextSession.availableLoginMethods),
@@ -470,19 +520,23 @@ export function AuthGate({ children }: AuthGateProps) {
window.removeEventListener(AUTH_STATE_EVENT, handleAuthStateChange);
window.removeEventListener('hashchange', handleAuthHashChange);
};
}, [restoreAuthSession]);
}, [markAuthStateReloadIfChanged, restoreAuthSession]);
useEffect(() => {
if (!readyUser) {
setShowSettingsModal(false);
return;
} else {
setShowLoginModal(false);
const pendingAction = pendingProtectedActionRef.current;
pendingProtectedActionRef.current = null;
pendingAction?.();
}
setShowLoginModal(false);
const pendingAction = pendingProtectedActionRef.current;
pendingProtectedActionRef.current = null;
pendingAction?.();
if (pendingAuthStateReloadRef.current) {
pendingAuthStateReloadRef.current = false;
reloadCurrentPageForAuthStateChange();
}
}, [readyUser]);
useEffect(() => {

View File

@@ -3908,6 +3908,9 @@ test('mobile recommend startup keeps cover visible without loading copy', () =>
expect(
document.querySelector('.platform-recommend-runtime-cover'),
).toBeTruthy();
expect(
document.querySelector('.platform-recommend-runtime-loading'),
).toBeTruthy();
expect(screen.queryByText('加载中...')).toBeNull();
expect(screen.getAllByText('奇幻拼图').length).toBeGreaterThan(0);
});

View File

@@ -1288,6 +1288,7 @@ function RecommendRuntimeCover({
position="cover"
resolvedCoverUrls={resolvedCoverUrls}
/>
<div className="platform-recommend-runtime-loading" />
</div>
);
}