180 lines
7.6 KiB
Python
180 lines
7.6 KiB
Python
"""前端组件目录及查询结果"""
|
||
|
||
from typing import Literal, 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 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 由它派生,不单独保存",
|
||
)
|
||
eventOptions: list[JsonObject] = Field(default_factory=list)
|
||
sample_data: bool = Field(
|
||
default=False, description="默认配置是否含示例数据,不能视为真实业务数据"
|
||
)
|
||
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")
|
||
return self
|
||
|
||
|
||
def _default_problem(field, template: JsonObject) -> str | None:
|
||
"""默认值只有一个来源:默认配置有该路径时取它,没有时字段必须声明 default"""
|
||
try:
|
||
current = lookup(template, field.segments)
|
||
except ValueError:
|
||
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 引用"
|
||
)
|
||
|
||
@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 不能重复")
|
||
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}")
|
||
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
|
||
|
||
|
||
class ComponentQuery(ExtensibleModel):
|
||
key: NonBlankString | None = Field(default=None, description="精确匹配目录编号")
|
||
type: NonBlankString | None = Field(
|
||
default=None, description="精确匹配前端节点类型,可能对应多个预设"
|
||
)
|
||
keyword: NonBlankString | None = Field(
|
||
default=None, description="按名称、说明和标签查询,多个条件同时满足"
|
||
)
|
||
|
||
|
||
class ComponentResult(ExtensibleModel):
|
||
status: Literal["resolved", "not_found", "unsupported"]
|
||
components: list[ComponentDefinition] = Field(default_factory=list)
|
||
catalog_version: NonBlankString | None = None
|
||
message: NonBlankString
|
||
|
||
def to_planner_context(self) -> JsonObject:
|
||
# 显式提取摘要,避免默认数据、事件和扩展字段进入规划提示词
|
||
return {
|
||
"status": self.status,
|
||
"catalog_version": self.catalog_version,
|
||
"message": self.message,
|
||
"components": [
|
||
ComponentSummary(
|
||
key=component.key,
|
||
type=component.type,
|
||
name=component.name,
|
||
description=component.description,
|
||
default_width=component.schema_.layout.width,
|
||
default_height=component.schema_.layout.height,
|
||
).model_dump(mode="json")
|
||
for component in self.components
|
||
],
|
||
}
|