feat(mc-server-status): 服务器列表 Web 管理页 / 数据层抽离 / mcstatus 13 适配
- services/servers.py:校验 + 读写 + 增删改收敛成一份数据层(顶层不 import nonebot, 可脱离运行时单测),群命令与 Web API 都只经它改数据,校验不再各写一套 - web_hub.py + /hub/mc_server_status 页面:按群列出服务器,抽屉新增/编辑(改名)/ 测试连接,行内删除(删空一台连带删群);每条路由自带 require_admin - handlers/server.py 改为薄封装,只在需要命令特有提示时自己判断 - services/mc.py 的图标属性名兼容 mcstatus ≥13 的 icon(旧版叫 favicon, 读错会把整条查询打成"查询失败") - tests/test_mc_servers.py:数据层归一/改名/删群/坏文件兜底 + 图标解码两版属性名 Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,339 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import {
|
||||
Button, Card, Drawer, Input, Label, ListBox, Select, Spinner, TextField, toast, useOverlayState,
|
||||
} from '@heroui/react'
|
||||
import { Pencil, Plus, Signal, TrashBin } from '@gravity-ui/icons'
|
||||
import { api, hubGroups } from '../../api/client'
|
||||
import { PageHead, selectCls, tdCls, thCls } from '../../components/ui'
|
||||
|
||||
// 服务器数据的增删改(后端 hexi/plugins/nonebot_plugin_mc_server_status/web_hub.py,
|
||||
// 和群里的「添加服务器 / 删除服务器」命令改的是同一份数据)
|
||||
const mcs = api('mc_server_status')
|
||||
|
||||
function data(res: any) {
|
||||
if (res && typeof res === 'object' && typeof res.status === 'number') {
|
||||
if (res.status !== 0) throw new Error(res.msg || '请求失败')
|
||||
return res.data
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
interface ServerRow { name: string; address: string; type: string }
|
||||
interface GroupRow {
|
||||
group_id: string
|
||||
group_name: string
|
||||
online: boolean
|
||||
servers: ServerRow[]
|
||||
}
|
||||
interface Overview {
|
||||
groups: GroupRow[]
|
||||
types: Record<string, string>
|
||||
address_hint: string
|
||||
bot_online: boolean
|
||||
}
|
||||
interface GroupOption { group_id: number; group_name: string }
|
||||
|
||||
interface Draft {
|
||||
group_id: string
|
||||
/** 原服务器名:编辑时为改名前的名字,新增时为空 */
|
||||
old_name: string
|
||||
name: string
|
||||
address: string
|
||||
type: string
|
||||
}
|
||||
|
||||
const TYPE_LABEL: Record<string, string> = { js: 'Java 版', bds: '基岩版' }
|
||||
// 跟后端 services/servers.py 的 SERVER_TYPES 对齐;接口没回来时用它兜底
|
||||
const FALLBACK_TYPES = ['js', 'bds']
|
||||
const EMPTY = '-'
|
||||
|
||||
const typeLabel = (t: string) => TYPE_LABEL[t] || t
|
||||
|
||||
export default function McServerStatusPage() {
|
||||
const [ov, setOv] = useState<Overview | null>(null)
|
||||
const [err, setErr] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [testing, setTesting] = useState(false)
|
||||
const [testResult, setTestResult] = useState('')
|
||||
const [draft, setDraft] = useState<Draft | null>(null)
|
||||
const [botGroups, setBotGroups] = useState<GroupOption[]>([])
|
||||
const drawer = useOverlayState()
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true); setErr('')
|
||||
try {
|
||||
setOv(await data(await mcs.get('overview')))
|
||||
} catch (e: any) {
|
||||
setErr(e.message || '加载失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
useEffect(() => { load() }, [load])
|
||||
// 群列表只用来给「群号」下拉填名字;bot 没连上时退回手输群号
|
||||
useEffect(() => { hubGroups().then(d => setBotGroups((d && d.items) || [])).catch(() => setBotGroups([])) }, [])
|
||||
|
||||
const types = ov?.types && Object.keys(ov.types).length ? Object.keys(ov.types) : FALLBACK_TYPES
|
||||
|
||||
const openNew = (group_id = '') => {
|
||||
setTestResult('')
|
||||
setDraft({ group_id, old_name: '', name: '', address: '', type: types[0] || 'js' })
|
||||
drawer.open()
|
||||
}
|
||||
const openEdit = (group_id: string, server: ServerRow) => {
|
||||
setTestResult('')
|
||||
setDraft({ group_id, old_name: server.name, name: server.name, address: server.address, type: server.type })
|
||||
drawer.open()
|
||||
}
|
||||
|
||||
const isNew = !draft?.old_name
|
||||
const renamed = !!draft && !!draft.old_name && draft.old_name !== draft.name
|
||||
|
||||
const save = async () => {
|
||||
if (!draft) return
|
||||
if (!/^\d+$/.test(draft.group_id)) { toast.danger('群号必须是纯数字'); return }
|
||||
setSaving(true)
|
||||
try {
|
||||
const res = data(await mcs.post('server', {
|
||||
group_id: draft.group_id,
|
||||
old_name: draft.old_name,
|
||||
name: draft.name,
|
||||
address: draft.address,
|
||||
type: draft.type,
|
||||
}))
|
||||
toast.success(res?.msg || '已保存')
|
||||
drawer.close()
|
||||
await load()
|
||||
} catch (e: any) { toast.danger(e.message || '保存失败') } finally { setSaving(false) }
|
||||
}
|
||||
|
||||
/** 测试连接:编辑时测存量那台(后端按群号+名字取地址),新增时测当前填的地址,都不落库 */
|
||||
const testConnection = async () => {
|
||||
if (!draft) return
|
||||
setTesting(true); setTestResult('')
|
||||
try {
|
||||
const d = data(await mcs.post('test', {
|
||||
group_id: draft.group_id, name: draft.old_name, address: draft.address, type: draft.type,
|
||||
}))
|
||||
const msg = (d && d.message) || ''
|
||||
setTestResult(msg)
|
||||
if (d && d.ok) {
|
||||
const version = msg.match(/版本:(\S+)/)
|
||||
toast.success(version ? `连上了 · 版本 ${version[1]}` : '连上了')
|
||||
} else {
|
||||
toast.danger(msg.split('\n')[0] || '连不上')
|
||||
}
|
||||
} catch (e: any) {
|
||||
setTestResult(e.message || '测试失败')
|
||||
toast.danger(e.message || '测试失败')
|
||||
} finally { setTesting(false) }
|
||||
}
|
||||
|
||||
const removeServer = async (group: GroupRow, server: ServerRow) => {
|
||||
const last = group.servers.length === 1
|
||||
const tip = last
|
||||
? `确定删除「${server.name}」?这是 ${group.group_name || group.group_id} 的最后一台,删完这个群也会一并移除。`
|
||||
: `确定删除「${server.name}」(${server.address})?`
|
||||
if (!window.confirm(tip)) return
|
||||
try {
|
||||
const res = data(await mcs.del(`server/${encodeURIComponent(group.group_id)}/${encodeURIComponent(server.name)}`))
|
||||
toast.success(res?.msg || '已删除')
|
||||
await load()
|
||||
} catch (e: any) { toast.danger(e.message || '删除失败') }
|
||||
}
|
||||
|
||||
const removeGroup = async (group: GroupRow) => {
|
||||
if (!window.confirm(`确定删除 ${group.group_name || group.group_id} 的全部 ${group.servers.length} 台服务器配置?`)) return
|
||||
try {
|
||||
const res = data(await mcs.del(`group/${encodeURIComponent(group.group_id)}`))
|
||||
toast.success(res?.msg || '已删除')
|
||||
await load()
|
||||
} catch (e: any) { toast.danger(e.message || '删除失败') }
|
||||
}
|
||||
|
||||
if (loading && !ov) return <div className="flex justify-center py-20"><Spinner /></div>
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-4">
|
||||
<PageHead
|
||||
title="MC服务器"
|
||||
actions={
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm text-slate-400">按群配置服务器,「信息」命令查状态</span>
|
||||
<Button size="sm" onPress={() => openNew()}>
|
||||
<Plus className="size-4" />新增服务器
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
{err && <p className="text-xs text-red-500">{err}</p>}
|
||||
{ov && !ov.bot_online && (
|
||||
<p className="text-xs text-amber-600">bot 未连接,群名显示不出来(只显示群号,不影响编辑)</p>
|
||||
)}
|
||||
|
||||
{ov && (
|
||||
<Card className="border border-slate-200 bg-white">
|
||||
<Card.Header>
|
||||
<Card.Title>
|
||||
服务器列表
|
||||
<span className="ml-2 text-xs font-normal text-slate-400">
|
||||
{ov.groups.length} 个群 · {ov.groups.reduce((n, g) => n + g.servers.length, 0)} 台服务器
|
||||
</span>
|
||||
</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className={thCls}>群</th>
|
||||
<th className={thCls}>服务器名称</th>
|
||||
<th className={thCls}>地址</th>
|
||||
<th className={thCls}>类型</th>
|
||||
<th className={thCls}></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{ov.groups.map(g => g.servers.map((s, i) => (
|
||||
<tr key={`${g.group_id}-${s.name}`} className="transition-colors hover:bg-slate-50">
|
||||
{/* 群号 / 群名只在每组第一行显示,靠 rowSpan 合并不重复刷屏 */}
|
||||
{i === 0 && (
|
||||
<td className={tdCls + ' align-top whitespace-nowrap'} rowSpan={g.servers.length}>
|
||||
<span className="text-slate-700">
|
||||
{g.group_name ? `${g.group_name}(${g.group_id})` : g.group_id}
|
||||
</span>
|
||||
{!g.online && <span className="ml-2 text-xs text-slate-400">不在群列表</span>}
|
||||
<div className="mt-1 flex gap-2">
|
||||
<button className="text-xs text-indigo-600 hover:underline" onClick={() => openNew(g.group_id)}>加服务器</button>
|
||||
<button className="text-xs text-red-500 hover:underline" onClick={() => removeGroup(g)}>删整组</button>
|
||||
</div>
|
||||
</td>
|
||||
)}
|
||||
<td className={tdCls}>{s.name}</td>
|
||||
<td className={tdCls + ' font-mono text-xs'}>{s.address || EMPTY}</td>
|
||||
<td className={tdCls + ' whitespace-nowrap'}>{typeLabel(s.type)}</td>
|
||||
<td className={tdCls + ' whitespace-nowrap text-right'}>
|
||||
<Button size="sm" variant="tertiary" onPress={() => openEdit(g.group_id, s)}>
|
||||
<Pencil className="size-4" />编辑
|
||||
</Button>
|
||||
<Button size="sm" variant="tertiary" className="text-red-500" onPress={() => removeServer(g, s)}>
|
||||
<TrashBin className="size-4" />删除
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
)))}
|
||||
{!ov.groups.length && (
|
||||
<tr>
|
||||
<td colSpan={5} className={tdCls + ' text-center text-slate-400'}>
|
||||
还没有配置任何服务器,点右上角「新增服务器」(群里发「添加服务器」也一样)
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</Card.Content>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Drawer state={drawer}>
|
||||
<Drawer.Backdrop>
|
||||
<Drawer.Content placement="right">
|
||||
<Drawer.Dialog className="w-[560px] max-w-[88vw]">
|
||||
<Drawer.Header>
|
||||
<Drawer.Heading>
|
||||
{isNew ? '新增服务器' : `编辑「${draft?.old_name}」`}
|
||||
</Drawer.Heading>
|
||||
<Drawer.CloseTrigger />
|
||||
</Drawer.Header>
|
||||
<Drawer.Body>
|
||||
{draft && (
|
||||
<div className="space-y-5">
|
||||
<TextField>
|
||||
<Label>群号</Label>
|
||||
{isNew && botGroups.length > 0 ? (
|
||||
<Select
|
||||
selectedKey={draft.group_id}
|
||||
onSelectionChange={k => k && setDraft({ ...draft, group_id: String(k) })}
|
||||
>
|
||||
<Select.Trigger className={selectCls}><Select.Value /></Select.Trigger>
|
||||
<Select.Popover>
|
||||
<ListBox>
|
||||
{botGroups.map(g => (
|
||||
<ListBox.Item key={g.group_id} id={String(g.group_id)}>
|
||||
{`${g.group_name}(${g.group_id})`}
|
||||
</ListBox.Item>
|
||||
))}
|
||||
</ListBox>
|
||||
</Select.Popover>
|
||||
</Select>
|
||||
) : (
|
||||
<Input
|
||||
value={draft.group_id}
|
||||
onChange={e => setDraft({ ...draft, group_id: e.target.value.trim() })}
|
||||
disabled={!isNew}
|
||||
placeholder="872490448"
|
||||
/>
|
||||
)}
|
||||
</TextField>
|
||||
|
||||
<TextField>
|
||||
<Label>服务器名称 <span className="text-xs text-slate-400">群里显示的名字,改这里就是改名</span></Label>
|
||||
<Input
|
||||
value={draft.name}
|
||||
onChange={e => setDraft({ ...draft, name: e.target.value })}
|
||||
placeholder="EMDS"
|
||||
/>
|
||||
</TextField>
|
||||
|
||||
<TextField>
|
||||
<Label>服务器地址 <span className="text-xs text-slate-400">主机:端口</span></Label>
|
||||
<Input
|
||||
value={draft.address}
|
||||
onChange={e => setDraft({ ...draft, address: e.target.value })}
|
||||
placeholder="mc.example.com:25565"
|
||||
/>
|
||||
</TextField>
|
||||
<p className="-mt-3 text-xs text-slate-400">{ov?.address_hint}</p>
|
||||
|
||||
<Select selectedKey={draft.type} onSelectionChange={k => k && setDraft({ ...draft, type: String(k) })}>
|
||||
<Label>类型</Label>
|
||||
<Select.Trigger className={selectCls}><Select.Value /></Select.Trigger>
|
||||
<Select.Popover>
|
||||
<ListBox>
|
||||
{types.map(t => <ListBox.Item key={t} id={t}>{typeLabel(t)}</ListBox.Item>)}
|
||||
</ListBox>
|
||||
</Select.Popover>
|
||||
</Select>
|
||||
<p className="-mt-3 text-xs text-slate-400">基岩版不写端口时按默认 19132 查</p>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button size="sm" variant="tertiary" isDisabled={testing} onPress={testConnection}>
|
||||
<Signal className="size-4" />{testing ? '测试中…' : '测试连接'}
|
||||
</Button>
|
||||
<span className="text-xs text-slate-400">
|
||||
{isNew ? '测当前填的地址,不落库' : '测已保存的地址(改了地址先保存再测)'}
|
||||
</span>
|
||||
</div>
|
||||
{testResult && (
|
||||
<pre className="max-h-44 overflow-auto whitespace-pre-wrap rounded-lg bg-slate-50 p-3 text-xs text-slate-600">
|
||||
{testResult}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Drawer.Body>
|
||||
<Drawer.Footer>
|
||||
<Button variant="tertiary" onPress={drawer.close}>取消</Button>
|
||||
<Button isDisabled={saving} onPress={save}>
|
||||
{saving ? '保存中…' : renamed ? '保存(改名)' : '保存'}
|
||||
</Button>
|
||||
</Drawer.Footer>
|
||||
</Drawer.Dialog>
|
||||
</Drawer.Content>
|
||||
</Drawer.Backdrop>
|
||||
</Drawer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user