e2646346a6
新建 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>
70 lines
2.1 KiB
TypeScript
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;
|
|
}
|