179 lines
7.9 KiB
Python
179 lines
7.9 KiB
Python
"""物料字段定义:分组 → 字段,每个字段带路径、含义、值类型和取值约束
|
|
|
|
字段定义是“组件有哪些可改属性”的唯一来源:前端属性面板的 setters、
|
|
agent 查询字段、动作校验都由它派生,不再各自维护一份
|
|
"""
|
|
|
|
from typing import Literal, Self
|
|
|
|
from pydantic import Field, JsonValue, model_validator
|
|
|
|
from app.schemas.base import ExtensibleModel, NonBlankString
|
|
|
|
# 字段路径的第一段:节点名称、布局、组件属性、组件样式
|
|
FIELD_ROOTS = ("name", "layout", "props", "style")
|
|
|
|
ValueType = Literal[
|
|
"string", "number", "integer", "boolean", "color", "enum", "array", "object"
|
|
]
|
|
# 数组元素只用基础类型,枚举和嵌套数组没有需要的场景
|
|
ItemType = Literal["string", "number", "integer", "boolean", "color", "object"]
|
|
|
|
# 值类型对应的前端属性控件;array / object 没有通用控件,只能不在面板显示
|
|
# number 可以输入小数,integer 只能输入整数
|
|
PANEL_CONTROLS: dict[str, str] = {
|
|
"string": "input",
|
|
"number": "number",
|
|
"integer": "integer",
|
|
"boolean": "checkbox",
|
|
"color": "color",
|
|
"enum": "select",
|
|
}
|
|
|
|
|
|
# 默认配置中没有该路径时的标记,区别于值为 null
|
|
MISSING = object()
|
|
|
|
# 值类型对应的 JSON 类型,用于检查默认配置里的现值
|
|
_JSON_TYPES: dict[str, type | tuple[type, ...]] = {
|
|
"string": str,
|
|
"color": str,
|
|
"number": (int, float),
|
|
"integer": int,
|
|
"boolean": bool,
|
|
"array": list,
|
|
"object": dict,
|
|
}
|
|
|
|
|
|
def _type_error(value_type: str, value: JsonValue) -> str | None:
|
|
# bool 是 int 的子类,数值类型需要单独排除
|
|
if not isinstance(value, _JSON_TYPES[value_type]) or (
|
|
value_type in ("number", "integer") and isinstance(value, bool)
|
|
):
|
|
return f"类型必须是 {value_type}"
|
|
return None
|
|
|
|
|
|
def lookup(value: JsonValue, segments: list[str | int]):
|
|
"""按路径取值;路径不存在返回 MISSING,路径中途遇到标量时抛 ValueError"""
|
|
for segment in segments:
|
|
if isinstance(value, dict) and isinstance(segment, str):
|
|
if segment not in value:
|
|
return MISSING
|
|
value = value[segment]
|
|
elif isinstance(value, list) and isinstance(segment, int):
|
|
if segment >= len(value):
|
|
return MISSING
|
|
value = value[segment]
|
|
else:
|
|
raise ValueError(f"路径无法继续访问: {segments}")
|
|
return value
|
|
|
|
|
|
class FieldOption(ExtensibleModel):
|
|
value: JsonValue
|
|
label: NonBlankString
|
|
description: NonBlankString | None = None
|
|
|
|
|
|
class MaterialField(ExtensibleModel):
|
|
path: NonBlankString = Field(
|
|
description="点路径,与前端 setter key 一致,数字段为数组下标,例如 props.option.series.0.itemStyle.color"
|
|
)
|
|
label: NonBlankString = Field(description="字段名称,属性面板显示的文字")
|
|
description: NonBlankString = Field(
|
|
description="字段含义、在画面上的效果,以及用户通常怎么称呼它"
|
|
)
|
|
value_type: ValueType
|
|
options: list[FieldOption] = Field(
|
|
default_factory=list, description="enum 的可选值,每个值可带说明"
|
|
)
|
|
item_type: ItemType | None = Field(
|
|
default=None, description="array 的元素类型"
|
|
)
|
|
minimum: int | float | None = None
|
|
maximum: int | float | None = None
|
|
unit: NonBlankString | None = Field(default=None, description="数值单位,例如 px")
|
|
default: JsonValue = Field(
|
|
default=None,
|
|
description="物料默认配置中没有该路径时的默认值;有该路径时以默认配置为准,不在这里重复",
|
|
)
|
|
aliases: list[NonBlankString] = Field(
|
|
default_factory=list, description="用户可能的叫法,供字段搜索使用"
|
|
)
|
|
themed: bool = Field(
|
|
default=False,
|
|
description="颜色跟随画布主题:默认配置里不写值,由前端按画布主题(亮 / 暗)取色;写入 null 恢复跟随",
|
|
)
|
|
example: Literal["data", "label"] | None = Field(
|
|
default=None,
|
|
description="默认配置里的值是演示内容,不是结构默认值:data 是示例数据(图表数据、指标值),"
|
|
"label 是示例文字(标题、系列名)。新增组件沿用了它们时告诉 agent;沿用示例数据的组件交付时标为示例数据",
|
|
)
|
|
identifies: bool = Field(
|
|
default=False,
|
|
description="这个字段的文字是组件在画面上的名字或内容(图表标题、指标名、文本),大屏摘要用它帮助认出用户说的组件",
|
|
)
|
|
editable: bool = Field(default=True, description="是否允许修改")
|
|
panel: bool = Field(default=True, description="是否显示在前端属性面板")
|
|
span: int | None = Field(default=None, description="属性面板栅格宽度,24 为整行")
|
|
|
|
@model_validator(mode="after")
|
|
def consistent(self) -> Self:
|
|
if self.path.split(".")[0] not in FIELD_ROOTS:
|
|
raise ValueError(f"字段路径必须以 {'/'.join(FIELD_ROOTS)} 开头: {self.path}")
|
|
if (self.value_type == "enum") != bool(self.options):
|
|
raise ValueError(f"只有 enum 字段提供 options,且 enum 必须提供: {self.path}")
|
|
if (self.value_type == "array") != (self.item_type is not None):
|
|
raise ValueError(f"只有 array 字段提供 item_type,且 array 必须提供: {self.path}")
|
|
if self.panel and self.value_type not in PANEL_CONTROLS:
|
|
raise ValueError(f"{self.value_type} 字段没有属性面板控件,panel 必须为 false: {self.path}")
|
|
if self.themed and (self.value_type != "color" or self.default is not None):
|
|
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}")
|
|
return self
|
|
|
|
def value_error(self, value: JsonValue) -> str | None:
|
|
"""检查值是否符合字段类型和取值约束,符合返回 None"""
|
|
# 跟随主题的颜色写 null 表示去掉单独设置的颜色、恢复跟随
|
|
if self.themed and value is None:
|
|
return None
|
|
if self.value_type == "enum":
|
|
allowed = [option.value for option in self.options]
|
|
return None if value in allowed else f"取值必须是 {allowed} 之一"
|
|
if error := _type_error(self.value_type, value):
|
|
return error
|
|
if self.item_type is not None:
|
|
for element in value:
|
|
if error := _type_error(self.item_type, element):
|
|
return f"数组元素{error}"
|
|
if self.value_type in ("number", "integer"):
|
|
if self.minimum is not None and value < self.minimum:
|
|
return f"不能小于 {self.minimum}"
|
|
if self.maximum is not None and value > self.maximum:
|
|
return f"不能大于 {self.maximum}"
|
|
return None
|
|
|
|
@property
|
|
def segments(self) -> list[str | int]:
|
|
# 数字段视为数组下标,与前端 setter key 的解析方式一致
|
|
return [int(part) if part.isdigit() else part for part in self.path.split(".")]
|
|
|
|
|
|
class FieldGroup(ExtensibleModel):
|
|
key: NonBlankString = Field(description="分组编号,共享分组在整个目录内唯一")
|
|
label: NonBlankString
|
|
description: NonBlankString = Field(description="这一组字段管什么,供模型先看目录再查字段")
|
|
fields: list[MaterialField]
|
|
|
|
@model_validator(mode="after")
|
|
def unique_paths(self) -> Self:
|
|
paths = [field.path for field in self.fields]
|
|
if len(paths) != len(set(paths)):
|
|
raise ValueError(f"分组 {self.key} 中字段路径重复")
|
|
return self
|