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:
@@ -0,0 +1,19 @@
|
||||
# 穿山甲 — iPad 客户端 UI Kit
|
||||
|
||||
iPad 横屏(1180×820)分栏布局的高保真可点击原型。介于手机与桌面之间:**左侧栏导航(触控尺寸,行高 ≥48px)+ 内容区**。
|
||||
|
||||
## 运行
|
||||
打开 `index.html`。可交互:连接键三态、智能选择推荐卡(默认选中)、免费版额度卡(看广告解锁)、节点双列网格、统计、账户(语言/深色/免费版视角切换)。
|
||||
|
||||
## 文件
|
||||
| 文件 | 内容 |
|
||||
|---|---|
|
||||
| `index.html` | 入口(iPad 外壳 + 状态栏) |
|
||||
| `tabapp.jsx` | `TabletApp` 外壳 + `TabRail` 侧栏 + 连接/节点/统计/账户四视图 |
|
||||
| `../mobile/parts.jsx` | 复用:`Icon`/`Mark`/`CC`/`Signal`/`SERVERS`/`makeT` 与共享字串 |
|
||||
|
||||
## 关键模式
|
||||
- **连接页双栏**:左=大连接按钮;右=信息列(额度卡 → 当前节点 → 实时速率)。
|
||||
- 节点页:置顶智能推荐卡 + 双列网格(同桌面)。
|
||||
- 全部走语义 token,双主题双语;Flutter 端建议宽度 ≥900 时从底部 Tab 切换为此侧栏布局。
|
||||
- 注意:交互态过渡只写 `background-color`/`color`,**不要 `transition: all`**(背景变量切换会失效,CLAUDE.md §5 有记录)。
|
||||
@@ -0,0 +1,28 @@
|
||||
<!-- @dsCard group="UI Kit — Tablet" name="UI Kit · iPad" subtitle="iPad 分栏布局 · 侧栏导航 · 连接/节点/统计/账户 · 双语双主题" viewport="1260x900" -->
|
||||
<!doctype html>
|
||||
<html lang="zh">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>穿山甲 · iPad 客户端</title>
|
||||
<link rel="stylesheet" href="../../colors_and_type.css">
|
||||
<style>
|
||||
html, body { margin: 0; height: 100%; }
|
||||
body { background: var(--sand-100); display: flex; align-items: center; justify-content: center; min-height: 100vh; padding: 28px 0; box-sizing: border-box; font-family: var(--font-sans); }
|
||||
@keyframes pg-spin { to { transform: rotate(360deg); } }
|
||||
* { -webkit-font-smoothing: antialiased; }
|
||||
button { font-family: inherit; }
|
||||
</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="../mobile/parts.jsx?v2"></script>
|
||||
<script type="text/babel" src="tabapp.jsx?v2"></script>
|
||||
<script type="text/babel">
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(<TabletApp />);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,368 @@
|
||||
/* tabapp.jsx — 穿山甲 iPad 客户端 · 分栏布局
|
||||
复用 ../mobile/parts.jsx 的 Icon/Mark/CC/Signal/SERVERS/makeT/srvName/srvSub。
|
||||
仅 iPad 专属字串定义在本文件 TSTR。 */
|
||||
|
||||
const { useState, useEffect, useRef } = React;
|
||||
|
||||
/* iPad 专属字串(双语单显) */
|
||||
const TSTR = {
|
||||
navConnect: { zh: '连接', en: 'Connect' },
|
||||
navNodes: { zh: '节点', en: 'Nodes' },
|
||||
navStats: { zh: '统计', en: 'Stats' },
|
||||
navAccount: { zh: '账户', en: 'Account' },
|
||||
capOff: { zh: '未连接 · 轻点连接', en: 'Not connected · tap to start' },
|
||||
capConnecting:{ zh: '连接中 Connecting…', en: 'Connecting…' },
|
||||
capOn: { zh: '已连接 · 网络已加密', en: 'Connected · traffic encrypted' },
|
||||
download: { zh: '下载', en: 'Down' },
|
||||
upload: { zh: '上传', en: 'Up' },
|
||||
latency: { zh: '延迟', en: 'Ping' },
|
||||
curNode: { zh: '当前节点', en: 'Current node' },
|
||||
searchPh: { zh: '搜索国家 / 城市', en: 'Search country / city' },
|
||||
statTraffic:{ zh: '本月流量', en: 'Data this month' },
|
||||
statPing: { zh: '平均延迟', en: 'Avg latency' },
|
||||
statTime: { zh: '本月时长', en: 'Time this month' },
|
||||
weekTraffic:{ zh: '本周流量 (GB)', en: 'This week (GB)' },
|
||||
days: { zh: ['周一','周二','周三','周四','周五','周六','周日'], en: ['Mon','Tue','Wed','Thu','Fri','Sat','Sun'] },
|
||||
acctEmail: { zh: '邮箱', en: 'Email' },
|
||||
language: { zh: '语言', en: 'Language' },
|
||||
darkMode: { zh: '深色外观', en: 'Dark appearance' },
|
||||
smartRoute: { zh: '智能分流', en: 'Smart routing' },
|
||||
smartRouteSub:{ zh: '海外应用走加速,国内直连', en: 'Accelerate abroad, direct at home' },
|
||||
killSwitch: { zh: 'Kill Switch', en: 'Kill Switch' },
|
||||
killSub: { zh: '断线时阻断网络,防止泄露', en: 'Block traffic if the link drops' },
|
||||
freeBanner: { zh: '免费版', en: 'Free plan' },
|
||||
trialNote: { zh: '升级 PRO:不限时长 · 80+ 加速线路 · 5 台设备', en: 'Go PRO: unlimited time · 80+ routes · 5 devices' },
|
||||
version: { zh: '穿山甲 · iPadOS · v2.4.0', en: 'Pangolin · iPadOS · v2.4.0' },
|
||||
};
|
||||
function makeTT(lang) { return (k) => (TSTR[k] && TSTR[k][lang]) !== undefined ? TSTR[k][lang] : k; }
|
||||
|
||||
function fmtHMS(s) { return `${String(Math.floor(s/3600)).padStart(2,'0')}:${String(Math.floor(s%3600/60)).padStart(2,'0')}:${String(s%60).padStart(2,'0')}`; }
|
||||
|
||||
/* ── 左侧栏 ── */
|
||||
function TabRail({ tt, view, setView, free, t }) {
|
||||
const items = [['connect','power','navConnect'],['nodes','globe','navNodes'],['stats','chart','navStats'],['account','user','navAccount']];
|
||||
return (
|
||||
<div style={{ width: 232, flexShrink: 0, background: 'var(--bg-subtle)', borderRight: '1px solid var(--border)', display: 'flex', flexDirection: 'column', padding: '18px 14px 16px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '0 8px 20px' }}>
|
||||
<Mark size={30} />
|
||||
<div>
|
||||
<div style={{ fontFamily: 'var(--font-display)', fontWeight: 700, fontSize: 17, color: 'var(--fg1)', lineHeight: 1 }}>穿山甲</div>
|
||||
<div style={{ fontSize: 9, fontWeight: 600, letterSpacing: '0.2em', color: 'var(--accent)', marginTop: 3 }}>PANGOLIN</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
{items.map(([id, ic, lk]) => {
|
||||
const a = view === id;
|
||||
return (
|
||||
<button key={id} onClick={() => setView(id)} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 12, width: '100%', textAlign: 'left', border: 'none', cursor: 'pointer',
|
||||
background: a ? 'var(--accent-subtle)' : 'transparent', color: a ? 'var(--accent)' : 'var(--fg2)',
|
||||
padding: '13px 14px', borderRadius: 'var(--radius-md)', fontFamily: 'var(--font-sans)', fontSize: 15, fontWeight: a ? 700 : 500,
|
||||
transition: 'background-color 140ms var(--ease-out), color 140ms var(--ease-out)', minHeight: 48,
|
||||
}}>
|
||||
<Icon name={ic} size={20} stroke={a ? 2.4 : 2} color={a ? 'var(--accent)' : 'var(--fg3)'} />{tt(lk)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div style={{ flex: 1 }}></div>
|
||||
<div style={{ background: free ? 'var(--surface)' : 'linear-gradient(150deg,var(--clay-600),var(--clay-800))', border: free ? '1px solid var(--border)' : 'none', borderRadius: 'var(--radius-lg)', padding: '12px 13px', color: free ? 'var(--fg1)' : '#fff' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 9 }}>
|
||||
<div style={{ width: 32, height: 32, borderRadius: '50%', background: free ? 'var(--bg-subtle)' : 'rgba(255,255,255,0.18)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
<Icon name={free ? 'user' : 'crown'} size={16} color={free ? 'var(--fg2)' : '#fff'} />
|
||||
</div>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ fontSize: 12.5, fontWeight: 700, whiteSpace: 'nowrap' }}>{free ? tt('freeBanner') : t('proMember')}</div>
|
||||
<div style={{ fontSize: 10.5, opacity: 0.65, whiteSpace: 'nowrap' }}>me@pangolin.vpn</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── 连接视图:左大按钮 + 右信息列 ── */
|
||||
function TabConnect({ t, tt, lang, status, onToggle, server, smart, free, mins, adDone, onAd, elapsed, goNodes }) {
|
||||
const map = {
|
||||
off: { ring: 'var(--border)', fill: 'var(--surface)', fg: 'var(--fg2)', icon: 'power', label: 'TAP', cap: tt('capOff') },
|
||||
connecting: { ring: 'var(--accent-subtle)', fill: 'var(--accent)', fg: '#fff', icon: 'loader-circle', label: '···', cap: tt('capConnecting') },
|
||||
on: { ring: 'var(--success-subtle)', fill: 'var(--success)', fg: '#fff', icon: 'shield-check', label: 'SAFE', cap: tt('capOn') },
|
||||
}[status];
|
||||
const card = { background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 'var(--radius-lg)', boxShadow: 'var(--shadow-sm)' };
|
||||
return (
|
||||
<div style={{ display: 'flex', height: '100%', alignItems: 'stretch' }}>
|
||||
<div style={{ flex: 1.25, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 26 }}>
|
||||
<button onClick={onToggle} style={{
|
||||
width: 216, height: 216, borderRadius: '50%', border: 'none', cursor: 'pointer',
|
||||
background: map.fill, color: map.fg, boxShadow: `0 0 0 11px ${map.ring}, var(--shadow-lg)`,
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 12,
|
||||
transition: 'box-shadow 300ms var(--ease-out), background-color 300ms var(--ease-out)',
|
||||
}}>
|
||||
<Icon name={map.icon} size={60} stroke={1.6} style={status === 'connecting' ? { animation: 'pg-spin 1s linear infinite' } : {}} />
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontWeight: 700, fontSize: 14, letterSpacing: '0.08em' }}>{map.label}</span>
|
||||
</button>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{ fontFamily: 'var(--font-sans)', fontSize: 16, fontWeight: 600, color: 'var(--fg2)', whiteSpace: 'nowrap' }}>{map.cap}</div>
|
||||
{status === 'on' && <div style={{ fontFamily: 'var(--font-mono)', fontSize: 28, fontWeight: 500, color: 'var(--fg1)', marginTop: 8, letterSpacing: '-0.01em' }}>{fmtHMS(elapsed)}</div>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ width: 332, flexShrink: 0, display: 'flex', flexDirection: 'column', gap: 14, justifyContent: 'center', paddingRight: 28 }}>
|
||||
{free && (
|
||||
<div style={{ ...card, padding: '14px 16px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 9 }}>
|
||||
<Icon name="clock" size={15} stroke={2.2} color="var(--accent)" />
|
||||
<span style={{ fontSize: 12.5, fontWeight: 600, color: 'var(--fg2)' }}>{t('quotaToday')}</span>
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 14, fontWeight: 600, color: 'var(--fg1)', whiteSpace: 'nowrap' }}>{mins} {t('minutes')}</span>
|
||||
<span style={{ marginLeft: 'auto', fontSize: 10.5, fontWeight: 600, color: 'var(--fg3)', background: 'var(--bg-subtle)', padding: '3px 9px', borderRadius: 'var(--radius-full)', whiteSpace: 'nowrap' }}>{t('quotaFree')}</span>
|
||||
</div>
|
||||
<div style={{ height: 6, background: 'var(--bg-subtle)', borderRadius: 3, overflow: 'hidden', marginBottom: 11 }}>
|
||||
<div style={{ width: `${Math.min(100, mins / 10 * 100)}%`, height: '100%', background: mins <= 3 ? 'var(--warning)' : 'var(--accent)', borderRadius: 3 }}></div>
|
||||
</div>
|
||||
{adDone ? (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 7, color: 'var(--success)', fontWeight: 700, fontSize: 13, padding: '8px' }}>
|
||||
<Icon name="check-circle" size={16} stroke={2.2} color="var(--success)" />{t('adUnlocked')}
|
||||
</div>
|
||||
) : (
|
||||
<button onClick={onAd} style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 7, width: '100%', border: '1.5px solid var(--accent-border)', background: 'var(--accent-subtle)', color: 'var(--accent)', fontFamily: 'var(--font-sans)', fontWeight: 700, fontSize: 13.5, padding: '10px', borderRadius: 'var(--radius-full)', cursor: 'pointer', minHeight: 44 }}>
|
||||
<Icon name="play-circle" size={16} stroke={2.2} />{t('watchAd')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button onClick={goNodes} style={{ ...card, display: 'flex', alignItems: 'center', gap: 13, textAlign: 'left', padding: '13px 15px', cursor: 'pointer', minHeight: 48 }}>
|
||||
<CC code={server.code} active />
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--fg3)', marginBottom: 2 }}>{tt('curNode')}</div>
|
||||
<div style={{ fontFamily: 'var(--font-sans)', fontSize: 15, fontWeight: 600, color: 'var(--fg1)', display: 'flex', alignItems: 'center', gap: 7 }}>
|
||||
{smart ? t('smartSelect') : srvName(server, lang)}
|
||||
{smart && <Icon name="zap" size={13} stroke={2.4} color="var(--accent)" />}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--fg3)' }}>{smart ? `${srvName(server, lang)} · ${server.ping}ms` : `${srvSub(server, lang)} · ${server.ping}ms`}</div>
|
||||
</div>
|
||||
<Icon name="chevron-right" size={20} color="var(--fg3)" />
|
||||
</button>
|
||||
|
||||
{status === 'on' && (
|
||||
<div style={{ display: 'flex', gap: 10 }}>
|
||||
{[['arrow-down', tt('download'), '86.4', 'Mb/s'], ['arrow-up', tt('upload'), '12.1', 'Mb/s'], ['zap', tt('latency'), String(server.ping), 'ms']].map(([ic, l, v, u]) => (
|
||||
<div key={ic} style={{ ...card, flex: 1, padding: '11px 13px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 5, color: 'var(--fg3)', fontSize: 11, fontWeight: 600, marginBottom: 4 }}><Icon name={ic} size={13} stroke={2.2} color="var(--accent)" />{l}</div>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 16, fontWeight: 500, color: 'var(--fg1)' }}>{v}<span style={{ fontSize: 10.5, color: 'var(--fg3)' }}> {u}</span></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── 节点视图 ── */
|
||||
function TabNodes({ t, tt, lang, current, onPick }) {
|
||||
const [q, setQ] = useState('');
|
||||
const list = SERVERS.filter(s => (s.name + s.en).toLowerCase().includes(q.toLowerCase()));
|
||||
const smartActive = current === 'AUTO';
|
||||
return (
|
||||
<div style={{ padding: '6px 28px 24px', overflow: 'auto', height: '100%', boxSizing: 'border-box' }}>
|
||||
<button onClick={() => onPick('AUTO')} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 13, width: '100%', textAlign: 'left', cursor: 'pointer',
|
||||
background: 'var(--accent-subtle)', border: `1.5px solid ${smartActive ? 'var(--accent)' : 'var(--accent-border)'}`,
|
||||
borderRadius: 'var(--radius-xl)', padding: '15px 16px', boxShadow: smartActive ? 'var(--shadow-md)' : 'var(--shadow-sm)', marginBottom: 14, minHeight: 56,
|
||||
}}>
|
||||
<div style={{ width: 42, height: 42, borderRadius: 'var(--radius-md)', background: 'linear-gradient(150deg,var(--clay-500),var(--clay-700))', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}><Icon name="zap" size={21} stroke={2.2} color="#fff" /></div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span style={{ fontFamily: 'var(--font-sans)', fontSize: 15.5, fontWeight: 700, color: 'var(--fg1)' }}>{t('smartSelect')}</span>
|
||||
<span style={{ fontSize: 10, fontWeight: 700, color: '#fff', background: 'var(--accent)', padding: '2px 8px', borderRadius: 999, letterSpacing: '0.04em' }}>{t('recommended')}</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--fg2)', marginTop: 3 }}>{t('smartSub')}</div>
|
||||
</div>
|
||||
{smartActive && <Icon name="check" size={19} color="var(--accent)" stroke={2.6} />}
|
||||
</button>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, background: 'var(--bg-subtle)', borderRadius: 'var(--radius-md)', padding: '11px 14px', marginBottom: 14, maxWidth: 380 }}>
|
||||
<Icon name="search" size={18} color="var(--fg3)" />
|
||||
<input value={q} onChange={e => setQ(e.target.value)} placeholder={tt('searchPh')} style={{ border: 'none', background: 'transparent', outline: 'none', flex: 1, fontFamily: 'var(--font-sans)', fontSize: 14.5, color: 'var(--fg1)' }} />
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 11 }}>
|
||||
{list.map(s => {
|
||||
const active = s.code === current;
|
||||
return (
|
||||
<button key={s.code} onClick={() => onPick(s.code)} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 12, textAlign: 'left', cursor: 'pointer',
|
||||
background: active ? 'var(--accent-subtle)' : 'var(--surface)', border: `1.5px solid ${active ? 'var(--accent-border)' : 'var(--border)'}`,
|
||||
borderRadius: 'var(--radius-lg)', padding: '13px 15px', boxShadow: 'var(--shadow-sm)', minHeight: 56,
|
||||
}}>
|
||||
<CC code={s.code} active={active} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontFamily: 'var(--font-sans)', fontSize: 14.5, fontWeight: 600, color: 'var(--fg1)' }}>{srvName(s, lang)}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--fg3)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{srvSub(s, lang)}</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 5 }}>
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--fg2)' }}>{s.ping}ms</span>
|
||||
<Signal ping={s.ping} />
|
||||
</div>
|
||||
{active && <Icon name="check" size={18} color="var(--accent)" />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── 统计视图 ── */
|
||||
function TabStats({ tt, lang }) {
|
||||
const vals = [2.1, 3.4, 1.8, 4.6, 5.2, 6.1, 3.0];
|
||||
const days = TSTR.days[lang];
|
||||
const max = Math.max(...vals);
|
||||
const card = { background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 'var(--radius-lg)', boxShadow: 'var(--shadow-sm)' };
|
||||
return (
|
||||
<div style={{ padding: '6px 28px 24px', overflow: 'auto', height: '100%', boxSizing: 'border-box' }}>
|
||||
<div style={{ display: 'flex', gap: 14, marginBottom: 20 }}>
|
||||
{[[tt('statTraffic'), '42.6 GB'], [tt('statPing'), '29 ms'], [tt('statTime'), '86.4 h']].map(([l, v]) => (
|
||||
<div key={l} style={{ ...card, flex: 1, padding: '18px 20px' }}>
|
||||
<div style={{ fontSize: 12.5, color: 'var(--fg3)', fontWeight: 600 }}>{l}</div>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 26, fontWeight: 500, color: 'var(--fg1)', marginTop: 7 }}>{v}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ ...card, padding: '20px 22px' }}>
|
||||
<div style={{ fontSize: 13.5, fontWeight: 600, color: 'var(--fg1)', marginBottom: 20 }}>{tt('weekTraffic')}</div>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 22, height: 150 }}>
|
||||
{vals.map((v, i) => (
|
||||
<div key={i} style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 9 }}>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 10.5, color: 'var(--fg3)' }}>{v}</div>
|
||||
<div style={{ width: '100%', maxWidth: 40, height: `${v / max * 104}px`, background: 'var(--accent)', borderRadius: '6px 6px 0 0', opacity: 0.85 }}></div>
|
||||
<div style={{ fontSize: 11.5, color: 'var(--fg3)' }}>{days[i]}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── 账户视图 ── */
|
||||
function TabToggle({ on, onChange }) {
|
||||
return (
|
||||
<div onClick={onChange} style={{ width: 48, height: 29, borderRadius: 999, position: 'relative', cursor: 'pointer', flexShrink: 0, background: on ? 'var(--accent)' : 'var(--border-strong)', transition: 'background 220ms var(--ease-out)' }}>
|
||||
<div style={{ position: 'absolute', top: 3, left: on ? 22 : 3, width: 23, height: 23, borderRadius: '50%', background: '#fff', boxShadow: 'var(--shadow-sm)', transition: 'left 220ms var(--ease-out)' }}></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
function TabAccount({ t, tt, lang, setLang, dark, setDark, free, setFree }) {
|
||||
const [smartRoute, setSmartRoute] = useState(true);
|
||||
const [kill, setKill] = useState(true);
|
||||
const card = { background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 'var(--radius-lg)', boxShadow: 'var(--shadow-sm)', overflow: 'hidden', marginBottom: 16 };
|
||||
const Row = ({ icon, title, sub, right, last }) => (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 13, padding: '15px 18px', borderBottom: last ? 'none' : '1px solid var(--border)', minHeight: 48, boxSizing: 'border-box' }}>
|
||||
{icon && <div style={{ width: 34, height: 34, borderRadius: 'var(--radius-sm)', background: 'var(--accent-subtle)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}><Icon name={icon} size={17} color="var(--accent)" stroke={2.2} /></div>}
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: 15, fontWeight: 500, color: 'var(--fg1)' }}>{title}</div>
|
||||
{sub && <div style={{ fontSize: 12, color: 'var(--fg3)', marginTop: 2 }}>{sub}</div>}
|
||||
</div>
|
||||
{right}
|
||||
</div>
|
||||
);
|
||||
const seg = (val) => ({
|
||||
border: 'none', cursor: 'pointer', padding: '7px 16px', borderRadius: 'var(--radius-full)', fontFamily: 'var(--font-sans)', fontSize: 13, fontWeight: 600, minHeight: 32,
|
||||
background: lang === val ? 'var(--surface)' : 'transparent', color: lang === val ? 'var(--fg1)' : 'var(--fg3)',
|
||||
boxShadow: lang === val ? 'var(--shadow-sm)' : 'none',
|
||||
});
|
||||
return (
|
||||
<div style={{ padding: '6px 28px 24px', overflow: 'auto', height: '100%', boxSizing: 'border-box', maxWidth: 640 }}>
|
||||
<div style={{ background: free ? 'var(--surface)' : 'linear-gradient(155deg,var(--clay-600),var(--clay-800))', border: free ? '1px solid var(--border)' : 'none', borderRadius: 'var(--radius-xl)', padding: 20, color: free ? 'var(--fg1)' : '#fff', marginBottom: 18, boxShadow: 'var(--shadow-md)' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<div style={{ width: 46, height: 46, borderRadius: '50%', background: free ? 'var(--bg-subtle)' : 'rgba(255,255,255,0.18)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}><Icon name={free ? 'user' : 'crown'} size={22} color={free ? 'var(--fg2)' : '#fff'} /></div>
|
||||
<div>
|
||||
<div style={{ fontWeight: 700, fontSize: 16.5 }}>me@pangolin.vpn</div>
|
||||
<div style={{ display: 'inline-block', fontSize: 11, fontWeight: 600, background: free ? 'var(--bg-subtle)' : 'rgba(255,255,255,0.2)', color: free ? 'var(--fg2)' : '#fff', padding: '2px 9px', borderRadius: 999, marginTop: 5 }}>{free ? tt('freeBanner') : t('proMember')}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ marginTop: 14, paddingTop: 13, borderTop: `1px solid ${free ? 'var(--border)' : 'rgba(255,255,255,0.18)'}`, fontSize: 12.5, opacity: free ? 1 : 0.92, color: free ? 'var(--fg2)' : '#fff' }}>
|
||||
{free ? tt('trialNote') : '2026-12-31 · 42.6 GB'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={card}>
|
||||
<Row icon="zap" title={tt('smartRoute')} sub={tt('smartRouteSub')} right={<TabToggle on={smartRoute} onChange={() => setSmartRoute(!smartRoute)} />} />
|
||||
<Row icon="shield" title={tt('killSwitch')} sub={tt('killSub')} right={<TabToggle on={kill} onChange={() => setKill(!kill)} />} last />
|
||||
</div>
|
||||
<div style={card}>
|
||||
<Row icon="globe" title={tt('language')} right={
|
||||
<div style={{ display: 'flex', gap: 4, background: 'var(--bg-subtle)', borderRadius: 'var(--radius-full)', padding: 3 }}>
|
||||
<button style={seg('zh')} onClick={() => setLang('zh')}>中文</button>
|
||||
<button style={seg('en')} onClick={() => setLang('en')}>EN</button>
|
||||
</div>
|
||||
} />
|
||||
<Row icon={dark ? 'moon' : 'sun'} title={tt('darkMode')} right={<TabToggle on={dark} onChange={() => setDark(!dark)} />} />
|
||||
<Row icon="clock" title={t('freeDemo')} sub={t('freeDemoSub')} right={<TabToggle on={free} onChange={() => setFree(!free)} />} last />
|
||||
</div>
|
||||
<div style={{ textAlign: 'center', fontSize: 12, color: 'var(--fg3)', fontFamily: 'var(--font-mono)' }}>{tt('version')}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── iPad 外壳 + 应用 ── */
|
||||
function TabletApp() {
|
||||
const [view, setView] = useState('connect');
|
||||
const [status, setStatus] = useState('off');
|
||||
const [code, setCode] = useState('AUTO');
|
||||
const [lang, setLang] = useState('zh');
|
||||
const [dark, setDark] = useState(false);
|
||||
const [free, setFree] = useState(true);
|
||||
const [mins, setMins] = useState(6);
|
||||
const [adDone, setAdDone] = useState(false);
|
||||
const [elapsed, setElapsed] = useState(0);
|
||||
const timer = useRef(null);
|
||||
const t = makeT(lang), tt = makeTT(lang);
|
||||
|
||||
const smart = code === 'AUTO';
|
||||
const server = smart ? SERVERS.reduce((a, b) => (a.ping < b.ping ? a : b)) : SERVERS.find(s => s.code === code);
|
||||
const titles = { connect: tt('navConnect'), nodes: tt('navNodes'), stats: tt('navStats'), account: tt('navAccount') };
|
||||
|
||||
function toggle() {
|
||||
if (status === 'off') { setStatus('connecting'); setTimeout(() => { setStatus('on'); setElapsed(0); }, 1500); }
|
||||
else setStatus('off');
|
||||
}
|
||||
function pick(c) { setCode(c); setView('connect'); if (status === 'on') { setStatus('connecting'); setTimeout(() => { setStatus('on'); setElapsed(0); }, 1200); } }
|
||||
useEffect(() => { if (status === 'on') timer.current = setInterval(() => setElapsed(e => e + 1), 1000); return () => clearInterval(timer.current); }, [status]);
|
||||
|
||||
let main;
|
||||
if (view === 'connect') main = <TabConnect t={t} tt={tt} lang={lang} status={status} onToggle={toggle} server={server} smart={smart} free={free} mins={mins} adDone={adDone} onAd={() => setAdDone(true)} elapsed={elapsed} goNodes={() => setView('nodes')} />;
|
||||
else if (view === 'nodes') main = <TabNodes t={t} tt={tt} lang={lang} current={code} onPick={pick} />;
|
||||
else if (view === 'stats') main = <TabStats tt={tt} lang={lang} />;
|
||||
else main = <TabAccount t={t} tt={tt} lang={lang} setLang={setLang} dark={dark} setDark={setDark} free={free} setFree={setFree} />;
|
||||
|
||||
return (
|
||||
<div style={{ background: '#1a1714', borderRadius: 34, padding: 16, boxShadow: '0 0 0 1px rgba(0,0,0,0.3), 0 30px 80px rgba(0,0,0,0.45)' }}>
|
||||
<div data-theme={dark ? 'dark' : 'light'} style={{ width: 1180, height: 820, borderRadius: 20, overflow: 'hidden', background: 'var(--bg)', display: 'flex', flexDirection: 'column', fontFamily: 'var(--font-sans)', position: 'relative' }}>
|
||||
{/* status bar */}
|
||||
<div style={{ height: 30, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '0 22px', fontFamily: 'var(--font-sans)', fontSize: 12.5, fontWeight: 600, color: 'var(--fg1)' }}>
|
||||
<span>9:41</span>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<Icon name="wifi" size={15} stroke={2.2} color="var(--fg1)" />
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 11.5 }}>87%</span>
|
||||
<div style={{ width: 24, height: 12, border: '1.5px solid var(--fg2)', borderRadius: 3.5, padding: 1.5, boxSizing: 'border-box' }}><div style={{ width: '80%', height: '100%', background: 'var(--fg1)', borderRadius: 1.5 }}></div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ flex: 1, minHeight: 0, display: 'flex' }}>
|
||||
<TabRail tt={tt} view={view} setView={setView} free={free} t={t} />
|
||||
<div style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column' }}>
|
||||
<div style={{ height: 56, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '0 28px' }}>
|
||||
<div style={{ fontFamily: 'var(--font-display)', fontWeight: 700, fontSize: 19, color: 'var(--fg1)' }}>{titles[view]}</div>
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 12.5, fontWeight: 600, color: status === 'on' ? 'var(--success)' : 'var(--fg3)', whiteSpace: 'nowrap' }}>{status === 'on' ? '● ' + server.code : '○'}</span>
|
||||
</div>
|
||||
<div style={{ flex: 1, minHeight: 0 }}>{main}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Object.assign(window, { TabletApp });
|
||||
Reference in New Issue
Block a user