feat: revamp web hub — global connection status, array config editor, log streaming
- Global connection badge in app header (persistent dashboard SSE via ConnectionProvider) - LogProvider keeps log data and SSE stream alive across page switches (no reload spinner) - Config drawer: password reveal toggle, array-type row editor for list fields - Plugin management: optimistic updates, race-free loading, reset all group overrides - Logs: load-earlier pagination; backend tail-read optimization (before/end offsets) - web_auth: Web password changes persist (sync_admin seeding only when .env has explicit key) - Dashboard: collect in thread pool + 1s result cache - ErrorBoundary + local Suspense: plugin chunk loading stays inside content area - UI polish: table row hover, mobile header/breadcrumb fixes, wider config drawer - config_standard: array type inference for list fields; array env skip - random_jm_code / steam_info: array schema migration; README web frontend note Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Avatar, Card } from '@heroui/react'
|
||||
import { hubDashboard, hubDashboardStream } from '../../api/client'
|
||||
import { Avatar, Card, Spinner } from '@heroui/react'
|
||||
import { useConnection } from '../../connection'
|
||||
import { fmtBytes, fmtRate, fmtUptime } from '../../lib/format'
|
||||
|
||||
interface BotInfo {
|
||||
self_id: string
|
||||
@@ -87,32 +87,6 @@ interface DashboardData {
|
||||
backend: BackendInfo
|
||||
}
|
||||
|
||||
function fmtBytes(n: number): string {
|
||||
if (!Number.isFinite(n)) return '—'
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
let v = n
|
||||
let i = 0
|
||||
while (v >= 1024 && i < units.length - 1) { v /= 1024; i++ }
|
||||
return v.toFixed(1) + ' ' + units[i]
|
||||
}
|
||||
|
||||
function fmtRate(n: number): string {
|
||||
if (!Number.isFinite(n) || n < 0) return '0 B/s'
|
||||
return fmtBytes(n) + '/s'
|
||||
}
|
||||
|
||||
function fmtUptime(sec: number): string {
|
||||
if (!Number.isFinite(sec)) return '—'
|
||||
const d = Math.floor(sec / 86400)
|
||||
const h = Math.floor((sec % 86400) / 3600)
|
||||
const m = Math.floor((sec % 3600) / 60)
|
||||
const parts: string[] = []
|
||||
if (d) parts.push(d + ' 天')
|
||||
if (h) parts.push(h + ' 小时')
|
||||
parts.push(m + ' 分')
|
||||
return parts.join(' ')
|
||||
}
|
||||
|
||||
function Bar({ value, color }: { value: number; color?: string }) {
|
||||
const v = Math.max(0, Math.min(100, value))
|
||||
const c = color || (v >= 80 ? 'bg-rose-500' : v >= 60 ? 'bg-amber-500' : 'bg-indigo-500')
|
||||
@@ -149,37 +123,9 @@ function SectionTitle({ title, desc }: { title: string; desc: string }) {
|
||||
}
|
||||
|
||||
export default function HomePage() {
|
||||
const [data, setData] = useState<DashboardData | null>(null)
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const refreshingRef = useRef(false)
|
||||
|
||||
const refresh = useCallback(async (silent = false) => {
|
||||
if (refreshingRef.current) return
|
||||
refreshingRef.current = true
|
||||
if (!silent) setLoading(true)
|
||||
try {
|
||||
const d = await hubDashboard()
|
||||
if (!d || d.ok === false) throw new Error((d && (d as any).msg) || '采集失败')
|
||||
setData(d)
|
||||
setError('')
|
||||
} catch (e: any) {
|
||||
setError(e.message || '加载失败')
|
||||
} finally {
|
||||
refreshingRef.current = false
|
||||
if (!silent) setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
refresh(true) // 初始静默加载,快速出首屏
|
||||
const dispose = hubDashboardStream({
|
||||
onData: (d) => {
|
||||
if (d && d.ok !== false) { setData(d); setError('') }
|
||||
},
|
||||
})
|
||||
return () => { dispose() }
|
||||
}, [refresh])
|
||||
// 数据与连接状态来自全局 ConnectionProvider(顶栏已显示状态徽章)
|
||||
const { data: rawData, error } = useConnection()
|
||||
const data = rawData as DashboardData | null
|
||||
|
||||
const cpu = data?.cpu
|
||||
const mem = data?.memory
|
||||
@@ -190,15 +136,82 @@ export default function HomePage() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-slate-800">总览</h2>
|
||||
<p className="text-sm text-slate-400">主机与 BOT 综合状态(每 2 秒刷新)</p>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-slate-800">总览</h2>
|
||||
<p className="text-sm text-slate-400">主机与 BOT 综合状态(实时刷新)</p>
|
||||
</div>
|
||||
|
||||
{error && !data && <Card className="border border-red-200 bg-red-50"><Card.Content><p className="text-sm text-red-600">{error}</p></Card.Content></Card>}
|
||||
|
||||
{/* 顶部:BOT 协议端信息 + BOT 后端信息 一行两列 */}
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-2 items-stretch">
|
||||
{/* BOT 协议端信息:登录账号昵称 / 账号 / 适配器类型 */}
|
||||
<Card className="border border-slate-200 bg-white h-full">
|
||||
<Card.Header>
|
||||
<SectionTitle title="BOT 协议端信息" desc="当前登录的账户昵称 / 账号 / 适配器类型" />
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
{!data ? (
|
||||
<div className="flex justify-center py-8"><Spinner /></div>
|
||||
) : !data.bots.length ? (
|
||||
<p className="text-sm text-slate-400">暂无在线 QQ 账号</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{data.bots.map(bot => (
|
||||
<div key={bot.self_id} className="flex items-center gap-3 rounded-xl border border-slate-100 px-4 py-3">
|
||||
<Avatar size="sm">
|
||||
<Avatar.Image src={bot.avatar} alt={bot.nick} />
|
||||
<Avatar.Fallback>{(bot.nick || bot.self_id).charAt(0).toUpperCase()}</Avatar.Fallback>
|
||||
</Avatar>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium text-slate-800">{bot.nick}</div>
|
||||
<div className="truncate text-xs text-slate-400">{bot.self_id} · {bot.adapter}</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{bot.msg_recv != null && <span className="text-xs text-slate-400">收 {bot.msg_recv}</span>}
|
||||
{bot.msg_sent != null && <span className="text-xs text-slate-400">发 {bot.msg_sent}</span>}
|
||||
{bot.online != null && <span className="text-xs text-slate-400">{bot.online ? '在线' : '离线'}</span>}
|
||||
<span className="inline-flex items-center gap-1.5 text-xs">
|
||||
<span className={'size-2 rounded-full ' + (bot.ws_connected ? 'bg-emerald-500' : 'bg-slate-300')} />
|
||||
<span className={bot.ws_connected ? 'text-emerald-600' : 'text-slate-400'}>{bot.ws_connected ? '已连接' : '未连接'}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card.Content>
|
||||
</Card>
|
||||
{/* BOT 后端信息:NoneBot 版本 / 驱动方式 / 装载插件数 */}
|
||||
<Card className="border border-slate-200 bg-white h-full">
|
||||
<Card.Header>
|
||||
<SectionTitle title="BOT 后端信息" desc="NoneBot 版本 / 驱动方式 / 装载插件数" />
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="rounded-xl border border-slate-200 p-4">
|
||||
<div className="text-xs text-slate-400">NoneBot 版本</div>
|
||||
<div className="mt-1 text-lg font-semibold text-slate-800">{backend?.nonebot_version || '—'}</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-slate-200 p-4">
|
||||
<div className="text-xs text-slate-400">驱动方式</div>
|
||||
<div className="mt-1 truncate text-sm font-medium text-slate-700" title={backend?.driver}>{backend?.driver || '—'}</div>
|
||||
<div className="text-xs text-slate-400">{backend?.driver_type || ''}</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-slate-200 p-4">
|
||||
<div className="text-xs text-slate-400">装载插件数</div>
|
||||
<div className="mt-1 text-lg font-semibold text-slate-800">{backend?.plugin_count ?? '—'} 个</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-slate-200 p-4">
|
||||
<div className="text-xs text-slate-400">Python</div>
|
||||
<div className="mt-1 truncate text-sm font-medium text-slate-700" title={backend?.python}>{backend?.python || '—'}</div>
|
||||
<div className="text-xs text-slate-400">bot 运行 {sys ? fmtUptime(sys.bot_uptime_seconds) : '—'}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card.Content>
|
||||
</Card>
|
||||
|
||||
</div>
|
||||
{/* 1. 主机规格信息:系统 / 处理器 / 内存 / 硬盘 */}
|
||||
<Card className="border border-slate-200 bg-white">
|
||||
<Card.Header>
|
||||
@@ -234,7 +247,7 @@ export default function HomePage() {
|
||||
<div className="mt-4">
|
||||
<div className="mb-2 text-sm font-medium text-slate-700">硬盘</div>
|
||||
{!data ? (
|
||||
<p className="text-sm text-slate-400">加载中…</p>
|
||||
<div className="flex justify-center py-8"><Spinner /></div>
|
||||
) : !data.disk.length ? (
|
||||
<p className="text-sm text-slate-400">未获取到磁盘信息</p>
|
||||
) : (
|
||||
@@ -323,72 +336,6 @@ export default function HomePage() {
|
||||
</Card.Content>
|
||||
</Card>
|
||||
|
||||
{/* 3. BOT 后端信息:NoneBot 版本 / 驱动方式 / 装载插件数 */}
|
||||
<Card className="border border-slate-200 bg-white">
|
||||
<Card.Header>
|
||||
<SectionTitle title="BOT 后端信息" desc="NoneBot 版本 / 驱动方式 / 装载插件数" />
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<div className="rounded-xl border border-slate-200 p-4">
|
||||
<div className="text-xs text-slate-400">NoneBot 版本</div>
|
||||
<div className="mt-1 text-lg font-semibold text-slate-800">{backend?.nonebot_version || '—'}</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-slate-200 p-4">
|
||||
<div className="text-xs text-slate-400">驱动方式</div>
|
||||
<div className="mt-1 truncate text-sm font-medium text-slate-700" title={backend?.driver}>{backend?.driver || '—'}</div>
|
||||
<div className="text-xs text-slate-400">{backend?.driver_type || ''}</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-slate-200 p-4">
|
||||
<div className="text-xs text-slate-400">装载插件数</div>
|
||||
<div className="mt-1 text-lg font-semibold text-slate-800">{backend?.plugin_count ?? '—'} 个</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-slate-200 p-4">
|
||||
<div className="text-xs text-slate-400">Python</div>
|
||||
<div className="mt-1 truncate text-sm font-medium text-slate-700" title={backend?.python}>{backend?.python || '—'}</div>
|
||||
<div className="text-xs text-slate-400">bot 运行 {sys ? fmtUptime(sys.bot_uptime_seconds) : '—'}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card.Content>
|
||||
</Card>
|
||||
|
||||
{/* 4. BOT 协议端信息:登录账号昵称 / 账号 / 适配器类型 */}
|
||||
<Card className="border border-slate-200 bg-white">
|
||||
<Card.Header>
|
||||
<SectionTitle title="BOT 协议端信息" desc="当前登录的账户昵称 / 账号 / 适配器类型" />
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
{!data ? (
|
||||
<p className="text-sm text-slate-400">加载中…</p>
|
||||
) : !data.bots.length ? (
|
||||
<p className="text-sm text-slate-400">暂无在线 QQ 账号</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{data.bots.map(bot => (
|
||||
<div key={bot.self_id} className="flex items-center gap-3 rounded-xl border border-slate-100 px-4 py-3">
|
||||
<Avatar size="sm">
|
||||
<Avatar.Image src={bot.avatar} alt={bot.nick} />
|
||||
<Avatar.Fallback>{(bot.nick || bot.self_id).charAt(0).toUpperCase()}</Avatar.Fallback>
|
||||
</Avatar>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium text-slate-800">{bot.nick}</div>
|
||||
<div className="truncate text-xs text-slate-400">{bot.self_id} · {bot.adapter}</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{bot.msg_recv != null && <span className="text-xs text-slate-400">收 {bot.msg_recv}</span>}
|
||||
{bot.msg_sent != null && <span className="text-xs text-slate-400">发 {bot.msg_sent}</span>}
|
||||
{bot.online != null && <span className="text-xs text-slate-400">{bot.online ? '在线' : '离线'}</span>}
|
||||
<span className="inline-flex items-center gap-1.5 text-xs">
|
||||
<span className={'size-2 rounded-full ' + (bot.ws_connected ? 'bg-emerald-500' : 'bg-slate-300')} />
|
||||
<span className={bot.ws_connected ? 'text-emerald-600' : 'text-slate-400'}>{bot.ws_connected ? '已连接' : '未连接'}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card.Content>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user