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:
+16
-9
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user