"""大屏变量与交互的引用检查:整批动作执行完后对照交付后的大屏检查一次 一批里常常先加组件、后写变量,逐个动作检查会误报,所以只在批末检查;保存大屏只做结构校验,不调用这里 数据行的位置按物料的 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