Initial commit: FunConnect project with server, relay, client and admin panel

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-02-24 20:56:36 +08:00
parent eb6e901440
commit b6891483ae
167 changed files with 16147 additions and 106 deletions

13
admin-panel/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>FunMC 管理面板</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

32
admin-panel/package.json Normal file
View File

@@ -0,0 +1,32 @@
{
"name": "funmc-admin-panel",
"version": "0.1.0",
"description": "FunMC 服务端管理面板",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.20.0",
"zustand": "^4.4.7",
"recharts": "^2.10.0",
"dayjs": "^1.11.10",
"clsx": "^2.0.0"
},
"devDependencies": {
"@types/react": "^18.2.37",
"@types/react-dom": "^18.2.15",
"@vitejs/plugin-react": "^4.2.0",
"autoprefixer": "^10.4.16",
"postcss": "^8.4.31",
"tailwindcss": "^3.3.5",
"typescript": "^5.2.2",
"vite": "^5.0.0"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<rect width="100" height="100" rx="20" fill="#22c55e"/>
<text x="50" y="68" font-family="Arial, sans-serif" font-size="50" font-weight="bold" fill="white" text-anchor="middle">F</text>
</svg>

After

Width:  |  Height:  |  Size: 259 B

44
admin-panel/src/App.tsx Normal file
View File

@@ -0,0 +1,44 @@
import { Routes, Route, Navigate } from 'react-router-dom'
import { useAuthStore } from './stores/authStore'
import Login from './pages/Login'
import Layout from './components/Layout'
import Dashboard from './pages/Dashboard'
import Users from './pages/Users'
import Rooms from './pages/Rooms'
import Settings from './pages/Settings'
import Downloads from './pages/Downloads'
import Logs from './pages/Logs'
function ProtectedRoute({ children }: { children: React.ReactNode }) {
const { isAuthenticated } = useAuthStore()
if (!isAuthenticated) {
return <Navigate to="/login" replace />
}
return <>{children}</>
}
function App() {
return (
<Routes>
<Route path="/login" element={<Login />} />
<Route
path="/"
element={
<ProtectedRoute>
<Layout />
</ProtectedRoute>
}
>
<Route index element={<Navigate to="/dashboard" replace />} />
<Route path="dashboard" element={<Dashboard />} />
<Route path="users" element={<Users />} />
<Route path="rooms" element={<Rooms />} />
<Route path="downloads" element={<Downloads />} />
<Route path="settings" element={<Settings />} />
<Route path="logs" element={<Logs />} />
</Route>
</Routes>
)
}
export default App

View File

@@ -0,0 +1,68 @@
import { Outlet, NavLink, useNavigate } from 'react-router-dom'
import { useAuthStore } from '../stores/authStore'
import clsx from 'clsx'
const navItems = [
{ path: '/dashboard', label: '仪表盘', icon: '📊' },
{ path: '/users', label: '用户管理', icon: '👥' },
{ path: '/rooms', label: '房间管理', icon: '🏠' },
{ path: '/downloads', label: '客户端下载', icon: '📥' },
{ path: '/settings', label: '服务器设置', icon: '⚙️' },
{ path: '/logs', label: '系统日志', icon: '📋' },
]
export default function Layout() {
const { logout } = useAuthStore()
const navigate = useNavigate()
const handleLogout = () => {
logout()
navigate('/login')
}
return (
<div className="min-h-screen flex">
{/* Sidebar */}
<aside className="w-64 bg-white border-r border-gray-200 flex flex-col">
<div className="p-6 border-b border-gray-200">
<h1 className="text-xl font-bold text-primary-600">FunMC</h1>
<p className="text-sm text-gray-500"></p>
</div>
<nav className="flex-1 p-4 space-y-1">
{navItems.map((item) => (
<NavLink
key={item.path}
to={item.path}
className={({ isActive }) =>
clsx('sidebar-link', isActive && 'active')
}
>
<span className="text-lg">{item.icon}</span>
<span>{item.label}</span>
</NavLink>
))}
</nav>
<div className="p-4 border-t border-gray-200">
<button
onClick={handleLogout}
className="w-full flex items-center gap-3 px-4 py-3 text-gray-600 hover:bg-red-50 hover:text-red-600 rounded-lg transition-colors"
>
<span className="text-lg">🚪</span>
<span>退</span>
</button>
</div>
<div className="p-4 text-center text-xs text-gray-400 border-t border-gray-200">
</div>
</aside>
{/* Main content */}
<main className="flex-1 overflow-auto">
<Outlet />
</main>
</div>
)
}

72
admin-panel/src/index.css Normal file
View File

@@ -0,0 +1,72 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background-color: #f3f4f6;
}
.sidebar-link {
@apply flex items-center gap-3 px-4 py-3 text-gray-600 hover:bg-gray-100 hover:text-gray-900 rounded-lg transition-colors;
}
.sidebar-link.active {
@apply bg-primary-50 text-primary-700 font-medium;
}
.card {
@apply bg-white rounded-xl shadow-sm border border-gray-100;
}
.btn-primary {
@apply px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors font-medium;
}
.btn-secondary {
@apply px-4 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors font-medium;
}
.btn-danger {
@apply px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors font-medium;
}
.input {
@apply w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent;
}
.table {
@apply w-full text-left;
}
.table th {
@apply px-4 py-3 bg-gray-50 text-gray-600 font-medium text-sm uppercase tracking-wider;
}
.table td {
@apply px-4 py-3 border-b border-gray-100;
}
.table tr:hover {
@apply bg-gray-50;
}
.badge {
@apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium;
}
.badge-success {
@apply bg-green-100 text-green-800;
}
.badge-warning {
@apply bg-yellow-100 text-yellow-800;
}
.badge-danger {
@apply bg-red-100 text-red-800;
}
.badge-info {
@apply bg-blue-100 text-blue-800;
}

