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,101 @@
import { create } from 'zustand'
import { invoke } from '@tauri-apps/api/core'
export interface User {
id: string
username: string
email: string
avatar_seed: string
}
interface AuthResult {
user: User
token: string
}
interface AuthState {
user: User | null
token: string | null
loading: boolean
error: string | null
initialized: boolean
login: (username: string, password: string) => Promise<void>
register: (username: string, email: string, password: string) => Promise<void>
logout: () => Promise<void>
init: () => Promise<void>
connectSignaling: () => Promise<void>
}
export const useAuthStore = create<AuthState>((set, get) => ({
user: null,
token: localStorage.getItem('auth_token'),
loading: false,
error: null,
initialized: false,
init: async () => {
if (get().initialized) return
try {
const result = await invoke<AuthResult | null>('get_current_user')
if (result && result.user && result.token) {
localStorage.setItem('auth_token', result.token)
set({ user: result.user, token: result.token, initialized: true })
// Auto-connect signaling after init
get().connectSignaling().catch(console.error)
} else {
localStorage.removeItem('auth_token')
set({ user: null, token: null, initialized: true })
}
} catch (e) {
console.error('Auth init failed:', e)
localStorage.removeItem('auth_token')
set({ user: null, token: null, initialized: true })
}
},
connectSignaling: async () => {
try {
await invoke('connect_signaling')
} catch (e) {
console.error('Signaling connection failed:', e)
}
},
login: async (username, password) => {
set({ loading: true, error: null })
try {
const result = await invoke<AuthResult>('login', { username, password })
localStorage.setItem('auth_token', result.token)
set({ user: result.user, token: result.token, loading: false })
// Connect signaling after login
get().connectSignaling().catch(console.error)
} catch (e: any) {
set({ error: String(e), loading: false })
throw e
}
},
register: async (username, email, password) => {
set({ loading: true, error: null })
try {
const result = await invoke<AuthResult>('register', { username, email, password })
localStorage.setItem('auth_token', result.token)
set({ user: result.user, token: result.token, loading: false })
// Connect signaling after register
get().connectSignaling().catch(console.error)
} catch (e: any) {
set({ error: String(e), loading: false })
throw e
}
},
logout: async () => {
try {
await invoke('disconnect_signaling')
await invoke('logout')
} finally {
localStorage.removeItem('auth_token')
set({ user: null, token: null })
}
},
}))

View File

@@ -0,0 +1,44 @@
import { create } from 'zustand'
import { invoke } from '@tauri-apps/api/core'
import { listen, UnlistenFn } from '@tauri-apps/api/event'
export interface ChatMessage {
room_id: string
from: string
username: string
content: string
timestamp: number
}
interface ChatState {
messages: ChatMessage[]
sendMessage: (roomId: string, content: string) => Promise<void>
addMessage: (message: ChatMessage) => void
clearMessages: () => void
subscribeToChat: () => Promise<UnlistenFn>
}
export const useChatStore = create<ChatState>((set, get) => ({
messages: [],
sendMessage: async (roomId, content) => {
await invoke('send_chat_message', { roomId, content })
},
addMessage: (message) => {
set((state) => ({
messages: [...state.messages.slice(-99), message],
}))
},
clearMessages: () => {
set({ messages: [] })
},
subscribeToChat: async () => {
const unlisten = await listen<ChatMessage>('signaling:chat_message', (event) => {
get().addMessage(event.payload)
})
return unlisten
},
}))

View File

