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/ # 运行数据(不入库) │ ├── data/ # 运行数据(不入库)
│ ├── config/ # 业务配置(不入库) │ ├── config/ # 业务配置(不入库)
│ ├── resource/ # 全局静态资源(fonts/ 字体、menu_config 等) │ ├── resource/ # 全局静态资源(fonts/ 字体、menu_config 等)
│ ├── web/ # 统一 Web 前端(Vue 3) │ ├── web/ # 统一 Web 前端(React + Vite,构建产物 dist/ 供 /hub 挂载)
│ ├── logs/ # 日志(不入库) │ ├── logs/ # 日志(不入库)
│ └── web_*.py # Web 管理相关(鉴权、插件注册) │ └── web_*.py # Web 管理相关(鉴权、插件注册)
├── docs/ # 插件开发标准文档 ├── docs/ # 插件开发标准文档
@@ -118,9 +118,9 @@ poetry run pyright # 类型检查
新增插件请参考: 新增插件请参考:
- [插件结构标准](docs/plugin-structure-standard.md) - [插件结构标准](dev/docs/plugin-structure-standard.md)
- [插件配置标准](docs/plugin-config-standard.md) - [插件配置标准](dev/docs/plugin-config-standard.md)
- `plugin_template/` 脚手架模板 - `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: if origin is Literal:
return "enum" return "enum"
if origin in (list, set, tuple): if origin in (list, set, tuple):
args = get_args(annotation) return "array"
if args and args[0] is str:
return "text"
return "text"
return "string" return "string"
@@ -119,11 +116,11 @@ def _coerce_value(value: Any, typ: str, item_type: str = "str") -> Any:
return float(value) return float(value)
except (TypeError, ValueError): except (TypeError, ValueError):
return None return None
if typ == "text": if typ in {"text", "array"}:
if isinstance(value, (list, tuple, set)): if isinstance(value, (list, tuple, set)):
items = list(value) items = list(value)
else: else:
# TextArea 多行/逗号分隔都接受 # 数组字段的字符串输入:多行/逗号分隔都接受
import re as _re import re as _re
parts = _re.split(r"[,\n]+", str(value)) parts = _re.split(r"[,\n]+", str(value))
items = [p.strip() for p in parts if p.strip()] 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: def _stringify(value: Any, typ: str) -> Any:
"""把 python 值转成 Web 表单能显示的值。""" """把 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 typ == "text":
if value is None: if value is None:
return "" return ""
@@ -240,7 +247,7 @@ def register_model_config(
} }
if typ == "enum": if typ == "enum":
field["options"] = (options or {}).get(key) or _enum_options(annotation) 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) field["item_type"] = _text_item_type(annotation)
schema_fields.append(field) schema_fields.append(field)
@@ -353,7 +360,7 @@ def register_config_items(
} }
if item.get("options"): if item.get("options"):
field["options"] = item["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") field["item_type"] = item.get("item_type", "str")
if item.get("type") == "object_set" and item.get("item_schema"): if item.get("type") == "object_set" and item.get("item_schema"):
field["item_schema"] = item["item_schema"] field["item_schema"] = item["item_schema"]
@@ -435,7 +442,7 @@ def register_object_set(
"description": sf.get("description", ""), "description": sf.get("description", ""),
"secret": sf.get("secret", _is_secret(sf["key"])), "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") field["item_type"] = sf.get("item_type", "str")
opts = (options_by_key or {}).get(sf["key"]) or sf.get("options") opts = (options_by_key or {}).get(sf["key"]) or sf.get("options")
if opts: if opts:
@@ -24,6 +24,16 @@ BLOCK_CODE = ["350235", "350234"]
WHITE_LIST = ["1019165018"] 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: def register_config() -> None:
register_config_items( register_config_items(
_PLUGIN_ID, _PLUGIN_ID,
@@ -31,24 +41,35 @@ def register_config() -> None:
{ {
"key": "TEXT_TEMPLATES", "key": "TEXT_TEMPLATES",
"label": "随机文案模板", "label": "随机文案模板",
"type": "text", "type": "array",
"item_type": "str", "item_type": "str",
"default": TEXT_TEMPLATES, "default": TEXT_TEMPLATES,
}, },
{ {
"key": "BLOCK_CODE", "key": "BLOCK_CODE",
"label": "屏蔽码", "label": "屏蔽码",
"type": "text", "type": "array",
"item_type": "str", "item_type": "str",
"default": BLOCK_CODE, "default": BLOCK_CODE,
}, },
{ {
"key": "WHITE_LIST", "key": "WHITE_LIST",
"label": "白名单群号", "label": "白名单群号",
"type": "text", "type": "array",
"item_type": "str", "item_type": "str",
"default": WHITE_LIST, "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__], store=_sys.modules[__name__],
) )
@@ -136,7 +136,7 @@ register_model_config(
"proxy", "proxy",
], ],
types={ types={
"steam_api_key": "text", "steam_api_key": "array",
"steam_broadcast_type": "enum", "steam_broadcast_type": "enum",
"steam_playtime_theme": "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 # hexi/web 是统一 Web 管理台前端(hexi/web/dist),不是插件目录下的 web
WEB_DIST = Path(__file__).resolve().parents[2] / "web" / "dist" WEB_DIST = Path(__file__).resolve().parents[2] / "web" / "dist"
# 管理台自身版本号(展示在设置页「关于」处) def _hub_version() -> str:
HUB_VERSION = "0.1.0" """管理台版本号:以 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 端口 # 仓库根目录(bot.py 所在)与 bot 端口
_REPO_ROOT = Path(__file__).resolve().parents[3] _REPO_ROOT = Path(__file__).resolve().parents[3]
@@ -118,6 +123,65 @@ def _archive_log() -> None:
logger.warning(f"归档日志失败: {type(e).__name__}: {e}") 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: def _schedule_bot_action(action: str) -> None:
"""1 秒后执行:stop 直接退出;restart 先拉起新进程再退出旧进程。""" """1 秒后执行:stop 直接退出;restart 先拉起新进程再退出旧进程。"""
@@ -205,7 +269,7 @@ def build_hub_app() -> FastAPI:
"created_at": user.get("created_at", ""), "created_at": user.get("created_at", ""),
}, },
"plugins": list_web_plugins(), "plugins": list_web_plugins(),
"version": HUB_VERSION, "version": _hub_version(),
} }
@app.get("/api/dashboard") @app.get("/api/dashboard")
@@ -360,9 +424,15 @@ def build_hub_app() -> FastAPI:
@app.get("/api/logs") @app.get("/api/logs")
async def logs( async def logs(
limit: int = 500, limit: int = 500,
before: int | None = None,
_: dict = Depends(get_current_user), _: 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] # 仓库根目录 root = Path(__file__).resolve().parents[3] # 仓库根目录
candidates = [ candidates = [
root / "_bot_run.log", root / "_bot_run.log",
@@ -372,20 +442,20 @@ def build_hub_app() -> FastAPI:
if path is None: if path is None:
return {"ok": False, "msg": "未找到日志文件", "lines": [], "size": 0, "total": 0} return {"ok": False, "msg": "未找到日志文件", "lines": [], "size": 0, "total": 0}
try: 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 except OSError as e: # noqa: BLE001
return {"ok": False, "msg": f"读取日志失败: {e}", "lines": [], "size": 0, "total": 0} return {"ok": False, "msg": f"读取日志失败: {e}", "lines": [], "size": 0, "total": 0}
# 逐行 UTF-8 优先、GBK 兜底:兼容旧 GBK 日志与切换为 UTF-8 后的新日志混存 return {
# 保留 ANSI 颜色码,交给前端渲染颜色 "ok": True,
lines = [] "file": path.name,
for raw_line in raw.split(b"\n"): "size": size,
try: "end": size,
line = raw_line.decode("utf-8") "offset": start,
except UnicodeDecodeError: "has_more": start > 0,
line = raw_line.decode("gbk", errors="replace") "lines": lines,
lines.append(line.rstrip("\r")) "total": total,
tail = lines[-max(1, int(limit)) :] }
return {"ok": True, "file": path.name, "size": len(raw), "offset": len(raw), "lines": tail, "total": len(lines)}
@app.get("/api/dashboard/stream") @app.get("/api/dashboard/stream")
async def dashboard_stream( async def dashboard_stream(
@@ -5,6 +5,8 @@
- bot 账号 / WS 连通性:从 OneBot V11 适配器的 bots / connections 读取 - bot 账号 / WS 连通性:从 OneBot V11 适配器的 bots / connections 读取
- host 性能:psutil(CPU/内存/磁盘/进程)+ platform + nonebot 版本 - host 性能:psutil(CPU/内存/磁盘/进程)+ platform + nonebot 版本
仅在 /hub/api/dashboard 被调用时执行,不做常驻采样。 仅在 /hub/api/dashboard 被调用时执行,不做常驻采样。
同步采集(psutil 等可能阻塞的调用)整体放入线程池执行,避免阻塞事件循环;
结果做 1 秒短缓存,多个前端标签页(含 SSE 循环)不至于各自重复采集。
""" """
from __future__ import annotations from __future__ import annotations
@@ -26,17 +28,21 @@ _MODULE_START = time.time()
# 网络速率采样:记录上一次累计计数与时间,用于计算实时 bytes/s 用量 # 网络速率采样:记录上一次累计计数与时间,用于计算实时 bytes/s 用量
_NET_LAST: dict = {"time": None, "sent": None, "recv": None} _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: try:
per_core = await asyncio.to_thread(psutil.cpu_percent, 0.3, True) per_core = psutil.cpu_percent(0.3, True)
except TypeError: except TypeError:
# 兼容部分平台不接受 percpu 参数 # 兼容部分平台不接受 percpu 参数
per_core = [] per_core = []
if per_core: if per_core:
percent = sum(per_core) / len(per_core) percent = sum(per_core) / len(per_core)
else: else:
percent = await asyncio.to_thread(psutil.cpu_percent, 0.3) percent = psutil.cpu_percent(0.3)
info: dict = { info: dict = {
"percent": round(float(percent), 1), "percent": round(float(percent), 1),
"per_core": [round(float(x), 1) for x in (per_core or [])], "per_core": [round(float(x), 1) for x in (per_core or [])],
@@ -57,7 +63,7 @@ async def _collect_cpu() -> dict:
return info return info
async def _collect_memory() -> dict: def _memory_sync() -> dict:
mem = psutil.virtual_memory() mem = psutil.virtual_memory()
try: try:
proc = psutil.Process() proc = psutil.Process()
@@ -76,10 +82,10 @@ async def _collect_memory() -> dict:
} }
async def _collect_process() -> dict: def _process_sync() -> dict:
try: try:
proc = psutil.Process() proc = psutil.Process()
cpu = await asyncio.to_thread(proc.cpu_percent, 0.2) cpu = proc.cpu_percent(0.2)
info = { info = {
"pid": proc.pid, "pid": proc.pid,
"name": proc.name(), "name": proc.name(),
@@ -94,7 +100,7 @@ async def _collect_process() -> dict:
return info return info
async def _collect_network() -> dict: def _network_sync() -> dict:
"""采集网络流量累计值 + 实时速率(bytes/s)。""" """采集网络流量累计值 + 实时速率(bytes/s)。"""
global _NET_LAST global _NET_LAST
now = time.time() now = time.time()
@@ -157,7 +163,7 @@ async def _collect_network() -> dict:
} }
async def _collect_backend() -> dict: def _backend_sync() -> dict:
"""BOT 后端信息:NoneBot 版本 / 驱动方式 / 装载插件数。""" """BOT 后端信息:NoneBot 版本 / 驱动方式 / 装载插件数。"""
from nonebot import get_loaded_plugins from nonebot import get_loaded_plugins
@@ -183,7 +189,7 @@ async def _collect_backend() -> dict:
return info return info
async def _collect_system() -> dict: def _system_sync() -> dict:
boot = psutil.boot_time() boot = psutil.boot_time()
now = time.time() now = time.time()
return { return {
@@ -206,7 +212,7 @@ def _nonebot_version() -> str:
return "" return ""
async def _collect_disk() -> list: def _disk_sync() -> list:
parts = [] parts = []
for part in psutil.disk_partitions(): for part in psutil.disk_partitions():
try: try:
@@ -271,18 +277,25 @@ async def _collect_bots() -> list:
async def collect_dashboard() -> dict: async def collect_dashboard() -> dict:
"""聚合采集,失败字段降级,不阻塞整体返回。""" """聚合采集,失败字段降级,不阻塞整体返回。
cpu, memory, process, system, disk, bots, network, backend = await asyncio.gather(
_collect_cpu(), 同步采集整体放线程池(psutil 的 disk_usage 等可能秒级阻塞),
_collect_memory(), 结果带 1 秒短缓存(SSE 每 2s 循环与多标签页共用)。
_collect_process(), """
_collect_system(), now = time.time()
_collect_disk(), if _DASH_CACHE["data"] is not None and now - _DASH_CACHE["time"] < _DASH_CACHE_TTL:
_collect_bots(), return _DASH_CACHE["data"]
_collect_network(), cpu, memory, process, system, disk, network, backend, bots = await asyncio.gather(
_collect_backend(), 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, "bots": bots,
"system": system, "system": system,
"cpu": cpu, "cpu": cpu,
@@ -292,3 +305,6 @@ async def collect_dashboard() -> dict:
"network": network, "network": network,
"backend": backend, "backend": backend,
} }
_DASH_CACHE["time"] = now
_DASH_CACHE["data"] = data
return data
+65 -64
View File
@@ -1,8 +1,17 @@
const TOKEN_KEY = 'hexi_web_token' export const TOKEN_KEY = 'hexi_web_token'
const USER_KEY = 'hexi_web_username' 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 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> } interface ReqOpts { method?: string; body?: any; headers?: Record<string,string> }
async function request(url: string, opts: ReqOpts = {}) { 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 }) const res = await fetch(url, { method: opts.method || 'GET', headers, body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined })
let data: any = null let data: any = null
try { data = await res.json() } catch { data = null } try { data = await res.json() } catch { data = null }
if (res.status === 401) {
handleAuthExpired()
}
if (!res.ok) { if (!res.ok) {
const msg = (data && (data.detail || data.msg)) || ('HTTP ' + res.status) const msg = (data && (data.detail || data.msg)) || ('HTTP ' + res.status)
throw new Error(msg) 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('登录失败') if (data && data.access_token === undefined) throw new Error('登录失败')
return { token: data.access_token, username: data.username || username } 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 hubPlugins = async () => await request('/hub/api/plugins')
export const hubDashboard = async () => await request('/hub/api/dashboard') export const hubDashboard = async () => await request('/hub/api/dashboard')
export const hubPluginCatalog = async () => await request('/hub/api/plugins/catalog') 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 hubSettings = async () => await request('/hub/api/settings')
export const hubChangePassword = async (old_password: string, new_password: string) => export const hubChangePassword = async (old_password: string, new_password: string) =>
await request('/hub/api/settings/password', { method: 'POST', body: { old_password, new_password } }) 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') => export const hubBotAction = async (action: 'stop' | 'restart') =>
await request('/hub/api/system/bot', { method: 'POST', body: { action } }) await request('/hub/api/system/bot', { method: 'POST', body: { action } })
export const hubBotConfig = async () => await request('/hub/api/system/bot_config') export const hubBotConfig = async () => await request('/hub/api/system/bot_config')
export const hubBotConfigSave = async (values: Record<string, any>) => export const hubBotConfigSave = async (values: Record<string, any>) =>
await request('/hub/api/system/bot_config', { method: 'POST', body: values }) await request('/hub/api/system/bot_config', { method: 'POST', body: values })
export function hubLogStream(opts: { interface SSEStreamOpts {
since: number url: () => string
onLine: (line: string) => void onData: (obj: any) => 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() }
}
export function hubDashboardStream(opts: {
onData: (data: any) => void
onStatus?: (s: 'open' | 'reconnecting' | 'closed') => void onStatus?: (s: 'open' | 'reconnecting' | 'closed') => void
}): () => void { retryMs?: number
}
// SSE 统一封装:fetch 流式读取 + 断线重连 + 401 登出通知
function makeSSEStream(opts: SSEStreamOpts): () => void {
const controller = new AbortController() const controller = new AbortController()
let stopped = false let stopped = false
const retryMs = opts.retryMs ?? 1500
const read = async () => { const read = async () => {
while (!stopped) { while (!stopped) {
try { try {
const res = await fetch('/hub/api/dashboard/stream', { const res = await fetch(opts.url(), {
headers: { Authorization: 'Bearer ' + getToken() }, headers: { Authorization: 'Bearer ' + getToken() },
signal: controller.signal, signal: controller.signal,
}) })
if (res.status === 401) {
handleAuthExpired()
opts.onStatus?.('closed')
return
}
if (!res.ok || !res.body) throw new Error('HTTP ' + res.status) if (!res.ok || !res.body) throw new Error('HTTP ' + res.status)
opts.onStatus?.('open') opts.onStatus?.('open')
const reader = res.body.getReader() const reader = res.body.getReader()
@@ -159,7 +129,7 @@ export function hubDashboardStream(opts: {
if (stopped || (e && e.name === 'AbortError')) return if (stopped || (e && e.name === 'AbortError')) return
opts.onStatus?.('reconnecting') 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() } 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) => ({ export const api = (plugin: string) => ({
get: (path: string) => request(`/api/${plugin}/${path}`), get: (path: string) => request(`/api/${plugin}/${path}`),
post: (path: string, body: any) => request(`/api/${plugin}/${path}`, { method: 'POST', body }), 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 { useState } from 'react'
import { useNavigate } from 'react-router-dom' import { useLocation, useNavigate } from 'react-router-dom'
import { Button } from '@heroui/react' import { Button } from '@heroui/react'
import { useAuth } from './auth' import { useAuth } from './auth'
export function LoginPage() { export function LoginPage() {
const { login } = useAuth() const { login } = useAuth()
const navigate = useNavigate() const navigate = useNavigate()
const location = useLocation()
// 被 AppLayout 带到登录页时记住原目标,登录后回跳
const from = (location.state as any)?.from || '/hub'
const [username, setUsername] = useState('') const [username, setUsername] = useState('')
const [password, setPassword] = useState('') const [password, setPassword] = useState('')
const [err, setErr] = useState('') const [err, setErr] = useState('')
@@ -15,7 +18,7 @@ export function LoginPage() {
setLoading(true); setErr('') setLoading(true); setErr('')
try { try {
await login(username, password) await login(username, password)
navigate('/hub') navigate(from, { replace: true })
} catch (e: any) { } catch (e: any) {
setErr(e.message || '登录失败') setErr(e.message || '登录失败')
} finally { setLoading(false) } } finally { setLoading(false) }
+19 -4
View File
@@ -1,5 +1,5 @@
import { createContext, useContext, useState, type ReactNode } from 'react' import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'
import { authLogin, clearSession, getUserName, setSession } from '../api/client' import { authLogin, authLogout, AUTH_EXPIRED_EVENT, clearSession, getUserName, setSession, TOKEN_KEY, USER_KEY } from '../api/client'
interface AuthState { interface AuthState {
token: string token: string
@@ -11,8 +11,8 @@ interface AuthState {
const Ctx = createContext<AuthState>({ token: '', username: '', login: async () => {}, logout: () => {} }) const Ctx = createContext<AuthState>({ token: '', username: '', login: async () => {}, logout: () => {} })
export function AuthProvider({ children }: { children: ReactNode }) { export function AuthProvider({ children }: { children: ReactNode }) {
const [token, setToken] = useState(() => localStorage.getItem('hexi_web_token') || '') const [token, setToken] = useState(() => localStorage.getItem(TOKEN_KEY) || '')
const [username, setUsername] = useState(() => localStorage.getItem('hexi_web_username') || '') const [username, setUsername] = useState(() => localStorage.getItem(USER_KEY) || '')
const login = async (u: string, p: string) => { const login = async (u: string, p: string) => {
const data = await authLogin(u, p) const data = await authLogin(u, p)
setSession(data.token, data.username) setSession(data.token, data.username)
@@ -20,10 +20,25 @@ export function AuthProvider({ children }: { children: ReactNode }) {
setUsername(data.username) setUsername(data.username)
} }
const logout = () => { const logout = () => {
// 先吊销服务端令牌(fire-and-forget,失败不阻塞退出),再清理本地
authLogout().catch(() => {})
clearSession() clearSession()
setToken('') setToken('')
setUsername('') 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> 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)
+130 -31
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 { Navigate, NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom'
import { Avatar, Button, Separator } from '@heroui/react' import { Avatar, Button, Separator, Spinner } from '@heroui/react'
import { House, Rocket, Gear, FileText } from '@gravity-ui/icons' import { House, Rocket, Gear, FileText, Bars } from '@gravity-ui/icons'
import { useAuth } from '../auth/auth' 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 } interface NavItem { id: string; label: string; description: string; icon: any }
export function AppLayout() { const NAV_ITEMS: NavItem[] = [
const { token, username, logout } = useAuth()
const navigate = useNavigate()
const location = useLocation()
const navItems: NavItem[] = [
{ id: 'home', label: '首页', description: '主机与服务状态', icon: House }, { id: 'home', label: '首页', description: '主机与服务状态', icon: House },
{ id: 'plugins', label: '插件管理', description: '插件总览与分群控制', icon: Rocket }, { id: 'plugins', label: '插件管理', description: '插件总览与分群控制', icon: Rocket },
{ id: 'logs', label: '查看日志', description: 'bot 运行日志实时查看', icon: FileText }, { id: 'logs', label: '查看日志', description: 'bot 运行日志实时查看', icon: FileText },
{ id: 'settings', label: '设置', description: '账号与系统设置', icon: Gear }, { 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 = NAV_ITEMS
const path = location.pathname.replace(/\/+$/, '') || '/' const path = location.pathname.replace(/\/+$/, '') || '/'
const isHome = path === '/hub' const isHome = path === '/hub'
const isSettings = path.startsWith('/hub/settings') const isSettings = path.startsWith('/hub/settings')
@@ -26,11 +37,62 @@ export function AppLayout() {
const activeId = isPlugins ? 'plugins' : isLogs ? 'logs' : isSettings ? 'settings' : 'home' const activeId = isPlugins ? 'plugins' : isLogs ? 'logs' : isSettings ? 'settings' : 'home'
const pageMeta = navItems.find(it => it.id === activeId) || navItems[0] 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 ( return (
<div className="flex h-screen w-screen overflow-hidden bg-slate-50 text-slate-900"> <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="flex h-16 shrink-0 items-center px-4">
<div className="grid size-9 place-items-center rounded-xl bg-indigo-100 text-indigo-600"><Gear className="size-5" /></div> <div className="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-sm font-bold">HeXi 管理系统</div>
<div className="truncate text-xs text-slate-400">统一管理台</div> <div className="truncate text-xs text-slate-400">统一管理台</div>
</div> </div>
<Button size="sm" variant="tertiary" className="md:hidden" onPress={() => setSidebarOpen(false)} aria-label="关闭菜单">✕</Button>
</div> </div>
{/* 导航 */} {/* 导航(主区,含滑动指示条) */}
<nav className="flex-1 min-h-0 space-y-1 overflow-y-auto px-2 py-2"> <nav ref={mainNavRef} className="relative flex-1 min-h-0 space-y-1 overflow-y-auto px-2 py-2">
{navItems.filter(it => it.id !== 'settings').map(it => { {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 const to = it.id === 'home' ? '/hub' : '/hub/' + it.id
return ( return (
<NavLink <NavLink
key={it.id} key={it.id}
to={to} to={to}
end={it.id === 'home'} end={it.id === 'home'}
data-nav-id={it.id}
className={({ isActive }) => { 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') return base + (isActive ? ' bg-indigo-50 text-indigo-700' : ' text-slate-600 hover:bg-slate-100 hover:text-slate-800')
}} }}
> >
{({ isActive }) => ( {({ 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 transition-transform duration-200 ' + (isActive ? 'scale-110' : 'group-hover:scale-105')}><it.icon className="size-4" /></span>
<span className="relative z-10 grid w-11 shrink-0 place-items-center"><it.icon className="size-4" /></span>
<span className="relative z-10 flex min-w-0 flex-1 flex-col items-start overflow-hidden pr-3"> <span className="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 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> </span>
</> </>
)} )}
@@ -69,23 +138,23 @@ export function AppLayout() {
})} })}
</nav> </nav>
{/* 底部设置 */} {/* 底部设置(单条目时用缩放指示条) */}
<nav className="shrink-0 space-y-1 border-t border-slate-100 px-2 py-2"> <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 const to = '/hub/' + it.id
return ( return (
<NavLink <NavLink
key={it.id} key={it.id}
to={to} to={to}
className={({ isActive }) => { 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') return base + (isActive ? ' bg-indigo-50 text-indigo-700' : ' text-slate-600 hover:bg-slate-100 hover:text-slate-800')
}} }}
> >
{({ isActive }) => ( {({ 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={'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"><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="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 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 text-slate-400">{it.description}</span>
@@ -99,22 +168,52 @@ export function AppLayout() {
</aside> </aside>
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden"> <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"> <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 items-center gap-3"> <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 && ( {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>
)} )}
<pageMeta.icon className="size-4 text-slate-500" /> {isPluginSub ? (
<span className="text-sm font-semibold text-slate-800">{pageMeta.label}</span> <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> </div>
<div className="flex items-center gap-3"> ) : (
<>
<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>
</>
)}
</div>
<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> <Avatar size="sm"><Avatar.Fallback>{(username || 'U').charAt(0).toUpperCase()}</Avatar.Fallback></Avatar>
<span className="text-sm text-slate-600">{username || '未登录'}</span> <span className="hidden text-sm text-slate-600 sm:inline">{username || '未登录'}</span>
<Separator orientation="vertical" className="h-5" /> <Separator orientation="vertical" className="hidden h-5 sm:block" />
<Button size="sm" variant="tertiary" onPress={() => { logout(); navigate('/hub/login') }}>退出</Button> <Button size="sm" variant="tertiary" onPress={() => { logout(); navigate('/hub/login') }}>退出</Button>
</div> </div>
</header> </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>
</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)
+77 -130
View File
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react' import { Avatar, Card, Spinner } from '@heroui/react'
import { Avatar, Card } from '@heroui/react' import { useConnection } from '../../connection'
import { hubDashboard, hubDashboardStream } from '../../api/client' import { fmtBytes, fmtRate, fmtUptime } from '../../lib/format'
interface BotInfo { interface BotInfo {
self_id: string self_id: string
@@ -87,32 +87,6 @@ interface DashboardData {
backend: BackendInfo 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 }) { function Bar({ value, color }: { value: number; color?: string }) {
const v = Math.max(0, Math.min(100, value)) 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') 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() { export default function HomePage() {
const [data, setData] = useState<DashboardData | null>(null) // 数据与连接状态来自全局 ConnectionProvider(顶栏已显示状态徽章)
const [error, setError] = useState('') const { data: rawData, error } = useConnection()
const [loading, setLoading] = useState(false) const data = rawData as DashboardData | null
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])
const cpu = data?.cpu const cpu = data?.cpu
const mem = data?.memory const mem = data?.memory
@@ -190,15 +136,82 @@ export default function HomePage() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-between">
<div> <div>
<h2 className="text-xl font-semibold text-slate-800">总览</h2> <h2 className="text-xl font-semibold text-slate-800">总览</h2>
<p className="text-sm text-slate-400">主机与 BOT 综合状态(每 2 秒刷新)</p> <p className="text-sm text-slate-400">主机与 BOT 综合状态(实时刷新)</p>
</div>
</div> </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>} {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. 主机规格信息:系统 / 处理器 / 内存 / 硬盘 */} {/* 1. 主机规格信息:系统 / 处理器 / 内存 / 硬盘 */}
<Card className="border border-slate-200 bg-white"> <Card className="border border-slate-200 bg-white">
<Card.Header> <Card.Header>
@@ -234,7 +247,7 @@ export default function HomePage() {
<div className="mt-4"> <div className="mt-4">
<div className="mb-2 text-sm font-medium text-slate-700">硬盘</div> <div className="mb-2 text-sm font-medium text-slate-700">硬盘</div>
{!data ? ( {!data ? (
<p className="text-sm text-slate-400">加载中…</p> <div className="flex justify-center py-8"><Spinner /></div>
) : !data.disk.length ? ( ) : !data.disk.length ? (
<p className="text-sm text-slate-400">未获取到磁盘信息</p> <p className="text-sm text-slate-400">未获取到磁盘信息</p>
) : ( ) : (
@@ -323,72 +336,6 @@ export default function HomePage() {
</Card.Content> </Card.Content>
</Card> </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> </div>
) )
} }
+18 -86
View File
@@ -1,17 +1,7 @@
import { useCallback, useEffect, useRef, useState, type CSSProperties } from 'react' import { useEffect, useRef, useState, type CSSProperties } from 'react'
import { Button, Card } from '@heroui/react' import { Button, Card, Spinner } from '@heroui/react'
import { hubLogs, hubLogStream } from '../../api/client' import { useLogs } from '../../logs'
import { fmtBytes } from '../../lib/format'
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]
}
// ANSI SGR 颜色码 → 内联样式,用于还原 loguru 彩色日志 // ANSI SGR 颜色码 → 内联样式,用于还原 loguru 彩色日志
const ANSI_FG: Record<number, string> = { const ANSI_FG: Record<number, string> = {
@@ -91,68 +81,10 @@ function AnsiLine({ text }: { text: string }) {
} }
export default function LogsPage() { export default function LogsPage() {
const [lines, setLines] = useState<string[]>([]) // 数据与日志流由全局 LogProvider 常驻管理:离开/回来不重建连接、不转圈
const [info, setInfo] = useState<{ file: string; size: number; total: number }>({ file: '', size: 0, total: 0 }) const { lines, info, error, loading, hasMore, loadingEarlier, paused, setPaused, loadEarlier } = useLogs()
const [error, setError] = useState('')
const [streamStatus, setStreamStatus] = useState<'连接中' | '实时' | '重连中' | '已断开'>('连接中')
const [follow, setFollow] = useState(true) const [follow, setFollow] = useState(true)
const [paused, setPaused] = useState(false)
const [loading, setLoading] = useState(false)
const scrollRef = useRef<HTMLDivElement>(null) 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(() => { useEffect(() => {
if (follow && scrollRef.current) { if (follow && scrollRef.current) {
@@ -160,17 +92,18 @@ export default function LogsPage() {
} }
}, [lines, follow]) }, [lines, follow])
const statusClass = streamStatus === '实时' ? 'text-emerald-600' : streamStatus === '重连中' ? 'text-amber-600' : 'text-slate-500'
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-between"> <div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div> <div className="min-w-0">
<h2 className="text-xl font-semibold text-slate-800">查看日志</h2> <h2 className="text-xl font-semibold text-slate-800">查看日志</h2>
<p className="text-sm text-slate-400">实时读取 bot 运行日志(SSE 推送)</p> <p className="text-sm text-slate-400">实时读取 bot 运行日志(SSE 推送)</p>
</div> </div>
<div className="flex flex-wrap items-center gap-2"> <div className="flex shrink-0 flex-wrap items-center gap-2 md:justify-end">
<Button size="sm" variant="tertiary" onPress={() => setPaused(v => !v)}>{paused ? '恢复接收' : '暂停接收'}</Button> {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> <Button size="sm" variant="tertiary" onPress={() => setFollow(v => !v)}>{follow ? '停止跟随' : '跟随最新'}</Button>
</div> </div>
</div> </div>
@@ -181,15 +114,14 @@ export default function LogsPage() {
<Card.Header> <Card.Header>
<div className="flex w-full items-center justify-between"> <div className="flex w-full items-center justify-between">
<Card.Title>日志流</Card.Title> <Card.Title>日志流</Card.Title>
<div className="flex items-center gap-3 text-xs text-slate-400"> {info.file && <span className="text-xs text-slate-400">{info.file} · {fmtBytes(info.size)} · 共 {info.total} 行</span>}
<span className={statusClass}>● {streamStatus}</span>
{info.file && <span>{info.file} · {fmtBytes(info.size)} · 共 {info.total} 行</span>}
</div>
</div> </div>
</Card.Header> </Card.Header>
<Card.Content> <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"> <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)]">
{lines.length ? lines.map((ln, i) => ( {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 key={i} className="whitespace-pre-wrap break-all"><AnsiLine text={ln} /></div>
)) : ( )) : (
<div className="text-slate-500">暂无日志…</div> <div className="text-slate-500">暂无日志…</div>
+107 -31
View File
@@ -1,9 +1,7 @@
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import { Link } from 'react-router-dom'
import { import {
Button, Button,
Card, Card,
Checkbox,
Drawer, Drawer,
Dropdown, Dropdown,
Input, Input,
@@ -17,13 +15,15 @@ import {
toast, toast,
useOverlayState, useOverlayState,
} from '@heroui/react' } from '@heroui/react'
import { useNavigate } from 'react-router-dom'
import { Eye, EyeSlash, TrashBin } from '@gravity-ui/icons'
import { import {
hubPluginCatalog, hubPluginCatalog,
hubPluginGetConfig, hubPluginGetConfig,
hubPluginRemoveGroup,
hubPluginSaveConfig, hubPluginSaveConfig,
hubPluginSetGlobal, hubPluginSetGlobal,
hubPluginSetGroup, hubPluginSetGroup,
hubPluginRemoveGroup,
hubGroups, hubGroups,
} from '../../api/client' } from '../../api/client'
@@ -50,6 +50,7 @@ interface ConfigField {
description?: string description?: string
options?: { value: string; label: string }[] | string[] options?: { value: string; label: string }[] | string[]
secret?: boolean secret?: boolean
item_type?: string
} }
interface ConfigData { schema: { fields: ConfigField[] }; values: Record<string, any>; revision: number } 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() { export default function PluginsPage() {
const navigate = useNavigate()
const [plugins, setPlugins] = useState<CatalogPlugin[]>([]) const [plugins, setPlugins] = useState<CatalogPlugin[]>([])
const [groups, setGroups] = useState<GroupItem[]>([]) const [groups, setGroups] = useState<GroupItem[]>([])
const [error, setError] = useState('') const [error, setError] = useState('')
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const loadRef = useRef(false) // 加载序号:并发 load 时只落地最后一次结果,避免旧响应覆盖新状态
const loadSeqRef = useRef(0)
const [configPlugin, setConfigPlugin] = useState<CatalogPlugin | null>(null) const [configPlugin, setConfigPlugin] = useState<CatalogPlugin | null>(null)
const [configData, setConfigData] = useState<ConfigData | null>(null) const [configData, setConfigData] = useState<ConfigData | null>(null)
const [configLoading, setConfigLoading] = useState(false) const [configLoading, setConfigLoading] = useState(false)
const [configSaving, setConfigSaving] = useState(false) const [configSaving, setConfigSaving] = useState(false)
const [form, setForm] = useState<Record<string, any>>({}) const [form, setForm] = useState<Record<string, any>>({})
// 密码/密钥类字段的明文显隐状态(按字段 key 记录)
const [revealed, setRevealed] = useState<Record<string, boolean>>({})
const configState = useOverlayState() const configState = useOverlayState()
const load = useCallback(async () => { const load = useCallback(async (silent = false) => {
if (loadRef.current) return const seq = ++loadSeqRef.current
loadRef.current = true if (!silent) setLoading(true)
setLoading(true)
try { try {
const [cat, grp] = await Promise.all([hubPluginCatalog(), hubGroups()]) const [cat, grp] = await Promise.all([hubPluginCatalog(), hubGroups()])
if (seq !== loadSeqRef.current) return
setPlugins((cat && cat.plugins) || []) setPlugins((cat && cat.plugins) || [])
setGroups((grp && grp.items) || []) setGroups((grp && grp.items) || [])
setError('') setError('')
} catch (e: any) { } catch (e: any) {
if (seq !== loadSeqRef.current) return
setError(e.message || '加载失败') setError(e.message || '加载失败')
} finally { } finally {
loadRef.current = false if (seq === loadSeqRef.current) setLoading(false)
setLoading(false)
} }
}, []) }, [])
@@ -117,6 +122,7 @@ export default function PluginsPage() {
setConfigPlugin(p) setConfigPlugin(p)
setConfigData(null) setConfigData(null)
setConfigLoading(true) setConfigLoading(true)
setRevealed({})
configState.open() configState.open()
try { try {
const d = await hubPluginGetConfig(p.id) const d = await hubPluginGetConfig(p.id)
@@ -139,7 +145,7 @@ export default function PluginsPage() {
await hubPluginSaveConfig(configPlugin.id, form, configData?.revision ?? 0) await hubPluginSaveConfig(configPlugin.id, form, configData?.revision ?? 0)
toast.success('配置已保存并热刷新') toast.success('配置已保存并热刷新')
configState.close() configState.close()
await load() await load(true)
} catch (e: any) { } catch (e: any) {
toast.danger(e.message || '保存失败') toast.danger(e.message || '保存失败')
} finally { } finally {
@@ -153,9 +159,9 @@ export default function PluginsPage() {
const v = form[field.key] const v = form[field.key]
if (field.type === 'bool') { if (field.type === 'bool') {
return ( return (
<Checkbox key={field.key} isSelected={!!v} onChange={(val) => setField(field.key, val)}> <Switch key={field.key} isSelected={!!v} onChange={(val) => setField(field.key, val)}>
<Checkbox.Content><Checkbox.Control><Checkbox.Indicator /></Checkbox.Control>{field.label}</Checkbox.Content> <Switch.Content><Switch.Control><Switch.Thumb /></Switch.Control>{field.label}</Switch.Content>
</Checkbox> </Switch>
) )
} }
if (field.type === 'enum') { if (field.type === 'enum') {
@@ -173,33 +179,101 @@ export default function PluginsPage() {
</div> </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') { if (field.type === 'text' || field.type === 'json' || field.type === 'object') {
const isJson = field.type === 'json' const isJson = field.type === 'json'
return ( 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> <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 ( return (
<TextField key={field.key}> <TextField key={field.key}>
<Label>{field.label}</Label> <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>} {field.description && <p className="text-xs text-slate-400">{field.description}</p>}
</TextField> </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) => { 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) => { 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) => { 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 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 resetGroup = async (p: CatalogPlugin, gid: number) => {
try { await hubPluginRemoveGroup(p.id, String(gid)); await load() } catch (e: any) { toast.danger(e.message || '重置失败') }
} }
const effectiveGroupEnabled = (p: CatalogPlugin, gid: number) => { const effectiveGroupEnabled = (p: CatalogPlugin, gid: number) => {
const ov = p.groups[String(gid)] const ov = p.groups[String(gid)]
@@ -208,22 +282,23 @@ export default function PluginsPage() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-between"> <div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div> <div className="min-w-0">
<h2 className="text-xl font-semibold text-slate-800">插件管理</h2> <h2 className="text-xl font-semibold text-slate-800">插件管理</h2>
<p className="text-sm text-slate-400">在所有插件之上统一控制:全局开关、群聊/私聊使用、分群控制、插件配置</p> <p className="text-sm text-slate-400">在所有插件之上统一控制:全局开关、群聊/私聊使用、分群控制、插件配置</p>
</div> </div>
<Button size="sm" isDisabled={loading} onPress={() => load()}>{loading ? '加载中…' : '刷新'}</Button> <Button size="sm" className="shrink-0" isDisabled={loading} onPress={() => load()}>{loading ? '加载中…' : '刷新'}</Button>
</div> </div>
{error && <Card className="border border-red-200 bg-red-50"><Card.Content><p className="text-sm text-red-600">{error}</p></Card.Content></Card>} {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>} {!loading && !plugins.length && !error && <p className="text-sm text-slate-400">未发现可管理的插件</p>}
<div className="space-y-3"> <div className="space-y-3">
{plugins.map(p => { {plugins.map(p => {
const on = p.enabled const on = p.enabled
return ( 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> <Card.Content>
<div className="flex flex-col gap-4 lg:flex-row lg:items-center"> <div className="flex flex-col gap-4 lg:flex-row lg:items-center">
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
@@ -239,7 +314,7 @@ export default function PluginsPage() {
<div className="flex flex-wrap items-center gap-4"> <div className="flex flex-wrap items-center gap-4">
{p.has_config && <Button size="sm" onPress={() => openConfig(p)}>配置</Button>} {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 selectedKey={chatValue(p.chat)} onSelectionChange={(k) => changeChat(p, String(k))} aria-label="聊天类型">
<Select.Trigger className={chatSelectCls}><Select.Value /><Select.Indicator /></Select.Trigger> <Select.Trigger className={chatSelectCls}><Select.Value /><Select.Indicator /></Select.Trigger>
@@ -253,17 +328,18 @@ export default function PluginsPage() {
</Dropdown.Trigger> </Dropdown.Trigger>
<Dropdown.Popover className="p-2" placement="bottom end"> <Dropdown.Popover className="p-2" placement="bottom end">
<div className="w-64 max-h-72 overflow-y-auto space-y-1"> <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.length && <div className="px-1 py-2 text-xs text-slate-400">未获取到群列表</div>}
{groups.map(g => { {groups.map(g => {
const eff = effectiveGroupEnabled(p, g.group_id) const eff = effectiveGroupEnabled(p, g.group_id)
const hasOv = !!p.groups[String(g.group_id)]
return ( 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 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.Content><Switch.Control><Switch.Thumb /></Switch.Control><span className="text-xs text-slate-700">{g.group_name || g.group_id}</span></Switch.Content>
</Switch> </Switch>
{hasOv && <Button size="sm" variant="tertiary" onPress={() => resetGroup(p, g.group_id)}>重置</Button>}
</div> </div>
) )
})} })}
@@ -285,7 +361,7 @@ export default function PluginsPage() {
<Drawer state={configState}> <Drawer state={configState}>
<Drawer.Backdrop> <Drawer.Backdrop>
<Drawer.Content placement="right"> <Drawer.Content placement="right">
<Drawer.Dialog className="w-[440px] max-w-[88vw]"> <Drawer.Dialog className="w-[600px] max-w-[88vw]">
<Drawer.Header> <Drawer.Header>
<Drawer.Heading>{configPlugin?.name || ''} · 配置</Drawer.Heading> <Drawer.Heading>{configPlugin?.name || ''} · 配置</Drawer.Heading>
<Drawer.CloseTrigger /> <Drawer.CloseTrigger />
+13 -3
View File
@@ -8,8 +8,6 @@ import {
interface SettingsData { interface SettingsData {
ok: boolean ok: boolean
user: { id: number; username: string; created_at: string }
plugins: { id: string; name: string; icon: string }[]
version: string version: string
} }
interface BotConfigData { interface BotConfigData {
@@ -40,6 +38,7 @@ export default function SettingsPage() {
const [restarting, setRestarting] = useState<null | 'stop' | 'restart'>(null) const [restarting, setRestarting] = useState<null | 'stop' | 'restart'>(null)
const botState = useOverlayState() const botState = useOverlayState()
const pollRef = useRef(0) const pollRef = useRef(0)
const pollTimerRef = useRef<number | null>(null)
const restartDisposeRef = useRef<(() => void) | null>(null) const restartDisposeRef = useRef<(() => void) | null>(null)
const [botCfg, setBotCfg] = useState<BotConfigData | null>(null) const [botCfg, setBotCfg] = useState<BotConfigData | null>(null)
@@ -76,6 +75,7 @@ export default function SettingsPage() {
useEffect(() => () => { useEffect(() => () => {
if (restartDisposeRef.current) restartDisposeRef.current() if (restartDisposeRef.current) restartDisposeRef.current()
if (pollTimerRef.current != null) window.clearTimeout(pollTimerRef.current)
}, []) }, [])
const changePassword = async () => { const changePassword = async () => {
@@ -119,6 +119,13 @@ export default function SettingsPage() {
botState.open() botState.open()
} }
const clearPollTimer = () => {
if (pollTimerRef.current != null) {
window.clearTimeout(pollTimerRef.current)
pollTimerRef.current = null
}
}
const pollForReconnect = async () => { const pollForReconnect = async () => {
const id = ++pollRef.current const id = ++pollRef.current
// 先等旧进程完全退出(旧的 dashboard SSE 可能仍被旧进程服务,提前收到数据会误判就绪) // 先等旧进程完全退出(旧的 dashboard SSE 可能仍被旧进程服务,提前收到数据会误判就绪)
@@ -132,6 +139,8 @@ export default function SettingsPage() {
if (d && d.ok !== false) { if (d && d.ok !== false) {
restartDisposeRef.current = null restartDisposeRef.current = null
dispose() dispose()
clearPollTimer()
pollRef.current += 1 // 让旧定时器即使未被清除也不会误判超时
setRestarting(null) setRestarting(null)
toast.success('bot 已重启并回连') toast.success('bot 已重启并回连')
load() load()
@@ -139,7 +148,8 @@ export default function SettingsPage() {
}, },
}) })
restartDisposeRef.current = dispose restartDisposeRef.current = dispose
window.setTimeout(() => { pollTimerRef.current = window.setTimeout(() => {
pollTimerRef.current = null
if (pollRef.current === id) { if (pollRef.current === id) {
restartDisposeRef.current = null restartDisposeRef.current = null
dispose() dispose()
+28 -39
View File
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useState } from 'react' 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 { api } from '../../api/client'
import { Tabs, thCls, tdCls, selectCls, type TabItem } from '../../components/ui'
const gc = api('galgame_card') 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 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 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 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 = '操作失败') { function unpack(res: any, errorLabel: string = '操作失败') {
if (res && res.status !== 0) throw new Error(res.msg || errorLabel) if (res && res.status !== 0) throw new Error(res.msg || errorLabel)
return res ? res.data : null 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 fmtTime(ts: string) { return ts || '—' }
function Tabs({ tabs, active, onChange }: { tabs: string[]; active: string; onChange: (v: string) => void }) { type GalTab = 'participants' | 'logs' | 'impressions' | 'summaries'
return ( const GAL_TABS: TabItem[] = [
<div className="flex gap-1 border-b border-slate-200 mb-4"> { key: 'participants', label: '参与者' },
{tabs.map(t => ( { key: 'logs', label: '语料' },
<button key={t} onClick={() => onChange(t)} { key: 'impressions', label: '印象' },
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')}> { key: 'summaries', label: '画像' },
{t} ]
</button> const DATA_TABS: TabItem[] = GAL_TABS.slice(1)
))}
</div>
)
}
export default function GalgameCardPage() { export default function GalgameCardPage() {
const [view, setView] = useState<'groups' | 'detail' | 'data' | 'settings'>('groups') const [view, setView] = useState<'groups' | 'detail' | 'data' | 'settings'>('groups')
@@ -65,12 +57,12 @@ export default function GalgameCardPage() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-between"> <div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div> <div className="min-w-0">
<h2 className="text-xl font-semibold text-slate-800">群聊人设卡</h2> <h2 className="text-xl font-semibold text-slate-800">群聊人设卡</h2>
<p className="text-sm text-slate-400">群采集开关 · 参与者 · 语料/印象/画像浏览</p> <p className="text-sm text-slate-400">群采集开关 · 参与者 · 语料/印象/画像浏览</p>
</div> </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"> <div className="flex gap-1">
{(['groups', 'data', 'settings'] as const).map(v => ( {(['groups', 'data', 'settings'] as const).map(v => (
<Button key={v} size="sm" variant={view === v ? 'primary' : 'tertiary'} onPress={() => setView(v)}> <Button key={v} size="sm" variant={view === v ? 'primary' : 'tertiary'} onPress={() => setView(v)}>
@@ -95,13 +87,12 @@ export default function GalgameCardPage() {
</tr></thead> </tr></thead>
<tbody> <tbody>
{groups.map(g => ( {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}><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}> <td className={tdCls}>
<button onClick={() => toggleGroup(g, !g.enabled)} <Switch size="sm" isSelected={g.enabled} onChange={(v) => toggleGroup(g, v)}>
className={'relative h-5 w-10 rounded-full transition-colors ' + (g.enabled ? 'bg-emerald-500' : 'bg-slate-300')}> <Switch.Content><Switch.Control><Switch.Thumb /></Switch.Control></Switch.Content>
<span className={'absolute top-0.5 h-4 w-4 rounded-full bg-white transition-all ' + (g.enabled ? 'left-5' : 'left-0.5')} /> </Switch>
</button>
</td> </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}>{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> <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 }) { 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 [participants, setParticipants] = useState<Participant[]>([])
const [logs, setLogs] = useState<LogRow[]>([]) const [logs, setLogs] = useState<LogRow[]>([])
const [logTotal, setLogTotal] = useState(0) 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 className="border border-slate-200 bg-white">
<Card.Content> <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' && ( {tab === 'participants' && (
<div className="space-y-3"> <div className="space-y-3">
<div className="flex items-center gap-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> <thead><tr><th className={thCls}>QQ</th><th className={thCls}>语料条数</th><th className={thCls}></th></tr></thead>
<tbody> <tbody>
{participants.map(p => ( {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>} {!participants.length && <tr><td colSpan={3} className={tdCls + ' text-slate-400 text-center'}>暂无参与者</td></tr>}
</tbody> </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> <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> <tbody>
{logs.map(r => ( {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>} {!logs.length && <tr><td colSpan={5} className={tdCls + ' text-slate-400 text-center'}>暂无语料</td></tr>}
</tbody> </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> <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> <tbody>
{impressions.map(r => ( {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>} {!impressions.length && <tr><td colSpan={5} className={tdCls + ' text-slate-400 text-center'}>暂无印象</td></tr>}
</tbody> </tbody>
@@ -264,7 +255,7 @@ function GroupDetail({ gid, onBack }: { gid: number; onBack: () => void }) {
function DataBrowser({ groups }: { groups: Group[] }) { function DataBrowser({ groups }: { groups: Group[] }) {
const [gid, setGid] = useState<number | ''>('') 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 [logs, setLogs] = useState<LogRow[]>([])
const [impressions, setImpressions] = useState<ImpressionRow[]>([]) const [impressions, setImpressions] = useState<ImpressionRow[]>([])
const [summaries, setSummaries] = useState<SummaryRow[]>([]) const [summaries, setSummaries] = useState<SummaryRow[]>([])
@@ -295,12 +286,12 @@ function DataBrowser({ groups }: { groups: Group[] }) {
</Select> </Select>
{err && <span className="text-xs text-red-500">{err}</span>} {err && <span className="text-xs text-red-500">{err}</span>}
</div> </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' && ( {tab === 'logs' && (
<table className="w-full"> <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> <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> <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>} {!logs.length && <tr><td colSpan={5} className={tdCls + ' text-slate-400 text-center'}>{gid ? '暂无数据' : '请先选择群'}</td></tr>}
</tbody> </tbody>
</table> </table>
@@ -309,7 +300,7 @@ function DataBrowser({ groups }: { groups: Group[] }) {
<table className="w-full"> <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> <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> <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>} {!impressions.length && <tr><td colSpan={5} className={tdCls + ' text-slate-400 text-center'}>{gid ? '暂无数据' : '请先选择群'}</td></tr>}
</tbody> </tbody>
</table> </table>
@@ -332,8 +323,6 @@ function SettingsView() {
gc.get('settings').then(res => { try { setData(unpack(res)) } catch { } }).catch(() => {}) gc.get('settings').then(res => { try { setData(unpack(res)) } catch { } }).catch(() => {})
}, []) }, [])
const rows = data ? [ const rows = data ? [
['Web 登录账号', data.web_username],
['Web 后台启用', data.web_enable ? '是' : '否'],
['长文截断上限', (data.thresholds?.max_content_len ?? '—') + ' 字'], ['长文截断上限', (data.thresholds?.max_content_len ?? '—') + ' 字'],
['复读检测窗口', (data.thresholds?.repeat_window ?? '—') + ' 条'], ['复读检测窗口', (data.thresholds?.repeat_window ?? '—') + ' 条'],
['刷屏采样间隔', (data.thresholds?.throttle_seconds ?? '—') + ' 秒'], ['刷屏采样间隔', (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> <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> </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.Content>
</Card> </Card>
) )
+206 -41
View File
@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from 'react' 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 { Plus, ArrowUpFromLine, ArrowDownToLine, TrashBin, Pencil } from '@gravity-ui/icons'
import { api } from '../../api/client' import { api } from '../../api/client'
@@ -9,11 +9,21 @@ interface Item {
} }
const hd = api('helldivers') 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) { function iconUrl(icon?: string) {
if (!icon) return '' 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 }) { 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 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) { async function copyText(text: string) {
try { await navigator.clipboard.writeText(text); return true } 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 }) { function AddModal({ onSaved }: { onSaved: () => void }) {
const state = useOverlayState() const state = useOverlayState()
const [name, setName] = useState('') const [name, setName] = useState('')
@@ -38,17 +73,38 @@ function AddModal({ onSaved }: { onSaved: () => void }) {
const [type, setType] = useState('blue') const [type, setType] = useState('blue')
const [backpack, setBackpack] = useState(false) const [backpack, setBackpack] = useState(false)
const [alias, setAlias] = useState('') const [alias, setAlias] = useState('')
const [iconSvg, setIconSvg] = useState('')
const [iconName, setIconName] = useState('')
const [busy, setBusy] = useState(false) const [busy, setBusy] = useState(false)
const [err, setErr] = useState('') 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 () => { const add = async () => {
if (!name) { setErr('名称不能为空'); return } if (!name) { setErr('名称不能为空'); return }
setBusy(true); setErr('') setBusy(true); setErr('')
try { 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: '' }) 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: '' })
setName(''); setNameEn(''); setAlias(''); setBackpack(false) 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() 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 ( return (
@@ -71,13 +127,28 @@ function AddModal({ onSaved }: { onSaved: () => void }) {
</Select> </Select>
</div> </div>
<div className="sm:col-span-2"><TextField><Label>别名(逗号分隔)</Label><Input value={alias} onChange={e => setAlias(e.target.value)} placeholder="逗号分隔" /></TextField></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>} {err && <div className="sm:col-span-2 text-sm text-red-500">{err}</div>}
</Form> </Form>
</Modal.Body> </Modal.Body>
<Modal.Footer> <Modal.Footer>
<Button variant="tertiary" onPress={state.close}>取消</Button> <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.Footer>
</Modal.Dialog></Modal.Container></Modal.Backdrop> </Modal.Dialog></Modal.Container></Modal.Backdrop>
</Modal> </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 state = useOverlayState()
const [name, setName] = useState(item.name) const [name, setName] = useState(item.name)
const [nameEn, setNameEn] = useState(item.name_en || '') const [nameEn, setNameEn] = useState(item.name_en || '')
const [type, setType] = useState(item.type || 'blue') const [type, setType] = useState(item.type || 'blue')
const [backpack, setBackpack] = useState(!!item.backpack) const [backpack, setBackpack] = useState(!!item.backpack)
const [alias, setAlias] = useState((item.alias || []).join(', ')) 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 [busy, setBusy] = useState(false)
const [err, setErr] = useState('') 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 () => { const save = async () => {
setBusy(true); setErr('') setBusy(true); setErr('')
try { 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() 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 ( return (
@@ -168,15 +262,38 @@ function EditModal({ item, framed, onSaved }: { item: Item; framed: boolean; onS
</Select.Popover> </Select.Popover>
</Select> </Select>
</div> </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={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>} {err && <div className="sm:col-span-2 text-sm text-red-500">{err}</div>}
</Form> </Form>
</Modal.Body> </Modal.Body>
<Modal.Footer> <Modal.Footer>
<Button variant="tertiary" onPress={state.close}>取消</Button> <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.Footer>
</Modal.Dialog> </Modal.Dialog>
</Modal.Container> </Modal.Container>
@@ -188,44 +305,42 @@ function EditModal({ item, framed, onSaved }: { item: Item; framed: boolean; onS
export function HelldiversPage() { export function HelldiversPage() {
const [items, setItems] = useState<Item[]>([]) const [items, setItems] = useState<Item[]>([])
const [framed, setFramed] = useState(true)
const [copied, setCopied] = useState(false) const [copied, setCopied] = useState(false)
const [page, setPage] = useState(1) const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(20) const [pageSize, setPageSize] = useState(10)
const [total, setTotal] = useState(0) const [total, setTotal] = useState(0)
const uploadRef = useRef<HTMLInputElement>(null) const [loading, setLoading] = useState(false)
const uploadTarget = useRef<number | null>(null) 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 deleteState = useOverlayState()
const [deleteTarget, setDeleteTarget] = useState<Item | null>(null) const [deleteTarget, setDeleteTarget] = useState<Item | null>(null)
const reload = async () => { const reload = async () => {
setLoading(true)
try { 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) || [] const data = (d && d.items) || []
setItems(data) setItems(data)
setTotal((d && d.total) || 0) setTotal((d && d.total) || 0)
if (data.length === 0 && page > 1) { setPage(page - 1); return } if (data.length === 0 && page > 1) { setPage(page - 1); return }
} catch (e: any) { toast('加载失败: ' + e.message, { variant: 'danger' }) } } 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) => { const remove = async (it: Item) => {
try { await hd.del(`stratagems/${it.id}`); reload() } catch (e: any) { toast('删除失败: ' + e.message, { variant: 'danger' }) } 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 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 () => { const exportJson = async () => {
try { try {
const data = await hd.get('export') const data = await hd.get('export')
@@ -240,16 +355,58 @@ export function HelldiversPage() {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h2 className="text-xl font-semibold text-slate-800">战备管理</h2> <h2 className="text-xl font-semibold text-slate-800">战备管理</h2>
<div className="flex items-center gap-4"> <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} /> <AddModal onSaved={reload} />
<ImportModal 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> <Button size="sm" onPress={exportJson}><span className="inline-flex items-center gap-1.5"><ArrowDownToLine className="size-4" /> {copied ? '已复制' : '导出'}</span></Button>
</div> </div>
</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 className="border border-slate-200 bg-white">
<Card.Header><Card.Title>战备列表(共 {total} 条)</Card.Title></Card.Header> <Card.Header><Card.Title>战备列表(共 {total} 条)</Card.Title></Card.Header>
<Card.Content> <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>
<Table.ScrollContainer> <Table.ScrollContainer>
<Table.Content aria-label="战备列表"> <Table.Content aria-label="战备列表">
@@ -270,8 +427,7 @@ export function HelldiversPage() {
<Table.Cell>{it.backpack ? '背包位' : '—'}</Table.Cell> <Table.Cell>{it.backpack ? '背包位' : '—'}</Table.Cell>
<Table.Cell><div className="flex flex-wrap gap-1 max-w-[220px]">{(it.alias || []).filter(Boolean).map((a, i) => <span key={i} className="text-xs text-slate-500 bg-slate-100 rounded px-1.5 py-0.5">{a}</span>)}</div></Table.Cell> <Table.Cell><div className="flex 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"> <Table.Cell><div className="flex gap-1 justify-end">
<EditModal key={it.id + '-' + it.name} item={it} framed={framed} onSaved={reload} /> <EditModal key={it.id + '-' + it.name} item={it} onSaved={reload} />
<Button size="sm" onPress={() => clickUpload(it)}>上传图标</Button>
<Button size="sm" variant="tertiary" onPress={() => confirmDelete(it)}><span className="inline-flex items-center gap-1"><TrashBin className="size-3.5" /> 删除</span></Button> <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> </div></Table.Cell>
</Table.Row> </Table.Row>
@@ -280,7 +436,7 @@ export function HelldiversPage() {
</Table.Content> </Table.Content>
</Table.ScrollContainer> </Table.ScrollContainer>
</Table> </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.Summary>共 {total} 条 · 第 {page} / {Math.max(1, Math.ceil(total / pageSize))} 页</Pagination.Summary>
<Pagination.Content> <Pagination.Content>
<Pagination.Item> <Pagination.Item>
@@ -288,9 +444,11 @@ export function HelldiversPage() {
<Pagination.PreviousIcon /><span>上一页</span> <Pagination.PreviousIcon /><span>上一页</span>
</Pagination.Previous> </Pagination.Previous>
</Pagination.Item> </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.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>
))} ))}
<Pagination.Item> <Pagination.Item>
@@ -299,12 +457,19 @@ export function HelldiversPage() {
</Pagination.Next> </Pagination.Next>
</Pagination.Item> </Pagination.Item>
</Pagination.Content> </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> </Pagination>
</Card.Content> </Card.Content>
</Card> </Card>
<input ref={uploadRef} type="file" accept=".svg,image/svg+xml" className="hidden" onChange={onUploadFile} />
<Modal isOpen={deleteState.isOpen} onOpenChange={deleteState.setOpen}> <Modal isOpen={deleteState.isOpen} onOpenChange={deleteState.setOpen}>
<Modal.Backdrop><Modal.Container><Modal.Dialog> <Modal.Backdrop><Modal.Container><Modal.Dialog>
<Modal.Header><Modal.Heading>删除战备</Modal.Heading></Modal.Header> <Modal.Header><Modal.Heading>删除战备</Modal.Heading></Modal.Header>
+47 -61
View File
@@ -1,7 +1,8 @@
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import type { ReactNode } from 'react' import { Button, Card, Input, Label, Select, ListBox, Switch, TextArea, TextField, toast } from '@heroui/react'
import { Button, Card, Checkbox, Input, Label, Select, ListBox, TextArea, TextField, toast } from '@heroui/react'
import { api } from '../../api/client' 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') const lc = api('learning_chat')
@@ -13,38 +14,17 @@ function data(res: any) {
return res 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' type MainTab = 'config' | 'messages' | 'contexts' | 'answers' | 'blacklist'
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 MAIN_TABS: TabItem[] = [
const thCls = 'px-3 py-2 text-left text-xs font-medium text-slate-400' { key: 'config', label: '配置' },
const tdCls = 'px-3 py-2 text-sm text-slate-700 border-t border-slate-100' { key: 'messages', label: '消息' },
{ key: 'contexts', label: '学习内容' },
function fmtU(ts?: number) { { key: 'answers', label: '回复' },
if (!ts) return '—' { key: 'blacklist', label: '禁用列表' },
const d = new Date(ts * 1000) ]
return d.toLocaleString('zh-CN', { hour12: false })
}
function PageHead({ title, actions }: { title: string; actions?: ReactNode }) {
return <div className="flex items-center justify-between"><h2 className="text-xl font-semibold text-slate-800">{title}</h2>{actions}</div>
}
function Tabs({ tabs, active, onChange }: { tabs: string[]; active: string; onChange: (v: string) => void }) {
return <div className="flex gap-1 border-b border-slate-200 mb-4">
{tabs.map(t => <button key={t} onClick={() => onChange(t)} className={'px-3 py-2 text-sm border-b-2 -mb-px ' + (active === t ? 'border-indigo-500 text-indigo-700 font-medium' : 'border-transparent text-slate-500 hover:text-slate-700')}>{t}</button>)}
</div>
}
function Pager({ page, total, perPage, onPage }: { page: number; total: number; perPage: number; onPage: (p: number) => void }) {
const pages = Math.max(1, Math.ceil(total / perPage))
return <div className="mt-3 flex items-center gap-2 text-xs text-slate-400">
<Button size="sm" variant="tertiary" isDisabled={page <= 1} onPress={() => onPage(page - 1)}>上一页</Button>
<span>第 {page} / {pages} 页 · 共 {total} 条</span>
<Button size="sm" variant="tertiary" isDisabled={page >= pages} onPress={() => onPage(page + 1)}>下一页</Button>
</div>
}
interface GlobalConfig { 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 KEYWORDS_SIZE: number; cross_group_threshold: number; learn_max_count: number
ban_words: string[]; ban_users: string[]; dictionary: string[] ban_words: string[]; ban_users: string[]; dictionary: string[]
} }
@@ -57,11 +37,11 @@ interface GroupConfig {
interface GroupOption { label: string; value: number } interface GroupOption { label: string; value: number }
export default function LearningChatPage() { export default function LearningChatPage() {
const [tab, setTab] = useState<'config' | 'messages' | 'contexts' | 'answers' | 'blacklist'>('config') const [tab, setTab] = useState<MainTab>('config')
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<PageHead title="群聊学习" actions={<span className="text-sm text-slate-400">学习群友发言 · 复读 · 主动发言</span>} /> <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 === 'config' && <ConfigView />}
{tab === 'messages' && <MessagesView />} {tab === 'messages' && <MessagesView />}
{tab === 'contexts' && <ContextsView />} {tab === 'contexts' && <ContextsView />}
@@ -127,11 +107,8 @@ function ConfigView() {
<Card className="border border-slate-200 bg-white"> <Card className="border border-slate-200 bg-white">
<Card.Header><Card.Title>基本设置</Card.Title></Card.Header> <Card.Header><Card.Title>基本设置</Card.Title></Card.Header>
<Card.Content className="space-y-3"> <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> <Switch isSelected={!!globalCfg.total_enable} onChange={v => setG('total_enable', v)}><Switch.Content><Switch.Control><Switch.Thumb /></Switch.Control>群聊学习总开关</Switch.Content></Switch>
<Checkbox isSelected={!!globalCfg.enable_web} onChange={v => setG('enable_web', v)}><Checkbox.Content><Checkbox.Control><Checkbox.Indicator /></Checkbox.Control>后台管理总开关</Checkbox.Content></Checkbox> <p className="text-xs text-slate-400">后台访问与账号由统一管理台 /hub 提供。</p>
<TextField><Label>后台管理用户名</Label><Input value={globalCfg.web_username || ''} onChange={e => setG('web_username', e.target.value)} /></TextField>
<TextField><Label>后台管理密码</Label><Input type="password" value={globalCfg.web_password || ''} onChange={e => setG('web_password', e.target.value)} /></TextField>
<TextField><Label>后台管理 Token 密钥</Label><Input value={globalCfg.web_secret_key || ''} onChange={e => setG('web_secret_key', e.target.value)} /></TextField>
</Card.Content> </Card.Content>
</Card> </Card>
<Card className="border border-slate-200 bg-white"> <Card className="border border-slate-200 bg-white">
@@ -163,7 +140,7 @@ function ConfigView() {
<Card className="border border-slate-200 bg-white"> <Card className="border border-slate-200 bg-white">
<Card.Header><Card.Title>基本设置</Card.Title></Card.Header> <Card.Header><Card.Title>基本设置</Card.Title></Card.Header>
<Card.Content className="space-y-3"> <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.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.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> <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 className="border border-slate-200 bg-white">
<Card.Header><Card.Title>主动发言设置</Card.Title></Card.Header> <Card.Header><Card.Title>主动发言设置</Card.Title></Card.Header>
<Card.Content className="space-y-3"> <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_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_min_interval ?? ''} onChange={e => setGrp('speak_min_interval', Number(e.target.value))} /></TextField>
<TextField><Label>连续主动发言概率(%)</Label><Input type="number" value={groupCfg.speak_continuously_probability ?? ''} onChange={e => setGrp('speak_continuously_probability', Number(e.target.value))} /></TextField> <TextField><Label>连续主动发言概率(%)</Label><Input type="number" value={groupCfg.speak_continuously_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() { function MessagesView() {
const [rows, setRows] = useState<Msg[]>([]); const [total, setTotal] = useState(0); const [page, setPage] = useState(1) 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 [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 perPage = 10
const load = useCallback(async () => { const load = useCallback(async () => {
setLoading(true) 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) } 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, g, u, m]) }, [page, query])
useEffect(() => { load() }, [load]) 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 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 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) } } 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.Header><Card.Title>消息记录</Card.Title></Card.Header>
<Card.Content className="space-y-3"> <Card.Content className="space-y-3">
<div className="flex flex-wrap items-center gap-2"> <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={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)} /> <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)} /> <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={() => { setPage(1); load() }}>搜索</Button> <Button size="sm" onPress={search}>搜索</Button>
<Button size="sm" variant="tertiary" className="text-red-500" onPress={delAll}>删除所有</Button> <Button size="sm" variant="tertiary" className="text-red-500" onPress={delAll}>删除所有</Button>
</div> </div>
<table className="w-full"> <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> <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> <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-amber-600" onPress={() => ban(r.id)}>禁用</Button>
<Button size="sm" variant="tertiary" className="text-red-500" onPress={() => del(r.id)}>删除</Button> <Button size="sm" variant="tertiary" className="text-red-500" onPress={() => del(r.id)}>删除</Button>
</td></tr>)} </td></tr>)}
@@ -258,8 +238,10 @@ interface Ctx { id: number; keywords: string; time: number; count: number }
function ContextsView() { function ContextsView() {
const [rows, setRows] = useState<Ctx[]>([]); const [total, setTotal] = useState(0); const [page, setPage] = useState(1) 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 [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]) 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 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 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) } } 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.Header><Card.Title>学习内容</Card.Title></Card.Header>
<Card.Content className="space-y-3"> <Card.Content className="space-y-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Input className="flex-1 min-w-[200px]" placeholder="搜索关键词" value={kw} onChange={e => setKw(e.target.value)} /> <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={() => { setPage(1); load() }}>搜索</Button> <Button size="sm" onPress={search}>搜索</Button>
<Button size="sm" variant="tertiary" className="text-red-500" onPress={delAll}>删除所有</Button> <Button size="sm" variant="tertiary" className="text-red-500" onPress={delAll}>删除所有</Button>
</div> </div>
<table className="w-full"> <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> <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> <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-amber-600" onPress={() => ban(r.id)}>禁用</Button>
<Button size="sm" variant="tertiary" className="text-red-500" onPress={() => del(r.id)}>删除</Button> <Button size="sm" variant="tertiary" className="text-red-500" onPress={() => del(r.id)}>删除</Button>
</td></tr>)} </td></tr>)}
@@ -293,8 +275,10 @@ interface Ans { id: number; group_id: number; keywords: string; time: number; co
function AnswersView() { function AnswersView() {
const [rows, setRows] = useState<Ans[]>([]); const [total, setTotal] = useState(0); const [page, setPage] = useState(1) 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 [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]) 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 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 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) } } 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.Header><Card.Title>学习到的回复</Card.Title></Card.Header>
<Card.Content className="space-y-3"> <Card.Content className="space-y-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Input className="flex-1 min-w-[200px]" placeholder="搜索关键词" value={kw} onChange={e => setKw(e.target.value)} /> <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={() => { setPage(1); load() }}>搜索</Button> <Button size="sm" onPress={search}>搜索</Button>
<Button size="sm" variant="tertiary" className="text-red-500" onPress={delAll}>删除所有</Button> <Button size="sm" variant="tertiary" className="text-red-500" onPress={delAll}>删除所有</Button>
</div> </div>
<table className="w-full"> <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> <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> <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-amber-600" onPress={() => ban(r.id)}>禁用</Button>
<Button size="sm" variant="tertiary" className="text-red-500" onPress={() => del(r.id)}>删除</Button> <Button size="sm" variant="tertiary" className="text-red-500" onPress={() => del(r.id)}>删除</Button>
</td></tr>)} </td></tr>)}
@@ -328,8 +312,10 @@ interface Bl { id: number; keywords: string; bans: string; global_ban?: boolean;
function BlacklistView() { function BlacklistView() {
const [rows, setRows] = useState<Bl[]>([]); const [total, setTotal] = useState(0); const [page, setPage] = useState(1) 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 [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]) 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 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) } } const clearAll = async () => { if (!window.confirm('确定取消所有禁用?')) return; try { await lc.put('delete_all?type=blacklist', {}); load() } catch (e: any) { toast.danger(e.message) } }
return ( return (
@@ -337,15 +323,15 @@ function BlacklistView() {
<Card.Header><Card.Title>禁用列表</Card.Title></Card.Header> <Card.Header><Card.Title>禁用列表</Card.Title></Card.Header>
<Card.Content className="space-y-3"> <Card.Content className="space-y-3">
<div className="flex items-center gap-2"> <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={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)} /> <Input className="w-40" placeholder="搜索禁用群" value={bans} onChange={e => setBans(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') search() }} />
<Button size="sm" onPress={() => { setPage(1); load() }}>搜索</Button> <Button size="sm" onPress={search}>搜索</Button>
<Button size="sm" variant="tertiary" onPress={clearAll}>取消所有禁用</Button> <Button size="sm" variant="tertiary" onPress={clearAll}>取消所有禁用</Button>
</div> </div>
<table className="w-full"> <table className="w-full">
<thead><tr><th className={thCls}>内容/关键词</th><th className={thCls}>已禁用的群</th><th className={thCls}></th></tr></thead> <thead><tr><th className={thCls}>内容/关键词</th><th className={thCls}>已禁用的群</th><th className={thCls}></th></tr></thead>
<tbody> <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> <Button size="sm" variant="tertiary" className="text-emerald-600" onPress={() => unban(r.id)}>取消禁用</Button>
</td></tr>)} </td></tr>)}
{!rows.length && <tr><td colSpan={3} className={tdCls + ' text-center text-slate-400'}>{loading ? '加载中…' : '暂无禁用项目'}</td></tr>} {!rows.length && <tr><td colSpan={3} className={tdCls + ' text-center text-slate-400'}>{loading ? '加载中…' : '暂无禁用项目'}</td></tr>}
+6 -1
View File
@@ -4,6 +4,9 @@ import { Toast } from '@heroui/react'
import { AuthProvider } from './auth/auth' import { AuthProvider } from './auth/auth'
import { LoginPage } from './auth/LoginPage' import { LoginPage } from './auth/LoginPage'
import { AppLayout } from './layout/AppLayout' import { AppLayout } from './layout/AppLayout'
import { ErrorBoundary } from './components/ErrorBoundary'
import { ConnectionProvider } from './connection'
import { LogProvider } from './logs'
import { pluginModules } from './plugins' import { pluginModules } from './plugins'
import HomePage from './pages/home' import HomePage from './pages/home'
import PluginsPage from './pages/plugins' import PluginsPage from './pages/plugins'
@@ -14,11 +17,12 @@ export function AppRoutes() {
return ( return (
<AuthProvider> <AuthProvider>
<Toast.Provider /> <Toast.Provider />
<ErrorBoundary>
<Suspense fallback={<div className="p-12 text-slate-400">加载中…</div>}> <Suspense fallback={<div className="p-12 text-slate-400">加载中…</div>}>
<Routes> <Routes>
<Route path="/" element={<Navigate to="/hub" replace />} /> <Route path="/" element={<Navigate to="/hub" replace />} />
<Route path="/hub/login" element={<LoginPage />} /> <Route path="/hub/login" element={<LoginPage />} />
<Route path="/hub" element={<AppLayout />}> <Route path="/hub" element={<ConnectionProvider><LogProvider><AppLayout /></LogProvider></ConnectionProvider>}>
<Route index element={<HomePage />} /> <Route index element={<HomePage />} />
<Route path="plugins" element={<PluginsPage />} /> <Route path="plugins" element={<PluginsPage />} />
<Route path="settings" element={<SettingsPage />} /> <Route path="settings" element={<SettingsPage />} />
@@ -30,6 +34,7 @@ export function AppRoutes() {
</Route> </Route>
</Routes> </Routes>
</Suspense> </Suspense>
</ErrorBoundary>
</AuthProvider> </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 from __future__ import annotations
import hashlib import hashlib
import json
import os import os
import secrets import secrets
import sqlite3 import sqlite3
@@ -104,11 +105,78 @@ def init_auth_db() -> None:
conn.commit() 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: def sync_admin() -> None:
"""启动时调用:以 .env 为准创建/同步超级管理员(含改密码场景)。""" """启动时调用:以 .env 为准创建/同步超级管理员。
仅当 .env 显式配置了 hexi_web_password 时才会在每次启动同步密码
(此时 .env 是权威源,重启始终以 .env 覆盖);若 .env 未显式配置,
则只在用户不存在时播种默认账号,保证 Web 端「修改密码」能持久生效、
不被每次启动静默还原。
"""
init_auth_db() init_auth_db()
username = _read_env("hexi_web_username", "admin") username = _read_env("hexi_web_username", "admin")
password = _read_env("hexi_web_password", "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: with _conn() as conn:
row = conn.execute( row = conn.execute(
"SELECT id FROM web_users WHERE username=?", (username,) "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( def change_password(
user_id: int, old_password: str, new_password: str, keep_token: Optional[str] = None user_id: int, old_password: str, new_password: str, keep_token: Optional[str] = None
) -> bool: ) -> bool:
"""校验旧密码后改新密码,并吊销旧令牌(保留当前令牌可选)。成功返回 True。""" """校验旧密码后改新密码,并吊销旧令牌(保留当前令牌可选)。成功返回 True。
成功后会回写 .env(hexi_web_password),保证重启后仍是新密码:
否则 sync_admin 会按 .env 默认值把密码还原。
"""
if not new_password: if not new_password:
return False return False
init_auth_db() init_auth_db()
@@ -170,6 +242,7 @@ def change_password(
(_hash_password(new_password), user_id), (_hash_password(new_password), user_id),
) )
conn.commit() conn.commit()
_write_env_password(new_password)
revoke_user_tokens(user_id, except_token=keep_token) revoke_user_tokens(user_id, except_token=keep_token)
return True return True
+33 -7
View File
@@ -11,7 +11,7 @@
"default": ..., # 默认值 "default": ..., # 默认值
"description": "...", # 提示 "description": "...", # 提示
"env": "WEB_PASSWORD", # 环境变量名(缺省=key.upper()) "env": "WEB_PASSWORD", # 环境变量名(缺省=key.upper())
"secret": true, # 是否敏感(前端脱敏/掩码) "secret": true, # 是否敏感(前端默认掩码显示,回传明文)
"options": [...], # enum 可选项 "options": [...], # enum 可选项
"required": false, "required": false,
} }
@@ -28,6 +28,7 @@ from __future__ import annotations
import json import json
import os import os
import re
import threading import threading
from pathlib import Path from pathlib import Path
from typing import Any, Callable, Optional from typing import Any, Callable, Optional
@@ -39,7 +40,7 @@ DATA_DIR = _ROOT / "config"
STORE_PATH = DATA_DIR / "plugin_config.json" STORE_PATH = DATA_DIR / "plugin_config.json"
ENV_PATH = _ROOT.parent / ".env" # 仓库根 .env 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 # plugin_id -> schema
_schemas: dict[str, dict[str, Any]] = {} _schemas: dict[str, dict[str, Any]] = {}
@@ -209,6 +210,23 @@ def _coerce(field: dict[str, Any], raw: Any) -> Any:
return float(raw) return float(raw)
except (TypeError, ValueError) as e: except (TypeError, ValueError) as e:
raise ValueError(f"字段 {field['key']} 必须是数字") from 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"}: if typ in {"string", "text", "password"}:
return "" if raw is None else str(raw) return "" if raw is None else str(raw)
if typ == "enum": if typ == "enum":
@@ -240,9 +258,16 @@ def get_config(plugin_id: str) -> Optional[dict[str, Any]]:
values[key] = f["default"] values[key] = f["default"]
else: else:
values[key] = None 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)} 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"] key = f["key"]
if key not in values: if key not in values:
continue continue
# text/json/object/path 这类非标量不适合写进 .env(重启解析会出错), # text/json/object/path/array 这类非标量不适合写进 .env(重启解析会出错),
# 这类字段仅在运行期热更新,不落盘 .env(见 config_standard 说明)。 # 这类字段仅在运行期热更新,不落盘 .env(见 config_standard 说明)。
if f.get("type") in {"text", "json", "object", "path"}: if f.get("type") in {"text", "json", "object", "path", "array"}:
continue continue
env_name = _field_env(f) env_name = _field_env(f)
raw = values[key] raw = values[key]
@@ -340,6 +365,7 @@ def save_config(
if key not in values: if key not in values:
continue continue
raw = values[key] raw = values[key]
# 兼容旧版本前端回传的掩码占位:不覆盖已保存的明文
if f.get("secret") and raw == MASKED_SECRET: if f.get("secret") and raw == MASKED_SECRET:
continue continue
if raw in (None, ""): if raw in (None, ""):