13
admin-panel/src/main.tsx Normal file
View File

@@ -0,0 +1,13 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import App from './App'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<BrowserRouter basename="/admin">
<App />
</BrowserRouter>
</React.StrictMode>
)

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View File

@@ -0,0 +1,209 @@
import { create } from 'zustand'
import { useAuthStore } from './authStore'
export interface User {
id: string
username: string
email: string
created_at: string
is_online: boolean
is_banned: boolean
}
export interface Room {
id: string
name: string
owner_id: string
owner_name: string
is_public: boolean
member_count: number
created_at: string
status: 'active' | 'idle'
}
export interface ServerStats {
total_users: number
online_users: number
total_rooms: number
active_rooms: number
total_connections: number
uptime_seconds: number
version: string
}
export interface ServerConfig {
server_name: string
server_ip: string
server_domain: string
max_rooms_per_user: number
max_room_members: number
relay_enabled: boolean
registration_enabled: boolean
client_download_enabled: boolean
client_version: string
}
interface AdminState {
stats: ServerStats | null
users: User[]
rooms: Room[]
config: ServerConfig | null
logs: string[]
loading: boolean
error: string | null
fetchStats: () => Promise<void>
fetchUsers: () => Promise<void>
fetchRooms: () => Promise<void>
fetchConfig: () => Promise<void>
fetchLogs: () => Promise<void>
updateConfig: (config: Partial<ServerConfig>) => Promise<boolean>
banUser: (userId: string) => Promise<boolean>
unbanUser: (userId: string) => Promise<boolean>
deleteRoom: (roomId: string) => Promise<boolean>
}
const getAuthHeader = () => {
const token = useAuthStore.getState().token
return { Authorization: `Bearer ${token}` }
}
export const useAdminStore = create<AdminState>((set, get) => ({
stats: null,
users: [],
rooms: [],
config: null,
logs: [],
loading: false,
error: null,
fetchStats: async () => {
set({ loading: true, error: null })
try {
const res = await fetch('/api/v1/admin/stats', {
headers: { ...getAuthHeader() },
})
if (!res.ok) throw new Error('Failed to fetch stats')
const data = await res.json()
set({ stats: data, loading: false })
} catch (e) {
set({ error: String(e), loading: false })
}
},
fetchUsers: async () => {
set({ loading: true, error: null })
try {
const res = await fetch('/api/v1/admin/users', {
headers: { ...getAuthHeader() },
})
if (!res.ok) throw new Error('Failed to fetch users')
const data = await res.json()
set({ users: data, loading: false })
} catch (e) {
set({ error: String(e), loading: false })
}
},
fetchRooms: async () => {
set({ loading: true, error: null })
try {
const res = await fetch('/api/v1/admin/rooms', {
headers: { ...getAuthHeader() },
})
if (!res.ok) throw new Error('Failed to fetch rooms')
const data = await res.json()
set({ rooms: data, loading: false })
} catch (e) {
set({ error: String(e), loading: false })
}
},
fetchConfig: async () => {
set({ loading: true, error: null })
try {
const res = await fetch('/api/v1/admin/config', {
headers: { ...getAuthHeader() },
})
if (!res.ok) throw new Error('Failed to fetch config')
const data = await res.json()
set({ config: data, loading: false })
} catch (e) {
set({ error: String(e), loading: false })
}
},
fetchLogs: async () => {
set({ loading: true, error: null })
try {
const res = await fetch('/api/v1/admin/logs', {
headers: { ...getAuthHeader() },
})
if (!res.ok) throw new Error('Failed to fetch logs')
const data = await res.json()
set({ logs: data.logs, loading: false })
} catch (e) {
set({ error: String(e), loading: false })
}
},
updateConfig: async (config) => {
try {
const res = await fetch('/api/v1/admin/config', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
...getAuthHeader(),
},
body: JSON.stringify(config),
})
if (!res.ok) return false
await get().fetchConfig()
return true
} catch {
return false
}
},
banUser: async (userId) => {
try {
const res = await fetch(`/api/v1/admin/users/${userId}/ban`, {
method: 'POST',
headers: { ...getAuthHeader() },
})
if (!res.ok) return false
await get().fetchUsers()
return true
} catch {
return false
}
},
unbanUser: async (userId) => {
try {
const res = await fetch(`/api/v1/admin/users/${userId}/unban`, {
method: 'POST',
headers: { ...getAuthHeader() },
})
if (!res.ok) return false
await get().fetchUsers()
return true
} catch {
return false
}
},
deleteRoom: async (roomId) => {
try {
const res = await fetch(`/api/v1/admin/rooms/${roomId}`, {
method: 'DELETE',
headers: { ...getAuthHeader() },
})
if (!res.ok) return false
await get().fetchRooms()
return true
} catch {
return false
}
},
}))

