300 lines
12 KiB
TypeScript
300 lines
12 KiB
TypeScript
|
|
import { useEffect, useState } from 'react'
|
|||
|
|
import { useNavigate } from 'react-router-dom'
|
|||
|
|
import { useRelayNodeStore } from '../stores/relayNodeStore'
|
|||
|
|
import { useConfigStore } from '../stores/configStore'
|
|||
|
|
import { useAuthStore } from '../stores/authStore'
|
|||
|
|
|
|||
|
|
export default function SettingsPage() {
|
|||
|
|
const navigate = useNavigate()
|
|||
|
|
const { nodes, loading, fetchNodes, addNode, removeNode, reportPing } = useRelayNodeStore()
|
|||
|
|
const { config, customServerUrl, setCustomServer, clearCustomServer } = useConfigStore()
|
|||
|
|
const { logout } = useAuthStore()
|
|||
|
|
const [newName, setNewName] = useState('')
|
|||
|
|
const [newUrl, setNewUrl] = useState('')
|
|||
|
|
const [newRegion, setNewRegion] = useState('')
|
|||
|
|
const [adding, setAdding] = useState(false)
|
|||
|
|
const [addError, setAddError] = useState('')
|
|||
|
|
const [pinging, setPinging] = useState<string | null>(null)
|
|||
|
|
const [showServerChange, setShowServerChange] = useState(false)
|
|||
|
|
const [newServerUrl, setNewServerUrl] = useState('')
|
|||
|
|
const [serverError, setServerError] = useState('')
|
|||
|
|
const [changingServer, setChangingServer] = useState(false)
|
|||
|
|
|
|||
|
|
useEffect(() => {
|
|||
|
|
fetchNodes()
|
|||
|
|
}, [])
|
|||
|
|
|
|||
|
|
const handleAdd = async (e: React.FormEvent) => {
|
|||
|
|
e.preventDefault()
|
|||
|
|
setAddError('')
|
|||
|
|
setAdding(true)
|
|||
|
|
try {
|
|||
|
|
await addNode(newName, newUrl, newRegion || undefined)
|
|||
|
|
setNewName('')
|
|||
|
|
setNewUrl('')
|
|||
|
|
setNewRegion('')
|
|||
|
|
} catch (e: any) {
|
|||
|
|
setAddError(String(e))
|
|||
|
|
} finally {
|
|||
|
|
setAdding(false)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const handlePing = async (id: string, url: string) => {
|
|||
|
|
setPinging(id)
|
|||
|
|
try {
|
|||
|
|
const start = performance.now()
|
|||
|
|
await fetch(`${url}/ping`, { signal: AbortSignal.timeout(4000) }).catch(() => {})
|
|||
|
|
const rtt = Math.round(performance.now() - start)
|
|||
|
|
await reportPing(id, rtt)
|
|||
|
|
} finally {
|
|||
|
|
setPinging(null)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<div className="h-full flex flex-col overflow-hidden">
|
|||
|
|
<div className="px-6 py-4 border-b border-border">
|
|||
|
|
<h1 className="text-xl font-bold text-text-primary">设置</h1>
|
|||
|
|
<p className="text-text-muted text-xs mt-0.5">管理应用配置</p>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div className="flex-1 overflow-y-auto p-6 space-y-6 max-w-2xl">
|
|||
|
|
{/* Server config */}
|
|||
|
|
<div className="card">
|
|||
|
|
<h2 className="text-sm font-semibold text-text-primary mb-1">服务器连接</h2>
|
|||
|
|
<p className="text-xs text-text-muted mb-4">当前连接的服务器信息</p>
|
|||
|
|
|
|||
|
|
<div className="p-3 rounded-lg bg-bg-tertiary border border-border mb-4">
|
|||
|
|
<div className="flex items-center justify-between">
|
|||
|
|
<div>
|
|||
|
|
<p className="text-sm font-medium text-text-primary">
|
|||
|
|
{config?.server_name || 'FunMC Server'}
|
|||
|
|
</p>
|
|||
|
|
<p className="text-xs text-text-muted font-mono mt-1">
|
|||
|
|
{config?.server_url || '未配置'}
|
|||
|
|
</p>
|
|||
|
|
</div>
|
|||
|
|
<div className="flex items-center gap-2">
|
|||
|
|
<span className="w-2 h-2 bg-accent-green rounded-full animate-pulse"></span>
|
|||
|
|
<span className="text-xs text-accent-green">已连接</span>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
{customServerUrl && (
|
|||
|
|
<p className="text-xs text-accent-orange mt-2">使用自定义服务器</p>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{showServerChange ? (
|
|||
|
|
<div className="space-y-3">
|
|||
|
|
<input
|
|||
|
|
type="text"
|
|||
|
|
value={newServerUrl}
|
|||
|
|
onChange={(e) => setNewServerUrl(e.target.value)}
|
|||
|
|
placeholder="输入新的服务器地址"
|
|||
|
|
className="input-field"
|
|||
|
|
/>
|
|||
|
|
{serverError && (
|
|||
|
|
<p className="text-xs text-accent-red">{serverError}</p>
|
|||
|
|
)}
|
|||
|
|
<div className="flex gap-2">
|
|||
|
|
<button
|
|||
|
|
onClick={async () => {
|
|||
|
|
setServerError('')
|
|||
|
|
setChangingServer(true)
|
|||
|
|
try {
|
|||
|
|
let url = newServerUrl.trim()
|
|||
|
|
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
|||
|
|
url = 'http://' + url
|
|||
|
|
}
|
|||
|
|
await setCustomServer(url)
|
|||
|
|
const { config: newConfig } = useConfigStore.getState()
|
|||
|
|
if (newConfig && newConfig.server_url) {
|
|||
|
|
await logout()
|
|||
|
|
navigate('/login')
|
|||
|
|
} else {
|
|||
|
|
setServerError('无法连接到服务器')
|
|||
|
|
}
|
|||
|
|
} catch (e) {
|
|||
|
|
setServerError(String(e))
|
|||
|
|
} finally {
|
|||
|
|
setChangingServer(false)
|
|||
|
|
}
|
|||
|
|
}}
|
|||
|
|
disabled={changingServer || !newServerUrl.trim()}
|
|||
|
|
className="btn-primary flex-1"
|
|||
|
|
>
|
|||
|
|
{changingServer ? '连接中...' : '切换服务器'}
|
|||
|
|
</button>
|
|||
|
|
<button
|
|||
|
|
onClick={() => {
|
|||
|
|
setShowServerChange(false)
|
|||
|
|
setNewServerUrl('')
|
|||
|
|
setServerError('')
|
|||
|
|
}}
|
|||
|
|
className="btn-secondary"
|
|||
|
|
>
|
|||
|
|
取消
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
) : (
|
|||
|
|
<div className="flex gap-2">
|
|||
|
|
<button
|
|||
|
|
onClick={() => setShowServerChange(true)}
|
|||
|
|
className="btn-secondary text-sm"
|
|||
|
|
>
|
|||
|
|
切换服务器
|
|||
|
|
</button>
|
|||
|
|
{customServerUrl && (
|
|||
|
|
<button
|
|||
|
|
onClick={async () => {
|
|||
|
|
clearCustomServer()
|
|||
|
|
await logout()
|
|||
|
|
navigate('/setup')
|
|||
|
|
}}
|
|||
|
|
className="btn-secondary text-sm text-accent-orange"
|
|||
|
|
>
|
|||
|
|
恢复默认服务器
|
|||
|
|
</button>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* Relay nodes */}
|
|||
|
|
<div className="card">
|
|||
|
|
<h2 className="text-sm font-semibold text-text-primary mb-1">中继服务器节点</h2>
|
|||
|
|
<p className="text-xs text-text-muted mb-4">
|
|||
|
|
支持多节点,P2P 穿透失败时自动选择延迟最低的节点进行流量中继。
|
|||
|
|
</p>
|
|||
|
|
|
|||
|
|
{loading ? (
|
|||
|
|
<p className="text-xs text-text-muted">加载中...</p>
|
|||
|
|
) : nodes.length === 0 ? (
|
|||
|
|
<p className="text-xs text-text-muted">暂无节点</p>
|
|||
|
|
) : (
|
|||
|
|
<div className="space-y-2 mb-4">
|
|||
|
|
{nodes.map((node) => (
|
|||
|
|
<div key={node.id} className="flex items-center gap-2 p-2.5 rounded-lg bg-bg-tertiary border border-border">
|
|||
|
|
<div className="flex-1 min-w-0">
|
|||
|
|
<div className="flex items-center gap-2">
|
|||
|
|
<span className="text-sm font-medium text-text-primary truncate">{node.name}</span>
|
|||
|
|
{node.region && node.region !== 'auto' && (
|
|||
|
|
<span className="text-xs px-1.5 py-0.5 rounded bg-bg-hover text-text-muted">
|
|||
|
|
{node.region}
|
|||
|
|
</span>
|
|||
|
|
)}
|
|||
|
|
{node.priority > 0 && (
|
|||
|
|
<span className="text-xs px-1.5 py-0.5 rounded bg-accent-green/15 text-accent-green border border-accent-green/20">
|
|||
|
|
主节点
|
|||
|
|
</span>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
<div className="flex items-center gap-3 mt-0.5">
|
|||
|
|
<p className="text-xs text-text-muted font-mono truncate">{node.url}</p>
|
|||
|
|
{node.last_ping_ms != null && (
|
|||
|
|
<span className={`text-xs font-mono flex-shrink-0 ${
|
|||
|
|
node.last_ping_ms < 80 ? 'text-accent-green' :
|
|||
|
|
node.last_ping_ms < 200 ? 'text-accent-orange' : 'text-accent-red'
|
|||
|
|
}`}>
|
|||
|
|
{node.last_ping_ms} ms
|
|||
|
|
</span>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<div className="flex items-center gap-1 flex-shrink-0">
|
|||
|
|
<button
|
|||
|
|
onClick={() => handlePing(node.id, node.url)}
|
|||
|
|
disabled={pinging === node.id}
|
|||
|
|
className="text-xs px-2 py-1 rounded text-text-muted hover:text-accent-blue hover:bg-accent-blue/10 transition-colors"
|
|||
|
|
>
|
|||
|
|
{pinging === node.id ? '测速...' : '测速'}
|
|||
|
|
</button>
|
|||
|
|
<button
|
|||
|
|
onClick={() => removeNode(node.id)}
|
|||
|
|
className="text-text-muted hover:text-accent-red transition-colors p-1"
|
|||
|
|
>
|
|||
|
|
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|||
|
|
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
|||
|
|
</svg>
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
))}
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{/* Add form */}
|
|||
|
|
<form onSubmit={handleAdd} className="border-t border-border pt-4 space-y-2">
|
|||
|
|
<p className="text-xs text-text-secondary font-medium mb-2">添加节点</p>
|
|||
|
|
<div className="flex gap-2">
|
|||
|
|
<input
|
|||
|
|
className="input-field w-28"
|
|||
|
|
placeholder="名称"
|
|||
|
|
value={newName}
|
|||
|
|
onChange={(e) => setNewName(e.target.value)}
|
|||
|
|
required
|
|||
|
|
/>
|
|||
|
|
<input
|
|||
|
|
className="input-field flex-1"
|
|||
|
|
placeholder="https://relay.example.com"
|
|||
|
|
value={newUrl}
|
|||
|
|
onChange={(e) => setNewUrl(e.target.value)}
|
|||
|
|
type="url"
|
|||
|
|
required
|
|||
|
|
/>
|
|||
|
|
<input
|
|||
|
|
className="input-field w-24"
|
|||
|
|
placeholder="区域"
|
|||
|
|
value={newRegion}
|
|||
|
|
onChange={(e) => setNewRegion(e.target.value)}
|
|||
|
|
/>
|
|||
|
|
<button type="submit" disabled={adding} className="btn-secondary flex-shrink-0">
|
|||
|
|
{adding ? '添加中' : '添加'}
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
{addError && <p className="text-xs text-accent-red">{addError}</p>}
|
|||
|
|
</form>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* About */}
|
|||
|
|
<div className="card">
|
|||
|
|
<h2 className="text-sm font-semibold text-text-primary mb-3">关于</h2>
|
|||
|
|
<div className="space-y-1 text-sm">
|
|||
|
|
<div className="flex justify-between">
|
|||
|
|
<span className="text-text-muted">版本</span>
|
|||
|
|
<span className="text-text-primary font-mono">0.1.0</span>
|
|||
|
|
</div>
|
|||
|
|
<div className="flex justify-between">
|
|||
|
|
<span className="text-text-muted">开发团队</span>
|
|||
|
|
<span className="text-accent-purple font-medium">魔幻方</span>
|
|||
|
|
</div>
|
|||
|
|
<div className="flex justify-between">
|
|||
|
|
<span className="text-text-muted">官网</span>
|
|||
|
|
<a href="https://funmc.com" target="_blank" rel="noopener" className="text-accent-green hover:underline">funmc.com</a>
|
|||
|
|
</div>
|
|||
|
|
<div className="flex justify-between">
|
|||
|
|
<span className="text-text-muted">框架</span>
|
|||
|
|
<span className="text-text-primary">Tauri 2 + React</span>
|
|||
|
|
</div>
|
|||
|
|
<div className="flex justify-between">
|
|||
|
|
<span className="text-text-muted">协议</span>
|
|||
|
|
<span className="text-text-primary">QUIC (quinn) over UDP</span>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<div className="mt-4 pt-4 border-t border-border text-center">
|
|||
|
|
<div className="flex items-center justify-center gap-2 mb-1">
|
|||
|
|
<svg className="w-4 h-4 text-accent-purple" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|||
|
|
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/>
|
|||
|
|
</svg>
|
|||
|
|
<span className="text-sm font-medium text-text-primary">魔幻方开发</span>
|
|||
|
|
</div>
|
|||
|
|
<p className="text-xs text-text-muted">© 2024 魔幻方. All rights reserved.</p>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
)
|
|||
|
|
}
|