# -*- 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), )