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,30 @@
|
||||
# 穿山甲 VPN — 桌面客户端 UI Kit
|
||||
|
||||
macOS / Windows 桌面客户端的高保真原型。920×600 窗口,侧栏导航 + 内容区,支持深浅主题与中英切换。
|
||||
|
||||
## 运行
|
||||
打开 `index.html`。窗口 chrome(交通灯)来自 `macos-window.jsx` 启动组件。完整流程:**登录/注册 → 首次引导 → 主应用**。可交互:
|
||||
- **登录 / 注册**(`DAuthFlow`):分屏式,左侧 clay 品牌面板 + 右侧表单。登录用邮箱 + 密码;注册走「邮箱 → 发送验证码 → 填验证码 → 设置密码」。
|
||||
- **首次引导**(`DOnboarding`):窗口卡片,3 屏(连接全球 → 授权 VPN 配置 → 安全无日志),可跳过。
|
||||
- **侧栏导航**:连接 / 节点 / 统计 / **账户** / **联系我们** / 设置;底部 PRO 账户卡。
|
||||
- **连接键**:三态(断开 / 连接中 / 已连接 + 计时 + 速率)。
|
||||
- **节点**:双列网格,搜索 + 点选切换。
|
||||
- **统计**:流量 / 延迟 / 时长指标卡 + 本周流量柱状图。
|
||||
- **账户**(集成后台):套餐横幅(续费/升级)、设备管理(可移除)、套餐选择(免费/专业/团队)→ 进入**兑换 & 购买**(激活码 + 渠道)。
|
||||
- **联系我们**(`DContactView`):Telegram / LINE / 邮箱 / 发卡商店 + 服务时间 + FAQ。
|
||||
- **设置**:兑换入口、开机自启 / 智能分流 / Kill Switch / 语言切换 / 深色外观 / 协议 / 版本 / 查看登录流程。
|
||||
- 顶栏右侧:连接状态 + 明暗快捷切换。
|
||||
|
||||
## 文件
|
||||
| 文件 | 内容 |
|
||||
|---|---|
|
||||
| `index.html` | 挂载 `DesktopApp` |
|
||||
| `dparts.jsx` | 原子:`DIcon` / `DMark`(穿山甲标)/ `DCC` / `DSignal`;`DSTRINGS` i18n 字典、`dT/dName/dSub`、`DSERVERS` |
|
||||
| `dflows.jsx` | `DAuthFlow`(登录/注册)/ `DOnboarding`(3 屏引导)/ `DAccountView`(集成后台)/ `DRedeemView`(兑换+购买)/ `DContactView`(联系我们) |
|
||||
| `dapp.jsx` | `DesktopApp` 状态机(stage: app/auth/onboarding)+ `DConnect/DServers/DStats/DSettings/NavItem/DToggle/DLangSwitch` |
|
||||
| `macos-window.jsx` | macOS 窗口 chrome 启动组件(此 Kit 仅用其 `MacTrafficLights`) |
|
||||
|
||||
## 关键模式
|
||||
- 与移动端共用同一套语义 token 与穿山甲品牌标,保证跨端一致。
|
||||
- 语言为单选(中文**或** EN),通过设置里的段控切换 —— 反映真实产品而非中英并排。
|
||||
- Windows 版本仅交通灯换成 Windows 控制按钮即可复用全部视图。
|
||||
@@ -0,0 +1,278 @@
|
||||
/* dapp.jsx — 穿山甲 VPN desktop · window + views (i18n) */
|
||||
const { useState, useEffect, useRef } = React;
|
||||
|
||||
function DToggle({ on, onChange }) {
|
||||
return <div onClick={onChange} style={{ width:42, height:25, 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?20:3, width:19, height:19, borderRadius:'50%', background:'#fff', boxShadow:'var(--shadow-sm)', transition:'left 220ms var(--ease-out)' }} /></div>;
|
||||
}
|
||||
function DLangSwitch({ lang, setLang }) {
|
||||
return <div style={{ display:'flex', background:'var(--bg-subtle)', borderRadius:999, padding:3, gap:2 }}>
|
||||
{[['zh','中文'],['en','EN']].map(([v,l])=>(
|
||||
<button key={v} onClick={()=>setLang(v)} style={{ border:'none', cursor:'pointer', borderRadius:999, padding:'4px 12px', fontFamily:'var(--font-sans)', fontSize:12, 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 NavItem({ icon, label, active, onClick }) {
|
||||
return (
|
||||
<button onClick={onClick} style={{ display:'flex', alignItems:'center', gap:11, width:'100%', textAlign:'left', border:'none', cursor:'pointer',
|
||||
background: active?'var(--accent-subtle)':'transparent', color: active?'var(--accent)':'var(--fg2)',
|
||||
padding:'9px 12px', borderRadius:'var(--radius-md)', fontFamily:'var(--font-sans)', fontSize:13.5, fontWeight: active?600:500, transition:'background-color 140ms var(--ease-out), color 140ms var(--ease-out)' }}>
|
||||
<DIcon name={icon} size={18} stroke={active?2.3:2} color={active?'var(--accent)':'var(--fg3)'} />{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function DConnect({ t, lang, status, onToggle, server, smart, free, mins, adDone, onAd, elapsed }) {
|
||||
const map = {
|
||||
off:{ fill:'var(--sand-100)', fg:'var(--clay-500)', icon:'power', cap:t('capOff'), sub:t('subOff'), ring:'track' },
|
||||
connecting:{ fill:'var(--accent)', fg:'#fff', icon:'loader-circle', cap:t('capConnecting'), sub:t('subConnecting'), ring:'spin' },
|
||||
on:{ fill:'var(--success)', fg:'#fff', icon:'shield-check', cap:t('capOn'), sub:t('subOn'), 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 8px ${status==='on'?'var(--success-subtle)':'var(--accent-subtle)'}, var(--shadow-lg)`;
|
||||
return (
|
||||
<div style={{ display:'flex', flexDirection:'column', alignItems:'center', justifyContent:'center', height:'100%', gap:24, padding:'10px 40px' }}>
|
||||
<button onClick={onToggle} style={{ position:'relative', width:176, height:176, borderRadius:'50%', border:'none', cursor:'pointer', background:map.fill, color:map.fg,
|
||||
boxShadow:shadow, display:'flex', flexDirection:'column', alignItems:'center', justifyContent:'center', gap:7, transition:'box-shadow 300ms var(--ease-out), background-color 300ms var(--ease-out)' }}>
|
||||
<svg viewBox="0 0 176 176" 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="88" cy="88" r="81" fill="none" stroke="var(--sand-200)" strokeWidth="3" strokeDasharray="2 8" strokeLinecap="round"/>}
|
||||
{map.ring==='spin' && <><circle cx="88" cy="88" r="81" fill="none" stroke="rgba(255,255,255,.3)" strokeWidth="4"/><circle cx="88" cy="88" r="81" fill="none" stroke="#fff" strokeWidth="4" strokeLinecap="round" strokeDasharray="130 380"/></>}
|
||||
{map.ring==='full' && <circle cx="88" cy="88" r="81" fill="none" stroke="rgba(255,255,255,.85)" strokeWidth="4" strokeLinecap="round"/>}
|
||||
</svg>
|
||||
<DIcon name={map.icon} size={46} stroke={1.7} style={status==='connecting'?{animation:'pg-spin 1s linear infinite'}:{}} />
|
||||
{status==='off' && <span style={{ fontFamily:'var(--font-sans)', fontWeight:700, fontSize:13 }}>{t('connectNow')}</span>}
|
||||
{status==='connecting' && <span style={{ fontFamily:'var(--font-mono)', fontWeight:700, fontSize:13, letterSpacing:'0.08em' }}>···</span>}
|
||||
{status==='on' && <span style={{ fontFamily:'var(--font-mono)', fontWeight:600, fontSize:15 }}>{fmt(elapsed)}</span>}
|
||||
</button>
|
||||
<div style={{ textAlign:'center' }}>
|
||||
<div style={{ fontFamily:'var(--font-display)', fontWeight:700, fontSize:22, color:'var(--fg1)' }}>{map.cap}</div>
|
||||
<div style={{ fontSize:13, color:'var(--fg3)', marginTop:4 }}>{map.sub}</div>
|
||||
<div style={{ display:'inline-flex', alignItems:'center', gap:7, marginTop:10, background:'var(--surface)', border:'1px solid var(--border)', borderRadius:'var(--radius-full)', padding:'6px 14px', fontSize:12.5, fontWeight:600, color:'var(--fg2)' }}>
|
||||
{smart && <DIcon name="zap" size={13} stroke={2.4} color="var(--accent)" />}
|
||||
<span style={{ whiteSpace:'nowrap' }}>{smart ? `${t('smartSelect')} · ` : ''}{dName(server,lang)} · {server.ping}ms</span>
|
||||
</div>
|
||||
</div>
|
||||
{free && (
|
||||
<div style={{ width:340, 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 }}>
|
||||
<DIcon name="clock" size={14} 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:13.5, 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 }}></div>
|
||||
</div>
|
||||
{adDone ? (
|
||||
<div style={{ display:'flex', alignItems:'center', justifyContent:'center', gap:7, color:'var(--success)', fontWeight:700, fontSize:13, padding:'8px' }}>
|
||||
<DIcon name="check-circle" size={15} 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:'8px', borderRadius:'var(--radius-full)', cursor:'pointer' }}>
|
||||
<DIcon name="play-circle" size={15} stroke={2.2} />{t('watchAd')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{status==='on' &&
|
||||
<div style={{ display:'flex', gap:10 }}>
|
||||
{[['arrow-down','86.4','Mb/s'],['arrow-up','12.1','Mb/s'],['zap',String(server.ping),'ms']].map(([ic,v,u])=>(
|
||||
<div key={ic} style={{ display:'flex', alignItems:'center', gap:7, background:'var(--surface)', border:'1px solid var(--border)', borderRadius:'var(--radius-md)', padding:'8px 14px', boxShadow:'var(--shadow-sm)' }}>
|
||||
<DIcon name={ic} size={15} stroke={2.2} color="var(--accent)" /><span style={{ fontFamily:'var(--font-mono)', fontSize:14, color:'var(--fg1)' }}>{v}<span style={{ fontSize:10, color:'var(--fg3)' }}> {u}</span></span>
|
||||
</div>
|
||||
))}
|
||||
</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DServers({ t, lang, current, onPick }) {
|
||||
const [q,setQ]=useState('');
|
||||
const list = DSERVERS.filter(s=>(s.name+s.en).toLowerCase().includes(q.toLowerCase()));
|
||||
const smartActive = current==='AUTO';
|
||||
return (
|
||||
<div style={{ padding:'4px 28px 24px' }}>
|
||||
<div style={{ display:'flex', alignItems:'center', gap:9, background:'var(--bg-subtle)', borderRadius:'var(--radius-md)', padding:'9px 13px', marginBottom:16, maxWidth:340 }}>
|
||||
<DIcon name="search" size={17} 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:13.5, color:'var(--fg1)' }} />
|
||||
</div>
|
||||
{/* smart select — recommended card */}
|
||||
<button onClick={()=>onPick('AUTO')} style={{ display:'flex', alignItems:'center', gap:12, width:'100%', textAlign:'left', cursor:'pointer',
|
||||
background:'var(--accent-subtle)', border:`1.5px solid ${smartActive?'var(--accent)':'var(--accent-border)'}`,
|
||||
borderRadius:'var(--radius-xl)', padding:'14px', boxShadow:smartActive?'var(--shadow-md)':'var(--shadow-sm)', marginBottom:10 }}>
|
||||
<div style={{ width:38, height:38, borderRadius:'var(--radius-md)', background:'linear-gradient(150deg,var(--clay-500),var(--clay-700))', display:'flex', alignItems:'center', justifyContent:'center', flexShrink:0 }}><DIcon name="zap" size={18} 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:14.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:11.5, color:'var(--fg2)', marginTop:3 }}>{t('smartSub')}</div>
|
||||
</div>
|
||||
{smartActive && <DIcon name="check" size={18} color="var(--accent)" stroke={2.6} />}
|
||||
</button>
|
||||
<div style={{ display:'grid', gridTemplateColumns:'1fr 1fr', gap:10 }}>
|
||||
{list.map(s=>{
|
||||
const active = s.code===current;
|
||||
return (
|
||||
<button key={s.code} onClick={()=>onPick(s.code)} style={{ display:'flex', alignItems:'center', gap:12, textAlign:'left', cursor:'pointer',
|
||||
background: active?'var(--accent-subtle)':'var(--surface)', border:`1.5px solid ${active?'var(--accent-border)':'var(--border)'}`,
|
||||
borderRadius:'var(--radius-lg)', padding:'12px 14px', boxShadow:'var(--shadow-sm)' }}>
|
||||
<DCC code={s.code} active={active} />
|
||||
<div style={{ flex:1, minWidth:0 }}>
|
||||
<div style={{ fontFamily:'var(--font-sans)', fontSize:14, fontWeight:600, color:'var(--fg1)' }}>{dName(s,lang)}</div>
|
||||
<div style={{ fontSize:11.5, color:'var(--fg3)', whiteSpace:'nowrap', overflow:'hidden', textOverflow:'ellipsis' }}>{dSub(s,lang)}</div>
|
||||
</div>
|
||||
<div style={{ display:'flex', flexDirection:'column', alignItems:'flex-end', gap:5 }}>
|
||||
<span style={{ fontFamily:'var(--font-mono)', fontSize:11.5, color:'var(--fg2)' }}>{s.ping}ms</span>
|
||||
<DSignal ping={s.ping} />
|
||||
</div>
|
||||
{active && <DIcon name="check" size={17} color="var(--accent)" />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DStats({ t, lang }) {
|
||||
const vals=[2.1,3.4,1.8,4.6,5.2,6.1,3.0]; const labels=t('days'); const max=6.1;
|
||||
return (
|
||||
<div style={{ padding:'8px 32px 24px' }}>
|
||||
<div style={{ display:'flex', gap:14, marginBottom:22 }}>
|
||||
{[[t('trafficMonth'),'42.6 GB'],[t('avgPing'),'29 ms'],[t('durMonth'),'86.4 h']].map(([l,v])=>(
|
||||
<div key={l} style={{ flex:1, background:'var(--surface)', border:'1px solid var(--border)', borderRadius:'var(--radius-lg)', padding:'16px 18px', boxShadow:'var(--shadow-sm)' }}>
|
||||
<div style={{ fontSize:12, color:'var(--fg3)', fontWeight:600 }}>{l}</div>
|
||||
<div style={{ fontFamily:'var(--font-mono)', fontSize:24, fontWeight:500, color:'var(--fg1)', marginTop:6 }}>{v}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ background:'var(--surface)', border:'1px solid var(--border)', borderRadius:'var(--radius-lg)', padding:'18px 20px', boxShadow:'var(--shadow-sm)' }}>
|
||||
<div style={{ fontSize:13, fontWeight:600, color:'var(--fg1)', marginBottom:18 }}>{t('weekTraffic')}</div>
|
||||
<div style={{ display:'flex', alignItems:'flex-end', gap:18, height:120 }}>
|
||||
{vals.map((v,i)=>(
|
||||
<div key={i} style={{ flex:1, display:'flex', flexDirection:'column', alignItems:'center', gap:8 }}>
|
||||
<div style={{ fontFamily:'var(--font-mono)', fontSize:10, color:'var(--fg3)' }}>{v}</div>
|
||||
<div style={{ width:'100%', maxWidth:34, height:`${v/max*86}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>
|
||||
);
|
||||
}
|
||||
|
||||
function DSettings({ t, lang, setLang, dark, setDark, free, setFree, onReplayAuth, onReplayOnboarding, onRedeem }) {
|
||||
const [auto,setAuto]=useState(true); const [kill,setKill]=useState(true); const [boot,setBoot]=useState(true);
|
||||
const card={ background:'var(--surface)', border:'1px solid var(--border)', borderRadius:'var(--radius-lg)', boxShadow:'var(--shadow-sm)', overflow:'hidden', marginBottom:16 };
|
||||
const Row=({title,sub,right,last,onClick})=>(
|
||||
<div onClick={onClick} style={{ display:'flex', alignItems:'center', gap:12, padding:'14px 18px', borderBottom:last?'none':'1px solid var(--border)', cursor:onClick?'pointer':'default' }}>
|
||||
<div style={{ flex:1 }}><div style={{ fontSize:14, fontWeight:500, color:'var(--fg1)' }}>{title}</div>{sub&&<div style={{ fontSize:12, color:'var(--fg3)', marginTop:2 }}>{sub}</div>}</div>{right}
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div style={{ padding:'8px 32px 24px', maxWidth:560 }}>
|
||||
<div style={card}>
|
||||
<Row title={t('redeemEntry')} sub={t('proMember')} right={<DIcon name="chevron-right" size={18} color="var(--fg3)" />} last onClick={onRedeem} />
|
||||
</div>
|
||||
<div style={card}>
|
||||
<Row title={t('autostart')} sub={t('autostartSub')} right={<DToggle on={boot} onChange={()=>setBoot(!boot)} />} />
|
||||
<Row title={t('smartRoute')} sub={t('smartRouteSub')} right={<DToggle on={auto} onChange={()=>setAuto(!auto)} />} />
|
||||
<Row title={t('killSwitch')} sub={t('killSwitchSub')} right={<DToggle on={kill} onChange={()=>setKill(!kill)} />} last />
|
||||
</div>
|
||||
<div style={card}>
|
||||
<Row title={t('language')} right={<DLangSwitch lang={lang} setLang={setLang} />} />
|
||||
<Row title={t('darkAppearance')} sub={dark?t('stateOn'):t('followLight')} right={<DToggle on={dark} onChange={()=>setDark(!dark)} />} />
|
||||
<Row title={t('protocol')} right={<span style={{ fontFamily:'var(--font-mono)', fontSize:13, color:'var(--fg3)' }}>WireGuard</span>} />
|
||||
<Row title={t('checkUpdate')} right={<DIcon name="chevron-right" size={18} color="var(--fg3)" />} />
|
||||
<Row title={t('version')} right={<span style={{ fontFamily:'var(--font-mono)', fontSize:13, color:'var(--fg3)' }}>v2.4.0 (macOS)</span>} last />
|
||||
</div>
|
||||
<div style={card}>
|
||||
<Row title={t('replayAuth')} sub={t('replayAuthSub')} right={<DIcon name="chevron-right" size={18} color="var(--fg3)" />} onClick={onReplayAuth} />
|
||||
<Row title={t('replayOnboarding')} sub={t('replayOnboardingSub')} right={<DIcon name="chevron-right" size={18} color="var(--fg3)" />} onClick={onReplayOnboarding} />
|
||||
<Row title={t('freeDemo')} sub={t('freeDemoSub')} right={<DToggle on={free} onChange={()=>setFree(!free)} />} last />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DesktopApp() {
|
||||
const [stage,setStage]=useState('app'); // app | auth | onboarding — UI kit opens on the main app
|
||||
const [view,setView]=useState('connect');
|
||||
const [status,setStatus]=useState('off');
|
||||
const [code,setCode]=useState('AUTO'); // 'AUTO' = 智能选择(默认)
|
||||
const [dark,setDark]=useState(false);
|
||||
const [lang,setLang]=useState('zh');
|
||||
const [free,setFree]=useState(true); // 演示:免费版视角(与移动端一致)
|
||||
const [mins,setMins]=useState(6);
|
||||
const [adDone,setAdDone]=useState(false);
|
||||
const [elapsed,setElapsed]=useState(0);
|
||||
const timer=useRef(null);
|
||||
const t=dT(lang);
|
||||
const smart=code==='AUTO';
|
||||
const server=smart ? DSERVERS.reduce((a,b)=>a.ping<b.ping?a:b) : DSERVERS.find(s=>s.code===code);
|
||||
const titles={connect:t('connect'),servers:t('servers'),stats:t('stats'),account:t('navAccount'),plans:t('choosePlan'),redeem:t('redeemTitle'),contact:t('navContact'),settings:t('settings')};
|
||||
|
||||
function toggle(){ if(status==='off'){ setStatus('connecting'); setTimeout(()=>{setStatus('on');setElapsed(0);},1500); } else setStatus('off'); }
|
||||
function pick(c){ setCode(c); setView('connect'); if(status==='on'){ setStatus('connecting'); setTimeout(()=>{setStatus('on');setElapsed(0);},1200); } }
|
||||
useEffect(()=>{ if(status==='on') timer.current=setInterval(()=>setElapsed(e=>e+1),1000); return ()=>clearInterval(timer.current); },[status]);
|
||||
|
||||
if(stage==='auth') return (
|
||||
<div style={{ display:'flex', alignItems:'center', justifyContent:'center', minHeight:'100vh', padding:28, boxSizing:'border-box' }}>
|
||||
<DAuthFlow t={t} lang={lang} dark={dark} onDone={()=>setStage('onboarding')} />
|
||||
</div>
|
||||
);
|
||||
if(stage==='onboarding') return (
|
||||
<div style={{ display:'flex', alignItems:'center', justifyContent:'center', minHeight:'100vh', padding:28, boxSizing:'border-box' }}>
|
||||
<DOnboarding t={t} lang={lang} dark={dark} onDone={()=>setStage('app')} />
|
||||
</div>
|
||||
);
|
||||
|
||||
let main;
|
||||
if(view==='connect') main=<DConnect t={t} lang={lang} status={status} onToggle={toggle} server={server} smart={smart} free={free} mins={mins} adDone={adDone} onAd={()=>setAdDone(true)} elapsed={elapsed} />;
|
||||
else if(view==='servers') main=<DServers t={t} lang={lang} current={code} onPick={pick} />;
|
||||
else if(view==='stats') main=<DStats t={t} lang={lang} />;
|
||||
else if(view==='account') main=<DAccountView t={t} lang={lang} free={free} onUpgrade={()=>setView('plans')} onSignOut={()=>setStage('auth')} />;
|
||||
else if(view==='plans') main=<DPlansView t={t} lang={lang} onChoose={()=>setView('redeem')} />;
|
||||
else if(view==='redeem') main=<DRedeemView t={t} lang={lang} />;
|
||||
else if(view==='contact') main=<DContactView t={t} lang={lang} />;
|
||||
else main=<DSettings t={t} lang={lang} setLang={setLang} dark={dark} setDark={setDark} free={free} setFree={setFree} onReplayAuth={()=>setStage('auth')} onReplayOnboarding={()=>setStage('onboarding')} onRedeem={()=>setView('redeem')} />;
|
||||
|
||||
return (
|
||||
<div data-theme={dark?'dark':'light'} style={{ width:920, height:600, borderRadius:16, overflow:'hidden', display:'flex',
|
||||
background:'var(--bg)', boxShadow:'0 0 0 1px rgba(0,0,0,0.12), 0 28px 70px rgba(0,0,0,0.4)', fontFamily:'var(--font-sans)' }}>
|
||||
<div style={{ width:204, flexShrink:0, background:'var(--bg-subtle)', borderRight:'1px solid var(--border)', display:'flex', flexDirection:'column', padding:'14px 12px' }}>
|
||||
<div style={{ padding:'2px 4px 16px' }}><MacTrafficLights /></div>
|
||||
<div style={{ display:'flex', alignItems:'center', gap:9, padding:'0 6px 16px' }}>
|
||||
<DMark size={28} /><div><div style={{ fontFamily:'var(--font-display)', fontWeight:700, fontSize:16, color:'var(--fg1)', lineHeight:1 }}>{lang==='zh'?'穿山甲':'Pangolin'}</div><div style={{ fontSize:9, fontWeight:600, letterSpacing:'0.18em', color:'var(--accent)', marginTop:3 }}>PANGOLIN</div></div>
|
||||
</div>
|
||||
<div style={{ display:'flex', flexDirection:'column', gap:3 }}>
|
||||
<NavItem icon="power" label={t('navConnect')} active={view==='connect'} onClick={()=>setView('connect')} />
|
||||
<NavItem icon="globe" label={t('navServers')} active={view==='servers'} onClick={()=>setView('servers')} />
|
||||
<NavItem icon="chart-no-axes-column" label={t('navStats')} active={view==='stats'} onClick={()=>setView('stats')} />
|
||||
<NavItem icon="user" label={t('navAccount')} active={view==='account'||view==='plans'||view==='redeem'} onClick={()=>setView('account')} />
|
||||
<NavItem icon="message-circle" label={t('navContact')} active={view==='contact'} onClick={()=>setView('contact')} />
|
||||
<NavItem icon="settings" label={t('navSettings')} active={view==='settings'} onClick={()=>setView('settings')} />
|
||||
</div>
|
||||
<div style={{ flex:1 }} />
|
||||
<div style={{ display:'flex', alignItems:'center', gap:10, background:'var(--surface)', border:'1px solid var(--border)', borderRadius:'var(--radius-md)', padding:'9px 11px' }}>
|
||||
<div style={{ width:30, height:30, borderRadius:'50%', background:'linear-gradient(150deg,var(--clay-500),var(--clay-700))', display:'flex', alignItems:'center', justifyContent:'center', flexShrink:0 }}><DIcon name="crown" size={15} color="#fff" /></div>
|
||||
<div style={{ minWidth:0 }}><div style={{ fontSize:12, fontWeight:600, color:'var(--fg1)', whiteSpace:'nowrap', overflow:'hidden', textOverflow:'ellipsis' }}>{free?t('freePlanName'):t('proMember')}</div><div style={{ fontSize:10.5, color:'var(--fg3)', whiteSpace:'nowrap', overflow:'hidden', textOverflow:'ellipsis' }}>{free?t('quotaFree'):t('until')}</div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ flex:1, display:'flex', flexDirection:'column', minWidth:0 }}>
|
||||
<div style={{ height:52, flexShrink:0, display:'flex', alignItems:'center', justifyContent:'space-between', padding:'0 24px', borderBottom:'1px solid var(--border)' }}>
|
||||
<div style={{ display:'flex', alignItems:'center', gap:10 }}>
|
||||
{(view==='redeem'||view==='plans') && <button onClick={()=>setView('account')} style={{ border:'none', background:'transparent', cursor:'pointer', padding:4, display:'flex' }}><DIcon name="arrow-left" size={20} color="var(--fg1)" /></button>}
|
||||
<div style={{ fontFamily:'var(--font-display)', fontWeight:700, fontSize:17, color:'var(--fg1)' }}>{titles[view]}</div>
|
||||
</div>
|
||||
<div style={{ display:'flex', alignItems:'center', gap:12 }}>
|
||||
<span style={{ fontFamily:'var(--font-mono)', fontSize:12, fontWeight:600, whiteSpace:'nowrap', color:status==='on'?'var(--success)':'var(--fg3)' }}>{status==='on'?'● '+t('connected')+' '+server.code:'○ '+t('notConnected')}</span>
|
||||
<button onClick={()=>setDark(!dark)} style={{ border:'none', background:'transparent', cursor:'pointer', padding:6, borderRadius:'var(--radius-sm)', display:'flex' }}><DIcon name={dark?'sun':'moon'} size={18} color="var(--fg2)" /></button>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ flex:1, overflow:'auto', minHeight:0 }}>{main}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Object.assign(window, { DesktopApp });
|
||||
@@ -0,0 +1,313 @@
|
||||
/* dflows.jsx — 穿山甲 VPN desktop · auth + account management (integrated dashboard) + redeem */
|
||||
const { useState: useStateD } = React;
|
||||
|
||||
const dCard = { background:'var(--surface)', border:'1px solid var(--border)', borderRadius:'var(--radius-xl)', boxShadow:'var(--shadow-sm)' };
|
||||
const dInput = { border:'1.5px solid var(--border-strong)', borderRadius:'var(--radius-md)', padding:'11px 13px', fontFamily:'var(--font-sans)', fontSize:14, color:'var(--fg1)', background:'var(--bg)', outline:'none', width:'100%', boxSizing:'border-box' };
|
||||
|
||||
function DField({ label, children }) {
|
||||
return <label style={{ display:'block' }}>
|
||||
<span style={{ display:'block', fontSize:12.5, fontWeight:600, color:'var(--fg2)', marginBottom:6 }}>{label}</span>
|
||||
{children}
|
||||
</label>;
|
||||
}
|
||||
|
||||
/* ───────── AUTH — split panel, brand left / form right ───────── */
|
||||
function DAuthFlow({ t, lang, dark, onDone }) {
|
||||
const [mode,setMode]=useStateD('login');
|
||||
const [step,setStep]=useStateD(0);
|
||||
const [email,setEmail]=useStateD('');
|
||||
const [code,setCode]=useStateD('');
|
||||
const [pw,setPw]=useStateD('');
|
||||
const [sent,setSent]=useStateD(false);
|
||||
const [totp,setTotp]=useStateD(false); // 2FA step after login (PRO)
|
||||
const [otp,setOtp]=useStateD('');
|
||||
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 primary=(label,onClick,enabled)=>(
|
||||
<button onClick={enabled?onClick:undefined} disabled={!enabled} style={{ width:'100%', border:'none', borderRadius:'var(--radius-full)', padding:'13px',
|
||||
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 data-theme={dark?'dark':'light'} style={{ width:880, height:560, borderRadius:16, overflow:'hidden', display:'flex',
|
||||
boxShadow:'0 0 0 1px rgba(0,0,0,0.12), 0 28px 70px rgba(0,0,0,0.4)', fontFamily:'var(--font-sans)' }}>
|
||||
{/* brand panel */}
|
||||
<div style={{ width:340, flexShrink:0, background:'linear-gradient(165deg,var(--clay-500),var(--clay-800))', color:'#fff', padding:'48px 40px', display:'flex', flexDirection:'column' }}>
|
||||
<div style={{ display:'flex', alignItems:'center', gap:11 }}>
|
||||
<DMark size={34} color="#fff" /><div style={{ fontFamily:'var(--font-display)', fontWeight:700, fontSize:20 }}>{lang==='zh'?'穿山甲':'Pangolin'}</div>
|
||||
</div>
|
||||
<div style={{ flex:1, display:'flex', flexDirection:'column', justifyContent:'center' }}>
|
||||
<div style={{ fontFamily:'var(--font-display)', fontWeight:700, fontSize:28, lineHeight:1.3 }}>{t('authSidePitch')}</div>
|
||||
</div>
|
||||
<div style={{ fontSize:12, opacity:.8, letterSpacing:'0.04em' }}>{t('authTagline')}</div>
|
||||
</div>
|
||||
{/* form panel */}
|
||||
<div style={{ flex:1, background:'var(--bg)', padding:'44px 46px', display:'flex', flexDirection:'column' }}>
|
||||
<div style={{ display:'flex', borderBottom:'1px solid var(--border)', marginBottom:26 }}>{tab('login',t('tabLogin'))}{tab('register',t('tabRegister'))}</div>
|
||||
<div style={{ flex:1, display:'flex', flexDirection:'column', gap:16 }}>
|
||||
{mode==='login' && !totp && (<>
|
||||
<DField label={t('emailLabel')}><input style={dInput} value={email} onChange={e=>setEmail(e.target.value)} placeholder={t('emailPh')} /></DField>
|
||||
<DField label={t('pwLabel')}><input style={dInput} type="password" value={pw} onChange={e=>setPw(e.target.value)} placeholder={t('pwPh')} /></DField>
|
||||
<div style={{ textAlign:'right', marginTop:-6 }}><span style={{ fontSize:13, color:'var(--accent)', fontWeight:600, cursor:'pointer' }}>{t('forgotPw')}</span></div>
|
||||
{primary(t('doLogin'), ()=>setTotp(true), valid && pw.length>0)}
|
||||
</>)}
|
||||
{mode==='login' && totp && (<>
|
||||
<div style={{ display:'flex', alignItems:'center', gap:8 }}>
|
||||
<DIcon name="shield-check" size={18} color="var(--accent)" />
|
||||
<span style={{ fontFamily:'var(--font-display)', fontWeight:700, fontSize:17, 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 }}>{t('twoFAHint')}</div>
|
||||
<input value={otp} onChange={e=>setOtp(e.target.value.replace(/\D/g,'').slice(0,6))} placeholder="······"
|
||||
style={{ ...dInput, fontFamily:'var(--font-mono)', fontSize:20, letterSpacing:'0.5em', textAlign:'center' }} />
|
||||
{primary(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 && (<>
|
||||
<DField label={t('emailLabel')}>
|
||||
<div style={{ display:'flex', gap:8 }}>
|
||||
<input style={dInput} value={email} onChange={e=>setEmail(e.target.value)} placeholder={t('emailPh')} />
|
||||
<button onClick={()=>valid&&setSent(true)} style={{ border:'1.5px solid var(--border-strong)', background:'transparent', borderRadius:'var(--radius-md)', padding:'0 16px', cursor:valid?'pointer':'default', color:valid?'var(--accent)':'var(--fg3)', fontWeight:700, fontSize:13, whiteSpace:'nowrap', flexShrink:0 }}>{sent?t('resend'):t('sendCode')}</button>
|
||||
</div>
|
||||
</DField>
|
||||
{sent && <div style={{ fontSize:12, color:'var(--success)', marginTop:-8, display:'flex', alignItems:'center', gap:6 }}><DIcon name="check-circle" size={14} color="var(--success)" />{t('codeSentTo')} {email}</div>}
|
||||
<DField label={t('codeLabel')}><input style={{ ...dInput, fontFamily:'var(--font-mono)', letterSpacing:'0.3em' }} value={code} onChange={e=>setCode(e.target.value.replace(/\D/g,'').slice(0,6))} placeholder="······" /></DField>
|
||||
{primary(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 }}><DIcon name="check-circle" size={15} color="var(--success)" />{email}</div>
|
||||
<DField label={t('pwLabel')}><input style={dInput} type="password" value={pw} onChange={e=>setPw(e.target.value)} placeholder={t('setPwPh')} /></DField>
|
||||
{primary(t('doCreate'), onDone, pw.length>=6)}
|
||||
</>)}
|
||||
</div>
|
||||
<div style={{ fontSize:11, color:'var(--fg3)', textAlign:'center', lineHeight:1.5, paddingTop:18 }}>{t('tos')}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ───────── ACCOUNT (integrated: plan + account info + devices) ───────── */
|
||||
function DAccountView({ t, lang, free, onUpgrade, onSignOut }) {
|
||||
const [twoFA,setTwoFA]=useStateD(true);
|
||||
const [devices,setDevices]=useStateD([
|
||||
{ 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 infoRow=(label,value,action,last)=>(
|
||||
<div style={{ display:'flex', alignItems:'center', gap:12, padding:'14px 18px', borderBottom:last?'none':'1px solid var(--border)' }}>
|
||||
<div style={{ width:96, fontSize:13, fontWeight:600, color:'var(--fg2)' }}>{label}</div>
|
||||
<div style={{ flex:1, fontSize:14, color:'var(--fg1)' }}>{value}</div>
|
||||
{action}
|
||||
</div>
|
||||
);
|
||||
const smallBtn={ border:'1.5px solid var(--border-strong)', background:'transparent', color:'var(--fg2)', fontSize:13, fontWeight:600, padding:'7px 14px', borderRadius:'var(--radius-full)', cursor:'pointer' };
|
||||
return (
|
||||
<div style={{ padding:'8px 32px 28px', display:'flex', flexDirection:'column', gap:22, maxWidth:760 }}>
|
||||
{/* plan banner */}
|
||||
<div style={{ ...dCard, background:'linear-gradient(150deg,var(--clay-600),var(--clay-800))', border:'none', color:'#fff', padding:'20px 24px', display:'flex', alignItems:'center', justifyContent:'space-between' }}>
|
||||
<div style={{ display:'flex', alignItems:'center', gap:15 }}>
|
||||
<div style={{ width:48, height:48, borderRadius:'50%', background:'rgba(255,255,255,.18)', display:'flex', alignItems:'center', justifyContent:'center' }}><DIcon name="crown" size={24} color="#fff" /></div>
|
||||
<div>
|
||||
<div style={{ fontSize:12.5, opacity:.8 }}>{t('curPlan')}</div>
|
||||
<div style={{ fontFamily:'var(--font-display)', fontSize:20, fontWeight:700, marginTop:2 }}>{free?t('freePlanName'):t('proMember')}</div>
|
||||
<div style={{ fontSize:11.5, opacity:.8, marginTop:3, fontFamily:'var(--font-mono)', whiteSpace:'nowrap' }}>{free?t('quotaFree'):`${t('expires')} 2026-12-31 · ${t('accUsage')} 42.6 GB`}</div>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={onUpgrade} style={{ border:'none', background:'#fff', color:'var(--clay-700)', fontWeight:700, fontSize:14, padding:'11px 20px', borderRadius:'var(--radius-full)', cursor:'pointer', display:'inline-flex', alignItems:'center', gap:8 }}><DIcon name="ticket" size={16} color="var(--clay-700)" />{t('renew')}</button>
|
||||
</div>
|
||||
|
||||
{/* account info */}
|
||||
<div>
|
||||
<div style={{ fontFamily:'var(--font-display)', fontSize:17, fontWeight:700, color:'var(--fg1)', marginBottom:14 }}>{t('accInfoTitle')}</div>
|
||||
<div style={{ ...dCard, overflow:'hidden' }}>
|
||||
{infoRow(t('accEmail'), 'me@pangolin.vpn', <button style={smallBtn}>{t('accChange')}</button>)}
|
||||
{infoRow(t('accPassword'), '••••••••••', <button style={smallBtn}>{t('accChange')}</button>)}
|
||||
{infoRow(t('twoFA'), t('twoFASub'), <DToggle on={twoFA} onChange={()=>setTwoFA(!twoFA)} />)}
|
||||
{infoRow(t('ucEntry'), t('ucEntrySub'), <button onClick={()=>window.open('../usercenter/index.html','_blank')} style={{ ...smallBtn, display:'inline-flex', alignItems:'center', gap:6 }}><DIcon name="external-link" size={14} color="var(--fg2)" />{t('ucOpen')}</button>)}
|
||||
<div style={{ display:'flex', alignItems:'center', gap:12, padding:'14px 18px' }}>
|
||||
<div style={{ width:96, fontSize:13, fontWeight:600, color:'var(--danger)' }}>{t('accSignOut')}</div>
|
||||
<div style={{ flex:1 }} />
|
||||
<button onClick={onSignOut} style={{ ...smallBtn, color:'var(--danger)', borderColor:'var(--danger-subtle)', background:'var(--danger-subtle)', display:'inline-flex', alignItems:'center', gap:7 }}><DIcon name="log-out" size={15} color="var(--danger)" />{t('accSignOut')}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* devices */}
|
||||
<div>
|
||||
<div style={{ fontFamily:'var(--font-display)', fontSize:17, fontWeight:700, color:'var(--fg1)' }}>{t('myDevices')}</div>
|
||||
<div style={{ fontSize:12.5, color:'var(--fg3)', margin:'3px 0 14px' }}>{t('devicesSub')}</div>
|
||||
<div style={{ ...dCard, overflow:'hidden' }}>
|
||||
{devices.map((d,i)=>(
|
||||
<div key={d.id} style={{ display:'flex', alignItems:'center', gap:14, padding:'14px 18px', borderBottom:i<devices.length-1?'1px solid var(--border)':'none' }}>
|
||||
<div style={{ width:40, height:40, borderRadius:'var(--radius-md)', background:'var(--accent-subtle)', display:'flex', alignItems:'center', justifyContent:'center', flexShrink:0 }}><DIcon name={d.icon} size={20} color="var(--accent)" /></div>
|
||||
<div style={{ flex:1 }}>
|
||||
<div style={{ fontSize:14.5, fontWeight:600, color:'var(--fg1)', display:'flex', alignItems:'center', gap:8 }}>{d.name}{d.me && <span style={{ fontSize:11, fontWeight:600, color:'var(--success)', background:'var(--success-subtle)', padding:'2px 8px', borderRadius:999 }}>{d.active}</span>}</div>
|
||||
<div style={{ fontSize:12, color:'var(--fg3)', marginTop:2 }}>{d.os}{!d.me && ` · ${t('lastActive')} ${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:13, fontWeight:600, padding:'7px 14px', borderRadius:'var(--radius-full)', cursor:'pointer' }}>{t('remove')}</button>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ───────── PLANS (shown on renew / upgrade) ───────── */
|
||||
function DPlansView({ t, lang, 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={{ padding:'8px 32px 28px' }}>
|
||||
<div style={{ display:'grid', gridTemplateColumns:'1fr 1fr 1fr', gap:16, alignItems:'start', maxWidth:760 }}>
|
||||
{plans.map(p=>{
|
||||
const isPro=p.variant==='pro';
|
||||
return (
|
||||
<div key={p.id} style={{ ...dCard, position:'relative', padding:'22px 20px',
|
||||
...(isPro?{ background:'linear-gradient(160deg,var(--clay-600),var(--clay-800))', border:'none', color:'#fff', boxShadow:'var(--shadow-md)' }:{}) }}>
|
||||
{isPro && <div style={{ position:'absolute', top:-10, left:'50%', transform:'translateX(-50%)', background:'#fff', color:'var(--clay-700)', fontSize:11, fontWeight:700, padding:'3px 11px', borderRadius:999, boxShadow:'var(--shadow-sm)' }}>{t('mostPopular')}</div>}
|
||||
<div style={{ fontSize:13.5, fontWeight:600, color:isPro?'#fff':'var(--fg2)', opacity:isPro?.85:1 }}>{p.name}</div>
|
||||
<div style={{ fontFamily:'var(--font-display)', fontSize:30, fontWeight:700, marginTop:6, color:isPro?'#fff':'var(--fg1)' }}>{p.price}<span style={{ fontSize:13, fontWeight:500, opacity:.7 }}>{t('perMonth')}</span></div>
|
||||
<div style={{ display:'flex', flexDirection:'column', gap:9, margin:'16px 0 18px' }}>
|
||||
{p.feats.map((f,i)=>(<div key={i} style={{ display:'flex', alignItems:'center', gap:9, fontSize:13, color:isPro?'rgba(255,255,255,.92)':'var(--fg2)' }}><DIcon name="check" size={15} 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:'11px', fontWeight:600, 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>
|
||||
);
|
||||
}
|
||||
|
||||
/* ───────── REDEEM & BUY ───────── */
|
||||
function DRedeemView({ t, lang }) {
|
||||
const [code,setCode]=useStateD('');
|
||||
const [ok,setOk]=useStateD(false);
|
||||
const channels=[
|
||||
{ icon:'shopping-bag', name:t('chStore'), sub:t('chStoreSub'), accent:true },
|
||||
{ icon:'credit-card', name:'USDT (TRC20)', sub:t('chUsdtSub'), mono:false },
|
||||
{ icon:'send', name:'Telegram', sub:t('chTgSub'), mono:true },
|
||||
{ icon:'message-circle', name:'LINE', sub:t('chLineSub'), mono:true },
|
||||
{ icon:'mail', name:t('chEmail'), sub:t('chEmailSub'), mono:true },
|
||||
];
|
||||
return (
|
||||
<div style={{ padding:'8px 32px 28px', maxWidth:680 }}>
|
||||
<div style={{ ...dCard, padding:'20px 22px', marginBottom:24 }}>
|
||||
<div style={{ fontSize:14.5, 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' }}><DIcon 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={{ ...dInput, fontFamily:'var(--font-mono)', letterSpacing:'0.08em' }} />
|
||||
<button onClick={()=>code.length>=4&&setOk(true)} style={{ border:'none', borderRadius:'var(--radius-md)', padding:'0 22px', background:'var(--accent)', color:'#fff', fontWeight:700, fontSize:14, cursor:'pointer', flexShrink:0 }}>{t('redeemBtn')}</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ fontSize:14.5, 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:'grid', gridTemplateColumns:'1fr 1fr', gap:12 }}>
|
||||
{channels.map(c=>(
|
||||
<button key={c.name} style={{ display:'flex', alignItems:'center', gap:13, 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 }}><DIcon name={c.icon} size={20} color={c.accent?'#fff':'var(--accent)'} /></div>
|
||||
<div style={{ flex:1, minWidth:0 }}>
|
||||
<div style={{ fontSize:14.5, fontWeight:600, color:'var(--fg1)' }}>{c.name}</div>
|
||||
<div style={{ fontSize:12, color:'var(--fg3)', fontFamily:c.mono?'var(--font-mono)':'var(--font-sans)', whiteSpace:'nowrap', overflow:'hidden', textOverflow:'ellipsis' }}>{c.sub}</div>
|
||||
</div>
|
||||
<DIcon name="external-link" size={16} color="var(--fg3)" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ───────── ONBOARDING — window card, 3 screens ───────── */
|
||||
function DOnboarding({ t, lang, dark, onDone }) {
|
||||
const [i,setI]=useStateD(0);
|
||||
const steps=[
|
||||
{ icon:'globe', title:t('ob1Title'), sub:t('ob1Sub'), cta:t('obNext') },
|
||||
{ icon:'lock', title:t('ob2Title'), sub:t('ob2Sub'), cta:t('obAllow') },
|
||||
{ icon:'shield-check', title:t('ob3Title'), sub:t('ob3Sub'), cta:t('obStart') },
|
||||
];
|
||||
const s=steps[i];
|
||||
const last=i===steps.length-1;
|
||||
return (
|
||||
<div data-theme={dark?'dark':'light'} style={{ width:560, height:520, borderRadius:16, overflow:'hidden', display:'flex', flexDirection:'column',
|
||||
background:'var(--bg)', boxShadow:'0 0 0 1px rgba(0,0,0,0.12), 0 28px 70px rgba(0,0,0,0.4)', fontFamily:'var(--font-sans)' }}>
|
||||
<div style={{ display:'flex', alignItems:'center', justifyContent:'space-between', padding:'18px 22px' }}>
|
||||
<div style={{ display:'flex', alignItems:'center', gap:9 }}><DMark size={24} /><span style={{ fontFamily:'var(--font-display)', fontWeight:700, fontSize:15, color:'var(--fg1)' }}>{lang==='zh'?'穿山甲':'Pangolin'}</span></div>
|
||||
{!last && <button onClick={onDone} style={{ border:'none', background:'transparent', cursor:'pointer', fontSize:13, fontWeight:600, color:'var(--fg3)' }}>{t('obSkip')}</button>}
|
||||
</div>
|
||||
<div style={{ flex:1, display:'flex', flexDirection:'column', alignItems:'center', justifyContent:'center', textAlign:'center', padding:'0 56px', gap:22 }}>
|
||||
<div style={{ width:108, height:108, borderRadius:'50%', background:'var(--accent-subtle)', display:'flex', alignItems:'center', justifyContent:'center' }}>
|
||||
<DIcon name={s.icon} size={52} stroke={1.6} color="var(--accent)" />
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontFamily:'var(--font-display)', fontSize:26, fontWeight:700, color:'var(--fg1)' }}>{s.title}</div>
|
||||
<div style={{ fontSize:15, color:'var(--fg2)', lineHeight:1.6, marginTop:10, maxWidth:380 }}>{s.sub}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ padding:'0 40px 36px', display:'flex', flexDirection:'column', alignItems:'center', gap:20 }}>
|
||||
<div style={{ display:'flex', gap:8 }}>
|
||||
{steps.map((_,k)=>(<div key={k} style={{ width:k===i?22:8, height:8, borderRadius:999, background:k===i?'var(--accent)':'var(--border-strong)', transition:'all 220ms var(--ease-out)' }} />))}
|
||||
</div>
|
||||
<button onClick={()=>last?onDone():setI(i+1)} style={{ width:'100%', maxWidth:360, border:'none', borderRadius:'var(--radius-full)', padding:'13px', fontFamily:'var(--font-sans)', fontWeight:700, fontSize:15, cursor:'pointer', background:'var(--accent)', color:'var(--fg-on-accent)' }}>{s.cta}</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ───────── CONTACT US ───────── */
|
||||
function DContactView({ t, lang }) {
|
||||
const channels=[
|
||||
{ icon:'send', name:t('contactTg'), sub:'@PangolinVPN_bot', accent:true },
|
||||
{ icon:'message-circle', name:t('contactLine'), sub:'@pangolinvpn' },
|
||||
{ icon:'mail', name:t('contactEmail'), sub:'support@pangolin.vpn' },
|
||||
{ icon:'shopping-bag', name:t('contactStore'), sub:'shop.pangolin.vpn' },
|
||||
];
|
||||
return (
|
||||
<div style={{ padding:'8px 32px 28px', maxWidth:680 }}>
|
||||
<div style={{ fontSize:13.5, color:'var(--fg2)', lineHeight:1.6, marginBottom:18 }}>{t('contactIntro')}</div>
|
||||
<div style={{ display:'grid', gridTemplateColumns:'1fr 1fr', gap:12, marginBottom:22 }}>
|
||||
{channels.map(c=>(
|
||||
<button key={c.name} style={{ display:'flex', alignItems:'center', gap:13, 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 }}><DIcon name={c.icon} size={20} color={c.accent?'#fff':'var(--accent)'} /></div>
|
||||
<div style={{ flex:1, minWidth:0 }}>
|
||||
<div style={{ fontSize:14.5, fontWeight:600, color:'var(--fg1)' }}>{c.name}</div>
|
||||
<div style={{ fontSize:12, color:'var(--fg3)', fontFamily:'var(--font-mono)', whiteSpace:'nowrap', overflow:'hidden', textOverflow:'ellipsis' }}>{c.sub}</div>
|
||||
</div>
|
||||
<DIcon name="external-link" size={16} color="var(--fg3)" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ display:'flex', gap:14 }}>
|
||||
<div style={{ ...dCard, flex:1, padding:'16px 18px' }}>
|
||||
<div style={{ fontSize:12.5, fontWeight:600, color:'var(--fg3)' }}>{t('contactHoursTitle')}</div>
|
||||
<div style={{ fontSize:15, fontWeight:600, color:'var(--fg1)', marginTop:6, fontFamily:'var(--font-mono)' }}>{t('contactHours')}</div>
|
||||
</div>
|
||||
<div style={{ ...dCard, flex:2, padding:'16px 18px', display:'flex', alignItems:'center', gap:14 }}>
|
||||
<div style={{ flex:1 }}>
|
||||
<div style={{ fontSize:12.5, fontWeight:600, color:'var(--fg3)' }}>{t('contactFaqTitle')}</div>
|
||||
<div style={{ fontSize:13, color:'var(--fg2)', marginTop:5, lineHeight:1.5 }}>{t('contactFaq')}</div>
|
||||
</div>
|
||||
<button style={{ border:'1.5px solid var(--border-strong)', background:'transparent', color:'var(--fg1)', fontSize:13, fontWeight:600, padding:'9px 15px', borderRadius:'var(--radius-full)', cursor:'pointer', whiteSpace:'nowrap', flexShrink:0 }}>{t('contactFaqBtn')}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Object.assign(window, { DAuthFlow, DOnboarding, DAccountView, DPlansView, DRedeemView, DContactView });
|
||||
@@ -0,0 +1,161 @@
|
||||
/* dparts.jsx — 穿山甲 VPN desktop · atoms (inline Lucide paths) */
|
||||
const DLUCIDE = {
|
||||
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"/>',
|
||||
'loader-circle': '<path d="M21 12a9 9 0 1 1-6.219-8.56"/>',
|
||||
'chart-no-axes-column': '<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"/>',
|
||||
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"/>',
|
||||
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"/>',
|
||||
'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"/>',
|
||||
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"/>',
|
||||
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"/>',
|
||||
'check-circle': '<path d="M21.801 10A10 10 0 1 1 17 3.335"/><path d="m9 11 3 3L22 4"/>',
|
||||
'arrow-left': '<path d="m12 19-7-7 7-7"/><path d="M19 12H5"/>',
|
||||
'chevron-right': '<path d="m9 18 6-6-6-6"/>',
|
||||
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"/>',
|
||||
'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"/>',
|
||||
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"/>',
|
||||
'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"/>',
|
||||
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"/>',
|
||||
'play-circle': '<circle cx="12" cy="12" r="10"/><polygon points="10 8 16 12 10 16 10 8"/>',
|
||||
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"/>',
|
||||
'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"/>',
|
||||
'credit-card': '<rect width="20" height="14" x="2" y="5" rx="2"/><line x1="2" x2="22" y1="10" y2="10"/>',
|
||||
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"/>',
|
||||
};
|
||||
function DIcon({ name, size = 20, 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: DLUCIDE[name] || '' }} />;
|
||||
}
|
||||
function DMark({ size = 28, 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>
|
||||
);
|
||||
}
|
||||
function DCC({ code, active, size = 34 }) {
|
||||
return <div style={{ width: size, height: size, borderRadius: 'var(--radius-md)', flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', fontFamily: 'var(--font-mono)', fontSize: size*0.31, fontWeight: 600, background: active ? 'var(--accent)' : 'var(--bg-subtle)', color: active ? '#fff' : 'var(--fg2)' }}>{code}</div>;
|
||||
}
|
||||
function DSignal({ ping }) {
|
||||
const lvl = ping < 40 ? 3 : ping < 90 ? 2 : 1;
|
||||
return <div style={{ display: 'flex', gap: 2, alignItems: 'flex-end', height: 13 }}>{[4,8,13].map((h,i)=><div key={i} style={{ width: 3, height: h, borderRadius: 1, background: i<lvl?'var(--success)':'var(--border-strong)' }} />)}</div>;
|
||||
}
|
||||
const DSTRINGS = {
|
||||
connect:{zh:'连接',en:'Connect'}, servers:{zh:'选择节点',en:'Choose server'}, stats:{zh:'统计',en:'Stats'}, settings:{zh:'设置',en:'Settings'},
|
||||
navConnect:{zh:'连接',en:'Connect'}, navServers:{zh:'节点',en:'Servers'}, navStats:{zh:'统计',en:'Stats'}, navSettings:{zh:'设置',en:'Settings'},
|
||||
proMember:{zh:'PRO 会员',en:'PRO member'}, until:{zh:'至 2026-12-31',en:'until 2026-12-31'},
|
||||
connected:{zh:'已连接',en:'Connected'}, notConnected:{zh:'未连接',en:'Not connected'},
|
||||
capOff:{zh:'未连接',en:'Not connected'}, subOff:{zh:'点击下方按钮开始保护',en:'Tap the button below to start'},
|
||||
capConnecting:{zh:'连接中…',en:'Connecting…'}, subConnecting:{zh:'正在建立加密隧道',en:'Establishing encrypted tunnel'},
|
||||
capOn:{zh:'已连接',en:'Connected'}, subOn:{zh:'你的网络已加密保护',en:'Your network is encrypted'},
|
||||
connectNow:{zh:'点击连接',en:'CONNECT'},
|
||||
search:{zh:'搜索国家 / 城市',en:'Search country / city'},
|
||||
trafficMonth:{zh:'本月流量',en:'Traffic'}, avgPing:{zh:'平均延迟',en:'Avg ping'}, durMonth:{zh:'本月时长',en:'Time'},
|
||||
weekTraffic:{zh:'本周流量 (GB)',en:'This week (GB)'},
|
||||
autostart:{zh:'开机自启动',en:'Launch at login'}, autostartSub:{zh:'登录系统时自动启动穿山甲',en:'Start Pangolin when you log in'},
|
||||
smartRoute:{zh:'智能分流',en:'Smart routing'}, smartRouteSub:{zh:'海外应用加速,本地直连',en:'Accelerate overseas apps, keep local direct'},
|
||||
killSwitch:{zh:'Kill Switch',en:'Kill Switch'}, killSwitchSub:{zh:'断线时阻断网络,防止 IP 泄露',en:'Block traffic if the VPN drops'},
|
||||
language:{zh:'语言',en:'Language'}, darkAppearance:{zh:'深色外观',en:'Dark appearance'}, stateOn:{zh:'已开启',en:'On'}, followLight:{zh:'跟随浅色',en:'Off'},
|
||||
protocol:{zh:'协议',en:'Protocol'}, version:{zh:'版本',en:'Version'},
|
||||
days:{zh:['周一','周二','周三','周四','周五','周六','周日'],en:['Mon','Tue','Wed','Thu','Fri','Sat','Sun']},
|
||||
/* 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'},
|
||||
authSidePitch:{zh:'一个账户,全平台同步。守护你的每一次连接。',en:'One account, every device. Protecting every connection.'},
|
||||
/* 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:'Redeem'},
|
||||
redeemOk:{zh:'激活成功 · PRO 已开通',en:'Activated · PRO unlocked'},
|
||||
buyTitle:{zh:'购买渠道',en:'Where to buy'},
|
||||
buySub:{zh:'客户端内不支持直接支付。请通过以下渠道获取激活码:',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'},
|
||||
smartSelect:{zh:'智能选择',en:'Smart select'}, smartSub:{zh:'根据当前网络环境,自动选择最优节点',en:'Picks the best node for your network'},
|
||||
recommended:{zh:'推荐',en:'Recommended'},
|
||||
twoFA:{zh:'双重认证',en:'Two-factor auth'}, twoFASub:{zh:'PRO 专享 · 登录需身份验证器动态码',en:'PRO only · TOTP code at login'},
|
||||
ucEntry:{zh:'Web 用户中心',en:'Web account center'}, ucEntrySub:{zh:'订阅导入 · 邀请返利',en:'Subscription import · referral'}, ucOpen:{zh:'打开',en:'Open'},
|
||||
/* quota + free (口径见 CLAUDE.md §7,与移动端一致) */
|
||||
quotaToday:{zh:'今日剩余',en:'Left today'}, minutes:{zh:'分钟',en:'min'},
|
||||
quotaFree:{zh:'免费版 · 每日 10 分钟',en:'Free · 10 min/day'},
|
||||
watchAd:{zh:'看广告开始使用',en:'Watch ad to start'},
|
||||
adUnlocked:{zh:'已解锁 · 今日可用',en:'Unlocked for today'},
|
||||
freePlanName:{zh:'免费版',en:'Free'},
|
||||
freeDemo:{zh:'演示:免费版视角',en:'Demo: free-tier view'}, freeDemoSub:{zh:'切换连接页的额度展示与套餐横幅',en:'Toggles quota UI & plan banner'},
|
||||
checkUpdate:{zh:'检查更新',en:'Check for updates'},
|
||||
replayOnboarding:{zh:'查看首次引导',en:'View onboarding'}, replayOnboardingSub:{zh:'演示用 · 重看 3 屏引导',en:'Demo · replay the 3 intro screens'},
|
||||
/* 2FA login step (与用户中心一致) */
|
||||
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'},
|
||||
chTgSub:{zh:'@PangolinVPN_bot',en:'@PangolinVPN_bot'}, chLineSub:{zh:'@pangolinvpn',en:'@pangolinvpn'},
|
||||
chEmail:{zh:'邮箱客服',en:'Email support'}, chEmailSub:{zh:'buy@pangolin.vpn',en:'buy@pangolin.vpn'},
|
||||
/* account management (integrated dashboard) */
|
||||
navAccount:{zh:'账户',en:'Account'},
|
||||
curPlan:{zh:'当前套餐',en:'Current plan'}, renew:{zh:'续费 / 升级',en:'Renew / Upgrade'},
|
||||
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 your 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']},
|
||||
/* account info */
|
||||
accInfoTitle:{zh:'账户信息',en:'Account info'}, accEmail:{zh:'邮箱',en:'Email'}, accPassword:{zh:'密码',en:'Password'}, accChange:{zh:'修改',en:'Change'},
|
||||
accUsage:{zh:'本月用量',en:'Used this month'}, accSignOut:{zh:'退出登录',en:'Sign out'},
|
||||
backToAccount:{zh:'返回账户',en:'Back to account'},
|
||||
/* demo replay */
|
||||
replayAuth:{zh:'查看登录 / 注册流程',en:'View login / sign-up'}, replayAuthSub:{zh:'演示用 · 退出到登录页',en:'Demo · back to login'},
|
||||
replayOnboarding:{zh:'查看首次引导',en:'View onboarding'}, replayOnboardingSub:{zh:'演示用 · 重看引导页',en:'Demo · replay intro'},
|
||||
/* 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:'Your system 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.'},
|
||||
/* contact us */
|
||||
navContact:{zh:'联系我们',en:'Contact us'},
|
||||
contactIntro:{zh:'遇到问题?通过以下任一渠道联系我们,通常数分钟内回复。',en:'Need help? Reach us through any channel below — we usually reply within minutes.'},
|
||||
contactTg:{zh:'Telegram 客服',en:'Telegram support'}, contactLine:{zh:'LINE 客服',en:'LINE support'},
|
||||
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)'},
|
||||
contactFaqTitle:{zh:'常见问题',en:'FAQ'}, contactFaq:{zh:'连不上 / 兑换码无效 / 多设备登录,先查阅帮助中心。',en:'Connection issues, invalid codes, multi-device — check the Help Center first.'},
|
||||
contactFaqBtn:{zh:'打开帮助中心',en:'Open Help Center'},
|
||||
};
|
||||
function dT(lang){ return k => { const v=DSTRINGS[k]; return v ? (v[lang]||k) : k; }; }
|
||||
function dName(s,lang){ return lang==='zh'?s.name:s.en; }
|
||||
function dSub(s,lang){ if(lang==='zh') return s.tag||s.en; return s.tag==='流媒体优化'?'Streaming':s.tag==='P2P'?'P2P':s.name; }
|
||||
Object.assign(window, { DIcon, DMark, DCC, DSignal, DSTRINGS, dT, dName, dSub, DSERVERS: [
|
||||
{ code:'HK', name:'香港 · 流媒体', en:'Hong Kong', ping:18, tag:'流媒体优化' },
|
||||
{ code:'JP', name:'日本 东京', en:'Tokyo', ping:32, tag:'P2P' },
|
||||
{ code:'TW', name:'台湾 台北', en:'Taipei', ping:28, tag:'' },
|
||||
{ code:'SG', name:'新加坡', en:'Singapore', ping:54, tag:'P2P' },
|
||||
{ code:'KR', name:'韩国 首尔', en:'Seoul', ping:41, tag:'' },
|
||||
{ code:'US', name:'美国 洛杉矶', en:'Los Angeles', ping:146, tag:'流媒体优化' },
|
||||
{ code:'DE', name:'德国 法兰克福', en:'Frankfurt', ping:198, tag:'' },
|
||||
{ code:'UK', name:'英国 伦敦', en:'London', ping:210, tag:'' },
|
||||
] });
|
||||
@@ -0,0 +1,31 @@
|
||||
<!-- @dsCard group="UI Kit — Desktop" name="UI Kit · 桌面客户端" subtitle="macOS 窗口 · 连接/节点/统计/设置 · 中英切换" viewport="980x640" -->
|
||||
<!doctype html>
|
||||
<html lang="zh">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>穿山甲 · 桌面客户端</title>
|
||||
<link rel="stylesheet" href="../../colors_and_type.css">
|
||||
<style>
|
||||
html, body { margin: 0; height: 100%; }
|
||||
body { background: var(--sand-200); display: flex; align-items: center; justify-content: center; min-height: 100vh; padding: 28px; box-sizing: border-box; font-family: var(--font-sans); }
|
||||
@keyframes pg-spin { to { transform: rotate(360deg); } }
|
||||
* { -webkit-font-smoothing: antialiased; }
|
||||
button { font-family: inherit; }
|
||||
::-webkit-scrollbar { width: 8px; } ::-webkit-scrollbar-thumb { background: var(--border-strong); border-radius: 4px; }
|
||||
</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="macos-window.jsx?v8"></script>
|
||||
<script type="text/babel" src="dparts.jsx?v8"></script>
|
||||
<script type="text/babel" src="dflows.jsx?v8"></script>
|
||||
<script type="text/babel" src="dapp.jsx?v8"></script>
|
||||
<script type="text/babel">
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(<DesktopApp />);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,198 @@
|
||||
|
||||
/* BEGIN USAGE */
|
||||
// MacOS.jsx — Simplified macOS Tahoe (Liquid Glass) window
|
||||
// Based on the macOS Tahoe UI Kit. No image assets, no dependencies.
|
||||
// Exports (to window): MacWindow, MacSidebar, MacSidebarItem, MacSidebarHeader, MacToolbar, MacGlass, MacTrafficLights
|
||||
//
|
||||
// Usage — wrap your app content in <MacWindow> to get the window chrome
|
||||
// (traffic lights + titlebar). Props: width, height, title, sidebar (pass a
|
||||
// <MacSidebar> element); compose MacToolbar/MacGlass inside as needed:
|
||||
//
|
||||
// <MacWindow width={980} height={620} title="Documents"
|
||||
// sidebar={<MacSidebar>…</MacSidebar>}>
|
||||
// ...your app content...
|
||||
// </MacWindow>
|
||||
/* END USAGE */
|
||||
|
||||
const MAC_FONT = '-apple-system, BlinkMacSystemFont, "SF Pro", "Helvetica Neue", sans-serif';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Liquid glass primitive — blur + white tint + inset highlight
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
function MacGlass({ children, radius = 296, dark = false, style = {} }) {
|
||||
return (
|
||||
<div style={{ position: 'relative', borderRadius: radius, ...style }}>
|
||||
<div style={{
|
||||
position: 'absolute', inset: 0, borderRadius: radius,
|
||||
background: dark ? 'rgba(255,255,255,0.08)' : 'rgba(255,255,255,0.35)',
|
||||
backdropFilter: 'blur(40px) saturate(180%)',
|
||||
WebkitBackdropFilter: 'blur(40px) saturate(180%)',
|
||||
border: dark ? '0.5px solid rgba(255,255,255,0.12)' : '0.5px solid rgba(255,255,255,0.6)',
|
||||
boxShadow: dark
|
||||
? '0 8px 40px rgba(0,0,0,0.2)'
|
||||
: '0 8px 40px rgba(0,0,0,0.08), inset 0 1px 0 rgba(255,255,255,0.4)',
|
||||
}} />
|
||||
<div style={{ position: 'relative', zIndex: 1 }}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Traffic lights (14px, Tahoe colors)
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
function MacTrafficLights({ style = {} }) {
|
||||
const dot = (bg) => (
|
||||
<div style={{
|
||||
width: 14, height: 14, borderRadius: '50%', background: bg,
|
||||
border: '0.5px solid rgba(0,0,0,0.1)',
|
||||
}} />
|
||||
);
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: 9, alignItems: 'center', padding: 1, ...style }}>
|
||||
{dot('#ff736a')}{dot('#febc2e')}{dot('#19c332')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Toolbar — title + single glass pill icon
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
function MacToolbar({ title = 'Folder' }) {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex', gap: 8, alignItems: 'center', padding: 8, flexShrink: 0,
|
||||
}}>
|
||||
{/* title */}
|
||||
<div style={{
|
||||
fontFamily: MAC_FONT, fontSize: 15, fontWeight: 700,
|
||||
color: 'rgba(0,0,0,0.85)', whiteSpace: 'nowrap', paddingLeft: 8,
|
||||
}}>{title}</div>
|
||||
<div style={{ flex: 1 }} />
|
||||
{/* single action */}
|
||||
<MacGlass>
|
||||
<div style={{
|
||||
width: 36, height: 36, display: 'flex',
|
||||
alignItems: 'center', justifyContent: 'center',
|
||||
}}>
|
||||
<div style={{ width: 14, height: 14, borderRadius: '50%', background: '#4c4c4c', opacity: 0.4 }} />
|
||||
</div>
|
||||
</MacGlass>
|
||||
{/* search */}
|
||||
<MacGlass>
|
||||
<div style={{
|
||||
width: 140, height: 36, display: 'flex', alignItems: 'center',
|
||||
gap: 6, padding: '0 12px',
|
||||
}}>
|
||||
<svg width="13" height="13" viewBox="0 0 13 13" fill="none">
|
||||
<circle cx="5.5" cy="5.5" r="4" stroke="#727272" strokeWidth="1.5"/>
|
||||
<path d="M8.5 8.5l3 3" stroke="#727272" strokeWidth="1.5" strokeLinecap="round"/>
|
||||
</svg>
|
||||
<span style={{
|
||||
fontFamily: MAC_FONT, fontSize: 13, fontWeight: 500, color: '#727272',
|
||||
}}>Search</span>
|
||||
</div>
|
||||
</MacGlass>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Sidebar — frosted glass panel floating inside the window
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
function MacSidebarItem({ label, selected = false }) {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6,
|
||||
height: 24, padding: '4px 10px 4px 6px', margin: '0 10px',
|
||||
borderRadius: 8, position: 'relative',
|
||||
fontFamily: MAC_FONT, fontSize: 11, fontWeight: 500,
|
||||
}}>
|
||||
{selected && (
|
||||
<div style={{
|
||||
position: 'absolute', inset: 0, borderRadius: 8,
|
||||
background: 'rgba(0,0,0,0.11)', mixBlendMode: 'multiply',
|
||||
}} />
|
||||
)}
|
||||
<div style={{
|
||||
width: 14, height: 14, borderRadius: '50%',
|
||||
background: selected ? '#007aff' : 'rgba(0,0,0,0.4)',
|
||||
opacity: selected ? 1 : 0.5, flexShrink: 0, position: 'relative',
|
||||
}} />
|
||||
<span style={{ color: 'rgba(0,0,0,0.85)', position: 'relative' }}>{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MacSidebar({ children }) {
|
||||
return (
|
||||
<div style={{
|
||||
width: 220, height: '100%', padding: 8, flexShrink: 0,
|
||||
position: 'relative', display: 'flex', flexDirection: 'column',
|
||||
}}>
|
||||
{/* glass panel */}
|
||||
<div style={{
|
||||
position: 'absolute', inset: 8, borderRadius: 18,
|
||||
background: 'rgba(210,225,245,0.45)',
|
||||
backdropFilter: 'blur(50px) saturate(200%)',
|
||||
WebkitBackdropFilter: 'blur(50px) saturate(200%)',
|
||||
border: '0.5px solid rgba(255,255,255,0.5)',
|
||||
boxShadow: '0 8px 40px rgba(0,0,0,0.10), inset 0 1px 0 rgba(255,255,255,0.35)',
|
||||
}} />
|
||||
{/* content */}
|
||||
<div style={{
|
||||
position: 'relative', zIndex: 1, padding: '10px 0',
|
||||
display: 'flex', flexDirection: 'column', gap: 2,
|
||||
}}>
|
||||
{/* window controls + sidebar toggle */}
|
||||
<div style={{
|
||||
height: 32, display: 'flex', alignItems: 'center',
|
||||
justifyContent: 'space-between', padding: '0 10px', marginBottom: 4,
|
||||
}}>
|
||||
<MacTrafficLights />
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MacSidebarHeader({ title }) {
|
||||
return (
|
||||
<div style={{
|
||||
padding: '14px 18px 5px',
|
||||
fontFamily: MAC_FONT, fontSize: 11, fontWeight: 700,
|
||||
color: 'rgba(0,0,0,0.5)',
|
||||
}}>{title}</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Window — r:26, big shadow, sidebar + toolbar + content
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
function MacWindow({
|
||||
width = 900, height = 600, title = 'Folder',
|
||||
sidebar, children,
|
||||
}) {
|
||||
return (
|
||||
<div style={{
|
||||
width, height, borderRadius: 26, overflow: 'hidden',
|
||||
background: '#fff',
|
||||
boxShadow: '0 0 0 1px rgba(0,0,0,0.23), 0 16px 48px rgba(0,0,0,0.35)',
|
||||
display: 'flex', position: 'relative',
|
||||
fontFamily: MAC_FONT,
|
||||
}}>
|
||||
<MacSidebar>{sidebar}</MacSidebar>
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
|
||||
<MacToolbar title={title} />
|
||||
<div style={{ flex: 1, overflow: 'auto', padding: '4px 8px' }}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Object.assign(window, {
|
||||
MacWindow, MacSidebar, MacSidebarItem, MacSidebarHeader,
|
||||
MacToolbar, MacGlass, MacTrafficLights,
|
||||
});
|
||||
Reference in New Issue
Block a user