feat:图表交互

This commit is contained in:
2026-10-15 03:38:20 -07:00
parent 565ecf4558
commit 9b64f9fc0b
43 changed files with 1537 additions and 727 deletions
+81 -13
View File
@@ -4,6 +4,7 @@ import re
from pydantic import JsonValue
from app.schemas.actions.set_interaction import INTERACTION_KEYS
from app.schemas.base import JsonObject
from app.schemas.component_resource import (
ComponentCatalog,
@@ -17,6 +18,7 @@ from app.schemas.material_field import (
MaterialField,
lookup,
)
from app.schemas.screen import ScreenNode
def resolve_field_groups(
@@ -69,6 +71,8 @@ def frontend_catalog(catalog: ComponentCatalog) -> dict:
for item, component in zip(data["components"], catalog.components, strict=True):
item["setters"] = panel_setters(resolve_field_groups(catalog, component))
item["defaults"] = structural_defaults(catalog, component)
# 定义里引用共享事件组,前端仍拿展开后的数组
item["eventOptions"] = catalog.event_options(component)
return data
@@ -108,9 +112,13 @@ def find_material(
return matches[0]
def material_payload(component: ComponentDefinition) -> dict:
"""给模型看的物料配置:完整默认 schema 及使用说明,字段定义由字段查询按需提供"""
return component.model_dump(mode="json", by_alias=True, exclude={"field_groups"})
def material_payload(catalog: ComponentCatalog, component: ComponentDefinition) -> dict:
"""给模型看的物料配置:完整默认 schema、可用事件及使用说明,字段定义由字段查询按需提供"""
data = component.model_dump(
mode="json", by_alias=True, exclude={"field_groups", "event_groups"}
)
data["eventOptions"] = catalog.event_options(component)
return data
def list_components(catalog: ComponentCatalog) -> dict:
@@ -142,7 +150,9 @@ def get_materials(catalog: ComponentCatalog, component_types: list[str]) -> dict
return {
"catalog_version": catalog.version,
"materials": [
material_payload(item) for item in catalog.components if item.type in types
material_payload(catalog, item)
for item in catalog.components
if item.type in types
],
}
@@ -161,6 +171,7 @@ def describe_field(
"themed",
"example",
"identifies",
"template",
},
exclude_none=True,
exclude_defaults=True,
@@ -278,6 +289,8 @@ def check_update_field(
}
path = action.field_path
field = fields.get(path)
if field is None and (top := _node_top_field(action)):
raise ValueError(top)
if field is None:
message = f"{node_type} 没有字段 {path},只能修改字段定义中的路径"
keyword = next(
@@ -295,6 +308,31 @@ def check_update_field(
raise ValueError(f"字段 {path}({field.label}){error}")
# 组件顶层字段不能用 update 改时该用什么
_TOP_FIELD_HINTS = {
"layout": "位置和尺寸用 move、resize 或 set_layout",
"name": "组件名称用 update 的 section=name、path=[]",
}
def _node_top_field(action) -> str | None:
"""props / style 下的路径第一段是组件的顶层字段(visibleWhen、filters、layout……)时的说明,否则为 None
模型把 filters 写成 section=props、path=["filters"],只说"没有字段 props.filters"时它不知道该换哪个动作
"""
if action.section == "name" or not action.path:
return None
key = action.path[0]
# props.style.color 这类多套了一层:props、style 本身能用 update 改,交给后面按字段名给建议
if key not in ScreenNode.model_fields or key in ("props", "style"):
return None
if key in INTERACTION_KEYS:
hint = "显示条件、数据筛选和事件用 set_interaction 设置"
else:
hint = _TOP_FIELD_HINTS.get(key, "不能用 update 修改")
return f"{key} 是组件的顶层字段,不在 {action.section} 下;{hint}"
def component_fields(
catalog: ComponentCatalog, component: ComponentDefinition
) -> list[MaterialField]:
@@ -334,6 +372,7 @@ def retained_examples(
"""组件上还和默认配置一样的示例内容(标题、系列名、数据……),按字段定义的 example 判断
只比较现在的值,不管是新增时没写、写了一样的值,还是之后又改回来;物料不存在时为空
有数据行时被 props.bind 覆盖的字段不算:画面上显示的是数据行里的值,留着的示例值不会出现
"""
if catalog is None:
return []
@@ -342,9 +381,10 @@ def retained_examples(
except ValueError:
return []
template = component.template()
bound = bound_paths(component, node)
retained = []
for field in component_fields(catalog, component):
if not field.example:
if not field.example or field.path in bound:
continue
try:
current = lookup(node, field.segments)
@@ -356,6 +396,19 @@ def retained_examples(
return retained
def bound_paths(component: ComponentDefinition, node: dict) -> set[str]:
"""props.bind 覆盖的字段路径
不看数据行是否已经写了:同一批里常先加卡片、再填 rows,中途提示"沿用了示例数据"会让模型去手写静态值;
交付时 bind 配空数据行由 interaction_problems 拦下
"""
props = node.get("props") or {}
bind = props.get("bind")
if component.rows_path is None or not isinstance(bind, dict) or not bind:
return set()
return {f"props.{key}" for key in bind}
# 摘要里每段文字的长度上限:文本组件的内容可能很长,认出组件只需要开头
IDENTITY_MAX_CHARS = 40
@@ -378,12 +431,26 @@ def identifying_texts(catalog: ComponentCatalog | None, node: dict) -> list[str]
text
if len(text) <= IDENTITY_MAX_CHARS
else text[:IDENTITY_MAX_CHARS] + "…"
for value in _values_at(node, field.segments)
if isinstance(value, str) and (text := value.strip())
for value in _identity_values(node, field)
if (text := value.strip())
)
return texts
def _identity_values(node: dict, field: MaterialField) -> list[str]:
"""识别字段的文字:字符串字段取它本身,选项数组取每项的 label"""
values = values_at(node, field.segments)
if field.value_type == "array":
values = [
item.get("label")
for value in values
if isinstance(value, list)
for item in value
if isinstance(item, dict)
]
return [value for value in values if isinstance(value, str)]
# 识别字段(标题、名称、文本内容)在画面上是一行短文字。换行、超过这个长度或带句号分号时是一段话:
# 线上问答之后"把结果放到大屏上",模型把"关键发现 1. …"、"注:……;……。"写进了文本组件
LABEL_MAX_CHARS = 60
@@ -413,19 +480,20 @@ def paragraph_texts(
(field, value)
for field in component_fields(catalog, component)
if field.identifies and (paths is None or field.path in paths)
for value in _values_at(node, field.segments)
if isinstance(value, str) and not is_label(value)
for value in _identity_values(node, field)
if not is_label(value)
]
def _values_at(value: JsonValue, segments: list[str | int]) -> list[JsonValue]:
def values_at(value: JsonValue, segments: list[str | int]) -> list[JsonValue]:
"""路径上的所有值:路径途经数组而字段按对象写时(ECharts 的多个 title),每一项都取"""
if not segments:
return [value]
head, *rest = segments
if isinstance(value, dict) and isinstance(head, str) and head in value:
return _values_at(value[head], rest)
return values_at(value[head], rest)
if isinstance(value, list) and isinstance(head, int) and 0 <= head < len(value):
return _values_at(value[head], rest)
return values_at(value[head], rest)
if isinstance(value, list) and isinstance(head, str):
return [found for item in value for found in _values_at(item, segments)]
return [found for item in value for found in values_at(item, segments)]
return []