b98ad9dce4
stats-overhaul 的设计稿(CONTRACT.md + mobile/tablet/desktop ui_kits)。 注:这批 ui_kits 描述的是统计页「上聚合·下分设备」原方案;统计页后续已 重设计为「月/周/日 周期卡 + 设备下拉 + 两周折线」(见 client/lib/screens/ stats_page.dart)。设计稿与新页布局有出入,待更新到新布局(单列 TODO 跟踪)。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
396 lines
27 KiB
React
396 lines
27 KiB
React
/* 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'] },
|
|
byDevice: { zh: '设备明细', en: 'By device' },
|
|
noDeviceUsage:{ zh: '暂无设备用量', en: 'No device usage yet' },
|
|
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)' };
|
|
const devs = [
|
|
{ name: 'iPhone 15', icon: 'smartphone', gb: '11.3', h: '5.2', frac: 1.0 },
|
|
{ name: 'MacBook Pro', icon: 'laptop', gb: '6.0', h: '3.1', frac: 0.53 },
|
|
{ name: 'Pixel 8', icon: 'smartphone', gb: '1.6', h: '1.1', frac: 0.14 },
|
|
];
|
|
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 style={{ fontSize: 13.5, fontWeight: 600, color: 'var(--fg1)', margin: '20px 0 12px 2px' }}>{tt('byDevice')}</div>
|
|
<div style={{ ...card, overflow: 'hidden' }}>
|
|
{devs.map((d, i) => (
|
|
<div key={d.name} style={{ display: 'flex', alignItems: 'center', gap: 13, padding: '15px 18px', borderBottom: i < devs.length - 1 ? '1px solid var(--border)' : 'none' }}>
|
|
<div style={{ width: 38, height: 38, borderRadius: 'var(--radius-md)', background: 'var(--accent-subtle)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
|
<Icon name={d.icon} size={19} color="var(--accent)" stroke={2.2} />
|
|
</div>
|
|
<div style={{ flex: 1, minWidth: 0 }}>
|
|
<div style={{ fontSize: 15, fontWeight: 600, color: 'var(--fg1)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{d.name}</div>
|
|
<div style={{ marginTop: 8, height: 4, borderRadius: 999, background: 'var(--border)', overflow: 'hidden' }}>
|
|
<div style={{ width: `${d.frac * 100}%`, height: '100%', background: 'var(--accent)', opacity: 0.85, borderRadius: 999 }} />
|
|
</div>
|
|
</div>
|
|
<div style={{ textAlign: 'right', flexShrink: 0 }}>
|
|
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 15, fontWeight: 500, color: 'var(--fg1)' }}>{d.gb}<span style={{ fontSize: 11.5, color: 'var(--fg3)' }}> GB</span></div>
|
|
<div style={{ fontSize: 11.5, color: 'var(--fg3)', marginTop: 2 }}>{d.h} h</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 });
|