292 lines
12 KiB
TypeScript
292 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>
|
||
<a href="https://fc.funmc.cn" target="_blank" rel="noopener" className="text-accent-green hover:underline">fc.funmc.cn</a>
|
||
</div>
|
||
<div className="flex justify-between">
|
||
<span className="text-text-muted">使用文档</span>
|
||
<a href="https://fc.funmc.cn/docs" target="_blank" rel="noopener" className="text-accent-green hover:underline">fc.funmc.cn/docs</a>
|
||
</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>
|
||
)
|
||
}
|