@@ -0,0 +1,122 @@
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
import { invoke } from '@tauri-apps/api/core'
export interface ServerConfig {
server_name: string
server_url: string
relay_url: string
version: string
}
interface ConfigState {
config: ServerConfig | null
customServerUrl: string | null
loading: boolean
error: string | null
initConfig: () => Promise<void>
setCustomServer: (url: string) => Promise<void>
clearCustomServer: () => void
getServerUrl: () => string
}
const DEFAULT_CONFIG: ServerConfig = {
server_name: 'FunMC',
server_url: 'http://localhost:3000',
relay_url: 'localhost:7900',
version: '0.1.0',
}
async function fetchServerConfigViaInvoke(serverUrl: string): Promise<ServerConfig | null> {
try {
// First set the server URL in Rust backend
await invoke('set_server_url', { url: serverUrl })
// Then fetch the config via the backend
const config = await invoke<ServerConfig>('fetch_server_config')
return config
} catch (e) {
console.error('Failed to fetch server config:', e)
return null
}
}
export const useConfigStore = create<ConfigState>()(
persist(
(set, get) => ({
config: null,
customServerUrl: null,
loading: false,
error: null,
initConfig: async () => {
set({ loading: true, error: null })
try {
const { customServerUrl } = get()
// Priority: custom server > default
if (customServerUrl) {
const serverConfig = await fetchServerConfigViaInvoke(customServerUrl)
if (serverConfig) {
set({ config: serverConfig, loading: false })
return
}
}
// Try default server
const defaultConfig = await fetchServerConfigViaInvoke(DEFAULT_CONFIG.server_url)
if (defaultConfig) {
set({ config: defaultConfig, loading: false })
return
}
// Use default config if no server is reachable
set({ config: DEFAULT_CONFIG, loading: false })
await invoke('set_server_url', { url: DEFAULT_CONFIG.server_url })
} catch (e) {
console.error('Config init error:', e)
set({ error: String(e), loading: false, config: DEFAULT_CONFIG })
}
},
setCustomServer: async (url: string) => {
set({ loading: true, error: null })
try {
const normalizedUrl = url.endsWith('/') ? url.slice(0, -1) : url
const serverConfig = await fetchServerConfigViaInvoke(normalizedUrl)
if (serverConfig) {
set({
customServerUrl: normalizedUrl,
config: serverConfig,
loading: false
})
} else {
set({
error: '无法连接到服务器,请检查地址是否正确',
loading: false
})
}
} catch (e) {
set({ error: String(e), loading: false })
}
},
clearCustomServer: () => {
set({ customServerUrl: null })
get().initConfig()
},
getServerUrl: () => {
const { config, customServerUrl } = get()
return customServerUrl || config?.server_url || DEFAULT_CONFIG.server_url
},
}),
{
name: 'funmc-config',
partialize: (state) => ({ customServerUrl: state.customServerUrl }),
}
)
)

View File

@@ -0,0 +1,66 @@
import { create } from 'zustand'
import { invoke } from '@tauri-apps/api/core'
export interface Friend {
id: string
username: string
avatar_seed: string
is_online: boolean
status: string
}
export interface FriendRequest {
id: string
username: string
avatar_seed: string
}
interface FriendState {
friends: Friend[]
requests: FriendRequest[]
loading: boolean
fetchFriends: () => Promise<void>
fetchRequests: () => Promise<void>
sendRequest: (username: string) => Promise<void>
acceptRequest: (requesterId: string) => Promise<void>
removeFriend: (friendId: string) => Promise<void>
}
export const useFriendStore = create<FriendState>((set) => ({
friends: [],
requests: [],
loading: false,
fetchFriends: async () => {
set({ loading: true })
try {
const friends = await invoke<Friend[]>('list_friends')
set({ friends, loading: false })
} catch {
set({ loading: false })
}
},
fetchRequests: async () => {
try {
const requests = await invoke<FriendRequest[]>('list_requests')
set({ requests })
} catch {}
},
sendRequest: async (username) => {
await invoke('send_friend_request', { username })
},
acceptRequest: async (requesterId) => {
await invoke('accept_friend_request', { requesterId })
set((s) => ({
requests: s.requests.filter((r) => r.id !== requesterId),
}))
},
removeFriend: async (friendId) => {
await invoke('remove_friend', { friendId })
set((s) => ({ friends: s.friends.filter((f) => f.id !== friendId) }))
},
}))

View File

@@ -0,0 +1,66 @@
import { create } from 'zustand'
import { invoke } from '@tauri-apps/api/core'
export interface ConnectionStats {
session_type: string
latency_ms: number
bytes_sent: number
bytes_received: number
connected: boolean
}
export interface NetworkSession {
room_id: string
local_port: number
session_type: string
}
interface NetworkState {
stats: ConnectionStats | null
session: NetworkSession | null
connectAddr: string | null
startHosting: (roomId: string, roomName?: string, mcPort?: number) => Promise<NetworkSession>
joinNetwork: (roomId: string, hostUserId?: string) => Promise<string>
stopNetwork: () => Promise<void>
refreshStats: () => Promise<void>
}
export const useNetworkStore = create<NetworkState>((set) => ({
stats: null,
session: null,
connectAddr: null,
startHosting: async (roomId, roomName, mcPort) => {
const session = await invoke<NetworkSession>('start_hosting', {
roomId,
roomName: roomName ?? null,
mcPort: mcPort ?? null
})
set({ session })
return session
},
joinNetwork: async (roomId, hostUserId) => {
const info = await invoke<{ connect_addr: string; local_port: number; session_type: string }>(
'join_room_network',
{ roomId, hostUserId: hostUserId ?? null }
)
set({
connectAddr: info.connect_addr,
session: { room_id: roomId, local_port: info.local_port, session_type: info.session_type },
})
return info.connect_addr
},
stopNetwork: async () => {
await invoke('stop_network')
set({ session: null, connectAddr: null, stats: null })
},
refreshStats: async () => {
try {
const stats = await invoke<ConnectionStats>('get_connection_stats')
set({ stats })
} catch {}
},
}))

View File

@@ -0,0 +1,57 @@
import { create } from 'zustand'
import { invoke } from '@tauri-apps/api/core'
export interface RelayNode {
id: string
name: string
url: string
region: string
is_active: boolean
priority: number
last_ping_ms: number | null
}
interface RelayNodeState {
nodes: RelayNode[]
loading: boolean
fetchNodes: () => Promise<void>
addNode: (name: string, url: string, region?: string, priority?: number) => Promise<void>
removeNode: (id: string) => Promise<void>
reportPing: (id: string, pingMs: number) => Promise<void>
}
export const useRelayNodeStore = create<RelayNodeState>((set) => ({
nodes: [],
loading: false,
fetchNodes: async () => {
set({ loading: true })
try {
const nodes = await invoke<RelayNode[]>('list_relay_nodes')
set({ nodes, loading: false })
} catch {
set({ loading: false })
}
},
addNode: async (name, url, region, priority) => {
const node = await invoke<RelayNode>('add_relay_node', { name, url, region, priority })
set((s) => ({ nodes: [...s.nodes, node] }))
},
removeNode: async (id) => {
await invoke('remove_relay_node', { nodeId: id })
set((s) => ({ nodes: s.nodes.filter((n) => n.id !== id) }))
},
reportPing: async (id, pingMs) => {
await invoke('report_relay_ping', { nodeId: id, pingMs })
set((s) => ({
nodes: s.nodes.map((n) =>
n.id === id
? { ...n, last_ping_ms: n.last_ping_ms == null ? pingMs : Math.round((n.last_ping_ms + pingMs) / 2) }
: n
),
}))
},
}))

View File

@@ -0,0 +1,89 @@
import { create } from 'zustand'
import { invoke } from '@tauri-apps/api/core'
export interface Room {
id: string
name: string
owner_id: string
owner_username: string
max_players: number
current_players: number
is_public: boolean
has_password: boolean
game_version: string
status: string
}
export interface RoomMember {
user_id: string
username: string
role: string
is_online: boolean
}
interface RoomState {
rooms: Room[]
currentRoom: Room | null
members: RoomMember[]
loading: boolean
fetchRooms: () => Promise<void>
createRoom: (params: {
name: string
maxPlayers?: number
isPublic?: boolean
password?: string
gameVersion?: string
}) => Promise<string>
joinRoom: (roomId: string, password?: string) => Promise<void>
leaveRoom: (roomId: string) => Promise<void>
fetchMembers: (roomId: string) => Promise<void>
setCurrentRoom: (room: Room | null) => void
}
export const useRoomStore = create<RoomState>((set) => ({
rooms: [],
currentRoom: null,
members: [],
loading: false,
fetchRooms: async () => {
set({ loading: true })
try {
const rooms = await invoke<Room[]>('list_rooms')
set({ rooms, loading: false })
} catch {
set({ loading: false })
}
},
createRoom: async ({ name, maxPlayers, isPublic, password, gameVersion }) => {
const roomId = await invoke<string>('create_room', {
name,
maxPlayers,
isPublic,
password,
gameVersion,
})
return roomId
},
joinRoom: async (roomId, password) => {
await invoke('join_room', { roomId, password })
},
leaveRoom: async (roomId) => {
await invoke('leave_room', { roomId })
set({ currentRoom: null, members: [] })
},
fetchMembers: async (roomId) => {
try {
const members = await invoke<RoomMember[]>('get_room_members', { roomId })
set({ members })
} catch {
set({ members: [] })
}
},
setCurrentRoom: (room) => set({ currentRoom: room }),
}))