Files
FunConnect/client/ui/src/pages/Settings.tsx

300 lines
12 KiB
TypeScript
Raw Normal View History

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>
)
}