feat: 同步 design/ 设计系统(含 iPad tablet kit) + 架构任务拆分(todo/)

design/ 同步自最新设计导出,新增 ui_kits/tablet/ 平板分栏布局;todo/ 录入 18 个并行实施任务。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-06-11 23:57:57 +08:00
parent 3ae96ef229
commit a642bf16a2
85 changed files with 19946 additions and 0 deletions
+22
View File
@@ -0,0 +1,22 @@
# 穿山甲 — Web 用户中心 UI Kit
phase-1 网页闭环(登录 → 取订阅 → 三方导入 → 兑换)的高保真原型;phase-3 自研后端时作为前端蓝本。中英单语言切换。
## 页面
- **登录**(`UCLogin`):邮箱 + 密码卡片;注册引导去 App(对齐"注册在 App 内完成"的流程)。从顶栏「退出」可回看。
- **概览**(`UCOverview`):套餐横幅(续费/升级)、今日时长/流量/设备指标卡、近 7 日用量图、快速操作。
- **订阅**(`UCSub`)——核心页:订阅链接(复制/重置,重置提示旧链接失效)、扫码导入占位、**一键导入三方客户端**(穿山甲 App / Shadowrocket / Clash Verge / v2rayN / sing-box),注明一条链接多格式兼容。
- **兑换 & 购买**(`UCRedeem`):激活码兑换 + 渠道卡(自助发卡 / **USDT TRC20** / Telegram / LINE / 邮箱)。
- **邀请返利**(`UCInvite`):邀请链接复制、已邀请/已付费/累计返利指标、邀请记录表。
## 文件
| 文件 | 内容 |
|---|---|
| `index.html` | 挂载 `UserCenterApp`(默认进概览;顶栏可登出回登录页) |
| `ucparts.jsx` | `UCIcon`(内联 Lucide)/ `UCMark`(穿山甲标)/ `UCSTRINGS` i18n |
| `ucapp.jsx` | 全部视图 + 顶栏导航壳 |
## 备注
- 套餐口径引用根 `CLAUDE.md` §7;渠道为演示占位。
- 二维码为占位框(落地由后端按订阅链接生成)。
- 设备管理/账户信息已集成在移动与桌面客户端,本站不重复(职责:订阅分发 + 兑换 + 裂变)。
+29
View File
@@ -0,0 +1,29 @@
<!-- @dsCard group="UI Kit — Web 用户中心" name="UI Kit · Web 用户中心" subtitle="概览 / 订阅导入 / 兑换 / 邀请返利 · 中英切换" viewport="1000x720" -->
<!doctype html>
<html lang="zh">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>穿山甲 · 用户中心</title>
<link rel="stylesheet" href="../../colors_and_type.css">
<style>
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); } }
</style>
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
</head>
<body>
<div id="root"></div>
<script type="text/babel" src="ucparts.jsx?v8"></script>
<script type="text/babel" src="ucapp.jsx?v8"></script>
<script type="text/babel">
ReactDOM.createRoot(document.getElementById('root')).render(<UserCenterApp />);
</script>
</body>
</html>
@@ -0,0 +1,13 @@
<!-- @dsCard group="UI Kit — Web 用户中心" name="UI Kit · 用户中心 · 移动端" subtitle="390px 手机布局 · 横滑导航 · 单列卡片" viewport="390x780" -->
<!doctype html>
<html lang="zh">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>穿山甲 · 用户中心移动端预览</title>
<style>html,body{margin:0;padding:0;height:100%;background:#FAF8F4}iframe{border:0;width:390px;height:100vh;display:block;margin:0 auto}</style>
</head>
<body>
<iframe src="index.html?v8" title="穿山甲用户中心 · 移动端"></iframe>
</body>
</html>
+367
View File
@@ -0,0 +1,367 @@
/* ucapp.jsx — 穿山甲 Web 用户中心 · views */
const { useState: useStateU } = React;
const ucCard = { background:'var(--surface)', border:'1px solid var(--border)', borderRadius:'var(--radius-xl)', boxShadow:'var(--shadow-sm)' };
const ucInput = { 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' };
/* viewport hook — mobile ≤ 700px */
function useIsMobileUC() {
const [m, setM] = useStateU(typeof window !== 'undefined' && window.innerWidth <= 700);
React.useEffect(() => {
const f = () => setM(window.innerWidth <= 700);
window.addEventListener('resize', f);
return () => window.removeEventListener('resize', f);
}, []);
return m;
}
function UCLang({ lang, setLang }) {
return <div style={{ display:'flex', background:'var(--bg-subtle)', borderRadius:999, padding:3, gap:2 }}>
{[['zh','中文'],['en','EN']].map(([v,l])=>(
<button key={v} onClick={()=>setLang(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>;
}
/* ───────── Login (+ PRO 2FA step) ───────── */
function UCLogin({ t, lang, setLang, onDone }) {
const [email,setEmail]=useStateU('');
const [pw,setPw]=useStateU('');
const [step,setStep]=useStateU('cred'); // cred | totp
const [otp,setOtp]=useStateU('');
const ok=/\S+@\S+\.\S+/.test(email)&&pw.length>0;
return (
<div style={{ minHeight:'100vh', display:'flex', alignItems:'center', justifyContent:'center', background:'var(--bg)', padding:24 }}>
<div style={{ ...ucCard, 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 }}>
<UCMark 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>
<UCLang 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={ucInput} 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={ucInput} type="password" value={pw} onChange={e=>setPw(e.target.value)} placeholder={t('pwPh')} />
</label>
<div style={{ textAlign:'right', margin:'10px 0 18px' }}><span style={{ fontSize:13, color:'var(--accent)', fontWeight:600, cursor:'pointer' }}>{t('forgotPw')}</span></div>
<button onClick={ok?()=>setStep('totp'):undefined} disabled={!ok} style={{ width:'100%', border:'none', borderRadius:'var(--radius-full)', padding:'13px', fontFamily:'var(--font-sans)', fontWeight:700, fontSize:15, cursor:ok?'pointer':'not-allowed', background:'var(--accent)', color:'var(--fg-on-accent)', opacity:ok?1:0.45 }}>{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 }}>
<UCIcon 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))} placeholder="······"
style={{ ...ucInput, fontFamily:'var(--font-mono)', fontSize:22, letterSpacing:'0.5em', textAlign:'center', padding:'14px' }} />
<button onClick={otp.length===6?onDone:undefined} disabled={otp.length!==6} style={{ width:'100%', border:'none', borderRadius:'var(--radius-full)', padding:'13px', marginTop:16, fontFamily:'var(--font-sans)', fontWeight:700, fontSize:15, cursor:otp.length===6?'pointer':'not-allowed', background:'var(--accent)', color:'var(--fg-on-accent)', opacity:otp.length===6?1:0.45 }}>{t('twoFAConfirm')}</button>
<div onClick={()=>setStep('cred')} style={{ textAlign:'center', fontSize:12.5, color:'var(--fg3)', marginTop:18, cursor:'pointer' }}>{t('twoFABack')}</div>
</>)}
</div>
</div>
);
}
/* ───────── Overview ───────── */
function UCOverview({ t, lang, free, mobile, goSub, goRedeem }) {
const vals=[1.8,2.4,1.2,3.1,4.0,5.2,2.6]; const max=5.2;
const labels = lang==='zh'?['一','二','三','四','五','六','日']:['M','T','W','T','F','S','S'];
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 }}>me@pangolin.vpn</div>
</div>
{/* plan banner */}
<div style={{ ...ucCard, 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' }}><UCIcon name="crown" size={23} color="#fff" /></div>
<div>
<div style={{ fontSize:12, opacity:.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:.8, marginTop:3, fontFamily:'var(--font-mono)', whiteSpace:'nowrap' }}>{free?t('quotaFree'):`${t('expires')} 2026-12-31`}</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 }}><UCIcon 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 }}>
{[['clock',t('quotaToday'), free?'6':'∞', free?'min':''],['arrow-down',t('dataToday'),'0.8','GB'],['smartphone',t('devices'),'2 / 5','']].map(([ic,l,v,u])=>(
<div key={l} style={{ ...ucCard, padding:'16px 18px' }}>
<div style={{ display:'flex', alignItems:'center', gap:7, color:'var(--fg3)', fontSize:12, fontWeight:600 }}><UCIcon 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={{ ...ucCard, 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:.85 }}></div>
<div style={{ fontSize:10.5, color:'var(--fg3)' }}>{labels[i]}</div>
</div>
))}
</div>
</div>
<div style={{ ...ucCard, 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]].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' }}>
<UCIcon name={ic} size={16} color="var(--accent)" />
<span style={{ flex:1, fontSize:13.5, fontWeight:600, color:'var(--fg1)' }}>{l}</span>
<UCIcon name="chevron-right" size={15} color="var(--fg3)" />
</button>
))}
</div>
</div>
</div>
</div>
);
}
/* ───────── Subscription ───────── */
function UCSub({ t, lang, mobile }) {
const [copied,setCopied]=useStateU(false);
const [reset,setReset]=useStateU(false);
const url='https://sub.pangolin.vpn/s/8f3kx92m' + (reset?'-new':'');
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' },
];
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={{ ...ucCard, 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 }}>
<UCIcon 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}</span>
</div>
<div style={{ display:'flex', gap:10, flexWrap:'wrap' }}>
<button onClick={()=>{setCopied(true);setTimeout(()=>setCopied(false),1800);}} 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' }}>
<UCIcon name={copied?'check':'copy'} size={15} color="#fff" />{copied?t('subCopied'):t('subCopy')}
</button>
<button onClick={()=>setReset(true)} 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:'pointer' }}>
<UCIcon 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&&<UCIcon name="check-circle" size={13} color="var(--success)" />}{reset?t('subResetOk'):t('subResetSub')}
</div>
</div>
{/* QR */}
<div style={{ ...ucCard, 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 }}>
<UCIcon 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={{ ...ucCard, 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 }}><UCIcon 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>
);
}
/* ───────── Redeem ───────── */
function UCRedeem({ t, lang }) {
const [code,setCode]=useStateU('');
const [ok,setOk]=useStateU(false);
const channels=[
{ 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:'@PangolinVPN_bot', mono:true },
{ icon:'message-circle', name:'LINE', sub:'@pangolinvpn', mono:true },
{ icon:'mail', name:t('chEmail'), sub:'buy@pangolin.vpn', mono:true },
];
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={{ ...ucCard, 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' }}><UCIcon 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())} placeholder={t('redeemPh')} style={{ ...ucInput, fontFamily:'var(--font-mono)', letterSpacing:'0.08em' }} />
<button onClick={()=>code.length>=4&&setOk(true)} style={{ border:'none', borderRadius:'var(--radius-md)', padding:'0 22px', background:'var(--accent)', color:'#fff', fontWeight:700, fontSize:14, cursor:'pointer', flexShrink:0 }}>{t('redeemBtn')}</button>
</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 }}><UCIcon 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>
<UCIcon name="external-link" size={15} color="var(--fg3)" />
</button>
))}
</div>
</div>
</div>
);
}
/* ───────── Invite ───────── */
function UCInvite({ t, lang }) {
const [copied,setCopied]=useStateU(false);
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 },
];
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={{ ...ucCard, 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 }}>
<UCIcon 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' }}>https://pangolin.vpn/r/PG8F3K</span>
</div>
<button onClick={()=>{setCopied(true);setTimeout(()=>setCopied(false),1800);}} 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 }}>
<UCIcon 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={{ ...ucCard, 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={{ ...ucCard, 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>
);
}
/* App shell */
function UserCenterApp() {
const [stage,setStage]=useStateU('app'); // app | login opens on the dashboard
const [view,setView]=useStateU('overview');
const [lang,setLang]=useStateU('zh');
const [free]=useStateU(false);
const [dir,setDir]=useStateU(0); // -1 左进 / 1 右进 / 0 无动画
const mobile=useIsMobileUC();
const t=ucT(lang);
const touchRef=React.useRef({x:0,y:0});
const ORDER=['overview','sub','redeem','invite'];
function go(id){ const a=ORDER.indexOf(view), b=ORDER.indexOf(id); setDir(b>a?1:b<a?-1:0); setView(id); setTimeout(()=>setDir(0),300); }
function onTouchStart(e){ const p=e.touches?e.touches[0]:e; touchRef.current={x:p.clientX,y:p.clientY}; }
function onTouchEnd(e){
const p=e.changedTouches?e.changedTouches[0]:e;
const dx=p.clientX-touchRef.current.x, 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]);
}
}
if(stage==='login') return <UCLogin t={t} lang={lang} setLang={setLang} onDone={()=>setStage('app')} />;
const nav=[['overview','layout-dashboard',t('navOverview')],['sub','link',t('navSub')],['redeem','ticket',t('navRedeem')],['invite','users',t('navInvite')]];
let main;
if(view==='overview') main=<UCOverview t={t} lang={lang} free={free} mobile={mobile} goSub={()=>setView('sub')} goRedeem={()=>setView('redeem')} />;
else if(view==='sub') main=<UCSub t={t} lang={lang} mobile={mobile} />;
else if(view==='redeem') main=<UCRedeem t={t} lang={lang} />;
else main=<UCInvite t={t} lang={lang} />;
const navBtns = nav.map(([id,ic,l])=>(
<button key={id} onClick={()=>go(id)} 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' }}>
<UCIcon 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 }}>
<UCMark 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 }} />}
<UCLang lang={lang} setLang={setLang} />
<button onClick={()=>setStage('login')} style={{ display:'inline-flex', alignItems:'center', gap:7, border:'none', background:'transparent', cursor:'pointer', color:'var(--fg2)', fontSize:13, fontWeight:600, padding:6 }}>
<UCIcon 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} onAnimationEnd={()=>setDir(0)} style={{ animation: dir!==0 ? `uc-in-${dir===1?'l':'r'} 200ms var(--ease-out)` : 'none' }}>{main}</div>
</div>
</div>
);
}
Object.assign(window, { UserCenterApp });
+96
View File
@@ -0,0 +1,96 @@
/* ucparts.jsx — 穿山甲 Web 用户中心 · atoms + i18n */
const UCLUCIDE = {
'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"/>',
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"/>',
};
function UCIcon({ name, size=18, stroke=2, color='currentColor', style={} }) {
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: UCLUCIDE[name]||'' }} />;
}
function UCMark({ size=30, color='var(--accent)' }) {
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>
);
}
const UCSTRINGS = {
navOverview:{zh:'概览',en:'Overview'}, navSub:{zh:'订阅',en:'Subscription'}, navRedeem:{zh:'兑换 & 购买',en:'Redeem & buy'}, navInvite:{zh:'邀请返利',en:'Referral'},
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'},
/* 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 */
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'},
};
function ucT(lang){ return k => { const v=UCSTRINGS[k]; return v?(v[lang]||k):k; }; }
Object.assign(window, { UCIcon, UCMark, UCSTRINGS, ucT });