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,29 @@
|
||||
# 穿山甲 VPN — 移动 App UI Kit
|
||||
|
||||
iOS / Android 连接界面的高保真可点击原型。极简、暖大地色、双语、支持深浅主题。
|
||||
|
||||
## 运行
|
||||
打开 `index.html`。设备框由 `ios-frame.jsx`(启动组件)提供。完整流程:**登录/注册 → 首次引导 → 主应用**。可交互:
|
||||
- **登录 / 注册**(`AuthFlow`):登录用邮箱 + 密码;注册走「邮箱 → 发送验证码 → 填验证码 → 设置密码」。密码用于多端登录。
|
||||
- **首次引导**(`Onboarding`):3 屏(连接全球 → 授权 VPN 配置 → 安全无日志),可跳过。
|
||||
- **连接键**:轻点 → 连接中(旋转 loader + 进度弧)→ 已连接(SAFE + 计时 + 实时速率)。再点断开。
|
||||
- **底部 Tab**:连接 / 节点 / 统计 / 我的。
|
||||
- **节点**:搜索 + 列表,点选切换(已连接时短暂重连),选中态高亮。
|
||||
- **统计**:流量 / 延迟 / 时长指标卡 + 本周柱状图。
|
||||
- **我的**:**兑换 & 购买**入口、PRO 账户卡、智能分流 / Kill Switch 开关、**深色外观开关**、语言切换。
|
||||
- **兑换 & 购买**(`RedeemScreen`):兑换激活码 + 购买渠道(自助发卡 / Telegram / LINE / 邮箱)。**App 内无支付表单**——资金流全走外部,符合 VPN 风控。
|
||||
|
||||
## 文件
|
||||
| 文件 | 内容 |
|
||||
|---|---|
|
||||
| `index.html` | 应用状态机:stage(auth/onboarding/app)/ tab / 连接态 / 节点 / 主题 / 计时器 |
|
||||
| `parts.jsx` | 原子:`Icon`(内联 Lucide 路径)、`Mark`(品牌标)、`CC`(国家码块)、`Signal`、`Pill`;`SERVERS` 数据;`STRINGS` i18n 字典 |
|
||||
| `screens.jsx` | `ConnectScreen` / `ServersScreen` / `StatsScreen` / `SettingsScreen` / `BottomTab` / `Toggle` / `Row` |
|
||||
| `flows.jsx` | `AuthFlow`(登录/邮箱验证码注册)/ `Onboarding`(3 屏)/ `RedeemScreen`(兑换+购买渠道) |
|
||||
| `ios-frame.jsx` | 设备框启动组件(状态栏 / 灵动岛 / Home 指示器) |
|
||||
|
||||
## 关键模式
|
||||
- 颜色 / 字体 / 圆角 / 阴影全部走 `../../colors_and_type.css` 的语义 token,组件天然双主题。
|
||||
- 图标用内联 Lucide 路径数据(`Icon` 组件),避免 React 中 `createIcons()` 时序问题。
|
||||
- 连接键三态用色环(`box-shadow` 外环)区分:中性 / clay / success。
|
||||
- 国家用 2 字母码块,不用 emoji 国旗。
|
||||
@@ -0,0 +1,269 @@
|
||||
/* flows.jsx — 穿山甲 VPN mobile · auth, onboarding, redeem/buy */
|
||||
const { useState: useStateF } = React;
|
||||
|
||||
/* shared field */
|
||||
function Field({ icon, label, children }) {
|
||||
return (
|
||||
<label style={{ display: 'block' }}>
|
||||
<span style={{ display: 'block', fontSize: 12, fontWeight: 600, color: 'var(--fg2)', marginBottom: 7 }}>{label}</span>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, background: 'var(--surface)', border: '1.5px solid var(--border-strong)', borderRadius: 'var(--radius-md)', padding: '12px 14px' }}>
|
||||
{icon && <Icon name={icon} size={18} color="var(--fg3)" />}
|
||||
{children}
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
const inputStyle = { border: 'none', background: 'transparent', outline: 'none', flex: 1, fontFamily: 'var(--font-sans)', fontSize: 15, color: 'var(--fg1)', minWidth: 0 };
|
||||
|
||||
/* ───────── AUTH (login + email-code register) ───────── */
|
||||
function AuthFlow({ t, lang, onDone }) {
|
||||
const [mode, setMode] = useStateF('login'); // login | register
|
||||
const [step, setStep] = useStateF(0); // register: 0 email+code, 1 set password
|
||||
const [email, setEmail] = useStateF('');
|
||||
const [code, setCode] = useStateF('');
|
||||
const [pw, setPw] = useStateF('');
|
||||
const [sent, setSent] = useStateF(false);
|
||||
const [totp, setTotp] = useStateF(false); // 2FA step after login (PRO)
|
||||
const [otp, setOtp] = useStateF('');
|
||||
|
||||
const valid = /\S+@\S+\.\S+/.test(email);
|
||||
const tab = (id, label) => (
|
||||
<button onClick={() => { setMode(id); setStep(0); setSent(false); }} style={{
|
||||
flex: 1, border: 'none', cursor: 'pointer', background: 'transparent', padding: '10px 0',
|
||||
fontFamily: 'var(--font-sans)', fontSize: 15, fontWeight: mode === id ? 700 : 500,
|
||||
color: mode === id ? 'var(--fg1)' : 'var(--fg3)', borderBottom: mode === id ? '2px solid var(--accent)' : '2px solid transparent',
|
||||
}}>{label}</button>
|
||||
);
|
||||
const primaryBtn = (label, onClick, enabled) => (
|
||||
<button onClick={enabled ? onClick : undefined} disabled={!enabled} style={{
|
||||
width: '100%', border: 'none', borderRadius: 'var(--radius-full)', padding: '14px',
|
||||
fontFamily: 'var(--font-sans)', fontWeight: 700, fontSize: 15, cursor: enabled ? 'pointer' : 'not-allowed',
|
||||
background: 'var(--accent)', color: 'var(--fg-on-accent)', opacity: enabled ? 1 : 0.45, marginTop: 4,
|
||||
}}>{label}</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', padding: '0 28px', background: 'var(--bg)' }}>
|
||||
{/* brand header */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 12, paddingTop: 18, paddingBottom: 28 }}>
|
||||
<Mark size={56} />
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{ fontFamily: 'var(--font-display)', fontWeight: 700, fontSize: 24, color: 'var(--fg1)' }}>{t('brand')}</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--accent)', fontWeight: 600, letterSpacing: '0.04em', marginTop: 4 }}>{t('authTagline')}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* tabs */}
|
||||
<div style={{ display: 'flex', borderBottom: '1px solid var(--border)', marginBottom: 22 }}>
|
||||
{tab('login', t('tabLogin'))}
|
||||
{tab('register', t('tabRegister'))}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16, flex: 1 }}>
|
||||
{mode === 'login' && !totp && (<>
|
||||
<Field icon="mail" label={t('emailLabel')}><input style={inputStyle} value={email} onChange={e => setEmail(e.target.value)} placeholder={t('emailPh')} /></Field>
|
||||
<Field icon="lock" label={t('pwLabel')}><input style={inputStyle} type="password" value={pw} onChange={e => setPw(e.target.value)} placeholder={t('pwPh')} /></Field>
|
||||
<div style={{ textAlign: 'right', marginTop: -6 }}><span style={{ fontSize: 13, color: 'var(--accent)', fontWeight: 600 }}>{t('forgotPw')}</span></div>
|
||||
{primaryBtn(t('doLogin'), () => setTotp(true), valid && pw.length > 0)}
|
||||
</>)}
|
||||
|
||||
{mode === 'login' && totp && (<>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<Icon name="shield-check" size={19} color="var(--accent)" />
|
||||
<span style={{ fontFamily: 'var(--font-display)', fontWeight: 700, fontSize: 18, color: 'var(--fg1)' }}>{t('twoFATitle')}</span>
|
||||
<span style={{ fontSize: 10, fontWeight: 700, color: 'var(--accent)', background: 'var(--accent-subtle)', border: '1px solid var(--accent-border)', padding: '2px 8px', borderRadius: 999 }}>PRO</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 12.5, color: 'var(--fg3)', lineHeight: 1.6, marginTop: -6 }}>{t('twoFAHint')}</div>
|
||||
<Field icon="shield-check" label={t('twoFATitle')}>
|
||||
<input style={{ ...inputStyle, fontFamily: 'var(--font-mono)', fontSize: 20, letterSpacing: '0.5em', textAlign: 'center' }} value={otp} onChange={e => setOtp(e.target.value.replace(/\D/g, '').slice(0, 6))} placeholder="······" />
|
||||
</Field>
|
||||
{primaryBtn(t('twoFAConfirm'), onDone, otp.length === 6)}
|
||||
<div onClick={() => setTotp(false)} style={{ textAlign: 'center', fontSize: 12.5, color: 'var(--fg3)', cursor: 'pointer' }}>{t('twoFABack')}</div>
|
||||
</>)}
|
||||
|
||||
{mode === 'register' && step === 0 && (<>
|
||||
<Field icon="mail" label={t('emailLabel')}>
|
||||
<input style={inputStyle} value={email} onChange={e => setEmail(e.target.value)} placeholder={t('emailPh')} />
|
||||
<button onClick={() => valid && setSent(true)} style={{ border: 'none', background: 'transparent', cursor: valid ? 'pointer' : 'default', color: valid ? 'var(--accent)' : 'var(--fg3)', fontWeight: 700, fontSize: 13, whiteSpace: 'nowrap', flexShrink: 0 }}>{sent ? t('resend') : t('sendCode')}</button>
|
||||
</Field>
|
||||
{sent && <div style={{ fontSize: 12, color: 'var(--success)', marginTop: -8, display: 'flex', alignItems: 'center', gap: 6 }}><Icon name="check-circle" size={14} color="var(--success)" />{t('codeSentTo')} {email}</div>}
|
||||
<Field icon="shield-check" label={t('codeLabel')}>
|
||||
<input style={{ ...inputStyle, fontFamily: 'var(--font-mono)', letterSpacing: '0.3em' }} value={code} onChange={e => setCode(e.target.value.replace(/\D/g, '').slice(0, 6))} placeholder="······" />
|
||||
</Field>
|
||||
{primaryBtn(t('doNext'), () => setStep(1), sent && code.length === 6)}
|
||||
</>)}
|
||||
|
||||
{mode === 'register' && step === 1 && (<>
|
||||
<div style={{ fontSize: 13, color: 'var(--fg2)', display: 'flex', alignItems: 'center', gap: 7, marginBottom: -4 }}><Icon name="check-circle" size={15} color="var(--success)" />{email}</div>
|
||||
<Field icon="lock" label={t('pwLabel')}><input style={inputStyle} type="password" value={pw} onChange={e => setPw(e.target.value)} placeholder={t('setPwPh')} /></Field>
|
||||
{primaryBtn(t('doCreate'), onDone, pw.length >= 6)}
|
||||
</>)}
|
||||
</div>
|
||||
|
||||
<div style={{ fontSize: 11, color: 'var(--fg3)', textAlign: 'center', lineHeight: 1.5, padding: '18px 8px 24px' }}>{t('tos')}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ───────── ONBOARDING (3 screens) ───────── */
|
||||
function Onboarding({ t, lang, onDone }) {
|
||||
const [i, setI] = useStateF(0);
|
||||
const slides = [
|
||||
{ icon: 'globe', tint: 'var(--accent)', bg: 'var(--accent-subtle)', title: t('ob1Title'), sub: t('ob1Sub'), cta: t('obNext') },
|
||||
{ icon: 'shield', tint: 'var(--accent)', bg: 'var(--accent-subtle)', title: t('ob2Title'), sub: t('ob2Sub'), cta: t('obAllow') },
|
||||
{ icon: 'shield-check', tint: 'var(--success)', bg: 'var(--success-subtle)', title: t('ob3Title'), sub: t('ob3Sub'), cta: t('obStart') },
|
||||
];
|
||||
const s = slides[i];
|
||||
const next = () => (i < 2 ? setI(i + 1) : onDone());
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', padding: '0 28px', background: 'var(--bg)' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', paddingTop: 10 }}>
|
||||
<button onClick={onDone} style={{ border: 'none', background: 'transparent', cursor: 'pointer', color: 'var(--fg3)', fontSize: 14, fontWeight: 600, padding: 8 }}>{t('obSkip')}</button>
|
||||
</div>
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 28, textAlign: 'center' }}>
|
||||
<div style={{ width: 120, height: 120, borderRadius: '50%', background: s.bg, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Icon name={s.icon} size={52} stroke={1.6} color={s.tint} />
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontFamily: 'var(--font-display)', fontWeight: 700, fontSize: 26, color: 'var(--fg1)' }}>{s.title}</div>
|
||||
<div style={{ fontSize: 15, color: 'var(--fg2)', lineHeight: 1.6, marginTop: 12, maxWidth: 280 }}>{s.sub}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'center', gap: 8, marginBottom: 22 }}>
|
||||
{slides.map((_, k) => <div key={k} style={{ width: k === i ? 22 : 7, height: 7, borderRadius: 999, background: k === i ? 'var(--accent)' : 'var(--border-strong)', transition: 'all 220ms var(--ease-out)' }} />)}
|
||||
</div>
|
||||
<button onClick={next} style={{ width: '100%', border: 'none', borderRadius: 'var(--radius-full)', padding: '15px', fontFamily: 'var(--font-sans)', fontWeight: 700, fontSize: 15, cursor: 'pointer', background: 'var(--accent)', color: 'var(--fg-on-accent)', marginBottom: 26 }}>{s.cta}</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ───────── REDEEM & BUY ───────── */
|
||||
function RedeemScreen({ t, lang, onBack }) {
|
||||
const [code, setCode] = useStateF('');
|
||||
const [ok, setOk] = useStateF(false);
|
||||
const channels = [
|
||||
{ icon: 'shopping-bag', name: t('chStore'), sub: t('chStoreSub'), accent: true },
|
||||
{ icon: 'credit-card', name: 'USDT (TRC20)', sub: t('chUsdtSub') },
|
||||
{ icon: 'send', name: 'Telegram', sub: t('chTgSub') },
|
||||
{ icon: 'message-circle', name: 'LINE', sub: t('chLineSub') },
|
||||
{ icon: 'mail', name: t('chEmail'), sub: t('chEmailSub') },
|
||||
];
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '6px 16px 14px' }}>
|
||||
<button onClick={onBack} style={{ border: 'none', background: 'transparent', cursor: 'pointer', padding: 6, display: 'flex' }}><Icon name="arrow-left" size={22} color="var(--fg1)" /></button>
|
||||
<span style={{ fontFamily: 'var(--font-display)', fontWeight: 700, fontSize: 18, color: 'var(--fg1)', whiteSpace: 'nowrap' }}>{t('redeemTitle')}</span>
|
||||
</div>
|
||||
<div style={{ flex: 1, overflow: 'auto', padding: '0 20px 20px' }}>
|
||||
{/* redeem code */}
|
||||
<div style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 'var(--radius-lg)', boxShadow: 'var(--shadow-sm)', padding: 18, marginBottom: 22 }}>
|
||||
<div style={{ fontSize: 14, fontWeight: 700, color: 'var(--fg1)', marginBottom: 12 }}>{t('redeemCodeTitle')}</div>
|
||||
{ok ? (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 9, color: 'var(--success)', fontWeight: 600, fontSize: 15, padding: '6px 0' }}><Icon name="check-circle" size={20} color="var(--success)" />{t('redeemOk')}</div>
|
||||
) : (<>
|
||||
<div style={{ display: 'flex', gap: 10 }}>
|
||||
<input value={code} onChange={e => setCode(e.target.value.toUpperCase())} placeholder={t('redeemPh')} style={{ flex: 1, minWidth: 0, border: '1.5px solid var(--border-strong)', borderRadius: 'var(--radius-md)', padding: '12px 14px', fontFamily: 'var(--font-mono)', fontSize: 14, letterSpacing: '0.08em', color: 'var(--fg1)', background: 'var(--bg)', outline: 'none' }} />
|
||||
<button onClick={() => code.length >= 4 && setOk(true)} style={{ border: 'none', borderRadius: 'var(--radius-md)', padding: '0 20px', background: 'var(--accent)', color: '#fff', fontWeight: 700, fontSize: 14, cursor: 'pointer', flexShrink: 0 }}>{t('redeemBtn')}</button>
|
||||
</div>
|
||||
</>)}
|
||||
</div>
|
||||
{/* buy channels */}
|
||||
<div style={{ fontSize: 14, fontWeight: 700, color: 'var(--fg1)', marginBottom: 6 }}>{t('buyTitle')}</div>
|
||||
<div style={{ fontSize: 12.5, color: 'var(--fg3)', lineHeight: 1.5, marginBottom: 14 }}>{t('buySub')}</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{channels.map(c => (
|
||||
<button key={c.name} style={{ display: 'flex', alignItems: 'center', gap: 13, width: '100%', textAlign: 'left', cursor: 'pointer',
|
||||
background: c.accent ? 'var(--accent-subtle)' : 'var(--surface)', border: `1px solid ${c.accent ? 'var(--accent-border)' : 'var(--border)'}`,
|
||||
borderRadius: 'var(--radius-lg)', padding: '14px 16px', boxShadow: 'var(--shadow-sm)' }}>
|
||||
<div style={{ width: 40, height: 40, borderRadius: 'var(--radius-md)', background: c.accent ? 'var(--accent)' : 'var(--bg-subtle)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
<Icon name={c.icon} size={20} color={c.accent ? '#fff' : 'var(--accent)'} />
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: 15, fontWeight: 600, color: 'var(--fg1)' }}>{c.name}</div>
|
||||
<div style={{ fontSize: 12.5, color: 'var(--fg3)', fontFamily: c.icon === 'send' || c.icon === 'mail' || c.icon === 'message-circle' ? 'var(--font-mono)' : 'var(--font-sans)' }}>{c.sub}</div>
|
||||
</div>
|
||||
<Icon name="external-link" size={17} color="var(--fg3)" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ───────── PLANS (shown on upgrade) ───────── */
|
||||
function PlansScreen({ t, lang, onBack, onChoose }) {
|
||||
const plans = [
|
||||
{ id: 'free', name: t('free'), price: '¥0', feats: t('feFree'), cta: t('current'), variant: 'cur' },
|
||||
{ id: 'pro', name: t('proPlan'), price: '¥25', feats: t('fePro'), cta: t('upgrade'), variant: 'pro' },
|
||||
{ id: 'team', name: t('team'), price: '¥99', feats: t('feTeam'), cta: t('choose'), variant: 'plain' },
|
||||
];
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '6px 16px 14px' }}>
|
||||
<button onClick={onBack} style={{ border: 'none', background: 'transparent', cursor: 'pointer', padding: 6, display: 'flex' }}><Icon name="arrow-left" size={22} color="var(--fg1)" /></button>
|
||||
<span style={{ fontFamily: 'var(--font-display)', fontWeight: 700, fontSize: 18, color: 'var(--fg1)', whiteSpace: 'nowrap' }}>{t('choosePlan')}</span>
|
||||
</div>
|
||||
<div style={{ flex: 1, overflow: 'auto', padding: '4px 20px 20px', display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
{plans.map(p => {
|
||||
const isPro = p.variant === 'pro';
|
||||
return (
|
||||
<div key={p.id} style={{ position: 'relative', borderRadius: 'var(--radius-xl)', padding: '18px 20px',
|
||||
...(isPro ? { background: 'linear-gradient(155deg,var(--clay-600),var(--clay-800))', color: '#fff', boxShadow: 'var(--shadow-md)' } : { background: 'var(--surface)', border: '1px solid var(--border)', boxShadow: 'var(--shadow-sm)' }) }}>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between' }}>
|
||||
<div style={{ fontSize: 14, fontWeight: 600, color: isPro ? '#fff' : 'var(--fg2)', opacity: isPro ? 0.9 : 1 }}>{p.name}{isPro && <span style={{ marginLeft: 8, fontSize: 10.5, fontWeight: 700, background: '#fff', color: 'var(--clay-700)', padding: '2px 8px', borderRadius: 999 }}>{t('mostPopular')}</span>}</div>
|
||||
<div style={{ fontFamily: 'var(--font-display)', fontSize: 26, fontWeight: 700, color: isPro ? '#fff' : 'var(--fg1)' }}>{p.price}<span style={{ fontSize: 12, fontWeight: 500, opacity: 0.7 }}>{t('perMonth')}</span></div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '7px 16px', margin: '14px 0 16px' }}>
|
||||
{p.feats.map((f, i) => (<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 7, fontSize: 12.5, color: isPro ? 'rgba(255,255,255,.92)' : 'var(--fg2)' }}><Icon name="check" size={14} color={isPro ? '#fff' : 'var(--success)'} stroke={2.4} />{f}</div>))}
|
||||
</div>
|
||||
<button onClick={p.variant !== 'cur' ? onChoose : undefined} style={{ width: '100%', border: p.variant === 'cur' ? '1.5px solid var(--border-strong)' : 'none', cursor: p.variant === 'cur' ? 'default' : 'pointer', borderRadius: 'var(--radius-full)', padding: '12px', fontWeight: 700, fontSize: 14,
|
||||
background: isPro ? '#fff' : (p.variant === 'cur' ? 'transparent' : 'var(--accent)'), color: isPro ? 'var(--clay-700)' : (p.variant === 'cur' ? 'var(--fg2)' : '#fff') }}>{p.cta}</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ───────── CONTACT US ───────── */
|
||||
function ContactScreen({ t, lang, onBack }) {
|
||||
const channels = [
|
||||
{ icon: 'send', name: 'Telegram', sub: '@PangolinVPN_bot', accent: true },
|
||||
{ icon: 'message-circle', name: 'LINE', sub: '@pangolinvpn' },
|
||||
{ icon: 'mail', name: t('contactEmail'), sub: 'support@pangolin.vpn' },
|
||||
{ icon: 'shopping-bag', name: t('contactStore'), sub: 'shop.pangolin.vpn' },
|
||||
];
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '6px 16px 14px' }}>
|
||||
<button onClick={onBack} style={{ border: 'none', background: 'transparent', cursor: 'pointer', padding: 6, display: 'flex' }}><Icon name="arrow-left" size={22} color="var(--fg1)" /></button>
|
||||
<span style={{ fontFamily: 'var(--font-display)', fontWeight: 700, fontSize: 18, color: 'var(--fg1)', whiteSpace: 'nowrap' }}>{t('contactTitle')}</span>
|
||||
</div>
|
||||
<div style={{ flex: 1, overflow: 'auto', padding: '0 20px 20px' }}>
|
||||
<div style={{ fontSize: 13, color: 'var(--fg2)', lineHeight: 1.6, marginBottom: 16 }}>{t('contactIntro')}</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, marginBottom: 18 }}>
|
||||
{channels.map(c => (
|
||||
<button key={c.name} style={{ display: 'flex', alignItems: 'center', gap: 13, width: '100%', textAlign: 'left', cursor: 'pointer',
|
||||
background: c.accent ? 'var(--accent-subtle)' : 'var(--surface)', border: `1px solid ${c.accent ? 'var(--accent-border)' : 'var(--border)'}`,
|
||||
borderRadius: 'var(--radius-lg)', padding: '14px 16px', boxShadow: 'var(--shadow-sm)' }}>
|
||||
<div style={{ width: 40, height: 40, borderRadius: 'var(--radius-md)', background: c.accent ? 'var(--accent)' : 'var(--bg-subtle)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}><Icon name={c.icon} size={20} color={c.accent ? '#fff' : 'var(--accent)'} /></div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 15, fontWeight: 600, color: 'var(--fg1)' }}>{c.name}</div>
|
||||
<div style={{ fontSize: 12.5, color: 'var(--fg3)', fontFamily: 'var(--font-mono)' }}>{c.sub}</div>
|
||||
</div>
|
||||
<Icon name="external-link" size={17} color="var(--fg3)" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 'var(--radius-lg)', boxShadow: 'var(--shadow-sm)', padding: '14px 16px' }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--fg3)' }}>{t('contactHoursTitle')}</div>
|
||||
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--fg1)', marginTop: 5, fontFamily: 'var(--font-mono)' }}>{t('contactHours')}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Object.assign(window, { AuthFlow, Onboarding, RedeemScreen, PlansScreen, ContactScreen });
|
||||
@@ -0,0 +1,128 @@
|
||||
<!-- @dsCard group="UI Kit — Mobile" name="UI Kit · 移动 App" subtitle="iOS 连接界面 · 节点 · 账户 · 中英切换 · 双主题" viewport="390x760" -->
|
||||
<!doctype html>
|
||||
<html lang="zh">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>穿山甲 · 移动 App</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: 32px 0; box-sizing: border-box; font-family: var(--font-sans); }
|
||||
[data-theme="dark"] ~ * , body:has(#root [data-theme="dark"]) { }
|
||||
@keyframes pg-spin { to { transform: rotate(360deg); } }
|
||||
@keyframes pg-in-l { from { opacity: 0; transform: translateX(18px); } to { opacity: 1; transform: translateX(0); } }
|
||||
@keyframes pg-in-r { from { opacity: 0; transform: translateX(-18px); } to { opacity: 1; transform: translateX(0); } }
|
||||
* { -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="ios-frame.jsx?v8"></script>
|
||||
<script type="text/babel" src="parts.jsx?v8"></script>
|
||||
<script type="text/babel" src="screens.jsx?v8"></script>
|
||||
<script type="text/babel" src="flows.jsx?v8"></script>
|
||||
<script type="text/babel">
|
||||
const { useState, useEffect, useRef } = React;
|
||||
|
||||
function App() {
|
||||
const [stage, setStage] = useState('app'); // app | auth | onboarding — UI kit opens on the main app
|
||||
const [tab, setTab] = useState('connect');
|
||||
const [status, setStatus] = useState('off'); // off | connecting | on
|
||||
const [code, setCode] = useState('AUTO'); // 'AUTO' = 智能选择(默认) | 节点 code
|
||||
const [dark, setDark] = useState(false);
|
||||
const [lang, setLang] = useState('zh'); // zh | en
|
||||
const [free, setFree] = useState(true); // 演示:免费版视角(额度 UI)
|
||||
const [mins, setMins] = useState(6); // 今日剩余分钟(演示值,每日 10)
|
||||
const [adDone, setAdDone] = useState(false); // 今日是否已看广告解锁
|
||||
const [elapsed, setElapsed] = useState(0);
|
||||
const [q, setQ] = useState('');
|
||||
const [dir, setDir] = useState(0); // 滑动方向:1 左滑进 / -1 右滑进 / 0 无动画
|
||||
const timer = useRef(null);
|
||||
const touchRef = useRef({ x: 0, y: 0 });
|
||||
const t = makeT(lang);
|
||||
const SWIPE_ORDER = ['connect', 'servers', 'stats', 'me'];
|
||||
|
||||
// 统一导航:主 Tab 间带方向动画,子页(兑换/套餐/联系)无动画
|
||||
function goTab(id) {
|
||||
const a = SWIPE_ORDER.indexOf(tab), b = SWIPE_ORDER.indexOf(id);
|
||||
setDir(a >= 0 && b >= 0 ? (b > a ? 1 : b < a ? -1 : 0) : 0);
|
||||
setTab(id);
|
||||
setTimeout(() => setDir(0), 300); // 动画结束后清除(隐藏环境下 animationend 不可靠)
|
||||
}
|
||||
function onSwipeStart(e) { const p = e.touches[0]; touchRef.current = { x: p.clientX, y: p.clientY }; }
|
||||
function onSwipeEnd(e) {
|
||||
if (!SWIPE_ORDER.includes(tab)) return; // 子页不响应滑动
|
||||
const p = e.changedTouches[0];
|
||||
const dx = p.clientX - touchRef.current.x, dy = p.clientY - touchRef.current.y;
|
||||
if (Math.abs(dx) > 60 && Math.abs(dx) > Math.abs(dy) * 1.5) {
|
||||
const i = SWIPE_ORDER.indexOf(tab);
|
||||
const ni = dx < 0 ? Math.min(SWIPE_ORDER.length - 1, i + 1) : Math.max(0, i - 1);
|
||||
if (ni !== i) goTab(SWIPE_ORDER[ni]);
|
||||
}
|
||||
}
|
||||
|
||||
const smart = code === 'AUTO';
|
||||
const server = smart ? SERVERS.reduce((a, b) => (a.ping < b.ping ? a : b)) : SERVERS.find(s => s.code === code);
|
||||
|
||||
function toggle() {
|
||||
if (status === 'off') {
|
||||
setStatus('connecting');
|
||||
setTimeout(() => { setStatus('on'); setElapsed(0); }, 1500);
|
||||
} else {
|
||||
setStatus('off');
|
||||
}
|
||||
}
|
||||
// pick a server: if connected, brief reconnect
|
||||
function pick(c) {
|
||||
setCode(c);
|
||||
setTab('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 body;
|
||||
if (stage === 'auth') {
|
||||
body = <AuthFlow t={t} lang={lang} onDone={() => setStage('onboarding')} />;
|
||||
} else if (stage === 'onboarding') {
|
||||
body = <Onboarding t={t} lang={lang} onDone={() => setStage('app')} />;
|
||||
} else {
|
||||
let screen;
|
||||
if (tab === 'connect') screen = <ConnectScreen t={t} lang={lang} status={status} onToggle={toggle} server={server} smart={smart} free={free} mins={mins} adDone={adDone} onAd={() => setAdDone(true)} goServers={() => goTab('servers')} elapsed={elapsed} />;
|
||||
else if (tab === 'servers') screen = <ServersScreen t={t} lang={lang} current={code} onPick={pick} q={q} setQ={setQ} />;
|
||||
else if (tab === 'stats') screen = <StatsScreen t={t} lang={lang} />;
|
||||
else if (tab === 'redeem') screen = <RedeemScreen t={t} lang={lang} onBack={() => goTab('plans')} />;
|
||||
else if (tab === 'plans') screen = <PlansScreen t={t} lang={lang} onBack={() => goTab('me')} onChoose={() => goTab('redeem')} />;
|
||||
else if (tab === 'contact') screen = <ContactScreen t={t} lang={lang} onBack={() => goTab('me')} />;
|
||||
else screen = <SettingsScreen t={t} lang={lang} setLang={setLang} dark={dark} setDark={setDark} free={free} setFree={setFree} onRedeem={() => goTab('redeem')} onUpgrade={() => goTab('plans')} onContact={() => goTab('contact')} onSignOut={() => setStage('auth')} onReplayAuth={() => setStage('auth')} onReplayOnboarding={() => setStage('onboarding')} />;
|
||||
body = (<>
|
||||
<div onTouchStart={onSwipeStart} onTouchEnd={onSwipeEnd} style={{ flex: 1, minHeight: 0, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
|
||||
<div key={tab} onAnimationEnd={() => setDir(0)} style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', animation: dir !== 0 ? `pg-in-${dir === 1 ? 'l' : 'r'} 200ms var(--ease-out)` : 'none' }}>
|
||||
{screen}
|
||||
</div>
|
||||
</div>
|
||||
<BottomTab t={t} tab={(tab === 'redeem' || tab === 'plans' || tab === 'contact') ? 'me' : tab} setTab={goTab} />
|
||||
</>);
|
||||
}
|
||||
|
||||
return (
|
||||
<IOSDevice dark={dark}>
|
||||
<div data-theme={dark ? 'dark' : 'light'} style={{ position: 'absolute', inset: 0, background: 'var(--bg)', display: 'flex', flexDirection: 'column', paddingTop: 56 }}>
|
||||
{body}
|
||||
</div>
|
||||
</IOSDevice>
|
||||
);
|
||||
}
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(<App />);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,348 @@
|
||||
|
||||
/* BEGIN USAGE */
|
||||
// iOS.jsx — Simplified iOS 26 (Liquid Glass) device frame
|
||||
// Based on the iOS 26 UI Kit + Figma status bar spec. No assets, no deps.
|
||||
// Exports (to window): IOSDevice, IOSStatusBar, IOSNavBar, IOSGlassPill, IOSList, IOSListRow, IOSKeyboard
|
||||
//
|
||||
// Usage — wrap your screen content in <IOSDevice> to get the bezel, status bar
|
||||
// and home indicator (props: title, dark, keyboard):
|
||||
//
|
||||
// <IOSDevice title="Settings">
|
||||
// ...your screen content...
|
||||
// </IOSDevice>
|
||||
// <IOSDevice dark title="Search" keyboard>…</IOSDevice>
|
||||
/* END USAGE */
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Status bar
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
function IOSStatusBar({ dark = false, time = '9:41' }) {
|
||||
const c = dark ? '#fff' : '#000';
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex', gap: 154, alignItems: 'center', justifyContent: 'center',
|
||||
padding: '21px 24px 19px', boxSizing: 'border-box',
|
||||
position: 'relative', zIndex: 20, width: '100%',
|
||||
}}>
|
||||
<div style={{ flex: 1, height: 22, display: 'flex', alignItems: 'center', justifyContent: 'center', paddingTop: 1.5 }}>
|
||||
<span style={{
|
||||
fontFamily: '-apple-system, "SF Pro", system-ui', fontWeight: 590,
|
||||
fontSize: 17, lineHeight: '22px', color: c,
|
||||
}}>{time}</span>
|
||||
</div>
|
||||
<div style={{ flex: 1, height: 22, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 7, paddingTop: 1, paddingRight: 1 }}>
|
||||
<svg width="19" height="12" viewBox="0 0 19 12">
|
||||
<rect x="0" y="7.5" width="3.2" height="4.5" rx="0.7" fill={c}/>
|
||||
<rect x="4.8" y="5" width="3.2" height="7" rx="0.7" fill={c}/>
|
||||
<rect x="9.6" y="2.5" width="3.2" height="9.5" rx="0.7" fill={c}/>
|
||||
<rect x="14.4" y="0" width="3.2" height="12" rx="0.7" fill={c}/>
|
||||
</svg>
|
||||
<svg width="17" height="12" viewBox="0 0 17 12">
|
||||
<path d="M8.5 3.2C10.8 3.2 12.9 4.1 14.4 5.6L15.5 4.5C13.7 2.7 11.2 1.5 8.5 1.5C5.8 1.5 3.3 2.7 1.5 4.5L2.6 5.6C4.1 4.1 6.2 3.2 8.5 3.2Z" fill={c}/>
|
||||
<path d="M8.5 6.8C9.9 6.8 11.1 7.3 12 8.2L13.1 7.1C11.8 5.9 10.2 5.1 8.5 5.1C6.8 5.1 5.2 5.9 3.9 7.1L5 8.2C5.9 7.3 7.1 6.8 8.5 6.8Z" fill={c}/>
|
||||
<circle cx="8.5" cy="10.5" r="1.5" fill={c}/>
|
||||
</svg>
|
||||
<svg width="27" height="13" viewBox="0 0 27 13">
|
||||
<rect x="0.5" y="0.5" width="23" height="12" rx="3.5" stroke={c} strokeOpacity="0.35" fill="none"/>
|
||||
<rect x="2" y="2" width="20" height="9" rx="2" fill={c}/>
|
||||
<path d="M25 4.5V8.5C25.8 8.2 26.5 7.2 26.5 6.5C26.5 5.8 25.8 4.8 25 4.5Z" fill={c} fillOpacity="0.4"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Liquid glass pill — blur + tint + shine
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
function IOSGlassPill({ children, dark = false, style = {} }) {
|
||||
return (
|
||||
<div style={{
|
||||
height: 44, minWidth: 44, borderRadius: 9999,
|
||||
position: 'relative', overflow: 'hidden',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
boxShadow: dark
|
||||
? '0 2px 6px rgba(0,0,0,0.35), 0 6px 16px rgba(0,0,0,0.2)'
|
||||
: '0 1px 3px rgba(0,0,0,0.07), 0 3px 10px rgba(0,0,0,0.06)',
|
||||
...style,
|
||||
}}>
|
||||
{/* blur + tint */}
|
||||
<div style={{
|
||||
position: 'absolute', inset: 0, borderRadius: 9999,
|
||||
backdropFilter: 'blur(12px) saturate(180%)',
|
||||
WebkitBackdropFilter: 'blur(12px) saturate(180%)',
|
||||
background: dark ? 'rgba(120,120,128,0.28)' : 'rgba(255,255,255,0.5)',
|
||||
}} />
|
||||
{/* shine */}
|
||||
<div style={{
|
||||
position: 'absolute', inset: 0, borderRadius: 9999,
|
||||
boxShadow: dark
|
||||
? 'inset 1.5px 1.5px 1px rgba(255,255,255,0.15), inset -1px -1px 1px rgba(255,255,255,0.08)'
|
||||
: 'inset 1.5px 1.5px 1px rgba(255,255,255,0.7), inset -1px -1px 1px rgba(255,255,255,0.4)',
|
||||
border: dark ? '0.5px solid rgba(255,255,255,0.15)' : '0.5px solid rgba(0,0,0,0.06)',
|
||||
}} />
|
||||
<div style={{ position: 'relative', zIndex: 1, display: 'flex', alignItems: 'center', padding: '0 4px' }}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Navigation bar — glass pills + large title
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
function IOSNavBar({ title = 'Title', dark = false, trailingIcon = true }) {
|
||||
const muted = dark ? 'rgba(255,255,255,0.6)' : '#404040';
|
||||
const text = dark ? '#fff' : '#000';
|
||||
const pillIcon = (content) => (
|
||||
<IOSGlassPill dark={dark}>
|
||||
<div style={{ width: 36, height: 36, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
{content}
|
||||
</div>
|
||||
</IOSGlassPill>
|
||||
);
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex', flexDirection: 'column', gap: 10,
|
||||
paddingTop: 62, paddingBottom: 10, position: 'relative', zIndex: 5,
|
||||
}}>
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
padding: '0 16px',
|
||||
}}>
|
||||
{/* back chevron */}
|
||||
{pillIcon(
|
||||
<svg width="12" height="20" viewBox="0 0 12 20" fill="none" style={{ marginLeft: -1 }}>
|
||||
<path d="M10 2L2 10l8 8" stroke={muted} strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
)}
|
||||
{/* trailing ellipsis */}
|
||||
{trailingIcon && pillIcon(
|
||||
<svg width="22" height="6" viewBox="0 0 22 6">
|
||||
<circle cx="3" cy="3" r="2.5" fill={muted}/>
|
||||
<circle cx="11" cy="3" r="2.5" fill={muted}/>
|
||||
<circle cx="19" cy="3" r="2.5" fill={muted}/>
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
{/* large title */}
|
||||
<div style={{
|
||||
padding: '0 16px',
|
||||
fontFamily: '-apple-system, system-ui',
|
||||
fontSize: 34, fontWeight: 700, lineHeight: '41px',
|
||||
color: text, letterSpacing: 0.4,
|
||||
}}>{title}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Grouped list (inset card, r:26) + row (52px)
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
function IOSListRow({ title, detail, icon, chevron = true, isLast = false, dark = false }) {
|
||||
const text = dark ? '#fff' : '#000';
|
||||
const sec = dark ? 'rgba(235,235,245,0.6)' : 'rgba(60,60,67,0.6)';
|
||||
const ter = dark ? 'rgba(235,235,245,0.3)' : 'rgba(60,60,67,0.3)';
|
||||
const sep = dark ? 'rgba(84,84,88,0.65)' : 'rgba(60,60,67,0.12)';
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', minHeight: 52,
|
||||
padding: '0 16px', position: 'relative',
|
||||
fontFamily: '-apple-system, system-ui', fontSize: 17,
|
||||
letterSpacing: -0.43,
|
||||
}}>
|
||||
{icon && (
|
||||
<div style={{
|
||||
width: 30, height: 30, borderRadius: 7, background: icon,
|
||||
marginRight: 12, flexShrink: 0,
|
||||
}} />
|
||||
)}
|
||||
<div style={{ flex: 1, color: text }}>{title}</div>
|
||||
{detail && <span style={{ color: sec, marginRight: 6 }}>{detail}</span>}
|
||||
{chevron && (
|
||||
<svg width="8" height="14" viewBox="0 0 8 14" style={{ flexShrink: 0 }}>
|
||||
<path d="M1 1l6 6-6 6" stroke={ter} strokeWidth="2" fill="none" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
)}
|
||||
{!isLast && (
|
||||
<div style={{
|
||||
position: 'absolute', bottom: 0, right: 0,
|
||||
left: icon ? 58 : 16, height: 0.5, background: sep,
|
||||
}} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function IOSList({ header, children, dark = false }) {
|
||||
const hc = dark ? 'rgba(235,235,245,0.6)' : 'rgba(60,60,67,0.6)';
|
||||
const bg = dark ? '#1C1C1E' : '#fff';
|
||||
return (
|
||||
<div>
|
||||
{header && (
|
||||
<div style={{
|
||||
fontFamily: '-apple-system, system-ui', fontSize: 13,
|
||||
color: hc, textTransform: 'uppercase',
|
||||
padding: '8px 36px 6px', letterSpacing: -0.08,
|
||||
}}>{header}</div>
|
||||
)}
|
||||
<div style={{
|
||||
background: bg, borderRadius: 26,
|
||||
margin: '0 16px', overflow: 'hidden',
|
||||
}}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Device frame
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
function IOSDevice({
|
||||
children, width = 402, height = 874, dark = false,
|
||||
title, keyboard = false,
|
||||
}) {
|
||||
return (
|
||||
<div style={{
|
||||
width, height, borderRadius: 48, overflow: 'hidden',
|
||||
position: 'relative', background: dark ? '#000' : '#F2F2F7',
|
||||
boxShadow: '0 40px 80px rgba(0,0,0,0.18), 0 0 0 1px rgba(0,0,0,0.12)',
|
||||
fontFamily: '-apple-system, system-ui, sans-serif',
|
||||
WebkitFontSmoothing: 'antialiased',
|
||||
}}>
|
||||
{/* dynamic island */}
|
||||
<div style={{
|
||||
position: 'absolute', top: 11, left: '50%', transform: 'translateX(-50%)',
|
||||
width: 126, height: 37, borderRadius: 24, background: '#000', zIndex: 50,
|
||||
}} />
|
||||
{/* status bar (absolute) */}
|
||||
<div style={{ position: 'absolute', top: 0, left: 0, right: 0, zIndex: 10 }}>
|
||||
<IOSStatusBar dark={dark} />
|
||||
</div>
|
||||
{/* nav + content */}
|
||||
<div style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
|
||||
{title !== undefined && <IOSNavBar title={title} dark={dark} />}
|
||||
<div style={{ flex: 1, overflow: 'auto' }}>{children}</div>
|
||||
{keyboard && <IOSKeyboard dark={dark} />}
|
||||
</div>
|
||||
{/* home indicator — always on top */}
|
||||
<div style={{
|
||||
position: 'absolute', bottom: 0, left: 0, right: 0, zIndex: 60,
|
||||
height: 34, display: 'flex', justifyContent: 'center', alignItems: 'flex-end',
|
||||
paddingBottom: 8, pointerEvents: 'none',
|
||||
}}>
|
||||
<div style={{
|
||||
width: 139, height: 5, borderRadius: 100,
|
||||
background: dark ? 'rgba(255,255,255,0.7)' : 'rgba(0,0,0,0.25)',
|
||||
}} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Keyboard — iOS 26 liquid glass
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
function IOSKeyboard({ dark = false }) {
|
||||
const glyph = dark ? 'rgba(255,255,255,0.7)' : '#595959';
|
||||
const sugg = dark ? 'rgba(255,255,255,0.6)' : '#333';
|
||||
const keyBg = dark ? 'rgba(255,255,255,0.22)' : 'rgba(255,255,255,0.85)';
|
||||
|
||||
// special-key icons
|
||||
const icons = {
|
||||
shift: <svg width="19" height="17" viewBox="0 0 19 17"><path d="M9.5 1L1 9.5h4.5V16h8V9.5H18L9.5 1z" fill={glyph}/></svg>,
|
||||
del: <svg width="23" height="17" viewBox="0 0 23 17"><path d="M7 1h13a2 2 0 012 2v11a2 2 0 01-2 2H7l-6-7.5L7 1z" fill="none" stroke={glyph} strokeWidth="1.6" strokeLinejoin="round"/><path d="M10 5l7 7M17 5l-7 7" stroke={glyph} strokeWidth="1.6" strokeLinecap="round"/></svg>,
|
||||
ret: <svg width="20" height="14" viewBox="0 0 20 14"><path d="M18 1v6H4m0 0l4-4M4 7l4 4" fill="none" stroke="#fff" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"/></svg>,
|
||||
};
|
||||
|
||||
const key = (content, { w, flex, ret, fs = 25, k } = {}) => (
|
||||
<div key={k} style={{
|
||||
height: 42, borderRadius: 8.5,
|
||||
flex: flex ? 1 : undefined, width: w, minWidth: 0,
|
||||
background: ret ? '#08f' : keyBg,
|
||||
boxShadow: '0 1px 0 rgba(0,0,0,0.075)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontFamily: '-apple-system, "SF Compact", system-ui',
|
||||
fontSize: fs, fontWeight: 458, color: ret ? '#fff' : glyph,
|
||||
}}>{content}</div>
|
||||
);
|
||||
|
||||
const row = (keys, pad = 0) => (
|
||||
<div style={{ display: 'flex', gap: 6.5, justifyContent: 'center', padding: `0 ${pad}px` }}>
|
||||
{keys.map(l => key(l, { flex: true, k: l }))}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
position: 'relative', zIndex: 15, borderRadius: 27, overflow: 'hidden',
|
||||
padding: '11px 0 2px',
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center',
|
||||
boxShadow: dark
|
||||
? '0 -2px 20px rgba(0,0,0,0.09)'
|
||||
: '0 -1px 6px rgba(0,0,0,0.018), 0 -3px 20px rgba(0,0,0,0.012)',
|
||||
}}>
|
||||
{/* liquid glass bg — same recipe as nav pills */}
|
||||
<div style={{
|
||||
position: 'absolute', inset: 0, borderRadius: 27,
|
||||
backdropFilter: 'blur(12px) saturate(180%)',
|
||||
WebkitBackdropFilter: 'blur(12px) saturate(180%)',
|
||||
background: dark ? 'rgba(120,120,128,0.14)' : 'rgba(255,255,255,0.25)',
|
||||
}} />
|
||||
<div style={{
|
||||
position: 'absolute', inset: 0, borderRadius: 27,
|
||||
boxShadow: dark
|
||||
? 'inset 1.5px 1.5px 1px rgba(255,255,255,0.15)'
|
||||
: 'inset 1.5px 1.5px 1px rgba(255,255,255,0.7), inset -1px -1px 1px rgba(255,255,255,0.4)',
|
||||
border: dark ? '0.5px solid rgba(255,255,255,0.15)' : '0.5px solid rgba(0,0,0,0.06)',
|
||||
pointerEvents: 'none',
|
||||
}} />
|
||||
|
||||
{/* autocorrect bar */}
|
||||
<div style={{
|
||||
display: 'flex', gap: 20, alignItems: 'center',
|
||||
padding: '8px 22px 13px', width: '100%', boxSizing: 'border-box',
|
||||
position: 'relative',
|
||||
}}>
|
||||
{['"The"', 'the', 'to'].map((w, i) => (
|
||||
<React.Fragment key={i}>
|
||||
{i > 0 && <div style={{ width: 1, height: 25, background: '#ccc', opacity: 0.3 }} />}
|
||||
<div style={{
|
||||
flex: 1, textAlign: 'center',
|
||||
fontFamily: '-apple-system, system-ui', fontSize: 17,
|
||||
color: sugg, letterSpacing: -0.43, lineHeight: '22px',
|
||||
}}>{w}</div>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* key layout */}
|
||||
<div style={{
|
||||
display: 'flex', flexDirection: 'column', gap: 13,
|
||||
padding: '0 6.5px', width: '100%', boxSizing: 'border-box',
|
||||
position: 'relative',
|
||||
}}>
|
||||
{row(['q','w','e','r','t','y','u','i','o','p'])}
|
||||
{row(['a','s','d','f','g','h','j','k','l'], 20)}
|
||||
<div style={{ display: 'flex', gap: 14.25, alignItems: 'center' }}>
|
||||
{key(icons.shift, { w: 45, k: 'shift' })}
|
||||
<div style={{ display: 'flex', gap: 6.5, flex: 1 }}>
|
||||
{['z','x','c','v','b','n','m'].map(l => key(l, { flex: true, k: l }))}
|
||||
</div>
|
||||
{key(icons.del, { w: 45, k: 'del' })}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
|
||||
{key('ABC', { w: 92.25, fs: 18, k: 'abc' })}
|
||||
{key('', { flex: true, k: 'space' })}
|
||||
{key(icons.ret, { w: 92.25, ret: true, k: 'ret' })}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* bottom spacer (emoji+mic area, icons omitted) */}
|
||||
<div style={{ height: 56, width: '100%', position: 'relative' }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Object.assign(window, {
|
||||
IOSDevice, IOSStatusBar, IOSNavBar, IOSGlassPill, IOSList, IOSListRow, IOSKeyboard,
|
||||
});
|
||||
@@ -0,0 +1,293 @@
|
||||
/* 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'] },
|
||||
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 traffic if the VPN 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: [] },
|
||||
] });
|
||||
@@ -0,0 +1,349 @@
|
||||
/* screens.jsx — 穿山甲 VPN mobile · screens + bottom tab (i18n) */
|
||||
|
||||
const { useState, useEffect, useRef } = React;
|
||||
|
||||
/* ───────── Top brand bar ───────── */
|
||||
function TopBar({ t, lang, right }) {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '6px 20px 14px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 9 }}>
|
||||
<Mark size={28} />
|
||||
<span style={{ fontFamily: 'var(--font-display)', fontWeight: 700, fontSize: 18, color: 'var(--fg1)', letterSpacing: '-0.01em' }}>{t('brand')}</span>
|
||||
</div>
|
||||
{right}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ───────── CONNECT screen ───────── */
|
||||
function ConnectScreen({ t, lang, status, onToggle, server, smart, free, mins, adDone, onAd, goServers, elapsed }) {
|
||||
const map = {
|
||||
off: { fill: 'var(--sand-100)', fg: 'var(--clay-500)', icon: 'power', cap: t('capOff'), ring: 'track' },
|
||||
connecting: { fill: 'var(--accent)', fg: '#fff', icon: 'loader-circle', cap: t('capConnecting'), ring: 'spin' },
|
||||
on: { fill: 'var(--success)', fg: '#fff', icon: 'shield-check', cap: t('capOn'), ring: 'full' },
|
||||
}[status];
|
||||
const fmt = (s) => `${String(Math.floor(s/3600)).padStart(2,'0')}:${String(Math.floor(s%3600/60)).padStart(2,'0')}:${String(s%60).padStart(2,'0')}`;
|
||||
const shadow = status==='off'
|
||||
? 'inset 0 0 0 1px var(--sand-200), var(--shadow-md)'
|
||||
: `0 0 0 9px ${status==='on' ? 'var(--success-subtle)' : 'var(--accent-subtle)'}, var(--shadow-lg)`;
|
||||
// in-circle center block
|
||||
const center = status==='off'
|
||||
? (<><span style={{ fontFamily:'var(--font-sans)', fontWeight:700, fontSize:14 }}>{t('connectNow')}</span></>)
|
||||
: status==='connecting'
|
||||
? (<span style={{ fontFamily:'var(--font-mono)', fontWeight:700, fontSize:14, letterSpacing:'0.08em' }}>···</span>)
|
||||
: (<><span style={{ fontFamily:'var(--font-mono)', fontWeight:600, fontSize:18 }}>{fmt(elapsed)}</span><span style={{ fontFamily:'var(--font-sans)', fontWeight:700, fontSize:10, letterSpacing:'0.1em', opacity:.9 }}>{t('secure')}</span></>);
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
<TopBar t={t} lang={lang} right={<span style={{ fontFamily: 'var(--font-mono)', fontSize: 12, whiteSpace: 'nowrap', color: status==='on'?'var(--success)':'var(--fg3)', fontWeight: 600 }}>{status==='on'?t('online'):t('offline')}</span>} />
|
||||
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 26, padding: '0 24px' }}>
|
||||
<button onClick={onToggle} style={{
|
||||
position: 'relative', width: 208, height: 208, borderRadius: '50%', border: 'none', cursor: 'pointer',
|
||||
background: map.fill, color: map.fg, boxShadow: shadow,
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 6,
|
||||
transition: 'box-shadow 300ms var(--ease-out), background-color 300ms var(--ease-out)',
|
||||
}}>
|
||||
{/* track ring */}
|
||||
<svg viewBox="0 0 208 208" style={{ position:'absolute', inset:0, width:'100%', height:'100%', transformOrigin:'center', animation: map.ring==='spin' ? 'pg-spin 1.4s linear infinite' : 'none' }}>
|
||||
{map.ring==='track' && <circle cx="104" cy="104" r="96" fill="none" stroke="var(--sand-200)" strokeWidth="3" strokeDasharray="2 8" strokeLinecap="round"/>}
|
||||
{map.ring==='spin' && <><circle cx="104" cy="104" r="96" fill="none" stroke="rgba(255,255,255,.3)" strokeWidth="4"/><circle cx="104" cy="104" r="96" fill="none" stroke="#fff" strokeWidth="4" strokeLinecap="round" strokeDasharray="150 453"/></>}
|
||||
{map.ring==='full' && <circle cx="104" cy="104" r="96" fill="none" stroke="rgba(255,255,255,.85)" strokeWidth="4" strokeLinecap="round"/>}
|
||||
</svg>
|
||||
<Icon name={map.icon} size={50} stroke={1.7} style={status==='connecting' ? { animation: 'pg-spin 1s linear infinite' } : {}} />
|
||||
{center}
|
||||
</button>
|
||||
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{ fontFamily: 'var(--font-sans)', fontSize: 15, fontWeight: 600, color: 'var(--fg2)', whiteSpace: 'nowrap' }}>{map.cap}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ padding: '0 20px 18px', display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
{free && (
|
||||
<div style={{ background: 'var(--surface)', borderRadius: 'var(--radius-lg)', padding: '12px 14px', boxShadow: 'var(--shadow-sm)', border: '1px solid var(--border)' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 7, marginBottom: 8 }}>
|
||||
<Icon name="clock" size={15} stroke={2.2} color="var(--accent)" />
|
||||
<span style={{ fontSize: 12, 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: 10 }}>
|
||||
<div style={{ width: `${Math.min(100, mins / 10 * 100)}%`, height: '100%', background: mins <= 3 ? 'var(--warning)' : 'var(--accent)', borderRadius: 3, transition: 'width 300ms var(--ease-out)' }}></div>
|
||||
</div>
|
||||
{adDone ? (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 7, color: 'var(--success)', fontWeight: 700, fontSize: 13, padding: '9px' }}>
|
||||
<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, padding: '9px', borderRadius: 'var(--radius-full)', cursor: 'pointer' }}>
|
||||
<Icon name="play-circle" size={16} stroke={2.2} />{t('watchAd')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{status === 'on' && (
|
||||
<div style={{ display: 'flex', gap: 12 }}>
|
||||
{[['arrow-down',t('download'),'86.4','Mb/s'],['arrow-up',t('upload'),'12.1','Mb/s'],['zap',t('latency'), String(server.ping),'ms']].map(([ic,l,v,u]) => (
|
||||
<div key={l} style={{ flex: 1, background: 'var(--surface)', borderRadius: 'var(--radius-lg)', padding: '12px 14px', boxShadow: 'var(--shadow-sm)', border: '1px solid var(--border)' }}>
|
||||
<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: 17, fontWeight: 500, color: 'var(--fg1)' }}>{v}<span style={{ fontSize: 11, color: 'var(--fg3)' }}> {u}</span></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<button onClick={goServers} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 12, width: '100%', textAlign: 'left',
|
||||
background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 'var(--radius-lg)',
|
||||
padding: '12px 14px', cursor: 'pointer', boxShadow: 'var(--shadow-sm)',
|
||||
}}>
|
||||
<CC code={server.code} active />
|
||||
<div style={{ flex: 1 }}>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ───────── SERVERS screen ───────── */
|
||||
function ServersScreen({ t, lang, current, onPick, q, setQ }) {
|
||||
const list = SERVERS.filter(s => (s.name + s.en).toLowerCase().includes(q.toLowerCase()));
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
<TopBar t={t} lang={lang} />
|
||||
<div style={{ padding: '0 20px 12px' }}>
|
||||
<div style={{ fontFamily: 'var(--font-display)', fontSize: 24, fontWeight: 700, color: 'var(--fg1)', marginBottom: 12 }}>{t('chooseNode')}</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, background: 'var(--bg-subtle)', borderRadius: 'var(--radius-md)', padding: '10px 14px' }}>
|
||||
<Icon name="search" size={18} color="var(--fg3)" />
|
||||
<input value={q} onChange={e => setQ(e.target.value)} placeholder={t('search')}
|
||||
style={{ border: 'none', background: 'transparent', outline: 'none', flex: 1, fontFamily: 'var(--font-sans)', fontSize: 15, color: 'var(--fg1)' }} />
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ flex: 1, overflow: 'auto', padding: '0 20px 20px' }}>
|
||||
{/* smart select — recommended card */}
|
||||
<button onClick={() => onPick('AUTO')} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 13, width: '100%', textAlign: 'left',
|
||||
padding: '15px 14px', border: `1.5px solid ${current === 'AUTO' ? 'var(--accent)' : 'var(--accent-border)'}`, cursor: 'pointer',
|
||||
background: 'var(--accent-subtle)',
|
||||
borderRadius: 'var(--radius-xl)', boxShadow: current === 'AUTO' ? 'var(--shadow-md)' : 'var(--shadow-sm)', marginBottom: 12,
|
||||
}}>
|
||||
<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, lineHeight: 1.5 }}>{t('smartSub')}</div>
|
||||
</div>
|
||||
{current === 'AUTO' && <Icon name="check" size={19} color="var(--accent)" stroke={2.6} />}
|
||||
</button>
|
||||
<div style={{ background: 'var(--surface)', borderRadius: 'var(--radius-lg)', boxShadow: 'var(--shadow-sm)', overflow: 'hidden', border: '1px solid var(--border)' }}>
|
||||
{list.map((s, i) => {
|
||||
const active = s.code === current;
|
||||
return (
|
||||
<button key={s.code} onClick={() => onPick(s.code)} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 13, width: '100%', textAlign: 'left',
|
||||
padding: '12px 14px', border: 'none', cursor: 'pointer',
|
||||
borderBottom: i < list.length-1 ? '1px solid var(--border)' : 'none',
|
||||
background: active ? 'var(--accent-subtle)' : 'transparent',
|
||||
}}>
|
||||
<CC code={s.code} active={active} />
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontFamily: 'var(--font-sans)', fontSize: 15, fontWeight: 600, color: 'var(--fg1)' }}>{srvName(s, lang)}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--fg3)' }}>{srvSub(s, lang)}</div>
|
||||
</div>
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--fg2)' }}>{s.ping}ms</span>
|
||||
<Signal ping={s.ping} />
|
||||
{active && <Icon name="check" size={18} color="var(--accent)" />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ───────── SETTINGS screen ───────── */
|
||||
function Toggle({ on, onChange }) {
|
||||
return (
|
||||
<div onClick={onChange} style={{ width: 46, height: 28, 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 ? 21 : 3, width: 22, height: 22, borderRadius: '50%', background: '#fff', boxShadow: 'var(--shadow-sm)', transition: 'left 220ms var(--ease-out)' }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
/* segmented language switch 中 / EN */
|
||||
function LangSwitch({ lang, setLang }) {
|
||||
return (
|
||||
<div style={{ display: 'flex', background: 'var(--bg-subtle)', borderRadius: 'var(--radius-full)', padding: 3, gap: 2 }}>
|
||||
{[['zh','中文'],['en','EN']].map(([v,l]) => (
|
||||
<button key={v} onClick={() => setLang(v)} style={{
|
||||
border: 'none', cursor: 'pointer', borderRadius: 'var(--radius-full)', padding: '5px 13px',
|
||||
fontFamily: 'var(--font-sans)', fontSize: 12.5, fontWeight: 700,
|
||||
background: lang===v ? 'var(--accent)' : 'transparent', color: lang===v ? 'var(--fg-on-accent)' : 'var(--fg3)',
|
||||
transition: 'all 140ms var(--ease-out)',
|
||||
}}>{l}</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
function Row({ icon, title, sub, right, last, onClick }) {
|
||||
return (
|
||||
<div onClick={onClick} style={{ display: 'flex', alignItems: 'center', gap: 13, padding: '13px 16px', borderBottom: last ? 'none' : '1px solid var(--border)', cursor: onClick ? 'pointer' : 'default' }}>
|
||||
{icon && <div style={{ width: 32, height: 32, 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={{ fontFamily: 'var(--font-sans)', fontSize: 15, fontWeight: 500, color: 'var(--fg1)' }}>{title}</div>
|
||||
{sub && <div style={{ fontSize: 12, color: 'var(--fg3)' }}>{sub}</div>}
|
||||
</div>
|
||||
{right}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
function SettingsScreen({ t, lang, setLang, dark, setDark, free, setFree, onRedeem, onUpgrade, onContact, onSignOut, onReplayAuth, onReplayOnboarding }) {
|
||||
const [autostart, setAuto] = useState(true);
|
||||
const [kill, setKill] = useState(true);
|
||||
const [twoFA, setTwoFA] = useState(true);
|
||||
const [devices, setDevices] = useState([
|
||||
{ id: 1, icon: 'laptop', name: 'MacBook Pro', os: 'macOS 26', active: t('thisDevice'), me: true },
|
||||
{ id: 2, icon: 'smartphone', name: 'iPhone 17', os: 'iOS 26', active: '2 min', me: false },
|
||||
{ id: 3, icon: 'monitor-smartphone', name: 'iPad Air', os: 'iPadOS 26', active: lang === 'zh' ? '3 小时前' : '3h ago', me: false },
|
||||
]);
|
||||
const card = { background: 'var(--surface)', borderRadius: 'var(--radius-lg)', boxShadow: 'var(--shadow-sm)', border: '1px solid var(--border)', overflow: 'hidden', marginBottom: 18 };
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
<TopBar t={t} lang={lang} />
|
||||
<div style={{ flex: 1, overflow: 'auto', padding: '0 20px 20px' }}>
|
||||
<div style={{ fontFamily: 'var(--font-display)', fontSize: 24, fontWeight: 700, color: 'var(--fg1)', marginBottom: 14 }}>{t('meTitle')}</div>
|
||||
|
||||
{/* plan banner */}
|
||||
<div style={{ background: 'linear-gradient(155deg,var(--clay-600),var(--clay-800))', borderRadius: 'var(--radius-xl)', padding: 18, color: '#fff', marginBottom: 18, boxShadow: 'var(--shadow-md)' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 11 }}>
|
||||
<div style={{ width: 44, height: 44, borderRadius: '50%', background: 'rgba(255,255,255,0.18)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}><Icon name="crown" size={22} color="#fff" /></div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontWeight: 700, fontSize: 16 }}>me@pangolin.vpn</div>
|
||||
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 4, fontSize: 11, fontWeight: 600, background: 'rgba(255,255,255,0.2)', padding: '2px 8px', borderRadius: 999, marginTop: 4 }}>{free ? t('freePlanName') : t('proMember')}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-end', marginTop: 16, paddingTop: 14, borderTop: '1px solid rgba(255,255,255,0.18)', fontSize: 12 }}>
|
||||
<div>
|
||||
<div style={{ opacity: 0.7 }}>{free ? t('quotaToday') : t('expires')}</div>
|
||||
<div style={{ fontWeight: 600, marginTop: 2, fontFamily: 'var(--font-mono)', whiteSpace: 'nowrap' }}>{free ? t('quotaFree') : '2026-12-31 · 42.6 GB'}</div>
|
||||
</div>
|
||||
<button onClick={onUpgrade} style={{ border: 'none', background: '#fff', color: 'var(--clay-700)', fontWeight: 700, fontSize: 13, padding: '9px 16px', borderRadius: 'var(--radius-full)', cursor: 'pointer', display: 'inline-flex', alignItems: 'center', gap: 6 }}><Icon name="ticket" size={15} color="var(--clay-700)" />{t('upgradeBtn')}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* account info */}
|
||||
<div style={{ fontSize: 13, fontWeight: 700, color: 'var(--fg2)', margin: '4px 4px 10px' }}>{t('accInfoTitle')}</div>
|
||||
<div style={card}>
|
||||
<Row icon="mail" title={t('accEmail')} sub="me@pangolin.vpn" right={<span style={{ fontSize: 13, color: 'var(--accent)', fontWeight: 600 }}>{t('accChange')}</span>} />
|
||||
<Row icon="lock" title={t('accPassword')} sub="••••••••••" right={<span style={{ fontSize: 13, color: 'var(--accent)', fontWeight: 600 }}>{t('accChange')}</span>} />
|
||||
<Row icon="shield" title={t('twoFA')} sub={t('twoFASub')} right={<Toggle on={twoFA} onChange={() => setTwoFA(!twoFA)} />} last />
|
||||
</div>
|
||||
|
||||
{/* devices */}
|
||||
<div style={{ fontSize: 13, fontWeight: 700, color: 'var(--fg2)', margin: '4px 4px 4px' }}>{t('myDevices')}</div>
|
||||
<div style={{ fontSize: 11.5, color: 'var(--fg3)', margin: '0 4px 10px' }}>{t('devicesSub')}</div>
|
||||
<div style={card}>
|
||||
{devices.map((d, i) => (
|
||||
<div key={d.id} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '12px 16px', borderBottom: i < devices.length - 1 ? '1px solid var(--border)' : 'none' }}>
|
||||
<div style={{ width: 34, height: 34, borderRadius: 'var(--radius-md)', background: 'var(--accent-subtle)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}><Icon name={d.icon} size={18} color="var(--accent)" /></div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--fg1)', display: 'flex', alignItems: 'center', gap: 7 }}>{d.name}{d.me && <span style={{ fontSize: 10.5, fontWeight: 600, color: 'var(--success)', background: 'var(--success-subtle)', padding: '1px 7px', borderRadius: 999 }}>{d.active}</span>}</div>
|
||||
<div style={{ fontSize: 11.5, color: 'var(--fg3)', marginTop: 1 }}>{d.os}{!d.me && ` · ${d.active}`}</div>
|
||||
</div>
|
||||
{!d.me && <button onClick={() => setDevices(devices.filter(x => x.id !== d.id))} style={{ border: '1.5px solid var(--border-strong)', background: 'transparent', color: 'var(--fg2)', fontSize: 12.5, fontWeight: 600, padding: '6px 12px', borderRadius: 'var(--radius-full)', cursor: 'pointer' }}>{t('remove')}</button>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* redeem + contact */}
|
||||
<div style={card}>
|
||||
<Row icon="shopping-bag" title={t('redeemEntry')} right={<Icon name="chevron-right" size={18} color="var(--fg3)" />} onClick={onRedeem} />
|
||||
<Row icon="external-link" title={t('ucEntry')} sub={t('ucEntrySub')} right={<Icon name="chevron-right" size={18} color="var(--fg3)" />} onClick={() => window.open('../usercenter/index.html', '_blank')} />
|
||||
<Row icon="message-circle" title={t('contactEntry')} right={<Icon name="chevron-right" size={18} color="var(--fg3)" />} last onClick={onContact} />
|
||||
</div>
|
||||
|
||||
{/* app settings */}
|
||||
<div style={card}>
|
||||
<Row icon="zap" title={t('smartRoute')} sub={t('smartRouteSub')} right={<Toggle on={autostart} onChange={() => setAuto(!autostart)} />} />
|
||||
<Row icon="shield" title={t('killSwitch')} sub={t('killSwitchSub')} right={<Toggle on={kill} onChange={() => setKill(!kill)} />} last />
|
||||
</div>
|
||||
<div style={card}>
|
||||
<Row icon="globe" title={t('language')} right={<LangSwitch lang={lang} setLang={setLang} />} />
|
||||
<Row icon={dark ? 'moon' : 'sun'} title={t('darkAppearance')} sub={dark ? t('stateOn') : t('followLight')} right={<Toggle on={dark} onChange={() => setDark(!dark)} />} />
|
||||
<Row icon="shield" title={t('protocol')} right={<span style={{ fontFamily: 'var(--font-mono)', fontSize: 13, color: 'var(--fg3)' }}>WireGuard</span>} />
|
||||
<Row icon="refresh-cw" title={t('checkUpdate')} right={<Icon name="chevron-right" size={18} color="var(--fg3)" />} last />
|
||||
</div>
|
||||
<div style={card}>
|
||||
<Row icon="log-out" title={t('replayAuth')} sub={t('replayAuthSub')} right={<Icon name="chevron-right" size={18} color="var(--fg3)" />} onClick={onReplayAuth} />
|
||||
<Row icon="compass" title={t('replayOnboarding')} sub={t('replayOnboardingSub')} right={<Icon name="chevron-right" size={18} color="var(--fg3)" />} onClick={onReplayOnboarding} />
|
||||
<Row icon="clock" title={t('freeDemo')} sub={t('freeDemoSub')} right={<Toggle on={free} onChange={() => setFree(!free)} />} last />
|
||||
</div>
|
||||
<div style={{ textAlign: 'center', fontSize: 12, color: 'var(--fg3)', fontFamily: 'var(--font-mono)' }}>穿山甲 · v2.4.0</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ───────── STATS screen ───────── */
|
||||
function StatsScreen({ t, lang }) {
|
||||
const vals = [2.1, 3.4, 1.8, 4.6, 5.2, 6.1, 3.0]; const max = 6.1; const labels = t('days7');
|
||||
const card = { background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 'var(--radius-lg)', boxShadow: 'var(--shadow-sm)' };
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
<TopBar t={t} lang={lang} />
|
||||
<div style={{ flex: 1, overflow: 'auto', padding: '0 20px 20px' }}>
|
||||
<div style={{ fontFamily: 'var(--font-display)', fontSize: 24, fontWeight: 700, color: 'var(--fg1)', marginBottom: 16 }}>{t('statsTitle')}</div>
|
||||
<div style={{ display: 'flex', gap: 12, marginBottom: 16 }}>
|
||||
{[[t('trafficMonth'), '42.6', 'GB'], [t('avgPing'), '29', 'ms'], [t('durMonth'), '86.4', 'h']].map(([l, v, u]) => (
|
||||
<div key={l} style={{ ...card, flex: 1, padding: '14px 14px' }}>
|
||||
<div style={{ fontSize: 11, color: 'var(--fg3)', fontWeight: 600 }}>{l}</div>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 20, fontWeight: 500, color: 'var(--fg1)', marginTop: 6 }}>{v}<span style={{ fontSize: 11, color: 'var(--fg3)' }}> {u}</span></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ ...card, padding: '18px 18px' }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--fg1)', marginBottom: 18 }}>{t('weekTraffic')}</div>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 12, height: 120 }}>
|
||||
{vals.map((v, i) => (
|
||||
<div key={i} style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 7 }}>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--fg3)' }}>{v}</div>
|
||||
<div style={{ width: '100%', maxWidth: 30, height: `${v / max * 84}px`, background: 'var(--accent)', borderRadius: '6px 6px 0 0', opacity: 0.85 }} />
|
||||
<div style={{ fontSize: 11, color: 'var(--fg3)' }}>{labels[i]}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ───────── Bottom tab ───────── */
|
||||
function BottomTab({ t, tab, setTab }) {
|
||||
const items = [['connect','power',t('tabConnect')],['servers','globe',t('tabServers')],['stats','chart',t('tabStats')],['me','user',t('tabMe')]];
|
||||
return (
|
||||
<div style={{ display: 'flex', borderTop: '1px solid var(--border)', background: 'var(--surface)', paddingBottom: 22 }}>
|
||||
{items.map(([id, ic, label]) => {
|
||||
const a = tab === id;
|
||||
return (
|
||||
<button key={id} onClick={() => setTab(id)} style={{ flex: 1, border: 'none', background: 'transparent', cursor: 'pointer', padding: '10px 0 4px', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 4 }}>
|
||||
<Icon name={ic} size={22} stroke={a ? 2.4 : 2} color={a ? 'var(--accent)' : 'var(--fg3)'} />
|
||||
<span style={{ fontFamily: 'var(--font-sans)', fontSize: 11, fontWeight: a ? 700 : 500, color: a ? 'var(--accent)' : 'var(--fg3)' }}>{label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Object.assign(window, { ConnectScreen, ServersScreen, StatsScreen, SettingsScreen, BottomTab, LangSwitch });
|
||||
Reference in New Issue
Block a user