Files
my-v0-server/app/schemas/screen.py
T
2026-10-15 03:38:20 -07:00

220 lines
8.2 KiB
Python
Raw 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.
"""大屏公共结构;组件属性、事件与数据源配置保持开放"""
import json
import re
from typing import Annotated, Any, Literal, Self
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):
width: PositiveNumber
height: PositiveNumber
theme: Literal["light", "dark"] | None = Field(
default=None,
description="画布主题:组件里没单独设置的文字、坐标轴、分隔线、卡片底等颜色按主题取;旧大屏没有时前端按背景色推断",
)
backgroundColor: str | None = Field(
default=None, description="画布背景色;为 null 时用主题的背景色"
)
variables: list[Variable] = Field(
default_factory=list,
description="大屏变量,整组替换;组件用 visibleWhen、filters、事件和 {{ID}} 模板读写它们",
)
class Layout(ExtensibleModel):
"""组件在画布上的绝对坐标和尺寸,单位 px"""
x: Number = Field(description="左上角横坐标")
y: Number = Field(description="左上角纵坐标")
width: PositiveNumber
height: PositiveNumber
class ScreenNode(ExtensibleModel):
id: NonBlankString
type: NonBlankString
layout: Layout
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):
canvas: Canvas
nodes: list[ScreenNode]
# 保持前端字段名;数据源的具体类型与绑定校验后续再引入
dataSource: list[JsonObject] = Field(default_factory=list)
@model_validator(mode="after")
def unique_node_ids(self) -> Self:
seen: set[str] = set()
for node in self.nodes:
if node.id in seen:
raise ValueError(f"重复的节点 ID: {node.id}")
seen.add(node.id)
return self