Initial commit: FunConnect project with server, relay, client and admin panel

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-02-24 20:56:36 +08:00
parent eb6e901440
commit b6891483ae
167 changed files with 16147 additions and 106 deletions

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

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

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

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

View 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]}`
}

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

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