feat(client): add authorized WireGuard device enrollment and desktop connection
This commit is contained in:
1 parent
d2f1f5040e
commit
6dd3c7dd45
25 files changed
+3274
-131
No files matched your search
+369
-102
@@ -1,10 +1,16 @@
|
||||
import { useEffect, useState, type FormEvent } from 'react'
|
||||
import { checkServer, getSystemReadiness, type SystemReadiness } from './platform'
|
||||
import { useCallback, useEffect, useRef, useState, type FormEvent } from 'react'
|
||||
import {
|
||||
checkServer, clientConnect, clientDeviceName, clientDevices, clientDisconnect,
|
||||
clientLines, clientLogin, clientLogout, clientResume, clientRevokeDevice, clientStatus,
|
||||
getSystemReadiness,
|
||||
type ConnState, type DeviceInfo, type Line, type SessionView, type StatusView,
|
||||
type SystemReadiness,
|
||||
} from './platform'
|
||||
import './system.css'
|
||||
|
||||
type Page = 'overview' | 'routes' | 'settings'
|
||||
type CheckState = 'idle' | 'checking' | 'reachable' | 'error'
|
||||
function Icon({ name, size = 22 }: { name: 'home' | 'globe' | 'settings' | 'power' | 'shield' | 'server' | 'check' | 'close'; size?: number }) {
|
||||
|
||||
function Icon({ name, size = 22 }: { name: 'home' | 'globe' | 'settings' | 'power' | 'shield' | 'server' | 'check' | 'close' | 'user' | 'device'; 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" /></>,
|
||||
@@ -14,112 +20,140 @@ 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" />,
|
||||
user: <><circle cx="12" cy="8" r="4" /><path d="M4 21c0-4 3.6-6.5 8-6.5s8 2.5 8 6.5" /></>,
|
||||
device: <><rect x="5" y="2" width="14" height="20" rx="2" /><path d="M12 18h.01" /></>,
|
||||
}
|
||||
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>
|
||||
}
|
||||
|
||||
function formatBytes(n?: number | null) {
|
||||
if (n === null || n === undefined) return '—'
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
let value = n, unit = 0
|
||||
while (value >= 1024 && unit < units.length - 1) { value /= 1024; unit++ }
|
||||
return `${value >= 100 ? Math.round(value) : value.toFixed(1)} ${units[unit]}`
|
||||
}
|
||||
|
||||
function formatDuration(sinceUnix: number, now: number) {
|
||||
if (!sinceUnix) return '—'
|
||||
const seconds = Math.max(0, now - sinceUnix)
|
||||
const h = Math.floor(seconds / 3600), m = Math.floor((seconds % 3600) / 60), s = seconds % 60
|
||||
return h > 0 ? `${h} 小时 ${m} 分` : m > 0 ? `${m} 分 ${s} 秒` : `${s} 秒`
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const [session, setSession] = useState<SessionView | null>(null)
|
||||
const [booting, setBooting] = useState(true)
|
||||
const [page, setPage] = useState<Page>('overview')
|
||||
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('')
|
||||
const [conn, setConn] = useState<ConnState | null>(null)
|
||||
const [status, setStatus] = useState<StatusView | null>(null)
|
||||
const [selectedLine, setSelectedLine] = useState<number | null>(null)
|
||||
const [lines, setLines] = useState<Line[]>([])
|
||||
const [actionError, setActionError] = useState('')
|
||||
const [now, setNow] = useState(() => Math.floor(Date.now() / 1000))
|
||||
|
||||
// 启动时恢复本机会话
|
||||
useEffect(() => {
|
||||
if (page !== 'overview' || !savedAddress) return
|
||||
clientResume()
|
||||
.then(view => {
|
||||
if (view) {
|
||||
setSession(view)
|
||||
setLines(view.lines)
|
||||
}
|
||||
})
|
||||
.catch(() => undefined)
|
||||
.finally(() => setBooting(false))
|
||||
}, [])
|
||||
|
||||
// 连接状态轮询:已连接/连接中时每 5 秒以服务端握手为准核对一次
|
||||
useEffect(() => {
|
||||
if (!session) return
|
||||
let active = true
|
||||
async function refresh() {
|
||||
const start = performance.now()
|
||||
setState('checking')
|
||||
async function poll() {
|
||||
try {
|
||||
const health = await checkServer(savedAddress)
|
||||
const view = await clientStatus()
|
||||
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)
|
||||
}
|
||||
setStatus(view)
|
||||
setConn(view.conn)
|
||||
} catch { /* 未连接时无状态可查 */ }
|
||||
}
|
||||
void refresh()
|
||||
const timer = window.setInterval(refresh, 30_000)
|
||||
void poll()
|
||||
const timer = window.setInterval(poll, 5000)
|
||||
return () => { active = false; window.clearInterval(timer) }
|
||||
}, [page, savedAddress])
|
||||
}, [session])
|
||||
|
||||
// 已连接时刷新时长显示
|
||||
useEffect(() => {
|
||||
if (conn?.phase !== 'connected') return
|
||||
const timer = window.setInterval(() => setNow(Math.floor(Date.now() / 1000)), 1000)
|
||||
return () => window.clearInterval(timer)
|
||||
}, [conn?.phase])
|
||||
|
||||
const refreshLines = useCallback(async () => {
|
||||
try {
|
||||
const list = await clientLines()
|
||||
setLines(list)
|
||||
} catch { /* 离线时保留缓存 */ }
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (page !== 'settings') return
|
||||
let active = true
|
||||
getSystemReadiness().then(result => {
|
||||
if (active) { setReadiness(result); setReadinessError('') }
|
||||
}).catch(() => {
|
||||
if (active) setReadinessError('本机检测仅在 Tauri 桌面应用中可用。')
|
||||
})
|
||||
return () => { active = false }
|
||||
}, [page])
|
||||
if (session && page === 'routes') void refreshLines()
|
||||
}, [session, page, refreshLines])
|
||||
|
||||
async function handleCheck(event: FormEvent) {
|
||||
event.preventDefault()
|
||||
setState('checking')
|
||||
setError('')
|
||||
setResponseMs(null)
|
||||
const start = performance.now()
|
||||
try {
|
||||
const health = await checkServer(address)
|
||||
setCheckedAddress(health.address)
|
||||
setResponseMs(Math.round(performance.now() - start))
|
||||
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(cause instanceof Error ? cause.message : String(cause))
|
||||
}
|
||||
}
|
||||
|
||||
function saveAddress() {
|
||||
try {
|
||||
const url = new URL(address.trim())
|
||||
if (!['http:', 'https:'].includes(url.protocol) || !url.hostname || url.username || url.password || url.pathname !== '/' || url.search || url.hash) {
|
||||
throw new Error('请输入 HTTP 或 HTTPS 服务端根地址,不包含路径、账号或参数。')
|
||||
}
|
||||
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('请先到“设置”检测并保存服务端地址。')
|
||||
async function handleConnect(tunnelId?: number) {
|
||||
setActionError('')
|
||||
const target = tunnelId ?? selectedLine ?? lines.find(l => l.state === 'running')?.tunnel_id ?? lines[0]?.tunnel_id
|
||||
if (!target) {
|
||||
setActionError('没有可用线路,请先在“线路”页查看授权。')
|
||||
return
|
||||
}
|
||||
try {
|
||||
const result = await getSystemReadiness()
|
||||
setReadiness(result)
|
||||
setConnectionGuide(result.wg_found && result.wg_quick_found
|
||||
? '本机已发现 WireGuard 工具。普通用户设备授权和自动下发尚未接入服务端,因此当前不能安全地直接拨号。'
|
||||
: '本机尚未发现完整的 WireGuard 工具;普通用户设备授权和自动下发也尚未接入。当前不能直接拨号。')
|
||||
} catch {
|
||||
setConnectionGuide('当前是浏览器预览,不能修改本机网络。自动连接需要桌面客户端的原生隧道和设备授权接口。')
|
||||
const next = await clientConnect(target)
|
||||
setConn(next)
|
||||
setSelectedLine(target)
|
||||
setPage('overview')
|
||||
} catch (cause) {
|
||||
setActionError(cause instanceof Error ? cause.message : String(cause))
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDisconnect() {
|
||||
setActionError('')
|
||||
try {
|
||||
await clientDisconnect()
|
||||
setConn(prev => prev ? { ...prev, phase: 'disconnected' } : prev)
|
||||
setStatus(null)
|
||||
} catch (cause) {
|
||||
setActionError(cause instanceof Error ? cause.message : String(cause))
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
try { await clientLogout() } catch (cause) {
|
||||
setActionError(cause instanceof Error ? cause.message : String(cause))
|
||||
return
|
||||
}
|
||||
setSession(null)
|
||||
setConn(null)
|
||||
setStatus(null)
|
||||
setLines([])
|
||||
setSelectedLine(null)
|
||||
setPage('overview')
|
||||
}
|
||||
|
||||
if (booting) {
|
||||
return <div className="app-shell"><div className="workspace"><main className="page"><p className="hint">正在启动…</p></main></div></div>
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
return <LoginView onLoggedIn={view => { setSession(view); setLines(view.lines) }} />
|
||||
}
|
||||
|
||||
const phase = conn?.phase ?? 'disconnected'
|
||||
const connected = phase === 'connected'
|
||||
const connecting = phase === 'connecting'
|
||||
const currentLineName = conn?.tunnel_name || lines.find(l => l.tunnel_id === selectedLine)?.name || ''
|
||||
|
||||
return <div className="app-shell">
|
||||
<aside className="sidebar" aria-label="主导航">
|
||||
<div className="brand-symbol" aria-label="OpsX VPN"><span /><i /></div>
|
||||
@@ -130,29 +164,262 @@ 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" />{session.display_name || session.username}</div>
|
||||
<span className={`preview-pill ${connected ? 'online' : ''}`}>● {connected ? '已连接' : connecting ? '连接中' : '未连接'}</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">● {connected ? `已连接 · ${currentLineName}` : connecting ? '连接中…' : '本客户端未连接'}</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" /><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>
|
||||
<div className="card-top"><div><span className="kicker">安全连接</span><h2>{currentLineName || '企业网络'}</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" />
|
||||
<button type="button" className={`power-display ${connected ? 'on' : ''} ${connecting ? 'busy' : ''}`}
|
||||
aria-label={connected ? '断开 VPN 连接' : '连接 VPN'}
|
||||
disabled={connecting}
|
||||
onClick={() => connected ? void handleDisconnect() : void handleConnect()}>
|
||||
<Icon name="power" size={42} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="connection-message">
|
||||
<strong>{connected ? '已连接' : connecting ? '连接中…' : '本客户端未连接'}</strong>
|
||||
<span>{connected
|
||||
? `${conn?.client_address ?? ''} · 已连接 ${formatDuration(conn?.since_unix ?? 0, now)}`
|
||||
: connecting ? (conn?.detail || '正在建立隧道') : (conn?.detail || actionError || '点击上方按钮接入企业网络')}</span>
|
||||
</div>
|
||||
{connected && status?.server && <div className="traffic-grid">
|
||||
<span>下行 {formatBytes(status.server.rx_bytes)}</span>
|
||||
<span>上行 {formatBytes(status.server.tx_bytes)}</span>
|
||||
<span>端点 {conn?.endpoint}</span>
|
||||
</div>}
|
||||
{actionError && !connecting && <div className="connection-guide" role="alert">{actionError}</div>}
|
||||
{status?.server?.detail && <div className="connection-guide" role="status">{status.server.detail}</div>}
|
||||
<div className="node-selector" role="button" tabIndex={0} onClick={() => setPage('routes')} onKeyDown={e => e.key === 'Enter' && setPage('routes')}>
|
||||
<span className="node-icon"><Icon name="globe" size={19} /></span>
|
||||
<span className="node-selector-copy"><small>当前线路</small><strong>{currentLineName || (lines[0]?.name ?? '暂无授权线路')}</strong></span>
|
||||
<small className="node-switch">切换 →</small>
|
||||
</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">{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>
|
||||
<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 className="info-card">
|
||||
<div className="info-heading"><span className="icon-tile"><Icon name="user" size={20} /></span><div><h2>当前账号</h2><p>拨号用户身份</p></div></div>
|
||||
<div className="system-grid">
|
||||
<span>用户名</span><strong>{session.username}</strong>
|
||||
<span>本机设备</span><strong>{session.device_name}</strong>
|
||||
<span>授权线路</span><strong>{session.offline ? '服务端暂不可达' : `${lines.length} 条`}</strong>
|
||||
</div>
|
||||
<button className="secondary-action" onClick={() => void handleLogout()}>退出登录</button>
|
||||
</section>
|
||||
<ServerCard baseUrl={undefined} />
|
||||
</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><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>}
|
||||
|
||||
{page === 'routes' && <main className="page subpage">
|
||||
<span className="overline">WORKSPACE / ROUTES</span><h1>线路</h1>
|
||||
<p className="subpage-intro">这里只显示当前账号被授权的真实线路。</p>
|
||||
{lines.length === 0 && <section className="info-card route-preview">
|
||||
<span className="icon-tile large"><Icon name="globe" size={29} /></span>
|
||||
<div><h2>{session.offline ? '服务端暂不可达' : '暂无授权线路'}</h2><p>{session.offline ? '恢复网络后自动重试。' : '请联系管理员在 WireGuard 专线中为您创建拨号用户。'}</p></div>
|
||||
<button className="secondary-action" onClick={() => void refreshLines()}>刷新</button>
|
||||
</section>}
|
||||
{lines.map(line => <section key={line.tunnel_id} className="info-card line-card">
|
||||
<span className="icon-tile"><Icon name="globe" size={20} /></span>
|
||||
<div className="line-info">
|
||||
<h2>{line.name} {selectedLine === line.tunnel_id && <em className="line-current">当前</em>}</h2>
|
||||
<p>{line.remark || line.endpoint || '—'}</p>
|
||||
</div>
|
||||
<span className={`service-state ${line.state === 'running' ? 'reachable' : 'error'}`}>{line.state === 'running' ? '运行中' : '未运行'}</span>
|
||||
<button className="secondary-action" disabled={line.state !== 'running' || connecting}
|
||||
onClick={() => { setSelectedLine(line.tunnel_id); void handleConnect(line.tunnel_id) }}>
|
||||
{connected && conn?.tunnel_name === line.name ? '已连接' : '连接'}
|
||||
</button>
|
||||
</section>)}
|
||||
</main>}
|
||||
|
||||
{page === 'settings' && <SettingsPage session={session} onLogout={handleLogout} />}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
// ---- 登录页 ----
|
||||
|
||||
function LoginView({ onLoggedIn }: { onLoggedIn: (view: SessionView) => void }) {
|
||||
const [address, setAddress] = useState(() => localStorage.getItem('opsx_server_address') || '')
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [otp, setOtp] = useState('')
|
||||
const [mfaRequired, setMfaRequired] = useState(false)
|
||||
const [deviceName, setDeviceName] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [health, setHealth] = useState<'idle' | 'checking' | 'reachable' | 'error'>('idle')
|
||||
|
||||
useEffect(() => { clientDeviceName().then(setDeviceName).catch(() => undefined) }, [])
|
||||
|
||||
async function handleSubmit(event: FormEvent) {
|
||||
event.preventDefault()
|
||||
setError('')
|
||||
setBusy(true)
|
||||
try {
|
||||
const view = await clientLogin(address, username, password, otp, deviceName)
|
||||
localStorage.setItem('opsx_server_address', address.trim().replace(/\/$/, ''))
|
||||
onLoggedIn(view)
|
||||
} catch (cause) {
|
||||
const message = cause instanceof Error ? cause.message : String(cause)
|
||||
setError(message)
|
||||
if (message.includes('动态口令')) setMfaRequired(true)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCheck() {
|
||||
setHealth('checking')
|
||||
try {
|
||||
const result = await checkServer(address)
|
||||
setHealth(result.reachable ? 'reachable' : 'error')
|
||||
} catch { setHealth('error') }
|
||||
}
|
||||
|
||||
return <div className="app-shell login-shell">
|
||||
<main className="login-card">
|
||||
<div className="topbar-brand"><span className="brand-dot" />OpsX <strong>VPN</strong><span className="divider" />企业网络接入</div>
|
||||
<h1>登录</h1>
|
||||
<p className="hint">使用管理员为您开通的 VPN 拨号账号登录;首次连接会自动为本机登记设备。</p>
|
||||
<form onSubmit={handleSubmit} className="login-form">
|
||||
<label htmlFor="login-server">服务端地址</label>
|
||||
<div className="input-row">
|
||||
<input id="login-server" type="url" required placeholder="https://vpn.example.com" value={address}
|
||||
onChange={e => { setAddress(e.target.value); setHealth('idle') }} />
|
||||
<button type="button" className="save-button" onClick={() => void handleCheck()} disabled={health === 'checking'}>
|
||||
{health === 'checking' ? '检测中…' : '检测'}
|
||||
</button>
|
||||
</div>
|
||||
{health === 'reachable' && <p className="saved-address">✓ 服务可达</p>}
|
||||
{health === 'error' && <p className="http-warning">服务不可达,请确认地址与网络。</p>}
|
||||
{address.trim().toLowerCase().startsWith('http://') && <p className="http-warning">HTTP 为明文传输,登录口令可能被窃听,建议使用可信 HTTPS 域名。</p>}
|
||||
<label htmlFor="login-username">用户名</label>
|
||||
<div className="input-row"><input id="login-username" required autoComplete="username" value={username} onChange={e => setUsername(e.target.value)} /></div>
|
||||
<label htmlFor="login-password">密码</label>
|
||||
<div className="input-row"><input id="login-password" type="password" required autoComplete="current-password" value={password} onChange={e => setPassword(e.target.value)} /></div>
|
||||
{mfaRequired && <>
|
||||
<label htmlFor="login-otp">动态口令(设置了 PIN 时输入 PIN+动态码)</label>
|
||||
<div className="input-row"><input id="login-otp" autoComplete="one-time-code" value={otp} onChange={e => setOtp(e.target.value)} /></div>
|
||||
</>}
|
||||
<label htmlFor="login-device">设备名(用于在管理端识别本机)</label>
|
||||
<div className="input-row"><input id="login-device" value={deviceName} onChange={e => setDeviceName(e.target.value)} placeholder="自动填充本机主机名" /></div>
|
||||
{error && <div className="result failure" role="alert"><Icon name="close" size={17} /><span>{error}</span></div>}
|
||||
<button className="login-submit" type="submit" disabled={busy}>{busy ? '登录中…' : '登录'}</button>
|
||||
</form>
|
||||
</main>
|
||||
</div>
|
||||
}
|
||||
|
||||
// ---- 服务端健康卡片(概览右栏)----
|
||||
|
||||
function ServerCard({ baseUrl }: { baseUrl?: string }) {
|
||||
const saved = baseUrl || localStorage.getItem('opsx_server_address') || ''
|
||||
const [state, setState] = useState<'idle' | 'checking' | 'reachable' | 'error'>('idle')
|
||||
const [responseMs, setResponseMs] = useState<number | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!saved) return
|
||||
let active = true
|
||||
async function refresh() {
|
||||
const start = performance.now()
|
||||
setState('checking')
|
||||
try {
|
||||
const health = await checkServer(saved)
|
||||
if (!active) return
|
||||
setResponseMs(Math.round(performance.now() - start))
|
||||
setState(health.reachable ? 'reachable' : 'error')
|
||||
} catch { if (active) { setState('error'); setResponseMs(null) } }
|
||||
}
|
||||
void refresh()
|
||||
const timer = window.setInterval(refresh, 30_000)
|
||||
return () => { active = false; window.clearInterval(timer) }
|
||||
}, [saved])
|
||||
|
||||
return <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">{saved || '未设置'}</p>
|
||||
<span className={`service-state ${state}`}>{state === 'reachable' ? `服务可达 · 控制面往返约 ${responseMs} ms` : state === 'error' ? '检测失败' : state === 'checking' ? '检测中…' : '尚未检测'}</span>
|
||||
<p className="hint">控制面检测不代表 VPN 已连接。</p>
|
||||
</section>
|
||||
}
|
||||
|
||||
// ---- 设置页 ----
|
||||
|
||||
function SettingsPage({ session, onLogout }: { session: SessionView; onLogout: () => void }) {
|
||||
const [readiness, setReadiness] = useState<SystemReadiness | null>(null)
|
||||
const [readinessError, setReadinessError] = useState('')
|
||||
const [devices, setDevices] = useState<DeviceInfo[]>([])
|
||||
const [deviceError, setDeviceError] = useState('')
|
||||
|
||||
const loadDevices = useCallback(() => {
|
||||
clientDevices().then(setDevices).catch(err => setDeviceError(String(err?.message ?? err)))
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
getSystemReadiness()
|
||||
.then(result => { if (active) setReadiness(result) })
|
||||
.catch(() => { if (active) setReadinessError('本机检测仅在 Tauri 桌面应用中可用。') })
|
||||
loadDevices()
|
||||
return () => { active = false }
|
||||
}, [loadDevices])
|
||||
|
||||
async function revoke(id: number, name: string) {
|
||||
if (!window.confirm(`确定撤销设备「${name}」?该设备将立即失去接入能力,需要重新登记。`)) return
|
||||
setDeviceError('')
|
||||
try {
|
||||
await clientRevokeDevice(id)
|
||||
loadDevices()
|
||||
} catch (cause) {
|
||||
setDeviceError(cause instanceof Error ? cause.message : String(cause))
|
||||
}
|
||||
}
|
||||
|
||||
return <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="user" size={20} /></span><div><h2>账号</h2><p>当前登录的拨号用户</p></div></div>
|
||||
<div className="system-grid">
|
||||
<span>用户名</span><strong>{session.username}</strong>
|
||||
<span>姓名</span><strong>{session.display_name || '—'}</strong>
|
||||
<span>服务端</span><strong>{localStorage.getItem('opsx_server_address') || '—'}</strong>
|
||||
</div>
|
||||
<button className="secondary-action" onClick={onLogout}>退出登录(同时断开 VPN)</button>
|
||||
</section>
|
||||
|
||||
<section className="info-card system-card">
|
||||
<div className="info-heading"><span className="icon-tile"><Icon name="device" size={20} /></span><div><h2>我的设备</h2><p>撤销后该设备立即无法接入</p></div></div>
|
||||
{deviceError && <div className="result failure" role="alert"><Icon name="close" size={17} /><span>{deviceError}</span></div>}
|
||||
{devices.length === 0 && <p className="hint">尚未登记设备;首次连接时会自动登记本机。</p>}
|
||||
{devices.map(d => <div key={d.id} className="milestone">
|
||||
<span className={`milestone-mark ${d.revoked ? '' : 'done'}`}>{d.revoked ? '×' : '✓'}</span>
|
||||
<span><strong>{d.name} <small className="device-platform">{d.platform}</small></strong><small>{d.client_address}{d.last_seen_at ? ` · 最近握手 ${new Date(d.last_seen_at).toLocaleString()}` : ''}</small></span>
|
||||
{d.revoked ? <em>已撤销</em> : <button className="secondary-action revoke-button" onClick={() => void revoke(d.id, d.name)}>撤销</button>}
|
||||
</div>)}
|
||||
</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>只读检查,不代表已具备拨号能力</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">建立隧道时系统会弹出提权授权;Windows 暂请使用 WireGuard 官方客户端导入配置。</p>
|
||||
</section>
|
||||
</main>
|
||||
}
|
||||
+94
-2
@@ -3,12 +3,104 @@ import { invoke } from '@tauri-apps/api/core'
|
||||
export type ServerHealth = { address: string; reachable: boolean }
|
||||
export type SystemReadiness = { platform: string; wg_found: boolean; wg_quick_found: boolean }
|
||||
|
||||
export type Line = {
|
||||
tunnel_id: number
|
||||
name: string
|
||||
remark: string
|
||||
state: string
|
||||
endpoint: string
|
||||
}
|
||||
|
||||
export type SessionView = {
|
||||
username: string
|
||||
display_name: string
|
||||
device_name: string
|
||||
lines: Line[]
|
||||
offline: boolean
|
||||
}
|
||||
|
||||
export type ConnPhase = 'disconnected' | 'connecting' | 'connected'
|
||||
|
||||
export type ConnState = {
|
||||
phase: ConnPhase
|
||||
detail: string
|
||||
tunnel_name: string
|
||||
client_address: string
|
||||
endpoint: string
|
||||
device_id: number
|
||||
since_unix: number
|
||||
}
|
||||
|
||||
export type DeviceStatus = {
|
||||
running: boolean
|
||||
connected: boolean
|
||||
latest_handshake_at?: number | null
|
||||
rx_bytes?: number | null
|
||||
tx_bytes?: number | null
|
||||
detail?: string | null
|
||||
}
|
||||
|
||||
export type StatusView = { conn: ConnState; server: DeviceStatus | null }
|
||||
|
||||
export type DeviceInfo = {
|
||||
id: number
|
||||
tunnel_id: number
|
||||
name: string
|
||||
platform: string
|
||||
client_address: string
|
||||
revoked: boolean
|
||||
last_seen_at?: string | null
|
||||
}
|
||||
|
||||
const isDesktop = () => '__TAURI_INTERNALS__' in window
|
||||
const desktopOnly = () => Promise.reject(new Error('浏览器预览不支持该操作,请使用桌面客户端。'))
|
||||
|
||||
// All desktop-to-native calls pass through this boundary. The WebView never
|
||||
// handles a management token or a WireGuard private key.
|
||||
export function checkServer(baseUrl: string): Promise<ServerHealth> {
|
||||
return invoke<ServerHealth>('check_server', { baseUrl })
|
||||
return isDesktop() ? invoke<ServerHealth>('check_server', { baseUrl }) : desktopOnly()
|
||||
}
|
||||
|
||||
export function getSystemReadiness(): Promise<SystemReadiness> {
|
||||
return invoke<SystemReadiness>('get_system_readiness')
|
||||
return isDesktop() ? invoke<SystemReadiness>('get_system_readiness') : desktopOnly()
|
||||
}
|
||||
|
||||
export function clientLogin(baseUrl: string, username: string, password: string, otp: string, deviceName: string): Promise<SessionView> {
|
||||
return isDesktop() ? invoke<SessionView>('client_login', { baseUrl, username, password, otp, deviceName }) : desktopOnly()
|
||||
}
|
||||
|
||||
export function clientResume(): Promise<SessionView | null> {
|
||||
return isDesktop() ? invoke<SessionView | null>('client_resume') : Promise.resolve(null)
|
||||
}
|
||||
|
||||
export function clientLogout(): Promise<void> {
|
||||
return isDesktop() ? invoke<void>('client_logout') : desktopOnly()
|
||||
}
|
||||
|
||||
export function clientLines(): Promise<Line[]> {
|
||||
return isDesktop() ? invoke<Line[]>('client_lines') : desktopOnly()
|
||||
}
|
||||
|
||||
export function clientConnect(tunnelId: number): Promise<ConnState> {
|
||||
return isDesktop() ? invoke<ConnState>('client_connect', { tunnelId }) : desktopOnly()
|
||||
}
|
||||
|
||||
export function clientDisconnect(): Promise<void> {
|
||||
return isDesktop() ? invoke<void>('client_disconnect') : desktopOnly()
|
||||
}
|
||||
|
||||
export function clientStatus(): Promise<StatusView> {
|
||||
return isDesktop() ? invoke<StatusView>('client_status') : desktopOnly()
|
||||
}
|
||||
|
||||
export function clientDevices(): Promise<DeviceInfo[]> {
|
||||
return isDesktop() ? invoke<DeviceInfo[]>('client_devices') : desktopOnly()
|
||||
}
|
||||
|
||||
export function clientRevokeDevice(deviceId: number): Promise<void> {
|
||||
return isDesktop() ? invoke<void>('client_revoke_device', { deviceId }) : desktopOnly()
|
||||
}
|
||||
|
||||
export function clientDeviceName(): Promise<string> {
|
||||
return isDesktop() ? invoke<string>('client_device_name') : Promise.resolve('')
|
||||
}
|
||||
@@ -17,3 +17,34 @@
|
||||
.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; }
|
||||
|
||||
/* ---- 登录页 ---- */
|
||||
.login-shell { align-items: center; justify-content: center; background: linear-gradient(160deg, #eef4ff, #f8faff 60%, #f0f6ff); }
|
||||
.login-card { width: 420px; max-width: calc(100vw - 48px); background: #fff; border: 1px solid #e8edf5; border-radius: 20px; box-shadow: 0 18px 48px rgba(29, 72, 137, .08); padding: 36px 38px 30px; }
|
||||
.login-card h1 { font-size: 24px; margin: 22px 0 6px; }
|
||||
.login-form { display: grid; gap: 4px; margin-top: 18px; }
|
||||
.login-form label { font-size: 11px; font-weight: 700; color: #5d6e85; margin: 10px 0 6px; }
|
||||
.login-submit { margin-top: 22px; height: 44px; border: 0; border-radius: 10px; background: #3476f5; color: #fff; font-size: 14px; font-weight: 700; }
|
||||
.login-submit:hover { background: #205ed9; }
|
||||
.login-submit:disabled { opacity: .6; cursor: wait; }
|
||||
|
||||
/* ---- 连接状态 ---- */
|
||||
.power-display.on { background: linear-gradient(145deg, #2fce88, #17a268); box-shadow: 0 16px 30px #17a26849, inset 0 1px 2px #ffffff66; }
|
||||
.power-display.busy { opacity: .75; cursor: wait; animation: pulse 1.4s ease-in-out infinite; }
|
||||
@keyframes pulse { 50% { transform: scale(.96); } }
|
||||
.preview-pill.online { border-color: #bdebd6; background: #ebf8f2; color: #168861; }
|
||||
.traffic-grid { display: flex; justify-content: center; gap: 18px; margin: -8px 0 18px; font-size: 11px; color: #63738a; }
|
||||
.node-switch { color: #3977e8; font-size: 11px; white-space: nowrap; }
|
||||
.node-selector { cursor: pointer; }
|
||||
.node-selector:hover { background: #f3f7ff; }
|
||||
|
||||
/* ---- 线路卡片 ---- */
|
||||
.line-card { display: flex; align-items: center; gap: 16px; margin-bottom: 14px; }
|
||||
.line-info { flex: 1; min-width: 0; }
|
||||
.line-info h2 { display: flex; align-items: center; gap: 8px; }
|
||||
.line-current { font-size: 10px; font-style: normal; color: #168861; background: #ebf8f2; border-radius: 100px; padding: 3px 8px; }
|
||||
|
||||
/* ---- 设备列表 ---- */
|
||||
.device-platform { color: #93a1b4; font-weight: 400; }
|
||||
.revoke-button { padding: 7px 12px; font-size: 11px; color: #b34444; border-color: #f3d2d0; background: #fff7f6; }
|
||||
.revoke-button:hover { background: #ffe9e7; }
|
||||
Reference in new issue
Block a user