Add bundled WireGuard diagnostics and application proxy
This commit is contained in:
1 parent
6dd3c7dd45
commit
eabb3e4472
20 files changed
+1129
-76
No files matched your search
+123
-25
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useRef, useState, type FormEvent } from 'react'
|
||||
import {
|
||||
checkServer, clientConnect, clientDeviceName, clientDevices, clientDisconnect,
|
||||
checkServer, clientConnect, clientDeviceName, clientDevices, clientDisconnect, clientProbe, clientProxyStart, clientProxyStop, clientProxyStatus,
|
||||
clientLines, clientLogin, clientLogout, clientResume, clientRevokeDevice, clientStatus,
|
||||
getSystemReadiness,
|
||||
type ConnState, type DeviceInfo, type Line, type SessionView, type StatusView,
|
||||
@@ -50,6 +50,9 @@ export default function App() {
|
||||
const [selectedLine, setSelectedLine] = useState<number | null>(null)
|
||||
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))
|
||||
|
||||
// 启动时恢复本机会话
|
||||
@@ -100,11 +103,30 @@ export default function App() {
|
||||
if (session && page === 'routes') void refreshLines()
|
||||
}, [session, page, refreshLines])
|
||||
|
||||
useEffect(() => {
|
||||
if (!session) return
|
||||
getSystemReadiness().then(setReadiness).catch(() => undefined)
|
||||
}, [session])
|
||||
|
||||
function beginAction() {
|
||||
if (actionLock.current) return false
|
||||
actionLock.current = true
|
||||
setActionBusy(true)
|
||||
return true
|
||||
}
|
||||
|
||||
function endAction() {
|
||||
actionLock.current = false
|
||||
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 {
|
||||
@@ -114,10 +136,11 @@ export default function App() {
|
||||
setPage('overview')
|
||||
} catch (cause) {
|
||||
setActionError(cause instanceof Error ? cause.message : String(cause))
|
||||
}
|
||||
} finally { endAction() }
|
||||
}
|
||||
|
||||
async function handleDisconnect() {
|
||||
if (!beginAction()) return
|
||||
setActionError('')
|
||||
try {
|
||||
await clientDisconnect()
|
||||
@@ -125,12 +148,14 @@ export default function App() {
|
||||
setStatus(null)
|
||||
} catch (cause) {
|
||||
setActionError(cause instanceof Error ? cause.message : String(cause))
|
||||
}
|
||||
} finally { endAction() }
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
if (!beginAction()) return
|
||||
try { await clientLogout() } catch (cause) {
|
||||
setActionError(cause instanceof Error ? cause.message : String(cause))
|
||||
endAction()
|
||||
return
|
||||
}
|
||||
setSession(null)
|
||||
@@ -139,6 +164,7 @@ export default function App() {
|
||||
setLines([])
|
||||
setSelectedLine(null)
|
||||
setPage('overview')
|
||||
endAction()
|
||||
}
|
||||
|
||||
if (booting) {
|
||||
@@ -180,9 +206,9 @@ export default function App() {
|
||||
<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()}>
|
||||
aria-label={connected || connecting ? '断开 VPN 连接' : '连接 VPN'}
|
||||
disabled={actionBusy || (!connected && !connecting && readiness?.engine_mode === 'unsupported')}
|
||||
onClick={() => connected || connecting ? void handleDisconnect() : void handleConnect()}>
|
||||
<Icon name="power" size={42} />
|
||||
</button>
|
||||
</div>
|
||||
@@ -190,20 +216,26 @@ export default function App() {
|
||||
<strong>{connected ? '已连接' : connecting ? '连接中…' : '本客户端未连接'}</strong>
|
||||
<span>{connected
|
||||
? `${conn?.client_address ?? ''} · 已连接 ${formatDuration(conn?.since_unix ?? 0, now)}`
|
||||
: connecting ? (conn?.detail || '正在建立隧道') : (conn?.detail || actionError || '点击上方按钮接入企业网络')}</span>
|
||||
: 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>
|
||||
</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>}
|
||||
{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>}
|
||||
<div className="node-selector" role="button" tabIndex={0} onClick={() => setPage('routes')} onKeyDown={e => e.key === 'Enter' && setPage('routes')}>
|
||||
<button type="button" className="node-selector" onClick={() => 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>
|
||||
</button>
|
||||
</section>
|
||||
<div className="right-column">
|
||||
<section className="info-card">
|
||||
@@ -213,7 +245,7 @@ export default function App() {
|
||||
<span>本机设备</span><strong>{session.device_name}</strong>
|
||||
<span>授权线路</span><strong>{session.offline ? '服务端暂不可达' : `${lines.length} 条`}</strong>
|
||||
</div>
|
||||
<button className="secondary-action" onClick={() => void handleLogout()}>退出登录</button>
|
||||
<button className="secondary-action" disabled={actionBusy} onClick={() => void handleLogout()}>退出登录</button>
|
||||
</section>
|
||||
<ServerCard baseUrl={undefined} />
|
||||
</div>
|
||||
@@ -236,14 +268,14 @@ 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' || connecting}
|
||||
<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 ? '已连接' : '连接'}
|
||||
</button>
|
||||
</section>)}
|
||||
</main>}
|
||||
|
||||
{page === 'settings' && <SettingsPage session={session} onLogout={handleLogout} />}
|
||||
{page === 'settings' && <SettingsPage session={session} lines={lines} onLogout={handleLogout} actionBusy={actionBusy} onReadinessChange={setReadiness} />}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -289,10 +321,20 @@ function LoginView({ onLoggedIn }: { onLoggedIn: (view: SessionView) => void })
|
||||
}
|
||||
|
||||
return <div className="app-shell login-shell">
|
||||
<div className="login-intro">
|
||||
<div className="login-brand"><span className="login-brand-mark"><Icon name="shield" size={26} /></span><span>OpsX <strong>VPN</strong></span></div>
|
||||
<div className="login-intro-content">
|
||||
<span className="login-eyebrow">SECURE ACCESS / 企业网络</span>
|
||||
<h2>连接工作网络,<br />从这里开始。</h2>
|
||||
<p>选择您的企业服务端,用已授权的 VPN 账号安全接入。</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="topbar-brand"><span className="brand-dot" />OpsX <strong>VPN</strong><span className="divider" />企业网络接入</div>
|
||||
<h1>登录</h1>
|
||||
<p className="hint">使用管理员为您开通的 VPN 拨号账号登录;首次连接会自动为本机登记设备。</p>
|
||||
<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">
|
||||
@@ -304,7 +346,7 @@ function LoginView({ onLoggedIn }: { onLoggedIn: (view: SessionView) => void })
|
||||
</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>}
|
||||
{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>
|
||||
@@ -313,10 +355,11 @@ function LoginView({ onLoggedIn }: { onLoggedIn: (view: SessionView) => void })
|
||||
<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>
|
||||
<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>
|
||||
<p className="login-footnote">首次连接时会自动登记本机设备</p>
|
||||
</form>
|
||||
</main>
|
||||
</div>
|
||||
@@ -357,11 +400,46 @@ function ServerCard({ baseUrl }: { baseUrl?: string }) {
|
||||
|
||||
// ---- 设置页 ----
|
||||
|
||||
function SettingsPage({ session, onLogout }: { session: SessionView; onLogout: () => void }) {
|
||||
function SettingsPage({ session, lines, onLogout, actionBusy, onReadinessChange }: { session: SessionView; lines: Line[]; onLogout: () => void; actionBusy: boolean; onReadinessChange: (value: SystemReadiness) => 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')
|
||||
if (!line) { setProbeResult('当前没有正在运行的授权线路。'); return }
|
||||
setProbeBusy(true)
|
||||
setProbeResult('')
|
||||
try {
|
||||
const result = await clientProbe(line.tunnel_id)
|
||||
setProbeResult(result.detail)
|
||||
} catch (cause) {
|
||||
setProbeResult(cause instanceof Error ? cause.message : String(cause))
|
||||
} finally { setProbeBusy(false) }
|
||||
}
|
||||
|
||||
const loadDevices = useCallback(() => {
|
||||
clientDevices().then(setDevices).catch(err => setDeviceError(String(err?.message ?? err)))
|
||||
@@ -370,11 +448,11 @@ function SettingsPage({ session, onLogout }: { session: SessionView; onLogout: (
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
getSystemReadiness()
|
||||
.then(result => { if (active) setReadiness(result) })
|
||||
.then(result => { if (active) { setReadiness(result); onReadinessChange(result) } })
|
||||
.catch(() => { if (active) setReadinessError('本机检测仅在 Tauri 桌面应用中可用。') })
|
||||
loadDevices()
|
||||
return () => { active = false }
|
||||
}, [loadDevices])
|
||||
}, [loadDevices, onReadinessChange])
|
||||
|
||||
async function revoke(id: number, name: string) {
|
||||
if (!window.confirm(`确定撤销设备「${name}」?该设备将立即失去接入能力,需要重新登记。`)) return
|
||||
@@ -398,7 +476,7 @@ function SettingsPage({ session, onLogout }: { session: SessionView; onLogout: (
|
||||
<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>
|
||||
<button className="secondary-action" disabled={actionBusy} onClick={onLogout}>退出登录(同时断开 VPN)</button>
|
||||
</section>
|
||||
|
||||
<section className="info-card system-card">
|
||||
@@ -416,10 +494,30 @@ function SettingsPage({ session, onLogout }: { session: SessionView; onLogout: (
|
||||
<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>
|
||||
<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>
|
||||
</div> : <p className="hint" role="status">{readinessError || '正在检测本机环境…'}</p>}
|
||||
<p className="hint">建立隧道时系统会弹出提权授权;Windows 暂请使用 WireGuard 官方客户端导入配置。</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>
|
||||
<div className="install-guide probe-guide">
|
||||
<strong>Go 隧道出口诊断</strong>
|
||||
<p>通过当前设备的 WireGuard 参数连接公网 TCP,不修改本机路由或 DNS;这项检查不代表系统 VPN 已连接。测试线路需授权公网路由,网关需开启出口 NAT。</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>}
|
||||
{proxyError && <p role="alert">{proxyError}</p>}
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
}
|
||||
+25
-1
@@ -1,7 +1,13 @@
|
||||
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 SystemReadiness = {
|
||||
platform: string
|
||||
wg_found: boolean
|
||||
wg_quick_found: boolean
|
||||
engine_mode: 'system_tools' | 'unsupported'
|
||||
engine_detail: string
|
||||
}
|
||||
|
||||
export type Line = {
|
||||
tunnel_id: number
|
||||
@@ -40,6 +46,8 @@ export type DeviceStatus = {
|
||||
detail?: string | null
|
||||
}
|
||||
|
||||
export type ProbeResult = { connected: boolean; detail: string; target: string }
|
||||
|
||||
export type StatusView = { conn: ConnState; server: DeviceStatus | null }
|
||||
|
||||
export type DeviceInfo = {
|
||||
@@ -93,6 +101,22 @@ export function clientStatus(): Promise<StatusView> {
|
||||
return isDesktop() ? invoke<StatusView>('client_status') : desktopOnly()
|
||||
}
|
||||
|
||||
export function clientProbe(tunnelId: number): Promise<ProbeResult> {
|
||||
return isDesktop() ? invoke<ProbeResult>('client_probe', { tunnelId }) : desktopOnly()
|
||||
}
|
||||
|
||||
export function clientProxyStart(tunnelId: number): Promise<string> {
|
||||
return isDesktop() ? invoke<string>('client_proxy_start', { tunnelId }) : desktopOnly()
|
||||
}
|
||||
|
||||
export function clientProxyStop(): Promise<void> {
|
||||
return isDesktop() ? invoke<void>('client_proxy_stop') : desktopOnly()
|
||||
}
|
||||
|
||||
export function clientProxyStatus(): Promise<boolean> {
|
||||
return isDesktop() ? invoke<boolean>('client_proxy_status') : desktopOnly()
|
||||
}
|
||||
|
||||
export function clientDevices(): Promise<DeviceInfo[]> {
|
||||
return isDesktop() ? invoke<DeviceInfo[]>('client_devices') : desktopOnly()
|
||||
}
|
||||
|
||||
+48
-7
@@ -1,4 +1,11 @@
|
||||
.system-card { margin-top: 20px; }
|
||||
/* 导航保持在视口内,长设置页只滚动右侧内容。 */
|
||||
body { min-width: 0; }
|
||||
.sidebar { position: sticky; top: 0; height: 100vh; overflow-y: auto; }
|
||||
.workspace { min-width: 0; }
|
||||
.topbar-brand { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
|
||||
.system-grid strong, .node-selector-copy { min-width: 0; overflow-wrap: anywhere; }
|
||||
@media (max-width: 720px) { .system-grid { grid-template-columns: minmax(90px, 32%) 1fr; } }
|
||||
.system-card .info-heading { margin-bottom: 15px; }
|
||||
.system-grid { display: grid; grid-template-columns: 140px 1fr; gap: 14px; padding: 16px 0; border-top: 1px solid #edf1f7; font-size: 12px; }
|
||||
.system-grid span { color: #8292a8; }
|
||||
@@ -19,18 +26,52 @@
|
||||
.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-shell { min-height: 100vh; align-items: stretch; justify-content: center; padding: 30px; background: #f0f4fb; }
|
||||
.login-intro { width: min(43%, 430px); min-height: 640px; padding: 36px; display: flex; flex-direction: column; color: white; border-radius: 24px 0 0 24px; background: radial-gradient(circle at 15% 75%, #5989ef 0, transparent 40%), linear-gradient(150deg, #1648b4, #2769dc 65%, #4382eb); position: relative; overflow: hidden; }
|
||||
.login-intro::after { content: ''; position: absolute; width: 300px; height: 300px; right: -140px; top: 22%; border: 1px solid #ffffff3a; border-radius: 50%; box-shadow: 0 0 0 42px #ffffff09, 0 0 0 86px #ffffff08; pointer-events: none; }
|
||||
.login-brand { display: flex; align-items: center; gap: 11px; font-size: 20px; letter-spacing: -.04em; position: relative; z-index: 1; }
|
||||
.login-brand-mark { display: grid; place-items: center; width: 42px; height: 42px; border-radius: 12px; background: #ffffff2b; }
|
||||
.login-intro-content { margin: auto 0; position: relative; z-index: 1; }
|
||||
.login-eyebrow { font-size: 11px; font-weight: 750; letter-spacing: .15em; color: #bdd4ff; }
|
||||
.login-intro h2 { margin: 19px 0 16px; font-size: clamp(27px, 3vw, 37px); line-height: 1.25; letter-spacing: -.04em; }
|
||||
.login-intro p { max-width: 280px; color: #dce9ff; line-height: 1.8; font-size: 13px; }
|
||||
.login-feature { margin-top: 46px; padding: 13px 0; display: flex; align-items: center; gap: 10px; border-top: 1px solid #ffffff46; font-size: 12px; color: #eaf2ff; }
|
||||
.login-intro-foot { font-size: 10px; letter-spacing: .16em; color: #c6d9ff; }
|
||||
.login-card { width: min(57%, 550px); min-width: 0; align-self: stretch; display: flex; flex-direction: column; justify-content: center; background: white; border: 1px solid #e8edf5; border-left: 0; border-radius: 0 24px 24px 0; box-shadow: 0 22px 55px #1d488915; padding: 42px clamp(30px, 5vw, 68px); }
|
||||
.login-card-head { display: flex; align-items: center; justify-content: space-between; color: #3574e9; }
|
||||
.login-step { border-radius: 100px; background: #eff5ff; padding: 7px 12px; font-size: 11px; font-weight: 700; }
|
||||
.login-card-icon { color: #3476f5; }
|
||||
.login-card h1 { font-size: 26px; margin: 24px 0 6px; }
|
||||
.login-description { margin: 0; font-size: 12px; color: #8391a6; }
|
||||
.login-form { display: grid; gap: 4px; margin-top: 20px; }
|
||||
.login-form label { font-size: 12px; font-weight: 700; color: #4f6078; margin: 10px 0 5px; }
|
||||
.login-form .input-row input { height: 43px; background: #fbfcff; font-size: 13px; }
|
||||
.login-submit { margin-top: 23px; height: 46px; border: 0; border-radius: 10px; background: #3476f5; color: #fff; font-size: 14px; font-weight: 700; box-shadow: 0 8px 18px #3476f533; }
|
||||
.login-submit:hover { background: #205ed9; }
|
||||
.login-submit:disabled { opacity: .6; cursor: wait; }
|
||||
.login-footnote { margin: 11px 0 0; text-align: center; font-size: 11px; color: #94a1b2; }
|
||||
@media (max-width: 780px) {
|
||||
.login-shell { padding: 18px; }
|
||||
.login-intro { width: 34%; padding: 25px; }
|
||||
.login-card { width: 66%; padding: 32px; }
|
||||
.login-intro h2 { font-size: 26px; }
|
||||
.login-feature { line-height: 1.5; }
|
||||
}
|
||||
@media (max-width: 620px) {
|
||||
.login-shell { display: block; padding: 12px; }
|
||||
.login-intro { width: 100%; min-height: auto; padding: 15px 22px; border-radius: 18px 18px 0 0; }
|
||||
.login-intro-content, .login-intro-foot { display: none; }
|
||||
.login-card { width: 100%; border-left: 1px solid #e8edf5; border-radius: 0 0 18px 18px; padding: 25px; }
|
||||
}
|
||||
.install-guide { padding: 14px; border: 1px solid #dae7ff; border-radius: 10px; background: #f5f8ff; font-size: 12px; color: #354969; }
|
||||
.install-guide p { margin: 7px 0 12px; line-height: 1.6; overflow-wrap: anywhere; }
|
||||
.probe-guide { margin-top: 15px; }
|
||||
.secondary-action:disabled { opacity: .55; cursor: not-allowed; }
|
||||
|
||||
/* ---- 连接状态 ---- */
|
||||
.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; }
|
||||
.power-display.busy { animation: pulse 1.4s ease-in-out infinite; }
|
||||
.power-display:disabled { opacity: .6; cursor: wait; }
|
||||
@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; }
|
||||
|
||||
Reference in new issue
Block a user