Files
pangolin/web/usercenter/components/UserCenter.tsx
wangjia 101b31e073 fix(usercenter): 消除刷新"重绘两次" —— 引导期直接渲染外壳骨架,去掉多余首帧
上一版为防水合不一致加了 !mounted 中性首帧,反而制造了额外一段:一次 Cmd+R 走
裸 spinner(mounted=false)→ 应用外壳(乐观)→ 内容区(overview),三段重绘 =
用户看到的"刷新两次(先主显示区、后整页)"。

改:去掉 mounted gate;引导期(!ready)一律渲染 appShell(spinner)——外壳骨架
(顶栏/导航就位、内容区加载态)。因 render 期不读 localStorage/hasRefresh,SSR 与
客户端首帧一致、无水合不一致,静态 HTML 首帧即外壳。引导完成后只把内容区从 spinner
换成真实视图,顶栏/导航原地不动 → 刷新只有内容区一次替换,不再整页重绘。

代价:未登录用户刷新 /user/ 会先见外壳骨架再落登录页(极短、且随即要登录),
可接受;已登录用户(刷新的绝大多数场景)全程外壳稳定、无闪。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 15:01:29 +08:00

214 lines
9.4 KiB
TypeScript

'use client';
// UserCenter.tsx — 应用外壳:顶栏 + 导航 + 视图切换 + 会话编排。承袭 ucapp.jsx UserCenterApp。
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { Icon, Mark } from './icons';
import { LangSeg } from './shared';
import Login from './Login';
import Overview from './Overview';
import Subscription from './Subscription';
import Redeem from './Redeem';
import Invite from './Invite';
import Settings from './Settings';
import { useUI } from '../lib/theme';
import { makeT } from '../lib/i18n';
import { apiMode, getClient } from '../lib/api/client';
import { hasRefresh, clearSession } from '../lib/api/session';
import type { Me } from '../lib/api/types';
type View = 'overview' | 'sub' | 'redeem' | 'invite' | 'settings';
const ORDER: View[] = ['overview', 'sub', 'redeem', 'invite', 'settings'];
/** redirect 白名单:仅接受单个 '/' 开头、且不以 '//' 或反斜杠开头的本站相对
* 路径(防 open redirect,与 app/sso/page.tsx::safeRedirect 一致);否则返回 null。 */
function safeRedirect(raw: string | null): string | null {
if (!raw) return null;
if (raw.charAt(0) !== '/' || raw.charAt(1) === '/' || raw.indexOf('\\') >= 0) return null;
return raw;
}
function useIsMobile() {
const [m, setM] = useState(false);
useEffect(() => {
const f = () => setM(window.innerWidth <= 700);
f();
window.addEventListener('resize', f);
return () => window.removeEventListener('resize', f);
}, []);
return m;
}
export default function UserCenter() {
const { lang, setLang, theme, toggleTheme } = useUI();
const t = makeT(lang);
const api = getClient();
const mobile = useIsMobile();
const [authed, setAuthed] = useState(false);
const [ready, setReady] = useState(false);
const [me, setMe] = useState<Me | null>(null);
const [view, setView] = useState<View>('overview');
const [dir, setDir] = useState(0);
const touchRef = useRef({ x: 0, y: 0 });
const loadMe = useCallback(() => {
api.getMe().then(setMe).catch(() => {});
}, [api]);
// 启动会话:mock 直接进面板;http 有 refresh 则静默续期,否则去登录
useEffect(() => {
let alive = true;
(async () => {
if (apiMode() === 'mock') {
if (alive) setAuthed(true);
} else if (hasRefresh()) {
try {
await api.refresh();
if (alive) setAuthed(true);
} catch {
// 续期失败(refresh token 过期/被撤销/Redis 丢 JTI):清掉本地会话,
// 否则 pg_uc_refresh 残留 → 官网仍显示"用户中心" → 点进来又续期失败 →
// 来回刷登录页。清了官网会回到"Log in",状态一致。
clearSession();
if (alive) setAuthed(false);
}
}
if (alive) setReady(true);
})();
return () => {
alive = false;
};
}, [api]);
useEffect(() => {
if (authed) loadMe();
else setMe(null);
}, [authed, loadMe]);
function go(id: View) {
const a = ORDER.indexOf(view);
const b = ORDER.indexOf(id);
setDir(b > a ? 1 : b < a ? -1 : 0);
setView(id);
setTimeout(() => setDir(0), 300);
}
function onTouchStart(e: React.TouchEvent) {
const p = e.touches[0];
touchRef.current = { x: p.clientX, y: p.clientY };
}
function onTouchEnd(e: React.TouchEvent) {
const p = e.changedTouches[0];
const dx = p.clientX - touchRef.current.x;
const dy = p.clientY - touchRef.current.y;
if (Math.abs(dx) > 60 && Math.abs(dx) > Math.abs(dy) * 1.5) {
const i = ORDER.indexOf(view);
const ni = dx < 0 ? Math.min(ORDER.length - 1, i + 1) : Math.max(0, i - 1);
if (ni !== i) go(ORDER[ni]);
}
}
async function signOut() {
await api.logout().catch(() => {});
setAuthed(false);
setView('overview');
}
// 登录成功回调:若 URL 带合法 ?redirect=<本站相对路径>(如官网带 ?redirect=/ 过来),
// 回跳来源页;否则进用户中心概览。
function onLoginDone() {
const redirect = safeRedirect(new URLSearchParams(window.location.search).get('redirect'));
if (redirect) {
window.location.replace(redirect);
return;
}
setAuthed(true);
setView('overview');
}
const nav: [View, string, string][] = [
['overview', 'layout-dashboard', t('navOverview')],
['sub', 'link', t('navSub')],
['redeem', 'ticket', t('navRedeem')],
['invite', 'users', t('navInvite')],
['settings', 'settings', t('navSettings')],
];
let main: React.ReactNode = null;
if (!me && (view === 'overview')) {
main = <div style={{ fontSize: 14, color: 'var(--fg3)' }}>{t('loading')}</div>;
} else if (view === 'overview' && me) {
main = <Overview t={t} lang={lang} me={me} mobile={mobile} goSub={() => go('sub')} goRedeem={() => go('redeem')} />;
} else if (view === 'sub') {
main = <Subscription t={t} mobile={mobile} />;
} else if (view === 'redeem') {
main = <Redeem t={t} lang={lang} onRedeemed={loadMe} />;
} else if (view === 'invite') {
main = <Invite t={t} />;
} else if (view === 'settings') {
main = <Settings t={t} lang={lang} totpEnabled={me?.totpEnabled ?? false} onTotpChange={loadMe} />;
}
const navBtns = nav.map(([id, ic, l]) => (
<button
key={id}
onClick={() => go(id)}
aria-current={view === id ? 'page' : undefined}
style={{ display: 'inline-flex', alignItems: 'center', gap: 7, border: 'none', cursor: 'pointer', borderRadius: 'var(--radius-md)', padding: '8px 13px', flexShrink: 0, background: view === id ? 'var(--accent-subtle)' : 'transparent', color: view === id ? 'var(--accent)' : 'var(--fg2)', fontFamily: 'var(--font-sans)', fontSize: 13.5, fontWeight: view === id ? 700 : 500, whiteSpace: 'nowrap' }}
>
<Icon name={ic} size={15} color={view === id ? 'var(--accent)' : 'var(--fg3)'} />
{l}
</button>
));
// 应用外壳(顶栏 + 导航 + 内容区);content 由调用方决定 —— 已就绪传真实视图,
// 引导期传加载占位。关键:外壳在「引导中(乐观)」与「已登录」两态都渲染,导航/顶栏
// 始终在位,只有内容区替换 → 刷新时不再"整页重绘",消除"刷两次"的观感。
const appShell = (content: React.ReactNode) => (
<div style={{ minHeight: '100vh', background: 'var(--bg)', fontFamily: 'var(--font-sans)' }}>
{/* top bar */}
<div style={{ position: 'sticky', top: 0, zIndex: 10, background: 'color-mix(in srgb, var(--bg) 85%, transparent)', backdropFilter: 'blur(12px)', borderBottom: '1px solid var(--border)' }}>
<div style={{ maxWidth: 1000, margin: '0 auto', padding: mobile ? '0 16px' : '0 24px', height: mobile ? 54 : 60, display: 'flex', alignItems: 'center', gap: mobile ? 12 : 22 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 9 }}>
<Mark size={26} />
<span style={{ fontFamily: 'var(--font-display)', fontWeight: 700, fontSize: 16.5, color: 'var(--fg1)' }}>{t('brandName')}</span>
</div>
{!mobile && <nav style={{ display: 'flex', gap: 4, flex: 1 }}>{navBtns}</nav>}
{mobile && <div style={{ flex: 1 }} />}
<a href="/" title={t('backHome')} style={{ display: 'inline-flex', alignItems: 'center', gap: 7, textDecoration: 'none', color: 'var(--fg2)', fontSize: 13, fontWeight: 600, padding: 6 }}>
<Icon name="home" size={15} color="var(--fg3)" />
{!mobile && t('backHome')}
</a>
<button onClick={toggleTheme} aria-label="theme" title="theme" style={{ border: 'none', background: 'transparent', cursor: 'pointer', color: 'var(--fg2)', padding: 6, display: 'flex' }}>
<Icon name={theme === 'dark' ? 'sun' : 'moon'} size={17} color="var(--fg3)" />
</button>
<LangSeg lang={lang} setLang={setLang} />
<button onClick={signOut} style={{ display: 'inline-flex', alignItems: 'center', gap: 7, border: 'none', background: 'transparent', cursor: 'pointer', color: 'var(--fg2)', fontSize: 13, fontWeight: 600, padding: 6, whiteSpace: 'nowrap' }}>
<Icon name="log-out" size={15} color="var(--fg3)" />
{!mobile && t('signOut')}
</button>
</div>
{mobile && <nav style={{ display: 'flex', gap: 2, padding: '0 12px 8px', overflowX: 'auto' }}>{navBtns}</nav>}
</div>
<div
onTouchStart={mobile ? onTouchStart : undefined}
onTouchEnd={mobile ? onTouchEnd : undefined}
style={{ maxWidth: 1000, margin: '0 auto', padding: mobile ? '20px 16px 40px' : '30px 24px 48px' }}
>
<div key={view} style={{ animation: dir !== 0 ? `uc-in-${dir === 1 ? 'l' : 'r'} 200ms var(--ease-out)` : 'none' }}>
{content}
</div>
</div>
</div>
);
const spinner = <div style={{ minHeight: '40vh', display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--fg3)', fontSize: 14 }}>{t('loading')}</div>;
// 引导(会话续期)期间一律渲染**应用外壳骨架**(顶栏/导航 + 内容区加载态)。关键:
// · SSR 与客户端首帧渲染同一份外壳(不在 render 期读 localStorage/hasRefresh)→ 无水合
// 不一致,首帧(静态 HTML)即外壳。
// · 引导完成后:已登录→外壳 + 真实视图(**只换内容区**,顶栏/导航原地不动),未登录→登录页。
// 于是刷新时不再"裸屏 spinner → 外壳 → 内容"多段重绘,只有内容区一次替换。
if (!ready) return appShell(spinner);
if (!authed) return <Login onDone={onLoginDone} />;
return appShell(main);
}