feat:图表交互
This commit is contained in:
@@ -10,6 +10,7 @@ from .delete import DeleteAction
|
||||
from .layout import AlignAction, ArrangeAction, DistributeAction, SetLayoutAction
|
||||
from .move import MoveAction
|
||||
from .resize import ResizeAction
|
||||
from .set_interaction import SetInteractionAction
|
||||
from .update import UpdateAction
|
||||
from .update_canvas import UpdateCanvasAction
|
||||
|
||||
@@ -20,7 +21,8 @@ Action = Annotated[
|
||||
| MoveAction
|
||||
| ResizeAction
|
||||
| UpdateAction
|
||||
| UpdateCanvasAction,
|
||||
| UpdateCanvasAction
|
||||
| SetInteractionAction,
|
||||
Field(discriminator="type"),
|
||||
]
|
||||
|
||||
@@ -33,6 +35,7 @@ AgentAction = Annotated[
|
||||
| ResizeAction
|
||||
| UpdateAction
|
||||
| UpdateCanvasAction
|
||||
| SetInteractionAction
|
||||
| ArrangeAction
|
||||
| AlignAction
|
||||
| DistributeAction
|
||||
@@ -53,6 +56,7 @@ __all__ = [
|
||||
"DeleteAction",
|
||||
"MoveAction",
|
||||
"ResizeAction",
|
||||
"SetInteractionAction",
|
||||
"UpdateAction",
|
||||
"UpdateCanvasAction",
|
||||
]
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
"""新增组件动作"""
|
||||
|
||||
from typing import Literal
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import Field
|
||||
from pydantic import Field, model_validator
|
||||
|
||||
from app.schemas.actions.base import ActionModel
|
||||
from app.schemas.screen import ScreenNode
|
||||
from app.schemas.screen import ScreenNode, json_equal
|
||||
|
||||
|
||||
class AddAction(ActionModel):
|
||||
@@ -14,11 +14,43 @@ class AddAction(ActionModel):
|
||||
type: Literal["add"] = "add"
|
||||
node: ScreenNode = Field(
|
||||
description="新组件。id、type、layout 必填,其余只写需要覆盖物料默认配置的字段;"
|
||||
"显示条件、数据筛选、事件(visibleWhen、filters、events)也写在 node 里,不写在动作这一层,也可以之后用 set_interaction 设置;"
|
||||
"后端按 type 找到物料,用默认配置补全:对象逐层覆盖,数组整体替换,null 明确覆盖。"
|
||||
"写出的字段和 update 一样按字段定义检查类型和取值(跟随主题的颜色可以写 null)。"
|
||||
"id 不能与当前大屏上的组件重复(包括之前提交里新增的),先 delete 的 ID 可以再用;type 用 list_components 返回的 type。"
|
||||
'任何字段值都可以写成 {"$query": "SELECT ..."},后端替换成上传数据的查询结果:写入单个值的字段时结果要一行一列,取那个值;写入数字、文字数组的字段时结果要只有一列;其余是对象数组,键是结果的列名'
|
||||
)
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def node_fields_into_node(cls, data: Any) -> Any:
|
||||
"""动作这一层写了节点字段时挪进 node:模型常照 set_interaction 的样子把 visibleWhen、filters、events
|
||||
写在 node 旁边,严格校验只报"Extra inputs are not permitted",它看不懂,反复提交直到放弃。
|
||||
按 ScreenNode 的字段判断,不列举;交付的动作里这些字段只在 node 里,前端协议不变;其他未知键照样报错
|
||||
"""
|
||||
if not isinstance(data, dict) or not isinstance(data.get("node"), dict):
|
||||
return data
|
||||
misplaced = [
|
||||
key
|
||||
for key in data
|
||||
if key in ScreenNode.model_fields and key not in cls.model_fields
|
||||
]
|
||||
if not misplaced:
|
||||
return data
|
||||
data = dict(data)
|
||||
node = dict(data["node"])
|
||||
for key in misplaced:
|
||||
value = data.pop(key)
|
||||
# 按 JSON 比较:Python 的 != 把 true 和 1 当成一样,会让外层的值静默覆盖
|
||||
if key not in node:
|
||||
node[key] = value
|
||||
elif not json_equal(node[key], value):
|
||||
raise ValueError(
|
||||
f"{key} 是组件的字段,同时写在 node 里和动作这一层且值不同;只写在 node 里"
|
||||
)
|
||||
data["node"] = node
|
||||
return data
|
||||
|
||||
sample_data: bool = Field(
|
||||
default=False,
|
||||
description="true 表示组件展示的是自己编写的数据(不是来自上传的文件或大屏上已有的组件),交付时标为示例数据。"
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
"""设置组件的交互:显示条件、数据筛选、事件;变量本身在画布上,用 update_canvas 写 variables"""
|
||||
|
||||
from typing import Literal, Self
|
||||
|
||||
from pydantic import Field, model_serializer, model_validator
|
||||
|
||||
from app.schemas.base import ExtensibleModel, NonBlankString
|
||||
from app.schemas.screen import EventAction, RowFilter, VisibleWhen
|
||||
|
||||
from .base import BaseAction
|
||||
|
||||
# 这三个键只处理写出的:没写保持原样,写 null 删除节点上的该字段
|
||||
INTERACTION_KEYS = ("visibleWhen", "filters", "events")
|
||||
|
||||
|
||||
class NodeEvent(ExtensibleModel):
|
||||
"""组件事件;写变量的事件只写 type 和 action,保留已有的代码事件时把它原样带上"""
|
||||
|
||||
type: NonBlankString = Field(
|
||||
description="事件类型,取物料 eventOptions 里的值:click 点击组件;itemClick 点击图表的某个数据项(只有图表有)"
|
||||
)
|
||||
action: EventAction | None = Field(
|
||||
default=None, description="写变量;有 action 时不执行事件代码"
|
||||
)
|
||||
|
||||
@model_serializer(mode="wrap")
|
||||
def without_empty_action(self, handler):
|
||||
# 代码事件没有 action,不补出 null
|
||||
data = handler(self)
|
||||
if self.action is None:
|
||||
data.pop("action", None)
|
||||
return data
|
||||
|
||||
|
||||
class SetInteractionAction(BaseAction):
|
||||
"""设置组件的显示条件、数据筛选和事件。只改写出的键,整体替换该键;写 null 删除该键;至少写一个键
|
||||
|
||||
变量要先在画布 variables 里声明(update_canvas),同一批里先后顺序不限,整批执行完再检查引用
|
||||
"""
|
||||
|
||||
type: Literal["set_interaction"] = "set_interaction"
|
||||
visibleWhen: VisibleWhen | None = Field(
|
||||
default=None,
|
||||
description="变量等于 equals 时才显示该组件,用于整页切换;同一变量取不同值的组件不会同时出现,可以放在同一位置",
|
||||
)
|
||||
filters: list[RowFilter] | None = Field(
|
||||
default=None,
|
||||
description="按变量筛选组件的数据行,多条为且,变量为 null 时不过滤;只用于有数据行的组件(图表的 dataset.source、指标卡的 rows),"
|
||||
"field 必须是数据行里的列。被筛选的图表的数据要带上这一列,查询时不要对它加 WHERE",
|
||||
)
|
||||
events: list[NodeEvent] | None = Field(
|
||||
default=None,
|
||||
description="组件的全部事件,整体替换:组件上已有的代码事件要保留时原样带上。"
|
||||
'例如 [{"type": "click", "action": {"setVar": "page", "value": "stock"}}]、'
|
||||
'[{"type": "itemClick", "action": {"setVar": "model", "fromField": "model"}}]',
|
||||
)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def some_key(self) -> Self:
|
||||
if not self.model_fields_set & set(INTERACTION_KEYS):
|
||||
raise ValueError(f"至少写出 {'、'.join(INTERACTION_KEYS)} 中的一个")
|
||||
return self
|
||||
|
||||
@model_serializer(mode="wrap")
|
||||
def written_keys_only(self, handler):
|
||||
# 交给前端时 null 表示删除,没写的键不能序列化成 null;检查点按序列化结果重建,也靠它保留"写了哪些键"
|
||||
data = handler(self)
|
||||
for key in INTERACTION_KEYS:
|
||||
if key not in self.model_fields_set:
|
||||
data.pop(key, None)
|
||||
return data
|
||||
@@ -16,5 +16,7 @@ class UpdateCanvasAction(ActionModel):
|
||||
properties: dict[NonBlankString, JsonValue] = Field(
|
||||
min_length=1,
|
||||
description="要替换的 canvas 顶层字段及最终值,如 width、height、theme(light 亮色 / dark 深色)、backgroundColor;未提供的字段保持不变。"
|
||||
"改 theme 会让组件里跟随主题的颜色一起变;backgroundColor 写 null 表示用主题的背景色。改变画布尺寸不会自动缩放或移动组件",
|
||||
"改 theme 会让组件里跟随主题的颜色一起变;backgroundColor 写 null 表示用主题的背景色。改变画布尺寸不会自动缩放或移动组件。"
|
||||
'variables 是大屏变量的完整列表(整组替换,保留已有的),例如 [{"id": "page", "name": "当前页", "default": "sales"}];'
|
||||
"组件怎么读写变量用 set_interaction 设置",
|
||||
)
|
||||
|
||||
@@ -27,6 +27,18 @@ class MaterialTemplate(ExtensibleModel):
|
||||
events: list[JsonObject] = Field(default_factory=list)
|
||||
|
||||
|
||||
class EventOption(ExtensibleModel):
|
||||
label: NonBlankString
|
||||
value: NonBlankString = Field(description="事件类型,即节点 events[].type")
|
||||
|
||||
|
||||
class EventGroup(ExtensibleModel):
|
||||
"""多个物料共用的一组事件,物料按 key 引用;前端接口展开成 eventOptions 数组"""
|
||||
|
||||
key: NonBlankString
|
||||
options: list[EventOption]
|
||||
|
||||
|
||||
class ComponentDefinition(ExtensibleModel):
|
||||
model_config = ConfigDict(serialize_by_alias=True)
|
||||
key: NonBlankString = Field(description="目录中的唯一编号,不是 Screen 节点 ID")
|
||||
@@ -49,7 +61,20 @@ class ComponentDefinition(ExtensibleModel):
|
||||
description="字段分组:字符串引用目录的共享分组,对象为本物料独有的分组;"
|
||||
"前端属性面板的 setters 由它派生,不单独保存",
|
||||
)
|
||||
eventOptions: list[JsonObject] = Field(default_factory=list)
|
||||
event_groups: list[NonBlankString] = Field(
|
||||
default_factory=list,
|
||||
description="引用目录的共享事件组,按顺序展开成前端的 eventOptions",
|
||||
)
|
||||
rows_path: NonBlankString | None = Field(
|
||||
default=None,
|
||||
description="组件数据行(对象数组)的点路径,例如 props.option.dataset.source;"
|
||||
"节点的 filters 作用在这里,后端检查按它取列;前端各物料自己知道数据行在哪",
|
||||
)
|
||||
data_source: bool = Field(
|
||||
default=False,
|
||||
description="组件可以绑定数据源(节点 dataId):绑定后运行时的数据行换成数据源的数据,不再读 rows_path;"
|
||||
"前端按它决定是否显示数据源绑定。需要 rows_path",
|
||||
)
|
||||
usage_notes: list[NonBlankString] = Field(
|
||||
default_factory=list, description="配置、数据绑定及使用限制说明"
|
||||
)
|
||||
@@ -67,8 +92,23 @@ class ComponentDefinition(ExtensibleModel):
|
||||
raise ValueError("目录 type 必须与 schema.type 一致")
|
||||
if "id" in self.schema_.model_extra:
|
||||
raise ValueError("默认物料配置不能包含实例 ID")
|
||||
if self.rows_path is not None:
|
||||
try:
|
||||
rows = lookup(self.template(), self.rows_segments)
|
||||
except ValueError:
|
||||
rows = MISSING
|
||||
if not isinstance(rows, list):
|
||||
raise ValueError(f"rows_path {self.rows_path} 在默认配置里不是数组")
|
||||
if self.data_source and self.rows_path is None:
|
||||
raise ValueError("data_source 的物料要声明 rows_path:绑定的数据源替换的是这里的数据行")
|
||||
return self
|
||||
|
||||
@property
|
||||
def rows_segments(self) -> list[str | int]:
|
||||
return [
|
||||
int(part) if part.isdigit() else part for part in self.rows_path.split(".")
|
||||
]
|
||||
|
||||
|
||||
def _default_problem(field, template: JsonObject) -> str | None:
|
||||
"""默认值只有一个来源:默认配置有该路径时取它,没有时字段必须声明 default;
|
||||
@@ -100,6 +140,18 @@ class ComponentCatalog(ExtensibleModel):
|
||||
field_groups: list[FieldGroup] = Field(
|
||||
default_factory=list, description="多个物料共用的字段分组,物料按 key 引用"
|
||||
)
|
||||
event_groups: list[EventGroup] = Field(
|
||||
default_factory=list, description="多个物料共用的事件组,物料按 key 引用"
|
||||
)
|
||||
|
||||
def event_options(self, component: ComponentDefinition) -> list[JsonObject]:
|
||||
"""物料引用的事件组按顺序展开,即前端的 eventOptions"""
|
||||
groups = {group.key: group for group in self.event_groups}
|
||||
return [
|
||||
option.model_dump(mode="json")
|
||||
for key in component.event_groups
|
||||
for option in groups[key].options
|
||||
]
|
||||
|
||||
@model_validator(mode="after")
|
||||
def unique_keys(self) -> Self:
|
||||
@@ -109,6 +161,9 @@ class ComponentCatalog(ExtensibleModel):
|
||||
shared = [group.key for group in self.field_groups]
|
||||
if len(shared) != len(set(shared)):
|
||||
raise ValueError("共享字段分组 key 不能重复")
|
||||
event_groups = [group.key for group in self.event_groups]
|
||||
if len(event_groups) != len(set(event_groups)):
|
||||
raise ValueError("共享事件组 key 不能重复")
|
||||
for component in self.components:
|
||||
groups = [
|
||||
group.key if isinstance(group, FieldGroup) else group
|
||||
@@ -132,6 +187,13 @@ class ComponentCatalog(ExtensibleModel):
|
||||
raise ValueError(
|
||||
f"物料 {component.key} 的独有分组与共享分组重名: {shadowed}"
|
||||
)
|
||||
if missing := [
|
||||
key for key in component.event_groups if key not in event_groups
|
||||
]:
|
||||
raise ValueError(f"物料 {component.key} 引用了不存在的事件组: {missing}")
|
||||
events = [option["value"] for option in self.event_options(component)]
|
||||
if len(events) != len(set(events)):
|
||||
raise ValueError(f"物料 {component.key} 的事件类型重复")
|
||||
by_key = {group.key: group for group in self.field_groups}
|
||||
fields = [
|
||||
field
|
||||
|
||||
@@ -113,7 +113,13 @@ class MaterialField(ExtensibleModel):
|
||||
)
|
||||
identifies: bool = Field(
|
||||
default=False,
|
||||
description="这个字段的文字是组件在画面上的名字或内容(图表标题、指标名、文本),大屏摘要用它帮助认出用户说的组件",
|
||||
description="这个字段的文字是组件在画面上的名字或内容(图表标题、指标名、文本),大屏摘要用它帮助认出用户说的组件;"
|
||||
"对象数组字段(导航的选项)取每项的 label",
|
||||
)
|
||||
template: bool = Field(
|
||||
default=False,
|
||||
description="前端渲染时把这个字段里的 {{变量ID}} 换成大屏变量的值(文本内容);其他字段里的 {{...}} 原样显示,"
|
||||
"批末检查只查这些字段和数据源 params 里引用的变量",
|
||||
)
|
||||
editable: bool = Field(default=True, description="是否允许修改")
|
||||
panel: bool = Field(default=True, description="是否显示在前端属性面板")
|
||||
@@ -133,8 +139,15 @@ class MaterialField(ExtensibleModel):
|
||||
raise ValueError(f"只有 color 字段可以跟随主题,且不能声明 default: {self.path}")
|
||||
if self.example and (self.themed or self.default is not None):
|
||||
raise ValueError(f"示例内容要写在默认配置里,不能跟随主题或声明 default: {self.path}")
|
||||
if self.identifies and self.value_type != "string":
|
||||
raise ValueError(f"只有 string 字段可以用来识别组件: {self.path}")
|
||||
if self.template and self.value_type != "string":
|
||||
raise ValueError(f"只有 string 字段可以写模板: {self.path}")
|
||||
if self.identifies and not (
|
||||
self.value_type == "string"
|
||||
or (self.value_type == "array" and self.item_type == "object")
|
||||
):
|
||||
raise ValueError(
|
||||
f"只有 string 字段或选项数组(取每项的 label)可以用来识别组件: {self.path}"
|
||||
)
|
||||
return self
|
||||
|
||||
def value_error(self, value: JsonValue) -> str | None:
|
||||
|
||||
+164
-2
@@ -1,13 +1,160 @@
|
||||
"""大屏公共结构;组件属性、事件与数据源配置保持开放"""
|
||||
|
||||
from typing import Annotated, Literal, Self
|
||||
import json
|
||||
import re
|
||||
from typing import Annotated, Any, Literal, Self
|
||||
|
||||
from pydantic import Field, model_validator
|
||||
from pydantic import (
|
||||
BaseModel,
|
||||
ConfigDict,
|
||||
Field,
|
||||
field_validator,
|
||||
model_serializer,
|
||||
model_validator,
|
||||
)
|
||||
|
||||
from .base import ExtensibleModel, JsonObject, NonBlankString
|
||||
|
||||
Number = int | float
|
||||
PositiveNumber = Annotated[Number, Field(gt=0)]
|
||||
# 变量的值:JSON 标量;相等按 JSON 严格比较,"1" 与 1 不等
|
||||
VarValue = str | int | float | bool | None
|
||||
|
||||
|
||||
# 模板占位符 {{变量ID}}:括号内可有空格,ID 由字母、数字、_、- 组成;和前端 fillTemplate 同一条规则
|
||||
# 用显式字符集而不是 \w:Python 的 \w 匹配中文,JS 的不匹配
|
||||
TEMPLATE = re.compile(r"\{\{\s*([A-Za-z0-9_-]+)\s*\}\}")
|
||||
|
||||
|
||||
def template_refs(value: Any) -> set[str]:
|
||||
"""值里(递归到字符串)引用到的变量 ID"""
|
||||
if isinstance(value, str):
|
||||
return set(TEMPLATE.findall(value))
|
||||
if isinstance(value, dict):
|
||||
return {ref for item in value.values() for ref in template_refs(item)}
|
||||
if isinstance(value, list):
|
||||
return {ref for item in value for ref in template_refs(item)}
|
||||
return set()
|
||||
|
||||
|
||||
def _as_text(value: Any) -> str:
|
||||
# 按 JS 的 String(value):null 为空串,布尔小写,整数值的小数不带 .0
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, bool):
|
||||
return "true" if value else "false"
|
||||
if isinstance(value, float) and value.is_integer():
|
||||
return str(int(value))
|
||||
return str(value)
|
||||
|
||||
|
||||
def fill_template(value: Any, variables: dict[str, Any]) -> Any:
|
||||
"""整个字符串就是一个占位符时换成变量原值(保留类型,null 也原样);嵌在文字中时转成文字拼接,
|
||||
null 和未定义为空串;对象和数组递归,其他原样返回"""
|
||||
if isinstance(value, str):
|
||||
if whole := TEMPLATE.fullmatch(value):
|
||||
return variables.get(whole.group(1))
|
||||
return TEMPLATE.sub(lambda m: _as_text(variables.get(m.group(1))), value)
|
||||
if isinstance(value, dict):
|
||||
return {key: fill_template(item, variables) for key, item in value.items()}
|
||||
if isinstance(value, list):
|
||||
return [fill_template(item, variables) for item in value]
|
||||
return value
|
||||
|
||||
|
||||
def json_key(value: Any) -> tuple[str, Any]:
|
||||
"""相等判断的键:键相同即相等。数字之间按值(1 与 1.0 相同),其余类型不同就不等("1" 与 1、true 与 1 都不等)
|
||||
|
||||
可以放进集合或字典里分组;对象和数组按内容逐层用同样的规则比较(前端的 === 不会认为它们相等,变量值只用标量)
|
||||
"""
|
||||
if isinstance(value, bool):
|
||||
return "boolean", value
|
||||
if isinstance(value, (int, float)):
|
||||
# int 与 float 值相等时哈希也相等,1 和 1.0 落在同一个键
|
||||
return "number", value
|
||||
if isinstance(value, str):
|
||||
return "string", value
|
||||
if value is None:
|
||||
return "null", None
|
||||
if isinstance(value, dict):
|
||||
return "object", tuple(sorted((key, json_key(item)) for key, item in value.items()))
|
||||
if isinstance(value, list):
|
||||
return "array", tuple(json_key(item) for item in value)
|
||||
return "json", json.dumps(value, sort_keys=True, ensure_ascii=False)
|
||||
|
||||
|
||||
def json_equal(left: Any, right: Any) -> bool:
|
||||
"""按 JSON 严格比较,规则见 json_key"""
|
||||
return json_key(left) == json_key(right)
|
||||
|
||||
|
||||
def is_scalar(value: Any) -> bool:
|
||||
"""变量的值只能是 JSON 标量"""
|
||||
return value is None or isinstance(value, (str, int, float, bool))
|
||||
|
||||
|
||||
class ProtocolModel(BaseModel):
|
||||
"""前后端约定的交互结构:字段固定,写错的键直接报错,不当作扩展字段保留"""
|
||||
|
||||
model_config = ConfigDict(extra="forbid", strict=True, allow_inf_nan=False)
|
||||
|
||||
|
||||
class Variable(ProtocolModel):
|
||||
"""大屏变量:点击写变量,组件读变量(显隐、筛选数据、填文字、取数参数)"""
|
||||
|
||||
id: NonBlankString = Field(
|
||||
description="变量 ID,组件按它引用;文本内容和数据源参数里写 {{ID}} 取它的值,所以只用字母、数字、_、-"
|
||||
)
|
||||
name: str | None = Field(
|
||||
default=None, description="给用户看的变量名,例如 选中模型"
|
||||
)
|
||||
default: VarValue = Field(
|
||||
default=None,
|
||||
description="打开大屏时的值:字符串、数字、布尔或 null;null 时按它筛选的组件不过滤。"
|
||||
"被 filters 引用时要取数据里存在的值",
|
||||
)
|
||||
|
||||
|
||||
class VisibleWhen(ProtocolModel):
|
||||
"""变量等于 equals 时组件才显示"""
|
||||
|
||||
var: NonBlankString = Field(description="变量 ID")
|
||||
equals: VarValue = Field(description="变量等于这个值时显示,按 JSON 严格比较")
|
||||
|
||||
|
||||
class RowFilter(ProtocolModel):
|
||||
"""只保留 row[field] 等于变量值的数据行;变量为 null 时不过滤"""
|
||||
|
||||
field: NonBlankString = Field(description="组件数据行里的列名")
|
||||
var: NonBlankString = Field(description="变量 ID")
|
||||
|
||||
|
||||
class EventAction(ProtocolModel):
|
||||
"""事件触发时写变量:value 写常量,fromField 写被点击数据行的某列(只用于 itemClick)"""
|
||||
|
||||
setVar: NonBlankString = Field(description="要写的变量 ID")
|
||||
value: VarValue = Field(default=None, description="写入的常量;与 fromField 二选一")
|
||||
fromField: NonBlankString | None = Field(
|
||||
default=None,
|
||||
description="写入被点击数据行这一列的值,只用于 itemClick 事件;与 value 二选一",
|
||||
)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def one_source(self) -> Self:
|
||||
# value 可以是 null,按是否写出判断,不按值判断
|
||||
if ("value" in self.model_fields_set) == (self.fromField is not None):
|
||||
raise ValueError("value 与 fromField 必须二选一")
|
||||
return self
|
||||
|
||||
@model_serializer(mode="wrap")
|
||||
def written_source_only(self, handler):
|
||||
# 二选一按"写了哪个键"区分,没写的那个不能序列化出来
|
||||
data = handler(self)
|
||||
if "value" not in self.model_fields_set:
|
||||
data.pop("value", None)
|
||||
if self.fromField is None:
|
||||
data.pop("fromField", None)
|
||||
return data
|
||||
|
||||
|
||||
class Canvas(ExtensibleModel):
|
||||
@@ -20,6 +167,10 @@ class Canvas(ExtensibleModel):
|
||||
backgroundColor: str | None = Field(
|
||||
default=None, description="画布背景色;为 null 时用主题的背景色"
|
||||
)
|
||||
variables: list[Variable] = Field(
|
||||
default_factory=list,
|
||||
description="大屏变量,整组替换;组件用 visibleWhen、filters、事件和 {{ID}} 模板读写它们",
|
||||
)
|
||||
|
||||
|
||||
class Layout(ExtensibleModel):
|
||||
@@ -38,7 +189,18 @@ class ScreenNode(ExtensibleModel):
|
||||
name: str | None = None
|
||||
props: JsonObject = Field(default_factory=dict)
|
||||
style: JsonObject | None = None
|
||||
# 事件保持开放(前端还有 title、name、code 等字段);带 action 时按 EventAction 校验结构
|
||||
events: list[JsonObject] | None = None
|
||||
visibleWhen: VisibleWhen | None = None
|
||||
filters: list[RowFilter] | None = None
|
||||
|
||||
@field_validator("events")
|
||||
@classmethod
|
||||
def event_actions(cls, events: list[JsonObject] | None):
|
||||
for event in events or []:
|
||||
if "action" in event:
|
||||
EventAction.model_validate(event["action"])
|
||||
return events
|
||||
|
||||
|
||||
class Screen(ExtensibleModel):
|
||||
|
||||
Reference in New Issue
Block a user