Compare commits

...

12 Commits

Author SHA1 Message Date
wangjia 9a8fec2e9c fix(usercenter): 加 no-FOUC 内联脚本,消除刷新时明暗主题整页配色闪烁
layout 之前把 data-theme 硬编码 light、挂载后才由 UIProvider effect 应用保存的主题,
暗色偏好用户每次刷新先见浅色再翻暗色 = 整页配色重绘(叠加内容加载,观感"刷两次")。

照官网 Site.astro 做法:<head> 内联脚本在首屏渲染前读 localStorage(pg_uc_theme/
pg_uc_lang)设好 data-theme 与 html lang。/user/* CSP 允许 unsafe-inline 脚本,直接内联;
脚本为编译期常量、无插值、只读 localStorage,无 XSS 风险。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 15:04:23 +08:00
wangjia 101b31e073 fix(usercenter): 消除刷新"重绘两次" —— 引导期直接渲染外壳骨架,去掉多余首帧
上一版为防水合不一致加了 !mounted 中性首帧,反而制造了额外一段:一次 Cmd+R 走
裸 spinner(mounted=false)→ 应用外壳(乐观)→ 内容区(overview),三段重绘 =
用户看到的"刷新两次(先主显示区、后整页)"。

改:去掉 mounted gate;引导期(!ready)一律渲染 appShell(spinner)——外壳骨架
(顶栏/导航就位、内容区加载态)。因 render 期不读 localStorage/hasRefresh,SSR 与
客户端首帧一致、无水合不一致,静态 HTML 首帧即外壳。引导完成后只把内容区从 spinner
换成真实视图,顶栏/导航原地不动 → 刷新只有内容区一次替换,不再整页重绘。

代价:未登录用户刷新 /user/ 会先见外壳骨架再落登录页(极短、且随即要登录),
可接受;已登录用户(刷新的绝大多数场景)全程外壳稳定、无闪。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 15:01:29 +08:00
wangjia 11ad888ee7 fix(usercenter): 刷新页面即被登出 —— refresh 改用请求体传 token(修致命鉴权 bug)
根因:服务端 POST /v1/auth/refresh 从**请求体** {refresh_token} 读取(handler.go
Refresh + 原生 Flutter auth_api.dart 均如此),但 web 客户端 refresh() 误把 refresh
token 放进 X-Refresh-Token **请求头**(只有 /auth/logout 读该头)。服务端拿到空 body
→ 400 auth.invalid_request → 客户端 catch → clearSession → **每次刷新页面即被登出**。

线上实测证实:POST 带头无体 → invalid_request(空);带体 {refresh_token} → invalid_token
(正常解析)。故服务端读体、web 发头 = 必然掉线。

改:web refresh() 改用 body:{ refresh_token } 传参(与原生客户端、服务端一致);
无需改/重部署服务端。logout 仍走 X-Refresh-Token 头(服务端 logout 读头,匹配)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 14:52:21 +08:00
wangjia 53d8ded7f6 fix(usercenter): 刷新 /user/ 不再整页重绘(引导期乐观渲染外壳)
上一版把首帧从登录页改成居中 Loading,消除了闪登录页,但用户仍感刷两次:
静态导出首帧无法知登录态 → 先渲染裸 Loading(无顶栏/导航)→ 再出现完整外壳
→ 再拉 /me 填内容。顶栏在裸 Loading 到外壳间消失再出现,观感=整页重绘两次。

改:抽出可复用 appShell(content);mount 后引导仍进行(!ready)时,若本地有 refresh
token,乐观渲染完整外壳(顶栏/导航就位,仅内容区转圈),没 token 才直接登录页。
于是顶栏/导航从乐观态到已登录态始终在位,只有内容区替换 → 不再整页重绘。

SSR 安全:!mounted(SSR + 首次客户端渲染)仍渲染中性 spinner(不读 localStorage),
与服务端 HTML 一致防水合不一致;mount 后才据 hasRefresh() 决定外壳/登录页。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 14:43:24 +08:00
wangjia e3e1d43d5d fix(usercenter): 刷新 /user/ 不再先闪登录页(引导期一律加载态)
根因:静态导出 SSR 首屏读不到 localStorage(hasRefresh 服务端恒 false),旧守卫
!ready&&hasRefresh() 在此时为 false → 首屏渲染成登录页 → 刷新 /user/ 浏览器先显示
登录页 HTML,再水合+续期跳回面板(闪现);且 render 期调 hasRefresh 造成 SSR/客户端
不一致加剧闪现。

修:引导未完成(!ready)一律显示加载态(不读 localStorage、不渲染登录页),SSR 与
客户端首屏一致;引导完再定:已登录→面板、未登录/失败→登录页。刷新不再闪登录页。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 14:28:17 +08:00
wangjia 09b1944376 fix(usercenter): 续期失败时清本地会话,修主页↔登录页来回刷
根因:引导时 api.refresh() 失败(refresh token 过期/被撤销/Redis 丢 JTI 白名单)
只 setAuthed(false),没清 localStorage 的 pg_uc_refresh/pg_uc_email → 官网仍据此
显示'用户中心' → 点进 /user/ 又续期失败回登录页 → 来回刷。

修:catch 里加 clearSession() 清掉残留 token,官网随即回到'Log in',状态一致、
不再来回刷。(登录态正常保持 30 天=refresh token TTL;失败多因会话真失效。)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 14:23:35 +08:00
wangjia e0912dd819 fix(website): 定价卡片 3 个按钮补链接(原纯 button 无响应)
PricingPlans.jsx 的 pcta 是纯 <button> 无 onClick/href → 点击无反应。改为
<a href>:Free「Download free」→ #download 下载区;Pro/Team「Get a code」→
#get-code(给渠道区 .pay-note 加 id,滚到购码渠道:自助店/USDT/Telegram/LINE/
邮箱)。.pcta 补 display:block/text-align:center/text-decoration:none 让 <a>
渲染同按钮。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 13:09:29 +08:00
wangjia f4c5f93370 fix(website): 主题切换按钮缩小,与用户中心一致(透明底/无框/17px 图标)
原 38×38 圆框 + surface 底太大;改为透明底、无边框、padding 6、图标 17px,
和用户中心顶栏主题按钮同款尺寸。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 12:13:01 +08:00
wangjia 6dcf582225 feat(website): 官网加明/暗主题切换(默认浅色,无 FOUC)
- Header 加 Moon/Sun 切换按钮(.themetoggle class,走语义 token,无内联 style),
  切 document.documentElement.dataset.theme + 存 localStorage pg_site_theme,
  挂载读回同步;移动端也能切。
- Site.astro + Doc.astro <head> 加 <script is:inline> 无 FOUC 初始化(首屏前读
  localStorage 设 data-theme;postbuild csp-hashes 已加 script hash 放行)。
- 暗色机制:tokens.gen.css 已含 [data-theme=dark] 块,官网 127 处语义 token 自动
  适配;23 处 light 专属原色(公告条/CTA/主推套餐卡 clay 品牌面 + 恒暗页脚)均
  有意固定,保留。
- 审计:所有元素走唯一真相源(check-ds/check-l1-sync/check_ds_code 三闸全绿)。

验证:check-l1-sync ✓ · build ✓(CSP hash 覆盖 FOUC 脚本)· redline 0 · Header
零内联 style。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 11:58:35 +08:00
wangjia 9ed7588ec0 fix(usercenter): 删冗余 Preferences 卡片 + 修登录态进用户中心闪登录页
1. Settings 页删掉 Preferences 卡片(语言/主题切换)——顶栏已有语言下拉 + 主题
   切换按钮,重复。连带删只被它用的 Preferences/Row/Seg 函数 + useUI 导入。
2. 修 bug:从官网登录态点"用户中心"进 /user/ 会先闪登录页再跳面板。根因:渲染
   守卫在 !ready(会话续期中)时也直接渲染 Login。改为:本地有 refresh token 且
   续期未完成时显示加载态(非登录页),续期成功→面板、失败/无 token→登录页。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 11:22:11 +08:00
wangjia 87647cae34 fix(usercenter): Subscription/Redeem/Referral/Settings 布局宽度对齐 Overview
这 4 页根布局各自设了更窄的 maxWidth(720/680),而 Overview 无 maxWidth 填满
1000px 居中容器 → 这几页内容贴左、右侧留白、与 Overview 不一致。去掉各自的
maxWidth,统一填满主容器(maxWidth:1000 margin:0 auto),宽度与 Overview 一致、
居中对齐。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 11:13:31 +08:00
wangjia 620d8148f3 fix(usercenter/overview): 4 处格式调整
1. 顶栏 Sign out 按钮加 whiteSpace:nowrap,不再折成两行。
2. 近 7 日柱状图数字保留 2 位有效数字(Number(v.toPrecision(2))),不再显示
   9.285392755642533 这种长数。
3. 英文星期标签 M/T/W/T/F/S/S → Mon/Tue/Wed/Thu/Fri/Sat/Sun(消歧义;中文
   一~日 本就清晰不改)。
4. 图表卡与 Quick actions 卡 alignItems start→stretch,两卡等高、上下对齐。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 11:09:51 +08:00
14 changed files with 86 additions and 75 deletions
+9
View File
@@ -20,6 +20,15 @@ export default function RootLayout({ children }: { children: React.ReactNode })
return (
<html lang="en" data-theme="light" suppressHydrationWarning>
<head>
{/* 首屏渲染前据 localStorage 设好 data-theme/lang,避免明暗主题(整页配色)在
挂载后才应用造成的 FOUC 闪烁。/user/* CSP 允许 'unsafe-inline' 脚本,故直接内联。
与官网 Site.astro 的 no-FOUC 脚本同法(键名用用户中心的 pg_uc_theme/pg_uc_lang)。 */}
<script
dangerouslySetInnerHTML={{
__html:
"(function(){try{var t=localStorage.getItem('pg_uc_theme');if(t==='dark'||t==='light')document.documentElement.dataset.theme=t;var l=localStorage.getItem('pg_uc_lang');if(l)document.documentElement.lang=l;}catch(e){}})()",
}}
/>
{/* 设计令牌单一真相源,原样链入(SRI 由构建期注入) */}
{/* eslint-disable-next-line @next/next/no-css-tags */}
<link rel="stylesheet" href={`${BASE_PATH}/colors_and_type.css`} />
+1 -1
View File
@@ -25,7 +25,7 @@ export default function Invite({ t }: { t: TFn }) {
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 18, maxWidth: 680 }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 18 }}>
<div>
<div style={{ fontFamily: 'var(--font-display)', fontSize: 25, fontWeight: 700, color: 'var(--fg1)' }}>{t('inviteTitle')}</div>
<div style={{ fontSize: 13, color: 'var(--fg3)', marginTop: 5, lineHeight: 1.6 }}>{t('inviteSub')}</div>
+3 -3
View File
@@ -24,7 +24,7 @@ export default function Overview({
const free = me.plan === 'free';
const vals = me.weeklyGB;
const max = Math.max(...vals, 0.1);
const labels = lang === 'zh' ? ['一', '二', '三', '四', '五', '六', '日'] : ['M', 'T', 'W', 'T', 'F', 'S', 'S'];
const labels = lang === 'zh' ? ['一', '二', '三', '四', '五', '六', '日'] : ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
const stats: [string, string, string, string][] = [
['clock', t('quotaToday'), free ? String(me.quotaTodayMin ?? 0) : '∞', free ? 'min' : ''],
@@ -76,13 +76,13 @@ export default function Overview({
</div>
{/* usage chart + quick actions */}
<div style={{ display: 'grid', gridTemplateColumns: mobile ? '1fr' : '1.6fr 1fr', gap: 14, alignItems: 'start' }}>
<div style={{ display: 'grid', gridTemplateColumns: mobile ? '1fr' : '1.6fr 1fr', gap: 14, alignItems: 'stretch' }}>
<div style={{ ...card, padding: '18px 22px' }}>
<div style={{ fontSize: 13.5, fontWeight: 600, color: 'var(--fg1)', marginBottom: 16 }}>{t('usageTitle')}</div>
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 14, height: 110 }}>
{vals.map((v, i) => (
<div key={i} style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 6 }}>
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 9.5, color: 'var(--fg3)' }}>{v}</div>
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 9.5, color: 'var(--fg3)' }}>{Number(v.toPrecision(2))}</div>
<div style={{ width: '100%', maxWidth: 30, height: `${(v / max) * 76}px`, background: 'var(--accent)', borderRadius: '5px 5px 0 0', opacity: 0.85 }} />
<div style={{ fontSize: 10.5, color: 'var(--fg3)' }}>{labels[i]}</div>
</div>
+1 -1
View File
@@ -39,7 +39,7 @@ export default function Redeem({ t, lang, onRedeemed }: { t: TFn; lang: Lang; on
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 18, maxWidth: 680 }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 18 }}>
<div style={{ fontFamily: 'var(--font-display)', fontSize: 25, fontWeight: 700, color: 'var(--fg1)' }}>{t('navRedeem')}</div>
<div style={{ ...card, padding: '20px 22px' }}>
<div style={{ fontSize: 14.5, fontWeight: 700, color: 'var(--fg1)', marginBottom: 12 }}>{t('redeemTitle')}</div>
+1 -54
View File
@@ -5,7 +5,6 @@ import React, { useEffect, useState } from 'react';
import { Icon } from './icons';
import { card, input } from './shared';
import { ErrorLine } from './Login';
import { useUI } from '../lib/theme';
import type { TFn, Lang } from '../lib/i18n';
import { getClient } from '../lib/api/client';
import { bilingual } from '../lib/api/errors';
@@ -13,9 +12,8 @@ import type { Device, TotpSetup } from '../lib/api/types';
export default function Settings({ t, lang, totpEnabled, onTotpChange }: { t: TFn; lang: Lang; totpEnabled: boolean; onTotpChange: () => void }) {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 18, maxWidth: 680 }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 18 }}>
<div style={{ fontFamily: 'var(--font-display)', fontSize: 25, fontWeight: 700, color: 'var(--fg1)' }}>{t('settingsTitle')}</div>
<Preferences t={t} />
<TotpSection t={t} lang={lang} enabled={totpEnabled} onChange={onTotpChange} />
<Devices t={t} lang={lang} />
</div>
@@ -24,57 +22,6 @@ export default function Settings({ t, lang, totpEnabled, onTotpChange }: { t: TF
const sectionTitle: React.CSSProperties = { fontSize: 14.5, fontWeight: 700, color: 'var(--fg1)' };
function Preferences({ t }: { t: TFn }) {
const { lang, setLang, theme, setTheme } = useUI();
return (
<div style={{ ...card, padding: '18px 20px', display: 'flex', flexDirection: 'column', gap: 16 }}>
<div style={sectionTitle}>{t('prefTitle')}</div>
<Row label={t('prefLang')}>
<Seg
value={lang}
options={[['zh', '中文'], ['en', 'EN']]}
onPick={(v) => setLang(v as Lang)}
/>
</Row>
<Row label={t('prefTheme')}>
<Seg
value={theme}
options={[['light', t('themeLight')], ['dark', t('themeDark')]]}
icons={{ light: 'sun', dark: 'moon' }}
onPick={(v) => setTheme(v as 'light' | 'dark')}
/>
</Row>
</div>
);
}
function Row({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
<span style={{ fontSize: 13.5, fontWeight: 600, color: 'var(--fg2)' }}>{label}</span>
{children}
</div>
);
}
function Seg({ value, options, onPick, icons }: { value: string; options: [string, string][]; onPick: (v: string) => void; icons?: Record<string, string> }) {
return (
<div style={{ display: 'flex', background: 'var(--bg-subtle)', borderRadius: 999, padding: 3, gap: 2 }}>
{options.map(([v, l]) => (
<button
key={v}
onClick={() => onPick(v)}
aria-pressed={value === v}
style={{ display: 'inline-flex', alignItems: 'center', gap: 6, border: 'none', cursor: 'pointer', borderRadius: 999, padding: '6px 14px', fontFamily: 'var(--font-sans)', fontSize: 12.5, fontWeight: 700, background: value === v ? 'var(--accent)' : 'transparent', color: value === v ? 'var(--fg-on-accent)' : 'var(--fg3)' }}
>
{icons && <Icon name={icons[v]} size={14} color={value === v ? 'var(--fg-on-accent)' : 'var(--fg3)'} />}
{l}
</button>
))}
</div>
);
}
/* ───────── TOTP 2FA ───────── */
function TotpSection({ t, lang, enabled, onChange }: { t: TFn; lang: Lang; enabled: boolean; onChange: () => void }) {
const api = getClient();
+1 -1
View File
@@ -48,7 +48,7 @@ export default function Subscription({ t, mobile }: { t: TFn; mobile: boolean })
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 18, maxWidth: 720 }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 18 }}>
<div>
<div style={{ fontFamily: 'var(--font-display)', fontSize: 25, fontWeight: 700, color: 'var(--fg1)' }}>{t('subTitle')}</div>
<div style={{ fontSize: 13, color: 'var(--fg3)', marginTop: 5, lineHeight: 1.6 }}>{t('subDesc')}</div>
+22 -10
View File
@@ -12,7 +12,7 @@ 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 { hasRefresh, clearSession } from '../lib/api/session';
import type { Me } from '../lib/api/types';
type View = 'overview' | 'sub' | 'redeem' | 'invite' | 'settings';
@@ -65,6 +65,10 @@ export default function UserCenter() {
await api.refresh();
if (alive) setAuthed(true);
} catch {
// 续期失败(refresh token 过期/被撤销/Redis 丢 JTI):清掉本地会话,
// 否则 pg_uc_refresh 残留 → 官网仍显示"用户中心" → 点进来又续期失败 →
// 来回刷登录页。清了官网会回到"Log in",状态一致。
clearSession();
if (alive) setAuthed(false);
}
}
@@ -120,12 +124,6 @@ export default function UserCenter() {
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')],
@@ -161,7 +159,10 @@ export default function UserCenter() {
</button>
));
return (
// 应用外壳(顶栏 + 导航 + 内容区);content 由调用方决定 —— 已就绪传真实视图,
// 引导期传加载占位。关键:外壳在「引导中(乐观)」与「已登录」两态都渲染,导航/顶栏
// 始终在位,只有内容区替换 → 刷新时不再"整页重绘",消除"刷两次"的观感。
const appShell = (content: React.ReactNode) => (
<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)' }}>
@@ -180,7 +181,7 @@ export default function UserCenter() {
<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 }}>
<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, whiteSpace: 'nowrap' }}>
<Icon name="log-out" size={15} color="var(--fg3)" />
{!mobile && t('signOut')}
</button>
@@ -193,9 +194,20 @@ export default function UserCenter() {
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}
{content}
</div>
</div>
</div>
);
const spinner = <div style={{ minHeight: '40vh', display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--fg3)', fontSize: 14 }}>{t('loading')}</div>;
// 引导(会话续期)期间一律渲染**应用外壳骨架**(顶栏/导航 + 内容区加载态)。关键:
// · SSR 与客户端首帧渲染同一份外壳(不在 render 期读 localStorage/hasRefresh)→ 无水合
// 不一致,首帧(静态 HTML)即外壳。
// · 引导完成后:已登录→外壳 + 真实视图(**只换内容区**,顶栏/导航原地不动),未登录→登录页。
// 于是刷新时不再"裸屏 spinner → 外壳 → 内容"多段重绘,只有内容区一次替换。
if (!ready) return appShell(spinner);
if (!authed) return <Login onDone={onLoginDone} />;
return appShell(main);
}
+4 -1
View File
@@ -161,11 +161,14 @@ export class HttpClient implements ApiClient {
async refresh(): Promise<Session> {
const rt = getRefreshToken();
if (!rt) throw new ApiError({ code: 'unauthorized', message_zh: '登录已失效', message_en: 'Session expired' });
// 服务端 /v1/auth/refresh 从 **请求体** {refresh_token} 读取(与原生 Flutter 客户端
// auth_api.dart 一致);此处曾误用 X-Refresh-Token 头(仅 /auth/logout 读头),导致
// 服务端拿到空 token → 400 invalid_request → 每次刷新页面即被登出。改回 body。
const r = await this.request<RawTokenPair>('/v1/auth/refresh', {
method: 'POST',
auth: false,
allowRefresh: false,
refreshToken: rt,
body: { refresh_token: rt },
});
const session = mapSession(r);
setSession(session);
+29 -1
View File
@@ -4,7 +4,7 @@
* 语言切换由原型的 JS 文本替换改为「路由跳转」(zh=/, en=/en/),单显不并排(铁律 6)。
*/
import { useEffect, useRef, useState } from 'react';
import { Download, Menu } from 'lucide-react';
import { Download, Menu, Sun, Moon } from 'lucide-react';
import { SITE } from '../config/site';
function Mark() {
@@ -33,6 +33,8 @@ export default function Header({ lang = 'zh', t = {} }) {
const [userOpen, setUserOpen] = useState(false);
const [loggedIn, setLoggedIn] = useState(false);
const [email, setEmail] = useState('');
// 明/暗主题:默认浅色(不跟随系统),持久化 localStorage key pg_site_theme。
const [theme, setTheme] = useState('light');
const langRef = useRef(null);
const userRef = useRef(null);
@@ -61,6 +63,24 @@ export default function Header({ lang = 'zh', t = {} }) {
const onSwitch = () => { clearSession(); window.location.href = loginHref; };
const onLogout = () => { clearSession(); window.location.href = '/'; };
// 挂载时读 localStorage 同步按钮态(首屏无 FOUC 脚本已在 <head> 设好 data-theme)。
useEffect(() => {
try {
const saved = localStorage.getItem('pg_site_theme');
setTheme(saved === 'dark' ? 'dark' : 'light');
} catch { /* ignore */ }
}, []);
// 切换主题:写 <html data-theme> + localStorage,图标随态变。
const toggleTheme = () => {
setTheme((prev) => {
const next = prev === 'dark' ? 'light' : 'dark';
try { localStorage.setItem('pg_site_theme', next); } catch { /* ignore */ }
document.documentElement.dataset.theme = next;
return next;
});
};
useEffect(() => {
if (!langOpen) return;
const onDoc = (e) => { if (langRef.current && !langRef.current.contains(e.target)) setLangOpen(false); };
@@ -113,6 +133,14 @@ export default function Header({ lang = 'zh', t = {} }) {
))}
</nav>
<div class="right">
<button
type="button"
class="themetoggle"
onClick={toggleTheme}
aria-label={theme === 'dark' ? 'Switch to light theme' : 'Switch to dark theme'}
>
{theme === 'dark' ? <Sun /> : <Moon />}
</button>
<div ref={langRef} class="langwrap">
<button
type="button"
+4 -1
View File
@@ -23,6 +23,7 @@ const plansData = {
feats: [t('pf.free1'), t('pf.free2'), t('pf.free3'), t('pf.free4')],
cta: t('price.cta_free'),
ctaClass: 'pcta-out',
href: '#download',
highlight: false,
},
{
@@ -33,6 +34,7 @@ const plansData = {
feats: [t('pf.pro1'), t('pf.pro2'), t('pf.pro3'), t('pf.pro4'), t('pf.pro5')],
cta: t('price.cta_pro'),
ctaClass: 'pcta-white',
href: '#get-code',
highlight: true,
},
{
@@ -43,6 +45,7 @@ const plansData = {
feats: [t('pf.team1'), t('pf.team2'), t('pf.team3'), t('pf.team4')],
cta: t('price.cta_team'),
ctaClass: 'pcta-fill',
href: '#get-code',
highlight: false,
},
],
@@ -75,7 +78,7 @@ function cell(v: string) {
<div class="wrap">
<!-- payment note -->
<div class="pay-note">
<div class="pay-note" id="get-code">
<Icon name="shield-check" />
<div>
<b>{t('pay.title')}</b><br>
+1 -1
View File
@@ -50,7 +50,7 @@ export default function PricingPlans({ data }) {
</li>
))}
</ul>
<button class={`pcta ${p.ctaClass}`}>{p.cta}</button>
<a href={p.href || '#get-code'} class={`pcta ${p.ctaClass}`}>{p.cta}</a>
</div>
))}
</div>
+2
View File
@@ -57,6 +57,8 @@ const metaTitle = `${title} · ${t('nav.brand')}`;
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
{/* 首屏渲染前设好 data-theme,避免明/暗切换闪烁(FOUC)。postbuild 会给此内联脚本加 CSP hash。 */}
<script is:inline>(function(){try{var t=localStorage.getItem('pg_site_theme');if(t==='dark'||t==='light')document.documentElement.dataset.theme=t;}catch(e){}})()</script>
<title>{metaTitle}</title>
{desc && <meta name="description" content={desc} />}
<meta name="robots" content="index,follow" />
+2
View File
@@ -70,6 +70,8 @@ const headerT = {
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
{/* 首屏渲染前设好 data-theme,避免明/暗切换闪烁(FOUC)。postbuild 会给此内联脚本加 CSP hash。 */}
<script is:inline>(function(){try{var t=localStorage.getItem('pg_site_theme');if(t==='dark'||t==='light')document.documentElement.dataset.theme=t;}catch(e){}})()</script>
<title>{t('meta.title')}</title>
<meta name="description" content={t('meta.desc')} />
<meta name="robots" content="index,follow" />
+6 -1
View File
@@ -42,6 +42,11 @@ img,svg{display:block}
.linklogin{font-size:14.5px;font-weight:600;color:var(--fg1);cursor:pointer}
.linklogin:hover{color:var(--accent)}
/* 明/暗主题切换(顶栏图标按钮,随 token 自适配) */
.themetoggle{display:inline-flex;align-items:center;justify-content:center;flex-shrink:0;border:none;background:transparent;color:var(--fg2);cursor:pointer;padding:6px;transition:color var(--dur-fast) var(--ease-out)}
.themetoggle:hover{color:var(--accent)}
.themetoggle svg{width:17px;height:17px}
/* ---------- language dropdown ---------- */
.langwrap{position:relative;display:inline-block}
.langsel{display:inline-flex;align-items:center;gap:6px;border:1.5px solid var(--border-strong);border-radius:var(--radius-full);padding:8px 14px;background:var(--surface);color:var(--fg1);font-family:var(--font-sans);font-size:14px;font-weight:600;cursor:pointer;transition:border-color var(--dur-fast) var(--ease-out),color var(--dur-fast) var(--ease-out)}
@@ -166,7 +171,7 @@ section{padding:88px 0}
.plan.feat-plan .feats li{color:rgba(255,255,255,.92)}
.plan .feats svg{width:16px;height:16px;color:var(--success);flex-shrink:0;margin-top:1px}
.plan.feat-plan .feats svg{color:#fff}
.plan .pcta{width:100%;border:none;border-radius:var(--radius-full);padding:13px;font-family:var(--font-sans);font-weight:700;font-size:14.5px;cursor:pointer}
.plan .pcta{display:block;width:100%;box-sizing:border-box;text-align:center;text-decoration:none;border:none;border-radius:var(--radius-full);padding:13px;font-family:var(--font-sans);font-weight:700;font-size:14.5px;cursor:pointer}
.pcta-fill{background:var(--accent);color:#fff}
.pcta-white{background:#fff;color:var(--clay-700)}
.pcta-out{background:transparent;color:var(--fg2);border:1.5px solid var(--border-strong)!important}