/* 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 (
{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]) => (
))}
)}
);
}
/* ── 节点视图 ── */
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]) => (
))}
{tt('weekTraffic')}
{vals.map((v, i) => (
))}
{tt('byDevice')}
{devs.map((d, i) => (
))}
);
}
/* ── 账户视图 ── */
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 }) => (
);
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 */}
{titles[view]}
{status === 'on' ? '● ' + server.code : '○'}
{main}
);
}
Object.assign(window, { TabletApp });