Files
pangolin/design/ui_kits/mobile/parts.jsx
T
wangjia b98ad9dce4 chore(design): stats-overhaul 设计 WIP — CONTRACT + ui_kits 统计页规格
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>
2026-06-28 18:13:49 +08:00

296 lines
20 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* parts.jsx — 穿山甲 VPN mobile · shared atoms
Icon uses inline Lucide path data (lucide.dev) for reliability in React. */
const LUCIDE = {
power: '<path d="M12 2v10"/><path d="M18.36 6.64a9 9 0 1 1-12.73 0"/>',
'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"/>',
globe: '<circle cx="12" cy="12" r="10"/><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"/><path d="M2 12h20"/>',
settings: '<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/>',
'chevron-right': '<path d="m9 18 6-6-6-6"/>',
'chevron-down': '<path d="m6 9 6 6 6-6"/>',
check: '<path d="M20 6 9 17l-5-5"/>',
zap: '<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/>',
'loader-circle': '<path d="M21 12a9 9 0 1 1-6.219-8.56"/>',
user: '<path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/>',
x: '<path d="M18 6 6 18"/><path d="m6 6 12 12"/>',
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"/>',
'map-pin': '<path d="M20 10c0 4.993-5.539 10.193-7.399 11.799a1 1 0 0 1-1.202 0C9.539 20.193 4 14.993 4 10a8 8 0 0 1 16 0"/><circle cx="12" cy="10" r="3"/>',
'arrow-down': '<path d="M12 5v14"/><path d="m19 12-7 7-7-7"/>',
'arrow-up': '<path d="M12 19V5"/><path d="m5 12 7-7 7 7"/>',
search: '<circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/>',
moon: '<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"/>',
sun: '<circle cx="12" cy="12" r="4"/><path d="M12 2v2"/><path d="M12 20v2"/><path d="m4.93 4.93 1.41 1.41"/><path d="m17.66 17.66 1.41 1.41"/><path d="M2 12h2"/><path d="M20 12h2"/><path d="m6.34 17.66-1.41 1.41"/><path d="m19.07 4.93-1.41 1.41"/>',
shield: '<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"/>',
'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"/>',
chart: '<line x1="18" x2="18" y1="20" y2="10"/><line x1="12" x2="12" y1="20" y2="4"/><line x1="6" x2="6" y1="20" y2="14"/>',
wifi: '<path d="M12 20h.01"/><path d="M2 8.82a15 15 0 0 1 20 0"/><path d="M5 12.859a10 10 0 0 1 14 0"/><path d="M8.5 16.429a5 5 0 0 1 7 0"/>',
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"/>',
lock: '<rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>',
'arrow-left': '<path d="m12 19-7-7 7-7"/><path d="M19 12H5"/>',
'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"/>',
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"/>',
'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"/>',
'check-circle': '<path d="M21.801 10A10 10 0 1 1 17 3.335"/><path d="m9 11 3 3L22 4"/>',
'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"/>',
compass: '<path d="m16.24 7.76-1.804 5.411a2 2 0 0 1-1.265 1.265L7.76 16.24l1.804-5.411a2 2 0 0 1 1.265-1.265z"/><circle cx="12" cy="12" r="10"/>',
laptop: '<path d="M18 5a2 2 0 0 1 2 2v8.526a2 2 0 0 0 .212.894l1.575 3.15A.5.5 0 0 1 21.342 21H2.658a.5.5 0 0 1-.445-.724l1.575-3.15A2 2 0 0 0 4 16.526V7a2 2 0 0 1 2-2z"/><path d="M20.054 15.987H3.946"/>',
smartphone: '<rect width="14" height="20" x="5" y="2" rx="2" ry="2"/><path d="M12 18h.01"/>',
'monitor-smartphone': '<path d="M18 8V5a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h8"/><path d="M10 19v-3.96 3.15"/><path d="M7 19h5"/><rect width="6" height="10" x="16" y="12" rx="2"/>',
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"/>',
clock: '<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>',
'credit-card': '<rect width="20" height="14" x="2" y="5" rx="2"/><line x1="2" x2="22" y1="10" y2="10"/>',
'play-circle': '<circle cx="12" cy="12" r="10"/><polygon points="10 8 16 12 10 16 10 8"/>',
};
function Icon({ name, size = 24, 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: LUCIDE[name] || '' }}
/>
);
}
/* Brand mark — walking pangolin (matches app icon), facing right */
function Mark({ size = 40, 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="M26 50 Q34 43 42 50"/><path d="M36 48 Q44 41 52 48"/><path d="M46 48 Q54 41 62 49"/>
</g>
<circle cx="23" cy="55" r="2.2" fill="#fff" opacity="0.85"/>
</g>
</svg>
);
}
/* Country code tile */
function CC({ code, active }) {
return (
<div style={{
width: 38, height: 38, borderRadius: 'var(--radius-md)', flexShrink: 0,
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontFamily: 'var(--font-mono)', fontSize: 12, fontWeight: 600,
background: active ? 'var(--accent)' : 'var(--bg-subtle)',
color: active ? '#fff' : 'var(--fg2)',
}}>{code}</div>
);
}
/* Signal bars from ping */
function Signal({ ping }) {
const lvl = ping < 40 ? 3 : ping < 90 ? 2 : 1;
return (
<div style={{ display: 'flex', gap: 2, alignItems: 'flex-end', height: 14 }}>
{[5, 9, 14].map((h, i) => (
<div key={i} style={{
width: 3, height: h, borderRadius: 1,
background: i < lvl ? 'var(--success)' : 'var(--border-strong)',
}} />
))}
</div>
);
}
/* Pill button */
function Pill({ children, variant = 'primary', onClick, style = {}, disabled }) {
const v = {
primary: { background: 'var(--accent)', color: 'var(--fg-on-accent)' },
secondary: { background: 'var(--surface)', color: 'var(--fg1)', border: '1.5px solid var(--border-strong)' },
ghost: { background: 'transparent', color: 'var(--accent)' },
danger: { background: 'var(--danger-subtle)', color: 'var(--danger)' },
}[variant];
return (
<button onClick={onClick} disabled={disabled} style={{
display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: 8,
fontFamily: 'var(--font-sans)', fontWeight: 600, fontSize: 15,
border: 'none', borderRadius: 'var(--radius-full)', padding: '13px 22px',
cursor: disabled ? 'not-allowed' : 'pointer', transition: 'all 140ms var(--ease-out)',
opacity: disabled ? 0.5 : 1, ...v, ...style,
}}>{children}</button>
);
}
/* ───────── i18n ─────────
Real product shows ONE language at a time (toggle in settings).
Bilingual specimens only live in the design-system preview cards. */
const STRINGS = {
brand: { zh: '穿山甲', en: 'Pangolin' },
online: { zh: '● 在线', en: '● Online' },
offline: { zh: '○ 离线', en: '○ Offline' },
capOff: { zh: '未连接 · 轻点连接', en: 'Tap to connect' },
capConnecting:{ zh: '连接中…', en: 'Connecting…' },
capOn: { zh: '已连接 · 网络已加密', en: 'Connected · Encrypted' },
centerOff: { zh: 'TAP', en: 'TAP' },
download: { zh: '下载', en: 'Down' },
upload: { zh: '上传', en: 'Up' },
latency: { zh: '延迟', en: 'Ping' },
chooseNode: { zh: '选择节点', en: 'Choose server' },
search: { zh: '搜索国家 / 城市', en: 'Search country / city' },
tabConnect: { zh: '连接', en: 'Connect' },
tabServers: { zh: '节点', en: 'Servers' },
tabStats: { zh: '统计', en: 'Stats' },
tabMe: { zh: '我的', en: 'Account' },
connectNow: { zh: '点击连接', en: 'CONNECT' },
secure: { zh: '已加密', en: 'SECURE' },
statsTitle: { zh: '使用统计', en: 'Statistics' },
trafficMonth: { zh: '本月流量', en: 'Traffic' },
avgPing: { zh: '平均延迟', en: 'Avg ping' },
durMonth: { zh: '本月时长', en: 'Time' },
weekTraffic: { zh: '本周流量 (GB)', en: 'This week (GB)' },
days7: { zh: ['一','二','三','四','五','六','日'], en: ['M','T','W','T','F','S','S'] },
byDevice: { zh: '设备明细', en: 'By device' },
noDeviceUsage:{ zh: '暂无设备用量', en: 'No device usage yet' },
proMember: { zh: 'PRO 会员', en: 'PRO member' },
expires: { zh: '有效期至', en: 'Expires' },
usage: { zh: '本月用量', en: 'Used this month' },
smartRoute: { zh: '智能分流', en: 'Smart routing' },
smartRouteSub:{ zh: '海外应用加速,本地直连', en: 'Accelerate overseas apps, keep local direct' },
killSwitch: { zh: 'Kill Switch', en: 'Kill Switch' },
killSwitchSub:{ zh: '断线时阻断网络,防止泄露', en: 'Block all traffic if connection drops' },
darkAppearance:{ zh: '深色外观', en: 'Dark appearance' },
stateOn: { zh: '已开启', en: 'On' },
followLight: { zh: '跟随浅色', en: 'Off' },
protocol: { zh: '协议', en: 'Protocol' },
checkUpdate: { zh: '检查更新', en: 'Check for updates' },
language: { zh: '语言', en: 'Language' },
/* auth */
authTagline: { zh: '极速 · 稳定 · 省心', en: 'Fast · Stable · Effortless' },
tabLogin: { zh: '登录', en: 'Log in' },
tabRegister: { zh: '注册', en: 'Sign up' },
emailLabel: { zh: '邮箱', en: 'Email' },
emailPh: { zh: '你的邮箱地址', en: 'your@email.com' },
pwLabel: { zh: '密码', en: 'Password' },
pwPh: { zh: '输入密码', en: 'Enter password' },
setPwPh: { zh: '设置登录密码(用于多端登录)', en: 'Set a password (for multi-device login)' },
codeLabel: { zh: '邮箱验证码', en: 'Verification code' },
codeSentTo: { zh: '验证码已发送至', en: 'Code sent to' },
sendCode: { zh: '发送验证码', en: 'Send code' },
resend: { zh: '重新发送', en: 'Resend' },
doLogin: { zh: '登录', en: 'Log in' },
doNext: { zh: '下一步', en: 'Next' },
doCreate: { zh: '创建账户', en: 'Create account' },
forgotPw: { zh: '忘记密码?', en: 'Forgot password?' },
tos: { zh: '继续即代表同意《服务条款》与《隐私政策》', en: 'By continuing you agree to our Terms & Privacy Policy' },
stepEmail: { zh: '验证邮箱', en: 'Verify email' },
stepPw: { zh: '设置密码', en: 'Set password' },
/* onboarding */
obSkip: { zh: '跳过', en: 'Skip' },
obNext: { zh: '下一步', en: 'Next' },
obAllow: { zh: '允许并继续', en: 'Allow & continue' },
obStart: { zh: '开始使用', en: 'Get started' },
ob1Title: { zh: '一键连接全球', en: 'Connect worldwide' },
ob1Sub: { zh: '80+ 节点,智能选择最快线路,稳定不掉线。', en: '80+ locations, auto-fastest routing, rock-solid.' },
ob2Title: { zh: '授权网络配置', en: 'Allow network setup' },
ob2Sub: { zh: '系统会请求添加一个网络配置,用于建立加密隧道。', en: 'iOS will ask to add a network profile to build the encrypted tunnel.' },
ob3Title: { zh: '安全 · 无日志', en: 'Private by design' },
ob3Sub: { zh: '端到端加密,我们不记录你的任何浏览数据。', en: 'End-to-end encrypted. We keep zero browsing logs.' },
/* redeem & buy */
redeemEntry: { zh: '兑换 & 购买', en: 'Redeem & buy' },
redeemTitle: { zh: '兑换 & 购买', en: 'Redeem & buy' },
redeemCodeTitle:{ zh: '兑换激活码', en: 'Redeem a code' },
redeemPh: { zh: '输入激活码', en: 'Enter activation code' },
redeemBtn: { zh: '激活', en: 'Activate' },
redeemOk: { zh: '激活成功 · PRO 已开通', en: 'Activated · PRO unlocked' },
buyTitle: { zh: '购买渠道', en: 'Where to buy' },
buySub: { zh: 'App 内不支持直接支付。请通过以下渠道获取激活码:', en: 'In-app payment is unavailable. Get an activation code via:' },
chStore: { zh: '自助发卡商店', en: 'Self-serve store' },
chStoreSub: { zh: '支付宝 / 微信 · 自动发码', en: 'Alipay / WeChat · instant code' },
chUsdtSub: { zh: '链上转账 · 最隐私 · 自动发码', en: 'On-chain · most private · instant code' },
chTgSub: { zh: '@PangolinVPN_bot', en: '@PangolinVPN_bot' },
chLineSub: { zh: '@pangolinvpn', en: '@pangolinvpn' },
chEmail: { zh: '邮箱', en: 'Email' },
chEmailSub: { zh: 'buy@pangolin.vpn', en: 'buy@pangolin.vpn' },
back: { zh: '返回', en: 'Back' },
replayAuth: { zh: '查看登录 / 注册流程', en: 'View login / sign-up' },
twoFA: { zh: '双重认证', en: 'Two-factor auth' },
twoFASub: { zh: 'PRO 专享 · 登录需身份验证器动态码', en: 'PRO only · TOTP code at login' },
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' },
ucEntry: { zh: 'Web 用户中心', en: 'Web account center' },
ucEntrySub: { zh: '订阅导入 · 邀请返利', en: 'Subscription import · referral' },
replayAuthSub:{ zh: '演示用 · 退出到登录页', en: 'Demo · back to login' },
replayOnboarding: { zh: '查看首次引导', en: 'View onboarding' },
replayOnboardingSub: { zh: '演示用 · 重看 3 屏引导', en: 'Demo · replay the 3 intro screens' },
/* account management (integrated) */
meTitle: { zh: '我的', en: 'Account' },
curPlan: { zh: '当前套餐', en: 'Current plan' },
expires: { zh: '有效期至', en: 'Expires' },
upgradeBtn: { zh: '续费 / 升级', en: 'Renew / Upgrade' },
accInfoTitle: { zh: '账户信息', en: 'Account info' },
accEmail: { zh: '邮箱', en: 'Email' },
accPassword: { zh: '密码', en: 'Password' },
accChange: { zh: '修改', en: 'Change' },
accSignOut: { zh: '退出登录', en: 'Sign out' },
myDevices: { zh: '我的设备', en: 'My devices' },
devicesSub: { zh: 'PRO 套餐最多 5 台设备同时在线', en: 'Up to 5 devices on PRO' },
thisDevice: { zh: '当前设备', en: 'This device' },
lastActive: { zh: '最近活跃', en: 'Last active' },
remove: { zh: '移除', en: 'Remove' },
choosePlan: { zh: '选择套餐', en: 'Choose plan' },
free: { zh: '免费版', en: 'Free' },
proPlan: { zh: '专业版', en: 'Pro' },
team: { zh: '团队版', en: 'Team' },
perMonth: { zh: '/月', en: '/mo' },
current: { zh: '当前', en: 'Current' },
upgrade: { zh: '立即升级', en: 'Upgrade' },
choose: { zh: '选择', en: 'Choose' },
mostPopular: { zh: '最受欢迎', en: 'Popular' },
feFree: { zh: ['仅 1 个基础节点','每日 10 分钟时长','使用前观看广告','注册享 7 天免费试用'], en: ['1 basic node only','10 min per day','Watch an ad to start','7-day free trial on sign-up'] },
fePro: { zh: ['80+ 全球节点','无限流量 · 极速','5 台设备同时在线','流媒体优化'], en: ['80+ locations','Unlimited · fast','5 devices','Streaming optimized'] },
feTeam: { zh: ['Pro 全部功能','10 个成员席位','集中计费与管理','优先客服'], en: ['Everything in Pro','10 seats','Central billing','Priority support'] },
/* contact */
contactEntry: { zh: '联系我们', en: 'Contact us' },
contactTitle: { zh: '联系我们', en: 'Contact us' },
contactIntro: { zh: '遇到问题?通过以下任一渠道联系我们,通常数分钟内回复。', en: 'Need help? Reach us via any channel below — usually replies in minutes.' },
contactEmail: { zh: '邮箱客服', en: 'Email support' },
contactStore: { zh: '自助发卡商店', en: 'Self-serve store' },
contactHoursTitle: { zh: '服务时间', en: 'Support hours' },
contactHours: { zh: '每日 9:00 24:00 (GMT+8)', en: 'Daily 9:00 24:00 (GMT+8)' },
devicesCount: { zh: '台设备', en: 'devices' },
/* smart select + quota (口径见 CLAUDE.md §7) */
smartSelect: { zh: '智能选择', en: 'Smart select' },
smartSub: { zh: '根据当前网络环境,自动选择最优节点', en: 'Picks the best node for your network' },
recommended: { zh: '推荐', en: 'Recommended' },
quotaToday: { zh: '今日剩余', en: 'Left today' },
minutes: { zh: '分钟', en: 'min' },
quotaFree: { zh: '免费版 · 每日 10 分钟', en: 'Free · 10 min/day' },
quotaTrial: { zh: '体验期 · 7 天免费使用', en: 'Trial · 7 days free' },
watchAd: { zh: '看广告开始使用', en: 'Watch ad to start' },
adUnlocked: { zh: '已解锁 · 今日可用', en: 'Unlocked for today' },
freePlanName: { zh: '免费版', en: 'Free' },
upgradeNow: { zh: '升级 PRO', en: 'Go PRO' },
freeDemo: { zh: '演示:免费版视角', en: 'Demo: free-tier view' },
freeDemoSub: { zh: '切换连接页的额度展示与套餐横幅', en: 'Toggles quota UI & plan banner' },
};
function makeT(lang) { return (k) => (STRINGS[k] && STRINGS[k][lang]) || k; }
/* localized server label */
function srvName(s, lang) { return lang === 'zh' ? s.name : s.en; }
function srvSub(s, lang) {
const tag = s.tags && s.tags[0];
if (lang === 'zh') return (tag ? tag : s.en);
return (tag === '流媒体优化' ? 'Streaming' : tag === 'P2P' ? 'P2P' : s.name);
}
Object.assign(window, { Icon, Mark, CC, Signal, Pill, STRINGS, makeT, srvName, srvSub, SERVERS: [
{ code: 'HK', name: '香港 · 流媒体', en: 'Hong Kong', ping: 18, tags: ['流媒体优化'] },
{ code: 'JP', name: '日本 东京', en: 'Tokyo', ping: 32, tags: ['P2P'] },
{ code: 'SG', name: '新加坡', en: 'Singapore', ping: 54, tags: ['P2P'] },
{ code: 'TW', name: '台湾 台北', en: 'Taipei', ping: 28, tags: [] },
{ code: 'US', name: '美国 洛杉矶', en: 'Los Angeles', ping: 146, tags: ['流媒体优化'] },
{ code: 'DE', name: '德国 法兰克福', en: 'Frankfurt', ping: 198, tags: [] },
{ code: 'UK', name: '英国 伦敦', en: 'London', ping: 210, tags: [] },
{ code: 'KR', name: '韩国 首尔', en: 'Seoul', ping: 41, tags: [] },
] });