feat(web): 5 项 UI 修复 — 统一浅色/登录回跳/logo locale/用户名下拉/Docs 真页
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
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>
This commit is contained in:
@@ -75,7 +75,7 @@ export default function Login({ onDone }: { onDone: () => void }) {
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<Mark size={32} />
|
||||
<div>
|
||||
<div style={{ fontFamily: 'var(--font-display)', fontWeight: 700, fontSize: 17, color: 'var(--fg1)', lineHeight: 1 }}>穿山甲</div>
|
||||
<div style={{ fontFamily: 'var(--font-display)', fontWeight: 700, fontSize: 17, color: 'var(--fg1)', lineHeight: 1 }}>{t('brandName')}</div>
|
||||
<div style={{ fontSize: 8.5, fontWeight: 600, letterSpacing: '0.2em', color: 'var(--accent)', marginTop: 3 }}>PANGOLIN</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -18,7 +18,7 @@ export default function Subscription({ t, mobile }: { t: TFn; mobile: boolean })
|
||||
}, [api]);
|
||||
|
||||
const clients = [
|
||||
{ name: '穿山甲 App', sub: 'iOS / Android / 桌面', icon: 'shield-check', accent: true },
|
||||
{ name: `${t('brandName')} App`, sub: 'iOS / Android / 桌面', icon: 'shield-check', accent: true },
|
||||
{ name: 'Shadowrocket', sub: 'iOS', icon: 'external-link' },
|
||||
{ name: 'Clash Verge', sub: 'Windows / macOS', icon: 'external-link' },
|
||||
{ name: 'v2rayN', sub: 'Windows', icon: 'external-link' },
|
||||
|
||||
@@ -18,6 +18,14 @@ 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(() => {
|
||||
@@ -100,10 +108,22 @@ export default function UserCenter() {
|
||||
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={() => { setAuthed(true); setView('overview'); }} />;
|
||||
return <Login onDone={onLoginDone} />;
|
||||
}
|
||||
|
||||
const nav: [View, string, string][] = [
|
||||
@@ -148,7 +168,7 @@ export default function UserCenter() {
|
||||
<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)' }}>穿山甲</span>
|
||||
<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 }} />}
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
clearSession,
|
||||
getAccessToken,
|
||||
getRefreshToken,
|
||||
setEmail,
|
||||
setSession,
|
||||
} from './session';
|
||||
|
||||
@@ -171,7 +172,11 @@ export class HttpClient implements ApiClient {
|
||||
return session;
|
||||
}
|
||||
|
||||
getMe = async (): Promise<Me> => mapMe(await this.request<RawMe>('/v1/me'));
|
||||
getMe = async (): Promise<Me> => {
|
||||
const me = mapMe(await this.request<RawMe>('/v1/me'));
|
||||
setEmail(me.email); // 同源官网读取显示用户名;clearSession/logout 时删除
|
||||
return me;
|
||||
};
|
||||
getSubscription = () => this.request<SubscriptionInfo>('/v1/me/subscription');
|
||||
resetSubscription = () => this.request<SubscriptionInfo>('/v1/me/subscription/reset', { method: 'POST' });
|
||||
listDevices = async (): Promise<Device[]> => {
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
SubscriptionInfo,
|
||||
TotpSetup,
|
||||
} from './types';
|
||||
import { setSession, clearSession } from './session';
|
||||
import { setSession, clearSession, setEmail } from './session';
|
||||
|
||||
const delay = (ms = 420) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
@@ -88,6 +88,7 @@ export class MockClient implements ApiClient {
|
||||
|
||||
async getMe(): Promise<Me> {
|
||||
await delay(260);
|
||||
setEmail('me@pangolin.vpn'); // 同源官网读取显示用户名;clearSession/logout 时删除
|
||||
return {
|
||||
email: 'me@pangolin.vpn',
|
||||
plan: 'pro',
|
||||
|
||||
@@ -5,6 +5,17 @@
|
||||
import type { Session } from './types';
|
||||
|
||||
const REFRESH_KEY = 'pg_uc_refresh';
|
||||
// 登录用户邮箱:同源官网(pangolin website)读取以显示用户名。仅邮箱、非敏感凭证。
|
||||
const EMAIL_KEY = 'pg_uc_email';
|
||||
|
||||
export function setEmail(email: string): void {
|
||||
if (typeof window === 'undefined' || !email) return;
|
||||
try {
|
||||
window.localStorage.setItem(EMAIL_KEY, email);
|
||||
} catch {
|
||||
/* ignore quota / privacy mode */
|
||||
}
|
||||
}
|
||||
|
||||
let accessToken: string | null = null;
|
||||
let accessExpiresAt = 0;
|
||||
@@ -44,6 +55,7 @@ export function clearSession(): void {
|
||||
if (typeof window !== 'undefined') {
|
||||
try {
|
||||
window.localStorage.removeItem(REFRESH_KEY);
|
||||
window.localStorage.removeItem(EMAIL_KEY);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ export const STRINGS: Record<string, Entry> = {
|
||||
navSettings: { zh: '设置', en: 'Settings', ja: '設定', ko: '설정', ru: 'Настройки', es: 'Ajustes' },
|
||||
signOut: { zh: '退出', en: 'Sign out', ja: 'ログアウト', ko: '로그아웃', ru: 'Выйти', es: 'Cerrar sesión' },
|
||||
backHome: { zh: '返回主页', en: 'Home', ja: 'ホーム', ko: '홈', ru: 'На главную', es: 'Inicio' },
|
||||
brandName: { zh: '穿山甲', en: 'Pangolin', ja: 'Pangolin', ko: 'Pangolin', ru: 'Pangolin', es: 'Pangolin' },
|
||||
|
||||
/* login */
|
||||
loginTitle: { zh: '登录用户中心', en: 'Log in to your account', ja: 'アカウントにログイン', ko: '계정에 로그인', ru: 'Вход в аккаунт', es: 'Inicia sesión en tu cuenta' },
|
||||
|
||||
@@ -27,7 +27,9 @@ export function UIProvider({ children }: { children: React.ReactNode }) {
|
||||
const l = window.localStorage.getItem(LANG_KEY) as Lang | null;
|
||||
const t = window.localStorage.getItem(THEME_KEY) as Theme | null;
|
||||
if (l && (['zh', 'en', 'ja', 'ko', 'ru', 'es'] as Lang[]).includes(l)) setLangState(l);
|
||||
const initial: Theme = t === 'dark' || t === 'light' ? t : window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
// 默认恒浅色:只有用户手动切换过(localStorage 有显式偏好)才用保存值,
|
||||
// 不跟随系统 prefers-color-scheme(避免系统暗色把登录页/用户中心染黑)。
|
||||
const initial: Theme = t === 'dark' || t === 'light' ? t : 'light';
|
||||
setThemeState(initial);
|
||||
} catch {
|
||||
/* ignore */
|
||||
|
||||
Reference in New Issue
Block a user