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:
2026-09-22 14:23:46 +08:00
co-authored by Claude Code
parent 4badcfcf32
commit 814c1994b6
9 changed files with 1150 additions and 46 deletions
@@ -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>
</>
)
}