/* 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 (
穿山甲
PANGOLIN
{items.map(([id, ic, lk]) => { const a = view === id; return ( ); })}
{free ? tt('freeBanner') : t('proMember')}
me@pangolin.vpn
); } /* ── 连接视图:左大按钮 + 右信息列 ── */ 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 (
{map.cap}
{status === 'on' &&
{fmtHMS(elapsed)}
}
{free && (
{t('quotaToday')} {mins} {t('minutes')} {t('quotaFree')}
{adDone ? (
{t('adUnlocked')}
) : ( )}
)} {status === 'on' && (
{[['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]) => (
{l}
{v} {u}
))}
)}
); } /* ── 节点视图 ── */ 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 (
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)' }} />
{list.map(s => { const active = s.code === current; return ( ); })}
); } /* ── 统计视图 ── */ 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 (
{[[tt('statTraffic'), '42.6 GB'], [tt('statPing'), '29 ms'], [tt('statTime'), '86.4 h']].map(([l, v]) => (
{l}
{v}
))}
{tt('weekTraffic')}
{vals.map((v, i) => (
{v}
{days[i]}
))}
{tt('byDevice')}
{devs.map((d, i) => (
{d.name}
{d.gb} GB
{d.h} h
))}
); } /* ── 账户视图 ── */ function TabToggle({ on, onChange }) { return (
); } 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 }) => (
{icon &&
}
{title}
{sub &&
{sub}
}
{right}
); 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 (
me@pangolin.vpn
{free ? tt('freeBanner') : t('proMember')}
{free ? tt('trialNote') : '2026-12-31 · 42.6 GB'}
setSmartRoute(!smartRoute)} />} /> setKill(!kill)} />} last />
} /> setDark(!dark)} />} /> setFree(!free)} />} last />
{tt('version')}
); } /* ── 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 = setAdDone(true)} elapsed={elapsed} goNodes={() => setView('nodes')} />; else if (view === 'nodes') main = ; else if (view === 'stats') main = ; else main = ; return (
{/* status bar */}
9:41
87%
{titles[view]}
{status === 'on' ? '● ' + server.code : '○'}
{main}
); } Object.assign(window, { TabletApp });