Initial commit: FunConnect project with server, relay, client and admin panel
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
19
client/ui/index.html
Normal file
19
client/ui/index.html
Normal file
@@ -0,0 +1,19 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/png" href="/icon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>FunMC</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
36
client/ui/package.json
Normal file
36
client/ui/package.json
Normal file
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "funmc-client-ui",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.0.0",
|
||||
"@tauri-apps/plugin-notification": "^2.0.0",
|
||||
"@tauri-apps/plugin-shell": "^2.0.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.22.3",
|
||||
"zustand": "^4.5.2",
|
||||
"clsx": "^2.1.0",
|
||||
"tailwind-merge": "^2.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.6",
|
||||
"postcss": "^8.4.38",
|
||||
"tailwindcss": "^3.4.3",
|
||||
"typescript": "^5.4.5",
|
||||
"vite": "^5.2.10"
|
||||
}
|
||||
}
|
||||
6
client/ui/postcss.config.js
Normal file
6
client/ui/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
218
client/ui/src/components/AppLayout.tsx
Normal file
218
client/ui/src/components/AppLayout.tsx
Normal file
@@ -0,0 +1,218 @@
|
||||
import { Outlet, NavLink, useNavigate, useLocation } from 'react-router-dom'
|
||||
import { useAuthStore } from '../stores/authStore'
|
||||
import { useState } from 'react'
|
||||
|
||||
const navItems = [
|
||||
{
|
||||
to: '/',
|
||||
label: '大厅',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" 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>
|
||||
),
|
||||
},
|
||||
{
|
||||
to: '/friends',
|
||||
label: '好友',
|
||||
icon: (
|
||||
<svg className="w-5 h-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.87"/>
|
||||
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
to: '/settings',
|
||||
label: '设置',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
<path d="M19.07 4.93l-1.41 1.41M4.93 4.93l1.41 1.41M19.07 19.07l-1.41-1.41M4.93 19.07l1.41-1.41M22 12h-2M4 12H2M12 22v-2M12 4V2"/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
export function AppLayout() {
|
||||
const { user, logout } = useAuthStore()
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const [showUserMenu, setShowUserMenu] = useState(false)
|
||||
|
||||
const handleLogout = async () => {
|
||||
await logout()
|
||||
navigate('/login')
|
||||
}
|
||||
|
||||
const avatarColor = user?.avatar_seed
|
||||
? `hsl(${parseInt(user.avatar_seed.slice(0, 8), 16) % 360}, 60%, 50%)`
|
||||
: '#4ade80'
|
||||
|
||||
const isInRoom = location.pathname.startsWith('/room/')
|
||||
|
||||
return (
|
||||
<div className="flex flex-col md:flex-row h-screen bg-bg-primary overflow-hidden">
|
||||
{/* Desktop Sidebar - hidden on mobile */}
|
||||
<aside className="hidden md:flex w-56 flex-shrink-0 bg-bg-secondary border-r border-border flex-col">
|
||||
{/* Logo */}
|
||||
<div className="px-4 py-5 border-b border-border">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-accent-green/20 border border-accent-green/40 flex items-center justify-center">
|
||||
<svg className="w-4 h-4 text-accent-green" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/>
|
||||
</svg>
|
||||
</div>
|
||||
<span className="font-bold text-text-primary text-lg">FunMC</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Nav */}
|
||||
<nav className="flex-1 p-3 space-y-1">
|
||||
{navItems.map((item) => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
end={item.to === '/'}
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors duration-150 ${
|
||||
isActive
|
||||
? 'bg-accent-green/15 text-accent-green border border-accent-green/20'
|
||||
: 'text-text-secondary hover:text-text-primary hover:bg-bg-tertiary'
|
||||
}`
|
||||
}
|
||||
>
|
||||
{item.icon}
|
||||
{item.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* User profile */}
|
||||
<div className="p-3 border-t border-border">
|
||||
<div className="flex items-center gap-3 px-2 py-2">
|
||||
<div
|
||||
className="w-8 h-8 rounded-full flex items-center justify-center text-white text-sm font-bold flex-shrink-0"
|
||||
style={{ backgroundColor: avatarColor }}
|
||||
>
|
||||
{user?.username?.[0]?.toUpperCase()}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-text-primary truncate">{user?.username}</p>
|
||||
<p className="text-xs text-text-muted">在线</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
title="退出登录"
|
||||
className="text-text-muted hover:text-accent-red transition-colors"
|
||||
>
|
||||
<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>
|
||||
|
||||
{/* Brand footer */}
|
||||
<div className="px-4 py-3 border-t border-border bg-bg-tertiary/50">
|
||||
<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>
|
||||
<p className="text-center text-[10px] text-text-muted/60 mt-1">v0.1.0</p>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Mobile Header - shown only on mobile */}
|
||||
<header className="md:hidden flex items-center justify-between px-4 py-3 bg-bg-secondary border-b border-border safe-area-top">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-7 h-7 rounded-lg bg-accent-green/20 border border-accent-green/40 flex items-center justify-center">
|
||||
<svg className="w-3.5 h-3.5 text-accent-green" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/>
|
||||
</svg>
|
||||
</div>
|
||||
<span className="font-bold text-text-primary">FunMC</span>
|
||||
</div>
|
||||
|
||||
{/* User avatar button */}
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowUserMenu(!showUserMenu)}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<div
|
||||
className="w-8 h-8 rounded-full flex items-center justify-center text-white text-sm font-bold"
|
||||
style={{ backgroundColor: avatarColor }}
|
||||
>
|
||||
{user?.username?.[0]?.toUpperCase()}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* User dropdown menu */}
|
||||
{showUserMenu && (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 z-40"
|
||||
onClick={() => setShowUserMenu(false)}
|
||||
/>
|
||||
<div className="absolute right-0 top-full mt-2 w-48 bg-bg-secondary border border-border rounded-lg shadow-xl z-50 py-2 animate-fade-in">
|
||||
<div className="px-4 py-2 border-b border-border">
|
||||
<p className="text-sm font-medium text-text-primary">{user?.username}</p>
|
||||
<p className="text-xs text-text-muted">{user?.email}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="w-full px-4 py-2 text-left text-sm text-accent-red hover:bg-bg-tertiary 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>
|
||||
</header>
|
||||
|
||||
{/* Main content */}
|
||||
<main className="flex-1 overflow-hidden">
|
||||
<Outlet />
|
||||
</main>
|
||||
|
||||
{/* Mobile Bottom Navigation - hidden when in room or on desktop */}
|
||||
{!isInRoom && (
|
||||
<nav className="md:hidden flex items-center justify-around bg-bg-secondary border-t border-border safe-area-bottom py-2">
|
||||
{navItems.map((item) => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
end={item.to === '/'}
|
||||
className={({ isActive }) =>
|
||||
`flex flex-col items-center gap-1 px-4 py-2 rounded-lg transition-colors ${
|
||||
isActive
|
||||
? 'text-accent-green'
|
||||
: 'text-text-muted'
|
||||
}`
|
||||
}
|
||||
>
|
||||
{item.icon}
|
||||
<span className="text-xs font-medium">{item.label}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AppLayout
|
||||
110
client/ui/src/components/Avatar.tsx
Normal file
110
client/ui/src/components/Avatar.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import { cn, generateAvatarColor, getInitials } from '../lib/utils';
|
||||
|
||||
interface AvatarProps {
|
||||
seed: string;
|
||||
name: string;
|
||||
size?: 'sm' | 'md' | 'lg' | 'xl';
|
||||
className?: string;
|
||||
showOnline?: boolean;
|
||||
isOnline?: boolean;
|
||||
}
|
||||
|
||||
export function Avatar({
|
||||
seed,
|
||||
name,
|
||||
size = 'md',
|
||||
className,
|
||||
showOnline = false,
|
||||
isOnline = false,
|
||||
}: AvatarProps) {
|
||||
const sizeClasses = {
|
||||
sm: 'w-6 h-6 text-xs',
|
||||
md: 'w-8 h-8 text-sm',
|
||||
lg: 'w-10 h-10 text-base',
|
||||
xl: 'w-12 h-12 text-lg',
|
||||
};
|
||||
|
||||
const dotSizeClasses = {
|
||||
sm: 'w-1.5 h-1.5 -bottom-0 -right-0',
|
||||
md: 'w-2 h-2 -bottom-0.5 -right-0.5',
|
||||
lg: 'w-2.5 h-2.5 -bottom-0.5 -right-0.5',
|
||||
xl: 'w-3 h-3 -bottom-1 -right-1',
|
||||
};
|
||||
|
||||
const bgColor = generateAvatarColor(seed);
|
||||
const initials = getInitials(name);
|
||||
|
||||
return (
|
||||
<div className={cn('relative flex-shrink-0', className)}>
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-full flex items-center justify-center font-semibold text-white',
|
||||
sizeClasses[size]
|
||||
)}
|
||||
style={{ backgroundColor: bgColor }}
|
||||
>
|
||||
{initials}
|
||||
</div>
|
||||
{showOnline && (
|
||||
<span
|
||||
className={cn(
|
||||
'absolute rounded-full border-2 border-bg-secondary',
|
||||
dotSizeClasses[size],
|
||||
isOnline ? 'bg-accent-green shadow-[0_0_6px_#4ade80]' : 'bg-text-muted'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface AvatarGroupProps {
|
||||
users: Array<{ seed: string; name: string }>;
|
||||
max?: number;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
}
|
||||
|
||||
export function AvatarGroup({ users, max = 4, size = 'md' }: AvatarGroupProps) {
|
||||
const displayed = users.slice(0, max);
|
||||
const remaining = users.length - max;
|
||||
|
||||
const overlapClasses = {
|
||||
sm: '-ml-2',
|
||||
md: '-ml-3',
|
||||
lg: '-ml-4',
|
||||
};
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'w-6 h-6 text-xs',
|
||||
md: 'w-8 h-8 text-sm',
|
||||
lg: 'w-10 h-10 text-base',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
{displayed.map((user, index) => (
|
||||
<div
|
||||
key={user.seed}
|
||||
className={cn(
|
||||
'rounded-full border-2 border-bg-secondary',
|
||||
index > 0 && overlapClasses[size]
|
||||
)}
|
||||
style={{ zIndex: displayed.length - index }}
|
||||
>
|
||||
<Avatar seed={user.seed} name={user.name} size={size} />
|
||||
</div>
|
||||
))}
|
||||
{remaining > 0 && (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-full bg-bg-tertiary flex items-center justify-center font-medium text-text-secondary border-2 border-bg-secondary',
|
||||
overlapClasses[size],
|
||||
sizeClasses[size]
|
||||
)}
|
||||
>
|
||||
+{remaining}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
101
client/ui/src/components/ConnectionStatus.tsx
Normal file
101
client/ui/src/components/ConnectionStatus.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useConfigStore } from '../stores/configStore'
|
||||
|
||||
type ConnectionState = 'connected' | 'connecting' | 'disconnected' | 'error'
|
||||
|
||||
interface ConnectionStatusProps {
|
||||
showDetails?: boolean
|
||||
}
|
||||
|
||||
export function ConnectionStatus({ showDetails = false }: ConnectionStatusProps) {
|
||||
const { config } = useConfigStore()
|
||||
const [status, setStatus] = useState<ConnectionState>('connecting')
|
||||
const [latency, setLatency] = useState<number | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const checkConnection = async () => {
|
||||
if (!config?.server_url) {
|
||||
setStatus('disconnected')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const start = performance.now()
|
||||
const response = await fetch(`${config.server_url}/api/v1/health`, {
|
||||
signal: AbortSignal.timeout(5000),
|
||||
})
|
||||
const elapsed = Math.round(performance.now() - start)
|
||||
|
||||
if (response.ok) {
|
||||
setStatus('connected')
|
||||
setLatency(elapsed)
|
||||
} else {
|
||||
setStatus('error')
|
||||
setLatency(null)
|
||||
}
|
||||
} catch {
|
||||
setStatus('disconnected')
|
||||
setLatency(null)
|
||||
}
|
||||
}
|
||||
|
||||
checkConnection()
|
||||
const interval = setInterval(checkConnection, 30000)
|
||||
return () => clearInterval(interval)
|
||||
}, [config?.server_url])
|
||||
|
||||
const statusConfig = {
|
||||
connected: {
|
||||
color: 'bg-green-500',
|
||||
text: '已连接',
|
||||
icon: '🟢',
|
||||
},
|
||||
connecting: {
|
||||
color: 'bg-yellow-500 animate-pulse',
|
||||
text: '连接中',
|
||||
icon: '🟡',
|
||||
},
|
||||
disconnected: {
|
||||
color: 'bg-red-500',
|
||||
text: '未连接',
|
||||
icon: '🔴',
|
||||
},
|
||||
error: {
|
||||
color: 'bg-orange-500',
|
||||
text: '连接异常',
|
||||
icon: '🟠',
|
||||
},
|
||||
}
|
||||
|
||||
const { color, text, icon } = statusConfig[status]
|
||||
|
||||
if (!showDetails) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`w-2 h-2 rounded-full ${color}`} />
|
||||
<span className="text-xs text-gray-400">{text}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 px-3 py-2 bg-gray-800/50 rounded-lg">
|
||||
<span className="text-sm">{icon}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-white">{config?.server_name || 'FunMC'}</p>
|
||||
<p className="text-xs text-gray-400 truncate">{config?.server_url}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-xs text-gray-400">{text}</p>
|
||||
{latency !== null && (
|
||||
<p className={`text-xs font-mono ${
|
||||
latency < 100 ? 'text-green-400' :
|
||||
latency < 300 ? 'text-yellow-400' : 'text-red-400'
|
||||
}`}>
|
||||
{latency}ms
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
60
client/ui/src/components/CreateRoomModal.tsx
Normal file
60
client/ui/src/components/CreateRoomModal.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import { useState } from 'react';
|
||||
import { Modal } from './Modal';
|
||||
import { useRoomStore } from '../stores/roomStore';
|
||||
import { useToast } from './Toast';
|
||||
|
||||
const VERSIONS = ['1.21.4', '1.21.3', '1.21', '1.20.4', '1.20.1', '1.19.4', '1.18.2', '1.16.5', '1.12.2'];
|
||||
|
||||
export function CreateRoomModal({ isOpen, onClose }: { isOpen: boolean; onClose: () => void }) {
|
||||
const { createRoom, loading } = useRoomStore();
|
||||
const { showToast } = useToast();
|
||||
const [form, setForm] = useState({ name: '', password: '', max_players: 8, game_version: '1.21.4', is_public: true });
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!form.name.trim()) { showToast('请输入房间名称', 'error'); return; }
|
||||
try {
|
||||
await createRoom({ name: form.name.trim(), password: form.password || undefined, max_players: form.max_players, game_version: form.game_version, is_public: form.is_public });
|
||||
showToast('房间创建成功', 'success');
|
||||
onClose();
|
||||
setForm({ name: '', password: '', max_players: 8, game_version: '1.21.4', is_public: true });
|
||||
} catch (err) { showToast(err instanceof Error ? err.message : '创建失败', 'error'); }
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} title="创建房间">
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-secondary mb-1.5">房间名称 <span className="text-accent-red">*</span></label>
|
||||
<input type="text" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} className="input w-full" placeholder="输入房间名称" maxLength={50} autoFocus />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-secondary mb-1.5">房间密码 <span className="text-text-muted">(可选)</span></label>
|
||||
<input type="password" value={form.password} onChange={(e) => setForm({ ...form, password: e.target.value })} className="input w-full" placeholder="留空表示无密码" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-secondary mb-1.5">最大人数</label>
|
||||
<select value={form.max_players} onChange={(e) => setForm({ ...form, max_players: parseInt(e.target.value) })} className="input w-full">
|
||||
{[2, 4, 6, 8, 10, 12, 16, 20].map((n) => <option key={n} value={n}>{n} 人</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-secondary mb-1.5">游戏版本</label>
|
||||
<select value={form.game_version} onChange={(e) => setForm({ ...form, game_version: e.target.value })} className="input w-full">
|
||||
{VERSIONS.map((v) => <option key={v} value={v}>{v}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input type="checkbox" id="is_public" checked={form.is_public} onChange={(e) => setForm({ ...form, is_public: e.target.checked })} className="w-4 h-4 rounded" />
|
||||
<label htmlFor="is_public" className="text-sm text-text-secondary">公开房间(可被搜索)</label>
|
||||
</div>
|
||||
<div className="flex justify-end gap-3 pt-4 border-t border-border">
|
||||
<button type="button" onClick={onClose} className="btn-ghost">取消</button>
|
||||
<button type="submit" className="btn-primary" disabled={loading}>{loading ? '创建中...' : '创建房间'}</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
98
client/ui/src/components/EmptyState.tsx
Normal file
98
client/ui/src/components/EmptyState.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import { cn } from '../lib/utils';
|
||||
|
||||
interface EmptyStateProps {
|
||||
icon?: React.ReactNode;
|
||||
title: string;
|
||||
description?: string;
|
||||
action?: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function EmptyState({ icon, title, description, action, className }: EmptyStateProps) {
|
||||
return (
|
||||
<div className={cn('flex flex-col items-center justify-center py-12 text-center', className)}>
|
||||
{icon && <div className="mb-4 text-text-muted opacity-40">{icon}</div>}
|
||||
<h3 className="text-sm font-medium text-text-primary mb-1">{title}</h3>
|
||||
{description && <p className="text-xs text-text-muted max-w-xs">{description}</p>}
|
||||
{action && <div className="mt-4">{action}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function NoRoomsState({ onCreate }: { onCreate?: () => void }) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={
|
||||
<svg
|
||||
className="w-12 h-12"
|
||||
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>
|
||||
}
|
||||
title="暂无公开房间"
|
||||
description="创建第一个房间开始游戏,或等待其他玩家创建"
|
||||
action={
|
||||
onCreate && (
|
||||
<button onClick={onCreate} className="btn-primary">
|
||||
创建房间
|
||||
</button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function NoFriendsState() {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={
|
||||
<svg
|
||||
className="w-12 h-12"
|
||||
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>
|
||||
}
|
||||
title="还没有好友"
|
||||
description="在右侧输入用户名添加好友,一起联机游戏"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function NoRequestsState() {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={
|
||||
<svg
|
||||
className="w-10 h-10"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M22 12h-4l-3 9L9 3l-3 9H2" />
|
||||
</svg>
|
||||
}
|
||||
title="暂无好友请求"
|
||||
description="当有人想添加你为好友时,请求会显示在这里"
|
||||
/>
|
||||
);
|
||||
}
|
||||
59
client/ui/src/components/ErrorBoundary.tsx
Normal file
59
client/ui/src/components/ErrorBoundary.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { Component, ErrorInfo, ReactNode } from 'react'
|
||||
|
||||
interface Props {
|
||||
children: ReactNode
|
||||
fallback?: ReactNode
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean
|
||||
error: Error | null
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props)
|
||||
this.state = { hasError: false, error: null }
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return { hasError: true, error }
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||
console.error('ErrorBoundary caught an error:', error, errorInfo)
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
if (this.props.fallback) {
|
||||
return this.props.fallback
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-900 flex items-center justify-center p-4">
|
||||
<div className="bg-gray-800 rounded-2xl p-8 max-w-md w-full text-center">
|
||||
<div className="text-5xl mb-4">😵</div>
|
||||
<h2 className="text-xl font-bold text-white mb-2">出错了</h2>
|
||||
<p className="text-gray-400 text-sm mb-6">
|
||||
应用遇到了一个意外错误
|
||||
</p>
|
||||
<div className="bg-gray-700/50 rounded-lg p-3 mb-6 text-left">
|
||||
<code className="text-xs text-red-400 break-all">
|
||||
{this.state.error?.message || '未知错误'}
|
||||
</code>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="px-6 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors"
|
||||
>
|
||||
重新加载
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return this.props.children
|
||||
}
|
||||
}
|
||||
121
client/ui/src/components/FriendCard.tsx
Normal file
121
client/ui/src/components/FriendCard.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
import { Avatar } from './Avatar';
|
||||
import { cn } from '../lib/utils';
|
||||
import { Friend, FriendRequest } from '../stores/friendStore';
|
||||
|
||||
interface FriendCardProps {
|
||||
friend: Friend;
|
||||
onRemove?: () => void;
|
||||
onInvite?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function FriendCard({ friend, onRemove, onInvite, className }: FriendCardProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-3 px-3 py-2.5 rounded-lg hover:bg-bg-tertiary transition-colors group',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<Avatar
|
||||
seed={friend.avatar_seed}
|
||||
name={friend.username}
|
||||
size="md"
|
||||
showOnline
|
||||
isOnline={friend.is_online}
|
||||
/>
|
||||
<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>
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{onInvite && friend.is_online && (
|
||||
<button
|
||||
onClick={onInvite}
|
||||
className="p-1.5 rounded text-text-muted hover:text-accent-green hover:bg-accent-green/10 transition-colors"
|
||||
title="邀请加入房间"
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="8.5" cy="7" r="4" />
|
||||
<line x1="20" y1="8" x2="20" y2="14" />
|
||||
<line x1="23" y1="11" x2="17" y2="11" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
{onRemove && (
|
||||
<button
|
||||
onClick={onRemove}
|
||||
className="p-1.5 rounded text-text-muted hover:text-accent-red hover:bg-accent-red/10 transition-colors"
|
||||
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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface FriendRequestCardProps {
|
||||
request: FriendRequest;
|
||||
onAccept: () => void;
|
||||
onReject?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function FriendRequestCard({
|
||||
request,
|
||||
onAccept,
|
||||
onReject,
|
||||
className,
|
||||
}: FriendRequestCardProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-3 px-3 py-2.5 rounded-lg bg-bg-tertiary',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<Avatar seed={request.avatar_seed} name={request.username} size="md" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-text-primary truncate">{request.username}</p>
|
||||
<p className="text-xs text-text-muted">想加你为好友</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{onReject && (
|
||||
<button
|
||||
onClick={onReject}
|
||||
className="btn-ghost py-1 px-2 text-xs"
|
||||
>
|
||||
拒绝
|
||||
</button>
|
||||
)}
|
||||
<button onClick={onAccept} className="btn-primary py-1 px-3 text-xs">
|
||||
接受
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
48
client/ui/src/components/JoinRoomModal.tsx
Normal file
48
client/ui/src/components/JoinRoomModal.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { useState } from 'react';
|
||||
import { Modal } from './Modal';
|
||||
import { useRoomStore, Room } from '../stores/roomStore';
|
||||
import { useToast } from './Toast';
|
||||
|
||||
export function JoinRoomModal({ isOpen, onClose, room }: { isOpen: boolean; onClose: () => void; room: Room | null }) {
|
||||
const { joinRoom, loading } = useRoomStore();
|
||||
const { showToast } = useToast();
|
||||
const [password, setPassword] = useState('');
|
||||
|
||||
if (!room) return null;
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (room.has_password && !password) { showToast('请输入房间密码', 'error'); return; }
|
||||
try {
|
||||
await joinRoom(room.id, password || undefined);
|
||||
showToast('加入房间成功', 'success');
|
||||
onClose();
|
||||
setPassword('');
|
||||
} catch (err) { showToast(err instanceof Error ? err.message : '加入失败', 'error'); }
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} title="加入房间">
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="card bg-bg-tertiary">
|
||||
<h4 className="font-medium text-text-primary">{room.name}</h4>
|
||||
<p className="text-sm text-text-muted mt-1">房主: {room.owner_username}</p>
|
||||
<div className="flex items-center gap-4 mt-2 text-xs text-text-secondary">
|
||||
<span>玩家: {room.current_players}/{room.max_players}</span>
|
||||
<span>版本: {room.game_version}</span>
|
||||
</div>
|
||||
</div>
|
||||
{room.has_password && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-secondary mb-1.5">房间密码</label>
|
||||
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} className="input w-full" placeholder="输入房间密码" autoFocus />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-end gap-3 pt-4 border-t border-border">
|
||||
<button type="button" onClick={onClose} className="btn-ghost">取消</button>
|
||||
<button type="submit" className="btn-primary" disabled={loading}>{loading ? '加入中...' : '加入房间'}</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
68
client/ui/src/components/Loading.tsx
Normal file
68
client/ui/src/components/Loading.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import { cn } from '../lib/utils';
|
||||
|
||||
interface LoadingSpinnerProps {
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function LoadingSpinner({ size = 'md', className }: LoadingSpinnerProps) {
|
||||
const sizeClasses = {
|
||||
sm: 'w-4 h-4 border-2',
|
||||
md: 'w-6 h-6 border-2',
|
||||
lg: 'w-8 h-8 border-3',
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'animate-spin rounded-full border-accent-green/20 border-t-accent-green',
|
||||
sizeClasses[size],
|
||||
className
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface LoadingOverlayProps {
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export function LoadingOverlay({ message = '加载中...' }: LoadingOverlayProps) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-bg-primary/80 backdrop-blur-sm">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<LoadingSpinner size="lg" />
|
||||
<p className="text-sm text-text-secondary">{message}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface LoadingCardProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function LoadingCard({ className }: LoadingCardProps) {
|
||||
return (
|
||||
<div className={cn('card animate-pulse', className)}>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-bg-tertiary" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="h-4 w-24 rounded bg-bg-tertiary" />
|
||||
<div className="h-3 w-16 rounded bg-bg-tertiary" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function LoadingPage() {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<LoadingSpinner size="lg" />
|
||||
<p className="text-sm text-text-muted">加载中...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
43
client/ui/src/components/LoadingSpinner.tsx
Normal file
43
client/ui/src/components/LoadingSpinner.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
interface LoadingSpinnerProps {
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
text?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function LoadingSpinner({ size = 'md', text, className = '' }: LoadingSpinnerProps) {
|
||||
const sizeClasses = {
|
||||
sm: 'w-4 h-4 border-2',
|
||||
md: 'w-8 h-8 border-3',
|
||||
lg: 'w-12 h-12 border-4',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`flex flex-col items-center justify-center ${className}`}>
|
||||
<div
|
||||
className={`${sizeClasses[size]} border-gray-600 border-t-green-500 rounded-full animate-spin`}
|
||||
/>
|
||||
{text && <p className="mt-3 text-sm text-gray-400">{text}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function FullPageLoading({ text = '加载中...' }: { text?: string }) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-900 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<LoadingSpinner size="lg" />
|
||||
<p className="mt-4 text-gray-400">{text}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function CardLoading() {
|
||||
return (
|
||||
<div className="animate-pulse">
|
||||
<div className="h-4 bg-gray-700 rounded w-3/4 mb-3"></div>
|
||||
<div className="h-3 bg-gray-700 rounded w-1/2 mb-2"></div>
|
||||
<div className="h-3 bg-gray-700 rounded w-2/3"></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
117
client/ui/src/components/Modal.tsx
Normal file
117
client/ui/src/components/Modal.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
interface ModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
}
|
||||
|
||||
export function Modal({ isOpen, onClose, title, children, size = 'md' }: ModalProps) {
|
||||
const overlayRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
document.body.style.overflow = 'hidden';
|
||||
} else {
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
return () => {
|
||||
document.body.style.overflow = '';
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleEsc = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
if (isOpen) {
|
||||
document.addEventListener('keydown', handleEsc);
|
||||
}
|
||||
return () => document.removeEventListener('keydown', handleEsc);
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'max-w-sm',
|
||||
md: 'max-w-md',
|
||||
lg: 'max-w-lg',
|
||||
}[size];
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={overlayRef}
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm animate-fade-in"
|
||||
onClick={(e) => e.target === overlayRef.current && onClose()}
|
||||
>
|
||||
<div
|
||||
className={`w-full ${sizeClasses} bg-bg-secondary border border-border rounded-xl shadow-2xl animate-scale-in`}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="modal-title"
|
||||
>
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-border">
|
||||
<h2 id="modal-title" className="text-lg font-semibold text-text-primary">
|
||||
{title}
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1.5 rounded-lg text-text-muted hover:text-text-primary hover:bg-bg-tertiary transition-colors"
|
||||
aria-label="关闭"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-5">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ConfirmModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
title: string;
|
||||
message: string;
|
||||
confirmText?: string;
|
||||
cancelText?: string;
|
||||
variant?: 'danger' | 'default';
|
||||
}
|
||||
|
||||
export function ConfirmModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onConfirm,
|
||||
title,
|
||||
message,
|
||||
confirmText = '确认',
|
||||
cancelText = '取消',
|
||||
variant = 'default',
|
||||
}: ConfirmModalProps) {
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} title={title} size="sm">
|
||||
<p className="text-sm text-text-secondary mb-6">{message}</p>
|
||||
<div className="flex justify-end gap-3">
|
||||
<button onClick={onClose} className="btn-secondary">
|
||||
{cancelText}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
onConfirm();
|
||||
onClose();
|
||||
}}
|
||||
className={variant === 'danger' ? 'btn-danger' : 'btn-primary'}
|
||||
>
|
||||
{confirmText}
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
73
client/ui/src/components/NetworkStats.tsx
Normal file
73
client/ui/src/components/NetworkStats.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { cn, formatBytes, formatDuration } from '../lib/utils';
|
||||
|
||||
interface ConnectionStats {
|
||||
is_connected: boolean;
|
||||
connection_type: 'p2p' | 'relay' | 'none';
|
||||
local_address: string | null;
|
||||
remote_address: string | null;
|
||||
bytes_sent: number;
|
||||
bytes_received: number;
|
||||
latency_ms: number;
|
||||
connected_since: string | null;
|
||||
packets_sent: number;
|
||||
packets_received: number;
|
||||
packets_lost: number;
|
||||
}
|
||||
|
||||
export function NetworkStats({ className, compact = false }: { className?: string; compact?: boolean }) {
|
||||
const [stats, setStats] = useState<ConnectionStats | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchStats = async () => {
|
||||
try {
|
||||
const data = await invoke<ConnectionStats>('get_connection_stats');
|
||||
setStats(data);
|
||||
} catch {}
|
||||
};
|
||||
fetchStats();
|
||||
const interval = setInterval(fetchStats, 2000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
if (!stats) {
|
||||
return <div className={cn('card animate-pulse', className)}><div className="h-4 w-24 bg-bg-tertiary rounded" /></div>;
|
||||
}
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<div className={cn('flex items-center gap-3', className)}>
|
||||
<ConnectionBadge type={stats.connection_type} />
|
||||
{stats.is_connected && <span className="text-xs text-text-muted">{stats.latency_ms}ms</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('card', className)}>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="font-semibold text-text-primary">网络状态</h3>
|
||||
<ConnectionBadge type={stats.connection_type} />
|
||||
</div>
|
||||
{stats.is_connected ? (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div><p className="text-xs text-text-muted">延迟</p><p className="text-sm font-medium">{stats.latency_ms}ms</p></div>
|
||||
<div><p className="text-xs text-text-muted">已发送</p><p className="text-sm font-medium">{formatBytes(stats.bytes_sent)}</p></div>
|
||||
<div><p className="text-xs text-text-muted">已接收</p><p className="text-sm font-medium">{formatBytes(stats.bytes_received)}</p></div>
|
||||
<div><p className="text-xs text-text-muted">连接时长</p><p className="text-sm font-medium">{stats.connected_since ? formatDuration(Date.now() - new Date(stats.connected_since).getTime()) : '-'}</p></div>
|
||||
</div>
|
||||
) : <p className="text-text-muted text-sm">未连接</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ConnectionBadge({ type }: { type: string }) {
|
||||
const styles: Record<string, string> = {
|
||||
p2p: 'bg-accent-green/15 text-accent-green',
|
||||
relay: 'bg-accent-orange/15 text-accent-orange',
|
||||
none: 'bg-text-muted/15 text-text-muted',
|
||||
};
|
||||
const labels: Record<string, string> = { p2p: 'P2P 直连', relay: '中继', none: '未连接' };
|
||||
return <span className={cn('px-2 py-0.5 rounded-full text-xs font-medium', styles[type])}>{labels[type]}</span>;
|
||||
}
|
||||
144
client/ui/src/components/RoomCard.tsx
Normal file
144
client/ui/src/components/RoomCard.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
import { cn } from '../lib/utils';
|
||||
import { Room } from '../stores/roomStore';
|
||||
|
||||
interface RoomCardProps {
|
||||
room: Room;
|
||||
onClick?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function RoomCard({ room, onClick, className }: RoomCardProps) {
|
||||
const statusColors: Record<string, string> = {
|
||||
open: 'bg-accent-green/15 text-accent-green border-accent-green/20',
|
||||
in_game: 'bg-accent-orange/15 text-accent-orange border-accent-orange/20',
|
||||
closed: 'bg-text-muted/15 text-text-muted border-text-muted/20',
|
||||
};
|
||||
|
||||
const statusLabels: Record<string, string> = {
|
||||
open: '开放',
|
||||
in_game: '游戏中',
|
||||
closed: '已关闭',
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'card-hover group',
|
||||
onClick && 'cursor-pointer',
|
||||
className
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
<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 group-hover:text-accent-green transition-colors">
|
||||
{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="flex-shrink-0">
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium border',
|
||||
statusColors[room.status] ?? statusColors.closed
|
||||
)}
|
||||
>
|
||||
{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>
|
||||
<span>
|
||||
{room.current_players}/{room.max_players}
|
||||
</span>
|
||||
</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>
|
||||
<span>{room.game_version}</span>
|
||||
</div>
|
||||
{!room.is_public && (
|
||||
<div className="flex items-center gap-1.5 text-text-muted 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="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<line x1="1" y1="1" x2="23" y2="23" />
|
||||
</svg>
|
||||
<span>私密</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface RoomCardSkeletonProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function RoomCardSkeleton({ className }: RoomCardSkeletonProps) {
|
||||
return (
|
||||
<div className={cn('card animate-pulse', className)}>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="h-5 w-32 bg-bg-tertiary rounded" />
|
||||
<div className="h-3 w-20 bg-bg-tertiary rounded" />
|
||||
</div>
|
||||
<div className="h-5 w-12 bg-bg-tertiary rounded-full" />
|
||||
</div>
|
||||
<div className="flex items-center gap-4 mt-3 pt-3 border-t border-border">
|
||||
<div className="h-3 w-12 bg-bg-tertiary rounded" />
|
||||
<div className="h-3 w-10 bg-bg-tertiary rounded" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
121
client/ui/src/components/Toast.tsx
Normal file
121
client/ui/src/components/Toast.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
import { useState, useEffect, createContext, useContext, useCallback } from 'react';
|
||||
|
||||
interface Toast {
|
||||
id: string;
|
||||
type: 'success' | 'error' | 'info' | 'warning';
|
||||
message: string;
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
interface ToastContextValue {
|
||||
toasts: Toast[];
|
||||
addToast: (toast: Omit<Toast, 'id'>) => void;
|
||||
removeToast: (id: string) => void;
|
||||
}
|
||||
|
||||
const ToastContext = createContext<ToastContextValue | null>(null);
|
||||
|
||||
export function ToastProvider({ children }: { children: React.ReactNode }) {
|
||||
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||
|
||||
const addToast = useCallback((toast: Omit<Toast, 'id'>) => {
|
||||
const id = Math.random().toString(36).slice(2, 9);
|
||||
setToasts((prev) => [...prev, { ...toast, id }]);
|
||||
}, []);
|
||||
|
||||
const removeToast = useCallback((id: string) => {
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={{ toasts, addToast, removeToast }}>
|
||||
{children}
|
||||
<ToastContainer />
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useToast() {
|
||||
const context = useContext(ToastContext);
|
||||
if (!context) {
|
||||
throw new Error('useToast must be used within ToastProvider');
|
||||
}
|
||||
|
||||
const toast = {
|
||||
success: (message: string) => context.addToast({ type: 'success', message }),
|
||||
error: (message: string) => context.addToast({ type: 'error', message }),
|
||||
info: (message: string) => context.addToast({ type: 'info', message }),
|
||||
warning: (message: string) => context.addToast({ type: 'warning', message }),
|
||||
};
|
||||
|
||||
return toast;
|
||||
}
|
||||
|
||||
function ToastContainer() {
|
||||
const context = useContext(ToastContext);
|
||||
if (!context) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-4 right-4 z-50 flex flex-col gap-2">
|
||||
{context.toasts.map((toast) => (
|
||||
<ToastItem key={toast.id} toast={toast} onClose={() => context.removeToast(toast.id)} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ToastItem({ toast, onClose }: { toast: Toast; onClose: () => void }) {
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(onClose, toast.duration || 4000);
|
||||
return () => clearTimeout(timer);
|
||||
}, [toast.duration, onClose]);
|
||||
|
||||
const bgColor = {
|
||||
success: 'bg-accent-green/15 border-accent-green/30 text-accent-green',
|
||||
error: 'bg-accent-red/15 border-accent-red/30 text-accent-red',
|
||||
info: 'bg-accent-blue/15 border-accent-blue/30 text-accent-blue',
|
||||
warning: 'bg-accent-orange/15 border-accent-orange/30 text-accent-orange',
|
||||
}[toast.type];
|
||||
|
||||
const icon = {
|
||||
success: (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
),
|
||||
error: (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
),
|
||||
info: (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
),
|
||||
warning: (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
),
|
||||
}[toast.type];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex items-center gap-3 px-4 py-3 rounded-lg border backdrop-blur-sm shadow-lg animate-slide-up ${bgColor}`}
|
||||
role="alert"
|
||||
>
|
||||
{icon}
|
||||
<span className="text-sm font-medium text-text-primary">{toast.message}</span>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="ml-2 p-1 rounded hover:bg-white/10 transition-colors"
|
||||
aria-label="关闭"
|
||||
>
|
||||
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
12
client/ui/src/components/index.ts
Normal file
12
client/ui/src/components/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export { AppLayout } from './AppLayout';
|
||||
export { Avatar, AvatarGroup } from './Avatar';
|
||||
export { ConnectionStatus } from './ConnectionStatus';
|
||||
export { CreateRoomModal } from './CreateRoomModal';
|
||||
export { EmptyState, NoRoomsState, NoFriendsState, NoRequestsState, NoSearchResultsState } from './EmptyState';
|
||||
export { FriendCard, FriendRequestCard } from './FriendCard';
|
||||
export { JoinRoomModal } from './JoinRoomModal';
|
||||
export { Loading, LoadingOverlay, LoadingCard, LoadingSpinner } from './Loading';
|
||||
export { Modal, ConfirmModal } from './Modal';
|
||||
export { NetworkStats } from './NetworkStats';
|
||||
export { RoomCard, RoomCardSkeleton } from './RoomCard';
|
||||
export { ToastProvider, useToast } from './Toast';
|
||||
6
client/ui/src/config.json
Normal file
6
client/ui/src/config.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"server_url": "",
|
||||
"server_name": "",
|
||||
"relay_url": "",
|
||||
"version": "0.1.0"
|
||||
}
|
||||
1
client/ui/src/hooks/index.ts
Normal file
1
client/ui/src/hooks/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { useSignalingEvents, useNetworkStatus, useFriendEvents, useRoomEvents } from './useWebSocket';
|
||||
147
client/ui/src/hooks/useWebSocket.ts
Normal file
147
client/ui/src/hooks/useWebSocket.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import { useEffect, useRef, useCallback, useState } from 'react';
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
import { useAuthStore } from '../stores/authStore';
|
||||
|
||||
type SignalingEvent =
|
||||
| 'friend:request_received'
|
||||
| 'friend:request_accepted'
|
||||
| 'friend:online'
|
||||
| 'friend:offline'
|
||||
| 'room:invite_received'
|
||||
| 'room:member_joined'
|
||||
| 'room:member_left'
|
||||
| 'network:signaling'
|
||||
| 'network:status_changed';
|
||||
|
||||
interface EventHandler<T = unknown> {
|
||||
event: SignalingEvent;
|
||||
handler: (payload: T) => void;
|
||||
}
|
||||
|
||||
export function useSignalingEvents(handlers: EventHandler[]) {
|
||||
useEffect(() => {
|
||||
const unsubscribes: (() => void)[] = [];
|
||||
|
||||
for (const { event, handler } of handlers) {
|
||||
listen(event, (e) => handler(e.payload)).then((unsub) => {
|
||||
unsubscribes.push(unsub);
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
unsubscribes.forEach((unsub) => unsub());
|
||||
};
|
||||
}, [handlers]);
|
||||
}
|
||||
|
||||
export function useNetworkStatus() {
|
||||
const [status, setStatus] = useState<{
|
||||
type: 'p2p' | 'relay' | 'disconnected';
|
||||
port?: number;
|
||||
connectAddr?: string;
|
||||
}>({ type: 'disconnected' });
|
||||
|
||||
useEffect(() => {
|
||||
const unsub = listen<{ type: string; port?: number; connect_addr?: string }>(
|
||||
'network:status_changed',
|
||||
(event) => {
|
||||
setStatus({
|
||||
type: event.payload.type as 'p2p' | 'relay' | 'disconnected',
|
||||
port: event.payload.port,
|
||||
connectAddr: event.payload.connect_addr,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
return () => {
|
||||
unsub.then((fn) => fn());
|
||||
};
|
||||
}, []);
|
||||
|
||||
return status;
|
||||
}
|
||||
|
||||
export function useFriendEvents(
|
||||
onRequest?: (from: string, username: string) => void,
|
||||
onAccepted?: (from: string, username: string) => void,
|
||||
onOnline?: (userId: string) => void,
|
||||
onOffline?: (userId: string) => void
|
||||
) {
|
||||
useEffect(() => {
|
||||
const unsubscribes: Promise<() => void>[] = [];
|
||||
|
||||
if (onRequest) {
|
||||
unsubscribes.push(
|
||||
listen<{ from: string; username: string }>('friend:request_received', (e) => {
|
||||
onRequest(e.payload.from, e.payload.username);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (onAccepted) {
|
||||
unsubscribes.push(
|
||||
listen<{ from: string; username: string }>('friend:request_accepted', (e) => {
|
||||
onAccepted(e.payload.from, e.payload.username);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (onOnline) {
|
||||
unsubscribes.push(
|
||||
listen<{ user_id: string }>('friend:online', (e) => {
|
||||
onOnline(e.payload.user_id);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (onOffline) {
|
||||
unsubscribes.push(
|
||||
listen<{ user_id: string }>('friend:offline', (e) => {
|
||||
onOffline(e.payload.user_id);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return () => {
|
||||
unsubscribes.forEach((p) => p.then((fn) => fn()));
|
||||
};
|
||||
}, [onRequest, onAccepted, onOnline, onOffline]);
|
||||
}
|
||||
|
||||
export function useRoomEvents(
|
||||
onInvite?: (from: string, roomId: string, roomName: string) => void,
|
||||
onMemberJoined?: (roomId: string, userId: string, username: string) => void,
|
||||
onMemberLeft?: (roomId: string, userId: string) => void
|
||||
) {
|
||||
useEffect(() => {
|
||||
const unsubscribes: Promise<() => void>[] = [];
|
||||
|
||||
if (onInvite) {
|
||||
unsubscribes.push(
|
||||
listen<{ from: string; room_id: string; room_name: string }>('room:invite_received', (e) => {
|
||||
onInvite(e.payload.from, e.payload.room_id, e.payload.room_name);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (onMemberJoined) {
|
||||
unsubscribes.push(
|
||||
listen<{ room_id: string; user_id: string; username: string }>('room:member_joined', (e) => {
|
||||
onMemberJoined(e.payload.room_id, e.payload.user_id, e.payload.username);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (onMemberLeft) {
|
||||
unsubscribes.push(
|
||||
listen<{ room_id: string; user_id: string }>('room:member_left', (e) => {
|
||||
onMemberLeft(e.payload.room_id, e.payload.user_id);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return () => {
|
||||
unsubscribes.forEach((p) => p.then((fn) => fn()));
|
||||
};
|
||||
}, [onInvite, onMemberJoined, onMemberLeft]);
|
||||
}
|
||||
207
client/ui/src/index.css
Normal file
207
client/ui/src/index.css
Normal file
@@ -0,0 +1,207 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html, body, #root {
|
||||
height: 100%;
|
||||
background-color: #0f1117;
|
||||
color: #e2e8f0;
|
||||
font-family: 'Inter', system-ui, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Safe area for mobile devices (notch, home indicator) */
|
||||
.safe-area-top {
|
||||
padding-top: env(safe-area-inset-top, 0);
|
||||
}
|
||||
.safe-area-bottom {
|
||||
padding-bottom: env(safe-area-inset-bottom, 0);
|
||||
}
|
||||
.safe-area-left {
|
||||
padding-left: env(safe-area-inset-left, 0);
|
||||
}
|
||||
.safe-area-right {
|
||||
padding-right: env(safe-area-inset-right, 0);
|
||||
}
|
||||
|
||||
/* Touch-friendly tap targets */
|
||||
@media (pointer: coarse) {
|
||||
button, a, input, select, textarea {
|
||||
min-height: 44px;
|
||||
}
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #363a50;
|
||||
border-radius: 3px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #4a4f6a;
|
||||
}
|
||||
|
||||
/* 禁止拖拽 */
|
||||
img, a {
|
||||
-webkit-user-drag: none;
|
||||
user-drag: none;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.btn-primary {
|
||||
@apply px-4 py-2 rounded-lg bg-accent-green text-bg-primary font-semibold text-sm
|
||||
hover:bg-green-300 active:scale-95 transition-all duration-150 disabled:opacity-50
|
||||
disabled:cursor-not-allowed;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
@apply px-4 py-2 rounded-lg bg-bg-tertiary text-text-primary font-medium text-sm
|
||||
border border-border hover:bg-bg-hover active:scale-95 transition-all duration-150;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
@apply px-4 py-2 rounded-lg bg-accent-red/20 text-accent-red font-medium text-sm
|
||||
border border-accent-red/30 hover:bg-accent-red/30 active:scale-95 transition-all duration-150;
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
@apply px-4 py-2 rounded-lg text-text-secondary font-medium text-sm
|
||||
hover:bg-bg-tertiary hover:text-text-primary active:scale-95 transition-all duration-150;
|
||||
}
|
||||
|
||||
.input-field {
|
||||
@apply w-full px-3 py-2 rounded-lg bg-bg-tertiary border border-border text-text-primary
|
||||
text-sm placeholder:text-text-muted focus:outline-none focus:border-accent-green/60
|
||||
focus:ring-1 focus:ring-accent-green/30 transition-all duration-150;
|
||||
}
|
||||
|
||||
.card {
|
||||
@apply bg-bg-secondary rounded-xl border border-border p-4;
|
||||
}
|
||||
|
||||
.card-hover {
|
||||
@apply bg-bg-secondary rounded-xl border border-border p-4
|
||||
hover:border-accent-green/30 hover:bg-bg-tertiary/50 transition-all duration-200 cursor-pointer;
|
||||
}
|
||||
|
||||
.badge-online {
|
||||
@apply w-2 h-2 rounded-full bg-accent-green shadow-[0_0_6px_#4ade80];
|
||||
}
|
||||
|
||||
.badge-offline {
|
||||
@apply w-2 h-2 rounded-full bg-text-muted;
|
||||
}
|
||||
|
||||
.badge-status {
|
||||
@apply inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium;
|
||||
}
|
||||
|
||||
.badge-status-open {
|
||||
@apply badge-status bg-accent-green/15 text-accent-green border border-accent-green/20;
|
||||
}
|
||||
|
||||
.badge-status-ingame {
|
||||
@apply badge-status bg-accent-orange/15 text-accent-orange border border-accent-orange/20;
|
||||
}
|
||||
|
||||
.badge-status-closed {
|
||||
@apply badge-status bg-text-muted/15 text-text-muted border border-text-muted/20;
|
||||
}
|
||||
|
||||
/* 加载动画 */
|
||||
.loading-spinner {
|
||||
@apply animate-spin rounded-full border-2 border-accent-green/20 border-t-accent-green;
|
||||
}
|
||||
|
||||
/* 工具提示 */
|
||||
.tooltip {
|
||||
@apply absolute z-50 px-2 py-1 text-xs font-medium text-text-primary
|
||||
bg-bg-tertiary border border-border rounded-lg shadow-lg
|
||||
animate-fade-in;
|
||||
}
|
||||
|
||||
/* 连接状态指示器 */
|
||||
.connection-indicator {
|
||||
@apply inline-flex items-center gap-1.5 px-2 py-1 rounded-full text-xs font-medium;
|
||||
}
|
||||
|
||||
.connection-p2p {
|
||||
@apply connection-indicator bg-accent-green/15 text-accent-green;
|
||||
}
|
||||
|
||||
.connection-relay {
|
||||
@apply connection-indicator bg-accent-orange/15 text-accent-orange;
|
||||
}
|
||||
|
||||
.connection-disconnected {
|
||||
@apply connection-indicator bg-text-muted/15 text-text-muted;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
/* 动画 */
|
||||
.animate-fade-in {
|
||||
animation: fadeIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
.animate-slide-up {
|
||||
animation: slideUp 0.3s ease-out;
|
||||
}
|
||||
|
||||
.animate-slide-down {
|
||||
animation: slideDown 0.3s ease-out;
|
||||
}
|
||||
|
||||
.animate-scale-in {
|
||||
animation: scaleIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
.animate-pulse-green {
|
||||
animation: pulseGreen 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from { opacity: 0; transform: translateY(10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes slideDown {
|
||||
from { opacity: 0; transform: translateY(-10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes scaleIn {
|
||||
from { opacity: 0; transform: scale(0.95); }
|
||||
to { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
|
||||
@keyframes pulseGreen {
|
||||
0%, 100% { box-shadow: 0 0 0 0 rgba(74, 222, 128, 0.4); }
|
||||
50% { box-shadow: 0 0 0 8px rgba(74, 222, 128, 0); }
|
||||
}
|
||||
|
||||
/* Minecraft 风格像素边框 */
|
||||
.pixel-border {
|
||||
box-shadow:
|
||||
inset -2px -2px 0 0 #1a1d27,
|
||||
inset 2px 2px 0 0 #4a4f6a;
|
||||
}
|
||||
}
|
||||
93
client/ui/src/lib/utils.ts
Normal file
93
client/ui/src/lib/utils.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { type ClassValue, clsx } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
export function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`;
|
||||
}
|
||||
|
||||
export function formatDuration(ms: number): string {
|
||||
const seconds = Math.floor(ms / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}小时${minutes % 60}分钟`;
|
||||
}
|
||||
if (minutes > 0) {
|
||||
return `${minutes}分钟${seconds % 60}秒`;
|
||||
}
|
||||
return `${seconds}秒`;
|
||||
}
|
||||
|
||||
export function formatRelativeTime(date: Date | string | number): string {
|
||||
const now = new Date();
|
||||
const then = new Date(date);
|
||||
const diffMs = now.getTime() - then.getTime();
|
||||
const diffSec = Math.floor(diffMs / 1000);
|
||||
const diffMin = Math.floor(diffSec / 60);
|
||||
const diffHour = Math.floor(diffMin / 60);
|
||||
const diffDay = Math.floor(diffHour / 24);
|
||||
|
||||
if (diffSec < 60) return '刚刚';
|
||||
if (diffMin < 60) return `${diffMin}分钟前`;
|
||||
if (diffHour < 24) return `${diffHour}小时前`;
|
||||
if (diffDay < 7) return `${diffDay}天前`;
|
||||
|
||||
return then.toLocaleDateString('zh-CN');
|
||||
}
|
||||
|
||||
export function generateAvatarColor(seed: string): string {
|
||||
const hash = seed.split('').reduce((acc, char) => {
|
||||
return char.charCodeAt(0) + ((acc << 5) - acc);
|
||||
}, 0);
|
||||
const h = Math.abs(hash) % 360;
|
||||
return `hsl(${h}, 60%, 50%)`;
|
||||
}
|
||||
|
||||
export function getInitials(name: string): string {
|
||||
return name
|
||||
.split(' ')
|
||||
.map((n) => n[0])
|
||||
.join('')
|
||||
.toUpperCase()
|
||||
.slice(0, 2);
|
||||
}
|
||||
|
||||
export function copyToClipboard(text: string): Promise<void> {
|
||||
return navigator.clipboard.writeText(text);
|
||||
}
|
||||
|
||||
export function debounce<T extends (...args: unknown[]) => unknown>(
|
||||
func: T,
|
||||
wait: number
|
||||
): (...args: Parameters<T>) => void {
|
||||
let timeoutId: ReturnType<typeof setTimeout>;
|
||||
|
||||
return (...args: Parameters<T>) => {
|
||||
clearTimeout(timeoutId);
|
||||
timeoutId = setTimeout(() => func(...args), wait);
|
||||
};
|
||||
}
|
||||
|
||||
export function throttle<T extends (...args: unknown[]) => unknown>(
|
||||
func: T,
|
||||
limit: number
|
||||
): (...args: Parameters<T>) => void {
|
||||
let inThrottle: boolean;
|
||||
|
||||
return (...args: Parameters<T>) => {
|
||||
if (!inThrottle) {
|
||||
func(...args);
|
||||
inThrottle = true;
|
||||
setTimeout(() => (inThrottle = false), limit);
|
||||
}
|
||||
};
|
||||
}
|
||||
124
client/ui/src/main.tsx
Normal file
124
client/ui/src/main.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { createMemoryRouter, RouterProvider, redirect, Navigate } from 'react-router-dom'
|
||||
import './index.css'
|
||||
|
||||
import LoginPage from './pages/Login'
|
||||
import RegisterPage from './pages/Register'
|
||||
import DashboardPage from './pages/Dashboard'
|
||||
import FriendsPage from './pages/Friends'
|
||||
import RoomPage from './pages/Room'
|
||||
import SettingsPage from './pages/Settings'
|
||||
import ServerSetupPage from './pages/ServerSetup'
|
||||
import AppLayout from './components/AppLayout'
|
||||
import { ToastProvider } from './components/Toast'
|
||||
import { useAuthStore } from './stores/authStore'
|
||||
import { useConfigStore } from './stores/configStore'
|
||||
|
||||
function requireAuth() {
|
||||
const { user } = useAuthStore.getState()
|
||||
if (!user) return redirect('/login')
|
||||
return null
|
||||
}
|
||||
|
||||
function requireGuest() {
|
||||
const { user } = useAuthStore.getState()
|
||||
if (user) return redirect('/dashboard')
|
||||
return null
|
||||
}
|
||||
|
||||
function requireConfig() {
|
||||
const { config } = useConfigStore.getState()
|
||||
if (!config || !config.server_url) return redirect('/setup')
|
||||
return null
|
||||
}
|
||||
|
||||
const router = createMemoryRouter([
|
||||
{
|
||||
path: '/setup',
|
||||
element: <ServerSetupPage />,
|
||||
},
|
||||
{
|
||||
path: '/login',
|
||||
loader: () => {
|
||||
requireConfig()
|
||||
return requireGuest()
|
||||
},
|
||||
element: <LoginPage />,
|
||||
},
|
||||
{
|
||||
path: '/register',
|
||||
loader: () => {
|
||||
requireConfig()
|
||||
return requireGuest()
|
||||
},
|
||||
element: <RegisterPage />,
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
loader: requireAuth,
|
||||
element: <AppLayout />,
|
||||
children: [
|
||||
{ index: true, element: <Navigate to="/dashboard" replace /> },
|
||||
{ path: 'dashboard', element: <DashboardPage /> },
|
||||
{ path: 'friends', element: <FriendsPage /> },
|
||||
{ path: 'room/:roomId', element: <RoomPage /> },
|
||||
{ path: 'settings', element: <SettingsPage /> },
|
||||
],
|
||||
},
|
||||
{ path: '*', element: <Navigate to="/login" replace /> },
|
||||
], { initialEntries: ['/setup'] })
|
||||
|
||||
function App() {
|
||||
const { init: initAuth } = useAuthStore()
|
||||
const { initConfig, config } = useConfigStore()
|
||||
const [ready, setReady] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const initialize = async () => {
|
||||
await initConfig()
|
||||
|
||||
const { config: currentConfig } = useConfigStore.getState()
|
||||
|
||||
if (currentConfig && currentConfig.server_url) {
|
||||
await initAuth()
|
||||
const { user } = useAuthStore.getState()
|
||||
if (user) {
|
||||
router.navigate('/dashboard', { replace: true })
|
||||
} else {
|
||||
router.navigate('/login', { replace: true })
|
||||
}
|
||||
} else {
|
||||
router.navigate('/setup', { replace: true })
|
||||
}
|
||||
|
||||
setReady(true)
|
||||
}
|
||||
|
||||
initialize()
|
||||
}, [])
|
||||
|
||||
if (!ready) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-900 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="text-4xl mb-4">🎮</div>
|
||||
<div className="text-white text-xl font-bold">FunMC</div>
|
||||
<div className="text-gray-400 text-sm mt-2">正在启动...</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ToastProvider>
|
||||
<RouterProvider router={router} />
|
||||
</ToastProvider>
|
||||
)
|
||||
}
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
)
|
||||
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>
|
||||
)
|
||||
}
|
||||
42
client/ui/src/router.tsx
Normal file
42
client/ui/src/router.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { createBrowserRouter, Navigate } from 'react-router-dom';
|
||||
import { AppLayout } from './components/AppLayout';
|
||||
import Login from './pages/Login';
|
||||
import Register from './pages/Register';
|
||||
import DashboardPage from './pages/Dashboard';
|
||||
import RoomPage from './pages/Room';
|
||||
import FriendsPage from './pages/Friends';
|
||||
import SettingsPage from './pages/Settings';
|
||||
import { useAuthStore } from './stores/authStore';
|
||||
|
||||
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||
const { token } = useAuthStore();
|
||||
if (!token) return <Navigate to="/login" replace />;
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
function PublicRoute({ children }: { children: React.ReactNode }) {
|
||||
const { token } = useAuthStore();
|
||||
if (token) return <Navigate to="/" replace />;
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
export const router = createBrowserRouter([
|
||||
{
|
||||
path: '/login',
|
||||
element: <PublicRoute><Login /></PublicRoute>,
|
||||
},
|
||||
{
|
||||
path: '/register',
|
||||
element: <PublicRoute><Register /></PublicRoute>,
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
element: <ProtectedRoute><AppLayout /></ProtectedRoute>,
|
||||
children: [
|
||||
{ index: true, element: <DashboardPage /> },
|
||||
{ path: 'room/:roomId', element: <RoomPage /> },
|
||||
{ path: 'friends', element: <FriendsPage /> },
|
||||
{ path: 'settings', element: <SettingsPage /> },
|
||||
],
|
||||
},
|
||||
]);
|
||||
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 }),
|
||||
}))
|
||||
49
client/ui/tailwind.config.js
Normal file
49
client/ui/tailwind.config.js
Normal file
@@ -0,0 +1,49 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
'bg-primary': '#0d1117',
|
||||
'bg-secondary': '#161b22',
|
||||
'bg-tertiary': '#21262d',
|
||||
'border': '#30363d',
|
||||
'text-primary': '#f0f6fc',
|
||||
'text-secondary': '#c9d1d9',
|
||||
'text-muted': '#8b949e',
|
||||
'accent-green': '#3fb950',
|
||||
'accent-blue': '#58a6ff',
|
||||
'accent-purple': '#a371f7',
|
||||
'accent-orange': '#d29922',
|
||||
'accent-red': '#f85149',
|
||||
},
|
||||
fontFamily: {
|
||||
'sans': ['Inter', '-apple-system', 'BlinkMacSystemFont', 'Segoe UI', 'Roboto', 'sans-serif'],
|
||||
'mono': ['JetBrains Mono', 'Fira Code', 'Consolas', 'monospace'],
|
||||
},
|
||||
animation: {
|
||||
'fade-in': 'fadeIn 0.2s ease-out',
|
||||
'slide-up': 'slideUp 0.3s ease-out',
|
||||
'pulse-green': 'pulseGreen 2s infinite',
|
||||
},
|
||||
keyframes: {
|
||||
fadeIn: {
|
||||
'0%': { opacity: '0' },
|
||||
'100%': { opacity: '1' },
|
||||
},
|
||||
slideUp: {
|
||||
'0%': { opacity: '0', transform: 'translateY(10px)' },
|
||||
'100%': { opacity: '1', transform: 'translateY(0)' },
|
||||
},
|
||||
pulseGreen: {
|
||||
'0%, 100%': { boxShadow: '0 0 0 0 rgba(63, 185, 80, 0.4)' },
|
||||
'50%': { boxShadow: '0 0 0 8px rgba(63, 185, 80, 0)' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
24
client/ui/tsconfig.json
Normal file
24
client/ui/tsconfig.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2021",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2021", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
11
client/ui/tsconfig.node.json
Normal file
11
client/ui/tsconfig.node.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
23
client/ui/vite.config.ts
Normal file
23
client/ui/vite.config.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import path from 'path'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
clearScreen: false,
|
||||
server: {
|
||||
port: 5173,
|
||||
strictPort: true,
|
||||
},
|
||||
envPrefix: ['VITE_', 'TAURI_'],
|
||||
build: {
|
||||
target: ['es2021', 'chrome100', 'safari15'],
|
||||
minify: !process.env.TAURI_DEBUG ? 'esbuild' : false,
|
||||
sourcemap: !!process.env.TAURI_DEBUG,
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user