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
+16 -9
View File
@@ -63,10 +63,7 @@ def _type_label(annotation: Any) -> str:
if origin is Literal:
return "enum"
if origin in (list, set, tuple):
args = get_args(annotation)
if args and args[0] is str:
return "text"
return "text"
return "array"
return "string"
@@ -119,11 +116,11 @@ def _coerce_value(value: Any, typ: str, item_type: str = "str") -> Any:
return float(value)
except (TypeError, ValueError):
return None
if typ == "text":
if typ in {"text", "array"}:
if isinstance(value, (list, tuple, set)):
items = list(value)
else:
# TextArea 多行/逗号分隔都接受
# 数组字段的字符串输入:多行/逗号分隔都接受
import re as _re
parts = _re.split(r"[,\n]+", str(value))
items = [p.strip() for p in parts if p.strip()]
@@ -168,6 +165,16 @@ def _coerce_value(value: Any, typ: str, item_type: str = "str") -> Any:
def _stringify(value: Any, typ: str) -> Any:
"""把 python 值转成 Web 表单能显示的值。"""
if typ == "array":
# 数组字段直接回传数组,前端用行编辑器(增删行)渲染;
# 兼容旧数据:换行/逗号分隔的字符串按行拆成数组
if value is None:
return []
if isinstance(value, (list, tuple, set)):
return [str(v) for v in value]
import re as _re
parts = _re.split(r"[,\n]+", str(value))
return [p.strip() for p in parts if p.strip()]
if typ == "text":
if value is None:
return ""
@@ -240,7 +247,7 @@ def register_model_config(
}
if typ == "enum":
field["options"] = (options or {}).get(key) or _enum_options(annotation)
if typ == "text":
if typ in {"text", "array"}:
field["item_type"] = _text_item_type(annotation)
schema_fields.append(field)
@@ -353,7 +360,7 @@ def register_config_items(
}
if item.get("options"):
field["options"] = item["options"]
if item.get("type") == "text":
if item.get("type") in {"text", "array"}:
field["item_type"] = item.get("item_type", "str")
if item.get("type") == "object_set" and item.get("item_schema"):
field["item_schema"] = item["item_schema"]
@@ -435,7 +442,7 @@ def register_object_set(
"description": sf.get("description", ""),
"secret": sf.get("secret", _is_secret(sf["key"])),
}
if sf.get("type") == "text":
if sf.get("type") in {"text", "array"}:
field["item_type"] = sf.get("item_type", "str")
opts = (options_by_key or {}).get(sf["key"]) or sf.get("options")
if opts: