feat:图表交互

This commit is contained in:
2026-10-15 03:38:20 -07:00
parent 565ecf4558
commit 9b64f9fc0b
43 changed files with 1537 additions and 727 deletions
+164 -2
View File
@@ -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):