feat:图表交互
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user