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 @@
|
||||
export { default } from './router'
|
||||
@@ -0,0 +1,67 @@
|
||||
const TOKEN_KEY = 'hexi_web_token'
|
||||
const USER_KEY = 'hexi_web_username'
|
||||
|
||||
export function getToken() { return localStorage.getItem(TOKEN_KEY) || '' }
|
||||
|
||||
interface ReqOpts { method?: string; body?: any; headers?: Record<string,string> }
|
||||
|
||||
async function request(url: string, opts: ReqOpts = {}) {
|
||||
const headers: Record<string,string> = { 'Content-Type': 'application/json', ...(opts.headers || {}) }
|
||||
const token = getToken()
|
||||
if (token) headers['Authorization'] = 'Bearer ' + token
|
||||
const res = await fetch(url, { method: opts.method || 'GET', headers, body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined })
|
||||
let data: any = null
|
||||
try { data = await res.json() } catch { data = null }
|
||||
if (!res.ok) {
|
||||
const msg = (data && (data.detail || data.msg)) || ('HTTP ' + res.status)
|
||||
throw new Error(msg)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
export function setSession(token: string, username: string) {
|
||||
localStorage.setItem(TOKEN_KEY, token)
|
||||
localStorage.setItem(USER_KEY, username)
|
||||
}
|
||||
export function clearSession() {
|
||||
localStorage.removeItem(TOKEN_KEY)
|
||||
localStorage.removeItem(USER_KEY)
|
||||
}
|
||||
export function getUserName() { return localStorage.getItem(USER_KEY) || '' }
|
||||
|
||||
export const authLogin = async (username: string, password: string) => {
|
||||
const res = await fetch('/hub/api/auth/token', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({ username, password }).toString(),
|
||||
})
|
||||
const data = await res.json().catch(() => null)
|
||||
if (!res.ok) throw new Error((data && (data.detail || data.msg)) || ('HTTP ' + res.status))
|
||||
if (data && data.access_token === undefined) throw new Error('登录失败')
|
||||
return { token: data.access_token, username: data.username || username }
|
||||
}
|
||||
export const authMe = async () => await request('/hub/api/auth/me')
|
||||
export const hubPlugins = async () => await request('/hub/api/plugins')
|
||||
export const hubDashboard = async () => await request('/hub/api/dashboard')
|
||||
export const hubPluginCatalog = async () => await request('/hub/api/plugins/catalog')
|
||||
export const hubGroups = async () => await request('/hub/api/groups')
|
||||
export const hubPluginSetGlobal = async (id: string, body: any) =>
|
||||
await request('/hub/api/plugins/' + encodeURIComponent(id) + '/global', { method: 'POST', body })
|
||||
export const hubPluginSetGroup = async (id: string, gid: string, body: any) =>
|
||||
await request('/hub/api/plugins/' + encodeURIComponent(id) + '/groups/' + encodeURIComponent(gid), { method: 'POST', body })
|
||||
export const hubPluginRemoveGroup = async (id: string, gid: string) =>
|
||||
await request('/hub/api/plugins/' + encodeURIComponent(id) + '/groups/' + encodeURIComponent(gid), { method: 'DELETE' })
|
||||
export const hubPluginGetConfig = async (id: string) =>
|
||||
await request('/hub/api/plugins/' + encodeURIComponent(id) + '/config')
|
||||
export const hubPluginSaveConfig = async (id: string, values: Record<string, any>, revision: number) =>
|
||||
await request('/hub/api/plugins/' + encodeURIComponent(id) + '/config', { method: 'POST', body: { values, revision } })
|
||||
export const hubSettings = async () => await request('/hub/api/settings')
|
||||
export const hubChangePassword = async (old_password: string, new_password: string) =>
|
||||
await request('/hub/api/settings/password', { method: 'POST', body: { old_password, new_password } })
|
||||
|
||||
export const api = (plugin: string) => ({
|
||||
get: (path: string) => request(`/api/${plugin}/${path}`),
|
||||
post: (path: string, body: any) => request(`/api/${plugin}/${path}`, { method: 'POST', body }),
|
||||
put: (path: string, body: any) => request(`/api/${plugin}/${path}`, { method: 'PUT', body }),
|
||||
del: (path: string) => request(`/api/${plugin}/${path}`, { method: 'DELETE' }),
|
||||
})
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Button } from '@heroui/react'
|
||||
import { useAuth } from './auth'
|
||||
|
||||
export function LoginPage() {
|
||||
const { login } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [err, setErr] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const submit = async () => {
|
||||
setLoading(true); setErr('')
|
||||
try {
|
||||
await login(username, password)
|
||||
navigate('/hub')
|
||||
} catch (e: any) {
|
||||
setErr(e.message || '登录失败')
|
||||
} finally { setLoading(false) }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 text-slate-800 flex items-center justify-center">
|
||||
<div className="w-full max-w-sm bg-white border border-slate-200 rounded-2xl shadow-sm p-8">
|
||||
<h1 className="text-xl font-bold mb-6">HeXi 管理系统</h1>
|
||||
<div className="space-y-4">
|
||||
<input className="w-full border border-slate-200 rounded-lg px-3 py-2 focus:border-indigo-500 focus:outline-none" placeholder="用户名" value={username} onChange={e => setUsername(e.target.value)} />
|
||||
<input className="w-full border border-slate-200 rounded-lg px-3 py-2 focus:border-indigo-500 focus:outline-none" type="password" placeholder="密码" value={password} onChange={e => setPassword(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') submit() }} />
|
||||
{err && <div className="text-red-500 text-sm">{err}</div>}
|
||||
<Button className="w-full" isDisabled={loading} onPress={submit}>{loading ? '登录中…' : '登录'}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { createContext, useContext, useState, type ReactNode } from 'react'
|
||||
import { authLogin, clearSession, getUserName, setSession } from '../api/client'
|
||||
|
||||
interface AuthState {
|
||||
token: string
|
||||
username: string
|
||||
login: (u: string, p: string) => Promise<void>
|
||||
logout: () => void
|
||||
}
|
||||
|
||||
const Ctx = createContext<AuthState>({ token: '', username: '', login: async () => {}, logout: () => {} })
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [token, setToken] = useState(() => localStorage.getItem('hexi_web_token') || '')
|
||||
const [username, setUsername] = useState(() => localStorage.getItem('hexi_web_username') || '')
|
||||
const login = async (u: string, p: string) => {
|
||||
const data = await authLogin(u, p)
|
||||
setSession(data.token, data.username)
|
||||
setToken(data.token)
|
||||
setUsername(data.username)
|
||||
}
|
||||
const logout = () => {
|
||||
clearSession()
|
||||
setToken('')
|
||||
setUsername('')
|
||||
}
|
||||
return <Ctx.Provider value={{ token, username, login, logout }}>{children}</Ctx.Provider>
|
||||
}
|
||||
|
||||
export const useAuth = () => useContext(Ctx)
|
||||
@@ -0,0 +1,3 @@
|
||||
@import "tailwindcss";
|
||||
@import "@heroui/styles";
|
||||
body { margin: 0; }
|
||||
@@ -0,0 +1,118 @@
|
||||
import { NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom'
|
||||
import { Avatar, Button, Separator } from '@heroui/react'
|
||||
import { House, Rocket, Gear } from '@gravity-ui/icons'
|
||||
import { useAuth } from '../auth/auth'
|
||||
|
||||
interface NavItem { id: string; label: string; description: string; icon: any }
|
||||
|
||||
export function AppLayout() {
|
||||
const { token, username, logout } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
|
||||
const navItems: NavItem[] = [
|
||||
{ id: 'home', label: '首页', description: '主机与服务状态', icon: House },
|
||||
{ id: 'plugins', label: '插件管理', description: '插件总览与分群控制', icon: Rocket },
|
||||
{ id: 'settings', label: '设置', description: '账号与系统设置', icon: Gear },
|
||||
]
|
||||
const isHome = location.pathname === '/hub'
|
||||
const isSettings = location.pathname.startsWith('/hub/settings')
|
||||
const isPluginList = location.pathname.startsWith('/hub/plugins')
|
||||
const isPlugins = isPluginList || (!isHome && !isSettings && location.pathname.startsWith('/hub/'))
|
||||
const isPluginSub = isPlugins && !isPluginList
|
||||
const activeId = isPlugins ? 'plugins' : isSettings ? 'settings' : 'home'
|
||||
const pageMeta = navItems.find(it => it.id === activeId) || navItems[0]
|
||||
|
||||
if (!token) return null
|
||||
|
||||
return (
|
||||
<div className="flex h-screen w-screen overflow-hidden bg-slate-50 text-slate-900">
|
||||
<aside className="flex h-full w-60 shrink-0 flex-col overflow-hidden bg-white border-r border-slate-200">
|
||||
{/* 品牌头 */}
|
||||
<div className="flex h-16 shrink-0 items-center px-4">
|
||||
<div className="grid size-9 place-items-center rounded-xl bg-indigo-100 text-indigo-600"><Gear className="size-5" /></div>
|
||||
<div className="ml-3 min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-bold">HeXi 管理系统</div>
|
||||
<div className="truncate text-xs text-slate-400">统一管理台</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 导航 */}
|
||||
<nav className="flex-1 min-h-0 space-y-1 overflow-y-auto px-2 py-2">
|
||||
{navItems.filter(it => it.id !== 'settings').map(it => {
|
||||
const to = it.id === 'home' ? '/hub' : '/hub/' + it.id
|
||||
return (
|
||||
<NavLink
|
||||
key={it.id}
|
||||
to={to}
|
||||
end={it.id === 'home'}
|
||||
className={({ isActive }) => {
|
||||
const base = 'group relative flex items-center rounded-lg py-2.5 text-sm font-medium transition-colors cursor-pointer'
|
||||
return base + (isActive ? ' bg-indigo-50 text-indigo-700' : ' text-slate-600 hover:bg-slate-100 hover:text-slate-800')
|
||||
}}
|
||||
>
|
||||
{({ isActive }) => (
|
||||
<>
|
||||
{isActive && <span className="absolute left-0 top-1/2 h-5 w-0.5 -translate-y-1/2 rounded-full bg-indigo-500" />}
|
||||
<span className="relative z-10 grid w-11 shrink-0 place-items-center"><it.icon className="size-4" /></span>
|
||||
<span className="relative z-10 flex min-w-0 flex-1 flex-col items-start overflow-hidden pr-3">
|
||||
<span className="w-full truncate leading-tight">{it.label}</span>
|
||||
<span className="w-full truncate text-xs font-normal text-slate-400">{it.description}</span>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</NavLink>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* 底部设置 */}
|
||||
<nav className="shrink-0 space-y-1 border-t border-slate-100 px-2 py-2">
|
||||
{navItems.filter(it => it.id === 'settings').map(it => {
|
||||
const to = '/hub/' + it.id
|
||||
return (
|
||||
<NavLink
|
||||
key={it.id}
|
||||
to={to}
|
||||
className={({ isActive }) => {
|
||||
const base = 'group relative flex items-center rounded-lg py-2.5 text-sm font-medium transition-colors cursor-pointer'
|
||||
return base + (isActive ? ' bg-indigo-50 text-indigo-700' : ' text-slate-600 hover:bg-slate-100 hover:text-slate-800')
|
||||
}}
|
||||
>
|
||||
{({ isActive }) => (
|
||||
<>
|
||||
{isActive && <span className="absolute left-0 top-1/2 h-5 w-0.5 -translate-y-1/2 rounded-full bg-indigo-500" />}
|
||||
<span className="relative z-10 grid w-11 shrink-0 place-items-center"><it.icon className="size-4" /></span>
|
||||
<span className="relative z-10 flex min-w-0 flex-1 flex-col items-start overflow-hidden pr-3">
|
||||
<span className="w-full truncate leading-tight">{it.label}</span>
|
||||
<span className="w-full truncate text-xs font-normal text-slate-400">{it.description}</span>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</NavLink>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
|
||||
<header className="flex h-16 shrink-0 items-center justify-between border-b border-slate-200 bg-white px-6">
|
||||
<div className="flex items-center gap-3">
|
||||
{isPluginSub && (
|
||||
<Button size="sm" variant="tertiary" onPress={() => navigate('/hub/plugins')}>← 返回插件管理</Button>
|
||||
)}
|
||||
<pageMeta.icon className="size-4 text-slate-500" />
|
||||
<span className="text-sm font-semibold text-slate-800">{pageMeta.label}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar size="sm"><Avatar.Fallback>{(username || 'U').charAt(0).toUpperCase()}</Avatar.Fallback></Avatar>
|
||||
<span className="text-sm text-slate-600">{username || '未登录'}</span>
|
||||
<Separator orientation="vertical" className="h-5" />
|
||||
<Button size="sm" variant="tertiary" onPress={logout}>退出</Button>
|
||||
</div>
|
||||
</header>
|
||||
<main className="min-h-0 flex-1 overflow-y-auto p-6"><Outlet /></main>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import './index.css'
|
||||
import App from './App'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</StrictMode>,
|
||||
)
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Button, Card, Input, Label, Select, ListBox, TextField, toast } from '@heroui/react'
|
||||
import { api } from '../../api/client'
|
||||
|
||||
const gc = api('galgame_card')
|
||||
|
||||
interface Group {
|
||||
group_id: number; group_name: string; enabled: boolean
|
||||
participants: number; corpus_count: number; impression_count: number; summary_count: number
|
||||
}
|
||||
interface Participant { user_id: number; corpus_count: number }
|
||||
interface LogRow { id: number; user_id: number; nickname?: string; content: string; target_user_id?: number | null; created_at: string }
|
||||
interface ImpressionRow { id: number; user_id: number; content: string; cover_from_id: number; cover_to_id: number; created_at: string }
|
||||
interface SummaryRow { version: number; card_text: string; corpus_count: number; impression_count: number; created_at: string }
|
||||
interface SettingsData { web_username: string; web_enable: boolean; thresholds: Record<string, number> }
|
||||
|
||||
function unpack(res: any, errorLabel: string = '操作失败') {
|
||||
if (res && res.status !== 0) throw new Error(res.msg || errorLabel)
|
||||
return res ? res.data : null
|
||||
}
|
||||
|
||||
const selectCls = 'bg-white border border-slate-200 rounded-lg px-3 py-2 text-sm text-slate-800 focus:border-indigo-400 focus:outline-none'
|
||||
const inputCls = 'w-full bg-white border border-slate-200 rounded-lg px-3 py-2 text-sm text-slate-800 focus:border-indigo-400 focus:outline-none'
|
||||
const thCls = 'px-3 py-2 text-left text-xs font-medium text-slate-400'
|
||||
const tdCls = 'px-3 py-2 text-sm text-slate-700 border-t border-slate-100'
|
||||
|
||||
function fmtTime(ts: string) { return ts || '—' }
|
||||
|
||||
function Tabs({ tabs, active, onChange }: { tabs: string[]; active: string; onChange: (v: string) => void }) {
|
||||
return (
|
||||
<div className="flex gap-1 border-b border-slate-200 mb-4">
|
||||
{tabs.map(t => (
|
||||
<button key={t} onClick={() => onChange(t)}
|
||||
className={'px-3 py-2 text-sm border-b-2 -mb-px ' + (active === t ? 'border-indigo-500 text-indigo-700 font-medium' : 'border-transparent text-slate-500 hover:text-slate-700')}>
|
||||
{t}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function GalgameCardPage() {
|
||||
const [view, setView] = useState<'groups' | 'detail' | 'data' | 'settings'>('groups')
|
||||
const [groups, setGroups] = useState<Group[]>([])
|
||||
const [activeGid, setActiveGid] = useState<number | null>(null)
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const loadGroups = useCallback(async () => {
|
||||
setLoading(true); setError('')
|
||||
try {
|
||||
const data = await unpack(await gc.get('groups'), '加载群列表失败')
|
||||
setGroups(data || [])
|
||||
} catch (e: any) { setError(e.message || '加载失败') } finally { setLoading(false) }
|
||||
}, [])
|
||||
|
||||
useEffect(() => { loadGroups() }, [loadGroups])
|
||||
|
||||
const toggleGroup = async (g: Group, enabled: boolean) => {
|
||||
try { await gc.post(`groups/${g.group_id}/enabled`, { enabled }); await loadGroups() }
|
||||
catch (e: any) { toast.danger(e.message || '切换失败') }
|
||||
}
|
||||
|
||||
const openDetail = (g: Group) => { setActiveGid(g.group_id); setView('detail') }
|
||||
|
||||
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>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex gap-1">
|
||||
{(['groups', 'data', 'settings'] as const).map(v => (
|
||||
<Button key={v} size="sm" variant={view === v ? 'primary' : 'tertiary'} onPress={() => setView(v)}>
|
||||
{v === 'groups' ? '群管理' : v === 'data' ? '数据浏览' : '设置'}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<Button size="sm" isDisabled={loading} onPress={() => { setView('groups'); loadGroups() }}>{loading ? '加载中…' : '刷新'}</Button>
|
||||
</div>
|
||||
</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>}
|
||||
|
||||
{view === 'groups' && (
|
||||
<Card className="border border-slate-200 bg-white">
|
||||
<Card.Header><Card.Title>群采集开关</Card.Title></Card.Header>
|
||||
<Card.Content>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[640px]">
|
||||
<thead><tr>
|
||||
<th className={thCls}>群</th><th className={thCls}>开关</th><th className={thCls}>参与者</th><th className={thCls}>语料</th><th className={thCls}>印象</th><th className={thCls}>画像</th><th className={thCls}></th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{groups.map(g => (
|
||||
<tr key={g.group_id}>
|
||||
<td className={tdCls}><div className="font-medium text-slate-800">{g.group_name || '(未命名群)'}</div><div className="text-xs text-slate-400">{g.group_id}</div></td>
|
||||
<td className={tdCls}>
|
||||
<button onClick={() => toggleGroup(g, !g.enabled)}
|
||||
className={'relative h-5 w-10 rounded-full transition-colors ' + (g.enabled ? 'bg-emerald-500' : 'bg-slate-300')}>
|
||||
<span className={'absolute top-0.5 h-4 w-4 rounded-full bg-white transition-all ' + (g.enabled ? 'left-5' : 'left-0.5')} />
|
||||
</button>
|
||||
</td>
|
||||
<td className={tdCls}>{g.participants}</td><td className={tdCls}>{g.corpus_count}</td><td className={tdCls}>{g.impression_count}</td><td className={tdCls}>{g.summary_count}</td>
|
||||
<td className={tdCls + ' text-right'}><Button size="sm" variant="tertiary" onPress={() => openDetail(g)}>管理</Button></td>
|
||||
</tr>
|
||||
))}
|
||||
{!groups.length && <tr><td colSpan={7} className={tdCls + ' text-slate-400 text-center'}>暂无管理中的群,请先在「添加群」中添加(或等待数据采集自动登记)</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card.Content>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{view === 'detail' && activeGid !== null && <GroupDetail gid={activeGid} onBack={() => setView('groups')} />}
|
||||
{view === 'data' && <DataBrowser groups={groups} />}
|
||||
{view === 'settings' && <SettingsView />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
function GroupDetail({ gid, onBack }: { gid: number; onBack: () => void }) {
|
||||
const [tab, setTab] = useState<'participants' | 'logs' | 'impressions' | 'summaries'>('participants')
|
||||
const [participants, setParticipants] = useState<Participant[]>([])
|
||||
const [logs, setLogs] = useState<LogRow[]>([])
|
||||
const [logTotal, setLogTotal] = useState(0)
|
||||
const [logPage, setLogPage] = useState(1)
|
||||
const [impressions, setImpressions] = useState<ImpressionRow[]>([])
|
||||
const [summaries, setSummaries] = useState<SummaryRow[]>([])
|
||||
const [addUserId, setAddUserId] = useState('')
|
||||
const [logFilter, setLogFilter] = useState('')
|
||||
|
||||
const loadParticipants = useCallback(async () => {
|
||||
try { const d = await unpack(await gc.get(`groups/${gid}/participants`), '加载参与者失败'); setParticipants(d || []) } catch (e: any) { toast.danger(e.message) }
|
||||
}, [gid])
|
||||
const loadLogs = useCallback(async (page: number, userId = '') => {
|
||||
const q = new URLSearchParams({ page: String(page), per_page: '50' })
|
||||
if (userId) q.set('user_id', userId)
|
||||
try {
|
||||
const d = await unpack(await gc.get(`groups/${gid}/logs?${q}`), '加载语料失败')
|
||||
setLogs((d && d.items) || []); setLogTotal((d && d.total) || 0); setLogPage(page)
|
||||
} catch (e: any) { toast.danger(e.message) }
|
||||
}, [gid])
|
||||
const loadImpressions = useCallback(async () => {
|
||||
try { const d = await unpack(await gc.get(`groups/${gid}/impressions?page=1&per_page=100`), '加载印象失败'); setImpressions((d && d.items) || []) } catch (e: any) { toast.danger(e.message) }
|
||||
}, [gid])
|
||||
const loadSummaries = useCallback(async () => {
|
||||
try { const d = await unpack(await gc.get(`groups/${gid}/summaries`), '加载画像失败'); setSummaries(d || []) } catch (e: any) { toast.danger(e.message) }
|
||||
}, [gid])
|
||||
|
||||
useEffect(() => { loadParticipants() }, [loadParticipants])
|
||||
useEffect(() => {
|
||||
if (tab === 'logs') loadLogs(1, logFilter)
|
||||
else if (tab === 'impressions') loadImpressions()
|
||||
else if (tab === 'summaries') loadSummaries()
|
||||
}, [tab]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const addParticipant = async () => {
|
||||
if (!addUserId.trim()) return
|
||||
try { await gc.post(`groups/${gid}/participants`, { user_id: Number(addUserId.trim()) }); setAddUserId(''); loadParticipants() }
|
||||
catch (e: any) { toast.danger(e.message || '添加失败') }
|
||||
}
|
||||
const removeParticipant = async (uid: number) => {
|
||||
if (!window.confirm(`移除 ${uid}?其语料保留在库中`)) return
|
||||
try { await gc.del(`groups/${gid}/participants/${uid}`); loadParticipants() } catch (e: any) { toast.danger(e.message) }
|
||||
}
|
||||
const clearGroup = async () => {
|
||||
if (!window.confirm('确认清空该群全部数据(语料/印象/画像/参与者)?此操作不可恢复')) return
|
||||
try { await gc.del(`groups/${gid}/data`); toast.success('已清空'); loadParticipants(); loadLogs(1); loadImpressions(); loadSummaries() } catch (e: any) { toast.danger(e.message) }
|
||||
}
|
||||
const clearLogs = async () => {
|
||||
if (!window.confirm('删除该群全部语料?印象/画像保留')) return
|
||||
try { await gc.del(`groups/${gid}/logs`); loadLogs(1) } catch (e: any) { toast.danger(e.message) }
|
||||
}
|
||||
|
||||
const pagination = Math.max(1, Math.ceil(logTotal / 50))
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button size="sm" variant="tertiary" onPress={onBack}>← 返回</Button>
|
||||
<span className="text-sm font-semibold text-slate-800">群 #{gid} 管理</span>
|
||||
</div>
|
||||
<Button size="sm" variant="tertiary" className="text-red-500" onPress={clearGroup}>清空该群全部数据</Button>
|
||||
</div>
|
||||
|
||||
<Card className="border border-slate-200 bg-white">
|
||||
<Card.Content>
|
||||
<Tabs tabs={['参与者', '语料', '印象', '画像']} active={{ participants: '参与者', logs: '语料', impressions: '印象', summaries: '画像' }[tab]} onChange={v => setTab(({ '参与者': 'participants', '语料': 'logs', '印象': 'impressions', '画像': 'summaries' } as any)[v])} />
|
||||
{tab === 'participants' && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<TextField><Label>QQ 号</Label><Input value={addUserId} onChange={e => setAddUserId(e.target.value)} placeholder="加入人设名单" /></TextField>
|
||||
<Button size="sm" onPress={addParticipant}>加入人设名单</Button>
|
||||
<span className="text-xs text-slate-400">仅名单内成员会被采集</span>
|
||||
</div>
|
||||
<table className="w-full">
|
||||
<thead><tr><th className={thCls}>QQ</th><th className={thCls}>语料条数</th><th className={thCls}></th></tr></thead>
|
||||
<tbody>
|
||||
{participants.map(p => (
|
||||
<tr key={p.user_id}><td className={tdCls}>{p.user_id}</td><td className={tdCls}>{p.corpus_count}</td><td className={tdCls + ' text-right'}><Button size="sm" variant="tertiary" className="text-red-500" onPress={() => removeParticipant(p.user_id)}>移除</Button></td></tr>
|
||||
))}
|
||||
{!participants.length && <tr><td colSpan={3} className={tdCls + ' text-slate-400 text-center'}>暂无参与者</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
{tab === 'logs' && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<TextField><Label>按 QQ 过滤</Label><Input value={logFilter} onChange={e => setLogFilter(e.target.value)} placeholder="可选" /></TextField>
|
||||
<Button size="sm" onPress={() => loadLogs(1, logFilter)}>查询</Button>
|
||||
<Button size="sm" variant="tertiary" className="text-red-500" onPress={clearLogs}>删除全部语料</Button>
|
||||
</div>
|
||||
<table className="w-full">
|
||||
<thead><tr><th className={thCls}>ID</th><th className={thCls}>发送者</th><th className={thCls}>发给</th><th className={thCls}>时间</th><th className={thCls}>内容(已脱敏)</th></tr></thead>
|
||||
<tbody>
|
||||
{logs.map(r => (
|
||||
<tr key={r.id}><td className={tdCls}>{r.id}</td><td className={tdCls}>{r.nickname}<span className="text-xs text-slate-400"> {r.user_id}</span></td><td className={tdCls}>{r.target_user_id ? '→ ' + r.target_user_id : '—'}</td><td className={tdCls}>{fmtTime(r.created_at)}</td><td className={tdCls}>{r.content}</td></tr>
|
||||
))}
|
||||
{!logs.length && <tr><td colSpan={5} className={tdCls + ' text-slate-400 text-center'}>暂无语料</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="flex items-center gap-3 text-xs text-slate-400">
|
||||
<Button size="sm" variant="tertiary" isDisabled={logPage <= 1} onPress={() => loadLogs(logPage - 1, logFilter)}>上一页</Button>
|
||||
<span>第 {logPage} / {pagination} 页 · 共 {logTotal} 条</span>
|
||||
<Button size="sm" variant="tertiary" isDisabled={logPage >= pagination} onPress={() => loadLogs(logPage + 1, logFilter)}>下一页</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{tab === 'impressions' && (
|
||||
<table className="w-full">
|
||||
<thead><tr><th className={thCls}>ID</th><th className={thCls}>用户</th><th className={thCls}>覆盖语料区间</th><th className={thCls}>时间</th><th className={thCls}>印象内容</th></tr></thead>
|
||||
<tbody>
|
||||
{impressions.map(r => (
|
||||
<tr key={r.id}><td className={tdCls}>{r.id}</td><td className={tdCls}>{r.user_id}</td><td className={tdCls}>#{r.cover_from_id} ~ #{r.cover_to_id}</td><td className={tdCls}>{fmtTime(r.created_at)}</td><td className={tdCls}>{r.content}</td></tr>
|
||||
))}
|
||||
{!impressions.length && <tr><td colSpan={5} className={tdCls + ' text-slate-400 text-center'}>暂无印象</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
{tab === 'summaries' && (
|
||||
<div className="space-y-3">
|
||||
{summaries.map(s => (
|
||||
<div key={s.version} className="rounded-lg border border-slate-200 bg-slate-50 p-3">
|
||||
<div className="mb-1 text-xs text-slate-500">v{s.version} · {fmtTime(s.created_at)} · 语料 {s.corpus_count} 条 · 印象 {s.impression_count} 条</div>
|
||||
<pre className="whitespace-pre-wrap text-xs text-slate-700">{s.card_text}</pre>
|
||||
</div>
|
||||
))}
|
||||
{!summaries.length && <p className="text-sm text-slate-400">暂无画像(语料积累后由总结路径生成)</p>}
|
||||
</div>
|
||||
)}
|
||||
</Card.Content>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
function DataBrowser({ groups }: { groups: Group[] }) {
|
||||
const [gid, setGid] = useState<number | ''>('')
|
||||
const [tab, setTab] = useState<'logs' | 'impressions' | 'summaries'>('logs')
|
||||
const [logs, setLogs] = useState<LogRow[]>([])
|
||||
const [impressions, setImpressions] = useState<ImpressionRow[]>([])
|
||||
const [summaries, setSummaries] = useState<SummaryRow[]>([])
|
||||
const [err, setErr] = useState('')
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setErr('')
|
||||
if (!gid) return
|
||||
try {
|
||||
if (tab === 'logs') { const d = await unpack(await gc.get(`groups/${gid}/logs?page=1&per_page=100`), '加载失败'); setLogs((d && d.items) || []) }
|
||||
else if (tab === 'impressions') { const d = await unpack(await gc.get(`groups/${gid}/impressions?page=1&per_page=100`), '加载失败'); setImpressions((d && d.items) || []) }
|
||||
else { const d = await unpack(await gc.get(`groups/${gid}/summaries`), '加载失败'); setSummaries(d || []) }
|
||||
} catch (e: any) { setErr(e.message) }
|
||||
}, [gid, tab])
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
return (
|
||||
<Card className="border border-slate-200 bg-white">
|
||||
<Card.Header><Card.Title>数据浏览</Card.Title></Card.Header>
|
||||
<Card.Content className="space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Select selectedKey={gid === '' ? '' : String(gid)} onSelectionChange={(k) => setGid(k ? Number(k) : '')} aria-label="选择群">
|
||||
<Select.Trigger className={selectCls}><Select.Value /></Select.Trigger>
|
||||
<Select.Popover><ListBox>
|
||||
{groups.map(g => <ListBox.Item key={g.group_id} id={String(g.group_id)}>{g.group_name || g.group_id}({g.group_id})</ListBox.Item>)}
|
||||
</ListBox></Select.Popover>
|
||||
</Select>
|
||||
{err && <span className="text-xs text-red-500">{err}</span>}
|
||||
</div>
|
||||
<Tabs tabs={['语料', '印象', '画像']} active={{ logs: '语料', impressions: '印象', summaries: '画像' }[tab]} onChange={v => setTab(({ '语料': 'logs', '印象': 'impressions', '画像': 'summaries' } as any)[v])} />
|
||||
{tab === 'logs' && (
|
||||
<table className="w-full">
|
||||
<thead><tr><th className={thCls}>ID</th><th className={thCls}>发送者</th><th className={thCls}>发给</th><th className={thCls}>时间</th><th className={thCls}>内容(已脱敏)</th></tr></thead>
|
||||
<tbody>
|
||||
{logs.map(r => <tr key={r.id}><td className={tdCls}>{r.id}</td><td className={tdCls}>{r.nickname}<span className="text-xs text-slate-400"> {r.user_id}</span></td><td className={tdCls}>{r.target_user_id ? '→ ' + r.target_user_id : '—'}</td><td className={tdCls}>{fmtTime(r.created_at)}</td><td className={tdCls}>{r.content}</td></tr>)}
|
||||
{!logs.length && <tr><td colSpan={5} className={tdCls + ' text-slate-400 text-center'}>{gid ? '暂无数据' : '请先选择群'}</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
{tab === 'impressions' && (
|
||||
<table className="w-full">
|
||||
<thead><tr><th className={thCls}>ID</th><th className={thCls}>用户</th><th className={thCls}>覆盖语料区间</th><th className={thCls}>时间</th><th className={thCls}>印象内容</th></tr></thead>
|
||||
<tbody>
|
||||
{impressions.map(r => <tr key={r.id}><td className={tdCls}>{r.id}</td><td className={tdCls}>{r.user_id}</td><td className={tdCls}>#{r.cover_from_id} ~ #{r.cover_to_id}</td><td className={tdCls}>{fmtTime(r.created_at)}</td><td className={tdCls}>{r.content}</td></tr>)}
|
||||
{!impressions.length && <tr><td colSpan={5} className={tdCls + ' text-slate-400 text-center'}>{gid ? '暂无数据' : '请先选择群'}</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
{tab === 'summaries' && (
|
||||
<div className="space-y-3">
|
||||
{summaries.map(s => <div key={s.version} className="rounded-lg border border-slate-200 bg-slate-50 p-3"><div className="mb-1 text-xs text-slate-500">v{s.version} · {fmtTime(s.created_at)}</div><pre className="whitespace-pre-wrap text-xs text-slate-700">{s.card_text}</pre></div>)}
|
||||
{!summaries.length && <p className="text-sm text-slate-400">{gid ? '暂无数据' : '请先选择群'}</p>}
|
||||
</div>
|
||||
)}
|
||||
</Card.Content>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
function SettingsView() {
|
||||
const [data, setData] = useState<SettingsData | null>(null)
|
||||
useEffect(() => {
|
||||
gc.get('settings').then(res => { try { setData(unpack(res)) } catch { } }).catch(() => {})
|
||||
}, [])
|
||||
const rows = data ? [
|
||||
['Web 登录账号', data.web_username],
|
||||
['Web 后台启用', data.web_enable ? '是' : '否'],
|
||||
['长文截断上限', (data.thresholds?.max_content_len ?? '—') + ' 字'],
|
||||
['复读检测窗口', (data.thresholds?.repeat_window ?? '—') + ' 条'],
|
||||
['刷屏采样间隔', (data.thresholds?.throttle_seconds ?? '—') + ' 秒'],
|
||||
['单用户语料滚动上限', (data.thresholds?.rolling_window ?? '—') + ' 条'],
|
||||
] : []
|
||||
return (
|
||||
<Card className="border border-slate-200 bg-white">
|
||||
<Card.Header><Card.Title>当前配置(只读)</Card.Title></Card.Header>
|
||||
<Card.Content>
|
||||
{!data ? <p className="text-sm text-slate-400">加载中…</p> : (
|
||||
<table className="w-full max-w-[560px]">
|
||||
<tbody>{rows.map(([k, v]) => <tr key={k}><td className={tdCls + ' w-48 text-slate-400'}>{k}</td><td className={tdCls}>{v}</td></tr>)}</tbody>
|
||||
</table>
|
||||
)}
|
||||
<p className="mt-3 text-xs text-slate-400">阈值与脱敏规则见插件 processor.py / DESIGN.md;Web 账号密码在 .env 配置 WEB_USERNAME / WEB_PASSWORD。</p>
|
||||
</Card.Content>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Button, Card, Checkbox, Form, Input, Label, ListBox, Modal, Pagination, Select, Table, TextArea, TextField, toast, useOverlayState } from '@heroui/react'
|
||||
import { Plus, ArrowUpFromLine, ArrowDownToLine, TrashBin, Pencil } from '@gravity-ui/icons'
|
||||
import { api } from '../../api/client'
|
||||
|
||||
interface Item {
|
||||
id: number; name: string; name_en?: string; type: string; backpack?: boolean;
|
||||
icon?: string; path?: string; alias?: string[];
|
||||
}
|
||||
|
||||
const hd = api('helldivers')
|
||||
const TYPE_LABEL: Record<string, string> = { blue: '蓝·补给', red: '红·飞鹰', green: '绿·设备' }
|
||||
|
||||
function iconUrl(icon?: string) {
|
||||
if (!icon) return ''
|
||||
return '/api/helldivers/img/' + encodeURI(icon.replace(/^img\//, ''))
|
||||
}
|
||||
|
||||
function TypeChip({ type }: { type: string }) {
|
||||
const map: Record<string, string> = { blue: 'text-sky-700 bg-sky-50 border-sky-200', red: 'text-rose-700 bg-rose-50 border-rose-200', green: 'text-emerald-700 bg-emerald-50 border-emerald-200' }
|
||||
return <span className={'inline-block px-2 py-0.5 rounded-full text-xs border ' + (map[type] || 'text-slate-500 bg-slate-100 border-slate-200')}>{TYPE_LABEL[type] || type}</span>
|
||||
}
|
||||
|
||||
const selectCls = 'w-full bg-white border border-slate-200 rounded-lg px-3 py-2 text-sm text-slate-800 focus:border-indigo-400 focus:outline-none'
|
||||
|
||||
async function copyText(text: string) {
|
||||
try { await navigator.clipboard.writeText(text); return true }
|
||||
catch {
|
||||
const ta = document.createElement('textarea'); ta.value = text; document.body.appendChild(ta); ta.select()
|
||||
const ok = document.execCommand('copy'); ta.remove(); return ok
|
||||
}
|
||||
}
|
||||
|
||||
function AddModal({ onSaved }: { onSaved: () => void }) {
|
||||
const state = useOverlayState()
|
||||
const [name, setName] = useState('')
|
||||
const [nameEn, setNameEn] = useState('')
|
||||
const [type, setType] = useState('blue')
|
||||
const [backpack, setBackpack] = useState(false)
|
||||
const [alias, setAlias] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [err, setErr] = useState('')
|
||||
|
||||
const add = async () => {
|
||||
if (!name) { setErr('名称不能为空'); return }
|
||||
setBusy(true); setErr('')
|
||||
try {
|
||||
await hd.post('stratagems', { name, name_en: nameEn, type, backpack: backpack ? 1 : 0, alias: alias.split(/[,,、\s]+/).map(s => s.trim()).filter(Boolean), icon: '' })
|
||||
setName(''); setNameEn(''); setAlias(''); setBackpack(false)
|
||||
state.close(); onSaved()
|
||||
} catch (e: any) { setErr(e.message || '新增失败') } finally { setBusy(false) }
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button onPress={state.open}><span className="inline-flex items-center gap-1.5"><Plus className="size-4" /> 新增战备</span></Button>
|
||||
<Modal isOpen={state.isOpen} onOpenChange={state.setOpen}>
|
||||
<Modal.Backdrop><Modal.Container><Modal.Dialog>
|
||||
<Modal.Header><Modal.Heading>新增战备</Modal.Heading></Modal.Header>
|
||||
<Modal.Body>
|
||||
<Form className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<TextField><Label>名称</Label><Input value={name} onChange={e => setName(e.target.value)} placeholder="如 AC-8 机炮" /></TextField>
|
||||
<TextField><Label>英文名</Label><Input value={nameEn} onChange={e => setNameEn(e.target.value)} placeholder="可选" /></TextField>
|
||||
<div className="sm:col-span-2">
|
||||
<Select selectedKey={type} onSelectionChange={(k) => setType(String(k))}>
|
||||
<Label>类型</Label>
|
||||
<Select.Trigger><Select.Value /></Select.Trigger>
|
||||
<Select.Popover>
|
||||
<ListBox>{Object.entries(TYPE_LABEL).map(([k, v]) => <ListBox.Item key={k} id={k}>{v}</ListBox.Item>)}</ListBox>
|
||||
</Select.Popover>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="sm:col-span-2"><TextField><Label>别名(逗号分隔)</Label><Input value={alias} onChange={e => setAlias(e.target.value)} placeholder="逗号分隔" /></TextField></div>
|
||||
<Checkbox isSelected={backpack} onChange={setBackpack}><Checkbox.Content><Checkbox.Control><Checkbox.Indicator /></Checkbox.Control>背包位</Checkbox.Content></Checkbox>
|
||||
{err && <div className="sm:col-span-2 text-sm text-red-500">{err}</div>}
|
||||
</Form>
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
<Button variant="tertiary" onPress={state.close}>取消</Button>
|
||||
<Button isDisabled={busy} onPress={add}>{busy ? '新增中…' : '新增'}</Button>
|
||||
</Modal.Footer>
|
||||
</Modal.Dialog></Modal.Container></Modal.Backdrop>
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
function ImportModal({ onSaved }: { onSaved: () => void }) {
|
||||
const state = useOverlayState()
|
||||
const [text, setText] = useState('')
|
||||
const [err, setErr] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const fileRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const doImport = async (t: string) => {
|
||||
let arr: any
|
||||
try { const d = JSON.parse(t); arr = Array.isArray(d) ? d : (d && d.items) } catch (e: any) { setErr('JSON 解析失败: ' + e.message); return }
|
||||
if (!Array.isArray(arr)) { setErr('需要 JSON 数组或 {items:[...]}'); return }
|
||||
setBusy(true); setErr('')
|
||||
try { await hd.post('import', { items: arr }); setText(''); state.close(); onSaved() }
|
||||
catch (e: any) { setErr('导入失败: ' + e.message) } finally { setBusy(false) }
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button size="sm" onPress={state.open}><span className="inline-flex items-center gap-1.5"><ArrowUpFromLine className="size-4" /> 导入</span></Button>
|
||||
<Modal isOpen={state.isOpen} onOpenChange={state.setOpen}>
|
||||
<Modal.Backdrop><Modal.Container><Modal.Dialog>
|
||||
<Modal.Header><Modal.Heading>导入战备</Modal.Heading></Modal.Header>
|
||||
<Modal.Body>
|
||||
<div className="space-y-3">
|
||||
<label className="text-sm text-slate-600">粘贴 JSON(数组 或 {'{items:[...]}'})</label>
|
||||
<TextArea className={selectCls + ' min-h-[160px] font-mono'} placeholder='[{"name":"AC-8 机炮","type":"blue","alias":["机炮"]}]' value={text} onChange={e => setText(e.target.value)} />
|
||||
{err && <div className="text-sm text-red-500">{err}</div>}
|
||||
</div>
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
<Button size="sm" variant="tertiary" onPress={() => fileRef.current?.click()}>从文件读取</Button>
|
||||
<Button variant="tertiary" onPress={state.close}>取消</Button>
|
||||
<Button isDisabled={busy || !text} onPress={() => doImport(text)}>{busy ? '导入中…' : '导入'}</Button>
|
||||
<input ref={fileRef} type="file" accept=".json" className="hidden" onChange={e => { const f = e.target.files?.[0]; if (f) { const r = new FileReader(); r.onload = () => setText(String(r.result)); r.readAsText(f) } }} />
|
||||
</Modal.Footer>
|
||||
</Modal.Dialog></Modal.Container></Modal.Backdrop>
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
function EditModal({ item, framed, onSaved }: { item: Item; framed: boolean; onSaved: () => void }) {
|
||||
const state = useOverlayState()
|
||||
const [name, setName] = useState(item.name)
|
||||
const [nameEn, setNameEn] = useState(item.name_en || '')
|
||||
const [type, setType] = useState(item.type || 'blue')
|
||||
const [backpack, setBackpack] = useState(!!item.backpack)
|
||||
const [alias, setAlias] = useState((item.alias || []).join(', '))
|
||||
const [icon, setIcon] = useState(item.icon || item.path || '')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [err, setErr] = useState('')
|
||||
|
||||
const save = async () => {
|
||||
setBusy(true); setErr('')
|
||||
try {
|
||||
await hd.put(`stratagems/${item.id}`, { name, name_en: nameEn, type, backpack: backpack ? 1 : 0, alias: alias.split(/[,,、\s]+/).map(s => s.trim()).filter(Boolean), icon })
|
||||
state.close(); onSaved()
|
||||
} catch (e: any) { setErr(e.message || '保存失败') } finally { setBusy(false) }
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button size="sm" onPress={state.open}><span className="inline-flex items-center gap-1"><Pencil className="size-3.5" /> 编辑</span></Button>
|
||||
<Modal isOpen={state.isOpen} onOpenChange={state.setOpen}>
|
||||
<Modal.Backdrop>
|
||||
<Modal.Container>
|
||||
<Modal.Dialog>
|
||||
<Modal.Header><Modal.Heading>编辑战备</Modal.Heading></Modal.Header>
|
||||
<Modal.Body>
|
||||
<Form className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<TextField><Label>名称</Label><Input value={name} onChange={e => setName(e.target.value)} /></TextField>
|
||||
<TextField><Label>英文名</Label><Input value={nameEn} onChange={e => setNameEn(e.target.value)} /></TextField>
|
||||
<div className="sm:col-span-2">
|
||||
<Select selectedKey={type} onSelectionChange={(k) => setType(String(k))}>
|
||||
<Label>类型</Label>
|
||||
<Select.Trigger><Select.Value /></Select.Trigger>
|
||||
<Select.Popover>
|
||||
<ListBox>{Object.entries(TYPE_LABEL).map(([k, v]) => <ListBox.Item key={k} id={k}>{v}</ListBox.Item>)}</ListBox>
|
||||
</Select.Popover>
|
||||
</Select>
|
||||
</div>
|
||||
<Checkbox isSelected={backpack} onChange={setBackpack}><Checkbox.Content><Checkbox.Control><Checkbox.Indicator /></Checkbox.Control>背包位</Checkbox.Content></Checkbox>
|
||||
<div className="sm:col-span-2"><TextField><Label>别名(逗号分隔)</Label><Input value={alias} onChange={e => setAlias(e.target.value)} /></TextField></div>
|
||||
<div className="sm:col-span-2"><TextField><Label>图标路径</Label><Input value={icon} onChange={e => setIcon(e.target.value)} placeholder="img/helldivers/xx.png" /></TextField></div>
|
||||
{err && <div className="sm:col-span-2 text-sm text-red-500">{err}</div>}
|
||||
</Form>
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
<Button variant="tertiary" onPress={state.close}>取消</Button>
|
||||
<Button isDisabled={busy} onPress={save}>{busy ? '保存中…' : '保存'}</Button>
|
||||
</Modal.Footer>
|
||||
</Modal.Dialog>
|
||||
</Modal.Container>
|
||||
</Modal.Backdrop>
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function HelldiversPage() {
|
||||
const [items, setItems] = useState<Item[]>([])
|
||||
const [framed, setFramed] = useState(true)
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(20)
|
||||
const [total, setTotal] = useState(0)
|
||||
const uploadRef = useRef<HTMLInputElement>(null)
|
||||
const uploadTarget = useRef<number | null>(null)
|
||||
const deleteState = useOverlayState()
|
||||
const [deleteTarget, setDeleteTarget] = useState<Item | null>(null)
|
||||
|
||||
const reload = async () => {
|
||||
try {
|
||||
const d = await hd.get(`stratagems?page=${page}&page_size=${pageSize}`)
|
||||
const data = (d && d.items) || []
|
||||
setItems(data)
|
||||
setTotal((d && d.total) || 0)
|
||||
if (data.length === 0 && page > 1) { setPage(page - 1); return }
|
||||
} catch (e: any) { toast('加载失败: ' + e.message, { variant: 'danger' }) }
|
||||
}
|
||||
useEffect(() => { reload() }, [page, pageSize])
|
||||
|
||||
|
||||
const remove = async (it: Item) => {
|
||||
try { await hd.del(`stratagems/${it.id}`); reload() } catch (e: any) { toast('删除失败: ' + e.message, { variant: 'danger' }) }
|
||||
}
|
||||
const confirmDelete = (it: Item) => { setDeleteTarget(it); deleteState.open() }
|
||||
const clickUpload = (it: Item) => { uploadTarget.current = it.id; uploadRef.current?.click() }
|
||||
const onUploadFile = async (e: any) => {
|
||||
const f = e.target.files?.[0]
|
||||
if (!f) return
|
||||
if (!/\.svg$/i.test(f.name)) { toast('请上传 SVG 文件', { variant: 'warning' }); return }
|
||||
const reader = new FileReader()
|
||||
reader.onload = async () => {
|
||||
try { await hd.post(`stratagems/${uploadTarget.current}/icon`, { svg: reader.result, framed }); reload() }
|
||||
catch (e: any) { toast('图标上传失败: ' + e.message, { variant: 'danger' }) }
|
||||
}
|
||||
reader.readAsText(f, 'utf-8')
|
||||
}
|
||||
const exportJson = async () => {
|
||||
try {
|
||||
const data = await hd.get('export')
|
||||
const ok = await copyText(JSON.stringify(data, null, 2))
|
||||
setCopied(ok)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
} catch (e: any) { toast('导出失败: ' + e.message, { variant: 'danger' }) }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-xl font-semibold text-slate-800">战备管理</h2>
|
||||
<div className="flex items-center gap-4">
|
||||
<Checkbox isSelected={framed} onChange={setFramed}><Checkbox.Content><Checkbox.Control><Checkbox.Indicator /></Checkbox.Control>上传 SVG 时叠加 HD2 边框</Checkbox.Content></Checkbox>
|
||||
<AddModal onSaved={reload} />
|
||||
<ImportModal onSaved={reload} />
|
||||
<Button size="sm" onPress={exportJson}><span className="inline-flex items-center gap-1.5"><ArrowDownToLine className="size-4" /> {copied ? '已复制' : '导出'}</span></Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card className="border border-slate-200 bg-white">
|
||||
<Card.Header><Card.Title>战备列表(共 {total} 条)</Card.Title></Card.Header>
|
||||
<Card.Content>
|
||||
<Table>
|
||||
<Table.ScrollContainer>
|
||||
<Table.Content aria-label="战备列表">
|
||||
<Table.Header>
|
||||
<Table.Column>图标</Table.Column>
|
||||
<Table.Column isRowHeader>名称</Table.Column>
|
||||
<Table.Column>类型</Table.Column>
|
||||
<Table.Column>背包</Table.Column>
|
||||
<Table.Column>别名</Table.Column>
|
||||
<Table.Column>操作</Table.Column>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{items.map(it => (
|
||||
<Table.Row key={it.id}>
|
||||
<Table.Cell>{iconUrl(it.icon || it.path) ? <img src={iconUrl(it.icon || it.path)} className="w-9 h-9 object-contain rounded bg-slate-50 border border-slate-100" alt="" /> : <span className="text-slate-400 text-xs">无图</span>}</Table.Cell>
|
||||
<Table.Cell><div className="font-medium text-slate-800">{it.name}</div>{it.name_en ? <div className="text-xs text-slate-400">{it.name_en}</div> : null}</Table.Cell>
|
||||
<Table.Cell><TypeChip type={it.type} /></Table.Cell>
|
||||
<Table.Cell>{it.backpack ? '背包位' : '—'}</Table.Cell>
|
||||
<Table.Cell><div className="flex flex-wrap gap-1 max-w-[220px]">{(it.alias || []).filter(Boolean).map((a, i) => <span key={i} className="text-xs text-slate-500 bg-slate-100 rounded px-1.5 py-0.5">{a}</span>)}</div></Table.Cell>
|
||||
<Table.Cell><div className="flex gap-1 justify-end">
|
||||
<EditModal key={it.id + '-' + it.name} item={it} framed={framed} onSaved={reload} />
|
||||
<Button size="sm" onPress={() => clickUpload(it)}>上传图标</Button>
|
||||
<Button size="sm" variant="tertiary" onPress={() => confirmDelete(it)}><span className="inline-flex items-center gap-1"><TrashBin className="size-3.5" /> 删除</span></Button>
|
||||
</div></Table.Cell>
|
||||
</Table.Row>
|
||||
))}
|
||||
</Table.Body>
|
||||
</Table.Content>
|
||||
</Table.ScrollContainer>
|
||||
</Table>
|
||||
<Pagination className="pt-4">
|
||||
<Pagination.Summary>共 {total} 条 · 第 {page} / {Math.max(1, Math.ceil(total / pageSize))} 页</Pagination.Summary>
|
||||
<Pagination.Content>
|
||||
<Pagination.Item>
|
||||
<Pagination.Previous isDisabled={page <= 1} onPress={() => setPage(page - 1)}>
|
||||
<Pagination.PreviousIcon /><span>上一页</span>
|
||||
</Pagination.Previous>
|
||||
</Pagination.Item>
|
||||
{Array.from({ length: Math.max(1, Math.ceil(total / pageSize)) }, (_, i) => (
|
||||
<Pagination.Item key={i}>
|
||||
<Pagination.Link isActive={page === i + 1} onPress={() => setPage(i + 1)}>{i + 1}</Pagination.Link>
|
||||
</Pagination.Item>
|
||||
))}
|
||||
<Pagination.Item>
|
||||
<Pagination.Next isDisabled={page >= Math.max(1, Math.ceil(total / pageSize))} onPress={() => setPage(page + 1)}>
|
||||
<span>下一页</span><Pagination.NextIcon />
|
||||
</Pagination.Next>
|
||||
</Pagination.Item>
|
||||
</Pagination.Content>
|
||||
</Pagination>
|
||||
</Card.Content>
|
||||
</Card>
|
||||
|
||||
<input ref={uploadRef} type="file" accept=".svg,image/svg+xml" className="hidden" onChange={onUploadFile} />
|
||||
|
||||
<Modal isOpen={deleteState.isOpen} onOpenChange={deleteState.setOpen}>
|
||||
<Modal.Backdrop><Modal.Container><Modal.Dialog>
|
||||
<Modal.Header><Modal.Heading>删除战备</Modal.Heading></Modal.Header>
|
||||
<Modal.Body><p className="text-sm text-slate-600">确认删除「{deleteTarget?.name}」?此操作不可撤销。</p></Modal.Body>
|
||||
<Modal.Footer>
|
||||
<Button variant="tertiary" onPress={deleteState.close}>取消</Button>
|
||||
<Button variant="tertiary" className="text-red-500" onPress={() => { if (deleteTarget) remove(deleteTarget); deleteState.close() }}>删除</Button>
|
||||
</Modal.Footer>
|
||||
</Modal.Dialog></Modal.Container></Modal.Backdrop>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default HelldiversPage
|
||||
@@ -0,0 +1,11 @@
|
||||
import { lazy } from 'react'
|
||||
import type { ComponentType } from 'react'
|
||||
|
||||
// 懒加载:每个插件页单独分包,避免首屏一次性打包全部插件
|
||||
const modules = import.meta.glob('./*/index.tsx') as Record<string, () => Promise<{ default: ComponentType }>>
|
||||
|
||||
export const pluginModules: Record<string, ComponentType> = {}
|
||||
for (const [path, loader] of Object.entries(modules)) {
|
||||
const id = path.split('/')[1]
|
||||
pluginModules[id] = lazy(loader)
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { Button, Card, Checkbox, Input, Label, Select, ListBox, TextArea, TextField, toast } from '@heroui/react'
|
||||
import { api } from '../../api/client'
|
||||
|
||||
const lc = api('learning_chat')
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
const selectCls = 'bg-white border border-slate-200 rounded-lg px-3 py-2 text-sm text-slate-800 focus:border-indigo-400 focus:outline-none'
|
||||
const inputCls = 'w-full bg-white border border-slate-200 rounded-lg px-3 py-2 text-sm text-slate-800 focus:border-indigo-400 focus:outline-none'
|
||||
const thCls = 'px-3 py-2 text-left text-xs font-medium text-slate-400'
|
||||
const tdCls = 'px-3 py-2 text-sm text-slate-700 border-t border-slate-100'
|
||||
|
||||
function fmtU(ts?: number) {
|
||||
if (!ts) return '—'
|
||||
const d = new Date(ts * 1000)
|
||||
return d.toLocaleString('zh-CN', { hour12: false })
|
||||
}
|
||||
|
||||
function PageHead({ title, actions }: { title: string; actions?: ReactNode }) {
|
||||
return <div className="flex items-center justify-between"><h2 className="text-xl font-semibold text-slate-800">{title}</h2>{actions}</div>
|
||||
}
|
||||
|
||||
function Tabs({ tabs, active, onChange }: { tabs: string[]; active: string; onChange: (v: string) => void }) {
|
||||
return <div className="flex gap-1 border-b border-slate-200 mb-4">
|
||||
{tabs.map(t => <button key={t} onClick={() => onChange(t)} className={'px-3 py-2 text-sm border-b-2 -mb-px ' + (active === t ? 'border-indigo-500 text-indigo-700 font-medium' : 'border-transparent text-slate-500 hover:text-slate-700')}>{t}</button>)}
|
||||
</div>
|
||||
}
|
||||
|
||||
function Pager({ page, total, perPage, onPage }: { page: number; total: number; perPage: number; onPage: (p: number) => void }) {
|
||||
const pages = Math.max(1, Math.ceil(total / perPage))
|
||||
return <div className="mt-3 flex items-center gap-2 text-xs text-slate-400">
|
||||
<Button size="sm" variant="tertiary" isDisabled={page <= 1} onPress={() => onPage(page - 1)}>上一页</Button>
|
||||
<span>第 {page} / {pages} 页 · 共 {total} 条</span>
|
||||
<Button size="sm" variant="tertiary" isDisabled={page >= pages} onPress={() => onPage(page + 1)}>下一页</Button>
|
||||
</div>
|
||||
}
|
||||
|
||||
interface GlobalConfig {
|
||||
total_enable: boolean; enable_web: boolean; web_username: string; web_password: string; web_secret_key: string
|
||||
KEYWORDS_SIZE: number; cross_group_threshold: number; learn_max_count: number
|
||||
ban_words: string[]; ban_users: string[]; dictionary: string[]
|
||||
}
|
||||
interface GroupConfig {
|
||||
enable: boolean; answer_threshold: number; answer_threshold_weights: number[]; repeat_threshold: number
|
||||
break_probability: number; speak_enable: boolean; speak_threshold: number; speak_min_interval: number
|
||||
speak_continuously_probability: number; speak_continuously_max_len: number; speak_poke_probability: number
|
||||
ban_words: string[]; ban_users: string[]; member_list: { label: string; value: number }[]
|
||||
}
|
||||
interface GroupOption { label: string; value: number }
|
||||
|
||||
export default function LearningChatPage() {
|
||||
const [tab, setTab] = useState<'config' | 'messages' | 'contexts' | 'answers' | 'blacklist'>('config')
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHead title="群聊学习" actions={<span className="text-sm text-slate-400">学习群友发言 · 复读 · 主动发言</span>} />
|
||||
<Tabs tabs={['配置', '消息', '学习内容', '回复', '禁用列表']} active={{ config: '配置', messages: '消息', contexts: '学习内容', answers: '回复', blacklist: '禁用列表' }[tab]} onChange={v => setTab(({ '配置': 'config', '消息': 'messages', '学习内容': 'contexts', '回复': 'answers', '禁用列表': 'blacklist' } as any)[v])} />
|
||||
{tab === 'config' && <ConfigView />}
|
||||
{tab === 'messages' && <MessagesView />}
|
||||
{tab === 'contexts' && <ContextsView />}
|
||||
{tab === 'answers' && <AnswersView />}
|
||||
{tab === 'blacklist' && <BlacklistView />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
function ConfigView() {
|
||||
const [mode, setMode] = useState<'global' | 'group'>('global')
|
||||
const [globalCfg, setGlobalCfg] = useState<GlobalConfig | null>(null)
|
||||
const [groups, setGroups] = useState<GroupOption[]>([])
|
||||
const [groupId, setGroupId] = useState<number | ''>('')
|
||||
const [groupCfg, setGroupCfg] = useState<GroupConfig | null>(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [err, setErr] = useState('')
|
||||
|
||||
const loadGlobal = useCallback(async () => { try { setGlobalCfg(await data(await lc.get('chat_global_config'))) } catch (e: any) { setErr(e.message) } }, [])
|
||||
const loadGroups = useCallback(async () => { try { const d = await data(await lc.get('get_group_list')); setGroups((d && d.group_list) || []) } catch (e: any) { setErr(e.message) } }, [])
|
||||
useEffect(() => { loadGlobal(); loadGroups() }, [loadGlobal, loadGroups])
|
||||
|
||||
const loadGroup = useCallback(async () => {
|
||||
if (groupId === '') { setGroupCfg(null); return }
|
||||
try { const c = await data(await lc.get(`chat_group_config?group_id=${groupId}`)); c.break_probability = c.break_probability ?? 0; c.speak_continuously_probability = c.speak_continuously_probability ?? 0; c.speak_poke_probability = c.speak_poke_probability ?? 0; setGroupCfg(c) } catch (e: any) { setErr(e.message); setGroupCfg(null) }
|
||||
}, [groupId])
|
||||
useEffect(() => { loadGroup() }, [loadGroup])
|
||||
|
||||
const saveGlobal = async () => {
|
||||
if (!globalCfg) return
|
||||
setBusy(true); setErr('')
|
||||
try { await lc.post('chat_global_config', globalCfg); toast.success('全局配置已保存') } catch (e: any) { toast.danger(e.message || '保存失败') } finally { setBusy(false) }
|
||||
}
|
||||
const saveGroup = async () => {
|
||||
if (!groupCfg || groupId === '') return
|
||||
setBusy(true); setErr('')
|
||||
try { await lc.post(`chat_group_config?group_id=${groupId}`, groupCfg); toast.success('分群配置已保存') } catch (e: any) { toast.danger(e.message || '保存失败') } finally { setBusy(false) }
|
||||
}
|
||||
const saveGroupAll = async () => {
|
||||
if (!groupCfg) return
|
||||
if (!window.confirm('确定要将当前配置保存至所有群吗?')) return
|
||||
setBusy(true); setErr('')
|
||||
try { await lc.post('chat_group_config?group_id=all', groupCfg); toast.success('已保存至所有群') } catch (e: any) { toast.danger(e.message || '保存失败') } finally { setBusy(false) }
|
||||
}
|
||||
|
||||
const setG = (k: string, v: any) => setGlobalCfg(prev => prev ? { ...prev, [k]: v } : prev)
|
||||
const setGrp = (k: string, v: any) => setGroupCfg(prev => prev ? { ...prev, [k]: v } : prev)
|
||||
|
||||
const parseList = (s: string) => s.split(/[\n,,]/).map(x => x.trim()).filter(Boolean)
|
||||
const joinList = (arr: string[]) => (arr || []).join('\n')
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex gap-1">
|
||||
<Button size="sm" variant={mode === 'global' ? 'primary' : 'tertiary'} onPress={() => setMode('global')}>全局配置</Button>
|
||||
<Button size="sm" variant={mode === 'group' ? 'primary' : 'tertiary'} onPress={() => setMode('group')}>分群配置</Button>
|
||||
</div>
|
||||
{err && <p className="text-xs text-red-500">{err}</p>}
|
||||
|
||||
{mode === 'global' && globalCfg && (
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<Card className="border border-slate-200 bg-white">
|
||||
<Card.Header><Card.Title>基本设置</Card.Title></Card.Header>
|
||||
<Card.Content className="space-y-3">
|
||||
<Checkbox isSelected={!!globalCfg.total_enable} onChange={v => setG('total_enable', v)}><Checkbox.Content><Checkbox.Control><Checkbox.Indicator /></Checkbox.Control>群聊学习总开关</Checkbox.Content></Checkbox>
|
||||
<Checkbox isSelected={!!globalCfg.enable_web} onChange={v => setG('enable_web', v)}><Checkbox.Content><Checkbox.Control><Checkbox.Indicator /></Checkbox.Control>后台管理总开关</Checkbox.Content></Checkbox>
|
||||
<TextField><Label>后台管理用户名</Label><Input value={globalCfg.web_username || ''} onChange={e => setG('web_username', e.target.value)} /></TextField>
|
||||
<TextField><Label>后台管理密码</Label><Input type="password" value={globalCfg.web_password || ''} onChange={e => setG('web_password', e.target.value)} /></TextField>
|
||||
<TextField><Label>后台管理 Token 密钥</Label><Input value={globalCfg.web_secret_key || ''} onChange={e => setG('web_secret_key', e.target.value)} /></TextField>
|
||||
</Card.Content>
|
||||
</Card>
|
||||
<Card className="border border-slate-200 bg-white">
|
||||
<Card.Header><Card.Title>学习参数</Card.Title></Card.Header>
|
||||
<Card.Content className="space-y-3">
|
||||
<TextField><Label>单句关键词数量</Label><Input type="number" value={globalCfg.KEYWORDS_SIZE ?? ''} onChange={e => setG('KEYWORDS_SIZE', Number(e.target.value))} /></TextField>
|
||||
<TextField><Label>跨群回复阈值</Label><Input type="number" value={globalCfg.cross_group_threshold ?? ''} onChange={e => setG('cross_group_threshold', Number(e.target.value))} /></TextField>
|
||||
<TextField><Label>最高学习次数</Label><Input type="number" value={globalCfg.learn_max_count ?? ''} onChange={e => setG('learn_max_count', Number(e.target.value))} /></TextField>
|
||||
<TextField><Label>全局屏蔽词(每行一个)</Label><TextArea rows={3} value={joinList(globalCfg.ban_words)} onChange={e => setG('ban_words', parseList(e.target.value))} /></TextField>
|
||||
<TextField><Label>全局屏蔽用户(每行一个)</Label><TextArea rows={3} value={joinList(globalCfg.ban_users)} onChange={e => setG('ban_users', parseList(e.target.value))} /></TextField>
|
||||
<TextField><Label>自定义词典(每行一个)</Label><TextArea rows={3} value={joinList(globalCfg.dictionary)} onChange={e => setG('dictionary', parseList(e.target.value))} /></TextField>
|
||||
</Card.Content>
|
||||
</Card>
|
||||
<div className="lg:col-span-2"><Button isDisabled={busy} onPress={saveGlobal}>{busy ? '保存中…' : '保存全局配置'}</Button></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mode === 'group' && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm text-slate-500">选择群:</span>
|
||||
<Select selectedKey={groupId === '' ? '' : String(groupId)} onSelectionChange={k => setGroupId(k ? Number(k) : '')} aria-label="选择群">
|
||||
<Select.Trigger className={selectCls}><Select.Value /></Select.Trigger>
|
||||
<Select.Popover><ListBox>{groups.map(g => <ListBox.Item key={g.value} id={String(g.value)}>{g.label}</ListBox.Item>)}</ListBox></Select.Popover>
|
||||
</Select>
|
||||
</div>
|
||||
{groupCfg && (
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<Card className="border border-slate-200 bg-white">
|
||||
<Card.Header><Card.Title>基本设置</Card.Title></Card.Header>
|
||||
<Card.Content className="space-y-3">
|
||||
<Checkbox isSelected={!!groupCfg.enable} onChange={v => setGrp('enable', v)}><Checkbox.Content><Checkbox.Control><Checkbox.Indicator /></Checkbox.Control>群聊学习开关</Checkbox.Content></Checkbox>
|
||||
<TextField><Label>回复阈值</Label><Input type="number" value={groupCfg.answer_threshold ?? ''} onChange={e => setGrp('answer_threshold', Number(e.target.value))} /></TextField>
|
||||
<TextField><Label>复读阈值</Label><Input type="number" value={groupCfg.repeat_threshold ?? ''} onChange={e => setGrp('repeat_threshold', Number(e.target.value))} /></TextField>
|
||||
<TextField><Label>打断复读概率(%)</Label><Input type="number" value={groupCfg.break_probability ?? ''} onChange={e => setGrp('break_probability', Number(e.target.value))} /></TextField>
|
||||
</Card.Content>
|
||||
</Card>
|
||||
<Card className="border border-slate-200 bg-white">
|
||||
<Card.Header><Card.Title>主动发言设置</Card.Title></Card.Header>
|
||||
<Card.Content className="space-y-3">
|
||||
<Checkbox isSelected={!!groupCfg.speak_enable} onChange={v => setGrp('speak_enable', v)}><Checkbox.Content><Checkbox.Control><Checkbox.Indicator /></Checkbox.Control>主动发言开关</Checkbox.Content></Checkbox>
|
||||
<TextField><Label>主动发言阈值</Label><Input type="number" value={groupCfg.speak_threshold ?? ''} onChange={e => setGrp('speak_threshold', Number(e.target.value))} /></TextField>
|
||||
<TextField><Label>主动发言最小间隔(秒)</Label><Input type="number" value={groupCfg.speak_min_interval ?? ''} onChange={e => setGrp('speak_min_interval', Number(e.target.value))} /></TextField>
|
||||
<TextField><Label>连续主动发言概率(%)</Label><Input type="number" value={groupCfg.speak_continuously_probability ?? ''} onChange={e => setGrp('speak_continuously_probability', Number(e.target.value))} /></TextField>
|
||||
<TextField><Label>最大连续发言句数</Label><Input type="number" value={groupCfg.speak_continuously_max_len ?? ''} onChange={e => setGrp('speak_continuously_max_len', Number(e.target.value))} /></TextField>
|
||||
<TextField><Label>戳一戳概率(%)</Label><Input type="number" value={groupCfg.speak_poke_probability ?? ''} onChange={e => setGrp('speak_poke_probability', Number(e.target.value))} /></TextField>
|
||||
</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 className="space-y-2">
|
||||
{(groupCfg.answer_threshold_weights || []).map((w, i) => (
|
||||
<div key={i} className="flex items-center gap-3">
|
||||
<Input type="number" className="max-w-[180px]" value={w} onChange={e => { const arr = [...(groupCfg.answer_threshold_weights || [])]; arr[i] = Number(e.target.value); setGrp('answer_threshold_weights', arr) }} />
|
||||
<Button size="sm" variant="tertiary" className="text-red-500" onPress={() => { const arr = [...(groupCfg.answer_threshold_weights || [])]; arr.splice(i, 1); setGrp('answer_threshold_weights', arr) }}>删除</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button size="sm" variant="tertiary" onPress={() => setGrp('answer_threshold_weights', [...(groupCfg.answer_threshold_weights || []), 25])}>+ 添加权重</Button>
|
||||
</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 className="space-y-3">
|
||||
<TextField><Label>屏蔽词(每行一个)</Label><TextArea rows={3} value={joinList(groupCfg.ban_words)} onChange={e => setGrp('ban_words', parseList(e.target.value))} /></TextField>
|
||||
<TextField><Label>屏蔽用户(每行一个)</Label><TextArea rows={3} value={joinList(groupCfg.ban_users)} onChange={e => setGrp('ban_users', parseList(e.target.value))} /></TextField>
|
||||
</Card.Content>
|
||||
</Card>
|
||||
<div className="flex gap-2 lg:col-span-2">
|
||||
<Button isDisabled={busy} onPress={saveGroup}>{busy ? '保存中…' : '保存该群'}</Button>
|
||||
<Button variant="tertiary" isDisabled={busy} onPress={saveGroupAll}>保存至所有群</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!groupCfg && groupId !== '' && <p className="text-sm text-slate-400">加载分群配置…</p>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
interface Msg { id: number; message_id: string | number; group_id: number; user_id: number; raw_message: string; message?: string; time: number }
|
||||
function MessagesView() {
|
||||
const [rows, setRows] = useState<Msg[]>([]); const [total, setTotal] = useState(0); const [page, setPage] = useState(1)
|
||||
const [g, setG] = useState(''); const [u, setU] = useState(''); const [m, setM] = useState(''); const [loading, setLoading] = useState(false)
|
||||
const perPage = 10
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try { const d = await data(await lc.get(`get_chat_messages?page=${page}&perPage=${perPage}&group_id=${g}&user_id=${u}&message=${m}`)); setRows((d && d.items) || []); setTotal((d && d.total) || 0) } catch (e: any) { toast.danger(e.message) } finally { setLoading(false) }
|
||||
}, [page, g, u, m])
|
||||
useEffect(() => { load() }, [load])
|
||||
const del = async (id: number) => { if (!window.confirm('确定删除?')) return; try { await lc.del(`delete_chat?type=message&id=${id}`); load() } catch (e: any) { toast.danger(e.message) } }
|
||||
const ban = async (id: number) => { try { await lc.put(`ban_chat?type=message&id=${id}`, {}); load() } catch (e: any) { toast.danger(e.message) } }
|
||||
const delAll = async () => { if (!window.confirm('确定删除所有消息?不可逆!')) return; try { await lc.put('delete_all?type=message', {}); load() } catch (e: any) { toast.danger(e.message) } }
|
||||
return (
|
||||
<Card className="border border-slate-200 bg-white">
|
||||
<Card.Header><Card.Title>消息记录</Card.Title></Card.Header>
|
||||
<Card.Content className="space-y-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Input className="w-36" placeholder="群ID" value={g} onChange={e => setG(e.target.value)} />
|
||||
<Input className="w-36" placeholder="用户ID" value={u} onChange={e => setU(e.target.value)} />
|
||||
<Input className="flex-1 min-w-[200px]" placeholder="消息内容" value={m} onChange={e => setM(e.target.value)} />
|
||||
<Button size="sm" onPress={() => { setPage(1); load() }}>搜索</Button>
|
||||
<Button size="sm" variant="tertiary" className="text-red-500" onPress={delAll}>删除所有</Button>
|
||||
</div>
|
||||
<table className="w-full">
|
||||
<thead><tr><th className={thCls}>消息ID</th><th className={thCls}>群ID</th><th className={thCls}>用户ID</th><th className={thCls}>消息</th><th className={thCls}>时间</th><th className={thCls}></th></tr></thead>
|
||||
<tbody>
|
||||
{rows.map(r => <tr key={r.id}><td className={tdCls}>{r.message_id}</td><td className={tdCls}>{r.group_id}</td><td className={tdCls}>{r.user_id}</td><td className={tdCls + ' max-w-[320px] truncate'}>{r.raw_message || r.message || '—'}</td><td className={tdCls}>{fmtU(r.time)}</td><td className={tdCls + ' text-right'}>
|
||||
<Button size="sm" variant="tertiary" className="text-amber-600" onPress={() => ban(r.id)}>禁用</Button>
|
||||
<Button size="sm" variant="tertiary" className="text-red-500" onPress={() => del(r.id)}>删除</Button>
|
||||
</td></tr>)}
|
||||
{!rows.length && <tr><td colSpan={6} className={tdCls + ' text-center text-slate-400'}>{loading ? '加载中…' : '暂无消息数据'}</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
<Pager page={page} total={total} perPage={perPage} onPage={setPage} />
|
||||
</Card.Content>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
interface Ctx { id: number; keywords: string; time: number; count: number }
|
||||
function ContextsView() {
|
||||
const [rows, setRows] = useState<Ctx[]>([]); const [total, setTotal] = useState(0); const [page, setPage] = useState(1)
|
||||
const [kw, setKw] = useState(''); const [loading, setLoading] = useState(false); const perPage = 10
|
||||
const load = useCallback(async () => { setLoading(true); try { const d = await data(await lc.get(`get_chat_contexts?page=${page}&perPage=${perPage}&orderBy=time&orderDir=desc&keywords=${kw}`)); setRows((d && d.items) || []); setTotal((d && d.total) || 0) } catch (e: any) { toast.danger(e.message) } finally { setLoading(false) } }, [page, kw])
|
||||
useEffect(() => { load() }, [load])
|
||||
const del = async (id: number) => { if (!window.confirm('确定删除该学习内容?')) return; try { await lc.del(`delete_chat?type=context&id=${id}`); load() } catch (e: any) { toast.danger(e.message) } }
|
||||
const ban = async (id: number) => { try { await lc.put(`ban_chat?type=context&id=${id}`, {}); load() } catch (e: any) { toast.danger(e.message) } }
|
||||
const delAll = async () => { if (!window.confirm('确定删除所有学习内容?不可逆!')) return; try { await lc.put('delete_all?type=context', {}); load() } catch (e: any) { toast.danger(e.message) } }
|
||||
return (
|
||||
<Card className="border border-slate-200 bg-white">
|
||||
<Card.Header><Card.Title>学习内容</Card.Title></Card.Header>
|
||||
<Card.Content className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Input className="flex-1 min-w-[200px]" placeholder="搜索关键词" value={kw} onChange={e => setKw(e.target.value)} />
|
||||
<Button size="sm" onPress={() => { setPage(1); load() }}>搜索</Button>
|
||||
<Button size="sm" variant="tertiary" className="text-red-500" onPress={delAll}>删除所有</Button>
|
||||
</div>
|
||||
<table className="w-full">
|
||||
<thead><tr><th className={thCls}>ID</th><th className={thCls}>内容/关键词</th><th className={thCls}>最后学习时间</th><th className={thCls}>已学次数</th><th className={thCls}></th></tr></thead>
|
||||
<tbody>
|
||||
{rows.map(r => <tr key={r.id}><td className={tdCls}>{r.id}</td><td className={tdCls + ' max-w-[320px] truncate'}>{r.keywords}</td><td className={tdCls}>{fmtU(r.time)}</td><td className={tdCls}>{r.count}</td><td className={tdCls + ' text-right'}>
|
||||
<Button size="sm" variant="tertiary" className="text-amber-600" onPress={() => ban(r.id)}>禁用</Button>
|
||||
<Button size="sm" variant="tertiary" className="text-red-500" onPress={() => del(r.id)}>删除</Button>
|
||||
</td></tr>)}
|
||||
{!rows.length && <tr><td colSpan={5} className={tdCls + ' text-center text-slate-400'}>{loading ? '加载中…' : '暂无学习内容'}</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
<Pager page={page} total={total} perPage={perPage} onPage={setPage} />
|
||||
</Card.Content>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
interface Ans { id: number; group_id: number; keywords: string; time: number; count: number; context_id: number; messages: { msg: string }[] | null }
|
||||
function AnswersView() {
|
||||
const [rows, setRows] = useState<Ans[]>([]); const [total, setTotal] = useState(0); const [page, setPage] = useState(1)
|
||||
const [kw, setKw] = useState(''); const [loading, setLoading] = useState(false); const perPage = 10
|
||||
const load = useCallback(async () => { setLoading(true); try { const d = await data(await lc.get(`get_chat_answers?page=${page}&perPage=${perPage}&orderBy=count&orderDir=desc&keywords=${kw}`)); setRows((d && d.items) || []); setTotal((d && d.total) || 0) } catch (e: any) { toast.danger(e.message) } finally { setLoading(false) } }, [page, kw])
|
||||
useEffect(() => { load() }, [load])
|
||||
const del = async (id: number) => { if (!window.confirm('确定删除这条回复?')) return; try { await lc.del(`delete_chat?type=answer&id=${id}`); load() } catch (e: any) { toast.danger(e.message) } }
|
||||
const ban = async (id: number) => { try { await lc.put(`ban_chat?type=answer&id=${id}`, {}); load() } catch (e: any) { toast.danger(e.message) } }
|
||||
const delAll = async () => { if (!window.confirm('确定删除所有回复?不可逆!')) return; try { await lc.put('delete_all?type=answer', {}); load() } catch (e: any) { toast.danger(e.message) } }
|
||||
return (
|
||||
<Card className="border border-slate-200 bg-white">
|
||||
<Card.Header><Card.Title>学习到的回复</Card.Title></Card.Header>
|
||||
<Card.Content className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Input className="flex-1 min-w-[200px]" placeholder="搜索关键词" value={kw} onChange={e => setKw(e.target.value)} />
|
||||
<Button size="sm" onPress={() => { setPage(1); load() }}>搜索</Button>
|
||||
<Button size="sm" variant="tertiary" className="text-red-500" onPress={delAll}>删除所有</Button>
|
||||
</div>
|
||||
<table className="w-full">
|
||||
<thead><tr><th className={thCls}>ID</th><th className={thCls}>内容/关键词</th><th className={thCls}>群ID</th><th className={thCls}>次数</th><th className={thCls}>消息预览</th><th className={thCls}></th></tr></thead>
|
||||
<tbody>
|
||||
{rows.map(r => <tr key={r.id}><td className={tdCls}>{r.id}</td><td className={tdCls + ' max-w-[220px] truncate'}>{r.keywords}</td><td className={tdCls}>{r.group_id}</td><td className={tdCls}>{r.count}</td><td className={tdCls + ' max-w-[260px]'}>{((r.messages || []).map(m => m.msg).join(';') || '—').slice(0, 120)}</td><td className={tdCls + ' text-right'}>
|
||||
<Button size="sm" variant="tertiary" className="text-amber-600" onPress={() => ban(r.id)}>禁用</Button>
|
||||
<Button size="sm" variant="tertiary" className="text-red-500" onPress={() => del(r.id)}>删除</Button>
|
||||
</td></tr>)}
|
||||
{!rows.length && <tr><td colSpan={6} className={tdCls + ' text-center text-slate-400'}>{loading ? '加载中…' : '暂无回复数据'}</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
<Pager page={page} total={total} perPage={perPage} onPage={setPage} />
|
||||
</Card.Content>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
interface Bl { id: number; keywords: string; bans: string; global_ban?: boolean; ban_group_id?: number[] }
|
||||
function BlacklistView() {
|
||||
const [rows, setRows] = useState<Bl[]>([]); const [total, setTotal] = useState(0); const [page, setPage] = useState(1)
|
||||
const [kw, setKw] = useState(''); const [bans, setBans] = useState(''); const [loading, setLoading] = useState(false); const perPage = 10
|
||||
const load = useCallback(async () => { setLoading(true); try { const d = await data(await lc.get(`get_chat_blacklist?page=${page}&perPage=${perPage}&keywords=${kw}&bans=${bans}`)); setRows((d && d.items) || []); setTotal((d && d.total) || 0) } catch (e: any) { toast.danger(e.message) } finally { setLoading(false) } }, [page, kw, bans])
|
||||
useEffect(() => { load() }, [load])
|
||||
const unban = async (id: number) => { if (!window.confirm('确定取消禁用?')) return; try { await lc.del(`delete_chat?type=blacklist&id=${id}`); load() } catch (e: any) { toast.danger(e.message) } }
|
||||
const clearAll = async () => { if (!window.confirm('确定取消所有禁用?')) return; try { await lc.put('delete_all?type=blacklist', {}); load() } catch (e: any) { toast.danger(e.message) } }
|
||||
return (
|
||||
<Card className="border border-slate-200 bg-white">
|
||||
<Card.Header><Card.Title>禁用列表</Card.Title></Card.Header>
|
||||
<Card.Content className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Input className="w-40" placeholder="搜索关键词" value={kw} onChange={e => setKw(e.target.value)} />
|
||||
<Input className="w-40" placeholder="搜索禁用群" value={bans} onChange={e => setBans(e.target.value)} />
|
||||
<Button size="sm" onPress={() => { setPage(1); load() }}>搜索</Button>
|
||||
<Button size="sm" variant="tertiary" onPress={clearAll}>取消所有禁用</Button>
|
||||
</div>
|
||||
<table className="w-full">
|
||||
<thead><tr><th className={thCls}>内容/关键词</th><th className={thCls}>已禁用的群</th><th className={thCls}></th></tr></thead>
|
||||
<tbody>
|
||||
{rows.map(r => <tr key={r.id}><td className={tdCls + ' max-w-[320px] truncate'}>{r.keywords}</td><td className={tdCls}>{r.global_ban ? '全局禁用' : (r.bans || '—')}</td><td className={tdCls + ' text-right'}>
|
||||
<Button size="sm" variant="tertiary" className="text-emerald-600" onPress={() => unban(r.id)}>取消禁用</Button>
|
||||
</td></tr>)}
|
||||
{!rows.length && <tr><td colSpan={3} className={tdCls + ' text-center text-slate-400'}>{loading ? '加载中…' : '暂无禁用项目'}</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
<Pager page={page} total={total} perPage={perPage} onPage={setPage} />
|
||||
</Card.Content>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Suspense } from 'react'
|
||||
import { Navigate, Route, Routes } from 'react-router-dom'
|
||||
import { Toast } from '@heroui/react'
|
||||
import { AuthProvider } from './auth/auth'
|
||||
import { LoginPage } from './auth/LoginPage'
|
||||
import { AppLayout } from './layout/AppLayout'
|
||||
import { pluginModules } from './plugins'
|
||||
import HomePage from './pages/home'
|
||||
import PluginsPage from './pages/plugins'
|
||||
import SettingsPage from './pages/settings'
|
||||
|
||||
export function AppRoutes() {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<Toast.Provider />
|
||||
<Suspense fallback={<div className="p-12 text-slate-400">加载中…</div>}>
|
||||
<Routes>
|
||||
<Route path="/" element={<Navigate to="/hub" replace />} />
|
||||
<Route path="/hub/login" element={<LoginPage />} />
|
||||
<Route path="/hub" element={<AppLayout />}>
|
||||
<Route index element={<HomePage />} />
|
||||
<Route path="plugins" element={<PluginsPage />} />
|
||||
<Route path="settings" element={<SettingsPage />} />
|
||||
{Object.entries(pluginModules).map(([id, Comp]) => (
|
||||
<Route key={id} path={`${id}/*`} element={<Comp />} />
|
||||
))}
|
||||
<Route path="*" element={<div className="text-neutral-400">插件未接入或不存在</div>} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</AuthProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export default AppRoutes
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user