feat(#50): 驾驶舱布局 JSON Schema 定义(PRD 5.5 配置化驾驶舱)

新增 core/cockpit 模块,定义 iAOP-cockpit-layout-v1 布局资产 schema 的
权威实现,对齐 PRD 5.5「⑤ 配置化驾驶舱」验收口径(切换模板零改码)。

交付内容:
- core/cockpit/layout.py:布局字段规范、合法性集合(主题/组件类型)、
  validate_layout 校验器(结构+语义)、load_layout 解析器、
  CockpitLayout/Widget/Grid 内存模型(dataclass)、LayoutValidationError。
- core/cockpit/__init__.py:对外导出。
- core/cockpit/tests/test_layout.py:30 个单测(PRD 示例/树脂模板兼容/
  各类非法/性能提示/解析/round-trip/不变入参)。
- core/cockpit/scripts/verify_layout_schema.py:端到端验证脚本(exit=0)。

兼容性:现有 templates/resin/dashboard/cockpit.resin.yaml 引用的
$schema: iAOP-cockpit-layout-v1 与本实现一致,树脂模板资产校验通过。
This commit is contained in:
2026-08-05 00:29:42 +08:00
parent afee113901
commit ace09a6d16
5 changed files with 823 additions and 0 deletions
+378
View File
@@ -0,0 +1,378 @@
# -*- coding: utf-8 -*-
"""驾驶舱布局 JSON Schema 定义 + 校验器 + 解析器(issue #50 / PRD 5.5)。
本文件是 ``$schema: iAOP-cockpit-layout-v1`` 的权威实现:定义布局资产的
字段规范、合法性集合,以及一份与现有行业模板(如
``templates/resin/dashboard/cockpit.resin.yaml``)完全兼容的校验/解析管线。
布局资产结构(PRD 5.5 示例)::
{
"$schema": "iAOP-cockpit-layout-v1",
"title": "氯化车间驾驶舱",
"theme": "dark",
"widgets": [
{"type": "process_view", "src": "ti_four_state.svg", "x":0,"y":0,"w":6,"h":4},
{"type": "trend", "bind": "CLF-01.TEMP", "x":6,"y":0,"w":6,"h":2},
{"type": "kpi_card", "metric": "Ti_purity", "x":6,"y":2,"w":3,"h":2},
{"type": "alarm_panel", "x":0,"y":4,"w":12,"h":3}
]
}
字段规范(PRD 5.5「布局 JSON Schema 定义」+「配置点」):
$schema string 必填 布局版本标识,固定 ``iAOP-cockpit-layout-v1``。
title string 必填 驾驶舱标题(行业模板级,如"氯化车间驾驶舱")。
theme enum 必填 主题:``dark`` / ``light``。
widgets list 必填 组件清单,至少 1 个;每个 widget 见下表。
grid object 选填 栅格基线(默认 12 列);见 ``Grid``。
widget 字段:
type enum 必填 组件类型(见 ``VALID_WIDGET_TYPES``)。
x, y int 必填 栅格左上角坐标(≥0)。
w, h int 必填 宽/高(>0)。
description string 选填 组件说明(实施/行业工程师可读)。
src string 条件必填 ``process_view`` 必填,流程图 SVG 资源名。
bind string 条件必填 ``trend`` 必填,绑定的点位/指标 ID(如 CLF-01.TEMP)。
metric string 条件必填 ``kpi_card`` 必填,指标键(如 Ti_purity)。
label string 选填 ``kpi_card`` 展示标签。
复杂仪表盘性能(PRD 5.5)由渲染层负责:组件 ≥ 30 或数据点 ≥ 500 时启用
虚拟滚动 + 采样降频 + WebWorker,保证首屏 ≤ 2s。本 schema 不强制该阈值,
仅在 ``LayoutValidationResult`` 中提示组件数,便于上层决策。
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional, Tuple
# ---------------------------------------------------------------------------
# 布局版本标识(被 templates/resin/dashboard/cockpit.resin.yaml 的 $schema 引用)
# ---------------------------------------------------------------------------
LAYOUT_SCHEMA_ID: str = "iAOP-cockpit-layout-v1"
LAYOUT_SCHEMA_VERSION: int = 1
# ---------------------------------------------------------------------------
# 合法性集合
# ---------------------------------------------------------------------------
# 主题(PRD 5.5「配置点:主题」)
VALID_THEMES: Tuple[str, ...] = ("dark", "light")
# 组件类型(PRD 5.5「能力:四状态工艺流程视图、实时趋势、KPI卡片、告警面板、NL查询入口」)
VALID_WIDGET_TYPES: Tuple[str, ...] = (
"process_view", # 四状态工艺流程视图
"trend", # 实时趋势
"kpi_card", # KPI 卡片
"alarm_panel", # 告警面板
"nl_query", # 自然语言查询入口
)
# 默认栅格基线(PRD 5.5 示例使用 12 列;resin 模板亦按 12 列布局)
DEFAULT_GRID_COLUMNS: int = 12
# 性能提示阈值(PRD 5.5「复杂仪表盘性能」)
PERF_WIDGET_THRESHOLD: int = 30
# 各组件类型必填的特有字段(type → 字段名)
_WIDGET_REQUIRED_FIELDS: Dict[str, Tuple[str, ...]] = {
"process_view": ("src",),
"trend": ("bind",),
"kpi_card": ("metric",),
"alarm_panel": (),
"nl_query": (),
}
# ---------------------------------------------------------------------------
# 异常 / 结果
# ---------------------------------------------------------------------------
class LayoutValidationError(ValueError):
"""布局资产校验失败。``load_layout`` 在校验不通过时抛出。
``errors`` 收集全部字段级错误,便于配置台「错误列表(行号+原因)」展示。
"""
def __init__(self, errors: List[str]):
super().__init__("; ".join(errors) if errors else "layout validation failed")
self.errors: List[str] = list(errors)
@dataclass
class LayoutValidationResult:
"""``validate_layout`` 的返回值,区分「是否合法」与「全部错误清单」。"""
ok: bool
errors: List[str] = field(default_factory=list)
widget_count: int = 0
perf_hint: Optional[str] = None
# ---------------------------------------------------------------------------
# 内存模型(dataclass)
# ---------------------------------------------------------------------------
@dataclass
class Widget:
"""单个驾驶舱组件的内存模型。"""
type: str
x: int
y: int
w: int
h: int
description: Optional[str] = None
# 以下为按 type 选填/必填的特有字段,统一存放,解析时已校验存在性
src: Optional[str] = None
bind: Optional[str] = None
metric: Optional[str] = None
label: Optional[str] = None
def to_dict(self) -> Dict[str, Any]:
"""序列化回布局资产 dict(仅保留有值/必填字段,便于发布)。"""
d: Dict[str, Any] = {
"type": self.type,
"x": self.x,
"y": self.y,
"w": self.w,
"h": self.h,
}
if self.description is not None:
d["description"] = self.description
if self.src is not None:
d["src"] = self.src
if self.bind is not None:
d["bind"] = self.bind
if self.metric is not None:
d["metric"] = self.metric
if self.label is not None:
d["label"] = self.label
return d
@dataclass
class Grid:
"""栅格基线(默认 12 列,可由行业模板覆盖)。"""
columns: int = DEFAULT_GRID_COLUMNS
@dataclass
class CockpitLayout:
"""一份完整驾驶舱布局的内存模型。
渲染层(Vue3 / 配置台)仅消费本对象:切换行业模板 = 加载另一份
``CockpitLayout``,**前端代码零改动**即满足 PRD 5.5 验收口径。
"""
title: str
theme: str
widgets: List[Widget]
grid: Grid = field(default_factory=Grid)
schema: str = LAYOUT_SCHEMA_ID
def to_dict(self) -> Dict[str, Any]:
"""序列化为可发布的布局资产 dict(结构对齐 PRD 5.5 示例)。"""
return {
"$schema": self.schema,
"title": self.title,
"theme": self.theme,
"grid": {"columns": self.grid.columns},
"widgets": [w.to_dict() for w in self.widgets],
}
# ---------------------------------------------------------------------------
# 校验器
# ---------------------------------------------------------------------------
def _require_type(value: Any, name: str, expected: type, errors: List[str], ctx: str) -> None:
"""检查 ``value`` 是 ``expected`` 类型,失败则追加一条错误。"""
# bool 是 int 的子类,栅格坐标不应接受 bool;此处显式排除
if expected is int and isinstance(value, bool):
errors.append(f"{ctx}: {name} 必须是整数,实际为 bool")
return
if not isinstance(value, expected):
errors.append(f"{ctx}: {name} 必须是 {expected.__name__},实际为 {type(value).__name__}")
def _validate_widget(widget: Any, index: int, grid_columns: int, errors: List[str]) -> None:
"""校验单个 widget dict,错误追加到 ``errors``。"""
ctx = f"widgets[{index}]"
if not isinstance(widget, dict):
errors.append(f"{ctx}: 组件必须是对象(dict)")
return
# type
wtype = widget.get("type")
if not isinstance(wtype, str) or not wtype:
errors.append(f"{ctx}: type 缺失或非字符串")
wtype = ""
elif wtype not in VALID_WIDGET_TYPES:
errors.append(
f"{ctx}: type '{wtype}' 非法,合法值 {list(VALID_WIDGET_TYPES)}"
)
# 栅格坐标 x/y/w/h(必填整数)
for key in ("x", "y", "w", "h"):
if key not in widget:
errors.append(f"{ctx}: 缺少必填字段 {key}")
else:
_require_type(widget[key], key, int, errors, ctx)
val = widget[key]
if isinstance(val, int) and not isinstance(val, bool):
if key in ("x", "y") and val < 0:
errors.append(f"{ctx}: {key} 必须 ≥ 0,实际 {val}")
if key in ("w", "h") and val <= 0:
errors.append(f"{ctx}: {key} 必须 > 0,实际 {val}")
# 越界:x + w 不应超过栅格列数(提示级,不阻断合法性,但记一条 warning 风格错误便于配置台纠正)
try:
x = widget["x"]
w = widget["w"]
if (
isinstance(x, int)
and isinstance(w, int)
and not isinstance(x, bool)
and not isinstance(w, bool)
and x + w > grid_columns
):
errors.append(
f"{ctx}: x+w={x + w} 超过栅格列数 {grid_columns},布局会被压缩"
)
except (KeyError, TypeError):
pass
# type 特有必填字段
if wtype in _WIDGET_REQUIRED_FIELDS:
for fname in _WIDGET_REQUIRED_FIELDS[wtype]:
val = widget.get(fname)
if not isinstance(val, str) or not val:
errors.append(f"{ctx}: type='{wtype}' 要求字段 {fname} 非空字符串")
# kpi_card 可选 label(若有则必须字符串)
label = widget.get("label")
if label is not None and not isinstance(label, str):
errors.append(f"{ctx}: label 必须是字符串")
def validate_layout(data: Any) -> LayoutValidationResult:
"""对一份布局资产(已解析的 dict)做完整校验。
返回 ``LayoutValidationResult``:``ok`` 表示是否通过,``errors`` 收集全部
字段级错误,``widget_count`` / ``perf_hint`` 供上层做性能决策。
"""
errors: List[str] = []
if not isinstance(data, dict):
return LayoutValidationResult(ok=False, errors=["布局根必须是对象(dict)"])
# $schema
schema = data.get("$schema")
if schema is None:
errors.append("缺少 $schema 字段")
elif schema != LAYOUT_SCHEMA_ID:
errors.append(
f"$schema='{schema}' 不被支持,当前版本 '{LAYOUT_SCHEMA_ID}'"
)
# title
title = data.get("title")
if not isinstance(title, str) or not title.strip():
errors.append("title 缺失或为空字符串")
# theme
theme = data.get("theme")
if theme is None:
errors.append("缺少 theme 字段")
elif theme not in VALID_THEMES:
errors.append(f"theme='{theme}' 非法,合法值 {list(VALID_THEMES)}")
# grid(可选)
grid_columns = DEFAULT_GRID_COLUMNS
grid = data.get("grid")
if grid is not None:
if not isinstance(grid, dict):
errors.append("grid 必须是对象(dict)")
else:
cols = grid.get("columns")
if cols is None:
errors.append("grid.columns 缺失")
else:
_require_type(cols, "columns", int, errors, "grid")
if isinstance(cols, int) and not isinstance(cols, bool) and cols <= 0:
errors.append(f"grid.columns 必须 > 0,实际 {cols}")
grid_columns = cols
elif isinstance(cols, int) and not isinstance(cols, bool):
grid_columns = cols
# widgets
widgets = data.get("widgets")
if widgets is None:
errors.append("缺少 widgets 字段")
widgets = []
if not isinstance(widgets, list):
errors.append("widgets 必须是数组(list)")
widgets = []
elif len(widgets) == 0:
errors.append("widgets 不能为空(至少 1 个组件)")
for idx, w in enumerate(widgets):
_validate_widget(w, idx, grid_columns, errors)
widget_count = len(widgets) if isinstance(widgets, list) else 0
perf_hint = None
if widget_count >= PERF_WIDGET_THRESHOLD:
perf_hint = (
f"组件数 {widget_count} ≥ {PERF_WIDGET_THRESHOLD},渲染层应启用"
"虚拟滚动 + 采样降频 + WebWorker(PRD 5.5)"
)
return LayoutValidationResult(
ok=len(errors) == 0,
errors=errors,
widget_count=widget_count,
perf_hint=perf_hint,
)
# ---------------------------------------------------------------------------
# 解析器
# ---------------------------------------------------------------------------
def _coerce_int(v: Any) -> int:
"""已校验为 int(非 bool)后取值。"""
return v # type: ignore[return-value]
def load_layout(data: Dict[str, Any]) -> CockpitLayout:
"""从已解析的 dict 构造 ``CockpitLayout``;校验失败抛 ``LayoutValidationError``。
调用前通常先用 ``validate_layout`` 判断,但本方法内部仍会再校验一次,
确保构造出的内存模型恒为合法资产。
"""
result = validate_layout(data)
if not result.ok:
raise LayoutValidationError(result.errors)
grid_raw = data.get("grid") or {}
grid = Grid(columns=grid_raw.get("columns", DEFAULT_GRID_COLUMNS))
widgets: List[Widget] = []
for w in data["widgets"]:
widgets.append(
Widget(
type=w["type"],
x=_coerce_int(w["x"]),
y=_coerce_int(w["y"]),
w=_coerce_int(w["w"]),
h=_coerce_int(w["h"]),
description=w.get("description"),
src=w.get("src"),
bind=w.get("bind"),
metric=w.get("metric"),
label=w.get("label"),
)
)
return CockpitLayout(
title=data["title"],
theme=data["theme"],
widgets=widgets,
grid=grid,
schema=data.get("$schema", LAYOUT_SCHEMA_ID),
)