diff --git a/README.md b/README.md index c9fdf77..8d0adcf 100644 --- a/README.md +++ b/README.md @@ -92,7 +92,7 @@ HeXi/ │ ├── data/ # 运行数据(不入库) │ ├── config/ # 业务配置(不入库) │ ├── resource/ # 全局静态资源(fonts/ 字体、menu_config 等) -│ ├── web/ # 统一 Web 前端(Vue 3) +│ ├── web/ # 统一 Web 前端(React + Vite,构建产物 dist/ 供 /hub 挂载) │ ├── logs/ # 日志(不入库) │ └── web_*.py # Web 管理相关(鉴权、插件注册) ├── docs/ # 插件开发标准文档 @@ -118,9 +118,9 @@ poetry run pyright # 类型检查 新增插件请参考: -- [插件结构标准](docs/plugin-structure-standard.md) -- [插件配置标准](docs/plugin-config-standard.md) -- `plugin_template/` 脚手架模板 +- [插件结构标准](dev/docs/plugin-structure-standard.md) +- [插件配置标准](dev/docs/plugin-config-standard.md) +- `dev/plugin_template/` 脚手架模板 ## 许可 diff --git a/dev/__init__.py b/dev/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/dev/docs/HD2/__init__.py b/dev/docs/HD2/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/dev/docs/__init__.py b/dev/docs/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/docs/plugin-audit-report.md b/dev/docs/plugin-audit-report.md similarity index 100% rename from docs/plugin-audit-report.md rename to dev/docs/plugin-audit-report.md diff --git a/docs/plugin-config-standard.md b/dev/docs/plugin-config-standard.md similarity index 100% rename from docs/plugin-config-standard.md rename to dev/docs/plugin-config-standard.md diff --git a/docs/plugin-structure-standard.md b/dev/docs/plugin-structure-standard.md similarity index 100% rename from docs/plugin-structure-standard.md rename to dev/docs/plugin-structure-standard.md diff --git a/dev/plugin_template/__init__.py b/dev/plugin_template/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/plugin_template/nonebot_plugin_template/README.md b/dev/plugin_template/nonebot_plugin_template/README.md similarity index 100% rename from plugin_template/nonebot_plugin_template/README.md rename to dev/plugin_template/nonebot_plugin_template/README.md diff --git a/plugin_template/nonebot_plugin_template/__init__.py b/dev/plugin_template/nonebot_plugin_template/__init__.py similarity index 100% rename from plugin_template/nonebot_plugin_template/__init__.py rename to dev/plugin_template/nonebot_plugin_template/__init__.py diff --git a/plugin_template/nonebot_plugin_template/config.py b/dev/plugin_template/nonebot_plugin_template/config.py similarity index 100% rename from plugin_template/nonebot_plugin_template/config.py rename to dev/plugin_template/nonebot_plugin_template/config.py diff --git a/plugin_template/nonebot_plugin_template/handlers/__init__.py b/dev/plugin_template/nonebot_plugin_template/handlers/__init__.py similarity index 100% rename from plugin_template/nonebot_plugin_template/handlers/__init__.py rename to dev/plugin_template/nonebot_plugin_template/handlers/__init__.py diff --git a/plugin_template/nonebot_plugin_template/handlers/entry.py b/dev/plugin_template/nonebot_plugin_template/handlers/entry.py similarity index 100% rename from plugin_template/nonebot_plugin_template/handlers/entry.py rename to dev/plugin_template/nonebot_plugin_template/handlers/entry.py diff --git a/plugin_template/nonebot_plugin_template/models.py b/dev/plugin_template/nonebot_plugin_template/models.py similarity index 100% rename from plugin_template/nonebot_plugin_template/models.py rename to dev/plugin_template/nonebot_plugin_template/models.py diff --git a/plugin_template/nonebot_plugin_template/repository.py b/dev/plugin_template/nonebot_plugin_template/repository.py similarity index 100% rename from plugin_template/nonebot_plugin_template/repository.py rename to dev/plugin_template/nonebot_plugin_template/repository.py diff --git a/plugin_template/nonebot_plugin_template/services/__init__.py b/dev/plugin_template/nonebot_plugin_template/services/__init__.py similarity index 100% rename from plugin_template/nonebot_plugin_template/services/__init__.py rename to dev/plugin_template/nonebot_plugin_template/services/__init__.py diff --git a/plugin_template/nonebot_plugin_template/services/main.py b/dev/plugin_template/nonebot_plugin_template/services/main.py similarity index 100% rename from plugin_template/nonebot_plugin_template/services/main.py rename to dev/plugin_template/nonebot_plugin_template/services/main.py diff --git a/plugin_template/nonebot_plugin_template/utils/__init__.py b/dev/plugin_template/nonebot_plugin_template/utils/__init__.py similarity index 100% rename from plugin_template/nonebot_plugin_template/utils/__init__.py rename to dev/plugin_template/nonebot_plugin_template/utils/__init__.py diff --git a/plugin_template/nonebot_plugin_template/web/__init__.py b/dev/plugin_template/nonebot_plugin_template/web/__init__.py similarity index 100% rename from plugin_template/nonebot_plugin_template/web/__init__.py rename to dev/plugin_template/nonebot_plugin_template/web/__init__.py diff --git a/plugin_template/nonebot_plugin_template/web/admin.py b/dev/plugin_template/nonebot_plugin_template/web/admin.py similarity index 100% rename from plugin_template/nonebot_plugin_template/web/admin.py rename to dev/plugin_template/nonebot_plugin_template/web/admin.py diff --git a/hexi/config_standard.py b/hexi/config_standard.py index 02abd92..7c5fe2a 100644 --- a/hexi/config_standard.py +++ b/hexi/config_standard.py @@ -63,10 +63,7 @@ def _type_label(annotation: Any) -> str: if origin is Literal: return "enum" if origin in (list, set, tuple): - args = get_args(annotation) - if args and args[0] is str: - return "text" - return "text" + return "array" return "string" @@ -119,11 +116,11 @@ def _coerce_value(value: Any, typ: str, item_type: str = "str") -> Any: return float(value) except (TypeError, ValueError): return None - if typ == "text": + if typ in {"text", "array"}: if isinstance(value, (list, tuple, set)): items = list(value) else: - # TextArea 多行/逗号分隔都接受 + # 数组字段的字符串输入:多行/逗号分隔都接受 import re as _re parts = _re.split(r"[,\n]+", str(value)) items = [p.strip() for p in parts if p.strip()] @@ -168,6 +165,16 @@ def _coerce_value(value: Any, typ: str, item_type: str = "str") -> Any: def _stringify(value: Any, typ: str) -> Any: """把 python 值转成 Web 表单能显示的值。""" + if typ == "array": + # 数组字段直接回传数组,前端用行编辑器(增删行)渲染; + # 兼容旧数据:换行/逗号分隔的字符串按行拆成数组 + if value is None: + return [] + if isinstance(value, (list, tuple, set)): + return [str(v) for v in value] + import re as _re + parts = _re.split(r"[,\n]+", str(value)) + return [p.strip() for p in parts if p.strip()] if typ == "text": if value is None: return "" @@ -240,7 +247,7 @@ def register_model_config( } if typ == "enum": field["options"] = (options or {}).get(key) or _enum_options(annotation) - if typ == "text": + if typ in {"text", "array"}: field["item_type"] = _text_item_type(annotation) schema_fields.append(field) @@ -353,7 +360,7 @@ def register_config_items( } if item.get("options"): field["options"] = item["options"] - if item.get("type") == "text": + if item.get("type") in {"text", "array"}: field["item_type"] = item.get("item_type", "str") if item.get("type") == "object_set" and item.get("item_schema"): field["item_schema"] = item["item_schema"] @@ -435,7 +442,7 @@ def register_object_set( "description": sf.get("description", ""), "secret": sf.get("secret", _is_secret(sf["key"])), } - if sf.get("type") == "text": + if sf.get("type") in {"text", "array"}: field["item_type"] = sf.get("item_type", "str") opts = (options_by_key or {}).get(sf["key"]) or sf.get("options") if opts: diff --git a/hexi/plugins/nonebot_plugin_random_jm_code/config.py b/hexi/plugins/nonebot_plugin_random_jm_code/config.py index 76f3663..54088f1 100644 --- a/hexi/plugins/nonebot_plugin_random_jm_code/config.py +++ b/hexi/plugins/nonebot_plugin_random_jm_code/config.py @@ -24,6 +24,16 @@ BLOCK_CODE = ["350235", "350234"] WHITE_LIST = ["1019165018"] +def _jm_codes_getter() -> list[str]: + from .services.store import sync_get_jm_codes + return sync_get_jm_codes() + + +def _jm_codes_setter(codes) -> None: + from .services.store import sync_set_jm_codes + sync_set_jm_codes(codes) + + def register_config() -> None: register_config_items( _PLUGIN_ID, @@ -31,24 +41,35 @@ def register_config() -> None: { "key": "TEXT_TEMPLATES", "label": "随机文案模板", - "type": "text", + "type": "array", "item_type": "str", "default": TEXT_TEMPLATES, }, { "key": "BLOCK_CODE", "label": "屏蔽码", - "type": "text", + "type": "array", "item_type": "str", "default": BLOCK_CODE, }, { "key": "WHITE_LIST", "label": "白名单群号", - "type": "text", + "type": "array", "item_type": "str", "default": WHITE_LIST, }, + { + "key": "JM_CODES", + "label": "JM 码列表", + "type": "array", + "item_type": "str", + "default": [], + "description": "一行一个(或逗号分隔)。保存会写回 hexi/data/random_jm_code/jm_code.json,机器人随改随用。", + "getter": _jm_codes_getter, + "setter": _jm_codes_setter, + "nosave": True, + }, ], store=_sys.modules[__name__], ) diff --git a/hexi/plugins/nonebot_plugin_steam_info/__init__.py b/hexi/plugins/nonebot_plugin_steam_info/__init__.py index e645855..204a000 100644 --- a/hexi/plugins/nonebot_plugin_steam_info/__init__.py +++ b/hexi/plugins/nonebot_plugin_steam_info/__init__.py @@ -136,7 +136,7 @@ register_model_config( "proxy", ], types={ - "steam_api_key": "text", + "steam_api_key": "array", "steam_broadcast_type": "enum", "steam_playtime_theme": "enum", }, diff --git a/hexi/plugins/nonebot_plugin_web_hub/__init__.py b/hexi/plugins/nonebot_plugin_web_hub/__init__.py index 4a126f2..515e1ec 100644 --- a/hexi/plugins/nonebot_plugin_web_hub/__init__.py +++ b/hexi/plugins/nonebot_plugin_web_hub/__init__.py @@ -54,8 +54,13 @@ basic_path = Path(__file__).resolve().parent # hexi/web 是统一 Web 管理台前端(hexi/web/dist),不是插件目录下的 web WEB_DIST = Path(__file__).resolve().parents[2] / "web" / "dist" -# 管理台自身版本号(展示在设置页「关于」处) -HUB_VERSION = "0.1.0" +def _hub_version() -> str: + """管理台版本号:以 hexi/web/package.json 为唯一来源(前端构建也读它)。""" + try: + pkg = WEB_DIST.parent / "package.json" + return str(json.loads(pkg.read_text(encoding="utf-8")).get("version", "0.1.0")) + except Exception: # noqa: BLE001 + return "0.1.0" # 仓库根目录(bot.py 所在)与 bot 端口 _REPO_ROOT = Path(__file__).resolve().parents[3] @@ -118,6 +123,65 @@ def _archive_log() -> None: logger.warning(f"归档日志失败: {type(e).__name__}: {e}") +def _tail_log_lines( + path: Path, limit: int, before: int | None +) -> tuple[list[str], int, int]: + """从日志文件读取最近 limit 行(或 before 字节之前最近的 limit 行)。 + + 返回 (lines, window_start_offset, file_size)。window_start_offset 可作为 + 下一批更早日志的 before;为 0 表示已到文件头。仅按 8KB 步进向后寻找行边界, + 避免大日志全量 read_bytes。逐行 UTF-8 优先、GBK 兜底:兼容旧 GBK 日志与 + 切换为 UTF-8 后的新日志混存(保留 ANSI 颜色码,交给前端渲染颜色)。 + """ + size = path.stat().st_size + end = size if before is None or before <= 0 or before > size else int(before) + if end <= 0: + return [], 0, size + start = 0 + with open(path, "rb") as f: + pos = end + newline_count = 0 + while pos > 0 and newline_count <= limit: + step = min(pos, 8192) + f.seek(pos - step) + chunk = f.read(step) + idx = chunk.rfind(b"\n") + if idx == -1: + pos -= step + continue + nl_abs = pos - step + idx + newline_count += 1 + if newline_count > limit: + start = nl_abs + 1 + break + pos = nl_abs + f.seek(start) + raw = f.read(end - start) + raw_lines = raw.split(b"\n") + if raw_lines and raw_lines[-1] == b"": + raw_lines.pop() + lines = [] + for raw_line in raw_lines: + try: + line = raw_line.decode("utf-8") + except UnicodeDecodeError: + line = raw_line.decode("gbk", errors="replace") + lines.append(line.rstrip("\r")) + return lines, start, size + + +def _count_log_lines(path: Path) -> int: + """分块统计全文件行数(仅换行计数,不全量载入)。""" + count = 0 + with open(path, "rb") as f: + while True: + chunk = f.read(1 << 20) + if not chunk: + break + count += chunk.count(b"\n") + return count + + def _schedule_bot_action(action: str) -> None: """1 秒后执行:stop 直接退出;restart 先拉起新进程再退出旧进程。""" @@ -205,7 +269,7 @@ def build_hub_app() -> FastAPI: "created_at": user.get("created_at", ""), }, "plugins": list_web_plugins(), - "version": HUB_VERSION, + "version": _hub_version(), } @app.get("/api/dashboard") @@ -360,9 +424,15 @@ def build_hub_app() -> FastAPI: @app.get("/api/logs") async def logs( limit: int = 500, + before: int | None = None, _: dict = Depends(get_current_user), ): - """实时读取 bot 运行日志(优先 _bot_run.log),返回最近 limit 行,供前端轮询。""" + """读取 bot 运行日志(优先 _bot_run.log)。 + + - before 不传:返回最近 limit 行,offset/end 为文件尾(SSE 以 end 续读) + - before 传字节偏移:返回该偏移之前最近 limit 行,offset 为这些行的起点, + 继续把 offset 传给 before 即可向前翻页;offset==0 表示已到文件头。 + """ root = Path(__file__).resolve().parents[3] # 仓库根目录 candidates = [ root / "_bot_run.log", @@ -372,20 +442,20 @@ def build_hub_app() -> FastAPI: if path is None: return {"ok": False, "msg": "未找到日志文件", "lines": [], "size": 0, "total": 0} try: - raw = path.read_bytes() + lines, start, size = _tail_log_lines(path, max(1, int(limit)), before) + total = _count_log_lines(path) except OSError as e: # noqa: BLE001 return {"ok": False, "msg": f"读取日志失败: {e}", "lines": [], "size": 0, "total": 0} - # 逐行 UTF-8 优先、GBK 兜底:兼容旧 GBK 日志与切换为 UTF-8 后的新日志混存 - # 保留 ANSI 颜色码,交给前端渲染颜色 - lines = [] - for raw_line in raw.split(b"\n"): - try: - line = raw_line.decode("utf-8") - except UnicodeDecodeError: - line = raw_line.decode("gbk", errors="replace") - lines.append(line.rstrip("\r")) - tail = lines[-max(1, int(limit)) :] - return {"ok": True, "file": path.name, "size": len(raw), "offset": len(raw), "lines": tail, "total": len(lines)} + return { + "ok": True, + "file": path.name, + "size": size, + "end": size, + "offset": start, + "has_more": start > 0, + "lines": lines, + "total": total, + } @app.get("/api/dashboard/stream") async def dashboard_stream( diff --git a/hexi/plugins/nonebot_plugin_web_hub/dashboard.py b/hexi/plugins/nonebot_plugin_web_hub/dashboard.py index 960cd98..2de528e 100644 --- a/hexi/plugins/nonebot_plugin_web_hub/dashboard.py +++ b/hexi/plugins/nonebot_plugin_web_hub/dashboard.py @@ -5,6 +5,8 @@ - bot 账号 / WS 连通性:从 OneBot V11 适配器的 bots / connections 读取 - host 性能:psutil(CPU/内存/磁盘/进程)+ platform + nonebot 版本 仅在 /hub/api/dashboard 被调用时执行,不做常驻采样。 +同步采集(psutil 等可能阻塞的调用)整体放入线程池执行,避免阻塞事件循环; +结果做 1 秒短缓存,多个前端标签页(含 SSE 循环)不至于各自重复采集。 """ from __future__ import annotations @@ -26,17 +28,21 @@ _MODULE_START = time.time() # 网络速率采样:记录上一次累计计数与时间,用于计算实时 bytes/s 用量 _NET_LAST: dict = {"time": None, "sent": None, "recv": None} +# 采集结果短缓存(多标签页 / SSE 每 2s 循环共用一份结果) +_DASH_CACHE: dict = {"time": 0.0, "data": None} +_DASH_CACHE_TTL = 1.0 -async def _collect_cpu() -> dict: + +def _cpu_sync() -> dict: try: - per_core = await asyncio.to_thread(psutil.cpu_percent, 0.3, True) + per_core = psutil.cpu_percent(0.3, True) except TypeError: # 兼容部分平台不接受 percpu 参数 per_core = [] if per_core: percent = sum(per_core) / len(per_core) else: - percent = await asyncio.to_thread(psutil.cpu_percent, 0.3) + percent = psutil.cpu_percent(0.3) info: dict = { "percent": round(float(percent), 1), "per_core": [round(float(x), 1) for x in (per_core or [])], @@ -57,7 +63,7 @@ async def _collect_cpu() -> dict: return info -async def _collect_memory() -> dict: +def _memory_sync() -> dict: mem = psutil.virtual_memory() try: proc = psutil.Process() @@ -76,10 +82,10 @@ async def _collect_memory() -> dict: } -async def _collect_process() -> dict: +def _process_sync() -> dict: try: proc = psutil.Process() - cpu = await asyncio.to_thread(proc.cpu_percent, 0.2) + cpu = proc.cpu_percent(0.2) info = { "pid": proc.pid, "name": proc.name(), @@ -94,7 +100,7 @@ async def _collect_process() -> dict: return info -async def _collect_network() -> dict: +def _network_sync() -> dict: """采集网络流量累计值 + 实时速率(bytes/s)。""" global _NET_LAST now = time.time() @@ -157,7 +163,7 @@ async def _collect_network() -> dict: } -async def _collect_backend() -> dict: +def _backend_sync() -> dict: """BOT 后端信息:NoneBot 版本 / 驱动方式 / 装载插件数。""" from nonebot import get_loaded_plugins @@ -183,7 +189,7 @@ async def _collect_backend() -> dict: return info -async def _collect_system() -> dict: +def _system_sync() -> dict: boot = psutil.boot_time() now = time.time() return { @@ -206,7 +212,7 @@ def _nonebot_version() -> str: return "" -async def _collect_disk() -> list: +def _disk_sync() -> list: parts = [] for part in psutil.disk_partitions(): try: @@ -271,18 +277,25 @@ async def _collect_bots() -> list: async def collect_dashboard() -> dict: - """聚合采集,失败字段降级,不阻塞整体返回。""" - cpu, memory, process, system, disk, bots, network, backend = await asyncio.gather( - _collect_cpu(), - _collect_memory(), - _collect_process(), - _collect_system(), - _collect_disk(), - _collect_bots(), - _collect_network(), - _collect_backend(), + """聚合采集,失败字段降级,不阻塞整体返回。 + + 同步采集整体放线程池(psutil 的 disk_usage 等可能秒级阻塞), + 结果带 1 秒短缓存(SSE 每 2s 循环与多标签页共用)。 + """ + now = time.time() + if _DASH_CACHE["data"] is not None and now - _DASH_CACHE["time"] < _DASH_CACHE_TTL: + return _DASH_CACHE["data"] + cpu, memory, process, system, disk, network, backend, bots = await asyncio.gather( + asyncio.to_thread(_cpu_sync), + asyncio.to_thread(_memory_sync), + asyncio.to_thread(_process_sync), + asyncio.to_thread(_system_sync), + asyncio.to_thread(_disk_sync), + asyncio.to_thread(_network_sync), + asyncio.to_thread(_backend_sync), + _collect_bots(), # 需要走 bot API,必须留在事件循环 ) - return { + data = { "bots": bots, "system": system, "cpu": cpu, @@ -292,3 +305,6 @@ async def collect_dashboard() -> dict: "network": network, "backend": backend, } + _DASH_CACHE["time"] = now + _DASH_CACHE["data"] = data + return data diff --git a/hexi/web/src/api/client.ts b/hexi/web/src/api/client.ts index dea4af2..165025b 100644 --- a/hexi/web/src/api/client.ts +++ b/hexi/web/src/api/client.ts @@ -1,8 +1,17 @@ -const TOKEN_KEY = 'hexi_web_token' -const USER_KEY = 'hexi_web_username' +export const TOKEN_KEY = 'hexi_web_token' +export const USER_KEY = 'hexi_web_username' + +export const AUTH_EXPIRED_EVENT = 'hexi:auth-expired' export function getToken() { return localStorage.getItem(TOKEN_KEY) || '' } +export function handleAuthExpired() { + clearSession() + // 通知 React AuthProvider 同步清空内存态,AppLayout 会通过 React Router + // 客户端跳转到登录页(内容区局部切换,不整页刷新)。 + try { window.dispatchEvent(new Event(AUTH_EXPIRED_EVENT)) } catch { /* ignore */ } +} + interface ReqOpts { method?: string; body?: any; headers?: Record } async function request(url: string, opts: ReqOpts = {}) { @@ -12,6 +21,9 @@ async function request(url: string, opts: ReqOpts = {}) { const res = await fetch(url, { method: opts.method || 'GET', headers, body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined }) let data: any = null try { data = await res.json() } catch { data = null } + if (res.status === 401) { + handleAuthExpired() + } if (!res.ok) { const msg = (data && (data.detail || data.msg)) || ('HTTP ' + res.status) throw new Error(msg) @@ -40,7 +52,8 @@ export const authLogin = async (username: string, password: string) => { if (data && data.access_token === undefined) throw new Error('登录失败') return { token: data.access_token, username: data.username || username } } -export const authMe = async () => await request('/hub/api/auth/me') +// 登出时吊销服务端令牌(避免已登出的 token 在 24h 内仍可复用) +export const authLogout = async () => await request('/hub/api/auth/logout', { method: 'POST' }) export const hubPlugins = async () => await request('/hub/api/plugins') export const hubDashboard = async () => await request('/hub/api/dashboard') export const hubPluginCatalog = async () => await request('/hub/api/plugins/catalog') @@ -58,82 +71,39 @@ export const hubPluginSaveConfig = async (id: string, values: Record await request('/hub/api/settings') export const hubChangePassword = async (old_password: string, new_password: string) => await request('/hub/api/settings/password', { method: 'POST', body: { old_password, new_password } }) -export const hubLogs = async (limit = 500) => await request('/hub/api/logs?limit=' + limit) +export const hubLogs = async (limit = 500, before?: number) => + await request('/hub/api/logs?limit=' + limit + (before != null ? '&before=' + before : '')) export const hubBotAction = async (action: 'stop' | 'restart') => await request('/hub/api/system/bot', { method: 'POST', body: { action } }) export const hubBotConfig = async () => await request('/hub/api/system/bot_config') export const hubBotConfigSave = async (values: Record) => await request('/hub/api/system/bot_config', { method: 'POST', body: values }) -export function hubLogStream(opts: { - since: number - onLine: (line: string) => void - onStatus: (s: 'open' | 'reconnecting' | 'closed') => void -}): () => void { - const controller = new AbortController() - let stopped = false - let since = opts.since - - const read = async () => { - while (!stopped) { - try { - const res = await fetch('/hub/api/logs/stream?since=' + since, { - headers: { Authorization: 'Bearer ' + getToken() }, - signal: controller.signal, - }) - if (!res.ok || !res.body) throw new Error('HTTP ' + res.status) - opts.onStatus('open') - const reader = res.body.getReader() - const decoder = new TextDecoder() - let buf = '' - while (!stopped) { - const { done, value } = await reader.read() - if (done) break - buf += decoder.decode(value, { stream: true }) - let i - while ((i = buf.indexOf('\n\n')) !== -1) { - const block = buf.slice(0, i) - buf = buf.slice(i + 2) - for (const ln of block.split('\n')) { - if (ln.startsWith('data: ')) { - try { - const obj = JSON.parse(ln.slice(6)) - if (obj && obj.line !== undefined) { - opts.onLine(obj.line) - if (typeof obj.offset === 'number') since = obj.offset - } - } catch { /* 忽略坏帧 */ } - } - } - } - } - if (!stopped) opts.onStatus('reconnecting') - } catch (e: any) { - if (stopped || (e && e.name === 'AbortError')) return - opts.onStatus('reconnecting') - } - if (!stopped) await new Promise(res => setTimeout(res, 1500)) - } - } - - read() - return () => { stopped = true; controller.abort() } +interface SSEStreamOpts { + url: () => string + onData: (obj: any) => void + onStatus?: (s: 'open' | 'reconnecting' | 'closed') => void + retryMs?: number } -export function hubDashboardStream(opts: { - onData: (data: any) => void - onStatus?: (s: 'open' | 'reconnecting' | 'closed') => void -}): () => void { +// SSE 统一封装:fetch 流式读取 + 断线重连 + 401 登出通知 +function makeSSEStream(opts: SSEStreamOpts): () => void { const controller = new AbortController() let stopped = false + const retryMs = opts.retryMs ?? 1500 const read = async () => { while (!stopped) { try { - const res = await fetch('/hub/api/dashboard/stream', { + const res = await fetch(opts.url(), { headers: { Authorization: 'Bearer ' + getToken() }, signal: controller.signal, }) + if (res.status === 401) { + handleAuthExpired() + opts.onStatus?.('closed') + return + } if (!res.ok || !res.body) throw new Error('HTTP ' + res.status) opts.onStatus?.('open') const reader = res.body.getReader() @@ -159,7 +129,7 @@ export function hubDashboardStream(opts: { if (stopped || (e && e.name === 'AbortError')) return opts.onStatus?.('reconnecting') } - if (!stopped) await new Promise(res => setTimeout(res, 2000)) + if (!stopped) await new Promise(res => setTimeout(res, retryMs)) } } @@ -167,6 +137,37 @@ export function hubDashboardStream(opts: { return () => { stopped = true; controller.abort() } } +export function hubLogStream(opts: { + since: number + onLine: (line: string) => void + onStatus?: (s: 'open' | 'reconnecting' | 'closed') => void +}): () => void { + let since = opts.since + return makeSSEStream({ + url: () => '/hub/api/logs/stream?since=' + since, + onData: (obj) => { + if (obj && obj.line !== undefined) { + opts.onLine(obj.line) + if (typeof obj.offset === 'number') since = obj.offset + } + }, + onStatus: opts.onStatus, + retryMs: 1500, + }) +} + +export function hubDashboardStream(opts: { + onData: (data: any) => void + onStatus?: (s: 'open' | 'reconnecting' | 'closed') => void +}): () => void { + return makeSSEStream({ + url: () => '/hub/api/dashboard/stream', + onData: opts.onData, + onStatus: opts.onStatus, + retryMs: 2000, + }) +} + export const api = (plugin: string) => ({ get: (path: string) => request(`/api/${plugin}/${path}`), post: (path: string, body: any) => request(`/api/${plugin}/${path}`, { method: 'POST', body }), diff --git a/hexi/web/src/auth/LoginPage.tsx b/hexi/web/src/auth/LoginPage.tsx index 7d05bfe..4bbf960 100644 --- a/hexi/web/src/auth/LoginPage.tsx +++ b/hexi/web/src/auth/LoginPage.tsx @@ -1,11 +1,14 @@ import { useState } from 'react' -import { useNavigate } from 'react-router-dom' +import { useLocation, useNavigate } from 'react-router-dom' import { Button } from '@heroui/react' import { useAuth } from './auth' export function LoginPage() { const { login } = useAuth() const navigate = useNavigate() + const location = useLocation() + // 被 AppLayout 带到登录页时记住原目标,登录后回跳 + const from = (location.state as any)?.from || '/hub' const [username, setUsername] = useState('') const [password, setPassword] = useState('') const [err, setErr] = useState('') @@ -15,7 +18,7 @@ export function LoginPage() { setLoading(true); setErr('') try { await login(username, password) - navigate('/hub') + navigate(from, { replace: true }) } catch (e: any) { setErr(e.message || '登录失败') } finally { setLoading(false) } diff --git a/hexi/web/src/auth/auth.tsx b/hexi/web/src/auth/auth.tsx index 8659ad3..9d867d8 100644 --- a/hexi/web/src/auth/auth.tsx +++ b/hexi/web/src/auth/auth.tsx @@ -1,5 +1,5 @@ -import { createContext, useContext, useState, type ReactNode } from 'react' -import { authLogin, clearSession, getUserName, setSession } from '../api/client' +import { createContext, useContext, useEffect, useState, type ReactNode } from 'react' +import { authLogin, authLogout, AUTH_EXPIRED_EVENT, clearSession, getUserName, setSession, TOKEN_KEY, USER_KEY } from '../api/client' interface AuthState { token: string @@ -11,8 +11,8 @@ interface AuthState { const Ctx = createContext({ token: '', username: '', login: async () => {}, logout: () => {} }) export function AuthProvider({ children }: { children: ReactNode }) { - const [token, setToken] = useState(() => localStorage.getItem('hexi_web_token') || '') - const [username, setUsername] = useState(() => localStorage.getItem('hexi_web_username') || '') + const [token, setToken] = useState(() => localStorage.getItem(TOKEN_KEY) || '') + const [username, setUsername] = useState(() => localStorage.getItem(USER_KEY) || '') const login = async (u: string, p: string) => { const data = await authLogin(u, p) setSession(data.token, data.username) @@ -20,10 +20,25 @@ export function AuthProvider({ children }: { children: ReactNode }) { setUsername(data.username) } const logout = () => { + // 先吊销服务端令牌(fire-and-forget,失败不阻塞退出),再清理本地 + authLogout().catch(() => {}) clearSession() setToken('') setUsername('') } + + // 任何请求命中 401(登录态无效/过期)时,同步清空内存态, + // AppLayout 会立即自动跳转到登录页。 + useEffect(() => { + const onExpired = () => { + clearSession() + setToken('') + setUsername('') + } + window.addEventListener(AUTH_EXPIRED_EVENT, onExpired) + return () => window.removeEventListener(AUTH_EXPIRED_EVENT, onExpired) + }, []) + return {children} } diff --git a/hexi/web/src/components/ErrorBoundary.tsx b/hexi/web/src/components/ErrorBoundary.tsx new file mode 100644 index 0000000..b0393c7 --- /dev/null +++ b/hexi/web/src/components/ErrorBoundary.tsx @@ -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 { + 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 ( +
+

