Initial commit: FunConnect project with server, relay, client and admin panel
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
101
client/ui/src/stores/authStore.ts
Normal file
101
client/ui/src/stores/authStore.ts
Normal 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 })
|
||||
}
|
||||
},
|
||||
}))
|
||||
44
client/ui/src/stores/chatStore.ts
Normal file
44
client/ui/src/stores/chatStore.ts
Normal 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
|
||||
},
|
||||
}))
|
||||
122
client/ui/src/stores/configStore.ts
Normal file
122
client/ui/src/stores/configStore.ts
Normal 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 }),
|
||||
}
|
||||
)
|
||||
)
|
||||
66
client/ui/src/stores/friendStore.ts
Normal file
66
client/ui/src/stores/friendStore.ts
Normal 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) }))
|
||||
},
|
||||
}))
|
||||
66
client/ui/src/stores/networkStore.ts
Normal file
66
client/ui/src/stores/networkStore.ts
Normal 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 {}
|
||||
},
|
||||
}))
|
||||
57
client/ui/src/stores/relayNodeStore.ts
Normal file
57
client/ui/src/stores/relayNodeStore.ts
Normal 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
|
||||
),
|
||||
}))
|
||||
},
|
||||
}))
|
||||
89
client/ui/src/stores/roomStore.ts
Normal file
89
client/ui/src/stores/roomStore.ts
Normal 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 }),
|
||||
}))
|
||||
Reference in New Issue
Block a user