Initial commit: FunConnect project with server, relay, client and admin panel
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
232
client/ui/src/pages/Dashboard.tsx
Normal file
232
client/ui/src/pages/Dashboard.tsx
Normal file
@@ -0,0 +1,232 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useRoomStore, Room } from '../stores/roomStore'
|
||||
import { useAuthStore } from '../stores/authStore'
|
||||
|
||||
function RoomCard({ room, onJoin }: { room: Room; onJoin: (room: Room) => void }) {
|
||||
const statusColors: Record<string, string> = {
|
||||
open: 'text-accent-green',
|
||||
in_game: 'text-accent-orange',
|
||||
closed: 'text-text-muted',
|
||||
}
|
||||
const statusLabels: Record<string, string> = {
|
||||
open: '开放',
|
||||
in_game: '游戏中',
|
||||
closed: '已关闭',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card hover:border-border-DEFAULT hover:bg-bg-tertiary transition-colors duration-150 cursor-pointer group"
|
||||
onClick={() => onJoin(room)}>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-semibold text-text-primary truncate">{room.name}</h3>
|
||||
{room.has_password && (
|
||||
<svg className="w-3.5 h-3.5 text-text-muted flex-shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
|
||||
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-text-muted text-xs mt-1">房主: {room.owner_username}</p>
|
||||
</div>
|
||||
<div className="text-right flex-shrink-0">
|
||||
<span className={`text-xs font-medium ${statusColors[room.status] ?? 'text-text-muted'}`}>
|
||||
{statusLabels[room.status] ?? room.status}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 mt-3 pt-3 border-t border-border">
|
||||
<div className="flex items-center gap-1.5 text-text-secondary text-xs">
|
||||
<svg className="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
|
||||
<circle cx="9" cy="7" r="4"/>
|
||||
<path d="M23 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75"/>
|
||||
</svg>
|
||||
{room.current_players}/{room.max_players}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-text-secondary text-xs">
|
||||
<svg className="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="16 18 22 12 16 6"/>
|
||||
<polyline points="8 6 2 12 8 18"/>
|
||||
</svg>
|
||||
{room.game_version}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
const { rooms, loading, fetchRooms, joinRoom, createRoom } = useRoomStore()
|
||||
const { user } = useAuthStore()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const [showCreate, setShowCreate] = useState(false)
|
||||
const [createName, setCreateName] = useState('')
|
||||
const [createVersion, setCreateVersion] = useState('1.20')
|
||||
const [createMax, setCreateMax] = useState('10')
|
||||
const [createPw, setCreatePw] = useState('')
|
||||
const [createPublic, setCreatePublic] = useState(true)
|
||||
const [createError, setCreateError] = useState('')
|
||||
const [creating, setCreating] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
fetchRooms()
|
||||
const id = setInterval(fetchRooms, 10000)
|
||||
return () => clearInterval(id)
|
||||
}, [])
|
||||
|
||||
const handleJoin = async (room: Room) => {
|
||||
try {
|
||||
let password: string | undefined
|
||||
if (room.has_password) {
|
||||
password = window.prompt(`房间 "${room.name}" 需要密码:`) ?? undefined
|
||||
if (password === undefined) return
|
||||
}
|
||||
await joinRoom(room.id, password)
|
||||
navigate(`/room/${room.id}`)
|
||||
} catch (e: any) {
|
||||
alert(`加入失败: ${e}`)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreate = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setCreateError('')
|
||||
setCreating(true)
|
||||
try {
|
||||
const roomId = await createRoom({
|
||||
name: createName,
|
||||
maxPlayers: parseInt(createMax) || 10,
|
||||
isPublic: createPublic,
|
||||
password: createPw || undefined,
|
||||
gameVersion: createVersion,
|
||||
})
|
||||
await joinRoom(roomId)
|
||||
navigate(`/room/${roomId}`)
|
||||
} catch (e: any) {
|
||||
setCreateError(String(e))
|
||||
setCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="px-6 py-4 border-b border-border flex items-center justify-between">
|
||||
<div>
|
||||
<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 items-center gap-2">
|
||||
<button
|
||||
onClick={() => fetchRooms()}
|
||||
className="btn-secondary flex items-center gap-2"
|
||||
>
|
||||
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="23 4 23 10 17 10"/>
|
||||
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/>
|
||||
</svg>
|
||||
刷新
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowCreate(true)}
|
||||
className="btn-primary flex items-center gap-2"
|
||||
>
|
||||
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
|
||||
</svg>
|
||||
创建房间
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Room grid */}
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
{loading && rooms.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-32 text-text-muted text-sm">
|
||||
加载中...
|
||||
</div>
|
||||
) : rooms.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-48 text-text-muted">
|
||||
<svg className="w-12 h-12 mb-3 opacity-30" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/>
|
||||
<rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/>
|
||||
</svg>
|
||||
<p className="text-sm">暂无公开房间</p>
|
||||
<p className="text-xs mt-1">创建第一个房间开始游戏</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{rooms.map((room) => (
|
||||
<RoomCard key={room.id} room={room} onJoin={handleJoin} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Create room modal */}
|
||||
{showCreate && (
|
||||
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center p-4 z-50 animate-fade-in">
|
||||
<div className="card w-full max-w-sm animate-slide-up">
|
||||
<div className="flex items-center justify-between mb-5">
|
||||
<h2 className="font-semibold text-text-primary">创建房间</h2>
|
||||
<button onClick={() => setShowCreate(false)} className="text-text-muted hover:text-text-primary">
|
||||
<svg className="w-5 h-5" 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>
|
||||
|
||||
<form onSubmit={handleCreate} className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-xs text-text-secondary mb-1">房间名称</label>
|
||||
<input className="input-field" placeholder="我的房间" value={createName}
|
||||
onChange={(e) => setCreateName(e.target.value)} required />
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<div className="flex-1">
|
||||
<label className="block text-xs text-text-secondary mb-1">游戏版本</label>
|
||||
<input className="input-field" placeholder="1.20" value={createVersion}
|
||||
onChange={(e) => setCreateVersion(e.target.value)} />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<label className="block text-xs text-text-secondary mb-1">最大人数</label>
|
||||
<input className="input-field" type="number" min="2" max="20" value={createMax}
|
||||
onChange={(e) => setCreateMax(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-text-secondary mb-1">密码(可选)</label>
|
||||
<input className="input-field" type="password" placeholder="留空表示无密码" value={createPw}
|
||||
onChange={(e) => setCreatePw(e.target.value)} />
|
||||
</div>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" checked={createPublic}
|
||||
onChange={(e) => setCreatePublic(e.target.checked)}
|
||||
className="w-4 h-4 rounded accent-accent-green" />
|
||||
<span className="text-sm text-text-secondary">公开房间</span>
|
||||
</label>
|
||||
|
||||
{createError && (
|
||||
<div className="px-3 py-2 rounded-lg bg-accent-red/10 border border-accent-red/30 text-accent-red text-xs">
|
||||
{createError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2 pt-1">
|
||||
<button type="button" className="btn-secondary flex-1" onClick={() => setShowCreate(false)}>取消</button>
|
||||
<button type="submit" className="btn-primary flex-1" disabled={creating}>
|
||||
{creating ? '创建中...' : '创建'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
168
client/ui/src/pages/Friends.tsx
Normal file
168
client/ui/src/pages/Friends.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useFriendStore, Friend } from '../stores/friendStore'
|
||||
|
||||
function Avatar({ seed, username, size = 8 }: { seed: string; username: string; size?: number }) {
|
||||
const color = `hsl(${parseInt(seed.slice(0, 8), 16) % 360}, 60%, 50%)`
|
||||
return (
|
||||
<div
|
||||
className={`w-${size} h-${size} rounded-full flex items-center justify-center text-white font-bold flex-shrink-0`}
|
||||
style={{ backgroundColor: color, fontSize: size < 10 ? '0.75rem' : '1rem' }}
|
||||
>
|
||||
{username[0]?.toUpperCase()}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FriendItem({ friend, onRemove }: { friend: Friend; onRemove: (id: string) => void }) {
|
||||
return (
|
||||
<div className="flex items-center gap-3 px-3 py-2.5 rounded-lg hover:bg-bg-tertiary transition-colors group">
|
||||
<div className="relative">
|
||||
<Avatar seed={friend.avatar_seed} username={friend.username} size={8} />
|
||||
<span className={`absolute -bottom-0.5 -right-0.5 ${friend.is_online ? 'badge-online' : 'badge-offline'}`} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-text-primary truncate">{friend.username}</p>
|
||||
<p className="text-xs text-text-muted">{friend.is_online ? '在线' : '离线'}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onRemove(friend.id)}
|
||||
className="opacity-0 group-hover:opacity-100 text-text-muted hover:text-accent-red transition-all"
|
||||
title="删除好友"
|
||||
>
|
||||
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="3 6 5 6 21 6"/>
|
||||
<path d="M19 6l-1 14H6L5 6"/>
|
||||
<path d="M10 11v6M14 11v6"/>
|
||||
<path d="M9 6V4h6v2"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function FriendsPage() {
|
||||
const { friends, requests, loading, fetchFriends, fetchRequests, sendRequest, acceptRequest, removeFriend } = useFriendStore()
|
||||
const [tab, setTab] = useState<'friends' | 'requests'>('friends')
|
||||
const [addUsername, setAddUsername] = useState('')
|
||||
const [addError, setAddError] = useState('')
|
||||
const [addSuccess, setAddSuccess] = useState('')
|
||||
const [sending, setSending] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
fetchFriends()
|
||||
fetchRequests()
|
||||
}, [])
|
||||
|
||||
const handleSendRequest = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setAddError('')
|
||||
setAddSuccess('')
|
||||
setSending(true)
|
||||
try {
|
||||
await sendRequest(addUsername)
|
||||
setAddSuccess(`已向 ${addUsername} 发送好友请求`)
|
||||
setAddUsername('')
|
||||
} catch (e: any) {
|
||||
setAddError(String(e))
|
||||
} finally {
|
||||
setSending(false)
|
||||
}
|
||||
}
|
||||
|
||||
const onlineCount = friends.filter((f) => f.is_online).length
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col overflow-hidden">
|
||||
{/* Header */}
|
||||
<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">{onlineCount} 人在线 · 共 {friends.length} 位好友</p>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
{/* Left: friend list */}
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-1 px-4 pt-4">
|
||||
{(['friends', 'requests'] as const).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => setTab(t)}
|
||||
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
|
||||
tab === t
|
||||
? 'bg-bg-tertiary text-text-primary'
|
||||
: 'text-text-muted hover:text-text-secondary'
|
||||
}`}
|
||||
>
|
||||
{t === 'friends' ? '好友列表' : `好友请求 ${requests.length > 0 ? `(${requests.length})` : ''}`}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-1">
|
||||
{tab === 'friends' ? (
|
||||
loading ? (
|
||||
<p className="text-text-muted text-sm text-center py-8">加载中...</p>
|
||||
) : friends.length === 0 ? (
|
||||
<div className="text-center py-12 text-text-muted">
|
||||
<svg className="w-10 h-10 mx-auto mb-3 opacity-30" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
|
||||
<circle cx="9" cy="7" r="4"/>
|
||||
<path d="M23 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75"/>
|
||||
</svg>
|
||||
<p className="text-sm">还没有好友</p>
|
||||
<p className="text-xs mt-1">从右侧搜索用户添加好友</p>
|
||||
</div>
|
||||
) : (
|
||||
friends.map((f) => (
|
||||
<FriendItem
|
||||
key={f.id}
|
||||
friend={f}
|
||||
onRemove={(id) => { if (confirm(`确定删除好友 ${f.username}?`)) removeFriend(id) }}
|
||||
/>
|
||||
))
|
||||
)
|
||||
) : requests.length === 0 ? (
|
||||
<p className="text-text-muted text-sm text-center py-8">暂无好友请求</p>
|
||||
) : (
|
||||
requests.map((req) => (
|
||||
<div key={req.id} className="flex items-center gap-3 px-3 py-2.5 rounded-lg bg-bg-tertiary">
|
||||
<Avatar seed={req.avatar_seed} username={req.username} size={8} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-text-primary">{req.username}</p>
|
||||
<p className="text-xs text-text-muted">想加你为好友</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => acceptRequest(req.id).then(fetchFriends)}
|
||||
className="btn-primary py-1 px-3 text-xs"
|
||||
>
|
||||
接受
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: add friend */}
|
||||
<div className="w-64 border-l border-border p-4 flex-shrink-0">
|
||||
<h3 className="text-sm font-semibold text-text-primary mb-3">添加好友</h3>
|
||||
<form onSubmit={handleSendRequest} className="space-y-2">
|
||||
<input
|
||||
className="input-field"
|
||||
placeholder="输入用户名"
|
||||
value={addUsername}
|
||||
onChange={(e) => setAddUsername(e.target.value)}
|
||||
required
|
||||
/>
|
||||
{addError && <p className="text-xs text-accent-red">{addError}</p>}
|
||||
{addSuccess && <p className="text-xs text-accent-green">{addSuccess}</p>}
|
||||
<button type="submit" disabled={sending} className="btn-primary w-full">
|
||||
{sending ? '发送中...' : '发送请求'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
102
client/ui/src/pages/Login.tsx
Normal file
102
client/ui/src/pages/Login.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import { useState, useRef } from 'react'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { useAuthStore } from '../stores/authStore'
|
||||
|
||||
export default function LoginPage() {
|
||||
const { login, loading } = useAuthStore()
|
||||
const navigate = useNavigate()
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
try {
|
||||
await login(username, password)
|
||||
navigate('/dashboard')
|
||||
} catch (e: any) {
|
||||
setError(String(e))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-bg-primary flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-sm animate-fade-in">
|
||||
{/* Logo */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-accent-green/10 border border-accent-green/30 mb-4">
|
||||
<svg className="w-8 h-8 text-accent-green" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5" stroke="currentColor" strokeWidth="2" fill="none" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-text-primary">FunMC</h1>
|
||||
<p className="text-text-secondary text-sm mt-1">Minecraft 联机助手</p>
|
||||
</div>
|
||||
|
||||
{/* Card */}
|
||||
<div className="card">
|
||||
<h2 className="text-lg font-semibold text-text-primary mb-6">登录账号</h2>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm text-text-secondary mb-1.5">用户名</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input-field"
|
||||
placeholder="输入用户名"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-text-secondary mb-1.5">密码</label>
|
||||
<input
|
||||
type="password"
|
||||
className="input-field"
|
||||
placeholder="输入密码"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="px-3 py-2 rounded-lg bg-accent-red/10 border border-accent-red/30 text-accent-red text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="btn-primary w-full mt-2"
|
||||
>
|
||||
{loading ? '登录中...' : '登录'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="text-center text-text-muted text-sm mt-4">
|
||||
没有账号?{' '}
|
||||
<Link to="/register" className="text-accent-green hover:underline">
|
||||
立即注册
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Brand footer */}
|
||||
<div className="mt-8 text-center">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<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-xs text-text-muted">魔幻方开发</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
137
client/ui/src/pages/Register.tsx
Normal file
137
client/ui/src/pages/Register.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
import { useState } from 'react'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { useAuthStore } from '../stores/authStore'
|
||||
|
||||
export default function RegisterPage() {
|
||||
const { register, loading } = useAuthStore()
|
||||
const navigate = useNavigate()
|
||||
const [username, setUsername] = useState('')
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [confirm, setConfirm] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
if (password !== confirm) {
|
||||
setError('两次密码不一致')
|
||||
return
|
||||
}
|
||||
if (password.length < 8) {
|
||||
setError('密码至少 8 位')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await register(username, email, password)
|
||||
navigate('/dashboard')
|
||||
} catch (e: any) {
|
||||
setError(String(e))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-bg-primary flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-sm animate-fade-in">
|
||||
<div className="text-center mb-8">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-accent-green/10 border border-accent-green/30 mb-4">
|
||||
<svg className="w-8 h-8 text-accent-green" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-text-primary">FunMC</h1>
|
||||
<p className="text-text-secondary text-sm mt-1">创建你的账号</p>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h2 className="text-lg font-semibold text-text-primary mb-6">注册</h2>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm text-text-secondary mb-1.5">用户名</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input-field"
|
||||
placeholder="3-32 个字符"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
minLength={3}
|
||||
maxLength={32}
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-text-secondary mb-1.5">邮箱</label>
|
||||
<input
|
||||
type="email"
|
||||
className="input-field"
|
||||
placeholder="your@email.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-text-secondary mb-1.5">密码</label>
|
||||
<input
|
||||
type="password"
|
||||
className="input-field"
|
||||
placeholder="至少 8 位"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
minLength={8}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-text-secondary mb-1.5">确认密码</label>
|
||||
<input
|
||||
type="password"
|
||||
className="input-field"
|
||||
placeholder="再次输入密码"
|
||||
value={confirm}
|
||||
onChange={(e) => setConfirm(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="px-3 py-2 rounded-lg bg-accent-red/10 border border-accent-red/30 text-accent-red text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="btn-primary w-full mt-2"
|
||||
>
|
||||
{loading ? '注册中...' : '创建账号'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="text-center text-text-muted text-sm mt-4">
|
||||
已有账号?{' '}
|
||||
<Link to="/login" className="text-accent-green hover:underline">
|
||||
立即登录
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Brand footer */}
|
||||
<div className="mt-8 text-center">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<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-xs text-text-muted">魔幻方开发</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
430
client/ui/src/pages/Room.tsx
Normal file
430
client/ui/src/pages/Room.tsx
Normal file
@@ -0,0 +1,430 @@
|
||||
import { useEffect, useState, useRef } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { listen } from '@tauri-apps/api/event'
|
||||
import { useRoomStore } from '../stores/roomStore'
|
||||
import { useNetworkStore } from '../stores/networkStore'
|
||||
import { useAuthStore } from '../stores/authStore'
|
||||
import { useChatStore } from '../stores/chatStore'
|
||||
import { useToast } from '../components/Toast'
|
||||
|
||||
export default function RoomPage() {
|
||||
const { roomId } = useParams<{ roomId: string }>()
|
||||
const navigate = useNavigate()
|
||||
const { currentRoom, rooms, members, leaveRoom, setCurrentRoom, fetchMembers } = useRoomStore()
|
||||
const { session, connectAddr, startHosting, joinNetwork, stopNetwork, stats, refreshStats } = useNetworkStore()
|
||||
const { user } = useAuthStore()
|
||||
const { messages, sendMessage, clearMessages, subscribeToChat } = useChatStore()
|
||||
const { showToast } = useToast()
|
||||
const [connecting, setConnecting] = useState(false)
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [chatInput, setChatInput] = useState('')
|
||||
const [showChat, setShowChat] = useState(true)
|
||||
const [kickingUser, setKickingUser] = useState<string | null>(null)
|
||||
const chatContainerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const room = currentRoom ?? rooms.find((r) => r.id === roomId)
|
||||
|
||||
useEffect(() => {
|
||||
if (roomId) {
|
||||
fetchMembers(roomId)
|
||||
const membersInterval = setInterval(() => fetchMembers(roomId), 5000)
|
||||
return () => clearInterval(membersInterval)
|
||||
}
|
||||
}, [roomId])
|
||||
|
||||
useEffect(() => {
|
||||
const id = setInterval(refreshStats, 2000)
|
||||
return () => clearInterval(id)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let unlisten: (() => void) | null = null
|
||||
subscribeToChat().then((fn) => {
|
||||
unlisten = fn
|
||||
})
|
||||
return () => {
|
||||
unlisten?.()
|
||||
clearMessages()
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const unlistenKicked = listen<{ room_id: string; reason: string }>('signaling:kicked', (event) => {
|
||||
if (event.payload.room_id === roomId) {
|
||||
showToast(event.payload.reason || '你已被踢出房间', 'error')
|
||||
navigate('/dashboard')
|
||||
}
|
||||
})
|
||||
|
||||
const unlistenClosed = listen<{ room_id: string }>('signaling:room_closed', (event) => {
|
||||
if (event.payload.room_id === roomId) {
|
||||
showToast('房间已关闭', 'info')
|
||||
navigate('/dashboard')
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
unlistenKicked.then((fn) => fn())
|
||||
unlistenClosed.then((fn) => fn())
|
||||
}
|
||||
}, [roomId])
|
||||
|
||||
useEffect(() => {
|
||||
if (chatContainerRef.current) {
|
||||
chatContainerRef.current.scrollTop = chatContainerRef.current.scrollHeight
|
||||
}
|
||||
}, [messages])
|
||||
|
||||
useEffect(() => {
|
||||
if (!room && !currentRoom) {
|
||||
navigate('/dashboard')
|
||||
}
|
||||
}, [room])
|
||||
|
||||
const isOwner = room?.owner_id === user?.id
|
||||
|
||||
const handleConnect = async () => {
|
||||
if (!roomId || !room) return
|
||||
setConnecting(true)
|
||||
try {
|
||||
if (isOwner) {
|
||||
await startHosting(roomId, room.name)
|
||||
} else {
|
||||
await joinNetwork(roomId, room.owner_id)
|
||||
}
|
||||
} catch (e: any) {
|
||||
alert(`连接失败: ${e}`)
|
||||
} finally {
|
||||
setConnecting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleLeave = async () => {
|
||||
if (!roomId) return
|
||||
await stopNetwork()
|
||||
await leaveRoom(roomId)
|
||||
setCurrentRoom(null)
|
||||
navigate('/dashboard')
|
||||
}
|
||||
|
||||
const handleCopy = () => {
|
||||
const addr = connectAddr ?? '127.0.0.1:25565'
|
||||
navigator.clipboard.writeText(addr)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
|
||||
const handleSendChat = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!roomId || !chatInput.trim()) return
|
||||
try {
|
||||
await sendMessage(roomId, chatInput.trim())
|
||||
setChatInput('')
|
||||
} catch (e: any) {
|
||||
console.error('Failed to send message:', e)
|
||||
}
|
||||
}
|
||||
|
||||
const handleKickMember = async (targetUserId: string, username: string) => {
|
||||
if (!roomId) return
|
||||
if (!confirm(`确定要踢出 ${username} 吗?`)) return
|
||||
|
||||
setKickingUser(targetUserId)
|
||||
try {
|
||||
await invoke('kick_room_member', { roomId, targetUserId })
|
||||
showToast(`已踢出 ${username}`, 'success')
|
||||
fetchMembers(roomId)
|
||||
} catch (e: any) {
|
||||
showToast(`踢出失败: ${e}`, 'error')
|
||||
} finally {
|
||||
setKickingUser(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCloseRoom = async () => {
|
||||
if (!roomId) return
|
||||
if (!confirm('确定要关闭房间吗?所有成员将被踢出。')) return
|
||||
|
||||
try {
|
||||
await invoke('close_room', { roomId })
|
||||
showToast('房间已关闭', 'success')
|
||||
navigate('/dashboard')
|
||||
} catch (e: any) {
|
||||
showToast(`关闭失败: ${e}`, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
const sessionTypeLabel = stats?.session_type === 'p2p' ? 'P2P 直连' : stats?.session_type === 'relay' ? '中继' : '未连接'
|
||||
const sessionTypeColor = stats?.session_type === 'p2p' ? 'text-accent-green' : stats?.session_type === 'relay' ? 'text-accent-orange' : 'text-text-muted'
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="px-6 py-4 border-b border-border flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-text-primary">{room?.name ?? '房间'}</h1>
|
||||
<p className="text-text-muted text-xs mt-0.5">
|
||||
{room?.game_version} · {room?.current_players}/{room?.max_players} 人
|
||||
{isOwner && <span className="ml-2 text-accent-green">你是房主</span>}
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={handleLeave} className="btn-danger flex items-center gap-2">
|
||||
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
|
||||
<polyline points="16 17 21 12 16 7"/>
|
||||
<line x1="21" y1="12" x2="9" y2="12"/>
|
||||
</svg>
|
||||
离开房间
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-6 space-y-4">
|
||||
{/* Network status card */}
|
||||
<div className="card">
|
||||
<h2 className="text-sm font-semibold text-text-primary mb-4">网络连接</h2>
|
||||
|
||||
{!session ? (
|
||||
<div className="space-y-3">
|
||||
<p className="text-text-secondary text-sm">
|
||||
{isOwner
|
||||
? '点击"开始托管"让 Minecraft 服务器接受来自此房间成员的连接。'
|
||||
: '点击"连接"获取本地代理地址,然后在 Minecraft 中添加该服务器。'}
|
||||
</p>
|
||||
<button
|
||||
onClick={handleConnect}
|
||||
disabled={connecting}
|
||||
className="btn-primary flex items-center gap-2"
|
||||
>
|
||||
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M5 12h14M12 5l7 7-7 7"/>
|
||||
</svg>
|
||||
{connecting ? '连接中...' : isOwner ? '开始托管' : '连接'}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{/* Stats grid */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="bg-bg-tertiary rounded-lg p-3">
|
||||
<p className="text-xs text-text-muted mb-1">连接类型</p>
|
||||
<p className={`text-sm font-semibold ${sessionTypeColor}`}>{sessionTypeLabel}</p>
|
||||
</div>
|
||||
<div className="bg-bg-tertiary rounded-lg p-3">
|
||||
<p className="text-xs text-text-muted mb-1">延迟</p>
|
||||
<p className="text-sm font-semibold text-text-primary">
|
||||
{stats?.connected ? `${stats.latency_ms} ms` : '—'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-bg-tertiary rounded-lg p-3">
|
||||
<p className="text-xs text-text-muted mb-1">上传</p>
|
||||
<p className="text-sm font-mono text-text-primary">
|
||||
{stats ? formatBytes(stats.bytes_sent) : '—'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-bg-tertiary rounded-lg p-3">
|
||||
<p className="text-xs text-text-muted mb-1">下载</p>
|
||||
<p className="text-sm font-mono text-text-primary">
|
||||
{stats ? formatBytes(stats.bytes_received) : '—'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* MC connect address */}
|
||||
{!isOwner && connectAddr && (
|
||||
<div>
|
||||
<p className="text-xs text-text-muted mb-2">在 Minecraft 中添加此服务器地址:</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 bg-bg-tertiary border border-border rounded-lg px-3 py-2 text-sm font-mono text-accent-green">
|
||||
{connectAddr}
|
||||
</code>
|
||||
<button onClick={handleCopy} className="btn-secondary px-3 py-2 text-xs">
|
||||
{copied ? '已复制' : '复制'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isOwner && (
|
||||
<div className="px-3 py-2 bg-accent-green/10 border border-accent-green/20 rounded-lg">
|
||||
<p className="text-xs text-accent-green">
|
||||
你的 Minecraft 服务器正在监听,房间成员可以通过 FunMC 连接到你的服务器。
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Room members */}
|
||||
<div className="card">
|
||||
<h2 className="text-sm font-semibold text-text-primary mb-3">
|
||||
房间成员 ({members.length})
|
||||
</h2>
|
||||
<div className="space-y-2">
|
||||
{members.length === 0 ? (
|
||||
<p className="text-text-muted text-sm">加载中...</p>
|
||||
) : (
|
||||
members.map((member) => (
|
||||
<div
|
||||
key={member.user_id}
|
||||
className="flex items-center justify-between py-2 px-3 bg-bg-tertiary rounded-lg"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative">
|
||||
<div className="w-8 h-8 rounded-full bg-accent-green/20 flex items-center justify-center text-accent-green text-sm font-medium">
|
||||
{member.username.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<span
|
||||
className={`absolute bottom-0 right-0 w-2.5 h-2.5 rounded-full border-2 border-bg-tertiary ${
|
||||
member.is_online ? 'bg-accent-green' : 'bg-text-muted'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-text-primary font-medium">
|
||||
{member.username}
|
||||
{member.user_id === user?.id && (
|
||||
<span className="ml-1 text-text-muted">(你)</span>
|
||||
)}
|
||||
</p>
|
||||
<p className="text-xs text-text-muted">
|
||||
{member.role === 'owner' ? '房主' : '成员'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`text-xs ${
|
||||
member.is_online ? 'text-accent-green' : 'text-text-muted'
|
||||
}`}
|
||||
>
|
||||
{member.is_online ? '在线' : '离线'}
|
||||
</span>
|
||||
{isOwner && member.user_id !== user?.id && (
|
||||
<button
|
||||
onClick={() => handleKickMember(member.user_id, member.username)}
|
||||
disabled={kickingUser === member.user_id}
|
||||
className="text-xs text-accent-red hover:text-red-400 disabled:opacity-50"
|
||||
title="踢出"
|
||||
>
|
||||
{kickingUser === member.user_id ? '...' : '踢出'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isOwner && (
|
||||
<div className="mt-4 pt-4 border-t border-border">
|
||||
<button
|
||||
onClick={handleCloseRoom}
|
||||
className="text-xs text-accent-red hover:text-red-400"
|
||||
>
|
||||
关闭房间
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Room info */}
|
||||
<div className="card">
|
||||
<h2 className="text-sm font-semibold text-text-primary mb-3">房间信息</h2>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-text-muted">房主</span>
|
||||
<span className="text-text-primary">{room?.owner_username}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-text-muted">游戏版本</span>
|
||||
<span className="text-text-primary">{room?.game_version}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-text-muted">人数</span>
|
||||
<span className="text-text-primary">{room?.current_players}/{room?.max_players}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-text-muted">状态</span>
|
||||
<span className="text-text-primary capitalize">{room?.status}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Chat */}
|
||||
<div className="card">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-sm font-semibold text-text-primary">房间聊天</h2>
|
||||
<button
|
||||
onClick={() => setShowChat(!showChat)}
|
||||
className="text-text-muted hover:text-text-primary text-xs"
|
||||
>
|
||||
{showChat ? '收起' : '展开'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showChat && (
|
||||
<>
|
||||
<div
|
||||
ref={chatContainerRef}
|
||||
className="h-48 overflow-y-auto bg-bg-tertiary rounded-lg p-3 mb-3 space-y-2"
|
||||
>
|
||||
{messages.length === 0 ? (
|
||||
<p className="text-text-muted text-xs text-center py-8">
|
||||
暂无消息,发送第一条消息开始聊天
|
||||
</p>
|
||||
) : (
|
||||
messages
|
||||
.filter((msg) => msg.room_id === roomId)
|
||||
.map((msg, i) => (
|
||||
<div key={i} className="text-sm">
|
||||
<span className="text-accent-green font-medium">
|
||||
{msg.username}
|
||||
{msg.from === user?.id && ' (你)'}
|
||||
</span>
|
||||
<span className="text-text-muted mx-1">:</span>
|
||||
<span className="text-text-primary">{msg.content}</span>
|
||||
<span className="text-text-muted text-xs ml-2">
|
||||
{new Date(msg.timestamp).toLocaleTimeString('zh-CN', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSendChat} className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={chatInput}
|
||||
onChange={(e) => setChatInput(e.target.value)}
|
||||
placeholder="输入消息..."
|
||||
className="input-field flex-1"
|
||||
maxLength={500}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!chatInput.trim()}
|
||||
className="btn-primary px-4"
|
||||
>
|
||||
发送
|
||||
</button>
|
||||
</form>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return '0 B'
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`
|
||||
}
|
||||
130
client/ui/src/pages/ServerSetup.tsx
Normal file
130
client/ui/src/pages/ServerSetup.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useConfigStore } from '../stores/configStore'
|
||||
|
||||
export default function ServerSetupPage() {
|
||||
const navigate = useNavigate()
|
||||
const { setCustomServer, config, loading, error } = useConfigStore()
|
||||
const [serverUrl, setServerUrl] = useState('')
|
||||
const [manualMode, setManualMode] = useState(false)
|
||||
|
||||
const handleAutoConnect = async () => {
|
||||
if (config && config.server_url) {
|
||||
navigate('/login')
|
||||
}
|
||||
}
|
||||
|
||||
const handleManualConnect = async () => {
|
||||
if (!serverUrl.trim()) return
|
||||
|
||||
let url = serverUrl.trim()
|
||||
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
||||
url = 'http://' + url
|
||||
}
|
||||
|
||||
await setCustomServer(url)
|
||||
|
||||
const { config: newConfig } = useConfigStore.getState()
|
||||
if (newConfig && newConfig.server_url) {
|
||||
navigate('/login')
|
||||
}
|
||||
}
|
||||
|
||||
const hasEmbeddedConfig = config && config.server_url && config.server_url !== 'http://localhost:3000'
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-gray-900 via-gray-800 to-gray-900 flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="text-center mb-8">
|
||||
<div className="text-6xl mb-4">🎮</div>
|
||||
<h1 className="text-3xl font-bold text-white mb-2">FunMC</h1>
|
||||
<p className="text-gray-400">Minecraft 联机工具</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-800/50 backdrop-blur rounded-2xl p-6 border border-gray-700/50">
|
||||
{hasEmbeddedConfig ? (
|
||||
<>
|
||||
<div className="text-center mb-6">
|
||||
<div className="inline-flex items-center gap-2 bg-green-500/20 text-green-400 px-4 py-2 rounded-full text-sm">
|
||||
<span className="w-2 h-2 bg-green-400 rounded-full animate-pulse"></span>
|
||||
检测到预配置服务器
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-700/50 rounded-xl p-4 mb-6">
|
||||
<p className="text-sm text-gray-400 mb-1">服务器名称</p>
|
||||
<p className="text-white font-medium">{config?.server_name || 'FunMC Server'}</p>
|
||||
<p className="text-sm text-gray-400 mt-3 mb-1">服务器地址</p>
|
||||
<p className="text-green-400 font-mono text-sm">{config?.server_url}</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleAutoConnect}
|
||||
className="w-full py-3 bg-green-600 text-white rounded-xl font-medium hover:bg-green-700 transition-colors"
|
||||
>
|
||||
连接到此服务器
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setManualMode(true)}
|
||||
className="w-full mt-3 py-3 bg-gray-700 text-gray-300 rounded-xl font-medium hover:bg-gray-600 transition-colors"
|
||||
>
|
||||
使用其他服务器
|
||||
</button>
|
||||
</>
|
||||
) : manualMode || !hasEmbeddedConfig ? (
|
||||
<>
|
||||
<h2 className="text-lg font-semibold text-white mb-4 text-center">
|
||||
连接到服务器
|
||||
</h2>
|
||||
|
||||
<div className="mb-6">
|
||||
<label className="block text-sm text-gray-400 mb-2">
|
||||
服务器地址
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={serverUrl}
|
||||
onChange={(e) => setServerUrl(e.target.value)}
|
||||
placeholder="例如: funmc.com:3000 或 192.168.1.100:3000"
|
||||
className="w-full px-4 py-3 bg-gray-700/50 border border-gray-600 rounded-xl text-white placeholder-gray-500 focus:outline-none focus:border-green-500 transition-colors"
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleManualConnect()}
|
||||
/>
|
||||
<p className="mt-2 text-xs text-gray-500">
|
||||
输入管理员提供的服务器地址
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-500/20 border border-red-500/30 rounded-lg text-red-400 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleManualConnect}
|
||||
disabled={loading || !serverUrl.trim()}
|
||||
className="w-full py-3 bg-green-600 text-white rounded-xl font-medium hover:bg-green-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading ? '连接中...' : '连接'}
|
||||
</button>
|
||||
|
||||
{hasEmbeddedConfig && (
|
||||
<button
|
||||
onClick={() => setManualMode(false)}
|
||||
className="w-full mt-3 py-3 bg-gray-700 text-gray-300 rounded-xl font-medium hover:bg-gray-600 transition-colors"
|
||||
>
|
||||
返回
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<p className="text-center text-gray-500 text-xs mt-6">
|
||||
魔幻方开发
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
299
client/ui/src/pages/Settings.tsx
Normal file
299
client/ui/src/pages/Settings.tsx
Normal file
@@ -0,0 +1,299 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user