"""物料字段定义:分组 → 字段,每个字段带路径、含义、值类型和取值约束 字段定义是“组件有哪些可改属性”的唯一来源:前端属性面板的 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 没有通用控件,只能不在面板显示 PANEL_CONTROLS: dict[str, str] = { "string": "input", "number": "number", "integer": "number", "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="用户可能的叫法,供字段搜索使用" ) 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}") return self def value_error(self, value: JsonValue) -> str | None: """检查值是否符合字段类型和取值约束,符合返回 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