Files
pangolin/web/usercenter/lib/theme.tsx
T
wangjia e2646346a6 feat(web/usercenter): Next.js 用户中心静态导出 + mock/http 双数据层 (tsk_3FIPC8lSnAfJ)
新建 web/usercenter/:Next.js App Router + output:'export' 纯静态导出,
直接复用 design/ui_kits/usercenter React 源码(概览/订阅/兑换/邀请/设置)。

阶段 A(mock,本提交):
- 复刻五大页面,明/暗 × zh/en 四态;colors_and_type.css 原样链入;
  顶栏主题切换 + 语言段控,移动端底部 Tab + 左右滑动切换。
- 设置页新增:偏好(语言/主题) + 设备管理(列表/移除/二次确认+刷新) +
  TOTP 2FA(绑定二维码占位+密钥/验证/解禁),登录二段式 TOTP。
- 数据层 lib/api:ApiClient 抽象 + MockClient/HttpClient 双实现,构建期
  NEXT_PUBLIC_API_MODE 切换;统一错误体 {code,message_zh,message_en} → 双语映射。
- 会话:access token 仅内存,refresh header token + localStorage,静默续期,
  登出失效(取舍:静态导出无服务端 cookie 能力,详见 README)。
- 安全:构建期注入 SRI(sha384);_headers 严格 CSP + 安全基线;
  红线词扫描(铁律13) CI 红线,零命中。

阶段 B(占位待联调):HttpClient 已写好域名池+退避重试+401 续期;
TOTP/登录二段式端点占位待 #1 契约增补;me/redeem/devices 切真实链路依赖 #2/#3/#4。

验证:npm run lint / redline / build 均通过,静态产物 out/ 无服务端依赖。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 15:23:18 +08:00

70 lines
2.1 KiB
TypeScript

'use client';
// theme.tsx — 语言 + 明暗主题上下文(localStorage 持久化,SSR 安全)
import React, { createContext, useCallback, useContext, useEffect, useState } from 'react';
import type { Lang } from './i18n';
type Theme = 'light' | 'dark';
interface Ctx {
lang: Lang;
setLang: (l: Lang) => void;
theme: Theme;
setTheme: (t: Theme) => void;
toggleTheme: () => void;
}
const ThemeCtx = createContext<Ctx | null>(null);
const LANG_KEY = 'pg_uc_lang';
const THEME_KEY = 'pg_uc_theme';
export function UIProvider({ children }: { children: React.ReactNode }) {
const [lang, setLangState] = useState<Lang>('zh');
const [theme, setThemeState] = useState<Theme>('light');
// 挂载后读取持久化(避免 hydration 不一致)
useEffect(() => {
try {
const l = window.localStorage.getItem(LANG_KEY) as Lang | null;
const t = window.localStorage.getItem(THEME_KEY) as Theme | null;
if (l === 'zh' || l === 'en') setLangState(l);
const initial: Theme = t === 'dark' || t === 'light' ? t : window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
setThemeState(initial);
} catch {
/* ignore */
}
}, []);
useEffect(() => {
document.documentElement.setAttribute('data-theme', theme);
document.documentElement.setAttribute('lang', lang === 'zh' ? 'zh' : 'en');
}, [theme, lang]);
const setLang = useCallback((l: Lang) => {
setLangState(l);
try {
window.localStorage.setItem(LANG_KEY, l);
} catch {
/* ignore */
}
}, []);
const setTheme = useCallback((t: Theme) => {
setThemeState(t);
try {
window.localStorage.setItem(THEME_KEY, t);
} catch {
/* ignore */
}
}, []);
const toggleTheme = useCallback(() => setTheme(theme === 'dark' ? 'light' : 'dark'), [theme, setTheme]);
return <ThemeCtx.Provider value={{ lang, setLang, theme, setTheme, toggleTheme }}>{children}</ThemeCtx.Provider>;
}
export function useUI(): Ctx {
const c = useContext(ThemeCtx);
if (!c) throw new Error('useUI must be used within UIProvider');
return c;
}