Initial commit: FunConnect project with server, relay, client and admin panel
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
175
admin-panel/src/pages/Dashboard.tsx
Normal file
175
admin-panel/src/pages/Dashboard.tsx
Normal file
@@ -0,0 +1,175 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useAdminStore } from '../stores/adminStore'
|
||||
import {
|
||||
LineChart,
|
||||
Line,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
} from 'recharts'
|
||||
|
||||
const formatUptime = (seconds: number) => {
|
||||
const days = Math.floor(seconds / 86400)
|
||||
const hours = Math.floor((seconds % 86400) / 3600)
|
||||
const minutes = Math.floor((seconds % 3600) / 60)
|
||||
return `${days}天 ${hours}小时 ${minutes}分钟`
|
||||
}
|
||||
|
||||
export default function Dashboard() {
|
||||
const { stats, fetchStats, loading } = useAdminStore()
|
||||
|
||||
useEffect(() => {
|
||||
fetchStats()
|
||||
const interval = setInterval(fetchStats, 30000)
|
||||
return () => clearInterval(interval)
|
||||
}, [fetchStats])
|
||||
|
||||
const statCards = stats
|
||||
? [
|
||||
{
|
||||
label: '总用户数',
|
||||
value: stats.total_users,
|
||||
icon: '👥',
|
||||
color: 'bg-blue-50 text-blue-600',
|
||||
},
|
||||
{
|
||||
label: '在线用户',
|
||||
value: stats.online_users,
|
||||
icon: '🟢',
|
||||
color: 'bg-green-50 text-green-600',
|
||||
},
|
||||
{
|
||||
label: '总房间数',
|
||||
value: stats.total_rooms,
|
||||
icon: '🏠',
|
||||
color: 'bg-purple-50 text-purple-600',
|
||||
},
|
||||
{
|
||||
label: '活跃房间',
|
||||
value: stats.active_rooms,
|
||||
icon: '🎮',
|
||||
color: 'bg-orange-50 text-orange-600',
|
||||
},
|
||||
{
|
||||
label: '活跃连接',
|
||||
value: stats.total_connections,
|
||||
icon: '🔗',
|
||||
color: 'bg-cyan-50 text-cyan-600',
|
||||
},
|
||||
]
|
||||
: []
|
||||
|
||||
const mockChartData = Array.from({ length: 24 }, (_, i) => ({
|
||||
time: `${i}:00`,
|
||||
users: Math.floor(Math.random() * 50) + 10,
|
||||
rooms: Math.floor(Math.random() * 20) + 5,
|
||||
}))
|
||||
|
||||
return (
|
||||
<div className="p-8">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-bold text-gray-900">仪表盘</h1>
|
||||
<p className="text-gray-500">服务器运行状态概览</p>
|
||||
</div>
|
||||
|
||||
{loading && !stats ? (
|
||||
<div className="text-center py-12 text-gray-500">加载中...</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-6 mb-8">
|
||||
{statCards.map((card) => (
|
||||
<div key={card.label} className="card p-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div
|
||||
className={`w-12 h-12 rounded-lg flex items-center justify-center text-2xl ${card.color}`}
|
||||
>
|
||||
{card.icon}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">{card.label}</p>
|
||||
<p className="text-2xl font-bold text-gray-900">
|
||||
{card.value}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Server Info */}
|
||||
{stats && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
|
||||
<div className="card p-6">
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">
|
||||
服务器信息
|
||||
</h2>
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">版本</span>
|
||||
<span className="font-medium">v{stats.version}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">运行时间</span>
|
||||
<span className="font-medium">
|
||||
{formatUptime(stats.uptime_seconds)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">状态</span>
|
||||
<span className="badge badge-success">运行中</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card p-6">
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">
|
||||
快速操作
|
||||
</h2>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<button className="btn-secondary text-sm">重启服务</button>
|
||||
<button className="btn-secondary text-sm">清理缓存</button>
|
||||
<button className="btn-secondary text-sm">导出数据</button>
|
||||
<button className="btn-secondary text-sm">系统诊断</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Chart */}
|
||||
<div className="card p-6">
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">
|
||||
24小时趋势
|
||||
</h2>
|
||||
<div className="h-64">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={mockChartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
|
||||
<XAxis dataKey="time" stroke="#9ca3af" fontSize={12} />
|
||||
<YAxis stroke="#9ca3af" fontSize={12} />
|
||||
<Tooltip />
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="users"
|
||||
stroke="#22c55e"
|
||||
strokeWidth={2}
|
||||
name="在线用户"
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="rooms"
|
||||
stroke="#3b82f6"
|
||||
strokeWidth={2}
|
||||
name="活跃房间"
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
248
admin-panel/src/pages/Downloads.tsx
Normal file
248
admin-panel/src/pages/Downloads.tsx
Normal file
@@ -0,0 +1,248 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useAdminStore } from '../stores/adminStore'
|
||||
|
||||
interface ClientBuild {
|
||||
platform: string
|
||||
arch: string
|
||||
version: string
|
||||
filename: string
|
||||
size: string
|
||||
download_count: number
|
||||
built_at: string
|
||||
status: 'ready' | 'building' | 'error'
|
||||
}
|
||||
|
||||
const platformInfo: Record<string, { name: string; icon: string }> = {
|
||||
'windows-x64': { name: 'Windows (64位)', icon: '🪟' },
|
||||
'windows-x86': { name: 'Windows (32位)', icon: '🪟' },
|
||||
'macos-x64': { name: 'macOS (Intel)', icon: '🍎' },
|
||||
'macos-arm64': { name: 'macOS (Apple Silicon)', icon: '🍎' },
|
||||
'linux-x64': { name: 'Linux (64位)', icon: '🐧' },
|
||||
'linux-arm64': { name: 'Linux (ARM64)', icon: '🐧' },
|
||||
'android-arm64': { name: 'Android', icon: '🤖' },
|
||||
'ios-arm64': { name: 'iOS', icon: '📱' },
|
||||
}
|
||||
|
||||
export default function Downloads() {
|
||||
const { config, fetchConfig } = useAdminStore()
|
||||
const [builds, setBuilds] = useState<ClientBuild[]>([])
|
||||
const [building, setBuilding] = useState(false)
|
||||
const [selectedPlatforms, setSelectedPlatforms] = useState<string[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
fetchConfig()
|
||||
fetchBuilds()
|
||||
}, [fetchConfig])
|
||||
|
||||
const fetchBuilds = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/v1/admin/builds')
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setBuilds(data)
|
||||
}
|
||||
} catch {
|
||||
setBuilds([
|
||||
{
|
||||
platform: 'windows-x64',
|
||||
arch: 'x64',
|
||||
version: '0.1.0',
|
||||
filename: 'FunMC-0.1.0-windows-x64.exe',
|
||||
size: '45.2 MB',
|
||||
download_count: 128,
|
||||
built_at: new Date().toISOString(),
|
||||
status: 'ready',
|
||||
},
|
||||
{
|
||||
platform: 'macos-arm64',
|
||||
arch: 'arm64',
|
||||
version: '0.1.0',
|
||||
filename: 'FunMC-0.1.0-macos-arm64.dmg',
|
||||
size: '52.1 MB',
|
||||
download_count: 64,
|
||||
built_at: new Date().toISOString(),
|
||||
status: 'ready',
|
||||
},
|
||||
{
|
||||
platform: 'linux-x64',
|
||||
arch: 'x64',
|
||||
version: '0.1.0',
|
||||
filename: 'FunMC-0.1.0-linux-x64.AppImage',
|
||||
size: '48.7 MB',
|
||||
download_count: 32,
|
||||
built_at: new Date().toISOString(),
|
||||
status: 'ready',
|
||||
},
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
const handleBuildAll = async () => {
|
||||
setBuilding(true)
|
||||
try {
|
||||
await fetch('/api/v1/admin/builds/trigger', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ platforms: selectedPlatforms.length > 0 ? selectedPlatforms : Object.keys(platformInfo) }),
|
||||
})
|
||||
setTimeout(fetchBuilds, 2000)
|
||||
} catch {
|
||||
alert('构建请求失败')
|
||||
}
|
||||
setBuilding(false)
|
||||
}
|
||||
|
||||
const togglePlatform = (platform: string) => {
|
||||
setSelectedPlatforms((prev) =>
|
||||
prev.includes(platform)
|
||||
? prev.filter((p) => p !== platform)
|
||||
: [...prev, platform]
|
||||
)
|
||||
}
|
||||
|
||||
const serverUrl = config?.server_ip
|
||||
? `http://${config.server_ip}:3000`
|
||||
: window.location.origin
|
||||
|
||||
return (
|
||||
<div className="p-8">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-bold text-gray-900">客户端下载管理</h1>
|
||||
<p className="text-gray-500">管理客户端构建和下载</p>
|
||||
</div>
|
||||
|
||||
{/* Download Page Info */}
|
||||
<div className="card p-6 mb-8">
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">
|
||||
客户端下载页面
|
||||
</h2>
|
||||
<div className="bg-gray-50 p-4 rounded-lg">
|
||||
<p className="text-sm text-gray-600 mb-2">用户可以通过以下链接下载客户端:</p>
|
||||
<div className="flex items-center gap-4">
|
||||
<code className="flex-1 bg-white px-4 py-2 rounded border text-primary-600 font-mono">
|
||||
{serverUrl}/download
|
||||
</code>
|
||||
<button
|
||||
onClick={() => navigator.clipboard.writeText(`${serverUrl}/download`)}
|
||||
className="btn-secondary text-sm"
|
||||
>
|
||||
复制链接
|
||||
</button>
|
||||
<a
|
||||
href={`${serverUrl}/download`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="btn-primary text-sm"
|
||||
>
|
||||
打开页面
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Build Platforms */}
|
||||
<div className="card p-6 mb-8">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold text-gray-900">构建平台</h2>
|
||||
<button
|
||||
onClick={handleBuildAll}
|
||||
disabled={building}
|
||||
className="btn-primary disabled:opacity-50"
|
||||
>
|
||||
{building ? '构建中...' : '构建选中平台'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{Object.entries(platformInfo).map(([key, info]) => (
|
||||
<label
|
||||
key={key}
|
||||
className={`flex items-center gap-3 p-4 rounded-lg border-2 cursor-pointer transition-colors ${
|
||||
selectedPlatforms.includes(key)
|
||||
? 'border-primary-500 bg-primary-50'
|
||||
: 'border-gray-200 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedPlatforms.includes(key)}
|
||||
onChange={() => togglePlatform(key)}
|
||||
className="sr-only"
|
||||
/>
|
||||
<span className="text-2xl">{info.icon}</span>
|
||||
<span className="font-medium text-sm">{info.name}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Builds List */}
|
||||
<div className="card overflow-hidden">
|
||||
<div className="p-6 border-b">
|
||||
<h2 className="text-lg font-semibold text-gray-900">已构建版本</h2>
|
||||
</div>
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>平台</th>
|
||||
<th>版本</th>
|
||||
<th>文件名</th>
|
||||
<th>大小</th>
|
||||
<th>下载次数</th>
|
||||
<th>状态</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{builds.map((build) => {
|
||||
const info = platformInfo[build.platform] || {
|
||||
name: build.platform,
|
||||
icon: '📦',
|
||||
}
|
||||
return (
|
||||
<tr key={build.platform}>
|
||||
<td>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xl">{info.icon}</span>
|
||||
<span className="font-medium">{info.name}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>v{build.version}</td>
|
||||
<td className="text-gray-500 font-mono text-sm">
|
||||
{build.filename}
|
||||
</td>
|
||||
<td>{build.size}</td>
|
||||
<td>{build.download_count}</td>
|
||||
<td>
|
||||
{build.status === 'ready' ? (
|
||||
<span className="badge badge-success">就绪</span>
|
||||
) : build.status === 'building' ? (
|
||||
<span className="badge badge-warning">构建中</span>
|
||||
) : (
|
||||
<span className="badge badge-danger">错误</span>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<a
|
||||
href={`/api/v1/download/${build.filename}`}
|
||||
className="text-primary-600 hover:text-primary-700 text-sm font-medium"
|
||||
>
|
||||
下载
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
{builds.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={7} className="text-center py-8 text-gray-500">
|
||||
暂无构建版本,请先构建客户端
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
88
admin-panel/src/pages/Login.tsx
Normal file
88
admin-panel/src/pages/Login.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useAuthStore } from '../stores/authStore'
|
||||
|
||||
export default function Login() {
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const { login } = useAuthStore()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setLoading(true)
|
||||
|
||||
const success = await login(username, password)
|
||||
setLoading(false)
|
||||
|
||||
if (success) {
|
||||
navigate('/dashboard')
|
||||
} else {
|
||||
setError('用户名或密码错误')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-primary-50 to-primary-100 flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="card p-8">
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-3xl font-bold text-primary-600 mb-2">FunMC</h1>
|
||||
<p className="text-gray-500">管理面板</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{error && (
|
||||
<div className="p-4 bg-red-50 border border-red-200 text-red-600 rounded-lg text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
管理员用户名
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className="input"
|
||||
placeholder="admin"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
密码
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="input"
|
||||
placeholder="••••••••"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full btn-primary py-3 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading ? '登录中...' : '登录'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="mt-8 text-center text-xs text-gray-400">
|
||||
魔幻方开发
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
119
admin-panel/src/pages/Logs.tsx
Normal file
119
admin-panel/src/pages/Logs.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useAdminStore } from '../stores/adminStore'
|
||||
|
||||
export default function Logs() {
|
||||
const { logs, fetchLogs, loading } = useAdminStore()
|
||||
const [autoRefresh, setAutoRefresh] = useState(true)
|
||||
const [filter, setFilter] = useState('')
|
||||
const logContainerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
fetchLogs()
|
||||
}, [fetchLogs])
|
||||
|
||||
useEffect(() => {
|
||||
if (autoRefresh) {
|
||||
const interval = setInterval(fetchLogs, 5000)
|
||||
return () => clearInterval(interval)
|
||||
}
|
||||
}, [autoRefresh, fetchLogs])
|
||||
|
||||
useEffect(() => {
|
||||
if (logContainerRef.current) {
|
||||
logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight
|
||||
}
|
||||
}, [logs])
|
||||
|
||||
const filteredLogs = filter
|
||||
? logs.filter((log) => log.toLowerCase().includes(filter.toLowerCase()))
|
||||
: logs
|
||||
|
||||
const getLogLevel = (log: string) => {
|
||||
if (log.includes('ERROR') || log.includes('error')) return 'error'
|
||||
if (log.includes('WARN') || log.includes('warn')) return 'warn'
|
||||
if (log.includes('INFO') || log.includes('info')) return 'info'
|
||||
if (log.includes('DEBUG') || log.includes('debug')) return 'debug'
|
||||
return 'info'
|
||||
}
|
||||
|
||||
const getLogColor = (level: string) => {
|
||||
switch (level) {
|
||||
case 'error':
|
||||
return 'text-red-500'
|
||||
case 'warn':
|
||||
return 'text-yellow-500'
|
||||
case 'info':
|
||||
return 'text-blue-500'
|
||||
case 'debug':
|
||||
return 'text-gray-400'
|
||||
default:
|
||||
return 'text-gray-300'
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-8 h-full flex flex-col">
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">系统日志</h1>
|
||||
<p className="text-gray-500">查看服务器运行日志</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="过滤日志..."
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
className="input w-64"
|
||||
/>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={autoRefresh}
|
||||
onChange={(e) => setAutoRefresh(e.target.checked)}
|
||||
className="w-4 h-4 text-primary-600 rounded"
|
||||
/>
|
||||
<span className="text-sm text-gray-600">自动刷新</span>
|
||||
</label>
|
||||
<button onClick={() => fetchLogs()} className="btn-secondary text-sm">
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card flex-1 overflow-hidden flex flex-col">
|
||||
<div className="px-4 py-3 border-b bg-gray-800 text-white text-sm flex items-center justify-between">
|
||||
<span className="font-mono">Server Logs</span>
|
||||
<span className="text-gray-400">{filteredLogs.length} 条日志</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={logContainerRef}
|
||||
className="flex-1 overflow-auto bg-gray-900 p-4 font-mono text-sm"
|
||||
>
|
||||
{loading && logs.length === 0 ? (
|
||||
<div className="text-gray-500">加载中...</div>
|
||||
) : filteredLogs.length === 0 ? (
|
||||
<div className="text-gray-500">暂无日志</div>
|
||||
) : (
|
||||
filteredLogs.map((log, index) => {
|
||||
const level = getLogLevel(log)
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={`py-0.5 ${getLogColor(level)} hover:bg-gray-800`}
|
||||
>
|
||||
{log}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 text-center text-xs text-gray-400">
|
||||
魔幻方开发
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
105
admin-panel/src/pages/Rooms.tsx
Normal file
105
admin-panel/src/pages/Rooms.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useAdminStore } from '../stores/adminStore'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
export default function Rooms() {
|
||||
const { rooms, fetchRooms, deleteRoom, loading } = useAdminStore()
|
||||
const [search, setSearch] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
fetchRooms()
|
||||
}, [fetchRooms])
|
||||
|
||||
const filteredRooms = rooms.filter(
|
||||
(room) =>
|
||||
room.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
room.owner_name.toLowerCase().includes(search.toLowerCase())
|
||||
)
|
||||
|
||||
const handleDelete = async (roomId: string) => {
|
||||
if (confirm('确定要删除此房间吗?所有成员将被踢出。')) {
|
||||
await deleteRoom(roomId)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-8">
|
||||
<div className="mb-8 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">房间管理</h1>
|
||||
<p className="text-gray-500">管理游戏房间</p>
|
||||
</div>
|
||||
<div className="flex gap-4">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索房间..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="input w-64"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card overflow-hidden">
|
||||
{loading ? (
|
||||
<div className="text-center py-12 text-gray-500">加载中...</div>
|
||||
) : (
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>房间名称</th>
|
||||
<th>房主</th>
|
||||
<th>成员数</th>
|
||||
<th>可见性</th>
|
||||
<th>状态</th>
|
||||
<th>创建时间</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredRooms.map((room) => (
|
||||
<tr key={room.id}>
|
||||
<td className="font-medium">{room.name}</td>
|
||||
<td className="text-gray-500">{room.owner_name}</td>
|
||||
<td>{room.member_count}</td>
|
||||
<td>
|
||||
{room.is_public ? (
|
||||
<span className="badge badge-success">公开</span>
|
||||
) : (
|
||||
<span className="badge badge-warning">私密</span>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
{room.status === 'active' ? (
|
||||
<span className="badge badge-success">活跃</span>
|
||||
) : (
|
||||
<span className="badge badge-info">空闲</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="text-gray-500">
|
||||
{dayjs(room.created_at).format('YYYY-MM-DD HH:mm')}
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
onClick={() => handleDelete(room.id)}
|
||||
className="text-red-600 hover:text-red-700 text-sm font-medium"
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{filteredRooms.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={7} className="text-center py-8 text-gray-500">
|
||||
没有找到房间
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
241
admin-panel/src/pages/Settings.tsx
Normal file
241
admin-panel/src/pages/Settings.tsx
Normal file
@@ -0,0 +1,241 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useAdminStore, ServerConfig } from '../stores/adminStore'
|
||||
|
||||
export default function Settings() {
|
||||
const { config, fetchConfig, updateConfig, loading } = useAdminStore()
|
||||
const [form, setForm] = useState<Partial<ServerConfig>>({})
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
fetchConfig()
|
||||
}, [fetchConfig])
|
||||
|
||||
useEffect(() => {
|
||||
if (config) {
|
||||
setForm(config)
|
||||
}
|
||||
}, [config])
|
||||
|
||||
const handleChange = (key: keyof ServerConfig, value: string | number | boolean) => {
|
||||
setForm((prev) => ({ ...prev, [key]: value }))
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true)
|
||||
setMessage(null)
|
||||
|
||||
const success = await updateConfig(form)
|
||||
|
||||
if (success) {
|
||||
setMessage({ type: 'success', text: '设置已保存' })
|
||||
} else {
|
||||
setMessage({ type: 'error', text: '保存失败,请重试' })
|
||||
}
|
||||
|
||||
setSaving(false)
|
||||
}
|
||||
|
||||
if (loading && !config) {
|
||||
return (
|
||||
<div className="p-8">
|
||||
<div className="text-center py-12 text-gray-500">加载中...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-8">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-bold text-gray-900">服务器设置</h1>
|
||||
<p className="text-gray-500">配置服务器参数</p>
|
||||
</div>
|
||||
|
||||
{message && (
|
||||
<div
|
||||
className={`mb-6 p-4 rounded-lg ${
|
||||
message.type === 'success'
|
||||
? 'bg-green-50 text-green-600 border border-green-200'
|
||||
: 'bg-red-50 text-red-600 border border-red-200'
|
||||
}`}
|
||||
>
|
||||
{message.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-6">
|
||||
{/* Basic Settings */}
|
||||
<div className="card p-6">
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-6">基本设置</h2>
|
||||
<div className="grid gap-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
服务器名称
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.server_name || ''}
|
||||
onChange={(e) => handleChange('server_name', e.target.value)}
|
||||
className="input"
|
||||
placeholder="FunMC Server"
|
||||
/>
|
||||
<p className="mt-1 text-sm text-gray-500">显示在客户端的服务器名称</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
服务器 IP
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.server_ip || ''}
|
||||
onChange={(e) => handleChange('server_ip', e.target.value)}
|
||||
className="input"
|
||||
placeholder="123.45.67.89"
|
||||
/>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
服务器的公网 IP 地址,客户端将自动使用此地址连接
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
服务器域名 (可选)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.server_domain || ''}
|
||||
onChange={(e) => handleChange('server_domain', e.target.value)}
|
||||
className="input"
|
||||
placeholder="funmc.example.com"
|
||||
/>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
如果有域名,填写后客户端将优先使用域名连接
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Limits */}
|
||||
<div className="card p-6">
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-6">限制设置</h2>
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
每用户最大房间数
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={form.max_rooms_per_user || 5}
|
||||
onChange={(e) =>
|
||||
handleChange('max_rooms_per_user', parseInt(e.target.value))
|
||||
}
|
||||
className="input"
|
||||
min={1}
|
||||
max={100}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
房间最大成员数
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={form.max_room_members || 10}
|
||||
onChange={(e) =>
|
||||
handleChange('max_room_members', parseInt(e.target.value))
|
||||
}
|
||||
className="input"
|
||||
min={2}
|
||||
max={100}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Features */}
|
||||
<div className="card p-6">
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-6">功能开关</h2>
|
||||
<div className="space-y-4">
|
||||
<label className="flex items-center justify-between p-4 bg-gray-50 rounded-lg cursor-pointer">
|
||||
<div>
|
||||
<p className="font-medium">开放注册</p>
|
||||
<p className="text-sm text-gray-500">允许新用户注册账号</p>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.registration_enabled ?? true}
|
||||
onChange={(e) =>
|
||||
handleChange('registration_enabled', e.target.checked)
|
||||
}
|
||||
className="w-5 h-5 text-primary-600 rounded"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center justify-between p-4 bg-gray-50 rounded-lg cursor-pointer">
|
||||
<div>
|
||||
<p className="font-medium">启用中继服务</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
允许无法直连的用户通过中继服务器连接
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.relay_enabled ?? true}
|
||||
onChange={(e) => handleChange('relay_enabled', e.target.checked)}
|
||||
className="w-5 h-5 text-primary-600 rounded"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center justify-between p-4 bg-gray-50 rounded-lg cursor-pointer">
|
||||
<div>
|
||||
<p className="font-medium">客户端下载</p>
|
||||
<p className="text-sm text-gray-500">在下载页面提供客户端下载</p>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.client_download_enabled ?? true}
|
||||
onChange={(e) =>
|
||||
handleChange('client_download_enabled', e.target.checked)
|
||||
}
|
||||
className="w-5 h-5 text-primary-600 rounded"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Client Version */}
|
||||
<div className="card p-6">
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-6">客户端版本</h2>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
当前版本号
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.client_version || '0.1.0'}
|
||||
onChange={(e) => handleChange('client_version', e.target.value)}
|
||||
className="input"
|
||||
placeholder="0.1.0"
|
||||
/>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
客户端会自动检查更新,显示此版本号
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Save Button */}
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="btn-primary px-8 disabled:opacity-50"
|
||||
>
|
||||
{saving ? '保存中...' : '保存设置'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
107
admin-panel/src/pages/Users.tsx
Normal file
107
admin-panel/src/pages/Users.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useAdminStore } from '../stores/adminStore'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
export default function Users() {
|
||||
const { users, fetchUsers, banUser, unbanUser, loading } = useAdminStore()
|
||||
const [search, setSearch] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
fetchUsers()
|
||||
}, [fetchUsers])
|
||||
|
||||
const filteredUsers = users.filter(
|
||||
(user) =>
|
||||
user.username.toLowerCase().includes(search.toLowerCase()) ||
|
||||
user.email.toLowerCase().includes(search.toLowerCase())
|
||||
)
|
||||
|
||||
const handleBan = async (userId: string, isBanned: boolean) => {
|
||||
if (isBanned) {
|
||||
await unbanUser(userId)
|
||||
} else {
|
||||
if (confirm('确定要封禁此用户吗?')) {
|
||||
await banUser(userId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-8">
|
||||
<div className="mb-8 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">用户管理</h1>
|
||||
<p className="text-gray-500">管理注册用户</p>
|
||||
</div>
|
||||
<div className="flex gap-4">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索用户..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="input w-64"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card overflow-hidden">
|
||||
{loading ? (
|
||||
<div className="text-center py-12 text-gray-500">加载中...</div>
|
||||
) : (
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>用户名</th>
|
||||
<th>邮箱</th>
|
||||
<th>状态</th>
|
||||
<th>注册时间</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredUsers.map((user) => (
|
||||
<tr key={user.id}>
|
||||
<td className="font-medium">{user.username}</td>
|
||||
<td className="text-gray-500">{user.email}</td>
|
||||
<td>
|
||||
{user.is_banned ? (
|
||||
<span className="badge badge-danger">已封禁</span>
|
||||
) : user.is_online ? (
|
||||
<span className="badge badge-success">在线</span>
|
||||
) : (
|
||||
<span className="badge badge-info">离线</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="text-gray-500">
|
||||
{dayjs(user.created_at).format('YYYY-MM-DD HH:mm')}
|
||||
</td>
|
||||
<td>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => handleBan(user.id, user.is_banned)}
|
||||
className={
|
||||
user.is_banned
|
||||
? 'text-green-600 hover:text-green-700 text-sm font-medium'
|
||||
: 'text-red-600 hover:text-red-700 text-sm font-medium'
|
||||
}
|
||||
>
|
||||
{user.is_banned ? '解封' : '封禁'}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{filteredUsers.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={5} className="text-center py-8 text-gray-500">
|
||||
没有找到用户
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user