feat:新增物料

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