feat: revamp web hub — global connection status, array config editor, log streaming

- Global connection badge in app header (persistent dashboard SSE via ConnectionProvider)
- LogProvider keeps log data and SSE stream alive across page switches (no reload spinner)
- Config drawer: password reveal toggle, array-type row editor for list fields
- Plugin management: optimistic updates, race-free loading, reset all group overrides
- Logs: load-earlier pagination; backend tail-read optimization (before/end offsets)
- web_auth: Web password changes persist (sync_admin seeding only when .env has explicit key)
- Dashboard: collect in thread pool + 1s result cache
- ErrorBoundary + local Suspense: plugin chunk loading stays inside content area
- UI polish: table row hover, mobile header/breadcrumb fixes, wider config drawer
- config_standard: array type inference for list fields; array env skip
- random_jm_code / steam_info: array schema migration; README web frontend note

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
2026-09-03 15:39:10 +08:00
co-authored by Claude Code
parent 9371a28e35
commit c53b210413
45 changed files with 1312 additions and 576 deletions
+4 -4
View File
@@ -92,7 +92,7 @@ HeXi/
│ ├── data/ # 运行数据(不入库)
│ ├── config/ # 业务配置(不入库)
│ ├── resource/ # 全局静态资源(fonts/ 字体、menu_config 等)
│ ├── web/ # 统一 Web 前端(Vue 3)
│ ├── web/ # 统一 Web 前端(React + Vite,构建产物 dist/ 供 /hub 挂载)
│ ├── logs/ # 日志(不入库)
│ └── web_*.py # Web 管理相关(鉴权、插件注册)
├── docs/ # 插件开发标准文档
@@ -118,9 +118,9 @@ poetry run pyright # 类型检查
新增插件请参考:
- [插件结构标准](docs/plugin-structure-standard.md)
- [插件配置标准](docs/plugin-config-standard.md)
- `plugin_template/` 脚手架模板
- [插件结构标准](dev/docs/plugin-structure-standard.md)
- [插件配置标准](dev/docs/plugin-config-standard.md)
- `dev/plugin_template/` 脚手架模板
## 许可
View File
View File
View File
View File
+16 -9
View File
@@ -63,10 +63,7 @@ def _type_label(annotation: Any) -> str:
if origin is Literal:
return "enum"
if origin in (list, set, tuple):
args = get_args(annotation)
if args and args[0] is str:
return "text"
return "text"
return "array"
return "string"
@@ -119,11 +116,11 @@ def _coerce_value(value: Any, typ: str, item_type: str = "str") -> Any:
return float(value)
except (TypeError, ValueError):
return None
if typ == "text":
if typ in {"text", "array"}:
if isinstance(value, (list, tuple, set)):
items = list(value)
else:
# TextArea 多行/逗号分隔都接受
# 数组字段的字符串输入:多行/逗号分隔都接受
import re as _re
parts = _re.split(r"[,\n]+", str(value))
items = [p.strip() for p in parts if p.strip()]
@@ -168,6 +165,16 @@ def _coerce_value(value: Any, typ: str, item_type: str = "str") -> Any:
def _stringify(value: Any, typ: str) -> Any:
"""把 python 值转成 Web 表单能显示的值。"""
if typ == "array":
# 数组字段直接回传数组,前端用行编辑器(增删行)渲染;
# 兼容旧数据:换行/逗号分隔的字符串按行拆成数组
if value is None:
return []
if isinstance(value, (list, tuple, set)):
return [str(v) for v in value]
import re as _re
parts = _re.split(r"[,\n]+", str(value))
return [p.strip() for p in parts if p.strip()]
if typ == "text":
if value is None:
return ""
@@ -240,7 +247,7 @@ def register_model_config(
}
if typ == "enum":
field["options"] = (options or {}).get(key) or _enum_options(annotation)
if typ == "text":
if typ in {"text", "array"}:
field["item_type"] = _text_item_type(annotation)
schema_fields.append(field)
@@ -353,7 +360,7 @@ def register_config_items(
}
if item.get("options"):
field["options"] = item["options"]
if item.get("type") == "text":
if item.get("type") in {"text", "array"}:
field["item_type"] = item.get("item_type", "str")
if item.get("type") == "object_set" and item.get("item_schema"):
field["item_schema"] = item["item_schema"]
@@ -435,7 +442,7 @@ def register_object_set(
"description": sf.get("description", ""),
"secret": sf.get("secret", _is_secret(sf["key"])),
}
if sf.get("type") == "text":
if sf.get("type") in {"text", "array"}:
field["item_type"] = sf.get("item_type", "str")
opts = (options_by_key or {}).get(sf["key"]) or sf.get("options")
if opts:
@@ -24,6 +24,16 @@ BLOCK_CODE = ["350235", "350234"]
WHITE_LIST = ["1019165018"]
def _jm_codes_getter() -> list[str]:
from .services.store import sync_get_jm_codes
return sync_get_jm_codes()
def _jm_codes_setter(codes) -> None:
from .services.store import sync_set_jm_codes
sync_set_jm_codes(codes)
def register_config() -> None:
register_config_items(
_PLUGIN_ID,
@@ -31,24 +41,35 @@ def register_config() -> None:
{
"key": "TEXT_TEMPLATES",
"label": "随机文案模板",
"type": "text",
"type": "array",
"item_type": "str",
"default": TEXT_TEMPLATES,
},
{
"key": "BLOCK_CODE",
"label": "屏蔽码",
"type": "text",
"type": "array",
"item_type": "str",
"default": BLOCK_CODE,
},
{
"key": "WHITE_LIST",
"label": "白名单群号",
"type": "text",
"type": "array",
"item_type": "str",
"default": WHITE_LIST,
},
{
"key": "JM_CODES",
"label": "JM 码列表",
"type": "array",
"item_type": "str",
"default": [],
"description": "一行一个(或逗号分隔)。保存会写回 hexi/data/random_jm_code/jm_code.json,机器人随改随用。",
"getter": _jm_codes_getter,
"setter": _jm_codes_setter,
"nosave": True,
},
],
store=_sys.modules[__name__],
)
@@ -136,7 +136,7 @@ register_model_config(
"proxy",
],
types={
"steam_api_key": "text",
"steam_api_key": "array",
"steam_broadcast_type": "enum",
"steam_playtime_theme": "enum",
},
+86 -16
View File
@@ -54,8 +54,13 @@ basic_path = Path(__file__).resolve().parent
# hexi/web 是统一 Web 管理台前端(hexi/web/dist),不是插件目录下的 web
WEB_DIST = Path(__file__).resolve().parents[2] / "web" / "dist"
# 管理台自身版本号(展示在设置页「关于」处)
HUB_VERSION = "0.1.0"
def _hub_version() -> str:
"""管理台版本号:以 hexi/web/package.json 为唯一来源(前端构建也读它)。"""
try:
pkg = WEB_DIST.parent / "package.json"
return str(json.loads(pkg.read_text(encoding="utf-8")).get("version", "0.1.0"))
except Exception: # noqa: BLE001
return "0.1.0"
# 仓库根目录(bot.py 所在)与 bot 端口
_REPO_ROOT = Path(__file__).resolve().parents[3]
@@ -118,6 +123,65 @@ def _archive_log() -> None:
logger.warning(f"归档日志失败: {type(e).__name__}: {e}")
def _tail_log_lines(
path: Path, limit: int, before: int | None
) -> tuple[list[str], int, int]:
"""从日志文件读取最近 limit 行(或 before 字节之前最近的 limit 行)。
返回 (lines, window_start_offset, file_size)。window_start_offset 可作为
下一批更早日志的 before;为 0 表示已到文件头。仅按 8KB 步进向后寻找行边界,
避免大日志全量 read_bytes。逐行 UTF-8 优先、GBK 兜底:兼容旧 GBK 日志与
切换为 UTF-8 后的新日志混存(保留 ANSI 颜色码,交给前端渲染颜色)。
"""
size = path.stat().st_size
end = size if before is None or before <= 0 or before > size else int(before)
if end <= 0:
return [], 0, size
start = 0
with open(path, "rb") as f:
pos = end
newline_count = 0
while pos > 0 and newline_count <= limit:
step = min(pos, 8192)
f.seek(pos - step)
chunk = f.read(step)
idx = chunk.rfind(b"\n")
if idx == -1:
pos -= step
continue
nl_abs = pos - step + idx
newline_count += 1
if newline_count > limit:
start = nl_abs + 1
break
pos = nl_abs
f.seek(start)
raw = f.read(end - start)
raw_lines = raw.split(b"\n")
if raw_lines and raw_lines[-1] == b"":
raw_lines.pop()
lines = []
for raw_line in raw_lines:
try:
line = raw_line.decode("utf-8")
except UnicodeDecodeError:
line = raw_line.decode("gbk", errors="replace")
lines.append(line.rstrip("\r"))
return lines, start, size
def _count_log_lines(path: Path) -> int:
"""分块统计全文件行数(仅换行计数,不全量载入)。"""
count = 0
with open(path, "rb") as f:
while True:
chunk = f.read(1 << 20)
if not chunk:
break
count += chunk.count(b"\n")
return count
def _schedule_bot_action(action: str) -> None:
"""1 秒后执行:stop 直接退出;restart 先拉起新进程再退出旧进程。"""
@@ -205,7 +269,7 @@ def build_hub_app() -> FastAPI:
"created_at": user.get("created_at", ""),
},
"plugins": list_web_plugins(),
"version": HUB_VERSION,
"version": _hub_version(),
}
@app.get("/api/dashboard")
@@ -360,9 +424,15 @@ def build_hub_app() -> FastAPI:
@app.get("/api/logs")
async def logs(
limit: int = 500,
before: int | None = None,
_: dict = Depends(get_current_user),
):
"""实时读取 bot 运行日志(优先 _bot_run.log),返回最近 limit 行,供前端轮询。"""
"""读取 bot 运行日志(优先 _bot_run.log)。
- before 不传:返回最近 limit 行,offset/end 为文件尾(SSE 以 end 续读)
- before 传字节偏移:返回该偏移之前最近 limit 行,offset 为这些行的起点,
继续把 offset 传给 before 即可向前翻页;offset==0 表示已到文件头。
"""
root = Path(__file__).resolve().parents[3] # 仓库根目录
candidates = [
root / "_bot_run.log",
@@ -372,20 +442,20 @@ def build_hub_app() -> FastAPI:
if path is None:
return {"ok": False, "msg": "未找到日志文件", "lines": [], "size": 0, "total": 0}
try:
raw = path.read_bytes()
lines, start, size = _tail_log_lines(path, max(1, int(limit)), before)
total = _count_log_lines(path)
except OSError as e: # noqa: BLE001
return {"ok": False, "msg": f"读取日志失败: {e}", "lines": [], "size": 0, "total": 0}
# 逐行 UTF-8 优先、GBK 兜底:兼容旧 GBK 日志与切换为 UTF-8 后的新日志混存
# 保留 ANSI 颜色码,交给前端渲染颜色
lines = []
for raw_line in raw.split(b"\n"):
try:
line = raw_line.decode("utf-8")
except UnicodeDecodeError:
line = raw_line.decode("gbk", errors="replace")
lines.append(line.rstrip("\r"))
tail = lines[-max(1, int(limit)) :]
return {"ok": True, "file": path.name, "size": len(raw), "offset": len(raw), "lines": tail, "total": len(lines)}
return {
"ok": True,
"file": path.name,
"size": size,
"end": size,
"offset": start,
"has_more": start > 0,
"lines": lines,
"total": total,
}
@app.get("/api/dashboard/stream")
async def dashboard_stream(
@@ -5,6 +5,8 @@
- bot 账号 / WS 连通性:从 OneBot V11 适配器的 bots / connections 读取
- host 性能:psutil(CPU/内存/磁盘/进程)+ platform + nonebot 版本
仅在 /hub/api/dashboard 被调用时执行,不做常驻采样。
同步采集(psutil 等可能阻塞的调用)整体放入线程池执行,避免阻塞事件循环;
结果做 1 秒短缓存,多个前端标签页(含 SSE 循环)不至于各自重复采集。
"""
from __future__ import annotations
@@ -26,17 +28,21 @@ _MODULE_START = time.time()
# 网络速率采样:记录上一次累计计数与时间,用于计算实时 bytes/s 用量
_NET_LAST: dict = {"time": None, "sent": None, "recv": None}
# 采集结果短缓存(多标签页 / SSE 每 2s 循环共用一份结果)
_DASH_CACHE: dict = {"time": 0.0, "data": None}
_DASH_CACHE_TTL = 1.0
async def _collect_cpu() -> dict:
def _cpu_sync() -> dict:
try:
per_core = await asyncio.to_thread(psutil.cpu_percent, 0.3, True)
per_core = psutil.cpu_percent(0.3, True)
except TypeError:
# 兼容部分平台不接受 percpu 参数
per_core = []
if per_core:
percent = sum(per_core) / len(per_core)
else:
percent = await asyncio.to_thread(psutil.cpu_percent, 0.3)
percent = psutil.cpu_percent(0.3)
info: dict = {
"percent": round(float(percent), 1),
"per_core": [round(float(x), 1) for x in (per_core or [])],
@@ -57,7 +63,7 @@ async def _collect_cpu() -> dict:
return info
async def _collect_memory() -> dict:
def _memory_sync() -> dict:
mem = psutil.virtual_memory()
try:
proc = psutil.Process()
@@ -76,10 +82,10 @@ async def _collect_memory() -> dict:
}
async def _collect_process() -> dict:
def _process_sync() -> dict:
try:
proc = psutil.Process()
cpu = await asyncio.to_thread(proc.cpu_percent, 0.2)
cpu = proc.cpu_percent(0.2)
info = {
"pid": proc.pid,
"name": proc.name(),
@@ -94,7 +100,7 @@ async def _collect_process() -> dict:
return info
async def _collect_network() -> dict:
def _network_sync() -> dict:
"""采集网络流量累计值 + 实时速率(bytes/s)。"""
global _NET_LAST
now = time.time()
@@ -157,7 +163,7 @@ async def _collect_network() -> dict:
}
async def _collect_backend() -> dict:
def _backend_sync() -> dict:
"""BOT 后端信息:NoneBot 版本 / 驱动方式 / 装载插件数。"""
from nonebot import get_loaded_plugins
@@ -183,7 +189,7 @@ async def _collect_backend() -> dict:
return info
async def _collect_system() -> dict:
def _system_sync() -> dict:
boot = psutil.boot_time()
now = time.time()
return {
@@ -206,7 +212,7 @@ def _nonebot_version() -> str:
return ""
async def _collect_disk() -> list:
def _disk_sync() -> list:
parts = []
for part in psutil.disk_partitions():
try:
@@ -271,18 +277,25 @@ async def _collect_bots() -> list:
async def collect_dashboard() -> dict:
"""聚合采集,失败字段降级,不阻塞整体返回。"""
cpu, memory, process, system, disk, bots, network, backend = await asyncio.gather(
_collect_cpu(),
_collect_memory(),
_collect_process(),
_collect_system(),
_collect_disk(),
_collect_bots(),
_collect_network(),
_collect_backend(),
"""聚合采集,失败字段降级,不阻塞整体返回。
同步采集整体放线程池(psutil 的 disk_usage 等可能秒级阻塞),
结果带 1 秒短缓存(SSE 每 2s 循环与多标签页共用)。
"""
now = time.time()
if _DASH_CACHE["data"] is not None and now - _DASH_CACHE["time"] < _DASH_CACHE_TTL:
return _DASH_CACHE["data"]
cpu, memory, process, system, disk, network, backend, bots = await asyncio.gather(
asyncio.to_thread(_cpu_sync),
asyncio.to_thread(_memory_sync),
asyncio.to_thread(_process_sync),
asyncio.to_thread(_system_sync),
asyncio.to_thread(_disk_sync),
asyncio.to_thread(_network_sync),
asyncio.to_thread(_backend_sync),
_collect_bots(), # 需要走 bot API,必须留在事件循环
)
return {
data = {
"bots": bots,
"system": system,
"cpu": cpu,
@@ -292,3 +305,6 @@ async def collect_dashboard() -> dict:
"network": network,
"backend": backend,
}
_DASH_CACHE["time"] = now
_DASH_CACHE["data"] = data
return data
+64 -63
View File
@@ -1,8 +1,17 @@
const TOKEN_KEY = 'hexi_web_token'
const USER_KEY = 'hexi_web_username'
export const TOKEN_KEY = 'hexi_web_token'
export const USER_KEY = 'hexi_web_username'
export const AUTH_EXPIRED_EVENT = 'hexi:auth-expired'
export function getToken() { return localStorage.getItem(TOKEN_KEY) || '' }
export function handleAuthExpired() {
clearSession()
// 通知 React AuthProvider 同步清空内存态,AppLayout 会通过 React Router
// 客户端跳转到登录页(内容区局部切换,不整页刷新)。
try { window.dispatchEvent(new Event(AUTH_EXPIRED_EVENT)) } catch { /* ignore */ }
}
interface ReqOpts { method?: string; body?: any; headers?: Record<string,string> }
async function request(url: string, opts: ReqOpts = {}) {
@@ -12,6 +21,9 @@ async function request(url: string, opts: ReqOpts = {}) {
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.status === 401) {
handleAuthExpired()
}
if (!res.ok) {
const msg = (data && (data.detail || data.msg)) || ('HTTP ' + res.status)
throw new Error(msg)
@@ -40,7 +52,8 @@ export const authLogin = async (username: string, password: string) => {
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')
// 登出时吊销服务端令牌(避免已登出的 token 在 24h 内仍可复用)
export const authLogout = async () => await request('/hub/api/auth/logout', { method: 'POST' })
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')
@@ -58,82 +71,39 @@ export const hubPluginSaveConfig = async (id: string, values: Record<string, any
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 hubLogs = async (limit = 500) => await request('/hub/api/logs?limit=' + limit)
export const hubLogs = async (limit = 500, before?: number) =>
await request('/hub/api/logs?limit=' + limit + (before != null ? '&before=' + before : ''))
export const hubBotAction = async (action: 'stop' | 'restart') =>
await request('/hub/api/system/bot', { method: 'POST', body: { action } })
export const hubBotConfig = async () => await request('/hub/api/system/bot_config')
export const hubBotConfigSave = async (values: Record<string, any>) =>
await request('/hub/api/system/bot_config', { method: 'POST', body: values })
export function hubLogStream(opts: {
since: number
onLine: (line: string) => void
onStatus: (s: 'open' | 'reconnecting' | 'closed') => void
}): () => void {
const controller = new AbortController()
let stopped = false
let since = opts.since
const read = async () => {
while (!stopped) {
try {
const res = await fetch('/hub/api/logs/stream?since=' + since, {
headers: { Authorization: 'Bearer ' + getToken() },
signal: controller.signal,
})
if (!res.ok || !res.body) throw new Error('HTTP ' + res.status)
opts.onStatus('open')
const reader = res.body.getReader()
const decoder = new TextDecoder()
let buf = ''
while (!stopped) {
const { done, value } = await reader.read()
if (done) break
buf += decoder.decode(value, { stream: true })
let i
while ((i = buf.indexOf('\n\n')) !== -1) {
const block = buf.slice(0, i)
buf = buf.slice(i + 2)
for (const ln of block.split('\n')) {
if (ln.startsWith('data: ')) {
try {
const obj = JSON.parse(ln.slice(6))
if (obj && obj.line !== undefined) {
opts.onLine(obj.line)
if (typeof obj.offset === 'number') since = obj.offset
}
} catch { /* 忽略坏帧 */ }
}
}
}
}
if (!stopped) opts.onStatus('reconnecting')
} catch (e: any) {
if (stopped || (e && e.name === 'AbortError')) return
opts.onStatus('reconnecting')
}
if (!stopped) await new Promise(res => setTimeout(res, 1500))
}
}
read()
return () => { stopped = true; controller.abort() }
interface SSEStreamOpts {
url: () => string
onData: (obj: any) => void
onStatus?: (s: 'open' | 'reconnecting' | 'closed') => void
retryMs?: number
}
export function hubDashboardStream(opts: {
onData: (data: any) => void
onStatus?: (s: 'open' | 'reconnecting' | 'closed') => void
}): () => void {
// SSE 统一封装:fetch 流式读取 + 断线重连 + 401 登出通知
function makeSSEStream(opts: SSEStreamOpts): () => void {
const controller = new AbortController()
let stopped = false
const retryMs = opts.retryMs ?? 1500
const read = async () => {
while (!stopped) {
try {
const res = await fetch('/hub/api/dashboard/stream', {
const res = await fetch(opts.url(), {
headers: { Authorization: 'Bearer ' + getToken() },
signal: controller.signal,
})
if (res.status === 401) {
handleAuthExpired()
opts.onStatus?.('closed')
return
}
if (!res.ok || !res.body) throw new Error('HTTP ' + res.status)
opts.onStatus?.('open')
const reader = res.body.getReader()
@@ -159,7 +129,7 @@ export function hubDashboardStream(opts: {
if (stopped || (e && e.name === 'AbortError')) return
opts.onStatus?.('reconnecting')
}
if (!stopped) await new Promise(res => setTimeout(res, 2000))
if (!stopped) await new Promise(res => setTimeout(res, retryMs))
}
}
@@ -167,6 +137,37 @@ export function hubDashboardStream(opts: {
return () => { stopped = true; controller.abort() }
}
export function hubLogStream(opts: {
since: number
onLine: (line: string) => void
onStatus?: (s: 'open' | 'reconnecting' | 'closed') => void
}): () => void {
let since = opts.since
return makeSSEStream({
url: () => '/hub/api/logs/stream?since=' + since,
onData: (obj) => {
if (obj && obj.line !== undefined) {
opts.onLine(obj.line)
if (typeof obj.offset === 'number') since = obj.offset
}
},
onStatus: opts.onStatus,
retryMs: 1500,
})
}
export function hubDashboardStream(opts: {
onData: (data: any) => void
onStatus?: (s: 'open' | 'reconnecting' | 'closed') => void
}): () => void {
return makeSSEStream({
url: () => '/hub/api/dashboard/stream',
onData: opts.onData,
onStatus: opts.onStatus,
retryMs: 2000,
})
}
export const api = (plugin: string) => ({
get: (path: string) => request(`/api/${plugin}/${path}`),
post: (path: string, body: any) => request(`/api/${plugin}/${path}`, { method: 'POST', body }),
+5 -2
View File
@@ -1,11 +1,14 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useLocation, useNavigate } from 'react-router-dom'
import { Button } from '@heroui/react'
import { useAuth } from './auth'
export function LoginPage() {
const { login } = useAuth()
const navigate = useNavigate()
const location = useLocation()
// 被 AppLayout 带到登录页时记住原目标,登录后回跳
const from = (location.state as any)?.from || '/hub'
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [err, setErr] = useState('')
@@ -15,7 +18,7 @@ export function LoginPage() {
setLoading(true); setErr('')
try {
await login(username, password)
navigate('/hub')
navigate(from, { replace: true })
} catch (e: any) {
setErr(e.message || '登录失败')
} finally { setLoading(false) }
+19 -4
View File
@@ -1,5 +1,5 @@
import { createContext, useContext, useState, type ReactNode } from 'react'
import { authLogin, clearSession, getUserName, setSession } from '../api/client'
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'
import { authLogin, authLogout, AUTH_EXPIRED_EVENT, clearSession, getUserName, setSession, TOKEN_KEY, USER_KEY } from '../api/client'
interface AuthState {
token: string
@@ -11,8 +11,8 @@ interface AuthState {
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 [token, setToken] = useState(() => localStorage.getItem(TOKEN_KEY) || '')
const [username, setUsername] = useState(() => localStorage.getItem(USER_KEY) || '')
const login = async (u: string, p: string) => {
const data = await authLogin(u, p)
setSession(data.token, data.username)
@@ -20,10 +20,25 @@ export function AuthProvider({ children }: { children: ReactNode }) {
setUsername(data.username)
}
const logout = () => {
// 先吊销服务端令牌(fire-and-forget,失败不阻塞退出),再清理本地
authLogout().catch(() => {})
clearSession()
setToken('')
setUsername('')
}
// 任何请求命中 401(登录态无效/过期)时,同步清空内存态,
// AppLayout 会立即自动跳转到登录页。
useEffect(() => {
const onExpired = () => {
clearSession()
setToken('')
setUsername('')
}
window.addEventListener(AUTH_EXPIRED_EVENT, onExpired)
return () => window.removeEventListener(AUTH_EXPIRED_EVENT, onExpired)
}, [])
return <Ctx.Provider value={{ token, username, login, logout }}>{children}</Ctx.Provider>
}
+56
View File
@@ -0,0 +1,56 @@
import { Component, type ReactNode } from 'react'
interface Props { children: ReactNode; compact?: boolean }
interface State { error: Error | null }
/**
* 顶层 ErrorBoundary(full)与内容区 ErrorBoundary(compact)共用。
* 插件页面/子路由抛异常时不再整页白屏,给出「重试 / 回首页」出口。
*/
export class ErrorBoundary extends Component<Props, State> {
state: State = { error: null }
static getDerivedStateFromError(error: Error): State {
return { error }
}
render() {
if (!this.state.error) return this.props.children
const err = this.state.error
if (this.props.compact) {
return (
<div className="rounded-xl border border-red-200 bg-red-50 p-4">
<p className="text-sm text-red-600">{err.message || '页面渲染出错'}</p>
<button
className="mt-2 text-sm text-indigo-600 hover:underline"
onClick={() => this.setState({ error: null })}
>
重试
</button>
</div>
)
}
return (
<div className="flex min-h-screen items-center justify-center bg-slate-50 p-4">
<div className="w-full max-w-sm rounded-2xl border border-slate-200 bg-white p-8 text-center">
<div className="text-lg font-semibold text-slate-800">页面出错了</div>
<p className="mt-2 break-all text-sm text-slate-500">{err.message || '未知错误'}</p>
<div className="mt-6 flex justify-center gap-3">
<button
className="rounded-lg bg-slate-100 px-4 py-2 text-sm text-slate-700 hover:bg-slate-200"
onClick={() => { this.setState({ error: null }) }}
>
重试
</button>
<button
className="rounded-lg bg-indigo-500 px-4 py-2 text-sm text-white hover:bg-indigo-600"
onClick={() => { window.location.href = '/hub' }}
>
回到首页
</button>
</div>
</div>
</div>
)
}
}
+29
View File
@@ -0,0 +1,29 @@
import type { ReactNode } from 'react'
import { Button } from '@heroui/react'
/** 插件页共用样式常量与小组件(表格/选择器/页头/Tabs/分页)。 */
export const thCls = 'px-3 py-2 text-left text-xs font-medium text-slate-400'
export const tdCls = 'px-3 py-2 text-sm text-slate-700 border-t border-slate-100'
export 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'
export 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>
}
export interface TabItem { key: string; label: string }
export function Tabs({ tabs, active, onChange }: { tabs: TabItem[]; active: string; onChange: (key: string) => void }) {
return <div className="flex gap-1 border-b border-slate-200 mb-4">
{tabs.map(t => <button key={t.key} onClick={() => onChange(t.key)} className={'px-3 py-2 text-sm border-b-2 -mb-px ' + (active === t.key ? 'border-indigo-500 text-indigo-700 font-medium' : 'border-transparent text-slate-500 hover:text-slate-700')}>{t.label}</button>)}
</div>
}
export 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>
}
+39
View File
@@ -0,0 +1,39 @@
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'
import { hubDashboardStream } from './api/client'
export type ConnStatus = '连接中' | '实时' | '重连中' | '已断开'
interface ConnState {
status: ConnStatus
data: any | null
error: string
lastUpdate: number | null
}
const Ctx = createContext<ConnState>({ status: '连接中', data: null, error: '', lastUpdate: null })
/**
* 全局连接状态:AppLayout 内常驻一个 dashboard SSE(对 /hub/api/dashboard/stream)。
* 顶栏徽章显示实时/重连/断开;首页数据也直接消费该流,不再各自订阅。
*/
export function ConnectionProvider({ children }: { children: ReactNode }) {
const [status, setStatus] = useState<ConnStatus>('连接中')
const [data, setData] = useState<any | null>(null)
const [error, setError] = useState('')
const [lastUpdate, setLastUpdate] = useState<number | null>(null)
useEffect(() => {
const dispose = hubDashboardStream({
onData: (d) => {
if (d && d.ok !== false) { setData(d); setError(''); setLastUpdate(Date.now()) }
else if (d && d.ok === false) setError(d.msg || '采集失败')
},
onStatus: (s) => setStatus(s === 'open' ? '实时' : s === 'reconnecting' ? '重连中' : '已断开'),
})
return dispose
}, [])
return <Ctx.Provider value={{ status, data, error, lastUpdate }}>{children}</Ctx.Provider>
}
export const useConnection = () => useContext(Ctx)
+131 -32
View File
@@ -1,21 +1,32 @@
import { useLayoutEffect, useEffect, useRef, useState, Suspense } from 'react'
import { Navigate, NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom'
import { Avatar, Button, Separator } from '@heroui/react'
import { House, Rocket, Gear, FileText } from '@gravity-ui/icons'
import { Avatar, Button, Separator, Spinner } from '@heroui/react'
import { House, Rocket, Gear, FileText, Bars } from '@gravity-ui/icons'
import { useAuth } from '../auth/auth'
import { hubPlugins } from '../api/client'
import { ErrorBoundary } from '../components/ErrorBoundary'
import { useConnection } from '../connection'
interface NavItem { id: string; label: string; description: string; icon: any }
const NAV_ITEMS: NavItem[] = [
{ id: 'home', label: '首页', description: '主机与服务状态', icon: House },
{ id: 'plugins', label: '插件管理', description: '插件总览与分群控制', icon: Rocket },
{ id: 'logs', label: '查看日志', description: 'bot 运行日志实时查看', icon: FileText },
{ id: 'settings', label: '设置', description: '账号与系统设置', icon: Gear },
]
// 插件名映射做模块级缓存:布局多次挂载(路由切换)时不必重复拉取
let webNamesCache: Record<string, string> | null = null
export function AppLayout() {
const { token, username, logout } = useAuth()
const navigate = useNavigate()
const location = useLocation()
// 全局连接状态(顶栏徽章):由常驻 dashboard SSE 驱动
const { status, lastUpdate } = useConnection()
const navItems: NavItem[] = [
{ id: 'home', label: '首页', description: '主机与服务状态', icon: House },
{ id: 'plugins', label: '插件管理', description: '插件总览与分群控制', icon: Rocket },
{ id: 'logs', label: '查看日志', description: 'bot 运行日志实时查看', icon: FileText },
{ id: 'settings', label: '设置', description: '账号与系统设置', icon: Gear },
]
const navItems = NAV_ITEMS
const path = location.pathname.replace(/\/+$/, '') || '/'
const isHome = path === '/hub'
const isSettings = path.startsWith('/hub/settings')
@@ -26,11 +37,62 @@ export function AppLayout() {
const activeId = isPlugins ? 'plugins' : isLogs ? 'logs' : isSettings ? 'settings' : 'home'
const pageMeta = navItems.find(it => it.id === activeId) || navItems[0]
if (!token) return <Navigate to="/hub/login" replace />
// 插件子页:从 /hub/<插件id>/... 提取插件 id,并用注册表名字做顶栏面包屑
const pluginId = path.split('/')[2] || ''
const [webNames, setWebNames] = useState<Record<string, string>>({})
useEffect(() => {
if (webNamesCache) { setWebNames(webNamesCache); return }
hubPlugins().then(d => {
const m: Record<string, string> = {}
for (const p of (d && d.plugins) || []) m[p.id] = p.name
webNamesCache = m
setWebNames(m)
}).catch(() => {})
}, [])
const subName = isPluginSub ? (webNames[pluginId] || pluginId) : ''
const navMain = navItems.filter(it => it.id !== 'settings')
const navBottom = navItems.filter(it => it.id === 'settings')
const activeMainId = navMain.find(it => it.id === activeId)?.id ?? null
// 移动端侧栏抽屉:默认收起,点击汉堡按钮展开;切换路由后自动收起
const [sidebarOpen, setSidebarOpen] = useState(false)
useEffect(() => { setSidebarOpen(false) }, [path])
useEffect(() => {
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setSidebarOpen(false) }
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [])
// 滑动指示条:在主导航区中根据高亮项位置平滑移动
const [indicator, setIndicator] = useState<{ top: number; height: number } | null>(null)
const mainNavRef = useRef<HTMLElement>(null)
const recalcIndicator = () => {
const nav = mainNavRef.current
if (!nav || !activeMainId) { setIndicator(null); return }
const el = nav.querySelector<HTMLElement>('[data-nav-id="' + activeMainId + '"]')
if (!el) { setIndicator(null); return }
const navRect = nav.getBoundingClientRect()
const itemRect = el.getBoundingClientRect()
setIndicator({ top: itemRect.top - navRect.top, height: itemRect.height })
}
useLayoutEffect(() => { recalcIndicator() }, [activeMainId, path])
useEffect(() => {
// 窗口尺寸变化(如移动端调转)时重算指示条位置
window.addEventListener('resize', recalcIndicator)
return () => window.removeEventListener('resize', recalcIndicator)
}, [activeMainId, path])
if (!token) return <Navigate to="/hub/login" replace state={{ from: location.pathname + location.search }} />
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">
{sidebarOpen && (
<div className="fixed inset-0 z-30 bg-slate-900/40 backdrop-blur-sm md:hidden" onClick={() => setSidebarOpen(false)} />
)}
<aside className={'fixed inset-y-0 left-0 z-40 flex h-full w-60 shrink-0 flex-col overflow-hidden bg-white border-r border-slate-200 transition-transform duration-300 ease-out md:static md:translate-x-0 ' + (sidebarOpen ? 'translate-x-0' : '-translate-x-full')}>
{/* 品牌头 */}
<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>
@@ -38,29 +100,36 @@ export function AppLayout() {
<div className="truncate text-sm font-bold">HeXi 管理系统</div>
<div className="truncate text-xs text-slate-400">统一管理台</div>
</div>
<Button size="sm" variant="tertiary" className="md:hidden" onPress={() => setSidebarOpen(false)} aria-label="关闭菜单">✕</Button>
</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 => {
{/* 导航(主区,含滑动指示条) */}
<nav ref={mainNavRef} className="relative flex-1 min-h-0 space-y-1 overflow-y-auto px-2 py-2">
{indicator && (
<span
className="pointer-events-none absolute left-2 w-0.5 rounded-full bg-indigo-500 transition-all duration-300 ease-out"
style={{ top: indicator.top + (indicator.height - 20) / 2, height: 20 }}
/>
)}
{navMain.map(it => {
const to = it.id === 'home' ? '/hub' : '/hub/' + it.id
return (
<NavLink
key={it.id}
to={to}
end={it.id === 'home'}
data-nav-id={it.id}
className={({ isActive }) => {
const base = 'group relative flex items-center rounded-lg py-2.5 text-sm font-medium transition-colors cursor-pointer'
const base = 'group relative flex items-center rounded-lg py-2.5 text-sm font-medium transition-colors duration-200 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 grid w-11 shrink-0 place-items-center transition-transform duration-200 ' + (isActive ? 'scale-110' : 'group-hover:scale-105')}><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 className={'w-full truncate text-xs font-normal transition-colors duration-200 ' + (isActive ? 'text-indigo-400' : 'text-slate-400')}>{it.description}</span>
</span>
</>
)}
@@ -69,23 +138,23 @@ export function AppLayout() {
})}
</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 => {
{navBottom.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'
const base = 'group relative flex items-center rounded-lg py-2.5 text-sm font-medium transition-colors duration-200 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={'absolute left-0 top-1/2 h-5 w-0.5 origin-center -translate-y-1/2 rounded-full bg-indigo-500 transition-all duration-300 ' + (isActive ? 'scale-y-100 opacity-100' : 'scale-y-0 opacity-0')} />
<span className={'relative z-10 grid w-11 shrink-0 place-items-center transition-transform duration-200 ' + (isActive ? 'scale-110' : 'group-hover:scale-105')}><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>
@@ -99,23 +168,53 @@ export function AppLayout() {
</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">
<header className="flex h-16 shrink-0 items-center justify-between border-b border-slate-200 bg-white px-3 md:px-6">
<div className="flex min-w-0 items-center gap-2 md:gap-3">
<Button size="sm" variant="tertiary" className="shrink-0 md:hidden" onPress={() => setSidebarOpen(v => !v)} aria-label="打开菜单">
<Bars className="size-5" />
</Button>
{isPluginSub && (
<Button size="sm" variant="tertiary" onPress={() => navigate('/hub/plugins')}>← 返回插件管理</Button>
<Button size="sm" variant="tertiary" className="max-w-24 shrink-0 truncate" onPress={() => navigate('/hub/plugins')}>← 返回</Button>
)}
{isPluginSub ? (
<div className="flex min-w-0 items-center gap-2">
<span className="shrink-0 text-sm text-slate-400">插件管理</span>
<span className="shrink-0 text-sm text-slate-300">›</span>
<span className="min-w-0 truncate text-sm font-semibold text-slate-800">{subName}</span>
</div>
) : (
<>
<pageMeta.icon className="hidden size-4 shrink-0 text-slate-500 sm:block" />
<span className="truncate text-sm font-semibold text-slate-800">{pageMeta.label}</span>
</>
)}
<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">
<div className="flex shrink-0 items-center gap-2 md:gap-3">
<span
title={lastUpdate ? '最后更新 ' + new Date(lastUpdate).toLocaleTimeString('zh-CN', { hour12: false }) : '正在连接后端…'}
className={'inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs transition-colors duration-300 ' + (status === '实时' ? 'border-emerald-200 bg-emerald-50 text-emerald-600' : status === '重连中' ? 'border-amber-200 bg-amber-50 text-amber-600' : 'border-slate-200 bg-slate-50 text-slate-400')}
>
<span className={'size-1.5 rounded-full ' + (status === '实时' ? 'bg-emerald-500' : status === '重连中' ? 'animate-pulse bg-amber-500' : 'bg-slate-300')} />
{status}
{lastUpdate && <span className="hidden text-[10px] opacity-70 sm:inline">{new Date(lastUpdate).toLocaleTimeString('zh-CN', { hour12: false })}</span>}
</span>
<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" />
<span className="hidden text-sm text-slate-600 sm:inline">{username || '未登录'}</span>
<Separator orientation="vertical" className="hidden h-5 sm:block" />
<Button size="sm" variant="tertiary" onPress={() => { logout(); navigate('/hub/login') }}>退出</Button>
</div>
</header>
<main className="min-h-0 flex-1 overflow-y-auto p-6"><Outlet /></main>
<main className="min-h-0 flex-1 overflow-y-auto p-4 md:p-6">
<div key={path} className="hexi-page-enter">
{/* 插件懒加载只替换内容区;子页面异常也不拖垮整个布局 */}
<ErrorBoundary compact>
<Suspense fallback={<div className="flex justify-center py-10"><Spinner /></div>}>
<Outlet />
</Suspense>
</ErrorBoundary>
</div>
</main>
</div>
</div>
)
}
}
+33
View File
@@ -0,0 +1,33 @@
/** 通用格式化工具(首页/日志页等共用)。 */
export 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]
}
export function fmtRate(n: number): string {
if (!Number.isFinite(n) || n < 0) return '0 B/s'
return fmtBytes(n) + '/s'
}
export 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(' ')
}
export function fmtTime(ts?: string | number): string {
if (!ts) return '—'
const d = new Date(typeof ts === 'number' ? ts * 1000 : ts)
return isNaN(d.getTime()) ? String(ts) : d.toLocaleString('zh-CN', { hour12: false })
}
+138
View File
@@ -0,0 +1,138 @@
import { createContext, useCallback, useContext, useEffect, useRef, useState, type ReactNode } from 'react'
import { hubLogs, hubLogStream } from './api/client'
// 保留的最大行数(窗口较大以容纳「加载更早」预置的历史行)
const MAX_LINES = 6000
export interface LogInfo { file: string; size: number; total: number }
interface LogState {
lines: string[]
info: LogInfo
error: string
loading: boolean
hasMore: boolean
loadingEarlier: boolean
paused: boolean
setPaused: (v: boolean) => void
refresh: () => Promise<void>
loadEarlier: () => Promise<void>
}
const Ctx = createContext<LogState>({
lines: [],
info: { file: '', size: 0, total: 0 },
error: '',
loading: false,
hasMore: false,
loadingEarlier: false,
paused: false,
setPaused: () => {},
refresh: async () => {},
loadEarlier: async () => {},
})
/**
* 日志数据与日志流常驻 Provider:首次进入(任意页面)时拉取一次,
* SSE 流保持不断,页面之间切换只读缓存数据,不重建连接、不显示加载动画。
*/
export function LogProvider({ children }: { children: ReactNode }) {
const [lines, setLines] = useState<string[]>([])
const [info, setInfo] = useState<LogInfo>({ file: '', size: 0, total: 0 })
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const [hasMore, setHasMore] = useState(false)
const [loadingEarlier, setLoadingEarlier] = useState(false)
const [paused, setPaused] = useState(false)
const sinceRef = useRef(0)
const earliestRef = useRef(0)
const pausedRef = useRef(false)
const pendingRef = useRef<string[]>([])
const disposeRef = useRef<(() => void) | null>(null)
const loadingEarlierRef = useRef(false)
const append = useCallback((line: string) => {
setLines(prev => [...prev, line].slice(-MAX_LINES))
}, [])
const connect = useCallback(() => {
if (disposeRef.current) disposeRef.current()
disposeRef.current = hubLogStream({
since: sinceRef.current,
onLine: (line) => {
if (pausedRef.current) {
pendingRef.current.push(line)
// 暂停期间只保留最近若干行,避免恢复时一次性渲染超大数组
if (pendingRef.current.length > 500) pendingRef.current.splice(0, pendingRef.current.length - 500)
return
}
append(line)
},
})
}, [append])
const refresh = useCallback(async () => {
setLoading(true)
setError('')
try {
const d = await hubLogs(500)
if (!d) throw new Error('读取日志失败')
if (d.ok === false) throw new Error(d.msg || '读取日志失败')
setLines(d.lines || [])
setInfo({ file: d.file || '', size: d.size || 0, total: d.total || 0 })
// stream 从文件尾续读(end);「加载更早」从窗口起点向前翻(offset)
sinceRef.current = d.end || 0
earliestRef.current = d.offset || 0
setHasMore((d.offset || 0) > 0)
connect()
} catch (e: any) {
setError(e.message || '读取日志失败')
} finally { setLoading(false) }
}, [connect])
const loadEarlier = useCallback(async () => {
const before = earliestRef.current
if (!before || loadingEarlierRef.current) return
loadingEarlierRef.current = true
setLoadingEarlier(true)
setError('')
try {
const d = await hubLogs(500, before)
if (!d || d.ok === false) throw new Error((d && d.msg) || '读取日志失败')
setLines(prev => [...(d.lines || []), ...prev].slice(-MAX_LINES))
earliestRef.current = d.offset || 0
setHasMore((d.offset || 0) > 0)
} catch (e: any) {
setError(e.message || '读取日志失败')
} finally {
loadingEarlierRef.current = false
setLoadingEarlier(false)
}
}, [])
// 首次进入就建立并保持;provider 卸载(登出/离开 /hub)时才断开
useEffect(() => {
refresh()
return () => { if (disposeRef.current) disposeRef.current() }
}, [refresh])
useEffect(() => { pausedRef.current = paused }, [paused])
useEffect(() => {
// 恢复接收时,把暂停期间暂存的行补进来
if (!paused && pendingRef.current.length) {
const flush = pendingRef.current
pendingRef.current = []
setLines(prev => [...prev, ...flush].slice(-MAX_LINES))
}
}, [paused])
return (
<Ctx.Provider value={{ lines, info, error, loading, hasMore, loadingEarlier, paused, setPaused, refresh, loadEarlier }}>
{children}
</Ctx.Provider>
)
}
export const useLogs = () => useContext(Ctx)
+79 -132
View File
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { Avatar, Card } from '@heroui/react'
import { hubDashboard, hubDashboardStream } from '../../api/client'
import { Avatar, Card, Spinner } from '@heroui/react'
import { useConnection } from '../../connection'
import { fmtBytes, fmtRate, fmtUptime } from '../../lib/format'
interface BotInfo {
self_id: string
@@ -87,32 +87,6 @@ interface DashboardData {
backend: BackendInfo
}
function fmtBytes(n: number): string {
if (!Number.isFinite(n)) return '—'
const units = ['B', 'KB', 'MB', 'GB', 'TB']
let v = n
let i = 0
while (v >= 1024 && i < units.length - 1) { v /= 1024; i++ }
return v.toFixed(1) + ' ' + units[i]
}
function fmtRate(n: number): string {
if (!Number.isFinite(n) || n < 0) return '0 B/s'
return fmtBytes(n) + '/s'
}
function fmtUptime(sec: number): string {
if (!Number.isFinite(sec)) return '—'
const d = Math.floor(sec / 86400)
const h = Math.floor((sec % 86400) / 3600)
const m = Math.floor((sec % 3600) / 60)
const parts: string[] = []
if (d) parts.push(d + ' 天')
if (h) parts.push(h + ' 小时')
parts.push(m + ' 分')
return parts.join(' ')
}
function Bar({ value, color }: { value: number; color?: string }) {
const v = Math.max(0, Math.min(100, value))
const c = color || (v >= 80 ? 'bg-rose-500' : v >= 60 ? 'bg-amber-500' : 'bg-indigo-500')
@@ -149,37 +123,9 @@ function SectionTitle({ title, desc }: { title: string; desc: string }) {
}
export default function HomePage() {
const [data, setData] = useState<DashboardData | null>(null)
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const refreshingRef = useRef(false)
const refresh = useCallback(async (silent = false) => {
if (refreshingRef.current) return
refreshingRef.current = true
if (!silent) setLoading(true)
try {
const d = await hubDashboard()
if (!d || d.ok === false) throw new Error((d && (d as any).msg) || '采集失败')
setData(d)
setError('')
} catch (e: any) {
setError(e.message || '加载失败')
} finally {
refreshingRef.current = false
if (!silent) setLoading(false)
}
}, [])
useEffect(() => {
refresh(true) // 初始静默加载,快速出首屏
const dispose = hubDashboardStream({
onData: (d) => {
if (d && d.ok !== false) { setData(d); setError('') }
},
})
return () => { dispose() }
}, [refresh])
// 数据与连接状态来自全局 ConnectionProvider(顶栏已显示状态徽章)
const { data: rawData, error } = useConnection()
const data = rawData as DashboardData | null
const cpu = data?.cpu
const mem = data?.memory
@@ -190,15 +136,82 @@ export default function HomePage() {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-xl font-semibold text-slate-800">总览</h2>
<p className="text-sm text-slate-400">主机与 BOT 综合状态(每 2 秒刷新)</p>
</div>
<div>
<h2 className="text-xl font-semibold text-slate-800">总览</h2>
<p className="text-sm text-slate-400">主机与 BOT 综合状态(实时刷新)</p>
</div>
{error && !data && <Card className="border border-red-200 bg-red-50"><Card.Content><p className="text-sm text-red-600">{error}</p></Card.Content></Card>}
{/* 顶部:BOT 协议端信息 + BOT 后端信息 一行两列 */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-2 items-stretch">
{/* BOT 协议端信息:登录账号昵称 / 账号 / 适配器类型 */}
<Card className="border border-slate-200 bg-white h-full">
<Card.Header>
<SectionTitle title="BOT 协议端信息" desc="当前登录的账户昵称 / 账号 / 适配器类型" />
</Card.Header>
<Card.Content>
{!data ? (
<div className="flex justify-center py-8"><Spinner /></div>
) : !data.bots.length ? (
<p className="text-sm text-slate-400">暂无在线 QQ 账号</p>
) : (
<div className="space-y-2">
{data.bots.map(bot => (
<div key={bot.self_id} className="flex items-center gap-3 rounded-xl border border-slate-100 px-4 py-3">
<Avatar size="sm">
<Avatar.Image src={bot.avatar} alt={bot.nick} />
<Avatar.Fallback>{(bot.nick || bot.self_id).charAt(0).toUpperCase()}</Avatar.Fallback>
</Avatar>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium text-slate-800">{bot.nick}</div>
<div className="truncate text-xs text-slate-400">{bot.self_id} · {bot.adapter}</div>
</div>
<div className="flex items-center gap-3">
{bot.msg_recv != null && <span className="text-xs text-slate-400">收 {bot.msg_recv}</span>}
{bot.msg_sent != null && <span className="text-xs text-slate-400">发 {bot.msg_sent}</span>}
{bot.online != null && <span className="text-xs text-slate-400">{bot.online ? '在线' : '离线'}</span>}
<span className="inline-flex items-center gap-1.5 text-xs">
<span className={'size-2 rounded-full ' + (bot.ws_connected ? 'bg-emerald-500' : 'bg-slate-300')} />
<span className={bot.ws_connected ? 'text-emerald-600' : 'text-slate-400'}>{bot.ws_connected ? '已连接' : '未连接'}</span>
</span>
</div>
</div>
))}
</div>
)}
</Card.Content>
</Card>
{/* BOT 后端信息:NoneBot 版本 / 驱动方式 / 装载插件数 */}
<Card className="border border-slate-200 bg-white h-full">
<Card.Header>
<SectionTitle title="BOT 后端信息" desc="NoneBot 版本 / 驱动方式 / 装载插件数" />
</Card.Header>
<Card.Content>
<div className="grid gap-4 sm:grid-cols-2">
<div className="rounded-xl border border-slate-200 p-4">
<div className="text-xs text-slate-400">NoneBot 版本</div>
<div className="mt-1 text-lg font-semibold text-slate-800">{backend?.nonebot_version || '—'}</div>
</div>
<div className="rounded-xl border border-slate-200 p-4">
<div className="text-xs text-slate-400">驱动方式</div>
<div className="mt-1 truncate text-sm font-medium text-slate-700" title={backend?.driver}>{backend?.driver || '—'}</div>
<div className="text-xs text-slate-400">{backend?.driver_type || ''}</div>
</div>
<div className="rounded-xl border border-slate-200 p-4">
<div className="text-xs text-slate-400">装载插件数</div>
<div className="mt-1 text-lg font-semibold text-slate-800">{backend?.plugin_count ?? '—'} 个</div>
</div>
<div className="rounded-xl border border-slate-200 p-4">
<div className="text-xs text-slate-400">Python</div>
<div className="mt-1 truncate text-sm font-medium text-slate-700" title={backend?.python}>{backend?.python || '—'}</div>
<div className="text-xs text-slate-400">bot 运行 {sys ? fmtUptime(sys.bot_uptime_seconds) : '—'}</div>
</div>
</div>
</Card.Content>
</Card>
</div>
{/* 1. 主机规格信息:系统 / 处理器 / 内存 / 硬盘 */}
<Card className="border border-slate-200 bg-white">
<Card.Header>
@@ -234,7 +247,7 @@ export default function HomePage() {
<div className="mt-4">
<div className="mb-2 text-sm font-medium text-slate-700">硬盘</div>
{!data ? (
<p className="text-sm text-slate-400">加载中…</p>
<div className="flex justify-center py-8"><Spinner /></div>
) : !data.disk.length ? (
<p className="text-sm text-slate-400">未获取到磁盘信息</p>
) : (
@@ -323,72 +336,6 @@ export default function HomePage() {
</Card.Content>
</Card>
{/* 3. BOT 后端信息:NoneBot 版本 / 驱动方式 / 装载插件数 */}
<Card className="border border-slate-200 bg-white">
<Card.Header>
<SectionTitle title="BOT 后端信息" desc="NoneBot 版本 / 驱动方式 / 装载插件数" />
</Card.Header>
<Card.Content>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<div className="rounded-xl border border-slate-200 p-4">
<div className="text-xs text-slate-400">NoneBot 版本</div>
<div className="mt-1 text-lg font-semibold text-slate-800">{backend?.nonebot_version || '—'}</div>
</div>
<div className="rounded-xl border border-slate-200 p-4">
<div className="text-xs text-slate-400">驱动方式</div>
<div className="mt-1 truncate text-sm font-medium text-slate-700" title={backend?.driver}>{backend?.driver || '—'}</div>
<div className="text-xs text-slate-400">{backend?.driver_type || ''}</div>
</div>
<div className="rounded-xl border border-slate-200 p-4">
<div className="text-xs text-slate-400">装载插件数</div>
<div className="mt-1 text-lg font-semibold text-slate-800">{backend?.plugin_count ?? '—'} 个</div>
</div>
<div className="rounded-xl border border-slate-200 p-4">
<div className="text-xs text-slate-400">Python</div>
<div className="mt-1 truncate text-sm font-medium text-slate-700" title={backend?.python}>{backend?.python || '—'}</div>
<div className="text-xs text-slate-400">bot 运行 {sys ? fmtUptime(sys.bot_uptime_seconds) : '—'}</div>
</div>
</div>
</Card.Content>
</Card>
{/* 4. BOT 协议端信息:登录账号昵称 / 账号 / 适配器类型 */}
<Card className="border border-slate-200 bg-white">
<Card.Header>
<SectionTitle title="BOT 协议端信息" desc="当前登录的账户昵称 / 账号 / 适配器类型" />
</Card.Header>
<Card.Content>
{!data ? (
<p className="text-sm text-slate-400">加载中…</p>
) : !data.bots.length ? (
<p className="text-sm text-slate-400">暂无在线 QQ 账号</p>
) : (
<div className="space-y-2">
{data.bots.map(bot => (
<div key={bot.self_id} className="flex items-center gap-3 rounded-xl border border-slate-100 px-4 py-3">
<Avatar size="sm">
<Avatar.Image src={bot.avatar} alt={bot.nick} />
<Avatar.Fallback>{(bot.nick || bot.self_id).charAt(0).toUpperCase()}</Avatar.Fallback>
</Avatar>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium text-slate-800">{bot.nick}</div>
<div className="truncate text-xs text-slate-400">{bot.self_id} · {bot.adapter}</div>
</div>
<div className="flex items-center gap-3">
{bot.msg_recv != null && <span className="text-xs text-slate-400">收 {bot.msg_recv}</span>}
{bot.msg_sent != null && <span className="text-xs text-slate-400">发 {bot.msg_sent}</span>}
{bot.online != null && <span className="text-xs text-slate-400">{bot.online ? '在线' : '离线'}</span>}
<span className="inline-flex items-center gap-1.5 text-xs">
<span className={'size-2 rounded-full ' + (bot.ws_connected ? 'bg-emerald-500' : 'bg-slate-300')} />
<span className={bot.ws_connected ? 'text-emerald-600' : 'text-slate-400'}>{bot.ws_connected ? '已连接' : '未连接'}</span>
</span>
</div>
</div>
))}
</div>
)}
</Card.Content>
</Card>
</div>
)
}
+18 -86
View File
@@ -1,17 +1,7 @@
import { useCallback, useEffect, useRef, useState, type CSSProperties } from 'react'
import { Button, Card } from '@heroui/react'
import { hubLogs, hubLogStream } from '../../api/client'
const MAX_LINES = 2000
function fmtBytes(n: number): string {
if (!Number.isFinite(n)) return '—'
const units = ['B', 'KB', 'MB', 'GB']
let v = n
let i = 0
while (v >= 1024 && i < units.length - 1) { v /= 1024; i++ }
return v.toFixed(1) + ' ' + units[i]
}
import { useEffect, useRef, useState, type CSSProperties } from 'react'
import { Button, Card, Spinner } from '@heroui/react'
import { useLogs } from '../../logs'
import { fmtBytes } from '../../lib/format'
// ANSI SGR 颜色码 → 内联样式,用于还原 loguru 彩色日志
const ANSI_FG: Record<number, string> = {
@@ -91,68 +81,10 @@ function AnsiLine({ text }: { text: string }) {
}
export default function LogsPage() {
const [lines, setLines] = useState<string[]>([])
const [info, setInfo] = useState<{ file: string; size: number; total: number }>({ file: '', size: 0, total: 0 })
const [error, setError] = useState('')
const [streamStatus, setStreamStatus] = useState<'连接中' | '实时' | '重连中' | '已断开'>('连接中')
// 数据与日志流由全局 LogProvider 常驻管理:离开/回来不重建连接、不转圈
const { lines, info, error, loading, hasMore, loadingEarlier, paused, setPaused, loadEarlier } = useLogs()
const [follow, setFollow] = useState(true)
const [paused, setPaused] = useState(false)
const [loading, setLoading] = useState(false)
const scrollRef = useRef<HTMLDivElement>(null)
const sinceRef = useRef(0)
const pausedRef = useRef(false)
const pendingRef = useRef<string[]>([])
const disposeRef = useRef<(() => void) | null>(null)
const append = useCallback((line: string) => {
setLines(prev => [...prev, line].slice(-MAX_LINES))
}, [])
const connect = useCallback(() => {
if (disposeRef.current) disposeRef.current()
disposeRef.current = hubLogStream({
since: sinceRef.current,
onLine: (line) => {
if (pausedRef.current) { pendingRef.current.push(line); return }
append(line)
},
onStatus: (s) => setStreamStatus(s === 'open' ? '实时' : s === 'reconnecting' ? '重连中' : '已断开'),
})
}, [append])
const refresh = useCallback(async () => {
setLoading(true)
setError('')
try {
const d = await hubLogs(500)
if (!d) throw new Error('读取日志失败')
if (d.ok === false) throw new Error(d.msg || '读取日志失败')
setLines(d.lines || [])
setInfo({ file: d.file || '', size: d.size || 0, total: d.total || 0 })
sinceRef.current = d.offset || 0
connect()
} catch (e: any) {
setError(e.message || '读取日志失败')
} finally { setLoading(false) }
}, [connect])
useEffect(() => {
pausedRef.current = paused
}, [paused])
useEffect(() => {
// 恢复接收时,把暂停期间暂存的行补进来
if (!paused && pendingRef.current.length) {
const flush = pendingRef.current
pendingRef.current = []
setLines(prev => [...prev, ...flush].slice(-MAX_LINES))
}
}, [paused])
useEffect(() => {
refresh()
return () => { if (disposeRef.current) disposeRef.current() }
}, [refresh])
useEffect(() => {
if (follow && scrollRef.current) {
@@ -160,17 +92,18 @@ export default function LogsPage() {
}
}, [lines, follow])
const statusClass = streamStatus === '实时' ? 'text-emerald-600' : streamStatus === '重连中' ? 'text-amber-600' : 'text-slate-500'
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div className="min-w-0">
<h2 className="text-xl font-semibold text-slate-800">查看日志</h2>
<p className="text-sm text-slate-400">实时读取 bot 运行日志(SSE 推送)</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button size="sm" variant="tertiary" onPress={() => setPaused(v => !v)}>{paused ? '恢复接收' : '暂停接收'}</Button>
<div className="flex shrink-0 flex-wrap items-center gap-2 md:justify-end">
{hasMore && (
<Button size="sm" variant="tertiary" isDisabled={loadingEarlier} onPress={loadEarlier}>{loadingEarlier ? '加载中…' : '加载更早'}</Button>
)}
<Button size="sm" variant="tertiary" onPress={() => setPaused(!paused)}>{paused ? '恢复接收' : '暂停接收'}</Button>
<Button size="sm" variant="tertiary" onPress={() => setFollow(v => !v)}>{follow ? '停止跟随' : '跟随最新'}</Button>
</div>
</div>
@@ -181,15 +114,14 @@ export default function LogsPage() {
<Card.Header>
<div className="flex w-full items-center justify-between">
<Card.Title>日志流</Card.Title>
<div className="flex items-center gap-3 text-xs text-slate-400">
<span className={statusClass}>● {streamStatus}</span>
{info.file && <span>{info.file} · {fmtBytes(info.size)} · 共 {info.total} 行</span>}
</div>
{info.file && <span className="text-xs text-slate-400">{info.file} · {fmtBytes(info.size)} · 共 {info.total} 行</span>}
</div>
</Card.Header>
<Card.Content>
<div ref={scrollRef} className="h-[calc(100vh-320px)] min-h-[320px] overflow-auto rounded-lg bg-slate-900 p-3 font-mono text-xs leading-relaxed text-slate-100">
{lines.length ? lines.map((ln, i) => (
<div ref={scrollRef} className="h-[60vh] min-h-[320px] overflow-auto rounded-lg bg-slate-900 p-3 font-mono text-xs leading-relaxed text-slate-100 md:h-[calc(100vh-320px)]">
{loading && !lines.length ? (
<div className="flex h-full items-center justify-center"><Spinner /></div>
) : lines.length ? lines.map((ln, i) => (
<div key={i} className="whitespace-pre-wrap break-all"><AnsiLine text={ln} /></div>
)) : (
<div className="text-slate-500">暂无日志…</div>
+107 -31
View File
@@ -1,9 +1,7 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { Link } from 'react-router-dom'
import {
Button,
Card,
Checkbox,
Drawer,
Dropdown,
Input,
@@ -17,13 +15,15 @@ import {
toast,
useOverlayState,
} from '@heroui/react'
import { useNavigate } from 'react-router-dom'
import { Eye, EyeSlash, TrashBin } from '@gravity-ui/icons'
import {
hubPluginCatalog,
hubPluginGetConfig,
hubPluginRemoveGroup,
hubPluginSaveConfig,
hubPluginSetGlobal,
hubPluginSetGroup,
hubPluginRemoveGroup,
hubGroups,
} from '../../api/client'
@@ -50,6 +50,7 @@ interface ConfigField {
description?: string
options?: { value: string; label: string }[] | string[]
secret?: boolean
item_type?: string
}
interface ConfigData { schema: { fields: ConfigField[] }; values: Record<string, any>; revision: number }
@@ -81,33 +82,37 @@ function normOptions(opts: any): { value: string; label: string }[] {
}
export default function PluginsPage() {
const navigate = useNavigate()
const [plugins, setPlugins] = useState<CatalogPlugin[]>([])
const [groups, setGroups] = useState<GroupItem[]>([])
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const loadRef = useRef(false)
// 加载序号:并发 load 时只落地最后一次结果,避免旧响应覆盖新状态
const loadSeqRef = useRef(0)
const [configPlugin, setConfigPlugin] = useState<CatalogPlugin | null>(null)
const [configData, setConfigData] = useState<ConfigData | null>(null)
const [configLoading, setConfigLoading] = useState(false)
const [configSaving, setConfigSaving] = useState(false)
const [form, setForm] = useState<Record<string, any>>({})
// 密码/密钥类字段的明文显隐状态(按字段 key 记录)
const [revealed, setRevealed] = useState<Record<string, boolean>>({})
const configState = useOverlayState()
const load = useCallback(async () => {
if (loadRef.current) return
loadRef.current = true
setLoading(true)
const load = useCallback(async (silent = false) => {
const seq = ++loadSeqRef.current
if (!silent) setLoading(true)
try {
const [cat, grp] = await Promise.all([hubPluginCatalog(), hubGroups()])
if (seq !== loadSeqRef.current) return
setPlugins((cat && cat.plugins) || [])
setGroups((grp && grp.items) || [])
setError('')
} catch (e: any) {
if (seq !== loadSeqRef.current) return
setError(e.message || '加载失败')
} finally {
loadRef.current = false
setLoading(false)
if (seq === loadSeqRef.current) setLoading(false)
}
}, [])
@@ -117,6 +122,7 @@ export default function PluginsPage() {
setConfigPlugin(p)
setConfigData(null)
setConfigLoading(true)
setRevealed({})
configState.open()
try {
const d = await hubPluginGetConfig(p.id)
@@ -139,7 +145,7 @@ export default function PluginsPage() {
await hubPluginSaveConfig(configPlugin.id, form, configData?.revision ?? 0)
toast.success('配置已保存并热刷新')
configState.close()
await load()
await load(true)
} catch (e: any) {
toast.danger(e.message || '保存失败')
} finally {
@@ -153,9 +159,9 @@ export default function PluginsPage() {
const v = form[field.key]
if (field.type === 'bool') {
return (
<Checkbox key={field.key} isSelected={!!v} onChange={(val) => setField(field.key, val)}>
<Checkbox.Content><Checkbox.Control><Checkbox.Indicator /></Checkbox.Control>{field.label}</Checkbox.Content>
</Checkbox>
<Switch key={field.key} isSelected={!!v} onChange={(val) => setField(field.key, val)}>
<Switch.Content><Switch.Control><Switch.Thumb /></Switch.Control>{field.label}</Switch.Content>
</Switch>
)
}
if (field.type === 'enum') {
@@ -173,33 +179,101 @@ export default function PluginsPage() {
</div>
)
}
if (field.type === 'array') {
// 数组字段:表格式行编辑器(每行一个元素,可增删行),回传时按数组存储
const arr = Array.isArray(v) ? v : []
const isNum = field.item_type === 'int' || field.item_type === 'float'
const setItem = (i: number, val: string) => {
const next = [...arr]; next[i] = val; setField(field.key, next)
}
const removeRow = (i: number) => setField(field.key, arr.filter((_, x) => x !== i))
const addRow = () => setField(field.key, [...arr, ''])
return (
<div key={field.key}>
<Label>{field.label}</Label>
<div className="space-y-1.5">
{arr.map((item, i) => (
<div key={i} className="flex items-center gap-1">
<Input className="min-w-0 flex-1" type={isNum ? 'number' : 'text'} value={String(item ?? '')} onChange={e => setItem(i, e.target.value)} />
<Button size="sm" variant="tertiary" isIconOnly className="shrink-0 text-slate-400 hover:text-red-500" aria-label="删除该行" onPress={() => removeRow(i)}>
<TrashBin className="size-4" />
</Button>
</div>
))}
{!arr.length && <p className="text-xs text-slate-400">暂无内容,点击下方添加</p>}
<Button size="sm" variant="tertiary" className="w-full border border-dashed border-slate-200 text-slate-500 hover:border-slate-300 hover:text-slate-700" onPress={addRow}>+ 新增一行</Button>
</div>
{field.description && <p className="text-xs text-slate-400">{field.description}</p>}
</div>
)
}
if (field.type === 'text' || field.type === 'json' || field.type === 'object') {
const isJson = field.type === 'json'
return (
<TextField key={field.key}><Label>{field.label}</Label><TextArea rows={isJson ? 14 : 8} className={isJson ? 'min-h-[320px] font-mono text-xs' : 'min-h-[160px]'} value={v ?? ''} onChange={e => setField(field.key, e.target.value)} /></TextField>
)
}
const inputType = field.type === 'password' ? 'password' : (field.type === 'int' || field.type === 'float' ? 'number' : 'text')
const isSecret = field.type === 'password'
// 后端回传明文值;前端仅以掩码(小圆点)形式默认展示,点「眼睛」可查看
const inputType = isSecret
? (revealed[field.key] ? 'text' : 'password')
: (field.type === 'int' || field.type === 'float' ? 'number' : 'text')
return (
<TextField key={field.key}>
<Label>{field.label}</Label>
<Input type={inputType} value={v ?? ''} onChange={e => { const val = inputType === 'number' ? e.target.value : e.target.value; setField(field.key, val) }} />
{isSecret ? (
<div className="flex items-center gap-1">
<Input className="min-w-0 flex-1" type={inputType} value={v ?? ''} onChange={e => setField(field.key, e.target.value)} />
<Button size="sm" variant="tertiary" isIconOnly aria-label={revealed[field.key] ? '隐藏内容' : '显示内容'} onPress={() => setRevealed(prev => ({ ...prev, [field.key]: !prev[field.key] }))}>
{revealed[field.key] ? <EyeSlash className="size-4" /> : <Eye className="size-4" />}
</Button>
</div>
) : (
<Input type={inputType} value={v ?? ''} onChange={e => setField(field.key, e.target.value)} />
)}
{field.description && <p className="text-xs text-slate-400">{field.description}</p>}
</TextField>
)
}
// 乐观更新:先翻转本地状态,失败回滚;成功后静默对齐服务端(不闪加载态)
const patch = (id: string, fn: (p: CatalogPlugin) => CatalogPlugin) =>
setPlugins(prev => prev.map(p => (p.id === id ? fn(p) : p)))
const toggleGlobal = async (p: CatalogPlugin, enabled: boolean) => {
try { await hubPluginSetGlobal(p.id, { enabled }); await load() } catch (e: any) { toast.danger(e.message || '切换失败') }
const prev = p.enabled
patch(p.id, x => ({ ...x, enabled }))
try { await hubPluginSetGlobal(p.id, { enabled }); await load(true) }
catch (e: any) { patch(p.id, x => ({ ...x, enabled: prev })); toast.danger(e.message || '切换失败') }
}
const changeChat = async (p: CatalogPlugin, v: string) => {
try { await hubPluginSetGlobal(p.id, { chat: chatFromValue(v) }); await load() } catch (e: any) { toast.danger(e.message || '修改失败') }
const prevChat = p.chat
patch(p.id, x => ({ ...x, chat: chatFromValue(v) }))
try { await hubPluginSetGlobal(p.id, { chat: chatFromValue(v) }); await load(true) }
catch (e: any) { patch(p.id, x => ({ ...x, chat: prevChat })); toast.danger(e.message || '修改失败') }
}
const toggleGroup = async (p: CatalogPlugin, gid: number, enabled: boolean) => {
try { await hubPluginSetGroup(p.id, String(gid), { enabled }); await load() } catch (e: any) { toast.danger(e.message || '分群切换失败') }
const prevOv = p.groups[String(gid)]
patch(p.id, x => ({ ...x, groups: { ...x.groups, [String(gid)]: { enabled, chat: prevOv?.chat ?? [] } } }))
try { await hubPluginSetGroup(p.id, String(gid), { enabled }); await load(true) }
catch (e: any) {
patch(p.id, x => prevOv ? { ...x, groups: { ...x.groups, [String(gid)]: prevOv } }
: { ...x, groups: (() => { const g = { ...x.groups }; delete g[String(gid)]; return g })() })
toast.danger(e.message || '分群切换失败')
}
}
const resetGroup = async (p: CatalogPlugin, gid: number) => {
try { await hubPluginRemoveGroup(p.id, String(gid)); await load() } catch (e: any) { toast.danger(e.message || '重置失败') }
const resetAllGroups = async (p: CatalogPlugin) => {
const overridden = Object.keys(p.groups)
if (!overridden.length) return
patch(p.id, x => ({ ...x, groups: {} }))
try {
await Promise.all(overridden.map(gid => hubPluginRemoveGroup(p.id, gid)))
await load(true)
} catch (e: any) {
// 部分请求可能已成功:直接拉取服务端真实状态对齐,不做盲目回滚
toast.danger((e.message || '恢复默认失败') + '(部分群可能未还原)')
await load(true).catch(() => {})
}
}
const effectiveGroupEnabled = (p: CatalogPlugin, gid: number) => {
const ov = p.groups[String(gid)]
@@ -208,22 +282,23 @@ export default function PluginsPage() {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div className="min-w-0">
<h2 className="text-xl font-semibold text-slate-800">插件管理</h2>
<p className="text-sm text-slate-400">在所有插件之上统一控制:全局开关、群聊/私聊使用、分群控制、插件配置</p>
</div>
<Button size="sm" isDisabled={loading} onPress={() => load()}>{loading ? '加载中…' : '刷新'}</Button>
<Button size="sm" className="shrink-0" isDisabled={loading} onPress={() => load()}>{loading ? '加载中…' : '刷新'}</Button>
</div>
{error && <Card className="border border-red-200 bg-red-50"><Card.Content><p className="text-sm text-red-600">{error}</p></Card.Content></Card>}
{loading && !plugins.length && <div className="flex justify-center py-10"><Spinner /></div>}
{!loading && !plugins.length && !error && <p className="text-sm text-slate-400">未发现可管理的插件</p>}
<div className="space-y-3">
{plugins.map(p => {
const on = p.enabled
return (
<Card key={p.id} className="border border-slate-200 bg-white">
<Card key={p.id} className="border border-slate-200 bg-white transition-colors hover:border-slate-300">
<Card.Content>
<div className="flex flex-col gap-4 lg:flex-row lg:items-center">
<div className="min-w-0 flex-1">
@@ -239,7 +314,7 @@ export default function PluginsPage() {
<div className="flex flex-wrap items-center gap-4">
{p.has_config && <Button size="sm" onPress={() => openConfig(p)}>配置</Button>}
{p.has_web && p.web_path && <Link to={p.web_path}><Button size="sm">打开 Web 页面</Button></Link>}
{p.has_web && p.web_path && <Button size="sm" onPress={() => navigate(p.web_path!)}>打开 Web 页面</Button>}
<Select selectedKey={chatValue(p.chat)} onSelectionChange={(k) => changeChat(p, String(k))} aria-label="聊天类型">
<Select.Trigger className={chatSelectCls}><Select.Value /><Select.Indicator /></Select.Trigger>
@@ -253,17 +328,18 @@ export default function PluginsPage() {
</Dropdown.Trigger>
<Dropdown.Popover className="p-2" placement="bottom end">
<div className="w-64 max-h-72 overflow-y-auto space-y-1">
<div className="px-1 py-1 text-xs font-medium text-slate-500">分群控制(覆盖全局)</div>
<div className="flex items-center justify-between px-1 py-1">
<span className="text-xs font-medium text-slate-500">分群控制(覆盖全局)</span>
<Button size="sm" variant="tertiary" className="text-xs text-slate-500" isDisabled={!Object.keys(p.groups).length} onPress={() => resetAllGroups(p)}>恢复全部</Button>
</div>
{!groups.length && <div className="px-1 py-2 text-xs text-slate-400">未获取到群列表</div>}
{groups.map(g => {
const eff = effectiveGroupEnabled(p, g.group_id)
const hasOv = !!p.groups[String(g.group_id)]
return (
<div key={g.group_id} className="flex items-center justify-between gap-2 rounded-lg px-1.5 py-1 hover:bg-slate-50">
<div key={g.group_id} className="flex items-center gap-2 rounded-lg px-1.5 py-1 hover:bg-slate-50">
<Switch size="sm" isSelected={eff} onChange={(v) => toggleGroup(p, g.group_id, v)}>
<Switch.Content><Switch.Control><Switch.Thumb /></Switch.Control><span className="text-xs text-slate-700">{g.group_name || g.group_id}</span></Switch.Content>
</Switch>
{hasOv && <Button size="sm" variant="tertiary" onPress={() => resetGroup(p, g.group_id)}>重置</Button>}
</div>
)
})}
@@ -285,7 +361,7 @@ export default function PluginsPage() {
<Drawer state={configState}>
<Drawer.Backdrop>
<Drawer.Content placement="right">
<Drawer.Dialog className="w-[440px] max-w-[88vw]">
<Drawer.Dialog className="w-[600px] max-w-[88vw]">
<Drawer.Header>
<Drawer.Heading>{configPlugin?.name || ''} · 配置</Drawer.Heading>
<Drawer.CloseTrigger />
+14 -4
View File
@@ -8,8 +8,6 @@ import {
interface SettingsData {
ok: boolean
user: { id: number; username: string; created_at: string }
plugins: { id: string; name: string; icon: string }[]
version: string
}
interface BotConfigData {
@@ -40,6 +38,7 @@ export default function SettingsPage() {
const [restarting, setRestarting] = useState<null | 'stop' | 'restart'>(null)
const botState = useOverlayState()
const pollRef = useRef(0)
const pollTimerRef = useRef<number | null>(null)
const restartDisposeRef = useRef<(() => void) | null>(null)
const [botCfg, setBotCfg] = useState<BotConfigData | null>(null)
@@ -76,6 +75,7 @@ export default function SettingsPage() {
useEffect(() => () => {
if (restartDisposeRef.current) restartDisposeRef.current()
if (pollTimerRef.current != null) window.clearTimeout(pollTimerRef.current)
}, [])
const changePassword = async () => {
@@ -119,6 +119,13 @@ export default function SettingsPage() {
botState.open()
}
const clearPollTimer = () => {
if (pollTimerRef.current != null) {
window.clearTimeout(pollTimerRef.current)
pollTimerRef.current = null
}
}
const pollForReconnect = async () => {
const id = ++pollRef.current
// 先等旧进程完全退出(旧的 dashboard SSE 可能仍被旧进程服务,提前收到数据会误判就绪)
@@ -132,6 +139,8 @@ export default function SettingsPage() {
if (d && d.ok !== false) {
restartDisposeRef.current = null
dispose()
clearPollTimer()
pollRef.current += 1 // 让旧定时器即使未被清除也不会误判超时
setRestarting(null)
toast.success('bot 已重启并回连')
load()
@@ -139,7 +148,8 @@ export default function SettingsPage() {
},
})
restartDisposeRef.current = dispose
window.setTimeout(() => {
pollTimerRef.current = window.setTimeout(() => {
pollTimerRef.current = null
if (pollRef.current === id) {
restartDisposeRef.current = null
dispose()
@@ -169,7 +179,7 @@ export default function SettingsPage() {
<div className="space-y-6">
<h2 className="text-xl font-semibold text-slate-800">设置</h2>
<div className="grid gap-6 lg:grid-cols-2">
<Card className="border border-slate-200 bg-white lg:col-span-2">
<Card className="border border-slate-200 bg-white lg:col-span-2">
<Card.Header><Card.Title>修改密码</Card.Title></Card.Header>
<Card.Content>
<Form className="space-y-4">
+28 -39
View File
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useState } from 'react'
import { Button, Card, Input, Label, Select, ListBox, TextField, toast } from '@heroui/react'
import { Button, Card, Input, Label, Select, ListBox, Switch, TextField, toast } from '@heroui/react'
import { api } from '../../api/client'
import { Tabs, thCls, tdCls, selectCls, type TabItem } from '../../components/ui'
const gc = api('galgame_card')
@@ -12,32 +13,23 @@ 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> }
interface SettingsData { 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>
)
}
type GalTab = 'participants' | 'logs' | 'impressions' | 'summaries'
const GAL_TABS: TabItem[] = [
{ key: 'participants', label: '参与者' },
{ key: 'logs', label: '语料' },
{ key: 'impressions', label: '印象' },
{ key: 'summaries', label: '画像' },
]
const DATA_TABS: TabItem[] = GAL_TABS.slice(1)
export default function GalgameCardPage() {
const [view, setView] = useState<'groups' | 'detail' | 'data' | 'settings'>('groups')
@@ -65,12 +57,12 @@ export default function GalgameCardPage() {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div className="min-w-0">
<h2 className="text-xl font-semibold text-slate-800">群聊人设卡</h2>
<p className="text-sm text-slate-400">群采集开关 · 参与者 · 语料/印象/画像浏览</p>
</div>
<div className="flex items-center gap-3">
<div className="flex shrink-0 flex-wrap items-center gap-3 md:justify-end">
<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)}>
@@ -95,13 +87,12 @@ export default function GalgameCardPage() {
</tr></thead>
<tbody>
{groups.map(g => (
<tr key={g.group_id}>
<tr key={g.group_id} className="transition-colors hover:bg-slate-50">
<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>
<Switch size="sm" isSelected={g.enabled} onChange={(v) => toggleGroup(g, v)}>
<Switch.Content><Switch.Control><Switch.Thumb /></Switch.Control></Switch.Content>
</Switch>
</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>
@@ -124,7 +115,7 @@ export default function GalgameCardPage() {
function GroupDetail({ gid, onBack }: { gid: number; onBack: () => void }) {
const [tab, setTab] = useState<'participants' | 'logs' | 'impressions' | 'summaries'>('participants')
const [tab, setTab] = useState<GalTab>('participants')
const [participants, setParticipants] = useState<Participant[]>([])
const [logs, setLogs] = useState<LogRow[]>([])
const [logTotal, setLogTotal] = useState(0)
@@ -191,7 +182,7 @@ function GroupDetail({ gid, onBack }: { gid: number; onBack: () => void }) {
<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])} />
<Tabs tabs={GAL_TABS} active={tab} onChange={v => setTab(v as GalTab)} />
{tab === 'participants' && (
<div className="space-y-3">
<div className="flex items-center gap-3">
@@ -203,7 +194,7 @@ function GroupDetail({ gid, onBack }: { gid: number; onBack: () => void }) {
<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>
<tr key={p.user_id} className="transition-colors hover:bg-slate-50"><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>
@@ -221,7 +212,7 @@ function GroupDetail({ gid, onBack }: { gid: number; onBack: () => void }) {
<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>
<tr key={r.id} className="transition-colors hover:bg-slate-50"><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>
@@ -238,7 +229,7 @@ function GroupDetail({ gid, onBack }: { gid: number; onBack: () => void }) {
<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>
<tr key={r.id} className="transition-colors hover:bg-slate-50"><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>
@@ -264,7 +255,7 @@ function GroupDetail({ gid, onBack }: { gid: number; onBack: () => void }) {
function DataBrowser({ groups }: { groups: Group[] }) {
const [gid, setGid] = useState<number | ''>('')
const [tab, setTab] = useState<'logs' | 'impressions' | 'summaries'>('logs')
const [tab, setTab] = useState<GalTab>('logs')
const [logs, setLogs] = useState<LogRow[]>([])
const [impressions, setImpressions] = useState<ImpressionRow[]>([])
const [summaries, setSummaries] = useState<SummaryRow[]>([])
@@ -295,12 +286,12 @@ function DataBrowser({ groups }: { groups: Group[] }) {
</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])} />
<Tabs tabs={DATA_TABS} active={tab} onChange={v => setTab(v as GalTab)} />
{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.map(r => <tr key={r.id} className="transition-colors hover:bg-slate-50"><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>
@@ -309,7 +300,7 @@ function DataBrowser({ groups }: { groups: Group[] }) {
<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.map(r => <tr key={r.id} className="transition-colors hover:bg-slate-50"><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>
@@ -332,8 +323,6 @@ function SettingsView() {
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 ?? '—') + ' 秒'],
@@ -348,7 +337,7 @@ function SettingsView() {
<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>
<p className="mt-3 text-xs text-slate-400">阈值与脱敏规则见插件 processor.py / DESIGN.md;后台访问由统一管理台 /hub 提供。</p>
</Card.Content>
</Card>
)
+207 -42
View File
@@ -1,5 +1,5 @@
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 { Button, Card, Form, Input, Label, ListBox, Modal, Pagination, Select, Switch, Table, TextArea, TextField, toast, useOverlayState } from '@heroui/react'
import { Plus, ArrowUpFromLine, ArrowDownToLine, TrashBin, Pencil } from '@gravity-ui/icons'
import { api } from '../../api/client'
@@ -9,11 +9,21 @@ interface Item {
}
const hd = api('helldivers')
const TYPE_LABEL: Record<string, string> = { blue: '蓝·补给', red: '红·飞鹰', green: '绿·设备' }
const TYPE_LABEL: Record<string, string> = { blue: '武装·补给', red: '轨道·飞鹰', green: '战术·设备' }
function iconUrl(icon?: string) {
if (!icon) return ''
return '/api/helldivers/img/' + encodeURI(icon.replace(/^img\//, ''))
// 兼容两种存量路径:res/img/...(插件根相对,新)与 img/...(旧库,res/img 相对),
// 统一归一化为 res/img 下的相对路径再拼接 API 前缀。
let p = String(icon).replace(/\\/g, '/')
if (p.startsWith('res/img/')) p = p.slice('res/img/'.length)
else if (p.startsWith('res/')) p = p.slice('res/'.length)
else if (p.startsWith('img/')) p = p.slice('img/'.length)
return '/api/helldivers/img/' + encodeURI(p)
}
function svgDataUri(svg: string) {
return 'data:image/svg+xml;utf8,' + encodeURIComponent(svg)
}
function TypeChip({ type }: { type: string }) {
@@ -22,6 +32,22 @@ function TypeChip({ type }: { type: string }) {
}
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'
const PAGE_SIZE_OPTIONS = [10, 20, 50, 100]
const TYPE_OPTIONS = [['all', '全部类型'], ...Object.entries(TYPE_LABEL)]
const BACKPACK_OPTIONS = [['all', '全部'], ['0', '否'], ['1', '是']]
// 页码折叠:1 … current±1 … 末页,避免全量渲染
function elidePages(current: number, count: number): (number | '...')[] {
if (count <= 7) return Array.from({ length: count }, (_, i) => i + 1)
const out: (number | '...')[] = [1]
const lo = Math.max(2, current - 1)
const hi = Math.min(count - 1, current + 1)
if (lo > 2) out.push('...')
for (let p = lo; p <= hi; p++) out.push(p)
if (hi < count - 1) out.push('...')
out.push(count)
return out
}
async function copyText(text: string) {
try { await navigator.clipboard.writeText(text); return true }
@@ -31,6 +57,15 @@ async function copyText(text: string) {
}
}
function readSvgFile(f: File) {
return new Promise<string>((resolve, reject) => {
const r = new FileReader()
r.onload = () => resolve(String(r.result))
r.onerror = () => reject(r.error)
r.readAsText(f, 'utf-8')
})
}
function AddModal({ onSaved }: { onSaved: () => void }) {
const state = useOverlayState()
const [name, setName] = useState('')
@@ -38,17 +73,38 @@ function AddModal({ onSaved }: { onSaved: () => void }) {
const [type, setType] = useState('blue')
const [backpack, setBackpack] = useState(false)
const [alias, setAlias] = useState('')
const [iconSvg, setIconSvg] = useState('')
const [iconName, setIconName] = useState('')
const [busy, setBusy] = useState(false)
const [err, setErr] = useState('')
const iconInputRef = useRef<HTMLInputElement>(null)
const pickIcon = async (e: any) => {
const f = e.target.files?.[0]
if (!f) return
if (!/\.svg$/i.test(f.name)) { toast('请上传 SVG 文件', { variant: 'warning' }); return }
try {
setIconSvg(await readSvgFile(f))
setIconName(f.name)
} catch (e2: any) { toast('读取图标失败: ' + (e2?.message || e2), { variant: 'danger' }) }
}
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)
const created = await hd.post('stratagems', { name, name_en: nameEn, type, backpack: backpack ? 1 : 0, alias: alias.split(/[,,、\s]+/).map(s => s.trim()).filter(Boolean), icon: '' })
if (iconSvg) {
await hd.post(`stratagems/${created.item.id}/icon`, { svg: iconSvg })
toast('图标上传成功', { variant: 'success' })
}
toast('新增成功', { variant: 'success' })
setName(''); setNameEn(''); setAlias(''); setBackpack(false); setIconSvg(''); setIconName('')
state.close(); onSaved()
} catch (e: any) { setErr(e.message || '新增失败') } finally { setBusy(false) }
} catch (e: any) {
setErr(e?.message || '新增失败')
toast('新增失败: ' + (e?.message || e), { variant: 'danger' })
} finally { setBusy(false) }
}
return (
@@ -71,13 +127,28 @@ function AddModal({ onSaved }: { onSaved: () => void }) {
</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>
<Switch isSelected={backpack} onChange={setBackpack}><Switch.Content><Switch.Control><Switch.Thumb /></Switch.Control>背包位</Switch.Content></Switch>
<div className="sm:col-span-2">
<Label>图标(SVG)</Label>
<div className="flex items-center gap-3">
{iconSvg ? (
<>
<img src={svgDataUri(iconSvg)} className="w-12 h-12 object-contain rounded border border-slate-200" alt="" />
<span className="text-sm text-slate-600">{iconName}</span>
<Button size="sm" variant="tertiary" onPress={() => { setIconSvg(''); setIconName('') }}>移除</Button>
</>
) : (
<Button size="sm" onPress={() => iconInputRef.current?.click()}>上传图标(SVG)</Button>
)}
<input ref={iconInputRef} type="file" accept=".svg,image/svg+xml" className="hidden" onChange={pickIcon} />
</div>
</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={add}>{busy ? '新增中…' : '新增'}</Button>
<Button isDisabled={busy} isPending={busy} onPress={add}>{busy ? '保存中…' : '保存'}</Button>
</Modal.Footer>
</Modal.Dialog></Modal.Container></Modal.Backdrop>
</Modal>
@@ -128,23 +199,46 @@ function ImportModal({ onSaved }: { onSaved: () => void }) {
}
function EditModal({ item, framed, onSaved }: { item: Item; framed: boolean; onSaved: () => void }) {
function EditModal({ item, onSaved }: { item: Item; 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 [iconSvg, setIconSvg] = useState('')
const [iconName, setIconName] = useState('')
const [busy, setBusy] = useState(false)
const [err, setErr] = useState('')
const iconInputRef = useRef<HTMLInputElement>(null)
const currentIcon = item.icon || item.path || ''
const pickIcon = async (e: any) => {
const f = e.target.files?.[0]
if (!f) return
if (!/\.svg$/i.test(f.name)) { toast('请上传 SVG 文件', { variant: 'warning' }); return }
try {
setIconSvg(await readSvgFile(f))
setIconName(f.name)
} catch (e2: any) { toast('读取图标失败: ' + (e2?.message || e2), { variant: 'danger' }) }
}
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 })
// 若有新图标,先上传(后端会转换并在 DB 里更新 icon)
if (iconSvg) {
await hd.post(`stratagems/${item.id}/icon`, { svg: iconSvg })
toast('图标上传成功', { variant: 'success' })
}
// 更新其它字段时不要带 icon,避免覆盖刚上传的新图标
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) })
toast('保存成功', { variant: 'success' })
state.close(); onSaved()
} catch (e: any) { setErr(e.message || '保存失败') } finally { setBusy(false) }
} catch (e: any) {
setErr(e?.message || '保存失败')
toast('保存失败: ' + (e?.message || e), { variant: 'danger' })
} finally { setBusy(false) }
}
return (
@@ -168,15 +262,38 @@ function EditModal({ item, framed, onSaved }: { item: Item; framed: boolean; onS
</Select.Popover>
</Select>
</div>
<Checkbox isSelected={backpack} onChange={setBackpack}><Checkbox.Content><Checkbox.Control><Checkbox.Indicator /></Checkbox.Control>背包位</Checkbox.Content></Checkbox>
<Switch isSelected={backpack} onChange={setBackpack}><Switch.Content><Switch.Control><Switch.Thumb /></Switch.Control>背包位</Switch.Content></Switch>
<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>
<div className="sm:col-span-2">
<Label>图标</Label>
<div className="flex items-center gap-3">
{iconSvg ? (
<>
<img src={svgDataUri(iconSvg)} className="w-12 h-12 object-contain rounded border border-slate-200" alt="" />
<span className="text-sm text-slate-600">{iconName}(新)</span>
<Button size="sm" variant="tertiary" onPress={() => { setIconSvg(''); setIconName('') }}>取消更换</Button>
</>
) : currentIcon ? (
<>
<img src={iconUrl(currentIcon)} className="w-12 h-12 object-contain rounded border border-slate-200" alt="" />
<span className="text-sm text-slate-600">更换图标</span>
<Button size="sm" variant="tertiary" onPress={() => iconInputRef.current?.click()}>更换</Button>
</>
) : (
<>
<span className="text-sm text-slate-400">暂无图标</span>
<Button size="sm" onPress={() => iconInputRef.current?.click()}>上传图标(SVG)</Button>
</>
)}
<input ref={iconInputRef} type="file" accept=".svg,image/svg+xml" className="hidden" onChange={pickIcon} />
</div>
</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>
<Button isDisabled={busy} isPending={busy} onPress={save}>{busy ? '保存中…' : '保存'}</Button>
</Modal.Footer>
</Modal.Dialog>
</Modal.Container>
@@ -188,44 +305,42 @@ function EditModal({ item, framed, onSaved }: { item: Item; framed: boolean; onS
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 [pageSize, setPageSize] = useState(10)
const [total, setTotal] = useState(0)
const uploadRef = useRef<HTMLInputElement>(null)
const uploadTarget = useRef<number | null>(null)
const [loading, setLoading] = useState(false)
const [keyword, setKeyword] = useState('')
const [filterType, setFilterType] = useState('all')
const [filterBackpack, setFilterBackpack] = useState('all')
const [query, setQuery] = useState({ keyword: '', type: '', backpack: '' })
const deleteState = useOverlayState()
const [deleteTarget, setDeleteTarget] = useState<Item | null>(null)
const reload = async () => {
setLoading(true)
try {
const d = await hd.get(`stratagems?page=${page}&page_size=${pageSize}`)
const params = new URLSearchParams({ page: String(page), page_size: String(pageSize) })
if (query.keyword) params.set('q', query.keyword)
if (query.type) params.set('type', query.type)
if (query.backpack) params.set('backpack', query.backpack)
const d = await hd.get(`stratagems?${params.toString()}`)
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' }) }
finally { setLoading(false) }
}
useEffect(() => { reload() }, [page, pageSize])
useEffect(() => { reload() }, [page, pageSize, query])
const applyQuery = () => { setPage(1); setQuery({ keyword: keyword.trim(), type: filterType === 'all' ? '' : filterType, backpack: filterBackpack === 'all' ? '' : filterBackpack }) }
const resetQuery = () => { setKeyword(''); setFilterType('all'); setFilterBackpack('all'); setPage(1); setQuery({ keyword: '', type: '', backpack: '' }) }
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')
@@ -240,16 +355,58 @@ export function HelldiversPage() {
<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.Content>
<div className="flex flex-wrap items-end gap-3">
<div className="flex flex-col gap-1">
<span className="text-xs text-slate-500">关键词</span>
<Input value={keyword} onChange={e => setKeyword(e.target.value)} placeholder="搜索名称/别名" className="w-52" />
</div>
<div className="flex flex-col gap-1">
<span className="text-xs text-slate-500">类型</span>
<div className="w-36">
<Select selectedKey={filterType} onSelectionChange={(k) => setFilterType(String(k))}>
<Select.Trigger><Select.Value /></Select.Trigger>
<Select.Popover>
<ListBox>{TYPE_OPTIONS.map(([k, v]) => <ListBox.Item key={k} id={k}>{v}</ListBox.Item>)}</ListBox>
</Select.Popover>
</Select>
</div>
</div>
<div className="flex flex-col gap-1">
<span className="text-xs text-slate-500">背包</span>
<div className="w-28">
<Select selectedKey={filterBackpack} onSelectionChange={(k) => setFilterBackpack(String(k))}>
<Select.Trigger><Select.Value /></Select.Trigger>
<Select.Popover>
<ListBox>{BACKPACK_OPTIONS.map(([k, v]) => <ListBox.Item key={k} id={k}>{v}</ListBox.Item>)}</ListBox>
</Select.Popover>
</Select>
</div>
</div>
<div className="flex gap-2">
<Button size="sm" isDisabled={loading} isPending={loading} onPress={applyQuery}>{loading ? '查询中…' : '查询'}</Button>
<Button size="sm" variant="tertiary" isDisabled={loading} onPress={resetQuery}>重置</Button>
</div>
</div>
</Card.Content>
</Card>
<Card className="border border-slate-200 bg-white">
<Card.Header><Card.Title>战备列表(共 {total} 条)</Card.Title></Card.Header>
<Card.Content>
{loading && (
<div className="flex items-center gap-2 py-2 text-sm text-slate-500">
<span className="inline-block size-4 animate-spin rounded-full border-2 border-slate-300 border-t-slate-600" />
加载中…
</div>
)}
<Table>
<Table.ScrollContainer>
<Table.Content aria-label="战备列表">
@@ -270,8 +427,7 @@ export function HelldiversPage() {
<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>
<EditModal key={it.id + '-' + it.name} item={it} onSaved={reload} />
<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>
@@ -280,7 +436,7 @@ export function HelldiversPage() {
</Table.Content>
</Table.ScrollContainer>
</Table>
<Pagination className="pt-4">
<Pagination className="pt-4 !w-auto !justify-start">
<Pagination.Summary>共 {total} 条 · 第 {page} / {Math.max(1, Math.ceil(total / pageSize))} 页</Pagination.Summary>
<Pagination.Content>
<Pagination.Item>
@@ -288,9 +444,11 @@ export function HelldiversPage() {
<Pagination.PreviousIcon /><span>上一页</span>
</Pagination.Previous>
</Pagination.Item>
{Array.from({ length: Math.max(1, Math.ceil(total / pageSize)) }, (_, i) => (
{elidePages(page, Math.max(1, Math.ceil(total / pageSize))).map((p, i) => (
<Pagination.Item key={i}>
<Pagination.Link isActive={page === i + 1} onPress={() => setPage(i + 1)}>{i + 1}</Pagination.Link>
{p === '...'
? <span className="px-2 text-slate-400">…</span>
: <Pagination.Link isActive={page === p} onPress={() => setPage(p)}>{p}</Pagination.Link>}
</Pagination.Item>
))}
<Pagination.Item>
@@ -299,12 +457,19 @@ export function HelldiversPage() {
</Pagination.Next>
</Pagination.Item>
</Pagination.Content>
<div className="ml-auto flex items-center gap-2">
<span className="text-sm text-slate-600">每页</span>
<Select selectedKey={String(pageSize)} onSelectionChange={(k) => { setPage(1); setPageSize(Number(k)) }} className="w-20">
<Select.Trigger><Select.Value /></Select.Trigger>
<Select.Popover>
<ListBox>{PAGE_SIZE_OPTIONS.map(v => <ListBox.Item key={v} id={String(v)}>{v} 条</ListBox.Item>)}</ListBox>
</Select.Popover>
</Select>
</div>
</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>
@@ -319,4 +484,4 @@ export function HelldiversPage() {
)
}
export default HelldiversPage
export default HelldiversPage
+47 -61
View File
@@ -1,7 +1,8 @@
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 { Button, Card, Input, Label, Select, ListBox, Switch, TextArea, TextField, toast } from '@heroui/react'
import { api } from '../../api/client'
import { PageHead, Pager, Tabs, thCls, tdCls, selectCls, type TabItem } from '../../components/ui'
import { fmtTime } from '../../lib/format'
const lc = api('learning_chat')
@@ -13,38 +14,17 @@ function data(res: any) {
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>
}
type MainTab = 'config' | 'messages' | 'contexts' | 'answers' | 'blacklist'
const MAIN_TABS: TabItem[] = [
{ key: 'config', label: '配置' },
{ key: 'messages', label: '消息' },
{ key: 'contexts', label: '学习内容' },
{ key: 'answers', label: '回复' },
{ key: 'blacklist', label: '禁用列表' },
]
interface GlobalConfig {
total_enable: boolean; enable_web: boolean; web_username: string; web_password: string; web_secret_key: string
total_enable: boolean
KEYWORDS_SIZE: number; cross_group_threshold: number; learn_max_count: number
ban_words: string[]; ban_users: string[]; dictionary: string[]
}
@@ -57,11 +37,11 @@ interface GroupConfig {
interface GroupOption { label: string; value: number }
export default function LearningChatPage() {
const [tab, setTab] = useState<'config' | 'messages' | 'contexts' | 'answers' | 'blacklist'>('config')
const [tab, setTab] = useState<MainTab>('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])} />
<Tabs tabs={MAIN_TABS} active={tab} onChange={v => setTab(v as MainTab)} />
{tab === 'config' && <ConfigView />}
{tab === 'messages' && <MessagesView />}
{tab === 'contexts' && <ContextsView />}
@@ -127,11 +107,8 @@ function ConfigView() {
<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>
<Switch isSelected={!!globalCfg.total_enable} onChange={v => setG('total_enable', v)}><Switch.Content><Switch.Control><Switch.Thumb /></Switch.Control>群聊学习总开关</Switch.Content></Switch>
<p className="text-xs text-slate-400">后台访问与账号由统一管理台 /hub 提供。</p>
</Card.Content>
</Card>
<Card className="border border-slate-200 bg-white">
@@ -163,7 +140,7 @@ function ConfigView() {
<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>
<Switch isSelected={!!groupCfg.enable} onChange={v => setGrp('enable', v)}><Switch.Content><Switch.Control><Switch.Thumb /></Switch.Control>群聊学习开关</Switch.Content></Switch>
<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>
@@ -172,7 +149,7 @@ function ConfigView() {
<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>
<Switch isSelected={!!groupCfg.speak_enable} onChange={v => setGrp('speak_enable', v)}><Switch.Content><Switch.Control><Switch.Thumb /></Switch.Control>主动发言开关</Switch.Content></Switch>
<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>
@@ -217,12 +194,15 @@ interface Msg { id: number; message_id: string | number; group_id: number; user_
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 [query, setQuery] = useState<{ g: string; u: string; m: string }>({ g: '', u: '', m: '' })
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])
try { const d = await data(await lc.get(`get_chat_messages?page=${page}&perPage=${perPage}&group_id=${query.g}&user_id=${query.u}&message=${query.m}`)); setRows((d && d.items) || []); setTotal((d && d.total) || 0) } catch (e: any) { toast.danger(e.message) } finally { setLoading(false) }
}, [page, query])
useEffect(() => { load() }, [load])
const search = () => { setPage(1); setQuery({ g, u, m }) }
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) } }
@@ -231,16 +211,16 @@ function MessagesView() {
<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>
<Input className="w-36" placeholder="群ID" value={g} onChange={e => setG(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') search() }} />
<Input className="w-36" placeholder="用户ID" value={u} onChange={e => setU(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') search() }} />
<Input className="flex-1 min-w-[200px]" placeholder="消息内容" value={m} onChange={e => setM(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') search() }} />
<Button size="sm" onPress={search}>搜索</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'}>
{rows.map(r => <tr key={r.id} className="transition-colors hover:bg-slate-50"><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}>{fmtTime(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>)}
@@ -258,8 +238,10 @@ 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])
const [qkw, setQkw] = useState('')
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=${qkw}`)); setRows((d && d.items) || []); setTotal((d && d.total) || 0) } catch (e: any) { toast.danger(e.message) } finally { setLoading(false) } }, [page, qkw])
useEffect(() => { load() }, [load])
const search = () => { setPage(1); setQkw(kw) }
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) } }
@@ -268,14 +250,14 @@ function ContextsView() {
<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>
<Input className="flex-1 min-w-[200px]" placeholder="搜索关键词" value={kw} onChange={e => setKw(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') search() }} />
<Button size="sm" onPress={search}>搜索</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'}>
{rows.map(r => <tr key={r.id} className="transition-colors hover:bg-slate-50"><td className={tdCls}>{r.id}</td><td className={tdCls + ' max-w-[320px] truncate'}>{r.keywords}</td><td className={tdCls}>{fmtTime(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>)}
@@ -293,8 +275,10 @@ interface Ans { id: number; group_id: number; keywords: string; time: number; co
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])
const [qkw, setQkw] = useState('')
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=${qkw}`)); setRows((d && d.items) || []); setTotal((d && d.total) || 0) } catch (e: any) { toast.danger(e.message) } finally { setLoading(false) } }, [page, qkw])
useEffect(() => { load() }, [load])
const search = () => { setPage(1); setQkw(kw) }
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) } }
@@ -303,14 +287,14 @@ function AnswersView() {
<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>
<Input className="flex-1 min-w-[200px]" placeholder="搜索关键词" value={kw} onChange={e => setKw(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') search() }} />
<Button size="sm" onPress={search}>搜索</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'}>
{rows.map(r => <tr key={r.id} className="transition-colors hover:bg-slate-50"><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>)}
@@ -328,8 +312,10 @@ interface Bl { id: number; keywords: string; bans: string; global_ban?: boolean;
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])
const [query, setQuery] = useState({ kw: '', bans: '' })
const load = useCallback(async () => { setLoading(true); try { const d = await data(await lc.get(`get_chat_blacklist?page=${page}&perPage=${perPage}&keywords=${query.kw}&bans=${query.bans}`)); setRows((d && d.items) || []); setTotal((d && d.total) || 0) } catch (e: any) { toast.danger(e.message) } finally { setLoading(false) } }, [page, query])
useEffect(() => { load() }, [load])
const search = () => { setPage(1); setQuery({ kw, bans }) }
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 (
@@ -337,15 +323,15 @@ function BlacklistView() {
<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>
<Input className="w-40" placeholder="搜索关键词" value={kw} onChange={e => setKw(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') search() }} />
<Input className="w-40" placeholder="搜索禁用群" value={bans} onChange={e => setBans(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') search() }} />
<Button size="sm" onPress={search}>搜索</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'}>
{rows.map(r => <tr key={r.id} className="transition-colors hover:bg-slate-50"><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>}
+21 -16
View File
@@ -4,6 +4,9 @@ import { Toast } from '@heroui/react'
import { AuthProvider } from './auth/auth'
import { LoginPage } from './auth/LoginPage'
import { AppLayout } from './layout/AppLayout'
import { ErrorBoundary } from './components/ErrorBoundary'
import { ConnectionProvider } from './connection'
import { LogProvider } from './logs'
import { pluginModules } from './plugins'
import HomePage from './pages/home'
import PluginsPage from './pages/plugins'
@@ -14,22 +17,24 @@ 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 />} />
<Route path="logs" element={<LogsPage />} />
{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>
<ErrorBoundary>
<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={<ConnectionProvider><LogProvider><AppLayout /></LogProvider></ConnectionProvider>}>
<Route index element={<HomePage />} />
<Route path="plugins" element={<PluginsPage />} />
<Route path="settings" element={<SettingsPage />} />
<Route path="logs" element={<LogsPage />} />
{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>
</ErrorBoundary>
</AuthProvider>
)
}
+1 -1
View File
@@ -1 +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/logs/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"}
{"root":["./src/app.tsx","./src/connection.tsx","./src/logs.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/components/errorboundary.tsx","./src/components/ui.tsx","./src/layout/applayout.tsx","./src/lib/format.ts","./src/pages/home/index.tsx","./src/pages/logs/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"}
+75 -2
View File
@@ -9,6 +9,7 @@
from __future__ import annotations
import hashlib
import json
import os
import secrets
import sqlite3
@@ -104,11 +105,78 @@ def init_auth_db() -> None:
conn.commit()
def _env_has_password_key() -> bool:
""".env 中是否显式配置了 hexi_web_password(区别于代码默认值 admin)。"""
env_path = Path(__file__).resolve().parent.parent / ".env"
if not env_path.exists():
return False
for line in env_path.read_text(encoding="utf-8").splitlines():
line = line.strip()
if line.startswith("hexi_web_password=") or line.startswith(
"hexi_web_password ="
):
return True
return False
def _write_env_password(password: str) -> None:
"""把 Web 端修改后的密码回写 .env,保持 .env 永远是权威源(重启后不还原)。"""
env_path = Path(__file__).resolve().parent.parent / ".env"
try:
lines = (
env_path.read_text(encoding="utf-8").splitlines()
if env_path.exists()
else []
)
replaced = False
out: list[str] = []
for line in lines:
stripped = line.strip()
if stripped.startswith("hexi_web_password=") or stripped.startswith(
"hexi_web_password ="
):
if not replaced:
# JSON 字符串写法可安全转义 # 等特殊字符,dotenv 解析后与裸值一致
out.append("hexi_web_password=" + json.dumps(password))
replaced = True
else:
out.append(line)
if not replaced:
out.append("hexi_web_password=" + json.dumps(password))
env_path.write_text(
"\n".join(out).rstrip("\n") + "\n", encoding="utf-8"
)
except OSError:
pass
def _seed_admin_if_missing(username: str, password: str) -> None:
with _conn() as conn:
row = conn.execute(
"SELECT id FROM web_users WHERE username=?", (username,)
).fetchone()
if row is None:
conn.execute(
"INSERT INTO web_users(username,password_hash,is_active,created_at) VALUES(?,?,1,?)",
(username, _hash_password(password), _now()),
)
conn.commit()
def sync_admin() -> None:
"""启动时调用:以 .env 为准创建/同步超级管理员(含改密码场景)。"""
"""启动时调用:以 .env 为准创建/同步超级管理员。
仅当 .env 显式配置了 hexi_web_password 时才会在每次启动同步密码
(此时 .env 是权威源,重启始终以 .env 覆盖);若 .env 未显式配置,
则只在用户不存在时播种默认账号,保证 Web 端「修改密码」能持久生效、
不被每次启动静默还原。
"""
init_auth_db()
username = _read_env("hexi_web_username", "admin")
password = _read_env("hexi_web_password", "admin")
if not _env_has_password_key():
_seed_admin_if_missing(username, password)
return
with _conn() as conn:
row = conn.execute(
"SELECT id FROM web_users WHERE username=?", (username,)
@@ -155,7 +223,11 @@ def revoke_user_tokens(user_id: int, except_token: Optional[str] = None) -> None
def change_password(
user_id: int, old_password: str, new_password: str, keep_token: Optional[str] = None
) -> bool:
"""校验旧密码后改新密码,并吊销旧令牌(保留当前令牌可选)。成功返回 True。"""
"""校验旧密码后改新密码,并吊销旧令牌(保留当前令牌可选)。成功返回 True。
成功后会回写 .env(hexi_web_password),保证重启后仍是新密码:
否则 sync_admin 会按 .env 默认值把密码还原。
"""
if not new_password:
return False
init_auth_db()
@@ -170,6 +242,7 @@ def change_password(
(_hash_password(new_password), user_id),
)
conn.commit()
_write_env_password(new_password)
revoke_user_tokens(user_id, except_token=keep_token)
return True
+33 -7
View File
@@ -11,7 +11,7 @@
"default": ..., # 默认值
"description": "...", # 提示
"env": "WEB_PASSWORD", # 环境变量名(缺省=key.upper())
"secret": true, # 是否敏感(前端脱敏/掩码)
"secret": true, # 是否敏感(前端默认掩码显示,回传明文)
"options": [...], # enum 可选项
"required": false,
}
@@ -28,6 +28,7 @@ from __future__ import annotations
import json
import os
import re
import threading
from pathlib import Path
from typing import Any, Callable, Optional
@@ -39,7 +40,7 @@ DATA_DIR = _ROOT / "config"
STORE_PATH = DATA_DIR / "plugin_config.json"
ENV_PATH = _ROOT.parent / ".env" # 仓库根 .env
_TYPES = {"string", "text", "password", "int", "float", "bool", "enum", "json", "object", "path", "object_set"}
_TYPES = {"string", "text", "password", "int", "float", "bool", "enum", "json", "object", "path", "object_set", "array"}
# plugin_id -> schema
_schemas: dict[str, dict[str, Any]] = {}
@@ -209,6 +210,23 @@ def _coerce(field: dict[str, Any], raw: Any) -> Any:
return float(raw)
except (TypeError, ValueError) as e:
raise ValueError(f"字段 {field['key']} 必须是数字") from e
if typ == "array":
item_type = str(field.get("item_type") or "str")
if isinstance(raw, str):
# 防御:兼容旧前端提交的多行/逗号分隔文本
raw = [p.strip() for p in re.split(r"[,\n]+", raw) if p.strip()]
if not isinstance(raw, list):
raise ValueError(f"字段 {field['key']} 必须是数组")
out = []
for item in raw:
if item_type in {"int", "float"}:
try:
out.append(int(item) if item_type == "int" else float(item))
except (TypeError, ValueError) as e:
raise ValueError(f"字段 {field['key']} 含非法数字: {item}") from e
else:
out.append(str(item))
return out
if typ in {"string", "text", "password"}:
return "" if raw is None else str(raw)
if typ == "enum":
@@ -240,9 +258,16 @@ def get_config(plugin_id: str) -> Optional[dict[str, Any]]:
values[key] = f["default"]
else:
values[key] = None
for field in schema.get("fields", []):
if field.get("secret") and values.get(field["key"]) not in (None, ""):
values[field["key"]] = MASKED_SECRET
# 密钥类字段回传明文:该管理台仅本地局域网/本机使用,无外泄面;
# 明文由前端默认以掩码(小圆点)形式展示,用户点「眼睛」才可查看。
# array 字段兼容旧存储:text 时期保存的是换行/逗号分隔字符串,归一化为数组
for f in schema.get("fields", []):
key = f["key"]
if f.get("type") == "array" and isinstance(values.get(key), str):
try:
values[key] = _coerce(f, values[key])
except ValueError:
values[key] = []
return {"schema": schema, "values": values, "revision": _revisions.get(plugin_id, 0)}
@@ -272,9 +297,9 @@ def _update_env(plugin_id: str, values: dict[str, Any]) -> None:
key = f["key"]
if key not in values:
continue
# text/json/object/path 这类非标量不适合写进 .env(重启解析会出错),
# text/json/object/path/array 这类非标量不适合写进 .env(重启解析会出错),
# 这类字段仅在运行期热更新,不落盘 .env(见 config_standard 说明)。
if f.get("type") in {"text", "json", "object", "path"}:
if f.get("type") in {"text", "json", "object", "path", "array"}:
continue
env_name = _field_env(f)
raw = values[key]
@@ -340,6 +365,7 @@ def save_config(
if key not in values:
continue
raw = values[key]
# 兼容旧版本前端回传的掩码占位:不覆盖已保存的明文
if f.get("secret") and raw == MASKED_SECRET:
continue
if raw in (None, ""):