- 新增 web/shared:pro.css(AntD 5 设计系统,旧 CSS 变量统一映射 AntD 色板)、 session.js(后端优先+本地账号降级双轨会话,IAOP_AUTH 兼容层)、 pro-header.js(全站统一 Pro 导航条:logo/页题/用户角色 Tag/退出) - 门户 web/index.html:未登录跳登录页(next 回跳),登录后按角色过滤 可见模块(readonly→驾驶舱/助手/移动端;engineer→+配置台;admin→全部) - 登录页 auth/login.html 重写为 Ant Design Pro 风格,本地降级模式自动 播种演示账号(admin/engineer/viewer),保留 OIDC SSO 双轨入口 - user_store.js 新增 verify() + seedDefaults() 默认账号播种 - 六模块页统一接入 pro.css + 统一导航条 + 登录守卫(PRD 8.2) - 驾驶舱主题 dark→light(Ant Design 5 色板):renderer.py THEME_TOKENS、 ti-cl4/resin 模板 theme、告警三级配色对齐 AntD(P0 #ff4d4f/P1 #faad14/ P2 #1677ff),渲染计划已重编(build_plans.py) - 验证:core/cockpit 86 单测通过;JS 语法检查通过;静态冒烟全 200
375 lines
15 KiB
Python
375 lines
15 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""图表组件配置化渲染引擎(issue #51 / PRD 5.5「⑤ 配置化驾驶舱」)。
|
||
|
||
本模块在 ``layout.py``(#50 布局 JSON Schema)之上实现**渲染层适配器**:
|
||
把一份 ``CockpitLayout`` 内存模型,编译成一份与前端框架无关的**渲染计划
|
||
(``RenderPlan``)**——每个 widget 被翻译成一个 ``WidgetRendererSpec``,
|
||
描述它该用哪个前端组件、摆在栅格的哪个位置、绑定什么数据、套用哪套主题
|
||
token、是否启用性能优化策略。
|
||
|
||
设计目标(PRD 5.5 验收口径「切换模板零改码」):
|
||
前端只需一个固定的「组件注册表」+ 一个通用 ``<WidgetHost>``,
|
||
按 ``RenderPlan`` 逐个挂载组件;切换行业模板 = 换一份 ``RenderPlan``,
|
||
**前端代码零改动**。
|
||
|
||
本模块不依赖任何第三方库(与 #50 一致,避免引入运行时依赖),只消费
|
||
``cockpit.layout`` 的内存模型,输出可 ``json.dumps`` 的纯数据结构。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
from dataclasses import dataclass, field
|
||
from typing import Any, Dict, List, Optional, Tuple
|
||
|
||
from .layout import ( # 复用 #50 已定义的布局模型与常量
|
||
DEFAULT_GRID_COLUMNS,
|
||
LAYOUT_SCHEMA_ID,
|
||
PERF_WIDGET_THRESHOLD,
|
||
VALID_THEMES,
|
||
VALID_WIDGET_TYPES,
|
||
CockpitLayout,
|
||
Grid,
|
||
Widget,
|
||
validate_layout,
|
||
)
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 主题 token(PRD 5.5「配置点:主题」dark / light 两套调色)
|
||
# ---------------------------------------------------------------------------
|
||
# 工业驾驶舱约定配色:dark 偏深蓝底 + 青绿强调(车间大屏常用);
|
||
# light 采用 Ant Design 5 官方色板(2026-08 全站 Ant Design Pro 风格重构:
|
||
# 白底卡片 + #1677ff 主色 + 灰底 #f0f2f5,与门户/配置台/管理台统一)。
|
||
# 前端按这些 CSS 变量渲染,切换主题 = 切一套变量,组件代码不动。
|
||
THEME_TOKENS: Dict[str, Dict[str, str]] = {
|
||
"dark": {
|
||
"--cockpit-bg": "#0b1220",
|
||
"--cockpit-surface": "#13203a",
|
||
"--cockpit-fg": "#e6edf6",
|
||
"--cockpit-fg-muted": "#8aa0bd",
|
||
"--cockpit-accent": "#18d3c8", # 青绿(KPI / 趋势主线)
|
||
"--cockpit-warn": "#f5a623", # 告警强调
|
||
"--cockpit-grid-line": "rgba(255,255,255,0.06)",
|
||
},
|
||
"light": {
|
||
"--cockpit-bg": "#f0f2f5", # AntD colorBgLayout
|
||
"--cockpit-surface": "#ffffff", # AntD colorBgContainer
|
||
"--cockpit-fg": "rgba(0,0,0,0.88)", # AntD colorText
|
||
"--cockpit-fg-muted": "rgba(0,0,0,0.45)", # AntD colorTextTertiary
|
||
"--cockpit-accent": "#1677ff", # AntD 5 主色蓝(KPI / 趋势主线)
|
||
"--cockpit-warn": "#faad14", # AntD warning(告警强调)
|
||
"--cockpit-grid-line": "rgba(5,5,5,0.06)", # AntD colorSplit
|
||
},
|
||
}
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# widget type → 前端组件名映射(PRD 5.5 能力清单)
|
||
# ---------------------------------------------------------------------------
|
||
# 前端「组件注册表」的键;``<WidgetHost>`` 据此选择具体组件实现。
|
||
# 命名遵循 Vue3 大驼峰组件约定,便于 ``app.component()`` 注册。
|
||
WIDGET_COMPONENT: Dict[str, str] = {
|
||
"process_view": "ProcessView", # 四状态工艺流程视图(SVG 资源)
|
||
"trend": "TrendChart", # 实时趋势曲线
|
||
"kpi_card": "KpiCard", # KPI 卡片
|
||
"alarm_panel": "AlarmPanel", # 告警面板
|
||
"nl_query": "NlQuery", # 自然语言查询入口
|
||
}
|
||
|
||
# 各组件类型向渲染描述里注入的「绑定字段」:来自布局 widget 的哪个属性。
|
||
# 例如 trend 组件的 series 绑定 = widget.bind(点位/指标 ID)。
|
||
_WIDGET_BINDING_SOURCE: Dict[str, Tuple[str, ...]] = {
|
||
"process_view": ("src",), # 流程图资源
|
||
"trend": ("bind",), # 趋势点位/指标
|
||
"kpi_card": ("metric", "label"), # 指标键 + 展示标签
|
||
"alarm_panel": (), # 无特有绑定,订阅告警流即可
|
||
"nl_query": (), # 无特有绑定,固定入口
|
||
}
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 异常
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class RenderError(ValueError):
|
||
"""渲染计划生成失败。``render_layout`` 在布局非法或组件缺失时抛出。"""
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 渲染描述符(dataclass)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
@dataclass
|
||
class GridPlacement:
|
||
"""单个 widget 在 CSS Grid 中的定位(``grid-column/grid-row`` 语法)。
|
||
|
||
PRD 5.5 栅格基线默认 12 列;这里把布局资产里的整数坐标 (x,y,w,h)
|
||
换算成 CSS Grid 的 ``column-start / span`` / ``row-start / span``,
|
||
以及占比百分比(便于非 Grid 容器/截图场景使用)。
|
||
|
||
约定(与 #50 schema 一致):x/y/w/h 为栅格单位,x≥0、y≥0、w>0、h>0。
|
||
"""
|
||
|
||
column_start: int # CSS Grid 列起点的 1-based 编号(= x + 1)
|
||
column_span: int # 跨列数(= w)
|
||
row_start: int # CSS Grid 行起点的 1-based 编号(= y + 1)
|
||
row_span: int # 跨行数(= h)
|
||
width_pct: float # 占栅格总宽的百分比(= w / columns * 100,保留 4 位)
|
||
style: str # 直接可用的 ``grid-column/grid-row`` CSS 文本
|
||
|
||
def to_dict(self) -> Dict[str, Any]:
|
||
return {
|
||
"columnStart": self.column_start,
|
||
"columnSpan": self.column_span,
|
||
"rowStart": self.row_start,
|
||
"rowSpan": self.row_span,
|
||
"widthPct": self.width_pct,
|
||
"style": self.style,
|
||
}
|
||
|
||
|
||
@dataclass
|
||
class WidgetRendererSpec:
|
||
"""单个驾驶舱组件的渲染描述符(``<WidgetHost>`` 的单一挂载项)。
|
||
|
||
前端渲染约定::
|
||
|
||
<component :is="registry[spec.component]" v-bind="spec.props"
|
||
:style="spec.grid.style" :perf="spec.perf" />
|
||
"""
|
||
|
||
id: str # 渲染层唯一标识(type + 序号),便于 diff / 虚拟滚动 key
|
||
component: str # 前端组件名(见 ``WIDGET_COMPONENT``)
|
||
type: str # 原始 widget type(调试/审计用)
|
||
grid: GridPlacement
|
||
description: Optional[str] = None
|
||
# 前端组件 props(绑定字段 + 展示参数),按组件类型组装
|
||
props: Dict[str, Any] = field(default_factory=dict)
|
||
# 性能策略:是否对该组件启用虚拟滚动/采样降频/WebWorker(仅 perf 触发时 True)
|
||
perf: Dict[str, bool] = field(default_factory=lambda: {
|
||
"virtualScroll": False,
|
||
"downsample": False,
|
||
"worker": False,
|
||
})
|
||
|
||
def to_dict(self) -> Dict[str, Any]:
|
||
return {
|
||
"id": self.id,
|
||
"component": self.component,
|
||
"type": self.type,
|
||
"grid": self.grid.to_dict(),
|
||
"description": self.description,
|
||
"props": dict(self.props),
|
||
"perf": dict(self.perf),
|
||
}
|
||
|
||
|
||
@dataclass
|
||
class RenderPlan:
|
||
"""整份驾驶舱布局的渲染计划(``<WidgetHost>`` 的总输入)。
|
||
|
||
前端拿到本对象即可完成整屏渲染:``themeTokens`` 注入到根容器 CSS 变量,
|
||
``specs`` 逐项挂载组件,``perfFlags`` 控制全局性能策略开关。
|
||
"""
|
||
|
||
schema: str # 继承自布局资产的 $schema(审计/版本对齐)
|
||
title: str
|
||
theme: str
|
||
theme_tokens: Dict[str, str] # 该主题下的 CSS 变量(来自 ``THEME_TOKENS``)
|
||
grid_columns: int # 栅格基线(用于前端百分比校验)
|
||
specs: List[WidgetRendererSpec]
|
||
widget_count: int
|
||
# 全局性能标志:组件数 ≥ PERF_WIDGET_THRESHOLD 时为 True(PRD 5.5 复杂仪表盘性能)
|
||
perf_flags: Dict[str, bool] = field(default_factory=lambda: {
|
||
"virtualScroll": False,
|
||
"downsample": False,
|
||
"worker": False,
|
||
})
|
||
perf_hint: Optional[str] = None # 来自 #50 布局校验的人类可读提示(透传)
|
||
|
||
def to_dict(self) -> Dict[str, Any]:
|
||
return {
|
||
"$schema": self.schema,
|
||
"title": self.title,
|
||
"theme": self.theme,
|
||
"themeTokens": dict(self.theme_tokens),
|
||
"grid": {"columns": self.grid_columns},
|
||
"widgets": [s.to_dict() for s in self.specs],
|
||
"widgetCount": self.widget_count,
|
||
"perfFlags": dict(self.perf_flags),
|
||
"perfHint": self.perf_hint,
|
||
}
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 栅格换算
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def compute_grid_placement(widget: Widget, grid_columns: int) -> GridPlacement:
|
||
"""把布局资产里的栅格整数坐标换算成 CSS Grid 定位 + 占比百分比。
|
||
|
||
- CSS Grid 的列/行起点是 1-based,布局资产里的 x/y 是 0-based,
|
||
因此 ``column_start = x + 1``、``row_start = y + 1``。
|
||
- 跨度直接等于 w/h(栅格单位)。
|
||
- 宽度百分比 = w / grid_columns * 100(便于非 Grid 容器降级)。
|
||
"""
|
||
if grid_columns <= 0:
|
||
raise RenderError(f"栅格列数必须 > 0,实际 {grid_columns}")
|
||
column_start = widget.x + 1
|
||
row_start = widget.y + 1
|
||
width_pct = round(widget.w / grid_columns * 100, 4)
|
||
style = (
|
||
f"grid-column: {column_start} / span {widget.w}; "
|
||
f"grid-row: {row_start} / span {widget.h};"
|
||
)
|
||
return GridPlacement(
|
||
column_start=column_start,
|
||
column_span=widget.w,
|
||
row_start=row_start,
|
||
row_span=widget.h,
|
||
width_pct=width_pct,
|
||
style=style,
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 组件 props 组装
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def _build_props(widget: Widget) -> Dict[str, Any]:
|
||
"""按 widget type 组装前端组件 props(绑定字段 + 展示参数)。
|
||
|
||
绑定字段来源见 ``_WIDGET_BINDING_SOURCE``;缺字段视为该组件不依赖它
|
||
(布局层 #50 已对必填字段做过校验,这里做防御性容错)。
|
||
"""
|
||
props: Dict[str, Any] = {}
|
||
|
||
if widget.type == "process_view":
|
||
# 流程图视图:资源名 + 四状态语义(前端按 SVG 内联渲染 + 状态着色)
|
||
props["src"] = widget.src or ""
|
||
props["states"] = ["running", "warning", "alarm", "offline"] # PRD 四状态
|
||
elif widget.type == "trend":
|
||
# 实时趋势:绑定点位/指标 ID,前端订阅时序总线
|
||
props["series"] = widget.bind or ""
|
||
props["window"] = "PT30M" # 默认 30 分钟滚动窗口(可由模板覆盖)
|
||
elif widget.type == "kpi_card":
|
||
# KPI 卡片:指标键 + 标签
|
||
props["metric"] = widget.metric or ""
|
||
props["label"] = widget.label or widget.metric or ""
|
||
elif widget.type == "alarm_panel":
|
||
# 告警面板:固定订阅告警流,无特有绑定
|
||
props["subscribe"] = "alarm_stream"
|
||
elif widget.type == "nl_query":
|
||
# NL 查询入口:固定能力,无特有绑定
|
||
props["placeholder"] = "输入自然语言查询(配方 / 质量 / 能耗)"
|
||
|
||
return props
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 渲染入口
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def render_widget(
|
||
widget: Widget,
|
||
index: int,
|
||
grid_columns: int,
|
||
perf_enabled: bool = False,
|
||
) -> WidgetRendererSpec:
|
||
"""渲染单个 widget 为 ``WidgetRendererSpec``。
|
||
|
||
- ``index`` 用于生成稳定 id(type + 序号),作为虚拟滚动 key。
|
||
- ``perf_enabled`` 为 True(全局性能策略开启)时,trend/alarm_panel 这类
|
||
高频刷新组件启用采样降频 + WebWorker(PRD 5.5)。
|
||
"""
|
||
if widget.type not in WIDGET_COMPONENT:
|
||
# 布局层 #50 已挡住非法 type,这里再防御一次,避免渲染出未注册组件
|
||
raise RenderError(
|
||
f"widget[{index}] type='{widget.type}' 无对应前端组件,"
|
||
f"已注册 {list(WIDGET_COMPONENT)}"
|
||
)
|
||
|
||
spec = WidgetRendererSpec(
|
||
id=f"{widget.type}-{index}",
|
||
component=WIDGET_COMPONENT[widget.type],
|
||
type=widget.type,
|
||
grid=compute_grid_placement(widget, grid_columns),
|
||
description=widget.description,
|
||
props=_build_props(widget),
|
||
)
|
||
|
||
if perf_enabled:
|
||
# 高频刷新组件才需要逐组件性能策略:趋势曲线 / 告警面板
|
||
if widget.type in ("trend", "alarm_panel"):
|
||
spec.perf["downsample"] = True
|
||
spec.perf["worker"] = True
|
||
# 大流程图(process_view)启用虚拟滚动分块
|
||
if widget.type == "process_view":
|
||
spec.perf["virtualScroll"] = True
|
||
|
||
return spec
|
||
|
||
|
||
def render_layout(layout: CockpitLayout) -> RenderPlan:
|
||
"""把一份合法 ``CockpitLayout`` 编译为 ``RenderPlan``。
|
||
|
||
先复用 #50 的 ``validate_layout`` 再校验一次(防御构造后被篡改),
|
||
再逐 widget 渲染,最后汇总主题 token 与全局性能标志。
|
||
|
||
校验失败抛 ``RenderError``(包装 #50 的错误清单),便于配置台定位。
|
||
"""
|
||
# 1) 复用 #50 校验器,确保布局恒为合法资产
|
||
result = validate_layout(layout.to_dict())
|
||
if not result.ok:
|
||
raise RenderError(result.errors)
|
||
|
||
# 2) 主题 token
|
||
if layout.theme not in THEME_TOKENS:
|
||
raise RenderError(
|
||
f"theme='{layout.theme}' 无调色方案,已支持 {list(THEME_TOKENS)}"
|
||
)
|
||
theme_tokens = dict(THEME_TOKENS[layout.theme])
|
||
|
||
# 3) 全局性能标志(PRD 5.5:组件数 ≥ 阈值 → 虚拟滚动 + 采样降频 + WebWorker)
|
||
perf_on = result.widget_count >= PERF_WIDGET_THRESHOLD
|
||
perf_flags = {
|
||
"virtualScroll": perf_on,
|
||
"downsample": perf_on,
|
||
"worker": perf_on,
|
||
}
|
||
|
||
# 4) 逐 widget 渲染
|
||
specs: List[WidgetRendererSpec] = [
|
||
render_widget(w, idx, layout.grid.columns, perf_enabled=perf_on)
|
||
for idx, w in enumerate(layout.widgets)
|
||
]
|
||
|
||
return RenderPlan(
|
||
schema=layout.schema,
|
||
title=layout.title,
|
||
theme=layout.theme,
|
||
theme_tokens=theme_tokens,
|
||
grid_columns=layout.grid.columns,
|
||
specs=specs,
|
||
widget_count=len(specs),
|
||
perf_flags=perf_flags,
|
||
perf_hint=result.perf_hint,
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 序列化
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def plan_to_dict(plan: RenderPlan) -> Dict[str, Any]:
|
||
"""渲染计划 → 可发布的 dict(前端直接消费 / 落盘缓存)。"""
|
||
return plan.to_dict()
|
||
|
||
|
||
def plan_to_json(plan: RenderPlan, indent: Optional[int] = 2) -> str:
|
||
"""渲染计划 → JSON 字符串(确保 ASCII 安全,中文转义不影响前端解析)。"""
|
||
return json.dumps(plan.to_dict(), ensure_ascii=False, indent=indent)
|