View File

@@ -0,0 +1,45 @@
import { create } from 'zustand'
import { persist, createJSONStorage } from 'zustand/middleware'
interface AuthState {
token: string | null
isAuthenticated: boolean
login: (username: string, password: string) => Promise<boolean>
logout: () => void
}
export const useAuthStore = create<AuthState>()(
persist(
(set) => ({
token: null,
isAuthenticated: false,
login: async (username: string, password: string) => {
try {
const res = await fetch('/api/v1/admin/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
})
if (!res.ok) {
return false
}
const data = await res.json()
set({ token: data.token, isAuthenticated: true })
return true
} catch {
return false
}
},
logout: () => {
set({ token: null, isAuthenticated: false })
},
}),
{
name: 'funmc-admin-auth',
}
)
)

View File

@@ -0,0 +1,23 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
theme: {
extend: {
colors: {
primary: {
50: '#f0fdf4',
100: '#dcfce7',
200: '#bbf7d0',
300: '#86efac',
400: '#4ade80',
500: '#22c55e',
600: '#16a34a',
700: '#15803d',
800: '#166534',
900: '#14532d',
},
},
},
},
plugins: [],
}

21
admin-panel/tsconfig.json Normal file
View File

@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

View File

@@ -0,0 +1,20 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
base: '/admin/',
server: {
port: 5174,
proxy: {
'/api': {
target: 'http://localhost:3000',
changeOrigin: true,
},
},
},
build: {
outDir: 'dist',
emptyOutDir: true,
},
})