{err.message || '页面渲染出错'}

+ +
+ ) + } + return ( +
+
+
页面出错了
+

{err.message || '未知错误'}

+
+ + +
+
+
+ ) + } +} diff --git a/hexi/web/src/components/ui.tsx b/hexi/web/src/components/ui.tsx new file mode 100644 index 0000000..a19791c --- /dev/null +++ b/hexi/web/src/components/ui.tsx @@ -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

{title}

{actions}
+} + +export interface TabItem { key: string; label: string } + +export function Tabs({ tabs, active, onChange }: { tabs: TabItem[]; active: string; onChange: (key: string) => void }) { + return
+ {tabs.map(t => )} +
+} + +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
+ + 第 {page} / {pages} 页 · 共 {total} 条 + +
+} diff --git a/hexi/web/src/connection.tsx b/hexi/web/src/connection.tsx new file mode 100644 index 0000000..9429c06 --- /dev/null +++ b/hexi/web/src/connection.tsx @@ -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({ status: '连接中', data: null, error: '', lastUpdate: null }) + +/** + * 全局连接状态:AppLayout 内常驻一个 dashboard SSE(对 /hub/api/dashboard/stream)。 + * 顶栏徽章显示实时/重连/断开;首页数据也直接消费该流,不再各自订阅。 + */ +export function ConnectionProvider({ children }: { children: ReactNode }) { + const [status, setStatus] = useState('连接中') + const [data, setData] = useState(null) + const [error, setError] = useState('') + const [lastUpdate, setLastUpdate] = useState(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 {children} +} + +export const useConnection = () => useContext(Ctx) diff --git a/hexi/web/src/layout/AppLayout.tsx b/hexi/web/src/layout/AppLayout.tsx index 0f23ccf..5e06ec6 100644 --- a/hexi/web/src/layout/AppLayout.tsx +++ b/hexi/web/src/layout/AppLayout.tsx @@ -1,21 +1,32 @@ +import { useLayoutEffect, useEffect, useRef, useState, Suspense } from 'react' import { Navigate, NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom' -import { Avatar, Button, Separator } from '@heroui/react' -import { House, Rocket, Gear, FileText } from '@gravity-ui/icons' +import { Avatar, Button, Separator, Spinner } from '@heroui/react' +import { House, Rocket, Gear, FileText, Bars } from '@gravity-ui/icons' import { useAuth } from '../auth/auth' +import { hubPlugins } from '../api/client' +import { ErrorBoundary } from '../components/ErrorBoundary' +import { useConnection } from '../connection' interface NavItem { id: string; label: string; description: string; icon: any } +const NAV_ITEMS: NavItem[] = [ + { id: 'home', label: '首页', description: '主机与服务状态', icon: House }, + { id: 'plugins', label: '插件管理', description: '插件总览与分群控制', icon: Rocket }, + { id: 'logs', label: '查看日志', description: 'bot 运行日志实时查看', icon: FileText }, + { id: 'settings', label: '设置', description: '账号与系统设置', icon: Gear }, +] + +// 插件名映射做模块级缓存:布局多次挂载(路由切换)时不必重复拉取 +let webNamesCache: Record | null = null + export function AppLayout() { const { token, username, logout } = useAuth() const navigate = useNavigate() const location = useLocation() + // 全局连接状态(顶栏徽章):由常驻 dashboard SSE 驱动 + const { status, lastUpdate } = useConnection() - const navItems: NavItem[] = [ - { id: 'home', label: '首页', description: '主机与服务状态', icon: House }, - { id: 'plugins', label: '插件管理', description: '插件总览与分群控制', icon: Rocket }, - { id: 'logs', label: '查看日志', description: 'bot 运行日志实时查看', icon: FileText }, - { id: 'settings', label: '设置', description: '账号与系统设置', icon: Gear }, - ] + const navItems = NAV_ITEMS const path = location.pathname.replace(/\/+$/, '') || '/' const isHome = path === '/hub' const isSettings = path.startsWith('/hub/settings') @@ -26,11 +37,62 @@ export function AppLayout() { const activeId = isPlugins ? 'plugins' : isLogs ? 'logs' : isSettings ? 'settings' : 'home' const pageMeta = navItems.find(it => it.id === activeId) || navItems[0] - if (!token) return + // 插件子页:从 /hub/<插件id>/... 提取插件 id,并用注册表名字做顶栏面包屑 + const pluginId = path.split('/')[2] || '' + const [webNames, setWebNames] = useState>({}) + useEffect(() => { + if (webNamesCache) { setWebNames(webNamesCache); return } + hubPlugins().then(d => { + const m: Record = {} + 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(null) + + const recalcIndicator = () => { + const nav = mainNavRef.current + if (!nav || !activeMainId) { setIndicator(null); return } + const el = nav.querySelector('[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 return (
-
-
-
+
+
+ {isPluginSub && ( - + + )} + {isPluginSub ? ( +
+ 插件管理 + › + {subName} +
+ ) : ( + <> + + {pageMeta.label} + )} - - {pageMeta.label}
-
+
+ + + {status} + {lastUpdate && {new Date(lastUpdate).toLocaleTimeString('zh-CN', { hour12: false })}} + {(username || 'U').charAt(0).toUpperCase()} - {username || '未登录'} - + {username || '未登录'} +
-
+
+
+ {/* 插件懒加载只替换内容区;子页面异常也不拖垮整个布局 */} + +
}> + + + +
+
) -} \ No newline at end of file +} diff --git a/hexi/web/src/lib/format.ts b/hexi/web/src/lib/format.ts new file mode 100644 index 0000000..4d35ecd --- /dev/null +++ b/hexi/web/src/lib/format.ts @@ -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 }) +} diff --git a/hexi/web/src/logs.tsx b/hexi/web/src/logs.tsx new file mode 100644 index 0000000..8b74348 --- /dev/null +++ b/hexi/web/src/logs.tsx @@ -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 + loadEarlier: () => Promise +} + +const Ctx = createContext({ + 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([]) + const [info, setInfo] = useState({ 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([]) + 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 ( + + {children} + + ) +} + +export const useLogs = () => useContext(Ctx) diff --git a/hexi/web/src/pages/home/index.tsx b/hexi/web/src/pages/home/index.tsx index ea13771..ad11284 100644 --- a/hexi/web/src/pages/home/index.tsx +++ b/hexi/web/src/pages/home/index.tsx @@ -1,6 +1,6 @@ -import { useCallback, useEffect, useRef, useState } from 'react' -import { Avatar, Card } from '@heroui/react' -import { hubDashboard, hubDashboardStream } from '../../api/client' +import { Avatar, Card, Spinner } from '@heroui/react' +import { useConnection } from '../../connection' +import { fmtBytes, fmtRate, fmtUptime } from '../../lib/format' interface BotInfo { self_id: string @@ -87,32 +87,6 @@ interface DashboardData { backend: BackendInfo } -function fmtBytes(n: number): string { - if (!Number.isFinite(n)) return '—' - const units = ['B', 'KB', 'MB', 'GB', 'TB'] - let v = n - let i = 0 - while (v >= 1024 && i < units.length - 1) { v /= 1024; i++ } - return v.toFixed(1) + ' ' + units[i] -} - -function fmtRate(n: number): string { - if (!Number.isFinite(n) || n < 0) return '0 B/s' - return fmtBytes(n) + '/s' -} - -function fmtUptime(sec: number): string { - if (!Number.isFinite(sec)) return '—' - const d = Math.floor(sec / 86400) - const h = Math.floor((sec % 86400) / 3600) - const m = Math.floor((sec % 3600) / 60) - const parts: string[] = [] - if (d) parts.push(d + ' 天') - if (h) parts.push(h + ' 小时') - parts.push(m + ' 分') - return parts.join(' ') -} - function Bar({ value, color }: { value: number; color?: string }) { const v = Math.max(0, Math.min(100, value)) const c = color || (v >= 80 ? 'bg-rose-500' : v >= 60 ? 'bg-amber-500' : 'bg-indigo-500') @@ -149,37 +123,9 @@ function SectionTitle({ title, desc }: { title: string; desc: string }) { } export default function HomePage() { - const [data, setData] = useState(null) - const [error, setError] = useState('') - const [loading, setLoading] = useState(false) - const refreshingRef = useRef(false) - - const refresh = useCallback(async (silent = false) => { - if (refreshingRef.current) return - refreshingRef.current = true - if (!silent) setLoading(true) - try { - const d = await hubDashboard() - if (!d || d.ok === false) throw new Error((d && (d as any).msg) || '采集失败') - setData(d) - setError('') - } catch (e: any) { - setError(e.message || '加载失败') - } finally { - refreshingRef.current = false - if (!silent) setLoading(false) - } - }, []) - - useEffect(() => { - refresh(true) // 初始静默加载,快速出首屏 - const dispose = hubDashboardStream({ - onData: (d) => { - if (d && d.ok !== false) { setData(d); setError('') } - }, - }) - return () => { dispose() } - }, [refresh]) + // 数据与连接状态来自全局 ConnectionProvider(顶栏已显示状态徽章) + const { data: rawData, error } = useConnection() + const data = rawData as DashboardData | null const cpu = data?.cpu const mem = data?.memory @@ -190,15 +136,82 @@ export default function HomePage() { return (
-
-
-

总览

-

主机与 BOT 综合状态(每 2 秒刷新)

-
+
+

总览

+

主机与 BOT 综合状态(实时刷新)

{error && !data &&

{error}

} + {/* 顶部:BOT 协议端信息 + BOT 后端信息 一行两列 */} +
+ {/* BOT 协议端信息:登录账号昵称 / 账号 / 适配器类型 */} + + + + + + {!data ? ( +
+ ) : !data.bots.length ? ( +

暂无在线 QQ 账号

+ ) : ( +
+ {data.bots.map(bot => ( +
+ + + {(bot.nick || bot.self_id).charAt(0).toUpperCase()} + +
+
{bot.nick}
+
{bot.self_id} · {bot.adapter}
+
+
+ {bot.msg_recv != null && 收 {bot.msg_recv}} + {bot.msg_sent != null && 发 {bot.msg_sent}} + {bot.online != null && {bot.online ? '在线' : '离线'}} + + + {bot.ws_connected ? '已连接' : '未连接'} + +
+
+ ))} +
+ )} +
+
+ {/* BOT 后端信息:NoneBot 版本 / 驱动方式 / 装载插件数 */} + + + + + +
+
+
NoneBot 版本
+
{backend?.nonebot_version || '—'}
+
+
+
驱动方式
+
{backend?.driver || '—'}
+
{backend?.driver_type || ''}
+
+
+
装载插件数
+
{backend?.plugin_count ?? '—'} 个
+
+
+
Python
+
{backend?.python || '—'}
+
bot 运行 {sys ? fmtUptime(sys.bot_uptime_seconds) : '—'}
+
+
+
+
+ +
{/* 1. 主机规格信息:系统 / 处理器 / 内存 / 硬盘 */} @@ -234,7 +247,7 @@ export default function HomePage() {
硬盘
{!data ? ( -

加载中…

+
) : !data.disk.length ? (

未获取到磁盘信息

) : ( @@ -323,72 +336,6 @@ export default function HomePage() { - {/* 3. BOT 后端信息:NoneBot 版本 / 驱动方式 / 装载插件数 */} - - - - - -
-
-
NoneBot 版本
-
{backend?.nonebot_version || '—'}
-
-
-
驱动方式
-
{backend?.driver || '—'}
-
{backend?.driver_type || ''}
-
-
-
装载插件数
-
{backend?.plugin_count ?? '—'} 个
-
-
-
Python
-
{backend?.python || '—'}
-
bot 运行 {sys ? fmtUptime(sys.bot_uptime_seconds) : '—'}
-
-
-
-
- - {/* 4. BOT 协议端信息:登录账号昵称 / 账号 / 适配器类型 */} - - - - - - {!data ? ( -

加载中…

- ) : !data.bots.length ? ( -

暂无在线 QQ 账号

- ) : ( -
- {data.bots.map(bot => ( -
- - - {(bot.nick || bot.self_id).charAt(0).toUpperCase()} - -
-
{bot.nick}
-
{bot.self_id} · {bot.adapter}
-
-
- {bot.msg_recv != null && 收 {bot.msg_recv}} - {bot.msg_sent != null && 发 {bot.msg_sent}} - {bot.online != null && {bot.online ? '在线' : '离线'}} - - - {bot.ws_connected ? '已连接' : '未连接'} - -
-
- ))} -
- )} -
-
) } diff --git a/hexi/web/src/pages/logs/index.tsx b/hexi/web/src/pages/logs/index.tsx index f36e1ff..830f67e 100644 --- a/hexi/web/src/pages/logs/index.tsx +++ b/hexi/web/src/pages/logs/index.tsx @@ -1,17 +1,7 @@ -import { useCallback, useEffect, useRef, useState, type CSSProperties } from 'react' -import { Button, Card } from '@heroui/react' -import { hubLogs, hubLogStream } from '../../api/client' - -const MAX_LINES = 2000 - -function fmtBytes(n: number): string { - if (!Number.isFinite(n)) return '—' - const units = ['B', 'KB', 'MB', 'GB'] - let v = n - let i = 0 - while (v >= 1024 && i < units.length - 1) { v /= 1024; i++ } - return v.toFixed(1) + ' ' + units[i] -} +import { useEffect, useRef, useState, type CSSProperties } from 'react' +import { Button, Card, Spinner } from '@heroui/react' +import { useLogs } from '../../logs' +import { fmtBytes } from '../../lib/format' // ANSI SGR 颜色码 → 内联样式,用于还原 loguru 彩色日志 const ANSI_FG: Record = { @@ -91,68 +81,10 @@ function AnsiLine({ text }: { text: string }) { } export default function LogsPage() { - const [lines, setLines] = useState([]) - const [info, setInfo] = useState<{ file: string; size: number; total: number }>({ file: '', size: 0, total: 0 }) - const [error, setError] = useState('') - const [streamStatus, setStreamStatus] = useState<'连接中' | '实时' | '重连中' | '已断开'>('连接中') + // 数据与日志流由全局 LogProvider 常驻管理:离开/回来不重建连接、不转圈 + const { lines, info, error, loading, hasMore, loadingEarlier, paused, setPaused, loadEarlier } = useLogs() const [follow, setFollow] = useState(true) - const [paused, setPaused] = useState(false) - const [loading, setLoading] = useState(false) const scrollRef = useRef(null) - const sinceRef = useRef(0) - const pausedRef = useRef(false) - const pendingRef = useRef([]) - const disposeRef = useRef<(() => void) | null>(null) - - const append = useCallback((line: string) => { - setLines(prev => [...prev, line].slice(-MAX_LINES)) - }, []) - - const connect = useCallback(() => { - if (disposeRef.current) disposeRef.current() - disposeRef.current = hubLogStream({ - since: sinceRef.current, - onLine: (line) => { - if (pausedRef.current) { pendingRef.current.push(line); return } - append(line) - }, - onStatus: (s) => setStreamStatus(s === 'open' ? '实时' : s === 'reconnecting' ? '重连中' : '已断开'), - }) - }, [append]) - - const refresh = useCallback(async () => { - setLoading(true) - setError('') - try { - const d = await hubLogs(500) - if (!d) throw new Error('读取日志失败') - if (d.ok === false) throw new Error(d.msg || '读取日志失败') - setLines(d.lines || []) - setInfo({ file: d.file || '', size: d.size || 0, total: d.total || 0 }) - sinceRef.current = d.offset || 0 - connect() - } catch (e: any) { - setError(e.message || '读取日志失败') - } finally { setLoading(false) } - }, [connect]) - - useEffect(() => { - pausedRef.current = paused - }, [paused]) - - useEffect(() => { - // 恢复接收时,把暂停期间暂存的行补进来 - if (!paused && pendingRef.current.length) { - const flush = pendingRef.current - pendingRef.current = [] - setLines(prev => [...prev, ...flush].slice(-MAX_LINES)) - } - }, [paused]) - - useEffect(() => { - refresh() - return () => { if (disposeRef.current) disposeRef.current() } - }, [refresh]) useEffect(() => { if (follow && scrollRef.current) { @@ -160,17 +92,18 @@ export default function LogsPage() { } }, [lines, follow]) - const statusClass = streamStatus === '实时' ? 'text-emerald-600' : streamStatus === '重连中' ? 'text-amber-600' : 'text-slate-500' - return (
-
-
+
+

查看日志

实时读取 bot 运行日志(SSE 推送)

-
- +
+ {hasMore && ( + + )} +
@@ -181,15 +114,14 @@ export default function LogsPage() {
日志流 -
- ● {streamStatus} - {info.file && {info.file} · {fmtBytes(info.size)} · 共 {info.total} 行} -
+ {info.file && {info.file} · {fmtBytes(info.size)} · 共 {info.total} 行}
-
- {lines.length ? lines.map((ln, i) => ( +
+ {loading && !lines.length ? ( +
+ ) : lines.length ? lines.map((ln, i) => (
)) : (
暂无日志…
diff --git a/hexi/web/src/pages/plugins/index.tsx b/hexi/web/src/pages/plugins/index.tsx index c22bf24..dc16821 100644 --- a/hexi/web/src/pages/plugins/index.tsx +++ b/hexi/web/src/pages/plugins/index.tsx @@ -1,9 +1,7 @@ import { useCallback, useEffect, useRef, useState } from 'react' -import { Link } from 'react-router-dom' import { Button, Card, - Checkbox, Drawer, Dropdown, Input, @@ -17,13 +15,15 @@ import { toast, useOverlayState, } from '@heroui/react' +import { useNavigate } from 'react-router-dom' +import { Eye, EyeSlash, TrashBin } from '@gravity-ui/icons' import { hubPluginCatalog, hubPluginGetConfig, + hubPluginRemoveGroup, hubPluginSaveConfig, hubPluginSetGlobal, hubPluginSetGroup, - hubPluginRemoveGroup, hubGroups, } from '../../api/client' @@ -50,6 +50,7 @@ interface ConfigField { description?: string options?: { value: string; label: string }[] | string[] secret?: boolean + item_type?: string } interface ConfigData { schema: { fields: ConfigField[] }; values: Record; revision: number } @@ -81,33 +82,37 @@ function normOptions(opts: any): { value: string; label: string }[] { } export default function PluginsPage() { + const navigate = useNavigate() const [plugins, setPlugins] = useState([]) const [groups, setGroups] = useState([]) const [error, setError] = useState('') const [loading, setLoading] = useState(false) - const loadRef = useRef(false) + // 加载序号:并发 load 时只落地最后一次结果,避免旧响应覆盖新状态 + const loadSeqRef = useRef(0) const [configPlugin, setConfigPlugin] = useState(null) const [configData, setConfigData] = useState(null) const [configLoading, setConfigLoading] = useState(false) const [configSaving, setConfigSaving] = useState(false) const [form, setForm] = useState>({}) + // 密码/密钥类字段的明文显隐状态(按字段 key 记录) + const [revealed, setRevealed] = useState>({}) const configState = useOverlayState() - const load = useCallback(async () => { - if (loadRef.current) return - loadRef.current = true - setLoading(true) + const load = useCallback(async (silent = false) => { + const seq = ++loadSeqRef.current + if (!silent) setLoading(true) try { const [cat, grp] = await Promise.all([hubPluginCatalog(), hubGroups()]) + if (seq !== loadSeqRef.current) return setPlugins((cat && cat.plugins) || []) setGroups((grp && grp.items) || []) setError('') } catch (e: any) { + if (seq !== loadSeqRef.current) return setError(e.message || '加载失败') } finally { - loadRef.current = false - setLoading(false) + if (seq === loadSeqRef.current) setLoading(false) } }, []) @@ -117,6 +122,7 @@ export default function PluginsPage() { setConfigPlugin(p) setConfigData(null) setConfigLoading(true) + setRevealed({}) configState.open() try { const d = await hubPluginGetConfig(p.id) @@ -139,7 +145,7 @@ export default function PluginsPage() { await hubPluginSaveConfig(configPlugin.id, form, configData?.revision ?? 0) toast.success('配置已保存并热刷新') configState.close() - await load() + await load(true) } catch (e: any) { toast.danger(e.message || '保存失败') } finally { @@ -153,9 +159,9 @@ export default function PluginsPage() { const v = form[field.key] if (field.type === 'bool') { return ( - setField(field.key, val)}> - {field.label} - + setField(field.key, val)}> + {field.label} + ) } if (field.type === 'enum') { @@ -173,33 +179,101 @@ export default function PluginsPage() {
) } + 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 ( +
+ +
+ {arr.map((item, i) => ( +
+ setItem(i, e.target.value)} /> + +
+ ))} + {!arr.length &&

暂无内容,点击下方添加

} + +
+ {field.description &&

{field.description}

} +
+ ) + } if (field.type === 'text' || field.type === 'json' || field.type === 'object') { const isJson = field.type === 'json' return (