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
+457
View File
@@ -0,0 +1,457 @@
"""大屏变量与交互的引用检查:整批动作执行完后对照交付后的大屏检查一次
一批里常常先加组件、后写变量,逐个动作检查会误报,所以只在批末检查;保存大屏只做结构校验,不调用这里
数据行的位置按物料的 rows_path 取,导航、指标卡的规则按物料有没有对应字段判断,不写死物料类型
模板只查字段定义里标了 template 的字段(文本内容)和数据源 params:其他字段里的 {{...}} 前端原样显示,是普通文字
数据行从哪来按物料的 data_source:可以绑数据源的物料(图表)有 dataId 时运行时用数据源的数据,
静态数据源按它的 data 检查,接口数据源检查时不知道,依赖数据行内容的检查跳过;其余组件(指标卡)一律读 rows_path
"""
from typing import Any, NamedTuple
from app.resources.material_fields import component_fields, find_material, values_at
from app.schemas.component_resource import ComponentCatalog, ComponentDefinition
from app.schemas.material_field import MISSING, MaterialField, lookup
from app.schemas.screen import (
Screen,
ScreenNode,
is_scalar,
json_equal,
json_key,
template_refs,
)
# 有这些字段的物料按导航检查:点击选项写 props.var,选项在 props.items
NAV_VAR = "props.var"
NAV_ITEMS = "props.items"
# 有这个字段的物料按数据行绑定检查:键是 props 下的字段路径,值是数据行的列名
BIND = "props.bind"
NULL = json_key(None)
class Problem(NamedTuple):
"""一个问题。key 标识是哪一个问题(问题种类加组件、字段、变量这些不随措辞变的部分),
message 给模型看,可以带上数据行的列这类会变的上下文;批末按 key 判断是不是这一批新出现的"""
key: tuple
message: str
def _problem(key: tuple, message: str) -> Problem:
"""key 的各部分来自大屏 JSON,事件类型这类没校验的值可能是数组、对象:不是字符串的部分按 json_key 转成
能放进集合的值,相等规则也和别处一致(1 与 1.0 相同、true 与 1 不同);第一项是问题种类"""
return Problem(
tuple(part if isinstance(part, str) else json_key(part) for part in key), message
)
class _Node(NamedTuple):
node: ScreenNode
material: ComponentDefinition
data: dict
definitions: list[MaterialField]
rows: list[Any] | None
known: bool
origin: str | None
def node_template_refs(definitions: list[MaterialField], node: dict) -> set[str]:
"""组件里会代入变量的模板引用:只看字段定义标了 template 的字段"""
return {
ref
for field in definitions
if field.template
for value in values_at(node, field.segments)
for ref in template_refs(value)
}
def interaction_problems(screen: Screen, catalog: ComponentCatalog) -> list[Problem]:
"""变量引用、筛选、事件、导航选项、指标卡绑定中对不上的地方,每条一个问题;没有问题时为空"""
problems: list[Problem] = []
ids = [variable.id for variable in screen.canvas.variables]
for item in sorted({item for item in ids if ids.count(item) > 1}):
problems.append(
_problem(("duplicate_var", item), f"画布 variables 里变量 ID 重复:{item}")
)
defaults = {variable.id: variable.default for variable in screen.canvas.variables}
def unknown(owner: str, where: str, refs: set[str]) -> None:
for ref in sorted(refs - defaults.keys()):
problems.append(
_problem(
("undeclared_var", owner, ref),
f"{where} 引用了未声明的变量 {ref},在同一批里用 update_canvas 在 variables 里声明"
"(整组替换,带上已有的变量)",
)
)
nodes = []
for node in screen.nodes:
try:
material = find_material(catalog, node.type)
except ValueError: # 物料不存在的组件在执行时已经报错
continue
data = node.model_dump(mode="json")
nodes.append(
_Node(
node,
material,
data,
component_fields(catalog, material),
*_rows(material, data, screen.dataSource),
)
)
written = _written_values(nodes)
for item in nodes:
node = item.node
where = f"组件 {node.id}"
fields = {field.path for field in item.definitions}
refs = node_template_refs(item.definitions, item.data)
if node.visibleWhen is not None:
refs.add(node.visibleWhen.var)
refs.update(row_filter.var for row_filter in node.filters or [])
refs.update(
event["action"]["setVar"]
for event in node.events or []
if isinstance(event.get("action"), dict)
)
if NAV_VAR in fields:
if isinstance(var := node.props.get("var"), str) and var:
refs.add(var)
else:
problems.append(
_problem(
("nav_var", node.id),
f"{where} 的 props.var 为空,在同一批里写上点击选项时要写的变量 ID(node.props.var 或 update)",
)
)
unknown(node.id, where, refs)
problems.extend(_event_problems(where, item, catalog))
problems.extend(_filter_problems(where, item, defaults, written))
if BIND in fields:
problems.extend(_bind_problems(where, item, catalog))
if NAV_ITEMS in fields:
problems.extend(_nav_problems(where, node, defaults))
for source in screen.dataSource:
unknown(
f"dataSource:{source.get('id')}",
f"数据源 {source.get('id')} 的 params",
template_refs(source.get("params")),
)
return problems
def _rows(
material: ComponentDefinition, node: dict, sources: list[dict]
) -> tuple[list[Any] | None, bool, str | None]:
"""组件运行时的数据行、检查时是否知道、来自哪里(写进提示)
物料可以绑数据源且节点有 dataId 时取数据源:静态数据源的 data 是已知的数据行,接口数据源和找不到的数据源不知道;
其余取 rows_path,物料没有声明或节点上不是数组时数据行为 None
"""
if material.data_source and (source_id := node.get("dataId")):
source = next((item for item in sources if item.get("id") == source_id), None)
if (
source is not None
and source.get("type") == "static"
and isinstance(source.get("data"), list)
):
return source["data"], True, f"绑定的数据源 {source_id} 的 data"
return None, False, None
if material.rows_path is None:
return None, True, None
try:
rows = lookup(node, material.rows_segments)
except ValueError:
rows = None
return (
(rows if isinstance(rows, list) else None),
True,
f"数据行 {material.rows_path}",
)
def _columns(rows: list[Any]) -> set[str]:
return {key for row in rows if isinstance(row, dict) for key in row}
def _written_values(nodes: list[_Node]) -> dict[str, set | None]:
"""点击和导航选项可能写进各变量的值(json_key);有一处检查时不知道(接口数据、没有数据行)的变量为 None"""
values: dict[str, set | None] = {}
def add(var: Any, keys: set | None) -> None:
if not isinstance(var, str):
return
if keys is None or values.get(var, set()) is None:
values[var] = None
else:
values.setdefault(var, set()).update(keys)
for item in nodes:
if NAV_ITEMS in {field.path for field in item.definitions}:
items = item.node.props.get("items")
add(
item.node.props.get("var"),
{json_key(option.get("value")) for option in items if isinstance(option, dict)}
if isinstance(items, list)
else None,
)
for event in item.node.events or []:
action = event.get("action")
if not isinstance(action, dict):
continue
column = action.get("fromField")
if column is None:
keys = {json_key(action.get("value"))}
elif item.known and item.rows:
keys = {json_key(row.get(column)) for row in item.rows if isinstance(row, dict)}
else:
keys = None
add(action.get("setVar"), keys)
return values
def _no_rows(where, material, origin) -> str:
"""数据行不可用:物料没有数据行或数据行为空时,筛选、取被点击行的列都没有意义"""
if material.rows_path is None:
return f"{where}({material.type})没有数据行"
return f"{where} 的{origin} 为空,在同一批里写入数据行(可以用 $query)"
def _event_problems(where, item: _Node, catalog) -> list[Problem]:
node, material, rows, known = item.node, item.material, item.rows, item.known
problems = []
allowed = [option["value"] for option in catalog.event_options(material)]
for event in node.events or []:
action = event.get("action")
if not isinstance(action, dict):
continue
kind = event.get("type")
if kind not in allowed:
problems.append(
_problem(
("event_type", node.id, kind),
f"{where} 的事件类型 {kind} 不在物料的 eventOptions 里,可用:{'、'.join(allowed)}",
)
)
column = action.get("fromField")
if column is None:
continue
if kind != "itemClick":
problems.append(
_problem(
("event_from_field_type", node.id, kind),
f"{where} 的 {kind} 事件用了 fromField,只有 itemClick 能取被点击数据行的列",
)
)
elif material.rows_path is None or (known and not rows):
problems.append(
_problem(
("event_no_rows", node.id),
f"{_no_rows(where, material, item.origin)},itemClick 的 fromField 取不到值",
)
)
elif not known:
continue
elif column not in _columns(rows):
problems.append(
_problem(
("event_from_field", node.id, column),
f"{where} 的 itemClick 事件 fromField={column} 不是数据行的列,"
f"数据行的列是 {'、'.join(sorted(_columns(rows)))}",
)
)
elif not all(
is_scalar(row.get(column)) for row in rows if isinstance(row, dict)
):
problems.append(
_problem(
("event_from_field_value", node.id, column),
f"{where} 的 itemClick 事件 fromField={column}:这一列有数组或对象,"
"变量只能存字符串、数字、布尔或 null",
)
)
return problems
def _filter_problems(where, item: _Node, defaults, written) -> list[Problem]:
node, material, rows, known = item.node, item.material, item.rows, item.known
if not node.filters:
return []
if material.rows_path is None or (known and not rows):
return [
_problem(
("filter_no_rows", node.id),
f"{_no_rows(where, material, item.origin)},不能设置 filters",
)
]
if not known:
return []
columns = _columns(rows)
problems = []
for row_filter in node.filters:
if row_filter.field not in columns:
problems.append(
_problem(
("filter_field", node.id, row_filter.field),
f"{where} 的 filters 用了列 {row_filter.field},数据行里没有这一列(列是 {'、'.join(sorted(columns))});"
"在同一批里把数据换成带这一列的(查询时不要对它加 WHERE),或改 filters 的 field",
)
)
continue
present = {
json_key(row.get(row_filter.field)) for row in rows if isinstance(row, dict)
}
default = defaults.get(row_filter.var)
if default is not None and json_key(default) not in present:
problems.append(
_problem(
("filter_default", node.id, row_filter.var, row_filter.field, default),
f"{where} 按变量 {row_filter.var} 筛选 {row_filter.field},变量默认值 {default!r} 在这一列里找不到,"
'打开大屏时组件没有数据;默认值取数据里存在的值(注意类型,"1" 与 1 不等)',
)
)
# 点击、选项写进变量的值这一列里一个都没有:多半是筛选了别的列(例如按日期列筛模型名)
values = (written.get(row_filter.var) or set()) - {NULL}
if values and not values & present:
sample = "、".join(repr(value) for _, value in sorted(values, key=repr)[:3])
problems.append(
_problem(
("filter_unreachable", node.id, row_filter.var, row_filter.field),
f"{where} 按变量 {row_filter.var} 筛选 {row_filter.field},但点击、选项写进这个变量的值(如 {sample})"
"在这一列里一个都没有,点了之后组件没有数据;筛选的列要和写变量的那一列取值相同",
)
)
return problems
def _bind_problems(where, item: _Node, catalog) -> list[Problem]:
node, material, rows, known = item.node, item.material, item.rows, item.known
bind = node.props.get("bind")
if not isinstance(bind, dict) or not bind:
# 没有 bind 时数据行不显示,筛选改变不了显示的内容
if node.filters:
return [
_problem(
("filters_without_bind", node.id),
f"{where} 写了 filters 但没有 bind,数据行不会显示出来,筛选不起作用;"
'在同一批里用 props.bind 把要显示的字段绑到数据行的列(例如 {"value": "列名"}),或去掉 filters',
)
]
return []
if known and not rows:
return [
_problem(
("bind_empty_rows", node.id),
f"{where} 写了 bind 但数据行 {material.rows_path} 为空,bind 不生效;"
"在同一批里写入数据行(可以用 $query),或去掉 bind",
)
]
fields = {field.path: field for field in component_fields(catalog, material)}
problems = [
_problem(
("bind_key", node.id, key),
f"{where} 的 bind 键 {key} 不是可绑定的字段,键写 props 下的字段路径,例如 value、compare.value",
)
for key in bind
if f"props.{key}" not in fields or key in ("rows", "bind")
]
# 列和取值要看数据行,绑定数据源时不知道
if not known or problems:
return problems
columns = _columns(rows)
for key, column in bind.items():
field = fields[f"props.{key}"]
if not isinstance(column, str) or column not in columns:
problems.append(
_problem(
("bind_column", node.id, key, column),
f"{where} 的 bind.{key} 绑定的列 {column} 不在数据行里(列是 {'、'.join(sorted(columns))})",
)
)
continue
for row in rows:
if isinstance(row, dict) and (error := field.value_error(row.get(column))):
problems.append(
_problem(
("bind_value", node.id, key, column),
f"{where} 的 bind.{key} 绑定列 {column},数据行里的值 {row.get(column)!r} 不符合字段 {field.path}({field.label}):{error}",
)
)
break
# 每个筛选值组合最多一行,否则只显示第一行
keys = [row_filter.field for row_filter in node.filters or []]
seen: set[tuple] = set()
for row in rows:
if not isinstance(row, dict):
continue
# 和筛选同一个相等规则:1 与 1.0 是同一个值
combo = tuple(json_key(row.get(k)) for k in keys)
if combo in seen:
if keys:
problems.append(
_problem(
("bind_duplicate_rows", node.id, tuple(sorted(keys))),
f"{where} 的数据行按 {'、'.join(keys)} 的每个值只能有一行,指标卡只显示第一行;"
"按筛选字段汇总好数据,或在同一批里补上能区分各行的 filters",
)
)
else:
problems.append(
_problem(
("bind_multiple_rows", node.id),
f"{where} 有多行数据但没有 filters,指标卡只显示第一行;要按变量取其中一行,"
"就在同一批里写 filters(node.filters 或 set_interaction),否则汇总成一行",
)
)
break
seen.add(combo)
return problems
def _nav_problems(where, node: ScreenNode, defaults) -> list[Problem]:
items = node.props.get("items")
if not isinstance(items, list):
return []
values = [
item.get("value", MISSING) if isinstance(item, dict) else MISSING
for item in items
]
problems = []
if any(value is MISSING or value is None or value == "" for value in values):
problems.append(
_problem(("nav_value_empty", node.id), f"{where} 的 items 每项都要有非空的 value")
)
if not all(is_scalar(value) for value in values if value is not MISSING):
problems.append(
_problem(
("nav_value_type", node.id),
f"{where} 的 items 的 value 只能是字符串、数字或布尔,不能是数组或对象",
)
)
present = [value for value in values if value is not MISSING]
if any(
json_equal(left, right)
for index, left in enumerate(present)
for right in present[index + 1 :]
):
problems.append(
_problem(("nav_value_duplicate", node.id), f"{where} 的 items 的 value 重复")
)
default = defaults.get(node.props.get("var"))
if (
default is not None
and present
and not any(json_equal(default, value) for value in present)
):
problems.append(
_problem(
("nav_default", node.id, default),
f"{where} 写的变量默认值 {default!r} 不在 items 的 value 里,打开大屏时没有选中项",
)
)
return problems