feat(web/usercenter): Next.js 用户中心静态导出 + mock/http 双数据层 (tsk_3FIPC8lSnAfJ)
新建 web/usercenter/:Next.js App Router + output:'export' 纯静态导出,
直接复用 design/ui_kits/usercenter React 源码(概览/订阅/兑换/邀请/设置)。
阶段 A(mock,本提交):
- 复刻五大页面,明/暗 × zh/en 四态;colors_and_type.css 原样链入;
顶栏主题切换 + 语言段控,移动端底部 Tab + 左右滑动切换。
- 设置页新增:偏好(语言/主题) + 设备管理(列表/移除/二次确认+刷新) +
TOTP 2FA(绑定二维码占位+密钥/验证/解禁),登录二段式 TOTP。
- 数据层 lib/api:ApiClient 抽象 + MockClient/HttpClient 双实现,构建期
NEXT_PUBLIC_API_MODE 切换;统一错误体 {code,message_zh,message_en} → 双语映射。
- 会话:access token 仅内存,refresh header token + localStorage,静默续期,
登出失效(取舍:静态导出无服务端 cookie 能力,详见 README)。
- 安全:构建期注入 SRI(sha384);_headers 严格 CSP + 安全基线;
红线词扫描(铁律13) CI 红线,零命中。
阶段 B(占位待联调):HttpClient 已写好域名池+退避重试+401 续期;
TOTP/登录二段式端点占位待 #1 契约增补;me/redeem/devices 切真实链路依赖 #2/#3/#4。
验证:npm run lint / redline / build 均通过,静态产物 out/ 无服务端依赖。
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user