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
+86 -16
View File
@@ -54,8 +54,13 @@ basic_path = Path(__file__).resolve().parent
# hexi/web 是统一 Web 管理台前端(hexi/web/dist),不是插件目录下的 web
WEB_DIST = Path(__file__).resolve().parents[2] / "web" / "dist"
# 管理台自身版本号(展示在设置页「关于」处)
HUB_VERSION = "0.1.0"
def _hub_version() -> str:
"""管理台版本号:以 hexi/web/package.json 为唯一来源(前端构建也读它)。"""
try:
pkg = WEB_DIST.parent / "package.json"
return str(json.loads(pkg.read_text(encoding="utf-8")).get("version", "0.1.0"))
except Exception: # noqa: BLE001
return "0.1.0"
# 仓库根目录(bot.py 所在)与 bot 端口
_REPO_ROOT = Path(__file__).resolve().parents[3]
@@ -118,6 +123,65 @@ def _archive_log() -> None:
logger.warning(f"归档日志失败: {type(e).__name__}: {e}")
def _tail_log_lines(
path: Path, limit: int, before: int | None
) -> tuple[list[str], int, int]:
"""从日志文件读取最近 limit 行(或 before 字节之前最近的 limit 行)。
返回 (lines, window_start_offset, file_size)。window_start_offset 可作为
下一批更早日志的 before;为 0 表示已到文件头。仅按 8KB 步进向后寻找行边界,
避免大日志全量 read_bytes。逐行 UTF-8 优先、GBK 兜底:兼容旧 GBK 日志与
切换为 UTF-8 后的新日志混存(保留 ANSI 颜色码,交给前端渲染颜色)。
"""
size = path.stat().st_size
end = size if before is None or before <= 0 or before > size else int(before)
if end <= 0:
return [], 0, size
start = 0
with open(path, "rb") as f:
pos = end
newline_count = 0
while pos > 0 and newline_count <= limit:
step = min(pos, 8192)
f.seek(pos - step)
chunk = f.read(step)
idx = chunk.rfind(b"\n")
if idx == -1:
pos -= step
continue
nl_abs = pos - step + idx
newline_count += 1
if newline_count > limit:
start = nl_abs + 1
break
pos = nl_abs
f.seek(start)
raw = f.read(end - start)
raw_lines = raw.split(b"\n")
if raw_lines and raw_lines[-1] == b"":
raw_lines.pop()
lines = []
for raw_line in raw_lines:
try:
line = raw_line.decode("utf-8")
except UnicodeDecodeError:
line = raw_line.decode("gbk", errors="replace")
lines.append(line.rstrip("\r"))
return lines, start, size
def _count_log_lines(path: Path) -> int:
"""分块统计全文件行数(仅换行计数,不全量载入)。"""
count = 0
with open(path, "rb") as f:
while True:
chunk = f.read(1 << 20)
if not chunk:
break
count += chunk.count(b"\n")
return count
def _schedule_bot_action(action: str) -> None:
"""1 秒后执行:stop 直接退出;restart 先拉起新进程再退出旧进程。"""
@@ -205,7 +269,7 @@ def build_hub_app() -> FastAPI:
"created_at": user.get("created_at", ""),
},
"plugins": list_web_plugins(),
"version": HUB_VERSION,
"version": _hub_version(),
}
@app.get("/api/dashboard")
@@ -360,9 +424,15 @@ def build_hub_app() -> FastAPI:
@app.get("/api/logs")
async def logs(
limit: int = 500,
before: int | None = None,
_: dict = Depends(get_current_user),
):
"""实时读取 bot 运行日志(优先 _bot_run.log),返回最近 limit 行,供前端轮询。"""
"""读取 bot 运行日志(优先 _bot_run.log)。
- before 不传:返回最近 limit 行,offset/end 为文件尾(SSE 以 end 续读)
- before 传字节偏移:返回该偏移之前最近 limit 行,offset 为这些行的起点,
继续把 offset 传给 before 即可向前翻页;offset==0 表示已到文件头。
"""
root = Path(__file__).resolve().parents[3] # 仓库根目录
candidates = [
root / "_bot_run.log",
@@ -372,20 +442,20 @@ def build_hub_app() -> FastAPI:
if path is None:
return {"ok": False, "msg": "未找到日志文件", "lines": [], "size": 0, "total": 0}
try:
raw = path.read_bytes()
lines, start, size = _tail_log_lines(path, max(1, int(limit)), before)
total = _count_log_lines(path)
except OSError as e: # noqa: BLE001
return {"ok": False, "msg": f"读取日志失败: {e}", "lines": [], "size": 0, "total": 0}
# 逐行 UTF-8 优先、GBK 兜底:兼容旧 GBK 日志与切换为 UTF-8 后的新日志混存
# 保留 ANSI 颜色码,交给前端渲染颜色
lines = []
for raw_line in raw.split(b"\n"):
try:
line = raw_line.decode("utf-8")
except UnicodeDecodeError:
line = raw_line.decode("gbk", errors="replace")
lines.append(line.rstrip("\r"))
tail = lines[-max(1, int(limit)) :]
return {"ok": True, "file": path.name, "size": len(raw), "offset": len(raw), "lines": tail, "total": len(lines)}
return {
"ok": True,
"file": path.name,
"size": size,
"end": size,
"offset": start,
"has_more": start > 0,
"lines": lines,
"total": total,
}
@app.get("/api/dashboard/stream")
async def dashboard_stream(