Files
bot_dev1 7a2e45e7c4 feat: 全站 Ant Design Pro 风格重构 + 登录守卫与角色门户(issue #131/#134)
- 新增 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
2026-08-05 14:17:37 +08:00

375 lines
15 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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)