Files
my-v0-server/app/actions/interaction.py
T
2026-10-15 03:38:20 -07:00

458 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""大屏变量与交互的引用检查:整批动作执行完后对照交付后的大屏检查一次
一批里常常先加组件、后写变量,逐个动作检查会误报,所以只在批末检查;保存大屏只做结构校验,不调用这里
数据行的位置按物料的 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