Refine WireGuard client routing and authentication UI

This commit is contained in:
opsx committed 2026-10-21 21:10:53 +08:00
1 parent eabb3e4472
commit 41fb9d5631
33 files changed
+1709 -469

No files matched your search

+234 -91
View File
@@ -1,15 +1,28 @@
import { useCallback, useEffect, useRef, useState, type FormEvent } from 'react'
import {
checkServer, clientConnect, clientDeviceName, clientDevices, clientDisconnect, clientProbe, clientProxyStart, clientProxyStop, clientProxyStatus,
checkServer, clientCapabilities, clientDeviceName, clientDevices, clientDisconnect, clientProbe, clientProxyStart, clientProxyStop, clientProxyStatus, clientSystemProxyStatus, clientSystemProxyEnable, clientSystemProxyDisable,
clientLines, clientLogin, clientLogout, clientResume, clientRevokeDevice, clientStatus,
getSystemReadiness,
type ConnState, type DeviceInfo, type Line, type SessionView, type StatusView,
type ClientCapabilities, type ConnState, type DeviceInfo, type Line, type ProbeResult, type SessionView, type StatusView,
type SystemReadiness,
} from './platform'
import './system.css'
import './entry.css'
type Page = 'overview' | 'routes' | 'settings'
function unitLabel(address: string) {
try { return new URL(address).host } catch { return address }
}
function Signal({ ms }: { ms?: number }) {
const quality = ms === undefined ? 'unknown' : ms <= 120 ? 'good' : 'slow'
return <span className={`node-signal ${quality}`} aria-label={ms === undefined ? '尚未测速' : `VPN 隧道 TCP 建链 ${ms} 毫秒`}>
<span className="signal-bars" aria-hidden="true"><i /><i /><i /></span>
<span>{ms === undefined ? '未测速' : `${ms} ms`}</span>
</span>
}
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" />,
@@ -48,12 +61,22 @@ export default function App() {
const [conn, setConn] = useState<ConnState | null>(null)
const [status, setStatus] = useState<StatusView | null>(null)
const [selectedLine, setSelectedLine] = useState<number | null>(null)
const [selectedMode, setSelectedMode] = useState<'split' | 'global_ipv4'>('split')
const [lines, setLines] = useState<Line[]>([])
const [actionError, setActionError] = useState('')
const [actionBusy, setActionBusy] = useState(false)
const actionLock = useRef(false)
const [readiness, setReadiness] = useState<SystemReadiness | null>(null)
const [now, setNow] = useState(() => Math.floor(Date.now() / 1000))
const [nodePickerOpen, setNodePickerOpen] = useState(false)
const [latencies, setLatencies] = useState<Record<number, number>>({})
const [probeBusyLine, setProbeBusyLine] = useState<number | null>(null)
const [probeMessage, setProbeMessage] = useState('')
const [proxyRunning, setProxyRunning] = useState(false)
const [systemProxyOn, setSystemProxyOn] = useState(false)
const [proxyBusy, setProxyBusy] = useState(false)
const [proxyError, setProxyError] = useState('')
const proxyActionLock = useRef(false)
// 启动时恢复本机会话
useEffect(() => {
@@ -108,6 +131,25 @@ export default function App() {
getSystemReadiness().then(setReadiness).catch(() => undefined)
}, [session])
useEffect(() => {
if (!session) return
let active = true
const refresh = () => {
clientProxyStatus().then(running => { if (active) setProxyRunning(running) }).catch(() => undefined)
clientSystemProxyStatus().then(on => { if (active) setSystemProxyOn(on) }).catch(error => { if (active) setProxyError(String(error)) })
}
void refresh()
const timer = window.setInterval(refresh, 5000)
return () => { active = false; window.clearInterval(timer) }
}, [session])
useEffect(() => {
if (!nodePickerOpen) return
const closeOnEscape = (event: KeyboardEvent) => { if (event.key === 'Escape') setNodePickerOpen(false) }
window.addEventListener('keydown', closeOnEscape)
return () => window.removeEventListener('keydown', closeOnEscape)
}, [nodePickerOpen])
function beginAction() {
if (actionLock.current) return false
actionLock.current = true
@@ -120,25 +162,6 @@ export default function App() {
setActionBusy(false)
}
async function handleConnect(tunnelId?: number) {
if (!beginAction()) return
setActionError('')
const target = tunnelId ?? selectedLine ?? lines.find(l => l.state === 'running')?.tunnel_id ?? lines[0]?.tunnel_id
if (!target) {
setActionError('没有可用线路,请先在“线路”页查看授权。')
endAction()
return
}
try {
const next = await clientConnect(target)
setConn(next)
setSelectedLine(target)
setPage('overview')
} catch (cause) {
setActionError(cause instanceof Error ? cause.message : String(cause))
} finally { endAction() }
}
async function handleDisconnect() {
if (!beginAction()) return
setActionError('')
@@ -151,6 +174,59 @@ export default function App() {
} finally { endAction() }
}
async function measureLine(line: Line) {
if (probeBusyLine !== null) return
setProbeBusyLine(line.tunnel_id)
setProbeMessage('')
try {
const result: ProbeResult = await clientProbe(line.tunnel_id)
if (!result.connected || result.latency_ms === undefined) throw new Error(result.detail)
setLatencies(previous => ({ ...previous, [line.tunnel_id]: result.latency_ms! }))
} catch (cause) {
setLatencies(previous => { const next = { ...previous }; delete next[line.tunnel_id]; return next })
setProbeMessage(`${line.name}:${cause instanceof Error ? cause.message : String(cause)}`)
} finally { setProbeBusyLine(null) }
}
async function toggleProxy(tunnelId?: number) {
if (proxyActionLock.current) return
proxyActionLock.current = true
setProxyBusy(true)
setProxyError('')
try {
if (await clientProxyStatus() && tunnelId === undefined) {
await clientProxyStop()
setProxyRunning(false)
setSystemProxyOn(false)
} else {
if (await clientProxyStatus()) { await clientProxyStop(); setProxyRunning(false); setSystemProxyOn(false) }
const target = tunnelId ?? selectedLine ?? lines.find(line => line.state === 'running')?.tunnel_id
const line = lines.find(item => item.tunnel_id === target && item.state === 'running')
if (!line) throw new Error('请先选择一条正在运行的授权线路。')
await clientProxyStart(line.tunnel_id)
const running = await clientProxyStatus()
setProxyRunning(running)
if (!running) throw new Error('Go 应用代理启动后已退出,请检查专线和本机端口。')
setSelectedLine(line.tunnel_id)
setSelectedMode(line.routing_mode === 'global_ipv4' ? 'global_ipv4' : 'split')
}
} catch (cause) { setProxyError(cause instanceof Error ? cause.message : String(cause)) }
finally { proxyActionLock.current = false; setProxyBusy(false) }
}
async function toggleSystemProxy() {
if (proxyActionLock.current) return
proxyActionLock.current = true
setProxyBusy(true)
setProxyError('')
try {
if (systemProxyOn) await clientSystemProxyDisable()
else await clientSystemProxyEnable()
setSystemProxyOn(await clientSystemProxyStatus())
} catch (cause) { setProxyError(cause instanceof Error ? cause.message : String(cause)) }
finally { proxyActionLock.current = false; setProxyBusy(false) }
}
async function handleLogout() {
if (!beginAction()) return
try { await clientLogout() } catch (cause) {
@@ -163,6 +239,10 @@ export default function App() {
setStatus(null)
setLines([])
setSelectedLine(null)
setSelectedMode('split')
setNodePickerOpen(false)
setLatencies({})
setProxyRunning(false)
setPage('overview')
endAction()
}
@@ -172,13 +252,14 @@ export default function App() {
}
if (!session) {
return <LoginView onLoggedIn={view => { setSession(view); setLines(view.lines) }} />
return <LoginView onLoggedIn={view => { setSession(view); setLines(view.lines); setLatencies({}) }} />
}
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 || ''
const currentLine = lines.find(l => l.tunnel_id === selectedLine) ?? lines.find(l => l.state === 'running')
return <div className="app-shell">
<aside className="sidebar" aria-label="主导航">
@@ -192,37 +273,42 @@ export default function App() {
<div className="workspace">
<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' : ''}`}>● &nbsp;{connected ? '已连接' : connecting ? '连接中' : '未连接'}</span>
<span className={`preview-pill ${connected ? 'online' : ''}`}>● &nbsp;{connected ? '已连接' : connecting ? '连接中' : proxyRunning ? systemProxyOn ? '系统代理已启用' : '应用代理已启动' : '本客户端未连接'}</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">●&nbsp;{connected ? `已连接 · ${currentLineName}` : connecting ? '连接中…' : '本客户端未连接'}</span>
<div><span className="overline">WORKSPACE / OVERVIEW</span><h1>连接概览</h1><p>选择线路,启动节点代理。</p></div>
<span className="status-tag">●&nbsp;{connected ? `已连接 · ${currentLineName}` : connecting ? '连接中…' : proxyRunning ? systemProxyOn ? '系统代理已启用' : '应用代理已启动' : '本客户端未连接'}</span>
</div>
<div className="overview-grid">
<section className="connection-card" aria-label="VPN 连接状态">
<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 || connecting ? '断开 VPN 连接' : '连接 VPN'}
disabled={actionBusy || (!connected && !connecting && readiness?.engine_mode === 'unsupported')}
onClick={() => connected || connecting ? void handleDisconnect() : void handleConnect()}>
<button type="button" className={`power-display ${connected || proxyRunning ? 'on' : ''} ${connecting ? 'busy' : ''}`}
aria-label={connected || connecting ? '断开连接' : proxyRunning ? '停止节点代理' : '启动节点代理'}
disabled={actionBusy || proxyBusy || (!connected && !connecting && !proxyRunning && !readiness?.app_proxy_available)}
onClick={() => connected || connecting ? void handleDisconnect() : void toggleProxy()}>
<Icon name="power" size={42} />
</button>
</div>
<div className="connection-message">
<strong>{connected ? '已连接' : connecting ? '连接中…' : '本客户端未连接'}</strong>
<strong>{connected ? '已连接' : connecting ? '连接中…' : proxyRunning ? systemProxyOn ? '系统代理已启用' : '应用代理已启动' : '本客户端未连接'}</strong>
<span>{connected
? `${conn?.client_address ?? ''} · 已连接 ${formatDuration(conn?.since_unix ?? 0, now)}`
: connecting ? (conn?.detail || '正在建立隧道') : readiness?.engine_mode === 'unsupported'
? '本机隧道环境尚未就绪'
? '点击按钮启动节点代理'
: (actionError || conn?.detail || '点击上方按钮接入企业网络')}</span>
</div>
{readiness?.engine_mode === 'unsupported' && <div className="connection-guide" role="status">
{readiness.engine_detail}
<button type="button" onClick={() => setPage('settings')}>查看本机环境与安装方法 →</button>
{readiness?.engine_mode === 'unsupported' && <div className="connection-guide app-proxy-guide">
{currentLine?.routing_mode === 'split' && <p>当前线路只允许授权网段,应用代理无法访问未授权的公网地址。</p>}
<button type="button" className="secondary-action" disabled={proxyBusy || actionBusy || (!proxyRunning && (!readiness.app_proxy_available || !lines.some(line => line.state === 'running')))} onClick={() => void toggleProxy()}>{proxyBusy ? '处理中…' : proxyRunning ? '停止应用代理' : '启动应用代理'}</button>
{!readiness.app_proxy_available && <small role="status">安装包缺少 Go 隧道组件,请重新安装桌面客户端。</small>}
{proxyRunning && <small>代理地址:socks5h://127.0.0.1:18743</small>}
{(proxyRunning || systemProxyOn) && <button type="button" className="secondary-action" disabled={proxyBusy || readiness.platform !== 'macos' || (!systemProxyOn && currentLine?.routing_mode !== 'global_ipv4')} onClick={() => void toggleSystemProxy()}>{systemProxyOn ? '关闭系统代理' : '启用系统代理'}</button>}
{systemProxyOn && <small>已启用 macOS 系统 SOCKS 设置;遵循系统代理的应用会通过节点访问网络。</small>}
{proxyError && <small role="alert">{proxyError}</small>}
</div>}
{connected && status?.server && <div className="traffic-grid">
<span>下行 {formatBytes(status.server.rx_bytes)}</span>
@@ -231,10 +317,11 @@ export default function App() {
</div>}
{actionError && !connecting && readiness?.engine_mode !== 'unsupported' && <div className="connection-guide" role="alert">{actionError}</div>}
{status?.server?.detail && <div className="connection-guide" role="status">{status.server.detail}</div>}
<button type="button" className="node-selector" onClick={() => setPage('routes')}>
<button type="button" className="node-selector" onClick={() => { if (currentLine) { setSelectedLine(currentLine.tunnel_id); setSelectedMode(currentLine.routing_mode === 'global_ipv4' ? 'global_ipv4' : 'split') } setNodePickerOpen(true) }}>
<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>
<Signal ms={currentLine ? latencies[currentLine.tunnel_id] : undefined} />
<small className="node-switch">选择 →</small>
</button>
</section>
<div className="right-column">
@@ -268,15 +355,50 @@ export default function App() {
<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' || actionBusy}
onClick={() => { setSelectedLine(line.tunnel_id); void handleConnect(line.tunnel_id) }}>
{connected && conn?.tunnel_name === line.name ? '已连接' : '连接'}
<Signal ms={latencies[line.tunnel_id]} />
<button type="button" className="latency-action" disabled={probeBusyLine !== null || line.state !== 'running'} onClick={() => void measureLine(line)}>{probeBusyLine === line.tunnel_id ? '探测中…' : '测隧道'}</button>
<button className="secondary-action" disabled={line.state !== 'running' || !readiness?.app_proxy_available || proxyBusy || actionBusy}
onClick={() => { setSelectedLine(line.tunnel_id); void toggleProxy(line.tunnel_id) }}>
{proxyRunning && selectedLine === line.tunnel_id ? '切换代理线路' : '启动节点代理'}
</button>
</section>)}
{probeMessage && <p className="hint" role="status">{probeMessage}</p>}
{proxyRunning && <div className="connection-guide">节点代理已启动:socks5h://127.0.0.1:18743。{systemProxyOn ? 'macOS 系统代理已启用。' : '需要使用它的应用手动设置代理。'}<button type="button" disabled={proxyBusy} onClick={() => void toggleProxy()}>停止代理</button></div>}
{proxyError && <p className="hint" role="alert">{proxyError}</p>}
</main>}
{page === 'settings' && <SettingsPage session={session} lines={lines} onLogout={handleLogout} actionBusy={actionBusy} onReadinessChange={setReadiness} />}
{page === 'settings' && <SettingsPage session={session} lines={lines} onLogout={handleLogout} actionBusy={actionBusy} onReadinessChange={setReadiness} proxyRunning={proxyRunning} proxyBusy={proxyBusy} proxyError={proxyError} onToggleProxy={toggleProxy} onProxyStatusChange={setProxyRunning} systemProxyOn={systemProxyOn} onToggleSystemProxy={toggleSystemProxy} />}
</div>
{nodePickerOpen && <div className="node-dialog-backdrop" onClick={() => setNodePickerOpen(false)}>
<section className="node-dialog" role="dialog" aria-modal="true" aria-labelledby="node-dialog-title" onClick={event => event.stopPropagation()}>
<button type="button" className="dialog-close" aria-label="关闭" onClick={() => setNodePickerOpen(false)}>×</button>
<span className="overline">VPN / LINES</span><h2 id="node-dialog-title">选择服务器节点</h2>
<p className="node-dialog-intro">只显示当前账号获授权的真实线路。耗时为经该线路到管理员设置的目标(默认公网)的 TCP 建链时间,包含隧道启动与握手。</p>
<div className="node-dialog-grid">
<div className="mode-column"><span className="dialog-column-label">网络模式</span>
{(['split', 'global_ipv4'] as const).map(mode => {
const available = lines.find(line => line.state === 'running' && (line.routing_mode === 'global_ipv4' ? 'global_ipv4' : 'split') === mode)
return <button type="button" key={mode} className={`mode-option ${selectedMode === mode ? 'selected' : ''}`} disabled={!available} onClick={() => { setSelectedMode(mode); setSelectedLine(available!.tunnel_id) }}><span className={`mode-mark ${mode === 'global_ipv4' ? 'globe' : ''}`}>{mode === 'split' ? '↗' : '◎'}</span><span><strong>{mode === 'split' ? '极速模式' : '全局模式'}</strong><small>{available ? mode === 'split' ? '选择授权内网线路' : '选择提供公网出口的线路' : '暂无此模式的授权线路'}</small></span></button>
})}
<p className="mode-help">模式随授权线路切换;同一线路的路由由服务端下发。</p>
</div>
<div className="dialog-lines"><div className="dialog-list-head"><span>接入服务器</span><span>隧道探测</span></div>
{lines.length === 0 && <p className="hint">当前没有获授权线路。</p>}
{lines.map(line => <div key={line.tunnel_id} className={`dialog-line ${selectedLine === line.tunnel_id ? 'picked' : ''}`}>
<button type="button" className="dialog-line-select" disabled={line.state !== 'running'} onClick={() => { setSelectedLine(line.tunnel_id); setSelectedMode(line.routing_mode === 'global_ipv4' ? 'global_ipv4' : 'split') }}>
<span className="selection-radio" aria-hidden="true">{selectedLine === line.tunnel_id ? '✓' : ''}</span>
<span className="dialog-line-text"><strong>{line.name}</strong><small>{line.state === 'running' ? (line.routing_mode === 'global_ipv4' ? '全局 IPv4' : '按线路分流') : '暂未运行'}</small></span>
</button>
<Signal ms={latencies[line.tunnel_id]} />
<button type="button" className="latency-action" disabled={probeBusyLine !== null || line.state !== 'running'} onClick={() => void measureLine(line)}>{probeBusyLine === line.tunnel_id ? '检测中' : '测速'}</button>
</div>)}
{probeMessage && <p className="hint" role="status">{probeMessage}</p>}
</div>
</div>
<div className="dialog-actions"><button type="button" className="dialog-cancel" onClick={() => setNodePickerOpen(false)}>取消</button><button type="button" className="dialog-confirm" disabled={!lines.some(line => line.tunnel_id === selectedLine && line.state === 'running') || actionBusy || proxyBusy || !readiness?.app_proxy_available} onClick={() => { setNodePickerOpen(false); void toggleProxy(selectedLine ?? undefined) }}>启动节点代理</button></div>
{readiness?.engine_mode === 'unsupported' && <p className="mode-help">当前使用内置节点代理;全局线路可在概览启用系统代理。</p>}
</section>
</div>}
</div>
}
@@ -284,6 +406,8 @@ export default function App() {
function LoginView({ onLoggedIn }: { onLoggedIn: (view: SessionView) => void }) {
const [address, setAddress] = useState(() => localStorage.getItem('opsx_server_address') || '')
const [stage, setStage] = useState<'unit' | 'auth'>('unit')
const [capabilities, setCapabilities] = useState<ClientCapabilities | null>(null)
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [otp, setOtp] = useState('')
@@ -295,13 +419,42 @@ function LoginView({ onLoggedIn }: { onLoggedIn: (view: SessionView) => void })
useEffect(() => { clientDeviceName().then(setDeviceName).catch(() => undefined) }, [])
async function handleContinue(event: FormEvent) {
event.preventDefault()
setBusy(true)
setError('')
try {
const healthResult = await checkServer(address)
if (!healthResult.reachable) throw new Error('服务端未就绪')
const offered = await clientCapabilities(healthResult.address)
if (!offered.wireguard) throw new Error('该服务器尚未提供 WireGuard 客户端接入')
setAddress(healthResult.address)
localStorage.setItem('opsx_server_address', healthResult.address)
setCapabilities(offered)
setHealth('reachable')
setStage('auth')
} catch (cause) { setError(cause instanceof Error ? cause.message : String(cause)) }
finally { setBusy(false) }
}
function changeUnit() {
setStage('unit')
setCapabilities(null)
setUsername('')
setPassword('')
setOtp('')
setMfaRequired(false)
setError('')
}
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(/\/$/, ''))
setPassword('')
setOtp('')
onLoggedIn(view)
} catch (cause) {
const message = cause instanceof Error ? cause.message : String(cause)
@@ -326,27 +479,36 @@ function LoginView({ onLoggedIn }: { onLoggedIn: (view: SessionView) => void })
<div className="login-intro-content">
<span className="login-eyebrow">SECURE ACCESS / 企业网络</span>
<h2>连接工作网络,<br />从这里开始。</h2>
<p>选择您的企业服务端,用已授权的 VPN 账号安全接入。</p>
<p>选择您所属企业的服务端,用管理员开通的账号安全接入。</p>
<div className="login-feature"><Icon name="shield" size={19} /><span>独立设备身份 · WireGuard 加密隧道</span></div>
</div>
<small className="login-intro-foot">OPSX / PRIVATE NETWORK</small>
</div>
<main className="login-card">
<div className="login-card-head"><span className="login-step">账号登录</span><span className="login-card-icon"><Icon name="shield" size={21} /></span></div>
<h1>欢迎使用 OpsX VPN</h1>
<p className="login-description">请使用管理员开通的 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>}
<div className="login-card-head"><span className="login-step">{stage === 'unit' ? '01 / 选择接入点' : '02 / 身份认证'}</span><span className="login-card-icon"><Icon name="shield" size={21} /></span></div>
{stage === 'unit' ? <>
<h1>选择接入点</h1>
<p className="login-description">输入企业提供的服务器地址,继续使用 VPN 拨号用户登录。</p>
<form onSubmit={handleContinue} 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={event => { setAddress(event.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>}
{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>
<p className="login-footnote">浏览器预览无法执行本机认证;请使用桌面客户端继续。</p>
</form>
</> : <>
<button type="button" className="login-back" onClick={changeUnit}>← 切换接入点</button>
<h1>登录 {unitLabel(address)}</h1>
<p className="login-description">使用该接入点提供的认证方式登录 VPN 拨号用户</p>
{capabilities?.oidc && <div className="lark-login-card"><span className="lark-mark" aria-hidden="true"><i /><i /><i /></span><div><strong>{['feishu', 'lark'].includes(capabilities.oidc_provider?.toLowerCase() || '') ? '飞书 / Lark · ' : '第三方认证 · '}OAuth2 / OIDC</strong><small>服务端支持 OIDC;客户端浏览器回调与拨号用户授权映射尚未接通</small></div><button type="button" disabled aria-label="第三方浏览器登录尚未开放">待接入</button></div>}
{capabilities?.password && <>
{capabilities.oidc && <div className="login-divider"><span>使用账号密码登录</span></div>}
<form onSubmit={handleSubmit} className="login-form">
{address.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>
@@ -361,6 +523,9 @@ function LoginView({ onLoggedIn }: { onLoggedIn: (view: SessionView) => void })
<button className="login-submit" type="submit" disabled={busy}>{busy ? '登录中…' : '登录'}</button>
<p className="login-footnote">首次连接时会自动登记本机设备</p>
</form>
</>}
{!capabilities?.password && <p className="login-description" role="status">{capabilities?.oidc ? '此接入点暂未开放客户端浏览器登录,请联系管理员启用密码登录。' : '此接入点暂未提供可用的客户端认证方式。'}</p>}
</>}
</main>
</div>
}
@@ -400,33 +565,13 @@ function ServerCard({ baseUrl }: { baseUrl?: string }) {
// ---- 设置页 ----
function SettingsPage({ session, lines, onLogout, actionBusy, onReadinessChange }: { session: SessionView; lines: Line[]; onLogout: () => void; actionBusy: boolean; onReadinessChange: (value: SystemReadiness) => void }) {
function SettingsPage({ session, lines, onLogout, actionBusy, onReadinessChange, proxyRunning, proxyBusy, proxyError, onToggleProxy, onProxyStatusChange, systemProxyOn, onToggleSystemProxy }: { session: SessionView; lines: Line[]; onLogout: () => void; actionBusy: boolean; onReadinessChange: (value: SystemReadiness) => void; proxyRunning: boolean; proxyBusy: boolean; proxyError: string; onToggleProxy: (tunnelId?: number) => Promise<void>; onProxyStatusChange: (running: boolean) => void; systemProxyOn: boolean; onToggleSystemProxy: () => Promise<void> }) {
const [readiness, setReadiness] = useState<SystemReadiness | null>(null)
const [readinessError, setReadinessError] = useState('')
const [devices, setDevices] = useState<DeviceInfo[]>([])
const [deviceError, setDeviceError] = useState('')
const [probeBusy, setProbeBusy] = useState(false)
const [probeResult, setProbeResult] = useState('')
const [proxyRunning, setProxyRunning] = useState(false)
const [proxyBusy, setProxyBusy] = useState(false)
const [proxyError, setProxyError] = useState('')
useEffect(() => { clientProxyStatus().then(setProxyRunning).catch(() => undefined) }, [])
async function toggleProxy() {
setProxyBusy(true)
setProxyError('')
try {
if (proxyRunning) { await clientProxyStop(); setProxyRunning(false) }
else {
const line = lines.find(l => l.state === 'running')
if (!line) throw new Error('当前没有正在运行的授权线路。')
await clientProxyStart(line.tunnel_id)
setProxyRunning(true)
}
} catch (cause) { setProxyError(cause instanceof Error ? cause.message : String(cause)) }
finally { setProxyBusy(false) }
}
async function runProbe() {
const line = lines.find(l => l.state === 'running')
@@ -459,6 +604,7 @@ function SettingsPage({ session, lines, onLogout, actionBusy, onReadinessChange
setDeviceError('')
try {
await clientRevokeDevice(id)
clientProxyStatus().then(onProxyStatusChange).catch(() => undefined)
loadDevices()
} catch (cause) {
setDeviceError(cause instanceof Error ? cause.message : String(cause))
@@ -494,28 +640,25 @@ function SettingsPage({ session, lines, onLogout, actionBusy, onReadinessChange
<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>隧道引擎</span><strong>{readiness.engine_mode === 'system_tools' ? '系统工具模式' : '当前不可用'}</strong>
<span>wg 命令</span><strong>{readiness.wg_found ? '已找到' : '未找到'}</strong>
<span>wg-quick 命令</span><strong>{readiness.wg_quick_found ? '已找到' : '未找到'}</strong>
<span>内置直连</span><strong>尚未就绪</strong>
<span>Go 应用代理组件</span><strong>{readiness.app_proxy_available ? '已找到' : '缺失'}</strong>
</div> : <p className="hint" role="status">{readinessError || '正在检测本机环境…'}</p>}
{readiness && <p className="hint" role="status">{readiness.engine_detail}</p>}
{readiness && (!readiness.wg_found || !readiness.wg_quick_found) && readiness.platform !== 'windows' && <div className="install-guide">
<strong>安装 WireGuard 工具</strong>
<p>{readiness.platform === 'macos' ? '在终端运行 brew install wireguard-tools。' : '请按发行版安装 wireguard-tools(Ubuntu / Debian:sudo apt install wireguard-tools)。'}</p>
<button type="button" className="secondary-action" onClick={() => { setReadiness(null); getSystemReadiness().then(result => { setReadiness(result); onReadinessChange(result) }).catch(() => setReadinessError('本机环境检测失败')) }}>重新检测</button>
</div>}
<p className="hint">建立隧道时系统会弹出提权授权;Windows 桌面隧道暂不支持,请使用 WireGuard 官方客户端。</p>
<p className="hint">内置直连尚需系统路由与 DNS 配置;当前可使用下方 Go 节点代理。</p>
<div className="install-guide probe-guide">
<strong>Go 隧道出口诊断</strong>
<p>通过当前设备的 WireGuard 参数连接公网 TCP,不修改本机路由或 DNS;这项检查不代表系统 VPN 已连接。测试线路需授权公网路由,网关需开启出口 NAT。</p>
<p>通过当前设备的 WireGuard 参数连接管理员设置的探测目标(默认公网 TCP),不修改本机路由或 DNS;这项检查不代表系统 VPN 已连接。目标必须在授权路由内。</p>
<button type="button" className="secondary-action" disabled={probeBusy} onClick={() => void runProbe()}>{probeBusy ? '检测中…' : '测试 VPN 出口'}</button>
{probeResult && <p role="status">{probeResult}</p>}
</div>
<div className="install-guide probe-guide">
<strong>本地 SOCKS5 代理(试用)</strong>
<p>通过内置 Go WireGuard 隧道为已配置代理的应用提供连接,不修改系统路由或 DNS。应用代理地址:socks5h://127.0.0.1:18743;使用 socks5h 才会经 VPN 解析域名。线路须允许目标公网路由并开启网关 NAT。</p>
<button type="button" className="secondary-action" disabled={proxyBusy} onClick={() => void toggleProxy()}>{proxyBusy ? '处理中…' : proxyRunning ? '停止本地代理' : '启动本地代理'}</button>
{proxyRunning && <p role="status">本地代理正在运行;只有主动配置此代理的应用会走 VPN。</p>}
<p>通过内置 Go WireGuard 提供 SOCKS5 连接,不修改系统路由或 DNS。手动配置地址:socks5h://127.0.0.1:18743;使用 socks5h 才会经 VPN 解析域名。目标地址必须在专线授权路由内;公网还需网关转发和 NAT。</p>
<button type="button" className="secondary-action" disabled={proxyBusy || (!proxyRunning && (!readiness?.app_proxy_available || !lines.some(line => line.state === 'running')))} onClick={() => void onToggleProxy()}>{proxyBusy ? '处理中…' : proxyRunning ? '停止本地代理' : '启动本地代理'}</button>
{proxyRunning && <p role="status">本地代理正在运行;可手动配置应用,也可在 macOS 上启用系统代理。</p>}
{(proxyRunning || systemProxyOn) && <button type="button" className="secondary-action" disabled={proxyBusy || (readiness?.platform !== 'macos' && !systemProxyOn)} onClick={() => void onToggleSystemProxy()}>{systemProxyOn ? '关闭系统代理' : '启用 macOS 系统代理'}</button>}
{proxyRunning && <p>系统代理只适用于遵循 macOS 设置的应用,需要线路允许全局 IPv4;部分软件和 UDP 流量不会使用它。</p>}
{systemProxyOn && <p role="status">系统 SOCKS 代理已启用。</p>}
{proxyError && <p role="alert">{proxyError}</p>}
</div>
</section>
+67
View File
@@ -0,0 +1,67 @@
/* Enterprise entry: the address is the only trustworthy unit identity today. */
.login-form input:focus-visible { outline: 2px solid #3977eb; outline-offset: 2px; }
.login-back { align-self: flex-start; border: 0; padding: 6px 0; margin: 18px 0 0; background: none; color: #3974e3; cursor: pointer; font-weight: 650; }
.lark-login-card { display: flex; align-items: center; gap: 13px; margin-top: 23px; padding: 16px; border: 1px solid #dfe8f6; border-radius: 14px; background: #fbfcff; }
.lark-login-card > div { display: grid; gap: 4px; flex: 1; min-width: 0; }
.lark-login-card strong { font-size: 14px; color: #263956; }
.lark-login-card small { color: #8190a7; font-size: 11px; line-height: 1.5; }
.lark-login-card button { border: 0; border-radius: 8px; padding: 9px 12px; color: #8595af; background: #e9eef7; font-weight: 700; white-space: nowrap; cursor: not-allowed; }
.lark-mark { position: relative; width: 42px; height: 42px; flex: none; border-radius: 12px; background: #f0f5ff; overflow: hidden; }
.lark-mark i { position: absolute; width: 22px; height: 11px; border-radius: 12px 2px 12px 2px; transform: rotate(-35deg); }
.lark-mark i:nth-child(1) { background: #2868f5; left: 9px; top: 21px; }
.lark-mark i:nth-child(2) { background: #16c6b1; left: 13px; top: 11px; }
.lark-mark i:nth-child(3) { background: #17479c; left: 19px; top: 22px; width: 13px; }
.login-divider { display: flex; align-items: center; gap: 13px; color: #99a6ba; font-size: 11px; margin-top: 25px; }
.login-divider::before, .login-divider::after { content: ''; flex: 1; height: 1px; background: #e5eaf3; }
/* Measured TCP setup through the selected WireGuard line, never a fabricated RTT. */
.node-signal { display: inline-flex; align-items: center; gap: 6px; white-space: nowrap; font-size: 12px; font-variant-numeric: tabular-nums; color: #18a464; }
.node-signal.slow { color: #d68819; }
.node-signal.unknown { color: #9ba8b9; }
.signal-bars { display: flex; align-items: end; gap: 2px; height: 15px; }
.signal-bars i { display: block; width: 3px; border-radius: 2px 2px 0 0; background: currentColor; }
.signal-bars i:nth-child(1) { height: 6px; }
.signal-bars i:nth-child(2) { height: 10px; }
.signal-bars i:nth-child(3) { height: 15px; }
.node-signal.unknown .signal-bars i:nth-child(n+2) { opacity: .35; }
.node-signal.slow .signal-bars i:nth-child(3) { opacity: .4; }
.latency-action { border: 0; background: transparent; color: #3876e9; padding: 7px 3px; font-size: 11px; white-space: nowrap; cursor: pointer; }
.latency-action:disabled { color: #a2aec0; cursor: default; }
.node-dialog-backdrop { position: fixed; z-index: 20; inset: 0; display: grid; place-items: center; padding: 20px; background: #1b304c88; backdrop-filter: blur(3px); }
.node-dialog { position: relative; width: min(820px, 100%); max-height: min(740px, calc(100vh - 40px)); overflow: auto; padding: 34px; border-radius: 22px; background: white; box-shadow: 0 26px 80px #14283c55; }
.node-dialog h2 { margin: 8px 0 7px; color: #182e51; letter-spacing: -.03em; }
.node-dialog-intro { margin: 0 0 25px; color: #75869f; line-height: 1.65; font-size: 12px; }
.dialog-close { position: absolute; top: 20px; right: 24px; width: 30px; height: 30px; border: 0; background: #f0f4fa; border-radius: 50%; font-size: 21px; color: #788ba7; cursor: pointer; }
.node-dialog-grid { display: grid; grid-template-columns: minmax(210px, 32%) 1fr; gap: 24px; }
.dialog-column-label, .dialog-list-head { color: #9aabc0; font-size: 11px; font-weight: 700; }
.mode-column { display: grid; align-content: start; gap: 10px; }
.mode-column .dialog-column-label { margin-bottom: 8px; }
.mode-option { display: flex; align-items: center; gap: 11px; padding: 13px; border: 1px solid transparent; border-radius: 12px; color: #75859b; }
.mode-option:is(button) { width: 100%; text-align: left; background: transparent; font: inherit; cursor: pointer; }
.mode-option:is(button):hover:not(:disabled) { background: #f6f9ff; }
.mode-option:is(button):disabled { opacity: .52; cursor: not-allowed; }
.mode-option.selected { border-color: #dbe7fb; background: #f6f9ff; color: #2353ac; }
.mode-option > span:last-child { display: grid; gap: 3px; }
.mode-option strong { font-size: 13px; color: #263953; }
.mode-option small, .mode-help { font-size: 10px; line-height: 1.6; color: #91a0b5; }
.mode-mark { width: 36px; height: 36px; display: grid; place-items: center; border-radius: 11px; background: #e8effe; font-size: 21px; }
.mode-mark.globe { background: #eef1f6; }
.mode-help { margin: 8px 0; }
.dialog-lines { min-width: 0; }
.dialog-list-head { display: flex; justify-content: space-between; padding: 0 64px 14px 12px; }
.dialog-line { display: flex; align-items: center; gap: 11px; min-height: 61px; padding: 5px 11px; border: 1px solid transparent; border-radius: 12px; }
.dialog-line.picked { border-color: #d8e7ff; background: #f7faff; }
.dialog-line-select { display: flex; flex: 1; align-items: center; gap: 11px; min-width: 0; text-align: left; border: 0; padding: 5px 0; background: transparent; cursor: pointer; }
.dialog-line-select:disabled { opacity: .55; cursor: default; }
.selection-radio { width: 19px; height: 19px; display: grid; place-items: center; flex: none; border: 1px solid #d6deea; border-radius: 50%; color: white; font-size: 12px; }
.picked .selection-radio { border-color: #3476f5; background: #3476f5; }
.dialog-line-text { min-width: 0; display: grid; gap: 3px; }
.dialog-line-text strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; color: #293e5b; }
.dialog-line-text small { color: #95a5b8; font-size: 10px; }
.dialog-actions { display: flex; justify-content: flex-end; gap: 12px; margin-top: 28px; }
.dialog-actions button { min-width: 108px; padding: 11px 17px; border-radius: 10px; cursor: pointer; font-weight: 700; }
.dialog-cancel { border: 0; background: #f2f4f8; color: #3e526f; }
.dialog-confirm { border: 0; background: #3476f5; color: #fff; }
.dialog-confirm:disabled { opacity: .5; cursor: not-allowed; }
@media (max-width: 720px) { .node-dialog { padding: 24px 18px; } .node-dialog-grid { grid-template-columns: 1fr; gap: 14px; } .mode-column { grid-template-columns: 1fr 1fr; } .mode-column .dialog-column-label, .mode-column .mode-help { grid-column: 1 / -1; } .dialog-line { flex-wrap: wrap; } }
+21 -8
View File
@@ -3,9 +3,8 @@ 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
engine_mode: 'system_tools' | 'unsupported'
app_proxy_available: boolean
engine_mode: 'unsupported'
engine_detail: string
}
@@ -15,6 +14,7 @@ export type Line = {
remark: string
state: string
endpoint: string
routing_mode?: 'split' | 'global_ipv4'
}
export type SessionView = {
@@ -46,7 +46,8 @@ export type DeviceStatus = {
detail?: string | null
}
export type ProbeResult = { connected: boolean; detail: string; target: string }
export type ProbeResult = { connected: boolean; detail: string; target: string; latency_ms?: number }
export type ClientCapabilities = { password: boolean; oidc: boolean; oidc_provider?: string | null; wireguard: boolean }
export type StatusView = { conn: ConnState; server: DeviceStatus | null }
@@ -89,10 +90,6 @@ 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()
}
@@ -105,6 +102,10 @@ export function clientProbe(tunnelId: number): Promise<ProbeResult> {
return isDesktop() ? invoke<ProbeResult>('client_probe', { tunnelId }) : desktopOnly()
}
export function clientCapabilities(baseUrl: string): Promise<ClientCapabilities> {
return isDesktop() ? invoke<ClientCapabilities>('client_capabilities', { baseUrl }) : desktopOnly()
}
export function clientProxyStart(tunnelId: number): Promise<string> {
return isDesktop() ? invoke<string>('client_proxy_start', { tunnelId }) : desktopOnly()
}
@@ -117,6 +118,18 @@ export function clientProxyStatus(): Promise<boolean> {
return isDesktop() ? invoke<boolean>('client_proxy_status') : desktopOnly()
}
export function clientSystemProxyStatus(): Promise<boolean> {
return isDesktop() ? invoke<boolean>('client_system_proxy_status') : desktopOnly()
}
export function clientSystemProxyEnable(): Promise<void> {
return isDesktop() ? invoke<void>('client_system_proxy_enable') : desktopOnly()
}
export function clientSystemProxyDisable(): Promise<void> {
return isDesktop() ? invoke<void>('client_system_proxy_disable') : desktopOnly()
}
export function clientDevices(): Promise<DeviceInfo[]> {
return isDesktop() ? invoke<DeviceInfo[]>('client_devices') : desktopOnly()
}
+7 -2
View File
@@ -20,7 +20,7 @@ body { min-width: 0; }
.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:not(:disabled):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; }
@@ -71,7 +71,12 @@ body { min-width: 0; }
/* ---- 连接状态 ---- */
.power-display.on { background: linear-gradient(145deg, #2fce88, #17a268); box-shadow: 0 16px 30px #17a26849, inset 0 1px 2px #ffffff66; }
.power-display.busy { animation: pulse 1.4s ease-in-out infinite; }
.power-display:disabled { opacity: .6; cursor: wait; }
.power-display:disabled { opacity: .6; cursor: not-allowed; }
.power-display.busy:disabled { cursor: progress; }
.connection-guide .secondary-action { display: inline-flex; align-items: center; margin: 9px 0 0; border: 1px solid #d9e6ff; border-radius: 9px; background: #e8f0ff; color: #285fd0; padding: 9px 12px; cursor: pointer; }
.connection-guide .secondary-action:disabled { cursor: not-allowed; }
.app-proxy-guide p { margin: 6px 0; }
.app-proxy-guide small { display: block; margin-top: 7px; }
@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; }