Initial commit: FunConnect project with server, relay, client and admin panel
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
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';
|
||||
Reference in New Issue
Block a user