'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 } 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(null); const [view, setView] = useState('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 { 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'); } // 静态导出无服务端会话:首屏(!ready)与未登录一律直接渲染登录页,避免出现空白 // 背景(慢网络下用户会看到"空的")。已登录用户(有 refresh)会话续期完成后再切面板。 if (!ready || !authed) { return ; } 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 =
{t('loading')}
; } else if (view === 'overview' && me) { main = go('sub')} goRedeem={() => go('redeem')} />; } else if (view === 'sub') { main = ; } else if (view === 'redeem') { main = ; } else if (view === 'invite') { main = ; } else if (view === 'settings') { main = ; } const navBtns = nav.map(([id, ic, l]) => ( )); return (
{/* top bar */}
{t('brandName')}
{!mobile && } {mobile &&
} {!mobile && t('backHome')}
{mobile && }
{main}
); }