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
+33 -7
View File
@@ -11,7 +11,7 @@
"default": ..., # 默认值
"description": "...", # 提示
"env": "WEB_PASSWORD", # 环境变量名(缺省=key.upper())
"secret": true, # 是否敏感(前端脱敏/掩码)
"secret": true, # 是否敏感(前端默认掩码显示,回传明文)
"options": [...], # enum 可选项
"required": false,
}
@@ -28,6 +28,7 @@ from __future__ import annotations
import json
import os
import re
import threading
from pathlib import Path
from typing import Any, Callable, Optional
@@ -39,7 +40,7 @@ DATA_DIR = _ROOT / "config"
STORE_PATH = DATA_DIR / "plugin_config.json"
ENV_PATH = _ROOT.parent / ".env" # 仓库根 .env
_TYPES = {"string", "text", "password", "int", "float", "bool", "enum", "json", "object", "path", "object_set"}
_TYPES = {"string", "text", "password", "int", "float", "bool", "enum", "json", "object", "path", "object_set", "array"}
# plugin_id -> schema
_schemas: dict[str, dict[str, Any]] = {}
@@ -209,6 +210,23 @@ def _coerce(field: dict[str, Any], raw: Any) -> Any:
return float(raw)
except (TypeError, ValueError) as e:
raise ValueError(f"字段 {field['key']} 必须是数字") from e
if typ == "array":
item_type = str(field.get("item_type") or "str")
if isinstance(raw, str):
# 防御:兼容旧前端提交的多行/逗号分隔文本
raw = [p.strip() for p in re.split(r"[,\n]+", raw) if p.strip()]
if not isinstance(raw, list):
raise ValueError(f"字段 {field['key']} 必须是数组")
out = []
for item in raw:
if item_type in {"int", "float"}:
try:
out.append(int(item) if item_type == "int" else float(item))
except (TypeError, ValueError) as e:
raise ValueError(f"字段 {field['key']} 含非法数字: {item}") from e
else:
out.append(str(item))
return out
if typ in {"string", "text", "password"}:
return "" if raw is None else str(raw)
if typ == "enum":
@@ -240,9 +258,16 @@ def get_config(plugin_id: str) -> Optional[dict[str, Any]]:
values[key] = f["default"]
else:
values[key] = None
for field in schema.get("fields", []):
if field.get("secret") and values.get(field["key"]) not in (None, ""):
values[field["key"]] = MASKED_SECRET
# 密钥类字段回传明文:该管理台仅本地局域网/本机使用,无外泄面;
# 明文由前端默认以掩码(小圆点)形式展示,用户点「眼睛」才可查看。
# array 字段兼容旧存储:text 时期保存的是换行/逗号分隔字符串,归一化为数组
for f in schema.get("fields", []):
key = f["key"]
if f.get("type") == "array" and isinstance(values.get(key), str):
try:
values[key] = _coerce(f, values[key])
except ValueError:
values[key] = []
return {"schema": schema, "values": values, "revision": _revisions.get(plugin_id, 0)}
@@ -272,9 +297,9 @@ def _update_env(plugin_id: str, values: dict[str, Any]) -> None:
key = f["key"]
if key not in values:
continue
# text/json/object/path 这类非标量不适合写进 .env(重启解析会出错),
# text/json/object/path/array 这类非标量不适合写进 .env(重启解析会出错),
# 这类字段仅在运行期热更新,不落盘 .env(见 config_standard 说明)。
if f.get("type") in {"text", "json", "object", "path"}:
if f.get("type") in {"text", "json", "object", "path", "array"}:
continue
env_name = _field_env(f)
raw = values[key]
@@ -340,6 +365,7 @@ def save_config(
if key not in values:
continue
raw = values[key]
# 兼容旧版本前端回传的掩码占位:不覆盖已保存的明文
if f.get("secret") and raw == MASKED_SECRET:
continue
if raw in (None, ""):