feat(client): replace placeholder connection data with live status
This commit is contained in:
1 parent
00f6c876cf
commit
9f743e19ad
5 files changed
+86
-38
No files matched your search
+1
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
仓库根目录的 `client/` 是独立的 Rust + Tauri v2 + React/TypeScript 桌面项目。形态参考同级 `opsxshell`、`opsxapi`:界面运行在 Tauri WebView,服务请求经 Rust 命令完成。与仓库的 `frontend/` 管理后台独立构建。
|
||||
|
||||
目前仅实现对现有 `GET /api/health` 的真实检测。在“设置”中输入并保存服务端 HTTP 或 HTTPS 根地址:HTTP 可用于健康检测,但明文传输今后不应承载登录口令或设备令牌,推荐使用可信 HTTPS 域名。地址仅保存在本机前端存储中,不含账号。界面采用蓝白灰布局,线路弹窗中的节点和绿黄延时均为**演示数据**,选择仅改变页面展示。健康检查显示的耗时是控制面请求往返时间,不能当作 VPN 节点延时。没有普通用户设备 API 和系统隧道适配前,不提供登录或一键拨号。
|
||||
目前仅实现对现有 `GET /api/health` 的真实检测。在“设置”中输入服务端 HTTP 或 HTTPS 根地址,检测成功自动保存并显示已保存地址,也可直接保存:HTTP 可用于健康检测,但明文传输今后不应承载登录口令或设备令牌,推荐使用可信 HTTPS 域名。地址仅保存在本机前端存储中,不含账号。概览在打开时和每隔 30 秒检查一次已保存的管理服务地址;耗时是控制面请求往返时间,不能当作 VPN 节点延时。线路页不展示虚构节点或延时,等待独立的终端用户线路接口。圆形按钮会说明当前连接前提,不伪装成已连接。没有普通用户设备 API 和系统隧道适配前,不提供登录或一键拨号。
|
||||
|
||||
桌面版托盘显示“尚未连接”,提供打开窗口与退出。设置页通过 Rust 只读检查 `wg`、`wg-quick` 是否出现在本机 PATH;工具存在不代表驱动、权限、路由及实际隧道可用。浏览器界面预览不会调用本机检查。
|
||||
|
||||
|
||||
+63
-25
@@ -4,15 +4,7 @@ import './system.css'
|
||||
|
||||
type Page = 'overview' | 'routes' | 'settings'
|
||||
type CheckState = 'idle' | 'checking' | 'reachable' | 'error'
|
||||
const nodes = [
|
||||
{ name: '自动选择', region: '推荐线路', ms: 74 },
|
||||
{ name: '香港 · 中心节点', region: '亚太地区', ms: 75 },
|
||||
{ name: '新加坡 · 备用节点', region: '亚太地区', ms: 91 },
|
||||
{ name: '法兰克福 · 节点', region: '欧洲地区', ms: 313 },
|
||||
{ name: '洛杉矶 · 节点', region: '美洲地区', ms: 314 },
|
||||
]
|
||||
|
||||
function Icon({ name, size = 22 }: { name: 'home' | 'globe' | 'settings' | 'power' | 'shield' | 'server' | 'check' | 'close' | 'chevron'; size?: number }) {
|
||||
function Icon({ name, size = 22 }: { name: 'home' | 'globe' | 'settings' | 'power' | 'shield' | 'server' | 'check' | 'close'; size?: number }) {
|
||||
const paths = {
|
||||
home: <path d="m3 10 9-7 9 7v10a1 1 0 0 1-1 1h-5v-7H9v7H4a1 1 0 0 1-1-1z" />,
|
||||
globe: <><circle cx="12" cy="12" r="9" /><path d="M3 12h18M12 3c-5 5-5 13 0 18M12 3c5 5 5 13 0 18" /></>,
|
||||
@@ -22,22 +14,45 @@ function Icon({ name, size = 22 }: { name: 'home' | 'globe' | 'settings' | 'powe
|
||||
server: <><rect x="3" y="3" width="18" height="7" rx="2" /><rect x="3" y="14" width="18" height="7" rx="2" /><path d="M7 6.5h.01M7 17.5h.01" /></>,
|
||||
check: <path d="m5 12 5 5 9-10" />,
|
||||
close: <path d="M5 5 19 19M19 5 5 19" />,
|
||||
chevron: <path d="m7 10 5 5 5-5" />,
|
||||
}
|
||||
return <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">{paths[name]}</svg>
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const [page, setPage] = useState<Page>('overview')
|
||||
const [dialog, setDialog] = useState(false)
|
||||
const [selected, setSelected] = useState(0)
|
||||
const [address, setAddress] = useState(() => localStorage.getItem('opsx_server_address') || '')
|
||||
const [savedAddress, setSavedAddress] = useState(() => localStorage.getItem('opsx_server_address') || '')
|
||||
const [state, setState] = useState<CheckState>('idle')
|
||||
const [error, setError] = useState('')
|
||||
const [checkedAddress, setCheckedAddress] = useState('')
|
||||
const [responseMs, setResponseMs] = useState<number | null>(null)
|
||||
const [readiness, setReadiness] = useState<SystemReadiness | null>(null)
|
||||
const [readinessError, setReadinessError] = useState('')
|
||||
const [connectionGuide, setConnectionGuide] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (page !== 'overview' || !savedAddress) return
|
||||
let active = true
|
||||
async function refresh() {
|
||||
const start = performance.now()
|
||||
setState('checking')
|
||||
try {
|
||||
const health = await checkServer(savedAddress)
|
||||
if (!active) return
|
||||
setCheckedAddress(health.address)
|
||||
setResponseMs(Math.round(performance.now() - start))
|
||||
setState(health.reachable ? 'reachable' : 'error')
|
||||
} catch (cause) {
|
||||
if (!active) return
|
||||
setState('error')
|
||||
setError(cause instanceof Error ? cause.message : String(cause))
|
||||
setResponseMs(null)
|
||||
}
|
||||
}
|
||||
void refresh()
|
||||
const timer = window.setInterval(refresh, 30_000)
|
||||
return () => { active = false; window.clearInterval(timer) }
|
||||
}, [page, savedAddress])
|
||||
|
||||
useEffect(() => {
|
||||
if (page !== 'settings') return
|
||||
@@ -60,10 +75,14 @@ export default function App() {
|
||||
const health = await checkServer(address)
|
||||
setCheckedAddress(health.address)
|
||||
setResponseMs(Math.round(performance.now() - start))
|
||||
setState(health.reachable ? 'reachable' : 'error')
|
||||
if (!health.reachable) throw new Error('服务端未就绪')
|
||||
localStorage.setItem('opsx_server_address', health.address)
|
||||
setAddress(health.address)
|
||||
setSavedAddress(health.address)
|
||||
setState('reachable')
|
||||
} catch (cause) {
|
||||
setState('error')
|
||||
setError(String(cause))
|
||||
setError(cause instanceof Error ? cause.message : String(cause))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,13 +95,31 @@ export default function App() {
|
||||
const root = url.href.replace(/\/$/, '')
|
||||
localStorage.setItem('opsx_server_address', root)
|
||||
setAddress(root)
|
||||
setSavedAddress(root)
|
||||
setError('')
|
||||
setState('idle')
|
||||
} catch (cause) {
|
||||
setError(cause instanceof TypeError ? '请输入完整的 HTTP 或 HTTPS 服务端地址。' : String(cause))
|
||||
setState('error')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleConnect() {
|
||||
if (!savedAddress) {
|
||||
setConnectionGuide('请先到“设置”检测并保存服务端地址。')
|
||||
return
|
||||
}
|
||||
try {
|
||||
const result = await getSystemReadiness()
|
||||
setReadiness(result)
|
||||
setConnectionGuide(result.wg_found && result.wg_quick_found
|
||||
? '本机已发现 WireGuard 工具。普通用户设备授权和自动下发尚未接入服务端,因此当前不能安全地直接拨号。'
|
||||
: '本机尚未发现完整的 WireGuard 工具;普通用户设备授权和自动下发也尚未接入。当前不能直接拨号。')
|
||||
} catch {
|
||||
setConnectionGuide('当前是浏览器预览,不能修改本机网络。自动连接需要桌面客户端的原生隧道和设备授权接口。')
|
||||
}
|
||||
}
|
||||
|
||||
return <div className="app-shell">
|
||||
<aside className="sidebar" aria-label="主导航">
|
||||
<div className="brand-symbol" aria-label="OpsX VPN"><span /><i /></div>
|
||||
@@ -93,28 +130,29 @@ export default function App() {
|
||||
<div className="sidebar-bottom">OX <small>/ VPN</small></div>
|
||||
</aside>
|
||||
<div className="workspace">
|
||||
<header className="topbar"><div className="topbar-brand"><span className="brand-dot" />OpsX <strong>VPN</strong><span className="divider" />桌面客户端</div><span className="preview-pill">● 界面预览 · 0.1</span></header>
|
||||
<header className="topbar"><div className="topbar-brand"><span className="brand-dot" />OpsX <strong>VPN</strong><span className="divider" />桌面客户端</div><span className="preview-pill">● 客户端 0.1</span></header>
|
||||
{page === 'overview' && <main className="page">
|
||||
<div className="section-intro"><div><span className="overline">WORKSPACE / OVERVIEW</span><h1>连接概览</h1><p>从这里查看接入状态,准备好后即可连接企业网络。</p></div><span className="status-tag">● 尚未连接</span></div>
|
||||
<div className="section-intro"><div><span className="overline">WORKSPACE / OVERVIEW</span><h1>连接概览</h1><p>查看本客户端的服务检测与接入状态。</p></div><span className="status-tag">● 本客户端未连接</span></div>
|
||||
<div className="overview-grid">
|
||||
<section className="connection-card" aria-label="VPN 连接状态">
|
||||
<div className="card-top"><div><span className="kicker">安全连接</span><h2>企业网络</h2></div><span className="protocol"><Icon name="shield" size={15} /> WIREGUARD</span></div>
|
||||
<div className="connect-visual"><div className="orbit outer" /><div className="orbit inner" /><div className="power-display" aria-label="VPN 拨号待接入"><Icon name="power" size={42} /></div></div>
|
||||
<div className="connection-message"><strong>尚未连接</strong><span>登录与设备授权接口接入后,即可启用 VPN 拨号</span></div>
|
||||
<button className="node-selector" onClick={() => setDialog(true)}><span className="node-icon"><Icon name="globe" size={19} /></span><span className="node-selector-copy"><small>当前预览线路</small><strong>{nodes[selected].name}</strong></span><span className="demo-chip">演示数据</span><Icon name="chevron" size={18} /></button>
|
||||
<div className="connect-visual"><div className="orbit outer" /><div className="orbit inner" /><button type="button" className="power-display" aria-label="查看 VPN 连接准备步骤" onClick={handleConnect}><Icon name="power" size={42} /></button></div>
|
||||
<div className="connection-message"><strong>本客户端未连接</strong><span>尚未配置自动设备授权和本机隧道</span></div>{connectionGuide && <div className="connection-guide" role="status">{connectionGuide}<button type="button" onClick={() => setPage('settings')}>查看设置 →</button></div>}
|
||||
<div className="node-selector"><span className="node-icon"><Icon name="globe" size={19} /></span><span className="node-selector-copy"><small>已授权 VPN 线路</small><strong>尚未获取</strong></span></div>
|
||||
</section>
|
||||
<div className="right-column">
|
||||
<section className="info-card"><div className="info-heading"><span className="icon-tile"><Icon name="server" size={20} /></span><div><h2>服务端状态</h2><p>管理服务可达性</p></div></div><p className="server-address">{checkedAddress || address || '尚未设置服务端地址'}</p><span className={`service-state ${state}`}>{state === 'reachable' ? `服务可达 · 控制面往返约 ${responseMs} ms` : state === 'error' ? '检测失败' : state === 'checking' ? '检测中…' : '尚未检测'}</span><p className="hint">控制面检测不代表 VPN 已连接。</p><button className="secondary-action" onClick={() => setPage('settings')}>在设置中配置 →</button></section>
|
||||
<section className="info-card"><div className="info-heading"><span className="icon-tile"><Icon name="server" size={20} /></span><div><h2>服务端状态</h2><p>管理服务可达性</p></div></div><p className="server-address">{savedAddress || '尚未设置服务端地址'}</p><span className={`service-state ${state}`}>{state === 'reachable' ? `服务可达 · 控制面往返约 ${responseMs} ms` : state === 'error' ? '检测失败' : state === 'checking' ? '检测中…' : '尚未检测'}</span><p className="hint">控制面检测不代表 VPN 已连接;概览每 30 秒更新一次。</p><button className="secondary-action" onClick={() => setPage('settings')}>在设置中配置 →</button></section>
|
||||
<section className="info-card progress-card"><div className="info-heading"><span className="icon-tile green"><Icon name="shield" size={20} /></span><div><h2>接入进度</h2><p>桌面端能力逐步开放</p></div></div>
|
||||
{([['✓', '服务可达性检测', '已接入现有健康接口', '已就绪'], ['2', '用户登录与设备授权', '等待客户端专用接口', '待接入'], ['3', '原生 WireGuard 连接', '等待多平台隧道适配', '待接入']] as const).map(([num, title, detail, status], index) => <div className="milestone" key={num}><span className={`milestone-mark ${index === 0 ? 'done' : ''}`}>{num}</span><span><strong>{title}</strong><small>{detail}</small></span><em>{status}</em></div>)}
|
||||
<div className="milestone"><span className={`milestone-mark ${state === 'reachable' ? 'done' : ''}`}>{state === 'reachable' ? '✓' : '1'}</span><span><strong>服务可达性</strong><small>{state === 'reachable' ? checkedAddress : '尚未完成健康检查'}</small></span><em>{state === 'reachable' ? '可达' : '未确认'}</em></div>
|
||||
<div className="milestone"><span className="milestone-mark">2</span><span><strong>用户与设备授权</strong><small>服务器尚无客户端专用接口</small></span><em>未接入</em></div>
|
||||
<div className="milestone"><span className="milestone-mark">3</span><span><strong>WireGuard 隧道</strong><small>未创建本机隧道或验证握手</small></span><em>未连接</em></div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
<p className="footnote">预览线路与延时仅展示交互样式,不代表实际节点;当前没有建立 VPN 隧道。</p>
|
||||
<p className="footnote">管理服务健康检查仅反映控制面状态;本客户端尚未建立 VPN 隧道。</p>
|
||||
</main>}
|
||||
{page === 'routes' && <main className="page subpage"><span className="overline">WORKSPACE / ROUTES</span><h1>线路</h1><p className="subpage-intro">了解线路选择界面。真实节点列表和延时需要服务端开放客户端接口。</p><section className="info-card route-preview"><span className="icon-tile large"><Icon name="globe" size={29} /></span><div><span className="demo-chip">演示数据</span><h2>{nodes[selected].name}</h2><p>当前选择只用于界面预览,不会建立 VPN 连接。</p></div><button className="secondary-action" onClick={() => setDialog(true)}>查看预览线路 →</button></section></main>}
|
||||
{page === 'settings' && <main className="page subpage"><span className="overline">WORKSPACE / SETTINGS</span><h1>设置</h1><p className="subpage-intro">管理服务端入口并查看本机环境。这里不会保存账号或修改本机网络。</p><section className="info-card"><div className="info-heading"><span className="icon-tile"><Icon name="server" size={20} /></span><div><h2>服务端地址</h2><p>保存后下次打开客户端仍可使用</p></div></div><form onSubmit={handleCheck}><label htmlFor="server-url">HTTP / HTTPS 根地址</label><div className="input-row"><input id="server-url" type="url" required placeholder="https://test-link.appx365.com 或 http://121.4.111.208:18080" value={address} onChange={event => { setAddress(event.target.value); setState('idle'); setError(''); setResponseMs(null) }} /><button type="submit" disabled={state === 'checking'}>{state === 'checking' ? '检测中…' : '检测服务'}</button><button className="save-button" type="button" onClick={saveAddress}>保存地址</button></div></form>{address.trim().toLowerCase().startsWith('http://') && <p className="http-warning">HTTP 是明文传输;健康检查可用,但今后登录和设备授权建议改用 HTTPS。</p>}<p className="hint">只填写服务端根地址;健康检查会请求 /api/health。</p>{state === 'reachable' && <div className="result success" role="status"><Icon name="check" size={17} /><span>服务可达 · 控制面往返约 {responseMs} ms<br /><small>{checkedAddress}。这不是 VPN 节点延时或已连接状态。</small></span></div>}{state === 'error' && <div className="result failure" role="alert"><Icon name="close" size={17} /><span>{error || '服务端没有返回有效的健康响应。'}</span></div>}</section><section className="info-card route-preview system-card"><span className="icon-tile large"><Icon name="shield" size={27} /></span><div><h2>安全接入</h2><p>用户认证、设备授权与系统隧道设置将在服务端接口具备后开放。</p></div><span className="status-tag">待接入</span></section><section className="info-card system-card"><div className="info-heading"><span className="icon-tile"><Icon name="server" size={20} /></span><div><h2>本机环境</h2><p>只读检查,不代表已经具备 VPN 拨号能力</p></div></div>{readiness ? <div className="system-grid"><span>操作系统</span><strong>{readiness.platform}</strong><span>wg 命令</span><strong>{readiness.wg_found ? 'PATH 中已发现' : 'PATH 中未发现'}</strong><span>wg-quick 命令</span><strong>{readiness.wg_quick_found ? 'PATH 中已发现' : 'PATH 中未发现'}</strong></div> : <p className="hint" role="status">{readinessError || '正在检测本机环境…'}</p>}<p className="hint">仅检查文件是否存在;没有检查执行权限、驱动、系统授权或 WireGuard 隧道状态。</p></section></main>}
|
||||
{page === 'routes' && <main className="page subpage"><span className="overline">WORKSPACE / ROUTES</span><h1>线路</h1><p className="subpage-intro">这里只显示经当前账户授权、由服务端提供的真实线路。</p><section className="info-card route-preview"><span className="icon-tile large"><Icon name="globe" size={29} /></span><div><h2>暂时无法获取线路</h2><p>服务端尚未提供终端用户身份和设备授权接口,因此无法查询线路或测量真实节点延时。</p></div><button className="secondary-action" onClick={() => setPage('settings')}>检查服务端 →</button></section></main>}
|
||||
{page === 'settings' && <main className="page subpage"><span className="overline">WORKSPACE / SETTINGS</span><h1>设置</h1><p className="subpage-intro">管理服务端入口并查看本机环境。这里不会保存账号或修改本机网络。</p><section className="info-card"><div className="info-heading"><span className="icon-tile"><Icon name="server" size={20} /></span><div><h2>服务端地址</h2><p>检测成功后自动保存,下次打开仍可使用</p></div></div><form onSubmit={handleCheck}><label htmlFor="server-url">HTTP / HTTPS 根地址</label><div className="input-row"><input id="server-url" type="url" required placeholder="https://test-link.appx365.com 或 http://121.4.111.208:18080" value={address} onChange={event => { setAddress(event.target.value); setState('idle'); setError(''); setResponseMs(null) }} /><button type="submit" disabled={state === 'checking'}>{state === 'checking' ? '检测中…' : '检测服务'}</button><button className="save-button" type="button" onClick={saveAddress}>保存地址</button></div></form>{savedAddress && <p className="saved-address" role="status">✓ 已保存:{savedAddress}</p>}{address.trim().toLowerCase().startsWith('http://') && <p className="http-warning">HTTP 是明文传输;健康检查可用,但今后登录和设备授权建议改用 HTTPS。</p>}<p className="hint">只填写服务端根地址;检测成功会自动保存,也可直接点击“保存地址”。</p>{state === 'reachable' && <div className="result success" role="status"><Icon name="check" size={17} /><span>服务可达 · 控制面往返约 {responseMs} ms<br /><small>{checkedAddress}。这不是 VPN 节点延时或已连接状态。</small></span></div>}{state === 'error' && <div className="result failure" role="alert"><Icon name="close" size={17} /><span>{error || '服务端没有返回有效的健康响应。'}</span></div>}</section><section className="info-card route-preview system-card"><span className="icon-tile large"><Icon name="shield" size={27} /></span><div><h2>安全接入</h2><p>用户认证、设备授权与系统隧道设置将在服务端接口具备后开放。</p></div><span className="status-tag">待接入</span></section><section className="info-card system-card"><div className="info-heading"><span className="icon-tile"><Icon name="server" size={20} /></span><div><h2>本机环境</h2><p>只读检查,不代表已经具备 VPN 拨号能力</p></div></div>{readiness ? <div className="system-grid"><span>操作系统</span><strong>{readiness.platform}</strong><span>wg 命令</span><strong>{readiness.wg_found ? 'PATH 中已发现' : 'PATH 中未发现'}</strong><span>wg-quick 命令</span><strong>{readiness.wg_quick_found ? 'PATH 中已发现' : 'PATH 中未发现'}</strong></div> : <p className="hint" role="status">{readinessError || '正在检测本机环境…'}</p>}<p className="hint">仅检查文件是否存在;没有检查执行权限、驱动、系统授权或 WireGuard 隧道状态。</p></section></main>}
|
||||
</div>
|
||||
{dialog && <div className="modal-backdrop" onMouseDown={event => { if (event.target === event.currentTarget) setDialog(false) }}><section className="node-dialog" role="dialog" aria-modal="true" aria-labelledby="dialog-title" onKeyDown={event => { if (event.key === 'Escape') setDialog(false) }}><div className="dialog-header"><div><span className="overline">ROUTE PREVIEW</span><h2 id="dialog-title">选择服务器节点</h2><p>线路名称与延时均为演示数据,尚未接入真实节点。</p></div><button className="icon-button" onClick={() => setDialog(false)} aria-label="关闭节点选择"><Icon name="close" size={20} /></button></div><div className="dialog-labels"><span>预览线路</span><span>示例延时</span></div><div className="node-list">{nodes.map((node, index) => <button className={`node-row ${selected === index ? 'selected' : ''}`} key={node.name} onClick={() => setSelected(index)}><span className="radio-mark">{selected === index ? '✓' : ''}</span><span className="node-icon"><Icon name={index === 0 ? 'globe' : 'server'} size={18} /></span><span className="node-name"><strong>{node.name}</strong><small>{node.region}</small></span><span className={`latency ${node.ms < 150 ? 'fast' : 'slow'}`}><span className="signal-bars"><i /><i /><i /></span>{node.ms} ms</span></button>)}</div><div className="dialog-footer"><span>仅预览界面,不会发起连接</span><button className="primary-action" onClick={() => setDialog(false)}>完成选择</button></div></section></div>}
|
||||
</div>
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
:root{font-family:Inter,-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif;color:#192b45;background:#f5f7fb;font-synthesis:none}*{box-sizing:border-box}body{margin:0;min-width:680px}button,input{font:inherit}button{cursor:pointer}button:focus-visible,input:focus-visible{outline:3px solid #a6c6ff;outline-offset:2px}.app-shell{display:flex;min-height:100vh}.sidebar{width:92px;flex:none;display:flex;flex-direction:column;align-items:center;background:linear-gradient(172deg,#3476f7,#245cdd 76%,#2859d0);color:#fff}.brand-symbol{width:46px;height:46px;margin:27px 0 50px;position:relative}.brand-symbol span{position:absolute;inset:5px;border:4px solid #fff;border-radius:12px;transform:rotate(-32deg)}.brand-symbol i{position:absolute;inset:15px;border-radius:50%;background:#fff}.navigation{width:100%;display:grid;gap:8px}.nav-button{width:100%;height:78px;display:flex;align-items:center;justify-content:center;flex-direction:column;gap:6px;border:0;background:transparent;color:#d6e5ff;font-size:12px}.nav-button:hover,.nav-button.active{background:#174bbd;color:#fff}.nav-button.active{box-shadow:inset 3px 0 0 #fff}.sidebar-bottom{margin:auto 0 28px;font-weight:800;font-size:15px;letter-spacing:-.06em}.sidebar-bottom small{font-size:9px;font-weight:500;opacity:.7}.workspace{flex:1;min-width:0}.topbar{height:70px;background:#fff;border-bottom:1px solid #ecf0f6;display:flex;align-items:center;justify-content:space-between;padding:0 43px}.topbar-brand{display:flex;align-items:center;gap:5px;font-size:15px;font-weight:700;color:#516077;white-space:nowrap}.topbar-brand strong{color:#326ef3}.brand-dot{width:10px;height:10px;background:#3575f5;border-radius:3px;transform:rotate(-30deg);margin-right:7px}.divider{height:16px;width:1px;background:#d9e0ec;margin:0 10px}.preview-pill{border:1px solid #dae7ff;border-radius:100px;background:#f3f7ff;color:#4268ad;font-size:11px;padding:7px 11px}.page{max-width:1160px;margin:0 auto;padding:38px 44px 50px}.section-intro{display:flex;align-items:center;justify-content:space-between;gap:20px;margin-bottom:29px}.overline{color:#6483bc;letter-spacing:.18em;font-size:10px;font-weight:800}h1{font-size:29px;letter-spacing:-.04em;margin:9px 0 8px}h2{margin:0;font-size:17px;letter-spacing:-.02em}.section-intro p,.subpage-intro{color:#8b98aa;font-size:13px;margin:0}.status-tag{padding:9px 14px;border:1px solid #e4e9f1;background:white;border-radius:100px;color:#6c7c94;font-size:12px;white-space:nowrap}.overview-grid{display:grid;grid-template-columns:minmax(350px,1.06fr) minmax(340px,.94fr);gap:20px}.connection-card,.info-card{background:white;border:1px solid #e8edf5;border-radius:18px;box-shadow:0 8px 32px rgba(29,72,137,.035)}.connection-card{padding:26px 27px 22px;min-height:494px;display:flex;flex-direction:column;overflow:hidden}.card-top{display:flex;justify-content:space-between;align-items:start;gap:12px}.kicker{font-size:11px;color:#7e90ac}.card-top h2{font-size:20px;margin-top:6px}.protocol{display:flex;align-items:center;gap:5px;font-size:10px;font-weight:750;color:#4a7de8;background:#f2f6ff;padding:8px;border-radius:7px;letter-spacing:.04em}.connect-visual{position:relative;width:218px;height:218px;margin:23px auto 0;display:grid;place-items:center}.orbit{position:absolute;border-radius:50%;border:1px solid #e8f0ff}.orbit.outer{inset:0;background:repeating-conic-gradient(from -2deg,#d5e4ff 0 1deg,transparent 1deg 5deg);mask:radial-gradient(transparent 66%,black 67%,black 69%,transparent 70%);-webkit-mask:radial-gradient(transparent 66%,black 67%,black 69%,transparent 70%)}.orbit.inner{inset:19px;border:1px solid #e2ecff}.power-display{display:grid;place-items:center;width:136px;height:136px;border-radius:50%;background:linear-gradient(145deg,#5090ff,#2864ea);color:white;box-shadow:0 16px 30px #397af149,inset 0 1px 2px #ffffff66}.connection-message{text-align:center;display:grid;gap:7px;margin:0 0 25px}.connection-message strong{font-size:16px}.connection-message span{color:#8898ae;font-size:12px}.node-selector{display:flex;align-items:center;width:100%;gap:11px;text-align:left;border:1px solid #e6edf8;background:#fafcff;padding:11px 12px;border-radius:11px;color:#536785;margin-top:auto}.node-selector:hover{border-color:#92b5fc;background:#f5f9ff}.node-icon{width:35px;height:35px;border-radius:10px;background:#ecf3ff;color:#3577f4;display:grid;place-items:center;flex:none}.node-selector-copy{display:grid;gap:3px;flex:1}.node-selector-copy small{color:#93a1b4;font-size:10px}.node-selector-copy strong{font-size:12px;color:#304461}.demo-chip{font-size:10px;color:#6383b7;background:#eaf2ff;padding:5px 7px;border-radius:5px;white-space:nowrap}.right-column{display:grid;gap:18px;grid-template-rows:auto 1fr}.info-card{padding:24px}.info-heading{display:flex;align-items:center;gap:12px;margin-bottom:25px}.info-heading p,.route-preview p{font-size:11px;color:#98a5b7;margin:5px 0 0}.icon-tile{width:39px;height:39px;border-radius:11px;display:grid;place-iteLine truncated
|
||||
:root{font-family:Inter,-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif;color:#192b45;background:#f5f7fb;font-synthesis:none}*{box-sizing:border-box}body{margin:0;min-width:680px}button,input{font:inherit}button{cursor:pointer}button:focus-visible,input:focus-visible{outline:3px solid #a6c6ff;outline-offset:2px}.app-shell{display:flex;min-height:100vh}.sidebar{width:92px;flex:none;display:flex;flex-direction:column;align-items:center;background:linear-gradient(172deg,#3476f7,#245cdd 76%,#2859d0);color:#fff}.brand-symbol{width:46px;height:46px;margin:27px 0 50px;position:relative}.brand-symbol span{position:absolute;inset:5px;border:4px solid #fff;border-radius:12px;transform:rotate(-32deg)}.brand-symbol i{position:absolute;inset:15px;border-radius:50%;background:#fff}.navigation{width:100%;display:grid;gap:8px}.nav-button{width:100%;height:78px;display:flex;align-items:center;justify-content:center;flex-direction:column;gap:6px;border:0;background:transparent;color:#d6e5ff;font-size:12px}.nav-button:hover,.nav-button.active{background:#174bbd;color:#fff}.nav-button.active{box-shadow:inset 3px 0 0 #fff}.sidebar-bottom{margin:auto 0 28px;font-weight:800;font-size:15px;letter-spacing:-.06em}.sidebar-bottom small{font-size:9px;font-weight:500;opacity:.7}.workspace{flex:1;min-width:0}.topbar{height:70px;background:#fff;border-bottom:1px solid #ecf0f6;display:flex;align-items:center;justify-content:space-between;padding:0 43px}.topbar-brand{display:flex;align-items:center;gap:5px;font-size:15px;font-weight:700;color:#516077;white-space:nowrap}.topbar-brand strong{color:#326ef3}.brand-dot{width:10px;height:10px;background:#3575f5;border-radius:3px;transform:rotate(-30deg);margin-right:7px}.divider{height:16px;width:1px;background:#d9e0ec;margin:0 10px}.preview-pill{border:1px solid #dae7ff;border-radius:100px;background:#f3f7ff;color:#4268ad;font-size:11px;padding:7px 11px}.page{max-width:1160px;margin:0 auto;padding:38px 44px 50px}.section-intro{display:flex;align-items:center;justify-content:space-between;gap:20px;margin-bottom:29px}.overline{color:#6483bc;letter-spacing:.18em;font-size:10px;font-weight:800}h1{font-size:29px;letter-spacing:-.04em;margin:9px 0 8px}h2{margin:0;font-size:17px;letter-spacing:-.02em}.section-intro p,.subpage-intro{color:#8b98aa;font-size:13px;margin:0}.status-tag{padding:9px 14px;border:1px solid #e4e9f1;background:white;border-radius:100px;color:#6c7c94;font-size:12px;white-space:nowrap}.overview-grid{display:grid;grid-template-columns:minmax(350px,1.06fr) minmax(340px,.94fr);gap:20px}.connection-card,.info-card{background:white;border:1px solid #e8edf5;border-radius:18px;box-shadow:0 8px 32px rgba(29,72,137,.035)}.connection-card{padding:26px 27px 22px;min-height:494px;display:flex;flex-direction:column;overflow:hidden}.card-top{display:flex;justify-content:space-between;align-items:start;gap:12px}.kicker{font-size:11px;color:#7e90ac}.card-top h2{font-size:20px;margin-top:6px}.protocol{display:flex;align-items:center;gap:5px;font-size:10px;font-weight:750;color:#4a7de8;background:#f2f6ff;padding:8px;border-radius:7px;letter-spacing:.04em}.connect-visual{position:relative;width:218px;height:218px;margin:23px auto 0;display:grid;place-items:center}.orbit{position:absolute;border-radius:50%;border:1px solid #e8f0ff}.orbit.outer{inset:0;background:repeating-conic-gradient(from -2deg,#d5e4ff 0 1deg,transparent 1deg 5deg);mask:radial-gradient(transparent 66%,black 67%,black 69%,transparent 70%);-webkit-mask:radial-gradient(transparent 66%,black 67%,black 69%,transparent 70%)}.orbit.inner{inset:19px;border:1px solid #e2ecff}.power-display{display:grid;place-items:center;width:136px;height:136px;border-radius:50%;background:linear-gradient(145deg,#5090ff,#2864ea);color:white;box-shadow:0 16px 30px #397af149,inset 0 1px 2px #ffffff66}.connection-message{text-align:center;display:grid;gap:7px;margin:0 0 25px}.connection-message strong{font-size:16px}.connection-message span{color:#8898ae;font-size:12px}.node-selector{display:flex;align-items:center;width:100%;gap:11px;text-align:left;border:1px solid #e6edf8;background:#fafcff;padding:11px 12px;border-radius:11px;color:#536785;margin-top:auto}.node-icon{width:35px;height:35px;border-radius:10px;background:#ecf3ff;color:#3577f4;display:grid;place-items:center;flex:none}.node-selector-copy{display:grid;gap:3px;flex:1}.node-selector-copy small{color:#93a1b4;font-size:10px}.node-selector-copy strong{font-size:12px;color:#304461}.right-column{display:grid;gap:18px;grid-template-rows:auto 1fr}.info-card{padding:24px}.info-heading{display:flex;align-items:center;gap:12px;margin-bottom:25px}.info-heading p,.route-preview p{font-size:11px;color:#98a5b7;margin:5px 0 0}.icon-tile{width:39px;height:39px;border-radius:11px;display:grid;place-items:center;flex:none;background:#ecf3ff;color:#3976ef}.icon-tile.green{background:#eef8f4;color:#29a77a}.info-card label{display:block;font-size:11px;font-weight:700;color:#5Line truncated
|
||||
@@ -11,3 +11,9 @@
|
||||
.system-card .input-row .save-button:hover { background: #dbe9ff; }
|
||||
.input-row .save-button { background: #edf4ff; color: #3476f5; }
|
||||
.http-warning { background: #fff7e6; color: #a0691b; border: 1px solid #fae5be; border-radius: 8px; padding: 10px; font-size: 12px; line-height: 1.6; }
|
||||
.saved-address { color: #168861; font-size: 12px; margin: 9px 0 0; overflow-wrap: anywhere; }
|
||||
.power-display { border: 0; cursor: pointer; position: relative; z-index: 1; }
|
||||
.power-display:hover { filter: brightness(1.08); transform: translateY(-2px); }
|
||||
.power-display:focus-visible { outline: 3px solid #144db6; outline-offset: 4px; }
|
||||
.connection-guide { margin: -11px 0 20px; padding: 11px 13px; border: 1px solid #dae7ff; border-radius: 9px; background: #f3f7ff; color: #43618f; font-size: 12px; line-height: 1.6; }
|
||||
.connection-guide button { display: block; padding: 3px 0 0; background: none; border: 0; color: #306eee; font-weight: 600; }
|
||||
@@ -4,7 +4,7 @@
|
||||
|---|---|
|
||||
| 版本 / 变更级别 | 0.1 / S3(未来涉及设备凭据、系统路由和隧道权限) |
|
||||
| 设计状态 | Draft |
|
||||
| 实现状态 | 客户端骨架、只读本机工具检测及托盘入口;拨号功能未实现 |
|
||||
| 实现状态 | 客户端骨架、健康检查后自动保存地址、概览实时检测、无模拟线路、连接按钮诊断提示、只读本机工具检测及托盘入口;自动下发与拨号功能未实现 |
|
||||
| 负责人 / 日期 | 待指定 / 2026-10-21 |
|
||||
| 关联需求 | 仓库根目录新增 `client/`,Rust + Tauri v2 桌面客户端,优先对接 WireGuard |
|
||||
| 批准状态 | 用户要求评估并新增客户端;后续接口契约尚待确认 |
|
||||
@@ -18,13 +18,14 @@
|
||||
|
||||
## 2. 需求与约束
|
||||
|
||||
- FR-01:桌面端可输入控制面 HTTPS 地址,使用 Rust 命令调用现有 `/api/health` 并显示真实结果;仅允许明确的本机 HTTP 开发地址。
|
||||
- FR-02:界面将“服务已可达”与“用户已登录/隧道已连接”分开显示;尚无客户端 API 时连接控制不可触发拨号,预览节点及延时必须在界面显著标记为演示数据。
|
||||
- FR-02a:桌面端采用蓝白灰连接概览、窄侧栏和独立线路弹窗;预览延时以绿、黄区分,但不伪装为实时测量。只有真实健康检查可展示控制面往返耗时,且明确不等于 VPN 节点延时。
|
||||
- FR-01:桌面端可输入控制面 HTTP 或 HTTPS 根地址,使用 Rust 命令调用现有 `/api/health` 并显示真实结果;HTTP 允许用户保存和检测,但显著提示明文传输风险。
|
||||
- FR-02:界面将“服务已可达”与“用户已登录/隧道已连接”分开显示;尚无客户端 API 时连接控制不可触发拨号,线路页必须显示不可获取状态,不得展示虚构节点或延时。
|
||||
- FR-02a:桌面端采用蓝白灰连接概览与窄侧栏;只有真实健康检查可展示控制面往返耗时,且明确不等于 VPN 节点延时。
|
||||
- FR-02b:托盘明确显示未连接,并提供打开窗口与退出;设置页只读检查本机 PATH 中是否存在 `wg`、`wg-quick`,不得把工具存在等同于可拨号或已连接。
|
||||
- FR-03(后续):专门的终端用户认证、MFA/OIDC 与短期设备令牌;区分管理员和拨号用户,过期、退出及设备撤销有可验证行为。
|
||||
- FR-04(后续):由桌面端生成并保管 WireGuard 私钥,只上传公钥;服务端按用户/设备身份分配地址、Peer、路由及 DNS,签发有限时配置并处理回收。
|
||||
- FR-05(后续):针对 Windows/macOS/Linux 的系统隧道、权限、路由/DNS 回滚和断线恢复逐平台交付;首版仅标准 WireGuard UDP。
|
||||
- FR-06(后续):终端用户在客户端登录后,客户端自动注册设备并取得该设备专属 WireGuard 参数,无需管理员或用户手动下载、导入 `.conf`;连接按钮只有在系统确认接口、路由、DNS 和握手时才能显示已连接。
|
||||
- NFR-01:应用不能请求广泛的 shell/filesystem 权限;客户端机密不进入前端源码、日志或仓库。桌面应用与管理 API 使用不同鉴权边界。
|
||||
- NFR-02:健康检查超时且 TLS 默认验证证书;失败时展示可读原因,不把网络失败当 VPN 连接失败。
|
||||
|
||||
@@ -38,7 +39,7 @@ Rust 系统隧道适配层(后续) ← 仅在本机保存的 WireGuard 私
|
||||
```
|
||||
|
||||
- 客户端采用 `client/src` + `client/src-tauri`,参考同族项目的轻量 Tauri v2 壳,不复制其 SSH/HTTP 工作台或许可证逻辑。UI 使用 React + TypeScript;网络请求由 Rust 执行,避免将控制面令牌交给 WebView。
|
||||
- UI 参考用户提供的蓝白灰截图与 Clash Verge Rev 的桌面交互分层思路;Clash Verge Rev 是 GPL-3.0-only,未复制其源码和视觉资源。线路列表目前是固定的演示内容,选择仅更新当前页面状态,不写入持久配置、不进行网络探测或拨号。
|
||||
- UI 参考用户提供的蓝白灰截图与 Clash Verge Rev 的桌面交互分层思路;Clash Verge Rev 是 GPL-3.0-only,未复制其源码和视觉资源。概览只使用已保存地址的真实健康检查;线路页在缺少终端用户线路接口时显示不可获取状态,不伪造节点、延时或连接。
|
||||
- `corplink-rs`、`Client_UI_for_Corplink` 与 PinkD 的 `wireguard-go` 面向飞连私有控制协议;现有 opsx-vpn 使用 Linux `wg`/`wg-quick` 和标准 WireGuard。不要引入 `protocol_version` 等私有扩展或假设它们兼容。SOCKS5/netstack、WireGuard over TCP 另行评估。
|
||||
- 可选的近期验证路线:由管理员在现有管理端下载独立测试 Peer 配置,再用目标 OS 的标准 WireGuard 客户端验证网关;桌面端的自动拨号必须等设备 API 和权限模型明确后再做。
|
||||
|
||||
@@ -48,7 +49,7 @@ Rust 系统隧道适配层(后续) ← 仅在本机保存的 WireGuard 私
|
||||
|
||||
| 顺序 | 借鉴方向 | opsx-vpn 需要自行实现或验证的前置条件 |
|
||||
|---|---|---|
|
||||
| 1 | Rust IPC 边界、连接状态模型、节点选择、托盘显示 | 先有独立的终端用户身份/设备授权 API;托盘只能根据真实握手与系统隧道状态宣称“已连接”,演示数据不能进入托盘 |
|
||||
| 1 | Rust IPC 边界、连接状态模型、线路选择、托盘显示 | 先有独立的终端用户身份/设备授权 API;线路和托盘只能根据实际授权、握手与系统隧道状态宣称“已连接” |
|
||||
| 2 | 服务可用性检查、失败恢复、跨平台权限管理 | 使用目标系统支持的**标准 WireGuard** 隧道方案,分别验证安装/卸载、DNS/路由保存与恢复、崩溃及重启行为;不以 Mihomo TUN 或系统代理开关替代 WireGuard |
|
||||
| 3 | 配置存储与更新流程 | 私钥在客户端安全存储,令牌按平台凭据库管理;普通偏好与机密分离。具备签名、可信发布地址和回滚策略后才添加自动更新 |
|
||||
|
||||
@@ -58,8 +59,10 @@ Rust 系统隧道适配层(后续) ← 仅在本机保存的 WireGuard 私
|
||||
|
||||
- 第一阶段复用 `GET /api/health`,不新增服务端 API、不改变数据库或现有 Peer 数据。
|
||||
- 桌面原生命令 `get_system_readiness` 返回操作系统和 PATH 中 `wg`、`wg-quick` 文件存在性;不执行这些工具、不申请特权,也不访问 VPN 凭据。托盘当前仅提供未连接静态状态、打开与退出,未来需绑定真实隧道状态机后才能展示连接状态。
|
||||
- 桌面 `check_server(base_url)` 只返回地址、是否可达及错误,不持久化用户口令或令牌。默认不配置公网地址;用户自行填真实 HTTPS 域名,开发时允许 `http://127.0.0.1` / `http://localhost`。
|
||||
- 桌面 `check_server(base_url)` 只返回地址、是否可达及错误,不持久化用户口令或令牌。默认不配置公网地址;用户可填写 HTTP 或 HTTPS 根地址,成功检测后在本机保存并在概览每 30 秒复测;后续认证流程仍推荐可信 HTTPS。
|
||||
- 后续拟新增独立的客户端 `/api/client/*` 契约:登录/续期、可用线路、设备注册(公钥、设备标识)、设备吊销、连接配置领取及状态。响应不下发服务端私钥,客户端私钥不上传。接口形状、LDAP/飞书绑定与授权范围在实现前另行确定。
|
||||
- 自动连接的服务端先决条件:建立普通用户身份和 WireGuard 专线之间的明确授权关系,不复用管理后台 `/api/login` 的管理员权限;设备由客户端生成私钥并上传公钥,服务端须原子分配独立 Peer/IP、限制设备数量与授权期限,支持撤销;运行中的 Peer 应增量更新,避免重启其他用户的隧道。随后才向已授权设备提供端点、路由、DNS 与服务端公钥,密钥不得进入 WebView 或日志。
|
||||
- 首发 macOS 隧道执行层需有权限受限的原生组件,以受控方式创建/关闭隧道并恢复路由和 DNS;`wg`、`wg-quick` 出现在 PATH 不等于具备权限。Windows 和 Linux 各有单独验收,不以 shell 执行任意用户输入配置或要求客户端以管理员账户登录管理 API。
|
||||
- 配置下载目前包含由服务端保存的客户端私钥,不得将管理员下载接口直接暴露给桌面端;现有管理员 `/api/login` 不等价于终端用户准入。
|
||||
|
||||
## 5. 风险与验收
|
||||
@@ -73,18 +76,19 @@ Rust 系统隧道适配层(后续) ← 仅在本机保存的 WireGuard 私
|
||||
|
||||
| AC | 对应需求 | 前置与操作 | 预期结果 | 验证方式 | 状态 |
|
||||
|---|---|---|---|---|---|
|
||||
| AC-01 | FR-01, NFR-02 | 输入有效/无效/非本机 HTTP 地址并点检测 | 真实健康结果、错误与拒绝行为明确 | Rust 单测、桌面手动试连 | 地址校验及本机健康接口测试通过;真实服务与桌面手动试连待做 |
|
||||
| AC-02 | FR-02, FR-02a, NFR-01 | 打开桌面端并切换线路 | 蓝白灰界面、绿黄演示延时、显著的演示标记;可见未接入,不会调用管理 API 或修改系统 | UI 构建、代码检查、桌面演示启动 | UI 构建通过;桌面 demo 进程已启动,手动视觉验收待做 |
|
||||
| AC-01 | FR-01, NFR-02 | 输入有效/无效及远端 HTTP 地址并点检测 | 真实健康结果、成功后保存、错误与拒绝行为明确 | Rust 单测、UI 构建、桌面试连 | Rust 测试与 UI 构建通过;浏览器手动截图显示远端健康检查可达,保存及重开验收待做 |
|
||||
| AC-02 | FR-02, FR-02a, NFR-01 | 打开概览和线路 | 蓝白灰界面、真实健康检查;无授权接口时显示不可获取线路,不调用管理 API 或修改系统 | UI 构建、代码检查、桌面启动 | UI 构建通过;桌面手动视觉验收待做 |
|
||||
| AC-02b | FR-02b, NFR-01 | 在桌面端打开托盘并访问设置页 | 托盘显示未连接,能打开/退出;本机工具存在性仅标注为 PATH 检查结果 | 原生构建、桌面手动检查 | UI 构建及 Rust 测试通过,Tauri 开发进程已重启;托盘点击与设置页人工验收待做 |
|
||||
| AC-03 | FR-03 | 普通用户/MFA/过期令牌/撤销设备 | 仅已授权设备可领取配置 | 后续 API 集成测试 | Not Run |
|
||||
| AC-04 | FR-04 | 同一用户多个设备注册/注销 | 各设备独立密钥、Peer、地址与审计 | 后续真实网关联调 | Not Run |
|
||||
| AC-05 | FR-05 | 各 OS 正常连接与异常退出 | 路由/DNS 可恢复,不影响已有连接 | 后续目标 OS 实机验收 | Not Run |
|
||||
| AC-06 | FR-06 | 新用户首次点击连接并完成登录;退出、撤销与重装再连 | 自动注册并获得专属配置,无需下载文件;只在真实握手后显示连接,撤销后不能重连 | 客户端/服务端集成测试、macOS 实机与网关握手 | Not Run |
|
||||
|
||||
## 6. 发布与恢复
|
||||
|
||||
- 第一阶段仅新增 `client/`,不修改服务器进程、不申请系统特权;构建检查与桌面手动健康检查后才能称为可运行。
|
||||
- 2026-10-21 已经管理 API 在现有服务器上创建停止状态测试专线 `opsx-client-pilot`(ID 4),配置与只读核验见 [WireGuard API 创建记录](../wireguard-api-create.md)。该资源尚未启动,不代表桌面客户端可以认证或连接;启用前确认云安全组与目标路由。
|
||||
- 后续按“独立客户端身份 API → 设备注册/增量 Peer 更新 → Linux/Windows/macOS 原生隧道”逐步交付;先在独立测试 WireGuard 隧道验证。
|
||||
- 2026-10-21 已经管理 API 在现有服务器上创建测试专线 `opsx-client-pilot`(ID 4),配置与早期核验见 [WireGuard API 创建记录](../wireguard-api-create.md)。后续只读复核确认 wg4 已在 UDP 51820 监听,公网测试包可达,但 Peer 握手仍为零;不代表桌面客户端可以认证或连接。
|
||||
- 后续按“独立客户端身份与专线授权 API → 设备注册/增量 Peer 更新 → macOS 原生隧道实机握手 → Linux/Windows 适配”逐步交付;客户端自动领取配置,下载 `.conf` 仅作为管理员排障功能。2026-10-21 只读核对:试验专线 wg4 已运行,UDP 51820 可从公网抵达服务器,但尚无 Peer 握手,不能据此声称桌面客户端已连接。
|
||||
- 若后续客户端版本故障,先禁用其设备访问凭据并停用客户端,保留现有管理后台与隧道;数据迁移需独立备份和回滚方案。
|
||||
|
||||
## 7. 待决问题
|
||||
|
||||
Reference in new issue
Block a user