merge: maestro/tsk_3FIPC8lSnAfJ [tsk_3FIPC8lSnAfJ]
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
# 数据层开关:mock(演示数据,UI 验收)| http(真实后端,阶段 B)
|
||||
NEXT_PUBLIC_API_MODE=mock
|
||||
|
||||
# 阶段 B:API 域名池(逗号分隔,N>=3,互不关联)。HttpClient 按序退避重试。
|
||||
# 与 doc/05 §1「API 域名池」灾备模型一致;mock 模式下忽略。
|
||||
NEXT_PUBLIC_API_DOMAINS=https://api1.example.com,https://api2.example.com,https://api3.example.com
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": "next/core-web-vitals"
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
node_modules/
|
||||
.next/
|
||||
out/
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
.env*.local
|
||||
@@ -0,0 +1,78 @@
|
||||
# 穿山甲 · Web 用户中心(Next.js 静态导出)
|
||||
|
||||
`design/ui_kits/usercenter/` 设计稿的生产实现。Next.js App Router + `output: 'export'`
|
||||
**纯静态导出**(无任何 Node 服务端能力),动态数据全走客户端 API,可任意静态托管直接跑。
|
||||
|
||||
## 页面
|
||||
概览 · 订阅导入 · 兑换 & 购买 · 邀请返利 · 设置(语言/主题 + 设备管理 + TOTP 2FA)。
|
||||
明/暗主题 × 中/英单显四态,移动端底部 Tab + 左右滑动切换。
|
||||
|
||||
## 运行
|
||||
|
||||
```bash
|
||||
cp .env.example .env.local # 默认 NEXT_PUBLIC_API_MODE=mock
|
||||
npm install
|
||||
npm run dev # http://localhost:3000
|
||||
```
|
||||
|
||||
## 构建(静态导出 → out/)
|
||||
|
||||
```bash
|
||||
npm run redline # 铁律 13 红线词扫描(CI 红线)
|
||||
npm run build # next build + 导出 + 注入 SRI
|
||||
# 产物在 out/,可直接静态托管(Cloudflare Pages 多镜像,同官网管线)
|
||||
```
|
||||
|
||||
`npm run check` = 红线扫描 + 构建。
|
||||
|
||||
## 数据层(mock / http 双实现)
|
||||
|
||||
`lib/api/` 抽象 `ApiClient`,构建期由 `NEXT_PUBLIC_API_MODE` 切换实现:
|
||||
|
||||
- **MockClient**(默认):演示数据,零网络依赖,供阶段 A 的 UI 验收。
|
||||
- 演示触发:密码 `wrong`→凭证错误,`locked`→账户锁定+倒计时;兑换码 `INVALID`/`USED`/`LOCKED`→对应错误;TOTP 码 `000000`→验证失败。
|
||||
- **HttpClient**(阶段 B):真实后端薄客户端 —— API 域名池故障转移 + 指数退避重试 +
|
||||
统一错误体 `{code, message_zh, message_en}` 解析 + 401 静默续期重放。
|
||||
域名池由 `NEXT_PUBLIC_API_DOMAINS`(逗号分隔,N≥3)配置。
|
||||
|
||||
接口:`login / loginTotp(登录二段式)/ refresh / getMe / getSubscription /
|
||||
resetSubscription / listDevices / removeDevice / redeem / totpSetup / totpVerify /
|
||||
totpDisable / logout`。
|
||||
|
||||
> **契约增补(依赖 #1 openapi)**:v1 暂无 TOTP 端点,已在 `lib/api/http.ts` 占位
|
||||
> `POST /v1/me/totp/setup|verify|disable` + 登录二段式 `/v1/auth/login/totp`。增补合入前
|
||||
> 2FA 仅 mock 验收,不阻塞本任务。
|
||||
|
||||
## 会话与安全
|
||||
|
||||
- **access token 仅存内存**(`lib/api/session.ts`),不落盘,降低 XSS 持久化窃取面。
|
||||
- **refresh token 走 header token + localStorage**,静默续期(401 自动 refresh 重放一次)。
|
||||
- 取舍说明:doc/05 §2 给「HttpOnly+Secure cookie 或 header token」两选。纯静态导出**无服务端
|
||||
设置 cookie 的能力**,故取 header token 方案,配合内存 access + 登出使后端 refresh 白名单失效收敛风险。
|
||||
- **登出**调用 `POST /v1/auth/logout` 使 refresh 失效,并清本地令牌。
|
||||
- **登录限流在服务端**;前端只做失败提示与锁定倒计时。
|
||||
|
||||
## 红线 / 合规
|
||||
|
||||
- **SRI**:构建期 `scripts/add-sri.mjs` 为导出 HTML 的本地 `<script>/<link>` 注入
|
||||
`integrity`(sha384) + `crossorigin`。
|
||||
- **CSP**:`public/_headers`(Cloudflare Pages)下发严格 CSP + 安全基线头。
|
||||
- 取舍:静态导出无 nonce 能力,Next 注入少量内联 bootstrap 脚本,故 `script-src` 保留
|
||||
`'unsafe-inline'`,以 SRI 补足自有 chunk 完整性;`style-src` 因设计稿大量内联样式同样需 `'unsafe-inline'`。
|
||||
- **红线词扫描**(铁律 13):`scripts/redline-scan.mjs` 扫 `app/ components/ lib/`,命中即非零退出(CI 红线)。
|
||||
- 设计令牌 `colors_and_type.css` 原样链入(`public/`,单一真相源);颜色全部走语义 token。
|
||||
|
||||
## 目录
|
||||
|
||||
```
|
||||
app/ layout + page(静态外壳)
|
||||
components/ UserCenter(外壳) + Login/Overview/Subscription/Redeem/Invite/Settings + icons/shared
|
||||
lib/ i18n · theme(语言/主题 context) · api/(client 工厂 + mock + http + types + errors + session)
|
||||
scripts/ add-sri.mjs · redline-scan.mjs
|
||||
public/ colors_and_type.css(原样) · _headers(CSP)
|
||||
```
|
||||
|
||||
## 部署(灾备口径同官网)
|
||||
|
||||
构建产物 `out/` 为纯静态,可在干净环境重放部署到任意新域名(Cloudflare Pages 多镜像)。
|
||||
用户中心被打击时,客户端内同功能仍可用(doc/05 §1 灾备模型)。
|
||||
@@ -0,0 +1,35 @@
|
||||
/* globals.css — 仅全局复位与视图切换动效;颜色/字体全部来自 colors_and_type.css(单一真相源)。 */
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
body {
|
||||
background: var(--bg);
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
* {
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
button {
|
||||
font-family: inherit;
|
||||
}
|
||||
@keyframes uc-in-l {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(18px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
@keyframes uc-in-r {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-18px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { Metadata, Viewport } from 'next';
|
||||
import './globals.css';
|
||||
import { UIProvider } from '../lib/theme';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: '穿山甲 · 用户中心',
|
||||
description: '管理订阅、兑换激活码、查看用量、邀请返利与账户安全设置。',
|
||||
};
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: 'device-width',
|
||||
initialScale: 1,
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="zh" data-theme="light">
|
||||
<head>
|
||||
{/* 设计令牌单一真相源,原样链入(SRI 由构建期注入) */}
|
||||
{/* eslint-disable-next-line @next/next/no-css-tags */}
|
||||
<link rel="stylesheet" href="/colors_and_type.css" />
|
||||
</head>
|
||||
<body>
|
||||
<UIProvider>{children}</UIProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import UserCenter from '../components/UserCenter';
|
||||
|
||||
export default function Page() {
|
||||
return <UserCenter />;
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
'use client';
|
||||
// Invite.tsx — 邀请返利(邀请链接 + 统计 + 记录)。承袭 UCInvite。
|
||||
import React, { useState } from 'react';
|
||||
import { Icon } from './icons';
|
||||
import { card } from './shared';
|
||||
import type { TFn } from '../lib/i18n';
|
||||
|
||||
export default function Invite({ t }: { t: TFn }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const link = 'https://pangolin.vpn/r/PG8F3K';
|
||||
const records = [
|
||||
{ mail: 'k***@gmail.com', date: '2026-06-08', paid: true },
|
||||
{ mail: 'w***@outlook.com', date: '2026-06-05', paid: false },
|
||||
{ mail: 'z***@qq.com', date: '2026-05-30', paid: true },
|
||||
];
|
||||
|
||||
async function copy() {
|
||||
try {
|
||||
await navigator.clipboard?.writeText(link);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1800);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 18, maxWidth: 680 }}>
|
||||
<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>
|
||||
</div>
|
||||
<div style={{ ...card, padding: '18px 20px' }}>
|
||||
<div style={{ fontSize: 12.5, fontWeight: 600, color: 'var(--fg2)', marginBottom: 8 }}>{t('inviteLink')}</div>
|
||||
<div style={{ display: 'flex', gap: 10 }}>
|
||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', gap: 9, background: 'var(--bg-subtle)', border: '1px solid var(--border)', borderRadius: 'var(--radius-md)', padding: '11px 13px', minWidth: 0 }}>
|
||||
<Icon name="gift" size={15} color="var(--fg3)" />
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 12.5, color: 'var(--fg1)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{link}</span>
|
||||
</div>
|
||||
<button onClick={copy} style={{ display: 'inline-flex', alignItems: 'center', gap: 7, border: 'none', borderRadius: 'var(--radius-md)', padding: '0 18px', background: 'var(--accent)', color: '#fff', fontWeight: 700, fontSize: 13.5, cursor: 'pointer', flexShrink: 0 }}>
|
||||
<Icon name={copied ? 'check' : 'copy'} size={15} color="#fff" />
|
||||
{copied ? t('subCopied') : t('subCopy')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit,minmax(150px,1fr))', gap: 14 }}>
|
||||
{[[t('invited'), '8'], [t('paidUsers'), '3'], [t('earned'), '¥45']].map(([l, v]) => (
|
||||
<div key={l} style={{ ...card, padding: '16px 18px' }}>
|
||||
<div style={{ fontSize: 12, color: 'var(--fg3)', fontWeight: 600 }}>{l}</div>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 23, fontWeight: 500, color: 'var(--fg1)', marginTop: 7 }}>{v}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ ...card, overflow: 'hidden' }}>
|
||||
<div style={{ padding: '13px 18px', fontSize: 13.5, fontWeight: 700, color: 'var(--fg1)', borderBottom: '1px solid var(--border)' }}>{t('inviteRecord')}</div>
|
||||
{records.map((r, i) => (
|
||||
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '12px 18px', borderBottom: i < records.length - 1 ? '1px solid var(--border)' : 'none' }}>
|
||||
<span style={{ flex: 1, fontFamily: 'var(--font-mono)', fontSize: 13, color: 'var(--fg1)' }}>{r.mail}</span>
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--fg3)' }}>{r.date}</span>
|
||||
<span style={{ fontSize: 11, fontWeight: 600, padding: '3px 10px', borderRadius: 999, background: r.paid ? 'var(--success-subtle)' : 'var(--bg-subtle)', color: r.paid ? 'var(--success)' : 'var(--fg3)' }}>
|
||||
{r.paid ? t('recPaid') : t('recRegistered')}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
'use client';
|
||||
// Login.tsx — 登录(凭证 → 可选 TOTP 二段式)。承袭 ucapp.jsx UCLogin 视觉。
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Icon, Mark } from './icons';
|
||||
import { card, input, LangSeg } from './shared';
|
||||
import { useUI } from '../lib/theme';
|
||||
import { makeT } from '../lib/i18n';
|
||||
import { getClient } from '../lib/api/client';
|
||||
import { ApiError } from '../lib/api/types';
|
||||
import { bilingual } from '../lib/api/errors';
|
||||
|
||||
export default function Login({ onDone }: { onDone: () => void }) {
|
||||
const { lang, setLang } = useUI();
|
||||
const t = makeT(lang);
|
||||
const api = getClient();
|
||||
|
||||
const [email, setEmail] = useState('');
|
||||
const [pw, setPw] = useState('');
|
||||
const [step, setStep] = useState<'cred' | 'totp'>('cred');
|
||||
const [otp, setOtp] = useState('');
|
||||
const [pending, setPending] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [err, setErr] = useState('');
|
||||
const [lockLeft, setLockLeft] = useState(0);
|
||||
|
||||
const ok = /\S+@\S+\.\S+/.test(email) && pw.length > 0;
|
||||
|
||||
// 锁定倒计时(限流在服务端,前端只做失败提示与倒计时)
|
||||
useEffect(() => {
|
||||
if (lockLeft <= 0) return;
|
||||
const id = setInterval(() => setLockLeft((s) => Math.max(0, s - 1)), 1000);
|
||||
return () => clearInterval(id);
|
||||
}, [lockLeft]);
|
||||
|
||||
async function submitCred() {
|
||||
if (!ok || busy || lockLeft > 0) return;
|
||||
setBusy(true);
|
||||
setErr('');
|
||||
try {
|
||||
const r = await api.login(email.trim(), pw);
|
||||
if (r.kind === 'totp_required') {
|
||||
setPending(r.pendingToken);
|
||||
setStep('totp');
|
||||
} else {
|
||||
onDone();
|
||||
}
|
||||
} catch (e) {
|
||||
setErr(bilingual(e, lang));
|
||||
if (e instanceof ApiError && e.code === 'account_locked' && e.retryAfter) setLockLeft(e.retryAfter);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function submitOtp() {
|
||||
if (otp.length !== 6 || busy) return;
|
||||
setBusy(true);
|
||||
setErr('');
|
||||
try {
|
||||
await api.loginTotp(pending, otp);
|
||||
onDone();
|
||||
} catch (e) {
|
||||
setErr(bilingual(e, lang));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
const credDisabled = !ok || busy || lockLeft > 0;
|
||||
|
||||
return (
|
||||
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'var(--bg)', padding: 24 }}>
|
||||
<div style={{ ...card, width: 400, maxWidth: '100%', padding: '36px 34px', boxSizing: 'border-box' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 26 }}>
|
||||
<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={{ fontSize: 8.5, fontWeight: 600, letterSpacing: '0.2em', color: 'var(--accent)', marginTop: 3 }}>PANGOLIN</div>
|
||||
</div>
|
||||
</div>
|
||||
<LangSeg lang={lang} setLang={setLang} />
|
||||
</div>
|
||||
|
||||
{step === 'cred' ? (
|
||||
<>
|
||||
<div style={{ fontFamily: 'var(--font-display)', fontWeight: 700, fontSize: 22, color: 'var(--fg1)' }}>{t('loginTitle')}</div>
|
||||
<div style={{ fontSize: 13.5, color: 'var(--fg3)', margin: '6px 0 24px' }}>{t('loginSub')}</div>
|
||||
<label style={{ display: 'block', marginBottom: 14 }}>
|
||||
<span style={{ display: 'block', fontSize: 12.5, fontWeight: 600, color: 'var(--fg2)', marginBottom: 6 }}>{t('emailLabel')}</span>
|
||||
<input style={input} value={email} onChange={(e) => setEmail(e.target.value)} placeholder={t('emailPh')} />
|
||||
</label>
|
||||
<label style={{ display: 'block' }}>
|
||||
<span style={{ display: 'block', fontSize: 12.5, fontWeight: 600, color: 'var(--fg2)', marginBottom: 6 }}>{t('pwLabel')}</span>
|
||||
<input
|
||||
style={input}
|
||||
type="password"
|
||||
value={pw}
|
||||
onChange={(e) => setPw(e.target.value)}
|
||||
placeholder={t('pwPh')}
|
||||
onKeyDown={(e) => e.key === 'Enter' && submitCred()}
|
||||
/>
|
||||
</label>
|
||||
<div style={{ textAlign: 'right', margin: '10px 0 18px' }}>
|
||||
<span style={{ fontSize: 13, color: 'var(--accent)', fontWeight: 600, cursor: 'pointer' }}>{t('forgotPw')}</span>
|
||||
</div>
|
||||
{err && <ErrorLine text={lockLeft > 0 ? `${err} · ${t('lockedCountdown', { s: lockLeft })}` : err} />}
|
||||
<button
|
||||
onClick={submitCred}
|
||||
disabled={credDisabled}
|
||||
style={primaryBtn(!credDisabled)}
|
||||
>
|
||||
{busy ? t('loading') : t('doLogin')}
|
||||
</button>
|
||||
<div style={{ textAlign: 'center', fontSize: 12.5, color: 'var(--fg3)', marginTop: 18 }}>{t('noAccount')}</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 9, marginBottom: 6 }}>
|
||||
<Icon name="shield-check" size={20} color="var(--accent)" />
|
||||
<span style={{ fontFamily: 'var(--font-display)', fontWeight: 700, fontSize: 20, color: 'var(--fg1)' }}>{t('twoFATitle')}</span>
|
||||
<span style={{ fontSize: 10.5, fontWeight: 700, color: 'var(--accent)', background: 'var(--accent-subtle)', border: '1px solid var(--accent-border)', padding: '2px 8px', borderRadius: 999 }}>PRO</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--fg3)', margin: '4px 0 20px', lineHeight: 1.6 }}>{t('twoFAHint')}</div>
|
||||
<input
|
||||
value={otp}
|
||||
onChange={(e) => setOtp(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
||||
onKeyDown={(e) => e.key === 'Enter' && submitOtp()}
|
||||
placeholder="······"
|
||||
style={{ ...input, fontFamily: 'var(--font-mono)', fontSize: 22, letterSpacing: '0.5em', textAlign: 'center', padding: '14px' }}
|
||||
/>
|
||||
{err && <div style={{ marginTop: 14 }}><ErrorLine text={err} /></div>}
|
||||
<button onClick={submitOtp} disabled={otp.length !== 6 || busy} style={{ ...primaryBtn(otp.length === 6 && !busy), marginTop: 16 }}>
|
||||
{busy ? t('loading') : t('twoFAConfirm')}
|
||||
</button>
|
||||
<div onClick={() => { setStep('cred'); setErr(''); setOtp(''); }} style={{ textAlign: 'center', fontSize: 12.5, color: 'var(--fg3)', marginTop: 18, cursor: 'pointer' }}>
|
||||
{t('twoFABack')}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function primaryBtn(enabled: boolean): React.CSSProperties {
|
||||
return {
|
||||
width: '100%',
|
||||
border: 'none',
|
||||
borderRadius: 'var(--radius-full)',
|
||||
padding: '13px',
|
||||
fontFamily: 'var(--font-sans)',
|
||||
fontWeight: 700,
|
||||
fontSize: 15,
|
||||
cursor: enabled ? 'pointer' : 'not-allowed',
|
||||
background: 'var(--accent)',
|
||||
color: 'var(--fg-on-accent)',
|
||||
opacity: enabled ? 1 : 0.45,
|
||||
};
|
||||
}
|
||||
|
||||
export function ErrorLine({ text }: { text: string }) {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 7, color: 'var(--danger)', fontSize: 12.5, fontWeight: 600, marginBottom: 12 }}>
|
||||
<Icon name="alert-triangle" size={14} color="var(--danger)" />
|
||||
<span>{text}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
'use client';
|
||||
// Overview.tsx — 概览(套餐横幅 + 用量指标 + 近 7 日柱状图 + 快速操作)。承袭 UCOverview。
|
||||
import React from 'react';
|
||||
import { Icon } from './icons';
|
||||
import { card } from './shared';
|
||||
import type { TFn, Lang } from '../lib/i18n';
|
||||
import type { Me } from '../lib/api/types';
|
||||
|
||||
export default function Overview({
|
||||
t,
|
||||
lang,
|
||||
me,
|
||||
mobile,
|
||||
goSub,
|
||||
goRedeem,
|
||||
}: {
|
||||
t: TFn;
|
||||
lang: Lang;
|
||||
me: Me;
|
||||
mobile: boolean;
|
||||
goSub: () => void;
|
||||
goRedeem: () => void;
|
||||
}) {
|
||||
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 stats: [string, string, string, string][] = [
|
||||
['clock', t('quotaToday'), free ? String(me.quotaTodayMin ?? 0) : '∞', free ? 'min' : ''],
|
||||
['arrow-down', t('dataToday'), me.dataTodayGB.toFixed(1), 'GB'],
|
||||
['smartphone', t('devices'), `${me.devicesUsed} / ${me.devicesMax}`, ''],
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
|
||||
<div>
|
||||
<div style={{ fontFamily: 'var(--font-display)', fontSize: 25, fontWeight: 700, color: 'var(--fg1)' }}>{t('greeting')}</div>
|
||||
<div style={{ fontSize: 13.5, color: 'var(--fg3)', marginTop: 3, fontFamily: 'var(--font-mono)' }}>{me.email}</div>
|
||||
</div>
|
||||
|
||||
{/* plan banner */}
|
||||
<div style={{ ...card, background: 'linear-gradient(150deg,var(--clay-600),var(--clay-800))', border: 'none', color: '#fff', padding: '20px 24px', display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 16, flexWrap: 'wrap' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
|
||||
<div style={{ width: 46, height: 46, borderRadius: '50%', background: 'rgba(255,255,255,.18)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Icon name="crown" size={23} color="#fff" />
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 12, opacity: 0.8 }}>{t('curPlan')}</div>
|
||||
<div style={{ fontFamily: 'var(--font-display)', fontSize: 19, fontWeight: 700, marginTop: 2 }}>{free ? t('freePlan') : t('proMember')}</div>
|
||||
<div style={{ fontSize: 11.5, opacity: 0.8, marginTop: 3, fontFamily: 'var(--font-mono)', whiteSpace: 'nowrap' }}>
|
||||
{free ? t('quotaFree') : `${t('expires')} ${me.expiresAt ?? '—'}`}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={goRedeem} style={{ border: 'none', background: '#fff', color: 'var(--clay-700)', fontWeight: 700, fontSize: 13.5, padding: '11px 20px', borderRadius: 'var(--radius-full)', cursor: 'pointer', display: 'inline-flex', alignItems: 'center', gap: 7 }}>
|
||||
<Icon name="ticket" size={15} color="var(--clay-700)" />
|
||||
{t('renew')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* stats */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit,minmax(160px,1fr))', gap: 14 }}>
|
||||
{stats.map(([ic, l, v, u]) => (
|
||||
<div key={l} style={{ ...card, padding: '16px 18px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 7, color: 'var(--fg3)', fontSize: 12, fontWeight: 600 }}>
|
||||
<Icon name={ic} size={15} color="var(--accent)" />
|
||||
{l}
|
||||
</div>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 23, fontWeight: 500, color: 'var(--fg1)', marginTop: 7 }}>
|
||||
{v}
|
||||
{u && <span style={{ fontSize: 12, color: 'var(--fg3)' }}> {u}</span>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* usage chart + quick actions */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: mobile ? '1fr' : '1.6fr 1fr', gap: 14, alignItems: 'start' }}>
|
||||
<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={{ 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>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ ...card, padding: '16px 18px' }}>
|
||||
<div style={{ fontSize: 13.5, fontWeight: 600, color: 'var(--fg1)', marginBottom: 12 }}>{t('quickSub')}</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{([['link', t('qaSub'), goSub], ['ticket', t('qaRedeem'), goRedeem], ['download', t('qaApp'), null]] as [string, string, (() => void) | null][]).map(([ic, l, fn]) => (
|
||||
<button key={l} onClick={fn || undefined} style={{ display: 'flex', alignItems: 'center', gap: 11, width: '100%', textAlign: 'left', cursor: 'pointer', background: 'var(--bg-subtle)', border: '1px solid var(--border)', borderRadius: 'var(--radius-md)', padding: '11px 13px' }}>
|
||||
<Icon name={ic} size={16} color="var(--accent)" />
|
||||
<span style={{ flex: 1, fontSize: 13.5, fontWeight: 600, color: 'var(--fg1)' }}>{l}</span>
|
||||
<Icon name="chevron-right" size={15} color="var(--fg3)" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
'use client';
|
||||
// Redeem.tsx — 兑换激活码 + 购买渠道(本站不收款,资金流全走外部)。承袭 UCRedeem。
|
||||
import React, { useState } from 'react';
|
||||
import { Icon } from './icons';
|
||||
import { card, input } from './shared';
|
||||
import { ErrorLine } from './Login';
|
||||
import type { TFn, Lang } from '../lib/i18n';
|
||||
import { getClient } from '../lib/api/client';
|
||||
import { bilingual } from '../lib/api/errors';
|
||||
|
||||
export default function Redeem({ t, lang, onRedeemed }: { t: TFn; lang: Lang; onRedeemed?: () => void }) {
|
||||
const api = getClient();
|
||||
const [code, setCode] = useState('');
|
||||
const [ok, setOk] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [err, setErr] = useState('');
|
||||
|
||||
const channels: { icon: string; name: string; sub: string; accent?: boolean; mono?: boolean }[] = [
|
||||
{ icon: 'shopping-bag', name: t('chStore'), sub: t('chStoreSub'), accent: true },
|
||||
{ icon: 'credit-card', name: 'USDT (TRC20)', sub: t('chUsdtSub') },
|
||||
{ icon: 'send', name: 'Telegram', sub: '@Pangolin_bot', mono: true },
|
||||
{ icon: 'message-circle', name: 'LINE', sub: '@pangolinvpn', mono: true },
|
||||
{ icon: 'mail', name: t('chEmail'), sub: 'buy@pangolin.vpn', mono: true },
|
||||
];
|
||||
|
||||
async function submit() {
|
||||
if (busy || code.trim().length < 4) return;
|
||||
setBusy(true);
|
||||
setErr('');
|
||||
try {
|
||||
await api.redeem(code.trim());
|
||||
setOk(true);
|
||||
onRedeemed?.();
|
||||
} catch (e) {
|
||||
setErr(bilingual(e, lang));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 18, maxWidth: 680 }}>
|
||||
<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>
|
||||
{ok ? (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 9, color: 'var(--success)', fontWeight: 600, fontSize: 15, padding: '4px 0' }}>
|
||||
<Icon name="check-circle" size={19} color="var(--success)" />
|
||||
{t('redeemOk')}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div style={{ display: 'flex', gap: 10 }}>
|
||||
<input
|
||||
value={code}
|
||||
onChange={(e) => { setCode(e.target.value.toUpperCase()); setErr(''); }}
|
||||
onKeyDown={(e) => e.key === 'Enter' && submit()}
|
||||
placeholder={t('redeemPh')}
|
||||
style={{ ...input, fontFamily: 'var(--font-mono)', letterSpacing: '0.08em' }}
|
||||
/>
|
||||
<button onClick={submit} disabled={busy} style={{ border: 'none', borderRadius: 'var(--radius-md)', padding: '0 22px', background: 'var(--accent)', color: '#fff', fontWeight: 700, fontSize: 14, cursor: busy ? 'wait' : 'pointer', flexShrink: 0 }}>
|
||||
{busy ? t('loading') : t('redeemBtn')}
|
||||
</button>
|
||||
</div>
|
||||
{err && <div style={{ marginTop: 12 }}><ErrorLine text={err} /></div>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 14.5, fontWeight: 700, color: 'var(--fg1)', marginBottom: 5 }}>{t('buyTitle')}</div>
|
||||
<div style={{ fontSize: 12.5, color: 'var(--fg3)', lineHeight: 1.5, marginBottom: 13 }}>{t('buySub')}</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit,minmax(240px,1fr))', gap: 11 }}>
|
||||
{channels.map((c) => (
|
||||
<button key={c.name} style={{ display: 'flex', alignItems: 'center', gap: 12, textAlign: 'left', cursor: 'pointer', background: c.accent ? 'var(--accent-subtle)' : 'var(--surface)', border: `1px solid ${c.accent ? 'var(--accent-border)' : 'var(--border)'}`, borderRadius: 'var(--radius-lg)', padding: '13px 15px', boxShadow: 'var(--shadow-sm)' }}>
|
||||
<div style={{ width: 38, height: 38, borderRadius: 'var(--radius-md)', background: c.accent ? 'var(--accent)' : 'var(--bg-subtle)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
<Icon name={c.icon} size={18} color={c.accent ? '#fff' : 'var(--accent)'} />
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--fg1)' }}>{c.name}</div>
|
||||
<div style={{ fontSize: 11.5, color: 'var(--fg3)', fontFamily: c.mono ? 'var(--font-mono)' : 'var(--font-sans)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{c.sub}</div>
|
||||
</div>
|
||||
<Icon name="external-link" size={15} color="var(--fg3)" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
'use client';
|
||||
// Settings.tsx — 设置:偏好(语言/主题) + 设备管理(列表/移除/二次确认) + TOTP 2FA(绑定/验证/解禁)。
|
||||
// 设计稿 §5:Web 用户中心「设置(含 2FA/TOTP 开关,用户可自选开启)」。
|
||||
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';
|
||||
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={{ 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>
|
||||
);
|
||||
}
|
||||
|
||||
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();
|
||||
const [mode, setMode] = useState<'idle' | 'setup' | 'disable'>('idle');
|
||||
const [setup, setSetup] = useState<TotpSetup | null>(null);
|
||||
const [code, setCode] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [err, setErr] = useState('');
|
||||
const [flash, setFlash] = useState('');
|
||||
|
||||
function reset() {
|
||||
setMode('idle');
|
||||
setCode('');
|
||||
setErr('');
|
||||
setSetup(null);
|
||||
}
|
||||
|
||||
async function startSetup() {
|
||||
setBusy(true);
|
||||
setErr('');
|
||||
try {
|
||||
const s = await api.totpSetup();
|
||||
setSetup(s);
|
||||
setMode('setup');
|
||||
} catch (e) {
|
||||
setErr(bilingual(e, lang));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function verify() {
|
||||
if (code.length !== 6 || busy) return;
|
||||
setBusy(true);
|
||||
setErr('');
|
||||
try {
|
||||
await api.totpVerify(code);
|
||||
reset();
|
||||
setFlash(t('totpEnabledOk'));
|
||||
onChange();
|
||||
} catch (e) {
|
||||
setErr(bilingual(e, lang));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function disable() {
|
||||
if (code.length !== 6 || busy) return;
|
||||
setBusy(true);
|
||||
setErr('');
|
||||
try {
|
||||
await api.totpDisable(code);
|
||||
reset();
|
||||
setFlash(t('totpDisabledOk'));
|
||||
onChange();
|
||||
} catch (e) {
|
||||
setErr(bilingual(e, lang));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ ...card, padding: '18px 20px', display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<div style={{ width: 38, height: 38, borderRadius: 'var(--radius-md)', background: 'var(--accent-subtle)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Icon name="shield-check" size={19} color="var(--accent)" />
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span style={sectionTitle}>{t('totpTitle')}</span>
|
||||
<span style={{ fontSize: 10.5, fontWeight: 700, padding: '2px 9px', borderRadius: 999, background: enabled ? 'var(--success-subtle)' : 'var(--bg-subtle)', color: enabled ? 'var(--success)' : 'var(--fg3)' }}>
|
||||
{enabled ? t('totpOn') : t('totpOff')}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--fg3)', marginTop: 4, lineHeight: 1.5, maxWidth: 380 }}>{t('totpDesc')}</div>
|
||||
</div>
|
||||
</div>
|
||||
{mode === 'idle' && (
|
||||
enabled ? (
|
||||
<button onClick={() => { setMode('disable'); setFlash(''); }} style={ghostBtn}>{t('totpDisableBtn')}</button>
|
||||
) : (
|
||||
<button onClick={startSetup} disabled={busy} style={accentBtn}>{busy ? t('loading') : t('totpEnable')}</button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
{flash && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 7, color: 'var(--success)', fontSize: 12.5, fontWeight: 600 }}>
|
||||
<Icon name="check-circle" size={14} color="var(--success)" />{flash}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mode === 'setup' && setup && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14, borderTop: '1px solid var(--border)', paddingTop: 14 }}>
|
||||
<div style={{ fontSize: 12.5, fontWeight: 600, color: 'var(--fg2)' }}>{t('totpStep1')}</div>
|
||||
<div style={{ display: 'flex', gap: 16, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<QrPlaceholder uri={setup.otpauthUri} />
|
||||
<div style={{ minWidth: 200 }}>
|
||||
<div style={{ fontSize: 11.5, color: 'var(--fg3)', fontWeight: 600, marginBottom: 6 }}>{t('totpSecretLabel')}</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, background: 'var(--bg-subtle)', border: '1px solid var(--border)', borderRadius: 'var(--radius-md)', padding: '10px 12px' }}>
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 14, letterSpacing: '0.12em', color: 'var(--fg1)', wordBreak: 'break-all' }}>{setup.secret}</span>
|
||||
<button onClick={() => navigator.clipboard?.writeText(setup.secret).catch(() => {})} title={t('copyKey')} style={{ border: 'none', background: 'transparent', cursor: 'pointer', padding: 2, display: 'flex' }}>
|
||||
<Icon name="copy" size={15} color="var(--accent)" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ fontSize: 12.5, fontWeight: 600, color: 'var(--fg2)', marginTop: 2 }}>{t('totpStep2')}</div>
|
||||
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}>
|
||||
<input
|
||||
value={code}
|
||||
onChange={(e) => { setCode(e.target.value.replace(/\D/g, '').slice(0, 6)); setErr(''); }}
|
||||
onKeyDown={(e) => e.key === 'Enter' && verify()}
|
||||
placeholder="······"
|
||||
style={{ ...input, width: 160, fontFamily: 'var(--font-mono)', fontSize: 18, letterSpacing: '0.4em', textAlign: 'center' }}
|
||||
/>
|
||||
<button onClick={verify} disabled={code.length !== 6 || busy} style={{ ...accentBtn, opacity: code.length === 6 && !busy ? 1 : 0.5 }}>{t('totpVerifyBtn')}</button>
|
||||
<button onClick={reset} style={ghostBtn}>{t('cancel')}</button>
|
||||
</div>
|
||||
{err && <ErrorLine text={err} />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mode === 'disable' && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, borderTop: '1px solid var(--border)', paddingTop: 14 }}>
|
||||
<div style={{ fontSize: 12.5, color: 'var(--fg2)' }}>{t('totpDisableHint')}</div>
|
||||
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}>
|
||||
<input
|
||||
value={code}
|
||||
onChange={(e) => { setCode(e.target.value.replace(/\D/g, '').slice(0, 6)); setErr(''); }}
|
||||
onKeyDown={(e) => e.key === 'Enter' && disable()}
|
||||
placeholder="······"
|
||||
style={{ ...input, width: 160, fontFamily: 'var(--font-mono)', fontSize: 18, letterSpacing: '0.4em', textAlign: 'center' }}
|
||||
/>
|
||||
<button onClick={disable} disabled={code.length !== 6 || busy} style={{ ...dangerBtn, opacity: code.length === 6 && !busy ? 1 : 0.5 }}>{t('totpDisableBtn')}</button>
|
||||
<button onClick={reset} style={ghostBtn}>{t('cancel')}</button>
|
||||
</div>
|
||||
{err && <ErrorLine text={err} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 二维码占位:与订阅页一致的设计语言(dashed 框 + qr 图标)。mock 阶段不引入 QR 依赖。
|
||||
function QrPlaceholder({ uri }: { uri: string }) {
|
||||
return (
|
||||
<div title={uri} style={{ width: 128, height: 128, borderRadius: 'var(--radius-md)', background: 'var(--bg-subtle)', border: '1px dashed var(--border-strong)', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 6, flexShrink: 0 }}>
|
||||
<Icon name="qr-code" size={40} color="var(--fg3)" stroke={1.5} />
|
||||
<span style={{ fontSize: 10, color: 'var(--fg3)', fontWeight: 600 }}>otpauth://</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ───────── Devices ───────── */
|
||||
function Devices({ t, lang }: { t: TFn; lang: Lang }) {
|
||||
const api = getClient();
|
||||
const [devices, setDevices] = useState<Device[] | null>(null);
|
||||
const [confirmId, setConfirmId] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [err, setErr] = useState('');
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
setDevices(await api.listDevices());
|
||||
} catch (e) {
|
||||
setErr(bilingual(e, lang));
|
||||
}
|
||||
}
|
||||
// 仅挂载时拉取一次设备列表
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
async function remove(id: string) {
|
||||
setBusy(true);
|
||||
setErr('');
|
||||
try {
|
||||
await api.removeDevice(id);
|
||||
setConfirmId(null);
|
||||
await load();
|
||||
} catch (e) {
|
||||
setErr(bilingual(e, lang));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ ...card, overflow: 'hidden' }}>
|
||||
<div style={{ padding: '16px 20px 12px' }}>
|
||||
<div style={sectionTitle}>{t('devTitle')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--fg3)', marginTop: 4, lineHeight: 1.5 }}>{t('devSub')}</div>
|
||||
</div>
|
||||
{err && <div style={{ padding: '0 20px 12px' }}><ErrorLine text={err} /></div>}
|
||||
{devices === null ? (
|
||||
<div style={{ padding: '8px 20px 18px', fontSize: 13, color: 'var(--fg3)' }}>{t('loading')}</div>
|
||||
) : devices.length === 0 ? (
|
||||
<div style={{ padding: '8px 20px 18px', fontSize: 13, color: 'var(--fg3)' }}>{t('devEmpty')}</div>
|
||||
) : (
|
||||
devices.map((d, i) => (
|
||||
<div key={d.id} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '13px 20px', borderTop: '1px solid var(--border)' }}>
|
||||
<div style={{ width: 34, height: 34, borderRadius: 'var(--radius-md)', background: 'var(--bg-subtle)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
<Icon name={/i(Pad|Phone)|Android|iOS/i.test(d.platform) ? 'smartphone' : 'monitor'} size={17} color="var(--accent)" />
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span style={{ fontSize: 13.5, fontWeight: 600, color: 'var(--fg1)' }}>{d.name}</span>
|
||||
{d.current && <span style={{ fontSize: 10, fontWeight: 700, padding: '2px 8px', borderRadius: 999, background: 'var(--accent-subtle)', color: 'var(--accent)' }}>{t('devCurrent')}</span>}
|
||||
</div>
|
||||
<div style={{ fontSize: 11.5, color: 'var(--fg3)', marginTop: 2 }}>{d.platform} · {t('devLastActive')} {d.lastActive}</div>
|
||||
</div>
|
||||
{!d.current && (
|
||||
<button onClick={() => setConfirmId(d.id)} style={{ display: 'inline-flex', alignItems: 'center', gap: 6, border: '1.5px solid var(--border-strong)', background: 'transparent', color: 'var(--danger)', fontWeight: 600, fontSize: 12.5, padding: '7px 13px', borderRadius: 'var(--radius-full)', cursor: 'pointer' }}>
|
||||
<Icon name="trash-2" size={14} color="var(--danger)" />{t('devRemove')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
|
||||
{confirmId && (
|
||||
<ConfirmModal
|
||||
t={t}
|
||||
busy={busy}
|
||||
onCancel={() => setConfirmId(null)}
|
||||
onConfirm={() => remove(confirmId)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ConfirmModal({ t, busy, onCancel, onConfirm }: { t: TFn; busy: boolean; onCancel: () => void; onConfirm: () => void }) {
|
||||
return (
|
||||
<div onClick={onCancel} style={{ position: 'fixed', inset: 0, zIndex: 50, background: 'var(--overlay)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 20 }}>
|
||||
<div onClick={(e) => e.stopPropagation()} style={{ ...card, width: 380, maxWidth: '100%', padding: '24px 24px 20px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 10 }}>
|
||||
<div style={{ width: 38, height: 38, borderRadius: 'var(--radius-md)', background: 'var(--danger-subtle)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Icon name="alert-triangle" size={19} color="var(--danger)" />
|
||||
</div>
|
||||
<span style={{ fontFamily: 'var(--font-display)', fontWeight: 700, fontSize: 17, color: 'var(--fg1)' }}>{t('devRemoveConfirmTitle')}</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--fg3)', lineHeight: 1.6, marginBottom: 18 }}>{t('devRemoveConfirmSub')}</div>
|
||||
<div style={{ display: 'flex', gap: 10, justifyContent: 'flex-end' }}>
|
||||
<button onClick={onCancel} style={ghostBtn}>{t('cancel')}</button>
|
||||
<button onClick={onConfirm} disabled={busy} style={dangerBtn}>{busy ? t('loading') : t('confirm')}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const accentBtn: React.CSSProperties = { border: 'none', borderRadius: 'var(--radius-full)', padding: '9px 18px', background: 'var(--accent)', color: 'var(--fg-on-accent)', fontWeight: 700, fontSize: 13, cursor: 'pointer' };
|
||||
const ghostBtn: React.CSSProperties = { border: '1.5px solid var(--border-strong)', borderRadius: 'var(--radius-full)', padding: '9px 18px', background: 'transparent', color: 'var(--fg2)', fontWeight: 600, fontSize: 13, cursor: 'pointer' };
|
||||
const dangerBtn: React.CSSProperties = { border: 'none', borderRadius: 'var(--radius-full)', padding: '9px 18px', background: 'var(--danger)', color: '#fff', fontWeight: 700, fontSize: 13, cursor: 'pointer' };
|
||||
@@ -0,0 +1,108 @@
|
||||
'use client';
|
||||
// Subscription.tsx — 订阅导入(链接 + 二维码占位 + 一键导入三方客户端)。承袭 UCSub。
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Icon } from './icons';
|
||||
import { card } from './shared';
|
||||
import type { TFn } from '../lib/i18n';
|
||||
import { getClient } from '../lib/api/client';
|
||||
|
||||
export default function Subscription({ t, mobile }: { t: TFn; mobile: boolean }) {
|
||||
const api = getClient();
|
||||
const [url, setUrl] = useState('');
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [reset, setReset] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
api.getSubscription().then((s) => setUrl(s.url)).catch(() => {});
|
||||
}, [api]);
|
||||
|
||||
const clients = [
|
||||
{ name: '穿山甲 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' },
|
||||
{ name: 'sing-box', sub: '全平台', icon: 'external-link' },
|
||||
];
|
||||
|
||||
async function copy() {
|
||||
try {
|
||||
await navigator.clipboard?.writeText(url);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1800);
|
||||
}
|
||||
|
||||
async function doReset() {
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const s = await api.resetSubscription();
|
||||
setUrl(s.url);
|
||||
setReset(true);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 18, maxWidth: 720 }}>
|
||||
<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>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: mobile ? '1fr' : '1fr auto', gap: 14, alignItems: 'start' }}>
|
||||
{/* link card */}
|
||||
<div style={{ ...card, padding: '18px 20px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 9, background: 'var(--bg-subtle)', border: '1px solid var(--border)', borderRadius: 'var(--radius-md)', padding: '11px 13px', marginBottom: 12 }}>
|
||||
<Icon name="link" size={15} color="var(--fg3)" />
|
||||
<span style={{ flex: 1, fontFamily: 'var(--font-mono)', fontSize: 12.5, color: 'var(--fg1)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{url || t('loading')}</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}>
|
||||
<button onClick={copy} style={{ display: 'inline-flex', alignItems: 'center', gap: 7, border: 'none', borderRadius: 'var(--radius-full)', padding: '10px 18px', background: 'var(--accent)', color: '#fff', fontWeight: 700, fontSize: 13.5, cursor: 'pointer' }}>
|
||||
<Icon name={copied ? 'check' : 'copy'} size={15} color="#fff" />
|
||||
{copied ? t('subCopied') : t('subCopy')}
|
||||
</button>
|
||||
<button onClick={doReset} disabled={busy} style={{ display: 'inline-flex', alignItems: 'center', gap: 7, border: '1.5px solid var(--border-strong)', borderRadius: 'var(--radius-full)', padding: '10px 18px', background: 'transparent', color: 'var(--fg2)', fontWeight: 600, fontSize: 13.5, cursor: busy ? 'wait' : 'pointer' }}>
|
||||
<Icon name="refresh-cw" size={14} color="var(--fg2)" />
|
||||
{t('subReset')}
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ fontSize: 11.5, color: reset ? 'var(--success)' : 'var(--fg3)', marginTop: 10, display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
{reset && <Icon name="check-circle" size={13} color="var(--success)" />}
|
||||
{reset ? t('subResetOk') : t('subResetSub')}
|
||||
</div>
|
||||
</div>
|
||||
{/* QR */}
|
||||
<div style={{ ...card, padding: '16px', width: mobile ? '100%' : 170, boxSizing: 'border-box', textAlign: 'center' }}>
|
||||
<div style={{ width: 138, height: 138, margin: '0 auto', borderRadius: 'var(--radius-md)', background: 'var(--bg-subtle)', border: '1px dashed var(--border-strong)', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 8 }}>
|
||||
<Icon name="qr-code" size={42} color="var(--fg3)" stroke={1.5} />
|
||||
<span style={{ fontSize: 10.5, color: 'var(--fg3)', fontWeight: 600 }}>{t('scanTitle')}</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 10.5, color: 'var(--fg3)', marginTop: 10, lineHeight: 1.5 }}>{t('scanSub')}</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* one-tap import */}
|
||||
<div style={{ ...card, padding: '18px 20px' }}>
|
||||
<div style={{ fontSize: 14.5, fontWeight: 700, color: 'var(--fg1)' }}>{t('importTitle')}</div>
|
||||
<div style={{ fontSize: 12.5, color: 'var(--fg3)', margin: '4px 0 14px' }}>{t('importSub')}</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill,minmax(190px,1fr))', gap: 10 }}>
|
||||
{clients.map((c) => (
|
||||
<button key={c.name} style={{ display: 'flex', alignItems: 'center', gap: 11, textAlign: 'left', cursor: 'pointer', background: c.accent ? 'var(--accent-subtle)' : 'var(--surface)', border: `1px solid ${c.accent ? 'var(--accent-border)' : 'var(--border)'}`, borderRadius: 'var(--radius-lg)', padding: '12px 14px' }}>
|
||||
<div style={{ width: 34, height: 34, borderRadius: 'var(--radius-md)', background: c.accent ? 'var(--accent)' : 'var(--bg-subtle)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
<Icon name={c.icon} size={17} color={c.accent ? '#fff' : 'var(--accent)'} />
|
||||
</div>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ fontSize: 13.5, fontWeight: 600, color: 'var(--fg1)', whiteSpace: 'nowrap' }}>{c.name}</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--fg3)' }}>{c.sub}</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ fontSize: 11.5, color: 'var(--fg3)', marginTop: 12 }}>{t('fmtNote')}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
'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'];
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
if (!ready) {
|
||||
return <div style={{ minHeight: '100vh', background: 'var(--bg)' }} />;
|
||||
}
|
||||
if (!authed) {
|
||||
return <Login onDone={() => { setAuthed(true); setView('overview'); }} />;
|
||||
}
|
||||
|
||||
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)' }}>穿山甲</span>
|
||||
</div>
|
||||
{!mobile && <nav style={{ display: 'flex', gap: 4, flex: 1 }}>{navBtns}</nav>}
|
||||
{mobile && <div style={{ flex: 1 }} />}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
// icons.tsx — Lucide 细线条图标(2px stroke,圆角端点)。
|
||||
// 直接承袭 design/ui_kits/usercenter/ucparts.jsx 的 UCLUCIDE/UCIcon/UCMark,并补设置页所需。
|
||||
import React from 'react';
|
||||
|
||||
export const LUCIDE: Record<string, string> = {
|
||||
'layout-dashboard': '<rect width="7" height="9" x="3" y="3" rx="1"/><rect width="7" height="5" x="14" y="3" rx="1"/><rect width="7" height="9" x="14" y="12" rx="1"/><rect width="7" height="5" x="3" y="16" rx="1"/>',
|
||||
link: '<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>',
|
||||
ticket: '<path d="M2 9a3 3 0 0 1 0 6v2a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-2a3 3 0 0 1 0-6V7a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2Z"/><path d="M13 5v2"/><path d="M13 17v2"/><path d="M13 11v2"/>',
|
||||
users: '<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>',
|
||||
copy: '<rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/>',
|
||||
'refresh-cw': '<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M3 21v-5h5"/>',
|
||||
'qr-code': '<rect width="5" height="5" x="3" y="3" rx="1"/><rect width="5" height="5" x="16" y="3" rx="1"/><rect width="5" height="5" x="3" y="16" rx="1"/><path d="M21 16h-3a2 2 0 0 0-2 2v3"/><path d="M21 21v.01"/><path d="M12 7v3a2 2 0 0 1-2 2H7"/><path d="M3 12h.01"/><path d="M12 3h.01"/><path d="M12 16v.01"/><path d="M16 12h1"/><path d="M21 12v.01"/><path d="M12 21v-1"/>',
|
||||
download: '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" x2="12" y1="15" y2="3"/>',
|
||||
check: '<path d="M20 6 9 17l-5-5"/>',
|
||||
'check-circle': '<path d="M21.801 10A10 10 0 1 1 17 3.335"/><path d="m9 11 3 3L22 4"/>',
|
||||
zap: '<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/>',
|
||||
clock: '<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>',
|
||||
'shield-check': '<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/><path d="m9 12 2 2 4-4"/>',
|
||||
shield: '<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/>',
|
||||
crown: '<path d="M11.562 3.266a.5.5 0 0 1 .876 0L15.39 8.87a1 1 0 0 0 1.516.294L21.183 5.5a.5.5 0 0 1 .798.519l-2.834 10.246a1 1 0 0 1-.956.734H5.81a1 1 0 0 1-.957-.734L2.02 6.02a.5.5 0 0 1 .798-.519l4.276 3.664a1 1 0 0 0 1.516-.294z"/><path d="M5 21h14"/>',
|
||||
'credit-card': '<rect width="20" height="14" x="2" y="5" rx="2"/><line x1="2" x2="22" y1="10" y2="10"/>',
|
||||
send: '<path d="M14.536 21.686a.5.5 0 0 0 .937-.024l6.5-19a.496.496 0 0 0-.635-.635l-19 6.5a.5.5 0 0 0-.024.937l7.93 3.18a2 2 0 0 1 1.112 1.11z"/><path d="m21.854 2.147-10.94 10.939"/>',
|
||||
'message-circle': '<path d="M7.9 20A9 9 0 1 0 4 16.1L2 22Z"/>',
|
||||
mail: '<rect width="20" height="16" x="2" y="4" rx="2"/><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/>',
|
||||
'shopping-bag': '<path d="M6 2 3 6v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6l-3-4Z"/><path d="M3 6h18"/><path d="M16 10a4 4 0 0 1-8 0"/>',
|
||||
'log-out': '<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" x2="9" y1="12" y2="12"/>',
|
||||
'external-link': '<path d="M15 3h6v6"/><path d="M10 14 21 3"/><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>',
|
||||
gift: '<rect x="3" y="8" width="18" height="4" rx="1"/><path d="M12 8v13"/><path d="M19 12v7a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2v-7"/><path d="M7.5 8a2.5 2.5 0 0 1 0-5A4.8 8 0 0 1 12 8a4.8 8 0 0 1 4.5-5 2.5 2.5 0 0 1 0 5"/>',
|
||||
smartphone: '<rect width="14" height="20" x="5" y="2" rx="2" ry="2"/><path d="M12 18h.01"/>',
|
||||
lock: '<rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>',
|
||||
'chevron-right': '<path d="m9 18 6-6-6-6"/>',
|
||||
'arrow-down': '<path d="M12 5v14"/><path d="m19 12-7 7-7-7"/>',
|
||||
/* —— 设置页补充 —— */
|
||||
settings: '<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/>',
|
||||
'trash-2': '<path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><line x1="10" x2="10" y1="11" y2="17"/><line x1="14" x2="14" y1="11" y2="17"/>',
|
||||
monitor: '<rect width="20" height="14" x="2" y="3" rx="2"/><line x1="8" x2="16" y1="21" y2="21"/><line x1="12" x2="12" y1="17" y2="21"/>',
|
||||
key: '<path d="m15.5 7.5 2.3 2.3a1 1 0 0 0 1.4 0l2.1-2.1a1 1 0 0 0 0-1.4L19 4"/><path d="m21 2-9.6 9.6"/><circle cx="7.5" cy="15.5" r="5.5"/>',
|
||||
sun: '<circle cx="12" cy="12" r="4"/><path d="M12 2v2"/><path d="M12 20v2"/><path d="m4.93 4.93 1.41 1.41"/><path d="m17.66 17.66 1.41 1.41"/><path d="M2 12h2"/><path d="M20 12h2"/><path d="m6.34 17.66-1.41 1.41"/><path d="m19.07 4.93-1.41 1.41"/>',
|
||||
moon: '<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"/>',
|
||||
'alert-triangle': '<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"/><path d="M12 9v4"/><path d="M12 17h.01"/>',
|
||||
x: '<path d="M18 6 6 18"/><path d="m6 6 12 12"/>',
|
||||
};
|
||||
|
||||
export function Icon({
|
||||
name,
|
||||
size = 18,
|
||||
stroke = 2,
|
||||
color = 'currentColor',
|
||||
style = {},
|
||||
}: {
|
||||
name: string;
|
||||
size?: number;
|
||||
stroke?: number;
|
||||
color?: string;
|
||||
style?: React.CSSProperties;
|
||||
}) {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth={stroke}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
style={{ display: 'block', flexShrink: 0, ...style }}
|
||||
dangerouslySetInnerHTML={{ __html: LUCIDE[name] || '' }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// 品牌母题:行走穿山甲(朝右,实心鳞甲剪影)
|
||||
export function Mark({ size = 30, color = 'var(--accent)' }: { size?: number; color?: string }) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 96 96" fill="none" style={{ display: 'block' }}>
|
||||
<g transform="translate(96,0) scale(-1,1)">
|
||||
<path
|
||||
d="M16 59 Q17 51 26 50 Q34 31 45 30 Q55 30 62 37 Q69 41 75 45 Q85 49 89 44 Q92 50 84 52 Q75 53 67 53 Q66 62 60 62 L56 62 Q54 55 49 55 Q47 62 37 62 L33 62 Q31 56 26 56 Q20 59 16 59 Z"
|
||||
fill={color}
|
||||
/>
|
||||
<g stroke="#fff" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round" fill="none" opacity="0.5">
|
||||
<path d="M27 50 Q35 43 43 50" />
|
||||
<path d="M37 48 Q45 41 53 48" />
|
||||
<path d="M47 48 Q55 41 63 49" />
|
||||
</g>
|
||||
<circle cx="19" cy="56" r="2.2" fill="#fff" opacity="0.85" />
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
// shared.tsx — 公共样式常量与原子(承袭 ucapp.jsx 的 ucCard / ucInput / UCLang)
|
||||
import React from 'react';
|
||||
import type { Lang } from '../lib/i18n';
|
||||
|
||||
export const card: React.CSSProperties = {
|
||||
background: 'var(--surface)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--radius-xl)',
|
||||
boxShadow: 'var(--shadow-sm)',
|
||||
};
|
||||
|
||||
export const input: React.CSSProperties = {
|
||||
border: '1.5px solid var(--border-strong)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
padding: '12px 14px',
|
||||
fontFamily: 'var(--font-sans)',
|
||||
fontSize: 14.5,
|
||||
color: 'var(--fg1)',
|
||||
background: 'var(--surface)',
|
||||
outline: 'none',
|
||||
width: '100%',
|
||||
boxSizing: 'border-box',
|
||||
};
|
||||
|
||||
export function LangSeg({ lang, setLang }: { lang: Lang; setLang: (l: Lang) => void }) {
|
||||
return (
|
||||
<div style={{ display: 'flex', background: 'var(--bg-subtle)', borderRadius: 999, padding: 3, gap: 2 }}>
|
||||
{([['zh', '中文'], ['en', 'EN']] as [Lang, string][]).map(([v, l]) => (
|
||||
<button
|
||||
key={v}
|
||||
onClick={() => setLang(v)}
|
||||
aria-pressed={lang === v}
|
||||
style={{
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
borderRadius: 999,
|
||||
padding: '5px 12px',
|
||||
fontFamily: 'var(--font-sans)',
|
||||
fontSize: 12.5,
|
||||
fontWeight: 700,
|
||||
background: lang === v ? 'var(--accent)' : 'transparent',
|
||||
color: lang === v ? 'var(--fg-on-accent)' : 'var(--fg3)',
|
||||
}}
|
||||
>
|
||||
{l}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
// client.ts — 数据层入口:构建期环境开关切换 mock / http。
|
||||
import type { ApiClient } from './types';
|
||||
import { MockClient } from './mock';
|
||||
import { HttpClient } from './http';
|
||||
|
||||
export * from './types';
|
||||
|
||||
let singleton: ApiClient | null = null;
|
||||
|
||||
export function apiMode(): 'mock' | 'http' {
|
||||
return process.env.NEXT_PUBLIC_API_MODE === 'http' ? 'http' : 'mock';
|
||||
}
|
||||
|
||||
/** 单例客户端:mock 模式保留内存状态(订阅重置/设备移除等可连续演示) */
|
||||
export function getClient(): ApiClient {
|
||||
if (!singleton) singleton = apiMode() === 'http' ? new HttpClient() : new MockClient();
|
||||
return singleton;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// errors.ts — 错误码 → 双语文案映射表(阶段 B 落地)
|
||||
import { ApiError } from './types';
|
||||
import type { Lang } from '../i18n';
|
||||
|
||||
/**
|
||||
* 后端统一返回 {code, message_zh, message_en};前端优先用后端文案,
|
||||
* 兜底用本表(后端文案缺失/网络层错误时)。codes 与 #1 openapi 对齐。
|
||||
*/
|
||||
export const ERROR_TEXT: Record<string, { zh: string; en: string }> = {
|
||||
invalid_credentials: { zh: '邮箱或密码错误', en: 'Wrong email or password' },
|
||||
rate_limited: { zh: '操作过于频繁,请稍后再试', en: 'Too many requests, slow down' },
|
||||
account_locked: { zh: '失败次数过多,账户已临时锁定', en: 'Too many attempts — account temporarily locked' },
|
||||
totp_required: { zh: '需要双重认证动态码', en: 'Two-factor code required' },
|
||||
totp_invalid: { zh: '动态码不正确,请重试', en: 'Invalid code, try again' },
|
||||
code_invalid: { zh: '激活码无效', en: 'Invalid activation code' },
|
||||
code_used: { zh: '激活码已被使用', en: 'Activation code already used' },
|
||||
code_expired: { zh: '激活码已过期', en: 'Activation code expired' },
|
||||
device_limit: { zh: '设备数量已达上限', en: 'Device limit reached' },
|
||||
device_not_found: { zh: '设备不存在', en: 'Device not found' },
|
||||
unauthorized: { zh: '登录已失效,请重新登录', en: 'Session expired, please log in again' },
|
||||
network: { zh: '网络异常,请稍后重试', en: 'Network error, please retry' },
|
||||
unknown: { zh: '操作失败,请稍后重试', en: 'Something went wrong, please retry' },
|
||||
};
|
||||
|
||||
/** 把任意错误转成当前语言文案(后端文案优先,再查表,最后 unknown) */
|
||||
export function bilingual(err: unknown, lang: Lang): string {
|
||||
if (err instanceof ApiError) {
|
||||
const fromServer = lang === 'zh' ? err.message_zh : err.message_en;
|
||||
if (fromServer) return fromServer;
|
||||
const m = ERROR_TEXT[err.code];
|
||||
if (m) return m[lang];
|
||||
}
|
||||
return ERROR_TEXT.unknown[lang];
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
// http.ts — HttpClient:真实后端薄客户端(阶段 B 联调)。
|
||||
// 特性:API 域名池(故障转移) + 指数退避重试 + 统一错误体解析 + 静默续期。
|
||||
import {
|
||||
ApiClient,
|
||||
ApiError,
|
||||
ApiErrorBody,
|
||||
Device,
|
||||
LoginResult,
|
||||
Me,
|
||||
RedeemResult,
|
||||
Session,
|
||||
SubscriptionInfo,
|
||||
TotpSetup,
|
||||
} from './types';
|
||||
import {
|
||||
accessValid,
|
||||
clearSession,
|
||||
getAccessToken,
|
||||
getRefreshToken,
|
||||
setSession,
|
||||
} from './session';
|
||||
|
||||
function domains(): string[] {
|
||||
const raw = process.env.NEXT_PUBLIC_API_DOMAINS || '';
|
||||
const list = raw.split(',').map((s) => s.trim()).filter(Boolean);
|
||||
return list.length ? list : ['/api'];
|
||||
}
|
||||
|
||||
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
interface ReqOpts {
|
||||
method?: string;
|
||||
body?: unknown;
|
||||
auth?: boolean;
|
||||
/** 401 时是否尝试静默续期后重放(默认 true,refresh 自身置 false 防递归) */
|
||||
allowRefresh?: boolean;
|
||||
refreshToken?: string;
|
||||
}
|
||||
|
||||
export class HttpClient implements ApiClient {
|
||||
private refreshing: Promise<Session> | null = null;
|
||||
|
||||
/** 跨域名池 + 退避的核心请求 */
|
||||
private async request<T>(path: string, opts: ReqOpts = {}): Promise<T> {
|
||||
const { method = 'GET', body, auth = true, allowRefresh = true } = opts;
|
||||
|
||||
if (auth && !accessValid() && getRefreshToken()) {
|
||||
await this.ensureFresh();
|
||||
}
|
||||
|
||||
const pool = domains();
|
||||
let lastErr: unknown;
|
||||
|
||||
for (let attempt = 0; attempt < pool.length * 2; attempt++) {
|
||||
const base = pool[attempt % pool.length];
|
||||
try {
|
||||
const headers: Record<string, string> = { Accept: 'application/json' };
|
||||
if (body !== undefined) headers['Content-Type'] = 'application/json';
|
||||
const token = getAccessToken();
|
||||
if (auth && token) headers['Authorization'] = `Bearer ${token}`;
|
||||
if (opts.refreshToken) headers['X-Refresh-Token'] = opts.refreshToken;
|
||||
|
||||
const res = await fetch(base + path, {
|
||||
method,
|
||||
headers,
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
credentials: 'omit',
|
||||
});
|
||||
|
||||
if (res.status === 401 && auth && allowRefresh && getRefreshToken()) {
|
||||
await this.ensureFresh(true);
|
||||
// 续期后重放一次(不再允许二次续期)
|
||||
return this.request<T>(path, { ...opts, allowRefresh: false });
|
||||
}
|
||||
|
||||
if (!res.ok) throw await this.toApiError(res);
|
||||
if (res.status === 204) return undefined as T;
|
||||
return (await res.json()) as T;
|
||||
} catch (err) {
|
||||
lastErr = err;
|
||||
// 业务错误(ApiError)不重试;仅网络/5xx 才退避换域名
|
||||
if (err instanceof ApiError && err.code !== 'network') throw err;
|
||||
await sleep(Math.min(1000 * 2 ** attempt, 4000));
|
||||
}
|
||||
}
|
||||
throw lastErr instanceof ApiError
|
||||
? lastErr
|
||||
: new ApiError({ code: 'network', message_zh: '网络异常,请稍后重试', message_en: 'Network error, please retry' });
|
||||
}
|
||||
|
||||
private async toApiError(res: Response): Promise<ApiError> {
|
||||
let bodyJson: Partial<ApiErrorBody> = {};
|
||||
try {
|
||||
bodyJson = await res.json();
|
||||
} catch {
|
||||
/* non-json error */
|
||||
}
|
||||
const code = bodyJson.code || (res.status === 401 ? 'unauthorized' : 'unknown');
|
||||
const retryAfter = Number(res.headers.get('Retry-After')) || undefined;
|
||||
return new ApiError(
|
||||
{
|
||||
code,
|
||||
message_zh: bodyJson.message_zh || '',
|
||||
message_en: bodyJson.message_en || '',
|
||||
},
|
||||
retryAfter,
|
||||
);
|
||||
}
|
||||
|
||||
private ensureFresh(force = false): Promise<Session> {
|
||||
if (!force && accessValid()) return Promise.resolve(null as unknown as Session);
|
||||
if (this.refreshing) return this.refreshing;
|
||||
this.refreshing = this.refresh().finally(() => {
|
||||
this.refreshing = null;
|
||||
});
|
||||
return this.refreshing;
|
||||
}
|
||||
|
||||
async login(email: string, password: string): Promise<LoginResult> {
|
||||
const r = await this.request<
|
||||
{ kind: 'session'; session: Session } | { kind: 'totp_required'; pending_token: string }
|
||||
>('/v1/auth/login', { method: 'POST', body: { email, password }, auth: false });
|
||||
if ('pending_token' in r) return { kind: 'totp_required', pendingToken: r.pending_token };
|
||||
setSession(r.session);
|
||||
return r;
|
||||
}
|
||||
|
||||
async loginTotp(pendingToken: string, code: string): Promise<Session> {
|
||||
const s = await this.request<Session>('/v1/auth/login/totp', {
|
||||
method: 'POST',
|
||||
body: { pending_token: pendingToken, code },
|
||||
auth: false,
|
||||
});
|
||||
setSession(s);
|
||||
return s;
|
||||
}
|
||||
|
||||
async refresh(): Promise<Session> {
|
||||
const rt = getRefreshToken();
|
||||
if (!rt) throw new ApiError({ code: 'unauthorized', message_zh: '登录已失效', message_en: 'Session expired' });
|
||||
const s = await this.request<Session>('/v1/auth/refresh', {
|
||||
method: 'POST',
|
||||
auth: false,
|
||||
allowRefresh: false,
|
||||
refreshToken: rt,
|
||||
});
|
||||
setSession(s);
|
||||
return s;
|
||||
}
|
||||
|
||||
getMe = () => this.request<Me>('/v1/me');
|
||||
getSubscription = () => this.request<SubscriptionInfo>('/v1/me/subscription');
|
||||
resetSubscription = () => this.request<SubscriptionInfo>('/v1/me/subscription/reset', { method: 'POST' });
|
||||
listDevices = () => this.request<Device[]>('/v1/me/devices');
|
||||
removeDevice = (id: string) => this.request<void>(`/v1/me/devices/${encodeURIComponent(id)}`, { method: 'DELETE' });
|
||||
redeem = (code: string) => this.request<RedeemResult>('/v1/me/redeem', { method: 'POST', body: { code } });
|
||||
|
||||
// 契约增补待 #1 合入:POST /v1/me/totp/setup|verify|disable
|
||||
totpSetup = () => this.request<TotpSetup>('/v1/me/totp/setup', { method: 'POST' });
|
||||
totpVerify = (code: string) => this.request<void>('/v1/me/totp/verify', { method: 'POST', body: { code } });
|
||||
totpDisable = (code: string) => this.request<void>('/v1/me/totp/disable', { method: 'POST', body: { code } });
|
||||
|
||||
async logout(): Promise<void> {
|
||||
try {
|
||||
await this.request<void>('/v1/auth/logout', { method: 'POST' });
|
||||
} finally {
|
||||
clearSession();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
// mock.ts — MockClient:演示数据,供阶段 A 的 UI 验收使用,零网络依赖。
|
||||
import {
|
||||
ApiClient,
|
||||
ApiError,
|
||||
Device,
|
||||
LoginResult,
|
||||
Me,
|
||||
RedeemResult,
|
||||
Session,
|
||||
SubscriptionInfo,
|
||||
TotpSetup,
|
||||
} from './types';
|
||||
import { setSession, clearSession } from './session';
|
||||
|
||||
const delay = (ms = 420) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
function makeSession(): Session {
|
||||
return {
|
||||
accessToken: 'mock-access-' + Math.random().toString(36).slice(2),
|
||||
refreshToken: 'mock-refresh-' + Math.random().toString(36).slice(2),
|
||||
accessExpiresAt: Date.now() + 10 * 60_000,
|
||||
};
|
||||
}
|
||||
|
||||
export class MockClient implements ApiClient {
|
||||
// 演示态:PRO 会员,已开启 2FA(故登录走二段式)
|
||||
private totpEnabled = true;
|
||||
private pending: string | null = null;
|
||||
private subUrl = 'https://sub.pangolin.vpn/s/8f3kx92m';
|
||||
private devices: Device[] = [
|
||||
{ id: 'd1', name: 'iPhone 15 Pro', platform: 'iOS 18', lastActive: '刚刚', current: true },
|
||||
{ id: 'd2', name: 'MacBook Air', platform: 'macOS 15', lastActive: '2 小时前', current: false },
|
||||
];
|
||||
|
||||
async login(email: string, password: string): Promise<LoginResult> {
|
||||
await delay();
|
||||
// 演示锁定/失败:用特定凭证触发,便于验收双语文案
|
||||
if (password === 'locked') {
|
||||
throw new ApiError(
|
||||
{ code: 'account_locked', message_zh: '失败次数过多,账户已临时锁定', message_en: 'Too many attempts — account temporarily locked' },
|
||||
30,
|
||||
);
|
||||
}
|
||||
if (password === 'wrong' || !/\S+@\S+\.\S+/.test(email)) {
|
||||
throw new ApiError({ code: 'invalid_credentials', message_zh: '邮箱或密码错误', message_en: 'Wrong email or password' });
|
||||
}
|
||||
if (this.totpEnabled) {
|
||||
this.pending = 'mock-pending-' + Math.random().toString(36).slice(2);
|
||||
return { kind: 'totp_required', pendingToken: this.pending };
|
||||
}
|
||||
const s = makeSession();
|
||||
setSession(s);
|
||||
return { kind: 'session', session: s };
|
||||
}
|
||||
|
||||
async loginTotp(_pendingToken: string, code: string): Promise<Session> {
|
||||
await delay();
|
||||
if (code === '000000') {
|
||||
throw new ApiError({ code: 'totp_invalid', message_zh: '动态码不正确,请重试', message_en: 'Invalid code, try again' });
|
||||
}
|
||||
const s = makeSession();
|
||||
setSession(s);
|
||||
this.pending = null;
|
||||
return s;
|
||||
}
|
||||
|
||||
async refresh(): Promise<Session> {
|
||||
await delay(150);
|
||||
const s = makeSession();
|
||||
setSession(s);
|
||||
return s;
|
||||
}
|
||||
|
||||
async getMe(): Promise<Me> {
|
||||
await delay(260);
|
||||
return {
|
||||
email: 'me@pangolin.vpn',
|
||||
plan: 'pro',
|
||||
expiresAt: '2026-12-31',
|
||||
devicesUsed: 2,
|
||||
devicesMax: 5,
|
||||
quotaTodayMin: null,
|
||||
dataTodayGB: 0.8,
|
||||
weeklyGB: [1.8, 2.4, 1.2, 3.1, 4.0, 5.2, 2.6],
|
||||
totpEnabled: this.totpEnabled,
|
||||
};
|
||||
}
|
||||
|
||||
async getSubscription(): Promise<SubscriptionInfo> {
|
||||
await delay(160);
|
||||
return { url: this.subUrl };
|
||||
}
|
||||
|
||||
async resetSubscription(): Promise<SubscriptionInfo> {
|
||||
await delay(500);
|
||||
this.subUrl = 'https://sub.pangolin.vpn/s/' + Math.random().toString(36).slice(2, 10);
|
||||
return { url: this.subUrl };
|
||||
}
|
||||
|
||||
async listDevices(): Promise<Device[]> {
|
||||
await delay(220);
|
||||
return [...this.devices];
|
||||
}
|
||||
|
||||
async removeDevice(id: string): Promise<void> {
|
||||
await delay(360);
|
||||
this.devices = this.devices.filter((d) => d.id !== id);
|
||||
}
|
||||
|
||||
async redeem(code: string): Promise<RedeemResult> {
|
||||
await delay(520);
|
||||
const c = code.trim().toUpperCase();
|
||||
if (c === 'LOCKED') {
|
||||
throw new ApiError(
|
||||
{ code: 'rate_limited', message_zh: '操作过于频繁,请稍后再试', message_en: 'Too many requests, slow down' },
|
||||
20,
|
||||
);
|
||||
}
|
||||
if (c === 'USED') {
|
||||
throw new ApiError({ code: 'code_used', message_zh: '激活码已被使用', message_en: 'Activation code already used' });
|
||||
}
|
||||
if (c.length < 4 || c === 'INVALID') {
|
||||
throw new ApiError({ code: 'code_invalid', message_zh: '激活码无效', message_en: 'Invalid activation code' });
|
||||
}
|
||||
return { plan: 'pro', expiresAt: '2027-12-31' };
|
||||
}
|
||||
|
||||
async totpSetup(): Promise<TotpSetup> {
|
||||
await delay(260);
|
||||
const secret = 'JBSWY3DPEHPK3PXP';
|
||||
return {
|
||||
secret,
|
||||
otpauthUri: `otpauth://totp/Pangolin:me@pangolin.vpn?secret=${secret}&issuer=Pangolin`,
|
||||
};
|
||||
}
|
||||
|
||||
async totpVerify(code: string): Promise<void> {
|
||||
await delay(300);
|
||||
if (code === '000000' || code.length !== 6) {
|
||||
throw new ApiError({ code: 'totp_invalid', message_zh: '动态码不正确,请重试', message_en: 'Invalid code, try again' });
|
||||
}
|
||||
this.totpEnabled = true;
|
||||
}
|
||||
|
||||
async totpDisable(code: string): Promise<void> {
|
||||
await delay(300);
|
||||
if (code === '000000' || code.length !== 6) {
|
||||
throw new ApiError({ code: 'totp_invalid', message_zh: '动态码不正确,请重试', message_en: 'Invalid code, try again' });
|
||||
}
|
||||
this.totpEnabled = false;
|
||||
}
|
||||
|
||||
async logout(): Promise<void> {
|
||||
await delay(120);
|
||||
this.pending = null;
|
||||
clearSession();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
// session.ts — 会话存储
|
||||
// access token:仅内存(防 XSS 持久化窃取);refresh token:localStorage(header token 方案)。
|
||||
// 取舍:doc/05 §2 给「HttpOnly+Secure cookie 或 header token」两选;纯静态导出无服务端
|
||||
// 设置 cookie 的能力,故取 header token + localStorage,并配合静默续期与登出失效收敛风险。
|
||||
import type { Session } from './types';
|
||||
|
||||
const REFRESH_KEY = 'pg_uc_refresh';
|
||||
|
||||
let accessToken: string | null = null;
|
||||
let accessExpiresAt = 0;
|
||||
|
||||
export function getAccessToken(): string | null {
|
||||
return accessToken;
|
||||
}
|
||||
|
||||
export function accessValid(skewMs = 15_000): boolean {
|
||||
return !!accessToken && Date.now() < accessExpiresAt - skewMs;
|
||||
}
|
||||
|
||||
export function getRefreshToken(): string | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
try {
|
||||
return window.localStorage.getItem(REFRESH_KEY);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function setSession(s: Session): void {
|
||||
accessToken = s.accessToken;
|
||||
accessExpiresAt = s.accessExpiresAt;
|
||||
if (typeof window !== 'undefined') {
|
||||
try {
|
||||
window.localStorage.setItem(REFRESH_KEY, s.refreshToken);
|
||||
} catch {
|
||||
/* ignore quota / privacy mode */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function clearSession(): void {
|
||||
accessToken = null;
|
||||
accessExpiresAt = 0;
|
||||
if (typeof window !== 'undefined') {
|
||||
try {
|
||||
window.localStorage.removeItem(REFRESH_KEY);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function hasRefresh(): boolean {
|
||||
return !!getRefreshToken();
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
// types.ts — 数据层契约(mock / http 双实现共用)
|
||||
|
||||
export type Plan = 'free' | 'pro';
|
||||
|
||||
export interface Session {
|
||||
/** access token:仅存内存,绝不落盘 */
|
||||
accessToken: string;
|
||||
/** refresh token:localStorage(header token 方案,见 README 取舍说明) */
|
||||
refreshToken: string;
|
||||
/** access 过期时间戳(ms) */
|
||||
accessExpiresAt: number;
|
||||
}
|
||||
|
||||
export interface Me {
|
||||
email: string;
|
||||
plan: Plan;
|
||||
/** PRO 到期日 YYYY-MM-DD;免费版为 null */
|
||||
expiresAt: string | null;
|
||||
devicesUsed: number;
|
||||
devicesMax: number;
|
||||
/** 今日剩余时长(分钟);PRO 为 null 表示不限 */
|
||||
quotaTodayMin: number | null;
|
||||
dataTodayGB: number;
|
||||
/** 近 7 日流量(GB) */
|
||||
weeklyGB: number[];
|
||||
totpEnabled: boolean;
|
||||
}
|
||||
|
||||
export interface SubscriptionInfo {
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface Device {
|
||||
id: string;
|
||||
name: string;
|
||||
platform: string;
|
||||
lastActive: string;
|
||||
current: boolean;
|
||||
}
|
||||
|
||||
export interface RedeemResult {
|
||||
plan: Plan;
|
||||
expiresAt: string | null;
|
||||
}
|
||||
|
||||
export interface TotpSetup {
|
||||
/** base32 密钥 */
|
||||
secret: string;
|
||||
/** otpauth:// URI(可渲染二维码) */
|
||||
otpauthUri: string;
|
||||
}
|
||||
|
||||
/** 统一错误体 —— 与后端约定 {code, message_zh, message_en} */
|
||||
export interface ApiErrorBody {
|
||||
code: string;
|
||||
message_zh: string;
|
||||
message_en: string;
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
code: string;
|
||||
message_zh: string;
|
||||
message_en: string;
|
||||
/** 锁定类错误:剩余秒数 */
|
||||
retryAfter?: number;
|
||||
constructor(body: ApiErrorBody, retryAfter?: number) {
|
||||
super(body.code);
|
||||
this.name = 'ApiError';
|
||||
this.code = body.code;
|
||||
this.message_zh = body.message_zh;
|
||||
this.message_en = body.message_en;
|
||||
this.retryAfter = retryAfter;
|
||||
}
|
||||
}
|
||||
|
||||
/** 登录第一段结果:成功直接给 session,或要求二段 TOTP */
|
||||
export type LoginResult =
|
||||
| { kind: 'session'; session: Session }
|
||||
| { kind: 'totp_required'; pendingToken: string };
|
||||
|
||||
export interface ApiClient {
|
||||
login(email: string, password: string): Promise<LoginResult>;
|
||||
/** 登录二段式:提交 TOTP 动态码换取 session */
|
||||
loginTotp(pendingToken: string, code: string): Promise<Session>;
|
||||
refresh(): Promise<Session>;
|
||||
getMe(): Promise<Me>;
|
||||
getSubscription(): Promise<SubscriptionInfo>;
|
||||
resetSubscription(): Promise<SubscriptionInfo>;
|
||||
listDevices(): Promise<Device[]>;
|
||||
removeDevice(id: string): Promise<void>;
|
||||
redeem(code: string): Promise<RedeemResult>;
|
||||
totpSetup(): Promise<TotpSetup>;
|
||||
totpVerify(code: string): Promise<void>;
|
||||
totpDisable(code: string): Promise<void>;
|
||||
logout(): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
// i18n.ts — 穿山甲 Web 用户中心 · 双语字串(单显,绝不并排)
|
||||
// 直接承袭 design/ui_kits/usercenter/ucparts.jsx 的 UCSTRINGS,并补充设置/设备/TOTP 键。
|
||||
|
||||
export type Lang = 'zh' | 'en';
|
||||
|
||||
type Entry = { zh: string; en: string };
|
||||
|
||||
export const STRINGS: Record<string, Entry> = {
|
||||
navOverview: { zh: '概览', en: 'Overview' },
|
||||
navSub: { zh: '订阅', en: 'Subscription' },
|
||||
navRedeem: { zh: '兑换 & 购买', en: 'Redeem & buy' },
|
||||
navInvite: { zh: '邀请返利', en: 'Referral' },
|
||||
navSettings: { zh: '设置', en: 'Settings' },
|
||||
signOut: { zh: '退出', en: 'Sign out' },
|
||||
|
||||
/* login */
|
||||
loginTitle: { zh: '登录用户中心', en: 'Log in to your account' },
|
||||
loginSub: { zh: '管理订阅、兑换激活码、查看用量', en: 'Manage subscription, redeem codes, track usage' },
|
||||
emailLabel: { zh: '邮箱', en: 'Email' },
|
||||
emailPh: { zh: '你的邮箱地址', en: 'your@email.com' },
|
||||
pwLabel: { zh: '密码', en: 'Password' },
|
||||
pwPh: { zh: '输入密码', en: 'Enter password' },
|
||||
doLogin: { zh: '登录', en: 'Log in' },
|
||||
forgotPw: { zh: '忘记密码?', en: 'Forgot password?' },
|
||||
noAccount: { zh: '没有账户?在 App 内注册', en: 'No account? Sign up in the app' },
|
||||
loginFailed: { zh: '邮箱或密码错误', en: 'Wrong email or password' },
|
||||
loginLocked: { zh: '失败次数过多,账户已临时锁定', en: 'Too many attempts — account temporarily locked' },
|
||||
lockedCountdown: { zh: '请于 {s} 秒后重试', en: 'Try again in {s}s' },
|
||||
|
||||
/* overview */
|
||||
greeting: { zh: '欢迎回来', en: 'Welcome back' },
|
||||
curPlan: { zh: '当前套餐', en: 'Current plan' },
|
||||
freePlan: { zh: '免费版', en: 'Free' },
|
||||
proMember: { zh: 'PRO 会员', en: 'PRO member' },
|
||||
expires: { zh: '有效期至', en: 'Expires' },
|
||||
renew: { zh: '续费 / 升级', en: 'Renew / Upgrade' },
|
||||
quotaToday: { zh: '今日剩余时长', en: 'Time left today' },
|
||||
dataToday: { zh: '今日已用流量', en: 'Data used today' },
|
||||
devices: { zh: '在线设备', en: 'Devices online' },
|
||||
quotaFree: { zh: '免费版 · 每日 10 分钟', en: 'Free · 10 min/day' },
|
||||
usageTitle: { zh: '近 7 日流量 (GB)', en: 'Last 7 days (GB)' },
|
||||
quickSub: { zh: '快速操作', en: 'Quick actions' },
|
||||
qaSub: { zh: '获取订阅链接', en: 'Get subscription' },
|
||||
qaRedeem: { zh: '兑换激活码', en: 'Redeem a code' },
|
||||
qaApp: { zh: '下载 App', en: 'Download apps' },
|
||||
|
||||
/* subscription */
|
||||
subTitle: { zh: '我的订阅', en: 'My subscription' },
|
||||
subDesc: { zh: '订阅链接是你的专属凭证,泄露后他人可使用你的额度。请勿分享。', en: 'Your subscription link is a private credential. Never share it.' },
|
||||
subCopy: { zh: '复制链接', en: 'Copy link' },
|
||||
subCopied: { zh: '已复制', en: 'Copied' },
|
||||
subReset: { zh: '重置链接', en: 'Reset link' },
|
||||
subResetSub: { zh: '旧链接立即失效,所有设备需重新导入', en: 'Old link stops working; re-import on all devices' },
|
||||
subResetOk: { zh: '已重置,请重新导入', en: 'Reset — re-import on your devices' },
|
||||
scanTitle: { zh: '扫码导入', en: 'Scan to import' },
|
||||
scanSub: { zh: '用穿山甲 App 或三方客户端扫码', en: 'Scan with the Pangolin app or a 3rd-party client' },
|
||||
importTitle: { zh: '一键导入三方客户端', en: 'One-tap import' },
|
||||
importSub: { zh: '已安装对应客户端时,点击即自动导入订阅。', en: 'If the client is installed, tapping imports the subscription automatically.' },
|
||||
fmtNote: { zh: '同一链接同时兼容 sing-box / Clash.Meta / v2ray 格式。', en: 'One link serves sing-box / Clash.Meta / v2ray formats.' },
|
||||
|
||||
/* redeem */
|
||||
redeemTitle: { zh: '兑换激活码', en: 'Redeem a code' },
|
||||
redeemPh: { zh: '输入激活码', en: 'Enter activation code' },
|
||||
redeemBtn: { zh: '激活', en: 'Redeem' },
|
||||
redeemOk: { zh: '激活成功 · 套餐已到账', en: 'Activated — plan applied' },
|
||||
buyTitle: { zh: '购买渠道', en: 'Where to buy' },
|
||||
buySub: { zh: '本站不直接收款。通过以下渠道购买激活码,回到本页兑换即可:', en: 'We never take payment on this site. Buy a code via a channel below, then redeem here:' },
|
||||
chStore: { zh: '自助发卡商店', en: 'Self-serve store' },
|
||||
chStoreSub: { zh: '支付宝 / 微信 · 自动发码', en: 'Alipay / WeChat · instant code' },
|
||||
chUsdtSub: { zh: '链上转账 · 最隐私 · 自动发码', en: 'On-chain · most private · instant code' },
|
||||
chEmail: { zh: '邮箱客服', en: 'Email support' },
|
||||
|
||||
/* invite */
|
||||
inviteTitle: { zh: '邀请返利', en: 'Referral' },
|
||||
inviteSub: { zh: '好友通过你的链接注册并付费,你获得其首单 20% 余额返利,可抵扣续费。', en: 'When a friend signs up and pays via your link, you earn 20% of their first order as credit.' },
|
||||
inviteLink: { zh: '我的邀请链接', en: 'My invite link' },
|
||||
invited: { zh: '已邀请', en: 'Invited' },
|
||||
paidUsers: { zh: '已付费', en: 'Converted' },
|
||||
earned: { zh: '累计返利', en: 'Credit earned' },
|
||||
inviteRecord: { zh: '邀请记录', en: 'History' },
|
||||
recEmpty: { zh: '暂无邀请记录', en: 'No referrals yet' },
|
||||
recRegistered: { zh: '已注册', en: 'Registered' },
|
||||
recPaid: { zh: '已付费', en: 'Paid' },
|
||||
|
||||
/* 2FA login step */
|
||||
twoFATitle: { zh: '双重认证', en: 'Two-factor auth' },
|
||||
twoFAHint: { zh: '你的账户已开启双重认证。请输入身份验证器 App 中的 6 位动态码。', en: 'Two-factor auth is on for this account. Enter the 6-digit code from your authenticator app.' },
|
||||
twoFAConfirm: { zh: '验证并登录', en: 'Verify & log in' },
|
||||
twoFABack: { zh: '返回上一步', en: 'Back' },
|
||||
twoFAWrong: { zh: '动态码不正确,请重试', en: 'Invalid code, try again' },
|
||||
|
||||
/* settings */
|
||||
settingsTitle: { zh: '设置', en: 'Settings' },
|
||||
prefTitle: { zh: '偏好', en: 'Preferences' },
|
||||
prefLang: { zh: '显示语言', en: 'Language' },
|
||||
prefTheme: { zh: '外观主题', en: 'Theme' },
|
||||
themeLight: { zh: '浅色', en: 'Light' },
|
||||
themeDark: { zh: '深色', en: 'Dark' },
|
||||
|
||||
/* devices */
|
||||
devTitle: { zh: '设备管理', en: 'Devices' },
|
||||
devSub: { zh: '管理已登录本账户的设备,移除后该设备需重新登录。', en: 'Devices signed in to your account. Removing one signs it out.' },
|
||||
devCurrent: { zh: '当前设备', en: 'This device' },
|
||||
devLastActive: { zh: '最近活跃', en: 'Last active' },
|
||||
devRemove: { zh: '移除', en: 'Remove' },
|
||||
devRemoveConfirmTitle: { zh: '移除此设备?', en: 'Remove this device?' },
|
||||
devRemoveConfirmSub: { zh: '该设备将被登出,需重新输入凭证才能再次连接。', en: 'It will be signed out and must re-authenticate to connect again.' },
|
||||
devEmpty: { zh: '暂无其他设备', en: 'No devices yet' },
|
||||
cancel: { zh: '取消', en: 'Cancel' },
|
||||
confirm: { zh: '确认移除', en: 'Remove' },
|
||||
|
||||
/* TOTP management */
|
||||
totpTitle: { zh: '双重认证 (TOTP)', en: 'Two-factor auth (TOTP)' },
|
||||
totpOff: { zh: '未开启', en: 'Off' },
|
||||
totpOn: { zh: '已开启', en: 'On' },
|
||||
totpDesc: { zh: '用身份验证器 App 生成的一次性动态码保护登录,显著提升账户安全。', en: 'Protect sign-in with one-time codes from an authenticator app.' },
|
||||
totpEnable: { zh: '开启双重认证', en: 'Enable 2FA' },
|
||||
totpDisableBtn: { zh: '关闭双重认证', en: 'Disable 2FA' },
|
||||
totpStep1: { zh: '1. 用身份验证器扫码,或手动输入密钥', en: '1. Scan with an authenticator, or enter the key manually' },
|
||||
totpSecretLabel: { zh: '手动密钥', en: 'Manual key' },
|
||||
totpStep2: { zh: '2. 输入 App 显示的 6 位动态码以确认', en: '2. Enter the 6-digit code shown in the app to confirm' },
|
||||
totpVerifyBtn: { zh: '确认开启', en: 'Confirm & enable' },
|
||||
totpEnabledOk: { zh: '双重认证已开启', en: 'Two-factor auth enabled' },
|
||||
totpDisabledOk: { zh: '双重认证已关闭', en: 'Two-factor auth disabled' },
|
||||
totpDisableHint: { zh: '输入当前动态码以关闭双重认证。', en: 'Enter your current code to disable 2FA.' },
|
||||
copyKey: { zh: '复制密钥', en: 'Copy key' },
|
||||
|
||||
/* generic errors */
|
||||
errNetwork: { zh: '网络异常,请稍后重试', en: 'Network error, please retry' },
|
||||
errUnknown: { zh: '操作失败,请稍后重试', en: 'Something went wrong, please retry' },
|
||||
loading: { zh: '加载中…', en: 'Loading…' },
|
||||
};
|
||||
|
||||
export type TFn = (key: string, vars?: Record<string, string | number>) => string;
|
||||
|
||||
export function makeT(lang: Lang): TFn {
|
||||
return (key, vars) => {
|
||||
const e = STRINGS[key];
|
||||
let s = e ? e[lang] || key : key;
|
||||
if (vars) for (const k of Object.keys(vars)) s = s.replace(`{${k}}`, String(vars[k]));
|
||||
return s;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
'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;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
// 纯静态导出:output:'export' 产出 out/,无任何 Node 服务端能力,可任意静态托管。
|
||||
// 数据全走客户端 API(lib/api),与官网共享「被打击时客户端内同功能仍可用」的灾备模型。
|
||||
const nextConfig = {
|
||||
output: 'export',
|
||||
reactStrictMode: true,
|
||||
trailingSlash: true,
|
||||
// 静态导出无图片优化服务,关闭以保证产物无服务端依赖。
|
||||
images: { unoptimized: true },
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
Generated
+5375
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "pangolin-usercenter",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "穿山甲 · Web 用户中心 (Next.js static export)",
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"postbuild": "node scripts/add-sri.mjs",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"redline": "node scripts/redline-scan.mjs",
|
||||
"check": "npm run redline && npm run build"
|
||||
},
|
||||
"dependencies": {
|
||||
"next": "14.2.35",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "20.14.10",
|
||||
"@types/react": "18.3.3",
|
||||
"@types/react-dom": "18.3.0",
|
||||
"eslint": "8.57.0",
|
||||
"eslint-config-next": "14.2.35",
|
||||
"typescript": "5.5.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
# Cloudflare Pages 响应头(同官网管线)。严格 CSP + 安全基线。
|
||||
# 说明(取舍):纯静态导出无服务端,无法下发 nonce;Next 会注入少量内联
|
||||
# bootstrap 脚本,故 script-src 保留 'unsafe-inline',并用 SRI(构建期注入 integrity)
|
||||
# 为自有 chunk 提供完整性校验。style-src 因设计稿大量内联样式同样需要 'unsafe-inline'。
|
||||
/*
|
||||
Content-Security-Policy: default-src 'self'; base-uri 'none'; object-src 'none'; frame-ancestors 'none'; form-action 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data:; connect-src 'self' https:; manifest-src 'self'; upgrade-insecure-requests
|
||||
X-Content-Type-Options: nosniff
|
||||
Referrer-Policy: strict-origin-when-cross-origin
|
||||
X-Frame-Options: DENY
|
||||
Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=()
|
||||
Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
|
||||
@@ -0,0 +1,218 @@
|
||||
/* =============================================================
|
||||
穿山甲 VPN · Pangolin VPN — Design Tokens
|
||||
colors_and_type.css
|
||||
Single source of truth: color ramps, semantic colors (light + dark),
|
||||
typography, spacing, radii, shadows, motion.
|
||||
============================================================= */
|
||||
|
||||
/* ---- Webfonts (open-source; documented as the brand's chosen faces) ----
|
||||
Sora — display / headings (geometric, friendly)
|
||||
Manrope — body / UI (humanist geometric)
|
||||
Noto Sans SC — Chinese (CJK companion)
|
||||
JetBrains Mono — data readouts (IP, speed, keys) */
|
||||
@import url('https://fonts.googleapis.com/css2?family=Sora:wght@500;600;700&family=Manrope:wght@400;500;600;700&family=Noto+Sans+SC:wght@400;500;700&family=JetBrains+Mono:wght@400;500&display=swap');
|
||||
|
||||
:root {
|
||||
/* ===========================================================
|
||||
1. PRIMITIVE COLOR RAMPS
|
||||
=========================================================== */
|
||||
|
||||
/* Clay / Copper — the pangolin-armor primary (warm earth) */
|
||||
--clay-50: #FAF3ED;
|
||||
--clay-100: #F2E2D4;
|
||||
--clay-200: #E6C7AC;
|
||||
--clay-300: #D9A982;
|
||||
--clay-400: #CC8B5C;
|
||||
--clay-500: #B96A3D; /* ← brand primary */
|
||||
--clay-600: #9E5630;
|
||||
--clay-700: #7E4426;
|
||||
--clay-800: #5E331D;
|
||||
--clay-900: #3D2213;
|
||||
|
||||
/* Sand / Taupe — warm neutral ramp */
|
||||
--sand-50: #FAF8F4;
|
||||
--sand-100: #F2EEE7;
|
||||
--sand-200: #E6DFD3;
|
||||
--sand-300: #D2C8B8;
|
||||
--sand-400: #B0A491;
|
||||
--sand-500: #8C8270;
|
||||
--sand-600: #6B6253;
|
||||
--sand-700: #4E4940;
|
||||
--sand-800: #2E2A24;
|
||||
--sand-900: #1F1C18;
|
||||
--sand-950: #14110E; /* warm espresso near-black */
|
||||
|
||||
/* Semantic hues (earth-tuned) */
|
||||
--green-400: #7FB07A;
|
||||
--green-500: #5B8C5A; /* connected / secure */
|
||||
--green-600: #467046;
|
||||
--amber-400: #E2B05A;
|
||||
--amber-500: #D69A3C; /* connecting / warning */
|
||||
--amber-600: #B47E29;
|
||||
--red-400: #D4715A;
|
||||
--red-500: #C0533B; /* error / disconnect */
|
||||
--red-600: #9E4230;
|
||||
|
||||
/* ===========================================================
|
||||
2. SEMANTIC TOKENS — LIGHT THEME (default)
|
||||
=========================================================== */
|
||||
|
||||
/* Backgrounds & surfaces */
|
||||
--bg: var(--sand-50); /* app canvas */
|
||||
--bg-subtle: var(--sand-100); /* striped / inset regions */
|
||||
--surface: #FFFFFF; /* cards, sheets */
|
||||
--surface-2: var(--sand-50); /* nested surface */
|
||||
--overlay: rgba(31, 28, 24, 0.45);
|
||||
|
||||
/* Foreground / text */
|
||||
--fg1: var(--sand-900); /* primary text */
|
||||
--fg2: var(--sand-600); /* secondary text */
|
||||
--fg3: var(--sand-500); /* tertiary / captions */
|
||||
--fg-on-accent: #FFFFFF; /* text on clay fills */
|
||||
|
||||
/* Brand / accent */
|
||||
--accent: var(--clay-500);
|
||||
--accent-hover: var(--clay-600);
|
||||
--accent-press: var(--clay-700);
|
||||
--accent-subtle: var(--clay-50);
|
||||
--accent-border: var(--clay-200);
|
||||
|
||||
/* Borders & lines */
|
||||
--border: var(--sand-200);
|
||||
--border-strong: var(--sand-300);
|
||||
--ring: rgba(185, 106, 61, 0.35); /* focus ring (clay) */
|
||||
|
||||
/* Status */
|
||||
--success: var(--green-500);
|
||||
--success-subtle: #E9F0E6;
|
||||
--warning: var(--amber-500);
|
||||
--warning-subtle: #F8EED6;
|
||||
--danger: var(--red-500);
|
||||
--danger-subtle: #F6E1DA;
|
||||
|
||||
/* ===========================================================
|
||||
3. TYPOGRAPHY
|
||||
=========================================================== */
|
||||
--font-display: 'Sora', 'Noto Sans SC', system-ui, sans-serif;
|
||||
--font-sans: 'Manrope', 'Noto Sans SC', system-ui, sans-serif;
|
||||
--font-cjk: 'Noto Sans SC', 'Manrope', system-ui, sans-serif;
|
||||
--font-mono: 'JetBrains Mono', ui-monospace, 'SFMono-Regular', monospace;
|
||||
|
||||
/* Type scale (root 16px) */
|
||||
--text-display-xl: 3rem; /* 48 */
|
||||
--text-display: 2.25rem; /* 36 */
|
||||
--text-h1: 1.875rem; /* 30 */
|
||||
--text-h2: 1.5rem; /* 24 */
|
||||
--text-h3: 1.25rem; /* 20 */
|
||||
--text-body-lg: 1.125rem; /* 18 */
|
||||
--text-body: 1rem; /* 16 */
|
||||
--text-sm: 0.875rem; /* 14 */
|
||||
--text-caption: 0.75rem; /* 12 */
|
||||
|
||||
--leading-tight: 1.15;
|
||||
--leading-snug: 1.3;
|
||||
--leading-normal:1.5;
|
||||
--leading-relaxed:1.65;
|
||||
|
||||
--tracking-tight: -0.02em;
|
||||
--tracking-snug: -0.01em;
|
||||
--tracking-wide: 0.04em;
|
||||
--tracking-caps: 0.08em;
|
||||
|
||||
/* ===========================================================
|
||||
4. SPACING (4px base)
|
||||
=========================================================== */
|
||||
--space-0: 0;
|
||||
--space-1: 0.25rem; /* 4 */
|
||||
--space-2: 0.5rem; /* 8 */
|
||||
--space-3: 0.75rem; /* 12 */
|
||||
--space-4: 1rem; /* 16 */
|
||||
--space-5: 1.25rem; /* 20 */
|
||||
--space-6: 1.5rem; /* 24 */
|
||||
--space-8: 2rem; /* 32 */
|
||||
--space-10: 2.5rem; /* 40 */
|
||||
--space-12: 3rem; /* 48 */
|
||||
--space-16: 4rem; /* 64 */
|
||||
|
||||
/* ===========================================================
|
||||
5. RADII (generous = friendly)
|
||||
=========================================================== */
|
||||
--radius-sm: 6px;
|
||||
--radius-md: 10px;
|
||||
--radius-lg: 14px;
|
||||
--radius-xl: 20px;
|
||||
--radius-2xl: 28px;
|
||||
--radius-full: 999px;
|
||||
|
||||
/* ===========================================================
|
||||
6. SHADOWS (warm-tinted, soft)
|
||||
=========================================================== */
|
||||
--shadow-sm: 0 1px 2px rgba(45, 30, 20, 0.06);
|
||||
--shadow-md: 0 4px 14px rgba(45, 30, 20, 0.08);
|
||||
--shadow-lg: 0 12px 32px rgba(45, 30, 20, 0.12);
|
||||
--shadow-xl: 0 24px 60px rgba(45, 30, 20, 0.16);
|
||||
--shadow-focus: 0 0 0 4px var(--ring);
|
||||
|
||||
/* ===========================================================
|
||||
7. MOTION
|
||||
=========================================================== */
|
||||
--ease-out: cubic-bezier(0.22, 1, 0.36, 1); /* @kind other */
|
||||
--ease-in-out: cubic-bezier(0.65, 0, 0.35, 1); /* @kind other */
|
||||
--dur-fast: 140ms; /* @kind other */
|
||||
--dur-base: 220ms; /* @kind other */
|
||||
--dur-slow: 360ms; /* @kind other */
|
||||
}
|
||||
|
||||
/* ===========================================================
|
||||
DARK THEME — warm espresso
|
||||
Apply via [data-theme="dark"] on <html> or any container.
|
||||
=========================================================== */
|
||||
[data-theme="dark"] {
|
||||
--bg: var(--sand-950);
|
||||
--bg-subtle: var(--sand-900);
|
||||
--surface: #221E19;
|
||||
--surface-2: #2A251F;
|
||||
--overlay: rgba(0, 0, 0, 0.6);
|
||||
|
||||
--fg1: #F4EFE8;
|
||||
--fg2: #B6AC9C;
|
||||
--fg3: #897F6F;
|
||||
--fg-on-accent: #1F1C18;
|
||||
|
||||
--accent: var(--clay-400);
|
||||
--accent-hover: var(--clay-300);
|
||||
--accent-press: var(--clay-500);
|
||||
--accent-subtle: rgba(204, 139, 92, 0.14);
|
||||
--accent-border: rgba(204, 139, 92, 0.30);
|
||||
|
||||
--border: rgba(242, 238, 231, 0.10);
|
||||
--border-strong: rgba(242, 238, 231, 0.18);
|
||||
--ring: rgba(204, 139, 92, 0.45);
|
||||
|
||||
--success: var(--green-400);
|
||||
--success-subtle: rgba(127, 176, 122, 0.16);
|
||||
--warning: var(--amber-400);
|
||||
--warning-subtle: rgba(226, 176, 90, 0.16);
|
||||
--danger: var(--red-400);
|
||||
--danger-subtle: rgba(212, 113, 90, 0.16);
|
||||
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.4);
|
||||
--shadow-md: 0 4px 14px rgba(0, 0, 0, 0.45);
|
||||
--shadow-lg: 0 12px 32px rgba(0, 0, 0, 0.5);
|
||||
--shadow-xl: 0 24px 60px rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
|
||||
/* ===========================================================
|
||||
SEMANTIC TYPE CLASSES (use directly in markup)
|
||||
=========================================================== */
|
||||
.t-display-xl { font-family: var(--font-display); font-size: var(--text-display-xl); font-weight: 700; line-height: var(--leading-tight); letter-spacing: var(--tracking-tight); }
|
||||
.t-display { font-family: var(--font-display); font-size: var(--text-display); font-weight: 700; line-height: var(--leading-tight); letter-spacing: var(--tracking-tight); }
|
||||
.t-h1 { font-family: var(--font-display); font-size: var(--text-h1); font-weight: 600; line-height: var(--leading-snug); letter-spacing: var(--tracking-snug); }
|
||||
.t-h2 { font-family: var(--font-display); font-size: var(--text-h2); font-weight: 600; line-height: var(--leading-snug); letter-spacing: var(--tracking-snug); }
|
||||
.t-h3 { font-family: var(--font-sans); font-size: var(--text-h3); font-weight: 600; line-height: var(--leading-snug); }
|
||||
.t-body-lg { font-family: var(--font-sans); font-size: var(--text-body-lg); font-weight: 400; line-height: var(--leading-relaxed); }
|
||||
.t-body { font-family: var(--font-sans); font-size: var(--text-body); font-weight: 400; line-height: var(--leading-normal); }
|
||||
.t-sm { font-family: var(--font-sans); font-size: var(--text-sm); font-weight: 400; line-height: var(--leading-normal); }
|
||||
.t-caption { font-family: var(--font-sans); font-size: var(--text-caption); font-weight: 500; line-height: var(--leading-normal); }
|
||||
.t-overline { font-family: var(--font-sans); font-size: var(--text-caption); font-weight: 600; text-transform: uppercase; letter-spacing: var(--tracking-caps); }
|
||||
.t-mono { font-family: var(--font-mono); font-size: var(--text-sm); font-weight: 400; font-feature-settings: 'tnum' 1; }
|
||||
@@ -0,0 +1,98 @@
|
||||
#!/usr/bin/env node
|
||||
// add-sri.mjs — 构建后为导出 HTML 中的本地 <script>/<link rel=stylesheet> 注入
|
||||
// Subresource Integrity (sha384) + crossorigin。满足 doc/05 §2「用户中心开启 SRI」。
|
||||
// 纯静态导出:Next 不内置 SRI,故在 out/ 上后处理。外链(Google Fonts 等)不注入,由 CSP 约束。
|
||||
import { createHash } from 'node:crypto';
|
||||
import { readFile, writeFile, readdir, stat } from 'node:fs/promises';
|
||||
import { join, normalize } from 'node:path';
|
||||
|
||||
const OUT = 'out';
|
||||
|
||||
async function walk(dir) {
|
||||
const entries = await readdir(dir, { withFileTypes: true });
|
||||
const files = [];
|
||||
for (const e of entries) {
|
||||
const p = join(dir, e.name);
|
||||
if (e.isDirectory()) files.push(...(await walk(p)));
|
||||
else if (e.name.endsWith('.html')) files.push(p);
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
const cache = new Map();
|
||||
async function sriFor(localPath) {
|
||||
if (cache.has(localPath)) return cache.get(localPath);
|
||||
try {
|
||||
const buf = await readFile(localPath);
|
||||
const hash = 'sha384-' + createHash('sha384').update(buf).digest('base64');
|
||||
cache.set(localPath, hash);
|
||||
return hash;
|
||||
} catch {
|
||||
cache.set(localPath, null);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function isLocal(url) {
|
||||
return url && !/^https?:\/\//i.test(url) && !url.startsWith('//') && !url.startsWith('data:');
|
||||
}
|
||||
|
||||
async function resolveAsset(url) {
|
||||
const clean = url.split('?')[0].split('#')[0];
|
||||
const rel = clean.startsWith('/') ? clean.slice(1) : clean;
|
||||
const candidate = normalize(join(OUT, rel));
|
||||
try {
|
||||
await stat(candidate);
|
||||
return candidate;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function processHtml(file) {
|
||||
let html = await readFile(file, 'utf8');
|
||||
let count = 0;
|
||||
|
||||
// <script ... src="...">
|
||||
const scriptRe = /<script\b[^>]*\bsrc=["']([^"']+)["'][^>]*><\/script>/gi;
|
||||
const linkRe = /<link\b[^>]*\brel=["']stylesheet["'][^>]*>/gi;
|
||||
|
||||
const replacements = [];
|
||||
let m;
|
||||
while ((m = scriptRe.exec(html))) replacements.push({ tag: m[0], url: m[1] });
|
||||
while ((m = linkRe.exec(html))) {
|
||||
const href = /href=["']([^"']+)["']/i.exec(m[0]);
|
||||
if (href) replacements.push({ tag: m[0], url: href[1] });
|
||||
}
|
||||
|
||||
for (const { tag, url } of replacements) {
|
||||
if (!isLocal(url) || /integrity=/.test(tag)) continue;
|
||||
const asset = await resolveAsset(url);
|
||||
if (!asset) continue;
|
||||
const hash = await sriFor(asset);
|
||||
if (!hash) continue;
|
||||
const newTag = tag.replace(/(\s*\/?>)(\s*(?:<\/script>)?)\s*$/, (mm, close, rest) => ` integrity="${hash}" crossorigin="anonymous"${close}${rest}`);
|
||||
if (newTag !== tag) {
|
||||
html = html.replace(tag, newTag);
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
await writeFile(file, html);
|
||||
return count;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
let files;
|
||||
try {
|
||||
files = await walk(OUT);
|
||||
} catch {
|
||||
console.error(`[sri] 找不到 ${OUT}/,请先 next build`);
|
||||
process.exit(1);
|
||||
}
|
||||
let total = 0;
|
||||
for (const f of files) total += await processHtml(f);
|
||||
console.log(`[sri] 处理 ${files.length} 个 HTML,注入 integrity ${total} 处`);
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,63 @@
|
||||
#!/usr/bin/env node
|
||||
// redline-scan.mjs — 铁律 13 红线词扫描(CI 红线)。
|
||||
// 扫描 UI 源面(app/ components/ lib/)中的红线词;命中即非零退出。
|
||||
// 说明:「VPN」仅可出现在技术文档/代码注释,绝不进 UI 文案;域名占位 pangolin.vpn 为小写
|
||||
// `.vpn`,与红线词大写 `VPN` 不冲突,故按大小写敏感匹配大写 VPN 以避开域名误报。
|
||||
import { readdir, readFile } from 'node:fs/promises';
|
||||
import { join, extname } from 'node:path';
|
||||
|
||||
const ROOTS = ['app', 'components', 'lib'];
|
||||
const EXT = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.css']);
|
||||
|
||||
// 红线词(doc CLAUDE §1 铁律 13)。中文短语 + 大写英文术语。
|
||||
const REDLINES = [
|
||||
{ re: /VPN/g, label: 'VPN' },
|
||||
{ re: /翻墙/g, label: '翻墙' },
|
||||
{ re: /科学上网/g, label: '科学上网' },
|
||||
{ re: /突破封锁/g, label: '突破封锁' },
|
||||
{ re: /自由穿越/g, label: '自由穿越' },
|
||||
{ re: /梯子/g, label: '梯子' },
|
||||
{ re: /机场/g, label: '机场' },
|
||||
{ re: /Go anywhere/gi, label: 'Go anywhere' },
|
||||
];
|
||||
|
||||
async function walk(dir) {
|
||||
let out = [];
|
||||
let entries;
|
||||
try {
|
||||
entries = await readdir(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return out;
|
||||
}
|
||||
for (const e of entries) {
|
||||
if (e.name === 'node_modules' || e.name === 'out' || e.name === '.next') continue;
|
||||
const p = join(dir, e.name);
|
||||
if (e.isDirectory()) out = out.concat(await walk(p));
|
||||
else if (EXT.has(extname(e.name))) out.push(p);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
let files = [];
|
||||
for (const r of ROOTS) files = files.concat(await walk(r));
|
||||
const hits = [];
|
||||
for (const f of files) {
|
||||
const text = await readFile(f, 'utf8');
|
||||
const lines = text.split('\n');
|
||||
lines.forEach((line, i) => {
|
||||
for (const rl of REDLINES) {
|
||||
rl.re.lastIndex = 0;
|
||||
if (rl.re.test(line)) hits.push({ f, n: i + 1, word: rl.label, line: line.trim().slice(0, 100) });
|
||||
}
|
||||
});
|
||||
}
|
||||
if (hits.length) {
|
||||
console.error(`✗ 红线词扫描命中 ${hits.length} 处:`);
|
||||
for (const h of hits) console.error(` ${h.f}:${h.n} [${h.word}] ${h.line}`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`✓ 红线词扫描通过(扫描 ${files.length} 个文件,零命中)`);
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [{ "name": "next" }],
|
||||
"paths": { "@/*": ["./*"] }
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules", "out"]
|
||||
}
|
||||
Reference in New Issue
Block a user