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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,17 +1,7 @@
|
||||
import { useCallback, useEffect, useRef, useState, type CSSProperties } from 'react'
|
||||
import { Button, Card } from '@heroui/react'
|
||||
import { hubLogs, hubLogStream } from '../../api/client'
|
||||
|
||||
const MAX_LINES = 2000
|
||||
|
||||
function fmtBytes(n: number): string {
|
||||
if (!Number.isFinite(n)) return '—'
|
||||
const units = ['B', 'KB', 'MB', 'GB']
|
||||
let v = n
|
||||
let i = 0
|
||||
while (v >= 1024 && i < units.length - 1) { v /= 1024; i++ }
|
||||
return v.toFixed(1) + ' ' + units[i]
|
||||
}
|
||||
import { useEffect, useRef, useState, type CSSProperties } from 'react'
|
||||
import { Button, Card, Spinner } from '@heroui/react'
|
||||
import { useLogs } from '../../logs'
|
||||
import { fmtBytes } from '../../lib/format'
|
||||
|
||||
// ANSI SGR 颜色码 → 内联样式,用于还原 loguru 彩色日志
|
||||
const ANSI_FG: Record<number, string> = {
|
||||
@@ -91,68 +81,10 @@ function AnsiLine({ text }: { text: string }) {
|
||||
}
|
||||
|
||||
export default function LogsPage() {
|
||||
const [lines, setLines] = useState<string[]>([])
|
||||
const [info, setInfo] = useState<{ file: string; size: number; total: number }>({ file: '', size: 0, total: 0 })
|
||||
const [error, setError] = useState('')
|
||||
const [streamStatus, setStreamStatus] = useState<'连接中' | '实时' | '重连中' | '已断开'>('连接中')
|
||||
// 数据与日志流由全局 LogProvider 常驻管理:离开/回来不重建连接、不转圈
|
||||
const { lines, info, error, loading, hasMore, loadingEarlier, paused, setPaused, loadEarlier } = useLogs()
|
||||
const [follow, setFollow] = useState(true)
|
||||
const [paused, setPaused] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
const sinceRef = useRef(0)
|
||||
const pausedRef = useRef(false)
|
||||
const pendingRef = useRef<string[]>([])
|
||||
const disposeRef = useRef<(() => void) | null>(null)
|
||||
|
||||
const append = useCallback((line: string) => {
|
||||
setLines(prev => [...prev, line].slice(-MAX_LINES))
|
||||
}, [])
|
||||
|
||||
const connect = useCallback(() => {
|
||||
if (disposeRef.current) disposeRef.current()
|
||||
disposeRef.current = hubLogStream({
|
||||
since: sinceRef.current,
|
||||
onLine: (line) => {
|
||||
if (pausedRef.current) { pendingRef.current.push(line); return }
|
||||
append(line)
|
||||
},
|
||||
onStatus: (s) => setStreamStatus(s === 'open' ? '实时' : s === 'reconnecting' ? '重连中' : '已断开'),
|
||||
})
|
||||
}, [append])
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const d = await hubLogs(500)
|
||||
if (!d) throw new Error('读取日志失败')
|
||||
if (d.ok === false) throw new Error(d.msg || '读取日志失败')
|
||||
setLines(d.lines || [])
|
||||
setInfo({ file: d.file || '', size: d.size || 0, total: d.total || 0 })
|
||||
sinceRef.current = d.offset || 0
|
||||
connect()
|
||||
} catch (e: any) {
|
||||
setError(e.message || '读取日志失败')
|
||||
} finally { setLoading(false) }
|
||||
}, [connect])
|
||||
|
||||
useEffect(() => {
|
||||
pausedRef.current = paused
|
||||
}, [paused])
|
||||
|
||||
useEffect(() => {
|
||||
// 恢复接收时,把暂停期间暂存的行补进来
|
||||
if (!paused && pendingRef.current.length) {
|
||||
const flush = pendingRef.current
|
||||
pendingRef.current = []
|
||||
setLines(prev => [...prev, ...flush].slice(-MAX_LINES))
|
||||
}
|
||||
}, [paused])
|
||||
|
||||
useEffect(() => {
|
||||
refresh()
|
||||
return () => { if (disposeRef.current) disposeRef.current() }
|
||||
}, [refresh])
|
||||
|
||||
useEffect(() => {
|
||||
if (follow && scrollRef.current) {
|
||||
@@ -160,17 +92,18 @@ export default function LogsPage() {
|
||||
}
|
||||
}, [lines, follow])
|
||||
|
||||
const statusClass = streamStatus === '实时' ? 'text-emerald-600' : streamStatus === '重连中' ? 'text-amber-600' : 'text-slate-500'
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-xl font-semibold text-slate-800">查看日志</h2>
|
||||
<p className="text-sm text-slate-400">实时读取 bot 运行日志(SSE 推送)</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button size="sm" variant="tertiary" onPress={() => setPaused(v => !v)}>{paused ? '恢复接收' : '暂停接收'}</Button>
|
||||
<div className="flex shrink-0 flex-wrap items-center gap-2 md:justify-end">
|
||||
{hasMore && (
|
||||
<Button size="sm" variant="tertiary" isDisabled={loadingEarlier} onPress={loadEarlier}>{loadingEarlier ? '加载中…' : '加载更早'}</Button>
|
||||
)}
|
||||
<Button size="sm" variant="tertiary" onPress={() => setPaused(!paused)}>{paused ? '恢复接收' : '暂停接收'}</Button>
|
||||
<Button size="sm" variant="tertiary" onPress={() => setFollow(v => !v)}>{follow ? '停止跟随' : '跟随最新'}</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -181,15 +114,14 @@ export default function LogsPage() {
|
||||
<Card.Header>
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<Card.Title>日志流</Card.Title>
|
||||
<div className="flex items-center gap-3 text-xs text-slate-400">
|
||||
<span className={statusClass}>● {streamStatus}</span>
|
||||
{info.file && <span>{info.file} · {fmtBytes(info.size)} · 共 {info.total} 行</span>}
|
||||
</div>
|
||||
{info.file && <span className="text-xs text-slate-400">{info.file} · {fmtBytes(info.size)} · 共 {info.total} 行</span>}
|
||||
</div>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<div ref={scrollRef} className="h-[calc(100vh-320px)] min-h-[320px] overflow-auto rounded-lg bg-slate-900 p-3 font-mono text-xs leading-relaxed text-slate-100">
|
||||
{lines.length ? lines.map((ln, i) => (
|
||||
<div ref={scrollRef} className="h-[60vh] min-h-[320px] overflow-auto rounded-lg bg-slate-900 p-3 font-mono text-xs leading-relaxed text-slate-100 md:h-[calc(100vh-320px)]">
|
||||
{loading && !lines.length ? (
|
||||
<div className="flex h-full items-center justify-center"><Spinner /></div>
|
||||
) : lines.length ? lines.map((ln, i) => (
|
||||
<div key={i} className="whitespace-pre-wrap break-all"><AnsiLine text={ln} /></div>
|
||||
)) : (
|
||||
<div className="text-slate-500">暂无日志…</div>
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Checkbox,
|
||||
Drawer,
|
||||
Dropdown,
|
||||
Input,
|
||||
@@ -17,13 +15,15 @@ import {
|
||||
toast,
|
||||
useOverlayState,
|
||||
} from '@heroui/react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Eye, EyeSlash, TrashBin } from '@gravity-ui/icons'
|
||||
import {
|
||||
hubPluginCatalog,
|
||||
hubPluginGetConfig,
|
||||
hubPluginRemoveGroup,
|
||||
hubPluginSaveConfig,
|
||||
hubPluginSetGlobal,
|
||||
hubPluginSetGroup,
|
||||
hubPluginRemoveGroup,
|
||||
hubGroups,
|
||||
} from '../../api/client'
|
||||
|
||||
@@ -50,6 +50,7 @@ interface ConfigField {
|
||||
description?: string
|
||||
options?: { value: string; label: string }[] | string[]
|
||||
secret?: boolean
|
||||
item_type?: string
|
||||
}
|
||||
interface ConfigData { schema: { fields: ConfigField[] }; values: Record<string, any>; revision: number }
|
||||
|
||||
@@ -81,33 +82,37 @@ function normOptions(opts: any): { value: string; label: string }[] {
|
||||
}
|
||||
|
||||
export default function PluginsPage() {
|
||||
const navigate = useNavigate()
|
||||
const [plugins, setPlugins] = useState<CatalogPlugin[]>([])
|
||||
const [groups, setGroups] = useState<GroupItem[]>([])
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const loadRef = useRef(false)
|
||||
// 加载序号:并发 load 时只落地最后一次结果,避免旧响应覆盖新状态
|
||||
const loadSeqRef = useRef(0)
|
||||
|
||||
const [configPlugin, setConfigPlugin] = useState<CatalogPlugin | null>(null)
|
||||
const [configData, setConfigData] = useState<ConfigData | null>(null)
|
||||
const [configLoading, setConfigLoading] = useState(false)
|
||||
const [configSaving, setConfigSaving] = useState(false)
|
||||
const [form, setForm] = useState<Record<string, any>>({})
|
||||
// 密码/密钥类字段的明文显隐状态(按字段 key 记录)
|
||||
const [revealed, setRevealed] = useState<Record<string, boolean>>({})
|
||||
const configState = useOverlayState()
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (loadRef.current) return
|
||||
loadRef.current = true
|
||||
setLoading(true)
|
||||
const load = useCallback(async (silent = false) => {
|
||||
const seq = ++loadSeqRef.current
|
||||
if (!silent) setLoading(true)
|
||||
try {
|
||||
const [cat, grp] = await Promise.all([hubPluginCatalog(), hubGroups()])
|
||||
if (seq !== loadSeqRef.current) return
|
||||
setPlugins((cat && cat.plugins) || [])
|
||||
setGroups((grp && grp.items) || [])
|
||||
setError('')
|
||||
} catch (e: any) {
|
||||
if (seq !== loadSeqRef.current) return
|
||||
setError(e.message || '加载失败')
|
||||
} finally {
|
||||
loadRef.current = false
|
||||
setLoading(false)
|
||||
if (seq === loadSeqRef.current) setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
@@ -117,6 +122,7 @@ export default function PluginsPage() {
|
||||
setConfigPlugin(p)
|
||||
setConfigData(null)
|
||||
setConfigLoading(true)
|
||||
setRevealed({})
|
||||
configState.open()
|
||||
try {
|
||||
const d = await hubPluginGetConfig(p.id)
|
||||
@@ -139,7 +145,7 @@ export default function PluginsPage() {
|
||||
await hubPluginSaveConfig(configPlugin.id, form, configData?.revision ?? 0)
|
||||
toast.success('配置已保存并热刷新')
|
||||
configState.close()
|
||||
await load()
|
||||
await load(true)
|
||||
} catch (e: any) {
|
||||
toast.danger(e.message || '保存失败')
|
||||
} finally {
|
||||
@@ -153,9 +159,9 @@ export default function PluginsPage() {
|
||||
const v = form[field.key]
|
||||
if (field.type === 'bool') {
|
||||
return (
|
||||
<Checkbox key={field.key} isSelected={!!v} onChange={(val) => setField(field.key, val)}>
|
||||
<Checkbox.Content><Checkbox.Control><Checkbox.Indicator /></Checkbox.Control>{field.label}</Checkbox.Content>
|
||||
</Checkbox>
|
||||
<Switch key={field.key} isSelected={!!v} onChange={(val) => setField(field.key, val)}>
|
||||
<Switch.Content><Switch.Control><Switch.Thumb /></Switch.Control>{field.label}</Switch.Content>
|
||||
</Switch>
|
||||
)
|
||||
}
|
||||
if (field.type === 'enum') {
|
||||
@@ -173,33 +179,101 @@ export default function PluginsPage() {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (field.type === 'array') {
|
||||
// 数组字段:表格式行编辑器(每行一个元素,可增删行),回传时按数组存储
|
||||
const arr = Array.isArray(v) ? v : []
|
||||
const isNum = field.item_type === 'int' || field.item_type === 'float'
|
||||
const setItem = (i: number, val: string) => {
|
||||
const next = [...arr]; next[i] = val; setField(field.key, next)
|
||||
}
|
||||
const removeRow = (i: number) => setField(field.key, arr.filter((_, x) => x !== i))
|
||||
const addRow = () => setField(field.key, [...arr, ''])
|
||||
return (
|
||||
<div key={field.key}>
|
||||
<Label>{field.label}</Label>
|
||||
<div className="space-y-1.5">
|
||||
{arr.map((item, i) => (
|
||||
<div key={i} className="flex items-center gap-1">
|
||||
<Input className="min-w-0 flex-1" type={isNum ? 'number' : 'text'} value={String(item ?? '')} onChange={e => setItem(i, e.target.value)} />
|
||||
<Button size="sm" variant="tertiary" isIconOnly className="shrink-0 text-slate-400 hover:text-red-500" aria-label="删除该行" onPress={() => removeRow(i)}>
|
||||
<TrashBin className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
{!arr.length && <p className="text-xs text-slate-400">暂无内容,点击下方添加</p>}
|
||||
<Button size="sm" variant="tertiary" className="w-full border border-dashed border-slate-200 text-slate-500 hover:border-slate-300 hover:text-slate-700" onPress={addRow}>+ 新增一行</Button>
|
||||
</div>
|
||||
{field.description && <p className="text-xs text-slate-400">{field.description}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (field.type === 'text' || field.type === 'json' || field.type === 'object') {
|
||||
const isJson = field.type === 'json'
|
||||
return (
|
||||
<TextField key={field.key}><Label>{field.label}</Label><TextArea rows={isJson ? 14 : 8} className={isJson ? 'min-h-[320px] font-mono text-xs' : 'min-h-[160px]'} value={v ?? ''} onChange={e => setField(field.key, e.target.value)} /></TextField>
|
||||
)
|
||||
}
|
||||
const inputType = field.type === 'password' ? 'password' : (field.type === 'int' || field.type === 'float' ? 'number' : 'text')
|
||||
const isSecret = field.type === 'password'
|
||||
// 后端回传明文值;前端仅以掩码(小圆点)形式默认展示,点「眼睛」可查看
|
||||
const inputType = isSecret
|
||||
? (revealed[field.key] ? 'text' : 'password')
|
||||
: (field.type === 'int' || field.type === 'float' ? 'number' : 'text')
|
||||
return (
|
||||
<TextField key={field.key}>
|
||||
<Label>{field.label}</Label>
|
||||
<Input type={inputType} value={v ?? ''} onChange={e => { const val = inputType === 'number' ? e.target.value : e.target.value; setField(field.key, val) }} />
|
||||
{isSecret ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<Input className="min-w-0 flex-1" type={inputType} value={v ?? ''} onChange={e => setField(field.key, e.target.value)} />
|
||||
<Button size="sm" variant="tertiary" isIconOnly aria-label={revealed[field.key] ? '隐藏内容' : '显示内容'} onPress={() => setRevealed(prev => ({ ...prev, [field.key]: !prev[field.key] }))}>
|
||||
{revealed[field.key] ? <EyeSlash className="size-4" /> : <Eye className="size-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Input type={inputType} value={v ?? ''} onChange={e => setField(field.key, e.target.value)} />
|
||||
)}
|
||||
{field.description && <p className="text-xs text-slate-400">{field.description}</p>}
|
||||
</TextField>
|
||||
)
|
||||
}
|
||||
|
||||
// 乐观更新:先翻转本地状态,失败回滚;成功后静默对齐服务端(不闪加载态)
|
||||
const patch = (id: string, fn: (p: CatalogPlugin) => CatalogPlugin) =>
|
||||
setPlugins(prev => prev.map(p => (p.id === id ? fn(p) : p)))
|
||||
|
||||
const toggleGlobal = async (p: CatalogPlugin, enabled: boolean) => {
|
||||
try { await hubPluginSetGlobal(p.id, { enabled }); await load() } catch (e: any) { toast.danger(e.message || '切换失败') }
|
||||
const prev = p.enabled
|
||||
patch(p.id, x => ({ ...x, enabled }))
|
||||
try { await hubPluginSetGlobal(p.id, { enabled }); await load(true) }
|
||||
catch (e: any) { patch(p.id, x => ({ ...x, enabled: prev })); toast.danger(e.message || '切换失败') }
|
||||
}
|
||||
const changeChat = async (p: CatalogPlugin, v: string) => {
|
||||
try { await hubPluginSetGlobal(p.id, { chat: chatFromValue(v) }); await load() } catch (e: any) { toast.danger(e.message || '修改失败') }
|
||||
const prevChat = p.chat
|
||||
patch(p.id, x => ({ ...x, chat: chatFromValue(v) }))
|
||||
try { await hubPluginSetGlobal(p.id, { chat: chatFromValue(v) }); await load(true) }
|
||||
catch (e: any) { patch(p.id, x => ({ ...x, chat: prevChat })); toast.danger(e.message || '修改失败') }
|
||||
}
|
||||
const toggleGroup = async (p: CatalogPlugin, gid: number, enabled: boolean) => {
|
||||
try { await hubPluginSetGroup(p.id, String(gid), { enabled }); await load() } catch (e: any) { toast.danger(e.message || '分群切换失败') }
|
||||
const prevOv = p.groups[String(gid)]
|
||||
patch(p.id, x => ({ ...x, groups: { ...x.groups, [String(gid)]: { enabled, chat: prevOv?.chat ?? [] } } }))
|
||||
try { await hubPluginSetGroup(p.id, String(gid), { enabled }); await load(true) }
|
||||
catch (e: any) {
|
||||
patch(p.id, x => prevOv ? { ...x, groups: { ...x.groups, [String(gid)]: prevOv } }
|
||||
: { ...x, groups: (() => { const g = { ...x.groups }; delete g[String(gid)]; return g })() })
|
||||
toast.danger(e.message || '分群切换失败')
|
||||
}
|
||||
}
|
||||
const resetGroup = async (p: CatalogPlugin, gid: number) => {
|
||||
try { await hubPluginRemoveGroup(p.id, String(gid)); await load() } catch (e: any) { toast.danger(e.message || '重置失败') }
|
||||
const resetAllGroups = async (p: CatalogPlugin) => {
|
||||
const overridden = Object.keys(p.groups)
|
||||
if (!overridden.length) return
|
||||
patch(p.id, x => ({ ...x, groups: {} }))
|
||||
try {
|
||||
await Promise.all(overridden.map(gid => hubPluginRemoveGroup(p.id, gid)))
|
||||
await load(true)
|
||||
} catch (e: any) {
|
||||
// 部分请求可能已成功:直接拉取服务端真实状态对齐,不做盲目回滚
|
||||
toast.danger((e.message || '恢复默认失败') + '(部分群可能未还原)')
|
||||
await load(true).catch(() => {})
|
||||
}
|
||||
}
|
||||
const effectiveGroupEnabled = (p: CatalogPlugin, gid: number) => {
|
||||
const ov = p.groups[String(gid)]
|
||||
@@ -208,22 +282,23 @@ export default function PluginsPage() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-xl font-semibold text-slate-800">插件管理</h2>
|
||||
<p className="text-sm text-slate-400">在所有插件之上统一控制:全局开关、群聊/私聊使用、分群控制、插件配置</p>
|
||||
</div>
|
||||
<Button size="sm" isDisabled={loading} onPress={() => load()}>{loading ? '加载中…' : '刷新'}</Button>
|
||||
<Button size="sm" className="shrink-0" isDisabled={loading} onPress={() => load()}>{loading ? '加载中…' : '刷新'}</Button>
|
||||
</div>
|
||||
|
||||
{error && <Card className="border border-red-200 bg-red-50"><Card.Content><p className="text-sm text-red-600">{error}</p></Card.Content></Card>}
|
||||
{loading && !plugins.length && <div className="flex justify-center py-10"><Spinner /></div>}
|
||||
{!loading && !plugins.length && !error && <p className="text-sm text-slate-400">未发现可管理的插件</p>}
|
||||
|
||||
<div className="space-y-3">
|
||||
{plugins.map(p => {
|
||||
const on = p.enabled
|
||||
return (
|
||||
<Card key={p.id} className="border border-slate-200 bg-white">
|
||||
<Card key={p.id} className="border border-slate-200 bg-white transition-colors hover:border-slate-300">
|
||||
<Card.Content>
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-center">
|
||||
<div className="min-w-0 flex-1">
|
||||
@@ -239,7 +314,7 @@ export default function PluginsPage() {
|
||||
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
{p.has_config && <Button size="sm" onPress={() => openConfig(p)}>配置</Button>}
|
||||
{p.has_web && p.web_path && <Link to={p.web_path}><Button size="sm">打开 Web 页面</Button></Link>}
|
||||
{p.has_web && p.web_path && <Button size="sm" onPress={() => navigate(p.web_path!)}>打开 Web 页面</Button>}
|
||||
|
||||
<Select selectedKey={chatValue(p.chat)} onSelectionChange={(k) => changeChat(p, String(k))} aria-label="聊天类型">
|
||||
<Select.Trigger className={chatSelectCls}><Select.Value /><Select.Indicator /></Select.Trigger>
|
||||
@@ -253,17 +328,18 @@ export default function PluginsPage() {
|
||||
</Dropdown.Trigger>
|
||||
<Dropdown.Popover className="p-2" placement="bottom end">
|
||||
<div className="w-64 max-h-72 overflow-y-auto space-y-1">
|
||||
<div className="px-1 py-1 text-xs font-medium text-slate-500">分群控制(覆盖全局)</div>
|
||||
<div className="flex items-center justify-between px-1 py-1">
|
||||
<span className="text-xs font-medium text-slate-500">分群控制(覆盖全局)</span>
|
||||
<Button size="sm" variant="tertiary" className="text-xs text-slate-500" isDisabled={!Object.keys(p.groups).length} onPress={() => resetAllGroups(p)}>恢复全部</Button>
|
||||
</div>
|
||||
{!groups.length && <div className="px-1 py-2 text-xs text-slate-400">未获取到群列表</div>}
|
||||
{groups.map(g => {
|
||||
const eff = effectiveGroupEnabled(p, g.group_id)
|
||||
const hasOv = !!p.groups[String(g.group_id)]
|
||||
return (
|
||||
<div key={g.group_id} className="flex items-center justify-between gap-2 rounded-lg px-1.5 py-1 hover:bg-slate-50">
|
||||
<div key={g.group_id} className="flex items-center gap-2 rounded-lg px-1.5 py-1 hover:bg-slate-50">
|
||||
<Switch size="sm" isSelected={eff} onChange={(v) => toggleGroup(p, g.group_id, v)}>
|
||||
<Switch.Content><Switch.Control><Switch.Thumb /></Switch.Control><span className="text-xs text-slate-700">{g.group_name || g.group_id}</span></Switch.Content>
|
||||
</Switch>
|
||||
{hasOv && <Button size="sm" variant="tertiary" onPress={() => resetGroup(p, g.group_id)}>重置</Button>}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
@@ -285,7 +361,7 @@ export default function PluginsPage() {
|
||||
<Drawer state={configState}>
|
||||
<Drawer.Backdrop>
|
||||
<Drawer.Content placement="right">
|
||||
<Drawer.Dialog className="w-[440px] max-w-[88vw]">
|
||||
<Drawer.Dialog className="w-[600px] max-w-[88vw]">
|
||||
<Drawer.Header>
|
||||
<Drawer.Heading>{configPlugin?.name || ''} · 配置</Drawer.Heading>
|
||||
<Drawer.CloseTrigger />
|
||||
|
||||
@@ -8,8 +8,6 @@ import {
|
||||
|
||||
interface SettingsData {
|
||||
ok: boolean
|
||||
user: { id: number; username: string; created_at: string }
|
||||
plugins: { id: string; name: string; icon: string }[]
|
||||
version: string
|
||||
}
|
||||
interface BotConfigData {
|
||||
@@ -40,6 +38,7 @@ export default function SettingsPage() {
|
||||
const [restarting, setRestarting] = useState<null | 'stop' | 'restart'>(null)
|
||||
const botState = useOverlayState()
|
||||
const pollRef = useRef(0)
|
||||
const pollTimerRef = useRef<number | null>(null)
|
||||
const restartDisposeRef = useRef<(() => void) | null>(null)
|
||||
|
||||
const [botCfg, setBotCfg] = useState<BotConfigData | null>(null)
|
||||
@@ -76,6 +75,7 @@ export default function SettingsPage() {
|
||||
|
||||
useEffect(() => () => {
|
||||
if (restartDisposeRef.current) restartDisposeRef.current()
|
||||
if (pollTimerRef.current != null) window.clearTimeout(pollTimerRef.current)
|
||||
}, [])
|
||||
|
||||
const changePassword = async () => {
|
||||
@@ -119,6 +119,13 @@ export default function SettingsPage() {
|
||||
botState.open()
|
||||
}
|
||||
|
||||
const clearPollTimer = () => {
|
||||
if (pollTimerRef.current != null) {
|
||||
window.clearTimeout(pollTimerRef.current)
|
||||
pollTimerRef.current = null
|
||||
}
|
||||
}
|
||||
|
||||
const pollForReconnect = async () => {
|
||||
const id = ++pollRef.current
|
||||
// 先等旧进程完全退出(旧的 dashboard SSE 可能仍被旧进程服务,提前收到数据会误判就绪)
|
||||
@@ -132,6 +139,8 @@ export default function SettingsPage() {
|
||||
if (d && d.ok !== false) {
|
||||
restartDisposeRef.current = null
|
||||
dispose()
|
||||
clearPollTimer()
|
||||
pollRef.current += 1 // 让旧定时器即使未被清除也不会误判超时
|
||||
setRestarting(null)
|
||||
toast.success('bot 已重启并回连')
|
||||
load()
|
||||
@@ -139,7 +148,8 @@ export default function SettingsPage() {
|
||||
},
|
||||
})
|
||||
restartDisposeRef.current = dispose
|
||||
window.setTimeout(() => {
|
||||
pollTimerRef.current = window.setTimeout(() => {
|
||||
pollTimerRef.current = null
|
||||
if (pollRef.current === id) {
|
||||
restartDisposeRef.current = null
|
||||
dispose()
|
||||
@@ -169,7 +179,7 @@ export default function SettingsPage() {
|
||||
<div className="space-y-6">
|
||||
<h2 className="text-xl font-semibold text-slate-800">设置</h2>
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<Card className="border border-slate-200 bg-white lg:col-span-2">
|
||||
<Card className="border border-slate-200 bg-white lg:col-span-2">
|
||||
<Card.Header><Card.Title>修改密码</Card.Title></Card.Header>
|
||||
<Card.Content>
|
||||
<Form className="space-y-4">
|
||||
|
||||
Reference in New Issue
Block a user