From a80727b7a0443892c2a17d1c1624826bfd1b1470 Mon Sep 17 00:00:00 2001 From: Marcus <1922576605@qq.com> Date: Tue, 13 Oct 2026 04:16:00 -0700 Subject: [PATCH] =?UTF-8?q?feat=EF=BC=9A=E6=96=B0=E5=A2=9E=E7=89=A9?= =?UTF-8?q?=E6=96=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/actions/update_canvas.py | 4 +- app/agent/query_values.py | 131 +++++++++++++++++++++------ app/agent/tools.py | 12 ++- app/processor/summary.py | 25 +++-- app/resources/material_fields.py | 9 +- app/schemas/actions/add.py | 2 +- app/schemas/actions/update.py | 2 +- app/schemas/actions/update_canvas.py | 3 +- app/schemas/component_resource.py | 5 +- app/schemas/material_field.py | 12 ++- app/schemas/screen.py | 9 +- app/services/screens.py | 3 +- skills/data-chart/SKILL.md | 4 +- skills/screen-design/SKILL.md | 6 +- skills/screen-edit/SKILL.md | 1 + 15 files changed, 174 insertions(+), 54 deletions(-) diff --git a/app/actions/update_canvas.py b/app/actions/update_canvas.py index bec2d97..da3c217 100644 --- a/app/actions/update_canvas.py +++ b/app/actions/update_canvas.py @@ -9,8 +9,8 @@ from app.schemas.screen import Canvas, Screen class UpdateCanvasHandler(BaseActionHandler[UpdateCanvasAction]): def apply(self, screen: Screen, action: UpdateCanvasAction) -> Screen: - # 将现有画布转成字典,保留动作没有指定的字段 - properties = screen.canvas.model_dump() + # 将现有画布转成字典,保留动作没有指定的字段;没写过的主题、背景色不补成 null + properties = screen.canvas.model_dump(exclude_unset=True) # 顶层字段整体替换,嵌套对象不做深度合并 properties.update(deepcopy(action.properties)) # 校验合并后的画布属性,尺寸等字段不合法时直接抛出异常 diff --git a/app/agent/query_values.py b/app/agent/query_values.py index d3b2859..8da2a97 100644 --- a/app/agent/query_values.py +++ b/app/agent/query_values.py @@ -1,7 +1,9 @@ """apply_actions 里的 $query:把字段值写成 {"$query": "SELECT ..."},提交时替换成上传数据的查询结果 模型不用把查询结果逐行抄进动作,又慢又容易抄错;替换发生在 run_batch 之前,替换后照常做字段校验 -结果是对象数组,键是查询结果的列名,正好对应图表物料的 dataset.source;series 的 encode 按列名引用 +结果的形状按写入字段的定义:单个值的字段(指标卡的数值等)取一行一列的那个值,元素是数字、文字的数组 +(指标卡的趋势数据)取唯一一列;其余是对象数组,键是查询结果的列名,正好对应图表物料的 dataset.source, +series 的 encode 按列名引用 """ from dataclasses import dataclass @@ -12,42 +14,64 @@ from pydantic import JsonValue from app.actions.batch import BatchError from app.agent.data_tools import DataContext from app.config import settings +from app.resources.material_fields import find_material, resolve_field_groups from app.schemas.actions import AddAction, AgentAction, UpdateAction +from app.schemas.component_resource import ComponentCatalog +from app.schemas.material_field import MaterialField from app.schemas.screen import Screen QUERY_KEY = "$query" # 图表物料的数据都在这里,series 的 encode 按它的列名引用;整个换成查询结果时数据就不再是示例数据 SOURCE_PATH = "props.option.dataset.source" +# 字段是这些类型时,$query 的结果取一行一列的值 +SCALAR_TYPES = {"string", "number", "integer", "boolean", "color", "enum"} +# 数组元素是这些类型时,$query 的结果取唯一一列 +LIST_ITEM_TYPES = {"string", "number", "integer", "boolean", "color"} @dataclass(frozen=True) class EmbeddedQuery: - """一处替换:第几个动作、写到哪个字段、结果的列和行数,写进 apply_actions 的结果供模型核对""" + """一处替换:第几个动作、写到哪个字段、结果的列和行数,写进 apply_actions 的结果供模型核对 + + 写入单个值的字段时 value 是取到的值,模型据此核对数字、在回复里引用 + """ index: int node_id: str path: str columns: list[str] rows: int + value: JsonValue = None + + +Found = list[EmbeddedQuery] def fill_queries( - actions: list[AgentAction], data: DataContext + actions: list[AgentAction], + data: DataContext, + catalog: ComponentCatalog | None = None, + screen: Screen | None = None, ) -> tuple[list[AgentAction], list[EmbeddedQuery]]: """替换 add 的 node 和 update 的 value 里所有的 $query;查询失败抛 BatchError,整批不生效 + catalog 和 screen 用来查写入字段的定义,决定结果的形状;没有时都按对象数组 add 用了 $query 又没写 sample_data 时设为 false:数据来自用户上传的文件,不是示例数据 """ + # update 的目标可能是这一批前面新增的组件,类型按执行顺序记下 + types = {node.id: node.type for node in screen.nodes} if screen else {} filled: list[AgentAction] = [] embedded: list[EmbeddedQuery] = [] for index, action in enumerate(actions): - found: list[tuple[str, list[str], int]] = [] - node_id = "" # 只有 add 和 update 有字段值,其他动作不会有替换 + found: Found = [] try: if isinstance(action, AddAction): + types[action.node.id] = action.node.type node = _fill( action.node.model_dump(mode="json", exclude_unset=True), - data, + _Target( + data, _fields(catalog, action.node.type), index, action.node.id + ), "", found, ) @@ -56,49 +80,70 @@ def fill_queries( fields["node"] = node fields.setdefault("sample_data", False) action = AddAction.model_validate(fields) - node_id = action.node.id elif isinstance(action, UpdateAction): - value = _fill(action.value, data, action.field_path, found) + target = _Target( + data, + _fields(catalog, types.get(action.target_id)), + index, + action.target_id, + ) + value = _fill(action.value, target, action.field_path, found) if found: action = action.model_copy(update={"value": value}) - node_id = action.target_id except ValueError as error: # 含 QueryError raise BatchError(index, action, str(error)) from error - embedded.extend( - EmbeddedQuery(index, node_id, path, columns, rows) - for path, columns, rows in found - ) + embedded.extend(found) filled.append(action) return filled, embedded -def _fill( - value: JsonValue, - data: DataContext, - path: str, - found: list[tuple[str, list[str], int]], -) -> JsonValue: +@dataclass(frozen=True) +class _Target: + """一个动作的替换上下文:查询用的数据、组件的字段定义、动作序号和组件 ID""" + + data: DataContext + fields: dict[str, MaterialField] + index: int + node_id: str + + +def _fields( + catalog: ComponentCatalog | None, node_type: str | None +) -> dict[str, MaterialField]: + """组件类型的字段定义,按路径索引;没有目录或类型不认识时为空,结果按对象数组处理""" + if catalog is None or node_type is None: + return {} + try: + component = find_material(catalog, node_type) + except ValueError: # 物料不存在,补全或执行时会报错 + return {} + return { + field.path: field + for group in resolve_field_groups(catalog, component) + for field in group.fields + } + + +def _fill(value: JsonValue, target: _Target, path: str, found: Found) -> JsonValue: if isinstance(value, dict): if QUERY_KEY in value: - return _query(value, data, path, found) + return _query(value, target, path, found) return { - key: _fill(item, data, _join(path, key), found) + key: _fill(item, target, _join(path, key), found) for key, item in value.items() } if isinstance(value, list): return [ - _fill(item, data, _join(path, position), found) + _fill(item, target, _join(path, position), found) for position, item in enumerate(value) ] return value def _query( - value: dict[str, Any], - data: DataContext, - path: str, - found: list[tuple[str, list[str], int]], -) -> list[dict[str, Any]]: + value: dict[str, Any], target: _Target, path: str, found: Found +) -> JsonValue: + data = target.data sql = value[QUERY_KEY] if len(value) != 1 or not isinstance(sql, str): raise ValueError( @@ -118,7 +163,37 @@ def _query( raise ValueError( f"{path} 的 $query 结果有重名的列 {'、'.join(duplicated)},用 AS 起不同的别名" ) - found.append((path, result.columns, len(result.rows))) + shape = ( + f"{len(result.rows)} 行 {len(result.columns)} 列({'、'.join(result.columns)})" + ) + field = target.fields.get(path) + if field is not None and field.value_type in SCALAR_TYPES: + if len(result.rows) != 1 or len(result.columns) != 1: + raise ValueError( + f"{path}({field.label})是单个值,$query 的结果要一行一列,实际 {shape}" + ) + scalar = result.rows[0][0] + found.append( + EmbeddedQuery(target.index, target.node_id, path, result.columns, 1, scalar) + ) + return scalar + listed = ( + field is not None + and field.value_type == "array" + and field.item_type in LIST_ITEM_TYPES + ) + if listed and len(result.columns) != 1: + raise ValueError( + f"{path}({field.label})是一组值,$query 的结果要只有一列,实际 {shape};" + "排序用的列不用选出来,写在 ORDER BY 里" + ) + found.append( + EmbeddedQuery( + target.index, target.node_id, path, result.columns, len(result.rows) + ) + ) + if listed: + return [row[0] for row in result.rows] return [dict(zip(result.columns, row, strict=True)) for row in result.rows] diff --git a/app/agent/tools.py b/app/agent/tools.py index 39b8681..68f2bce 100644 --- a/app/agent/tools.py +++ b/app/agent/tools.py @@ -131,9 +131,13 @@ def _result_text( lines.append("$query 已替换为查询结果:") for item in queries: note = "(结果为空,组件会没有数据)" if not item.rows else "" + result = ( + f"取值 {item.value}" + if item.value is not None + else f"{item.rows} 行{note},列 {'、'.join(item.columns)}" + ) lines.append( - f"- 第 {item.index + 1} 个动作 {item.node_id} 的 {item.path}:" - f"{item.rows} 行{note},列 {'、'.join(item.columns)}" + f"- 第 {item.index + 1} 个动作 {item.node_id} 的 {item.path}:{result}" ) old_layouts = {node.id: node.layout for node in before.nodes} @@ -246,7 +250,9 @@ def apply_actions( else "" ) try: - actions, queries = fill_queries(actions, runtime.context.data) + actions, queries = fill_queries( + actions, runtime.context.data, runtime.context.catalog, before + ) completed, after = run_batch(before, runtime.context.catalog, actions) # 这一批改过数据或映射的组件都核对,不论数据来自这一批的 $query、之前的提交还是直接写入 problems = encode_problems(after, rebound_nodes(before, after)) diff --git a/app/processor/summary.py b/app/processor/summary.py index e6c363a..083da87 100644 --- a/app/processor/summary.py +++ b/app/processor/summary.py @@ -15,20 +15,24 @@ def request_summary(context: InputContext) -> JsonObject: """用户输入、澄清记录、编辑上下文、附件、数据文件和大屏摘要""" nodes: list[JsonObject] = [] for node in context.screen.nodes: - # 图表标题帮助判断用户指的是哪个组件;没有 option 或标题格式不符时按无标题处理 + # 画面上的标题帮助判断用户指的是哪个组件:图表在 option.title,指标卡在 props.title; + # 格式不符时按无标题处理 option = node.props.get("option") title = option.get("title") if isinstance(option, dict) else None titles = title if isinstance(title, list) else [title] + texts = [ + item["text"] + for item in titles + if isinstance(item, dict) and isinstance(item.get("text"), str) + ] + if isinstance(node.props.get("title"), str) and node.props["title"]: + texts.append(node.props["title"]) nodes.append( { "id": node.id, "type": node.type, "name": node.name, - "titles": [ - item["text"] - for item in titles - if isinstance(item, dict) and isinstance(item.get("text"), str) - ], + "titles": texts, "layout": node.layout.model_dump(mode="json", exclude_unset=True), } ) @@ -38,10 +42,11 @@ def request_summary(context: InputContext) -> JsonObject: "clarification_history": context.clarification_history, "context": context.context.model_dump(mode="json", exclude_unset=True), "screen_summary": { - "canvas": { - "width": context.screen.canvas.width, - "height": context.screen.canvas.height, - }, + "canvas": context.screen.canvas.model_dump( + mode="json", + include={"width", "height", "theme", "backgroundColor"}, + exclude_none=True, + ), "nodes": nodes, # 数据源只保留标识、名称和类型 "data_sources": [ diff --git a/app/resources/material_fields.py b/app/resources/material_fields.py index 3056d88..34eeeff 100644 --- a/app/resources/material_fields.py +++ b/app/resources/material_fields.py @@ -28,7 +28,7 @@ def resolve_field_groups( def field_default(component: ComponentDefinition, field: MaterialField) -> JsonValue: - """物料默认配置中有该路径时取默认配置的值,否则取字段声明的 default""" + """物料默认配置中有该路径时取默认配置的值,否则取字段声明的 default;跟随主题的颜色为 None""" value = lookup(component.template(), field.segments) return field.default if value is MISSING else value @@ -54,6 +54,8 @@ def panel_setters(groups: list[FieldGroup]) -> list[dict]: } if field.span is not None: setter["span"] = field.span + if field.themed: + setter["themed"] = True setters.append(setter) return setters @@ -126,11 +128,14 @@ def describe_field( """给模型看的字段说明:省略空值和属性面板专用的信息""" data = field.model_dump( mode="json", - exclude={"default", "panel", "span", "editable"}, + exclude={"default", "panel", "span", "editable", "themed"}, exclude_none=True, exclude_defaults=True, ) data["default"] = field_default(component, field) + if field.themed: + # 默认为 null:没单独设置时画面上按画布主题取色 + data["follows_theme"] = True if not field.editable: data["editable"] = False data["group"] = group.key diff --git a/app/schemas/actions/add.py b/app/schemas/actions/add.py index c21ef47..36e0aa1 100644 --- a/app/schemas/actions/add.py +++ b/app/schemas/actions/add.py @@ -16,7 +16,7 @@ class AddAction(ActionModel): description="新组件。id、type、layout 必填,其余只写需要覆盖物料默认配置的字段;" "后端按 type 找到物料,用默认配置补全:对象逐层覆盖,数组整体替换,null 明确覆盖。" "id 不能与当前大屏上的组件重复(包括之前提交里新增的),先 delete 的 ID 可以再用;type 用 list_components 返回的 type。" - '任何字段值都可以写成 {"$query": "SELECT ..."},后端替换成上传数据的查询结果(对象数组,键是结果的列名)' + '任何字段值都可以写成 {"$query": "SELECT ..."},后端替换成上传数据的查询结果:写入单个值的字段时结果要一行一列,取那个值;写入数字、文字数组的字段时结果要只有一列;其余是对象数组,键是结果的列名' ) sample_data: bool = Field( default=False, diff --git a/app/schemas/actions/update.py b/app/schemas/actions/update.py index a8b28b1..6a9141b 100644 --- a/app/schemas/actions/update.py +++ b/app/schemas/actions/update.py @@ -23,7 +23,7 @@ class UpdateAction(BaseAction): ) value: JsonValue = Field( description="替换该字段的值,必须符合字段定义的类型和取值;" - '可以写成 {"$query": "SELECT ..."},后端替换成上传数据的查询结果(对象数组,键是结果的列名)' + '可以写成 {"$query": "SELECT ..."},后端替换成上传数据的查询结果:写入单个值的字段时结果要一行一列,取那个值;写入数字、文字数组的字段时结果要只有一列;其余是对象数组,键是结果的列名' ) @model_validator(mode="after") diff --git a/app/schemas/actions/update_canvas.py b/app/schemas/actions/update_canvas.py index 5dd9900..3ad77a8 100644 --- a/app/schemas/actions/update_canvas.py +++ b/app/schemas/actions/update_canvas.py @@ -15,5 +15,6 @@ class UpdateCanvasAction(ActionModel): type: Literal["update_canvas"] = "update_canvas" properties: dict[NonBlankString, JsonValue] = Field( min_length=1, - description="要替换的 canvas 顶层字段及最终值,如 width、height、backgroundColor;未提供的字段保持不变。改变画布尺寸不会自动缩放或移动组件", + description="要替换的 canvas 顶层字段及最终值,如 width、height、theme(light 亮色 / dark 深色)、backgroundColor;未提供的字段保持不变。" + "改 theme 会让组件里跟随主题的颜色一起变;backgroundColor 写 null 表示用主题的背景色。改变画布尺寸不会自动缩放或移动组件", ) diff --git a/app/schemas/component_resource.py b/app/schemas/component_resource.py index 09082f8..aadb880 100644 --- a/app/schemas/component_resource.py +++ b/app/schemas/component_resource.py @@ -74,11 +74,14 @@ class ComponentDefinition(ExtensibleModel): def _default_problem(field, template: JsonObject) -> str | None: - """默认值只有一个来源:默认配置有该路径时取它,没有时字段必须声明 default""" + """默认值只有一个来源:默认配置有该路径时取它,没有时字段必须声明 default; + 跟随主题的颜色由前端按画布主题取,默认配置里不能写死""" try: current = lookup(template, field.segments) except ValueError: return "的路径与默认配置结构冲突" + if field.themed: + return None if current is MISSING else "跟随主题,默认配置里不能写死颜色" if current is MISSING: if field.default is None: return "在默认配置中不存在,需要声明 default" diff --git a/app/schemas/material_field.py b/app/schemas/material_field.py index 17eec46..e13167d 100644 --- a/app/schemas/material_field.py +++ b/app/schemas/material_field.py @@ -20,10 +20,11 @@ ValueType = Literal[ ItemType = Literal["string", "number", "integer", "boolean", "color", "object"] # 值类型对应的前端属性控件;array / object 没有通用控件,只能不在面板显示 +# number 可以输入小数,integer 只能输入整数 PANEL_CONTROLS: dict[str, str] = { "string": "input", "number": "number", - "integer": "number", + "integer": "integer", "boolean": "checkbox", "color": "color", "enum": "select", @@ -101,6 +102,10 @@ class MaterialField(ExtensibleModel): aliases: list[NonBlankString] = Field( default_factory=list, description="用户可能的叫法,供字段搜索使用" ) + themed: bool = Field( + default=False, + description="颜色跟随画布主题:默认配置里不写值,由前端按画布主题(亮 / 暗)取色;写入 null 恢复跟随", + ) editable: bool = Field(default=True, description="是否允许修改") panel: bool = Field(default=True, description="是否显示在前端属性面板") span: int | None = Field(default=None, description="属性面板栅格宽度,24 为整行") @@ -115,10 +120,15 @@ class MaterialField(ExtensibleModel): 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}") 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} 之一" diff --git a/app/schemas/screen.py b/app/schemas/screen.py index 02eeda6..7202b73 100644 --- a/app/schemas/screen.py +++ b/app/schemas/screen.py @@ -1,6 +1,6 @@ """大屏公共结构;组件属性、事件与数据源配置保持开放""" -from typing import Annotated, Self +from typing import Annotated, Literal, Self from pydantic import Field, model_validator @@ -13,6 +13,13 @@ PositiveNumber = Annotated[Number, Field(gt=0)] class Canvas(ExtensibleModel): width: PositiveNumber height: PositiveNumber + theme: Literal["light", "dark"] | None = Field( + default=None, + description="画布主题:组件里没单独设置的文字、坐标轴、分隔线、卡片底等颜色按主题取;旧大屏没有时前端按背景色推断", + ) + backgroundColor: str | None = Field( + default=None, description="画布背景色;为 null 时用主题的背景色" + ) class Layout(ExtensibleModel): diff --git a/app/services/screens.py b/app/services/screens.py index 8aca333..c496b08 100644 --- a/app/services/screens.py +++ b/app/services/screens.py @@ -35,7 +35,8 @@ from app.services.thumbnails import ThumbnailStorage SCRIPT_MAX_CHARS = 64 * 1024 COPY_SUFFIX = " 副本" EMPTY_PAGE = { - "canvas": {"width": 1920, "height": 1080, "backgroundColor": "#ffffff10"}, + # 不设背景色:用主题的背景色,组件颜色也跟着主题走 + "canvas": {"width": 1920, "height": 1080, "theme": "light"}, "nodes": [], "dataSource": [], } diff --git a/skills/data-chart/SKILL.md b/skills/data-chart/SKILL.md index ad2a489..a5c198f 100644 --- a/skills/data-chart/SKILL.md +++ b/skills/data-chart/SKILL.md @@ -29,7 +29,9 @@ describe_data 为准 - 多个系列(例如每月线上、线下各一根柱子)用宽表,每个系列一列:长表(每行一个类别和数值)用 PIVOT(要写 IN 列出取值)或 SUM (CASE WHEN ...) 转成宽表;反过来宽表转长表用 UNPIVOT - 日期按需要的粒度转成文字,例如 strftime (月份, '%Y-%m'),坐标轴上不出现完整的时间 -- 指标卡、单个数值不用 $query:先用 query_data 查出来,再直接写数字 +- 指标卡(stat-card)的数值、对比的变化量和迷你趋势线的数据(trend.data)也写 $query:数值和变化量的查询结果要一行一列, + 环比、同比在 SQL 里算好;趋势数据只选数值一列,按时间 ORDER BY +- 用户要分析结论时,加一个文本组件写结论,只写查询结果支持的内容 - 用了 $query 的新增组件不用写 sample_data,后端会设为 false;标题按数据的内容写 ## 数据不合适时 diff --git a/skills/screen-design/SKILL.md b/skills/screen-design/SKILL.md index a8356bd..ed2694e 100644 --- a/skills/screen-design/SKILL.md +++ b/skills/screen-design/SKILL.md @@ -10,15 +10,19 @@ description: 新建大屏,或在大屏上新增组件、整体重新布局、 1. 用户说明了图表类型时,直接用 get_component_materials 查看默认配置;只有不确定选哪种时才用 list_components 查看可用组件,按用户要展示的内容选类型;不编造目录中没有的组件类型 2. 需要的查询(get_component_materials、get_nodes 等)互不依赖,在同一轮一起调用 3. 新建大屏时,根据主题安排一个标题文本和若干图表;图表标题按要展示的内容写,不沿用物料的示例标题 +4. 销售额、订单数、转化率这类关键数字用指标卡(stat-card),一个指标一张卡,不用文本拼数字 ## 布局 - 位置和尺寸参考物料的默认尺寸,按画布大小安排;新组件不与已有组件重叠 - 画布上没有空位时,可以移动或缩小已有组件给新组件腾出位置,不改它们的内容 - 多个组件对齐排列,之间留出间距 +- 有指标卡时排在标题下方一行,同一行的指标卡等宽等高;图表放在指标卡下方 - 用户没有指定的位置、尺寸、标题和数据采用合理默认 ## 整体调整 - 重新布局:用 arrange、align、distribute、set_layout 排列已有组件,不删除、不重建;只调整位置和尺寸不需要 get_nodes,大屏摘要里已有每个组件的 layout 和画布尺寸 -- 统一风格:先用 get_nodes 读取组件配置,用 search_fields 找到颜色等字段,再逐个修改;画布背景用 update_canvas +- 换亮色 / 深色风格:用 update_canvas 改 theme(light / dark)。组件里标了 follows_theme 的颜色(文字、坐标轴、分隔线、卡片底等)没单独设置时会跟着主题变,不用逐个改;画布 backgroundColor 设过的写 null 改用主题背景,除非用户指定了背景色 +- 换主题后,用 get_nodes 检查组件上单独设置过的跟随主题字段(值不是 null),和新主题不协调的写 null 恢复跟随;系列颜色、涨跌颜色等不跟主题的颜色按用户要求修改 +- 其他统一风格(换配色、统一字号等):用 get_nodes 读取组件配置,用 search_fields 找到字段,再逐个修改 diff --git a/skills/screen-edit/SKILL.md b/skills/screen-edit/SKILL.md index 36a9bf4..2b9384a 100644 --- a/skills/screen-edit/SKILL.md +++ b/skills/screen-edit/SKILL.md @@ -18,6 +18,7 @@ description: 修改大屏上已有的组件或画布:改标题、文字、颜 一次查多个分组 3. 只用查到的字段路径,值要符合字段的类型、可选值和取值范围;查不到对应字段说明物料不支持这项修改,按无法完成处理,不自行编造路径 4. 需要基于当前值修改时(如"字号调大一点"),先用 get_nodes 读取当前配置 +5. 字段标了 follows_theme 时,没单独设置(值为 null)表示按画布主题取色;用户要"恢复默认颜色""跟主题走"时写 null ## 位置和尺寸