"""前端组件目录及查询结果""" from typing import Self from pydantic import BaseModel, ConfigDict, Field, model_validator from app.schemas.base import ExtensibleModel, JsonObject, NonBlankString from app.schemas.material_field import MISSING, FieldGroup, lookup from app.schemas.screen import Layout, PositiveNumber class ComponentSummary(BaseModel): key: NonBlankString # 目录编号 type: NonBlankString # 前端实际组件类型 name: NonBlankString # 组件名称 description: NonBlankString # 组件用途 default_width: PositiveNumber # 默认宽度,单位 px default_height: PositiveNumber # 默认高度,单位 px class MaterialTemplate(ExtensibleModel): type: NonBlankString name: NonBlankString layout: Layout props: JsonObject = Field(default_factory=dict) style: JsonObject | None = None 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") type: NonBlankString = Field( description="前端实际注册的节点 type,不根据图表名称猜测" ) name: NonBlankString = Field(description="组件名称") description: NonBlankString = Field(description="组件用途和支持的展示方式") tags: list[NonBlankString] = Field( default_factory=list, description="用于检索的分类和关键词" ) renderer: NonBlankString = Field(description="渲染能力标识,当前从前端实现映射而来") group: NonBlankString icon: NonBlankString | None = None schema_: MaterialTemplate = Field( alias="schema", description="完整默认物料配置,不包含实例 ID" ) field_groups: list[NonBlankString | FieldGroup] = Field( default_factory=list, description="字段分组:字符串引用目录的共享分组,对象为本物料独有的分组;" "前端属性面板的 setters 由它派生,不单独保存", ) 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="配置、数据绑定及使用限制说明" ) def template(self) -> JsonObject: """默认配置的 JSON;style 为 null 表示未设置,字段默认值按路径不存在处理""" data = self.schema_.model_dump(mode="json") if data.get("style") is None: data.pop("style", None) return data @model_validator(mode="after") def consistent_type(self) -> Self: if self.type != self.schema_.type: 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; 跟随主题的颜色由前端按画布主题取,默认配置里不能写死""" try: current = lookup(template, field.segments) except ValueError: return "的路径与默认配置结构冲突" if field.themed: return None if current is MISSING else "跟随主题,默认配置里不能写死颜色" if field.example and current is MISSING: return "标为示例内容,默认配置里要有这个值" if current is MISSING: if field.default is None: return "在默认配置中不存在,需要声明 default" error = field.value_error(field.default) return f"的 default {error}" if error else None if field.default is not None: return "在默认配置中已有值,不能再声明 default" error = field.value_error(current) return f"的默认配置值 {error}" if error else None class ComponentCatalog(ExtensibleModel): version: NonBlankString = Field( description="前端组件目录版本,后续用于检查是否与前端匹配" ) components: list[ComponentDefinition] 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: keys = [component.key for component in self.components] if len(keys) != len(set(keys)): raise ValueError("组件目录 key 不能重复") 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 for group in component.field_groups ] if len(groups) != len(set(groups)): raise ValueError(f"物料 {component.key} 的字段分组 key 重复") if missing := [ group for group in component.field_groups if isinstance(group, str) and group not in shared ]: raise ValueError( f"物料 {component.key} 引用了不存在的共享分组: {missing}" ) if shadowed := [ group.key for group in component.field_groups if isinstance(group, FieldGroup) and group.key in shared ]: 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 for group in component.field_groups for field in (by_key[group] if isinstance(group, str) else group).fields ] paths = [field.path for field in fields] if duplicated := sorted({path for path in paths if paths.count(path) > 1}): raise ValueError(f"物料 {component.key} 的字段路径重复: {duplicated}") template = component.template() for field in fields: problem = _default_problem(field, template) if problem: raise ValueError(f"物料 {component.key} 字段 {field.path} {problem}") return self