5b89de656e
ci-pangolin / Redline Scan — 脱敏 (UI 文案) (pull_request) Successful in 25s
ci-pangolin / Codegen Drift — token 生成物未漂移 (pull_request) Successful in 7s
ci-pangolin / Cleartext Scan — Android 禁明文 (pull_request) Successful in 26s
ci-pangolin / DS-flow — 原型/跨端同源/代码色单源闸 (pull_request) Successful in 11s
ci-pangolin / Go — build + test (pull_request) Successful in 15s
ci-pangolin / Portable SQL — 可移植性 (mysql/sqlite) (pull_request) Successful in 26s
ci-pangolin / E2E Smoke — L4 进程级端到端 (pull_request) Successful in 10s
ci-pangolin / Go — integration (mysql/redis testcontainers) (pull_request) Successful in 4m36s
ci-pangolin / Golden — 视觉回归 (全量:components/auth/desktop/tablet) (pull_request) Successful in 20s
ci-pangolin / Lint — shellcheck (pull_request) Failing after 10m3s
Deploy Site / deploy-site (push) Failing after 13m20s
ci-pangolin / Flutter — analyze + test (pull_request) Failing after 14m42s
ci-pangolin / OpenAPI Sync Check (pull_request) Failing after 14m52s
用户中心: - 统一浅色:theme.tsx 无偏好时不再跟随系统 prefers-color-scheme:dark(登录页变 黑真因),恒浅色;保留手动切换。 - 登录回跳:UserCenter 加 safeRedirect 白名单,登录成功按 ?redirect= 回来源页 (官网带 /,登录后回主页),无则进 overview。 - logo locale:新增 i18n brandName(zh 穿山甲/其余 Pangolin),Login/UserCenter /Subscription 三处引用。 - 存 pg_uc_email(getMe 时)/ clearSession 删,供官网读用户名。 官网(全走 CSS class 合 CSP,无内联 style): - logo locale:i18n nav.brand(zh 穿山甲/其余 Pangolin),Header+Footer。 - 登录态头部显示用户名(读同源 pg_uc_email)+ 下拉菜单(进入用户中心/切换用户/ 退出登录,复用 .langmenu 风格);未登录 Log in 带 ?redirect=/ 回跳。 - Docs 四卡片补真内容页(en+zh:quickstart/faq/protocol/privacy + Doc.astro 布局),卡片改回 <a href>;Protocol 改正 sing-box+REALITY(去 WireGuard)。 验证:同源闸绿 · 两端 build 过 · redline 0 · Header 零内联 style · 无 WireGuard。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
202 lines
8.2 KiB
TypeScript
202 lines
8.2 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 } 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 {
|
|
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 <Login onDone={onLoginDone} />;
|
|
}
|
|
|
|
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>
|
|
));
|
|
|
|
return (
|
|
<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 }}>
|
|
<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' }}>
|
|
{main}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|