Add HeXi bot codebase: custom plugins, web frontends, tests
- hexi core: message handling, rate limiting, cooldown, plugin manager - Custom plugins: BF stats, daily check-in, quotes, persona cards, etc. - Community plugins vendored under hexi/plugins with local fixes - Web admin frontends (learning-chat, persona-admin), unified hexi/web - Tests for rate_limit/cooldown/memes/persona; poetry.lock Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,270 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Avatar, Button, Card } from '@heroui/react'
|
||||
import { hubDashboard } from '../../api/client'
|
||||
|
||||
interface BotInfo {
|
||||
self_id: string
|
||||
adapter: string
|
||||
nick: string
|
||||
avatar: string
|
||||
ws_connected: boolean
|
||||
online: boolean | null
|
||||
msg_recv: number | null
|
||||
msg_sent: number | null
|
||||
}
|
||||
interface SystemInfo {
|
||||
hostname: string
|
||||
os: string
|
||||
python: string
|
||||
nonebot: string
|
||||
boot_time: number
|
||||
system_uptime_seconds: number
|
||||
bot_uptime_seconds: number
|
||||
}
|
||||
interface CpuInfo {
|
||||
percent: number
|
||||
per_core: number[]
|
||||
logical: number | null
|
||||
physical: number | null
|
||||
brand: string
|
||||
load_avg: number[] | null
|
||||
}
|
||||
interface MemInfo {
|
||||
percent: number
|
||||
used: number
|
||||
total: number
|
||||
available: number
|
||||
process_rss: number
|
||||
process_percent: number
|
||||
}
|
||||
interface ProcessInfo {
|
||||
pid: number
|
||||
name: string
|
||||
rss: number
|
||||
cpu_percent: number
|
||||
memory_percent: number
|
||||
create_time: number
|
||||
}
|
||||
interface DiskInfo {
|
||||
mount: string
|
||||
device: string
|
||||
percent: number
|
||||
used: number
|
||||
total: number
|
||||
}
|
||||
interface DashboardData {
|
||||
ok: boolean
|
||||
bots: BotInfo[]
|
||||
system: SystemInfo
|
||||
cpu: CpuInfo
|
||||
memory: MemInfo
|
||||
process: ProcessInfo
|
||||
disk: DiskInfo[]
|
||||
}
|
||||
|
||||
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 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')
|
||||
return (
|
||||
<div className="h-2 rounded-full bg-slate-100 overflow-hidden">
|
||||
<div className={'h-full rounded-full transition-all ' + c} style={{ width: v + '%' }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function coreColor(v: number): string {
|
||||
if (v >= 80) return 'bg-rose-500'
|
||||
if (v >= 60) return 'bg-amber-500'
|
||||
if (v >= 30) return 'bg-indigo-500'
|
||||
return 'bg-emerald-500'
|
||||
}
|
||||
|
||||
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 timer = setInterval(() => {
|
||||
if (document.visibilityState !== 'hidden') refresh(true)
|
||||
}, 15000)
|
||||
const onVisible = () => {
|
||||
if (document.visibilityState === 'visible') refresh(true)
|
||||
}
|
||||
document.addEventListener('visibilitychange', onVisible)
|
||||
return () => {
|
||||
clearInterval(timer)
|
||||
document.removeEventListener('visibilitychange', onVisible)
|
||||
}
|
||||
}, [refresh])
|
||||
|
||||
const cpu = data?.cpu
|
||||
const mem = data?.memory
|
||||
const proc = data?.process
|
||||
const sys = data?.system
|
||||
|
||||
const hostSubtitle = [
|
||||
cpu?.brand,
|
||||
cpu?.logical && cpu.logical + ' 核',
|
||||
(data ? sys?.python : ''),
|
||||
data ? 'NoneBot ' + sys?.nonebot : '',
|
||||
].filter(Boolean).join(' · ')
|
||||
|
||||
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">主机与服务状态</p>
|
||||
</div>
|
||||
<Button size="sm" isDisabled={loading} onPress={() => refresh(false)}>{loading ? '刷新中…' : '刷新'}</Button>
|
||||
</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>}
|
||||
|
||||
{/* 主机资源 */}
|
||||
<Card className="border border-slate-200 bg-white">
|
||||
<Card.Header>
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div>
|
||||
<Card.Title>主机资源</Card.Title>
|
||||
<p className="text-xs text-slate-400">{hostSubtitle || '加载中…'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
{/* CPU */}
|
||||
<div className="rounded-xl border border-slate-200 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-slate-700">CPU 使用率{cpu?.logical ? ' ' + cpu.logical + ' 核' : ''}</span>
|
||||
<span className="text-sm font-semibold text-slate-800">{cpu ? cpu.percent + '%' : '—'}</span>
|
||||
</div>
|
||||
<div className="mt-3"><Bar value={cpu?.percent || 0} /></div>
|
||||
<p className="mt-2 text-xs text-slate-400">
|
||||
负载: {cpu?.load_avg ? cpu.load_avg.join(' / ') : '—'}
|
||||
</p>
|
||||
{cpu?.per_core?.length ? (
|
||||
<div className="mt-3 grid grid-cols-4 gap-2">
|
||||
{cpu.per_core.map((v, i) => (
|
||||
<div key={i} className="flex flex-col items-center rounded-lg border border-slate-100 bg-slate-50 px-2 py-1.5">
|
||||
<span className="text-xs text-slate-400">{i}</span>
|
||||
<div className={'mt-1 h-1.5 w-full rounded-full ' + coreColor(v)} />
|
||||
<span className="mt-1 text-[10px] text-slate-500">{v.toFixed(0)}%</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* 内存 */}
|
||||
<div className="rounded-xl border border-slate-200 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-slate-700">内存使用</span>
|
||||
<span className="text-sm font-semibold text-slate-800">{mem ? mem.percent + '%' : '—'}</span>
|
||||
</div>
|
||||
<div className="mt-3"><Bar value={mem?.percent || 0} /></div>
|
||||
<p className="mt-2 text-xs text-slate-400">
|
||||
本进程 {mem ? fmtBytes(mem.process_rss) : '—'} · 其他已用 {mem ? fmtBytes(Math.max(0, mem.used - mem.process_rss)) : '—'} · 空间 {mem ? fmtBytes(Math.max(0, mem.total - mem.used)) : '—'}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-slate-400">共 {mem ? fmtBytes(mem.total) : '—'}</p>
|
||||
</div>
|
||||
|
||||
{/* 进程 */}
|
||||
<div className="rounded-xl border border-slate-200 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-slate-700">运行进程</span>
|
||||
<span className="text-xs text-slate-500">PID {proc?.pid ?? '—'}</span>
|
||||
</div>
|
||||
<dl className="mt-3 space-y-2 text-xs">
|
||||
<div className="flex justify-between"><dt className="text-slate-400">RSS</dt><dd className="text-slate-700">{proc ? fmtBytes(proc.rss) : '—'}</dd></div>
|
||||
<div className="flex justify-between"><dt className="text-slate-400">进程</dt><dd className="text-slate-700">{proc?.name || '—'}</dd></div>
|
||||
<div className="flex justify-between"><dt className="text-slate-400">CPU</dt><dd className="text-slate-700">{proc?.cpu_percent != null ? proc.cpu_percent + '%' : '—'}</dd></div>
|
||||
<div className="flex justify-between"><dt className="text-slate-400">内存占用</dt><dd className="text-slate-700">{proc?.memory_percent != null ? proc.memory_percent + '%' : '—'}</dd></div>
|
||||
<div className="flex justify-between"><dt className="text-slate-400">启动时长</dt><dd className="text-slate-700">{sys ? fmtUptime(sys.bot_uptime_seconds) : '—'}</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</Card.Content>
|
||||
</Card>
|
||||
|
||||
{/* 在线会话 */}
|
||||
<Card className="border border-slate-200 bg-white">
|
||||
<Card.Header>
|
||||
<Card.Title>在线会话</Card.Title>
|
||||
<p className="text-xs text-slate-400">当前已接入并完成登录的 QQ 账号</p>
|
||||
</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>}
|
||||
<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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Checkbox,
|
||||
Input,
|
||||
Label,
|
||||
ListBox,
|
||||
Modal,
|
||||
Select,
|
||||
Spinner,
|
||||
TextArea,
|
||||
TextField,
|
||||
toast,
|
||||
useOverlayState,
|
||||
} from '@heroui/react'
|
||||
import {
|
||||
hubPluginCatalog,
|
||||
hubPluginGetConfig,
|
||||
hubPluginSaveConfig,
|
||||
hubPluginSetGlobal,
|
||||
hubPluginSetGroup,
|
||||
hubPluginRemoveGroup,
|
||||
hubGroups,
|
||||
} from '../../api/client'
|
||||
|
||||
interface GroupOverride { enabled: boolean; chat: string[] }
|
||||
interface CatalogPlugin {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
usage: string
|
||||
enabled: boolean
|
||||
chat: string[]
|
||||
groups: Record<string, GroupOverride>
|
||||
managed: boolean
|
||||
has_web: boolean
|
||||
web_path: string | null
|
||||
has_config: boolean
|
||||
}
|
||||
interface GroupItem { group_id: number; group_name: string }
|
||||
interface ConfigField {
|
||||
key: string
|
||||
label: string
|
||||
type: string
|
||||
default: any
|
||||
description?: string
|
||||
options?: { value: string; label: string }[] | string[]
|
||||
secret?: boolean
|
||||
}
|
||||
interface ConfigData { schema: { fields: ConfigField[] }; values: Record<string, any>; revision: number }
|
||||
|
||||
const CHAT_OPTIONS = [
|
||||
{ value: 'all', label: '全部(群聊 + 私聊)' },
|
||||
{ value: 'group', label: '仅群聊' },
|
||||
{ value: 'private', label: '仅私聊' },
|
||||
]
|
||||
const selectCls = 'min-w-[150px] bg-white border border-slate-200 rounded-lg px-3 py-2 text-sm text-slate-800 focus:border-indigo-400 focus:outline-none'
|
||||
|
||||
function chatValue(chat: string[]): string {
|
||||
const s = (chat || []).filter(c => c === 'group' || c === 'private')
|
||||
if (s.length === 2) return 'all'
|
||||
if (s[0] === 'group') return 'group'
|
||||
if (s[0] === 'private') return 'private'
|
||||
return 'all'
|
||||
}
|
||||
function chatFromValue(v: string): string[] {
|
||||
if (v === 'group') return ['group']
|
||||
if (v === 'private') return ['private']
|
||||
return ['group', 'private']
|
||||
}
|
||||
function normOptions(opts: any): { value: string; label: string }[] {
|
||||
if (Array.isArray(opts)) {
|
||||
return opts.map(o => typeof o === 'string' ? { value: o, label: o } : { value: String(o.value), label: String(o.label ?? o.value) })
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
export default function PluginsPage() {
|
||||
const [plugins, setPlugins] = useState<CatalogPlugin[]>([])
|
||||
const [groups, setGroups] = useState<GroupItem[]>([])
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [expanded, setExpanded] = useState<string | null>(null)
|
||||
const loadRef = useRef(false)
|
||||
|
||||
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>>({})
|
||||
const configState = useOverlayState()
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (loadRef.current) return
|
||||
loadRef.current = true
|
||||
setLoading(true)
|
||||
try {
|
||||
const [cat, grp] = await Promise.all([hubPluginCatalog(), hubGroups()])
|
||||
setPlugins((cat && cat.plugins) || [])
|
||||
setGroups((grp && grp.items) || [])
|
||||
setError('')
|
||||
} catch (e: any) {
|
||||
setError(e.message || '加载失败')
|
||||
} finally {
|
||||
loadRef.current = false
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
const openConfig = async (p: CatalogPlugin) => {
|
||||
setConfigPlugin(p)
|
||||
setConfigData(null)
|
||||
setConfigLoading(true)
|
||||
configState.open()
|
||||
try {
|
||||
const d = await hubPluginGetConfig(p.id)
|
||||
if (!d || d.ok === false) throw new Error((d && (d as any).msg) || '读取配置失败')
|
||||
setConfigData({ schema: d.schema, values: d.values, revision: d.revision ?? 0 })
|
||||
const f: Record<string, any> = {}
|
||||
for (const field of d.schema.fields) f[field.key] = d.values[field.key]
|
||||
setForm(f)
|
||||
} catch (e: any) {
|
||||
toast.danger(e.message || '读取配置失败')
|
||||
} finally {
|
||||
setConfigLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const saveConfig = async () => {
|
||||
if (!configPlugin) return
|
||||
setConfigSaving(true)
|
||||
try {
|
||||
await hubPluginSaveConfig(configPlugin.id, form, configData?.revision ?? 0)
|
||||
toast.success('配置已保存并热刷新')
|
||||
configState.close()
|
||||
await load()
|
||||
} catch (e: any) {
|
||||
toast.danger(e.message || '保存失败')
|
||||
} finally {
|
||||
setConfigSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const setField = (key: string, v: any) => setForm(prev => ({ ...prev, [key]: v }))
|
||||
|
||||
const renderField = (field: ConfigField) => {
|
||||
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>
|
||||
)
|
||||
}
|
||||
if (field.type === 'enum') {
|
||||
const opts = normOptions(field.options)
|
||||
return (
|
||||
<div key={field.key}>
|
||||
<Label>{field.label}</Label>
|
||||
<Select selectedKey={String(v ?? '')} onSelectionChange={(k) => setField(field.key, String(k))}>
|
||||
<Select.Trigger className={selectCls}><Select.Value /></Select.Trigger>
|
||||
<Select.Popover><ListBox>
|
||||
{opts.map(o => <ListBox.Item key={o.value} id={o.value}>{o.label}</ListBox.Item>)}
|
||||
</ListBox></Select.Popover>
|
||||
</Select>
|
||||
{field.description && <p className="text-xs text-slate-400">{field.description}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (field.type === 'text' || field.type === 'json' || field.type === 'object') {
|
||||
return (
|
||||
<TextField key={field.key}><Label>{field.label}</Label><TextArea rows={field.type === 'json' ? 10 : 4} 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')
|
||||
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) }} />
|
||||
{field.description && <p className="text-xs text-slate-400">{field.description}</p>}
|
||||
</TextField>
|
||||
)
|
||||
}
|
||||
|
||||
const toggleGlobal = async (p: CatalogPlugin, enabled: boolean) => {
|
||||
try { await hubPluginSetGlobal(p.id, { enabled }); await load() } catch (e: any) { 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 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 resetGroup = async (p: CatalogPlugin, gid: number) => {
|
||||
try { await hubPluginRemoveGroup(p.id, String(gid)); await load() } catch (e: any) { toast.danger(e.message || '重置失败') }
|
||||
}
|
||||
const effectiveGroupEnabled = (p: CatalogPlugin, gid: number) => {
|
||||
const ov = p.groups[String(gid)]
|
||||
return ov ? ov.enabled : p.enabled
|
||||
}
|
||||
|
||||
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">在所有插件之上统一控制:全局开关、群聊/私聊使用、分群控制、插件配置</p>
|
||||
</div>
|
||||
<Button size="sm" isDisabled={loading} onPress={() => { setExpanded(null); 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 && !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.Content>
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-center">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm font-semibold text-slate-800">{p.name}</span>
|
||||
{p.has_web && <span className="inline-block rounded-full bg-indigo-50 text-indigo-600 text-xs px-2 py-0.5">Web</span>}
|
||||
{p.has_config && <span className="inline-block rounded-full bg-amber-50 text-amber-600 text-xs px-2 py-0.5">配置</span>}
|
||||
<span className={'inline-block rounded-full text-xs px-2 py-0.5 ' + (on ? 'bg-emerald-50 text-emerald-600' : 'bg-slate-100 text-slate-400')}>{on ? '启用' : '停用'}</span>
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-slate-400">{p.id}</div>
|
||||
<div className="mt-1 text-xs text-slate-500">{p.description || '—'}</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<Checkbox isSelected={on} onChange={(v) => toggleGlobal(p, v)}>
|
||||
<Checkbox.Content><Checkbox.Control><Checkbox.Indicator /></Checkbox.Control>全局启用</Checkbox.Content>
|
||||
</Checkbox>
|
||||
|
||||
<Select selectedKey={chatValue(p.chat)} onSelectionChange={(k) => changeChat(p, String(k))} aria-label="聊天类型">
|
||||
<Select.Trigger className={selectCls}><Select.Value /></Select.Trigger>
|
||||
<Select.Popover><ListBox>
|
||||
{CHAT_OPTIONS.map(o => <ListBox.Item key={o.value} id={o.value}>{o.label}</ListBox.Item>)}
|
||||
</ListBox></Select.Popover>
|
||||
</Select>
|
||||
|
||||
{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>}
|
||||
<Button size="sm" variant="tertiary" onPress={() => setExpanded(expanded === p.id ? null : p.id)}>分群设置</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{expanded === p.id && (
|
||||
<div className="mt-4 rounded-xl border border-slate-100 bg-slate-50 p-3">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-slate-500">分群控制(覆盖全局)</span>
|
||||
{!groups.length && <span className="text-xs text-slate-400">未获取到群列表</span>}
|
||||
</div>
|
||||
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{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 gap-2 rounded-lg border border-slate-200 bg-white px-3 py-2">
|
||||
<Checkbox isSelected={eff} onChange={(v) => toggleGroup(p, g.group_id, v)}>
|
||||
<Checkbox.Content><Checkbox.Control><Checkbox.Indicator /></Checkbox.Control><span className="text-xs">{g.group_name || g.group_id}</span></Checkbox.Content>
|
||||
</Checkbox>
|
||||
{hasOv && <Button size="sm" variant="tertiary" onPress={() => resetGroup(p, g.group_id)}>重置</Button>}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card.Content>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<Modal isOpen={configState.isOpen} onOpenChange={configState.setOpen}>
|
||||
<Modal.Backdrop><Modal.Container><Modal.Dialog>
|
||||
<Modal.Header><Modal.Heading>{configPlugin?.name || ''} · 配置</Modal.Heading></Modal.Header>
|
||||
<Modal.Body>
|
||||
{configLoading ? (
|
||||
<div className="flex justify-center py-10"><Spinner /></div>
|
||||
) : configData ? (
|
||||
<div className="space-y-4">
|
||||
{configData.schema.fields.map(field => renderField(field))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-slate-400">该插件未提供可编辑配置</p>
|
||||
)}
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
<Button variant="tertiary" onPress={configState.close}>取消</Button>
|
||||
<Button isDisabled={configSaving || !configData} onPress={saveConfig}>{configSaving ? '保存中…' : '保存并热刷新'}</Button>
|
||||
</Modal.Footer>
|
||||
</Modal.Dialog></Modal.Container></Modal.Backdrop>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Button, Card, Form, Input, Label, TextField, toast } from '@heroui/react'
|
||||
import { hubSettings, hubChangePassword } from '../../api/client'
|
||||
|
||||
interface SettingsData {
|
||||
ok: boolean
|
||||
user: { id: number; username: string; created_at: string }
|
||||
plugins: { id: string; name: string; icon: string }[]
|
||||
version: string
|
||||
}
|
||||
|
||||
export default function SettingsPage() {
|
||||
const [data, setData] = useState<SettingsData | null>(null)
|
||||
const [error, setError] = useState('')
|
||||
const [oldPassword, setOldPassword] = useState('')
|
||||
const [newPassword, setNewPassword] = useState('')
|
||||
const [confirm, setConfirm] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
hubSettings()
|
||||
.then(setData)
|
||||
.catch((e: any) => setError(e.message || '加载失败'))
|
||||
}, [])
|
||||
|
||||
const changePassword = async () => {
|
||||
if (!oldPassword) { toast.warning('请输入原密码'); return }
|
||||
if (newPassword.length < 6) { toast.warning('新密码长度不能少于 6 位'); return }
|
||||
if (newPassword !== confirm) { toast.warning('两次输入的新密码不一致'); return }
|
||||
setBusy(true)
|
||||
try {
|
||||
await hubChangePassword(oldPassword, newPassword)
|
||||
toast.success('密码已修改')
|
||||
setOldPassword(''); setNewPassword(''); setConfirm('')
|
||||
} catch (e: any) {
|
||||
toast.danger(e.message || '修改失败')
|
||||
} finally { setBusy(false) }
|
||||
}
|
||||
|
||||
return (
|
||||
<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">
|
||||
<Card.Header><Card.Title>账号信息</Card.Title></Card.Header>
|
||||
<Card.Content>
|
||||
{error ? (
|
||||
<p className="text-red-500 text-sm">{error}</p>
|
||||
) : !data ? (
|
||||
<p className="text-slate-400 text-sm">加载中…</p>
|
||||
) : (
|
||||
<div className="space-y-3 text-sm">
|
||||
<div className="flex justify-between border-b border-slate-100 pb-2">
|
||||
<span className="text-slate-500">用户名</span>
|
||||
<span className="font-medium text-slate-800">{data.user.username}</span>
|
||||
</div>
|
||||
<div className="flex justify-between border-b border-slate-100 pb-2">
|
||||
<span className="text-slate-500">用户 ID</span>
|
||||
<span className="font-medium text-slate-800">{data.user.id}</span>
|
||||
</div>
|
||||
<div className="flex justify-between border-b border-slate-100 pb-2">
|
||||
<span className="text-slate-500">创建时间</span>
|
||||
<span className="text-slate-800">{data.user.created_at || '—'}</span>
|
||||
</div>
|
||||
<div className="flex justify-between border-b border-slate-100 pb-2">
|
||||
<span className="text-slate-500">已接入插件</span>
|
||||
<span className="text-slate-800">{data.plugins.length} 个</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card.Content>
|
||||
</Card>
|
||||
|
||||
<Card className="border border-slate-200 bg-white">
|
||||
<Card.Header><Card.Title>修改密码</Card.Title></Card.Header>
|
||||
<Card.Content>
|
||||
<Form className="space-y-4">
|
||||
<TextField><Label>原密码</Label><Input type="password" value={oldPassword} onChange={e => setOldPassword(e.target.value)} /></TextField>
|
||||
<TextField><Label>新密码</Label><Input type="password" value={newPassword} onChange={e => setNewPassword(e.target.value)} placeholder="至少 6 位" /></TextField>
|
||||
<TextField><Label>确认新密码</Label><Input type="password" value={confirm} onChange={e => setConfirm(e.target.value)} /></TextField>
|
||||
<Button isDisabled={busy} onPress={changePassword}>{busy ? '修改中…' : '保存修改'}</Button>
|
||||
</Form>
|
||||
<p className="text-xs text-slate-400 mt-3">修改后旧登录态失效,当前会话保持在线。</p>
|
||||
</Card.Content>
|
||||
</Card>
|
||||
|
||||
<Card className="border border-slate-200 bg-white lg:col-span-2">
|
||||
<Card.Header><Card.Title>关于</Card.Title></Card.Header>
|
||||
<Card.Content>
|
||||
<p className="text-sm text-slate-500">HeXi 管理系统 v{data?.version || '—'} · 统一 Web 管理台</p>
|
||||
</Card.Content>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user