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:
2026-09-01 13:13:40 +08:00
co-authored by Claude
parent 1783c60afa
commit b61d09f09f
3201 changed files with 160436 additions and 171 deletions
+12
View File
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>HeXi · 统一管理台</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+2388
View File
File diff suppressed because it is too large Load Diff
+28
View File
@@ -0,0 +1,28 @@
{
"name": "hexi-web-hub",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"@gravity-ui/icons": "^2.22.0",
"@heroui/react": "^3.2.4",
"@heroui/styles": "^3.2.4",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react-router-dom": "^7.18.3"
},
"devDependencies": {
"@tailwindcss/vite": "^4.3.3",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.5",
"@vitejs/plugin-react": "^6.1.1",
"tailwindcss": "^4.3.3",
"typescript": "^5.0.0",
"vite": "^8.2.2"
}
}
+1
View File
@@ -0,0 +1 @@
export { default } from './router'
+67
View File
@@ -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' }),
})
+37
View File
@@ -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>
)
}
+30
View File
@@ -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)
+3
View File
@@ -0,0 +1,3 @@
@import "tailwindcss";
@import "@heroui/styles";
body { margin: 0; }
+118
View File
@@ -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>
)
}
+13
View File
@@ -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>,
)
+270
View File
@@ -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>
)
}
+305
View File
@@ -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>
)
}
+96
View File
@@ -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>
)
}
+355
View File
@@ -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>
)
}
+322
View File
@@ -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
+11
View File
@@ -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>
)
}
+35
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+22
View File
@@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}
+10
View File
@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
{"root":["./src/app.tsx","./src/main.tsx","./src/router.tsx","./src/vite-env.d.ts","./src/api/client.ts","./src/auth/loginpage.tsx","./src/auth/auth.tsx","./src/layout/applayout.tsx","./src/pages/home/index.tsx","./src/pages/plugins/index.tsx","./src/pages/settings/index.tsx","./src/plugins/index.ts","./src/plugins/galgame_card/index.tsx","./src/plugins/helldivers/index.tsx","./src/plugins/learning_chat/index.tsx"],"version":"5.9.3"}
+2
View File
@@ -0,0 +1,2 @@
declare const _default: import("vite").UserConfig;
export default _default;
+11
View File
@@ -0,0 +1,11 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import tailwindcss from '@tailwindcss/vite';
export default defineConfig({
base: '/hub/',
plugins: [react(), tailwindcss()],
build: {
outDir: 'dist',
emptyOutDir: true,
},
});
+12
View File
@@ -0,0 +1,12 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
base: '/hub/',
plugins: [react(), tailwindcss()],
build: {
outDir: 'dist',
emptyOutDir: true,
},
})