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

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