feat:图表交互
This commit is contained in:
+25
-2
@@ -5,6 +5,7 @@
|
||||
|
||||
from copy import deepcopy
|
||||
|
||||
from app.actions.interaction import interaction_problems
|
||||
from app.actions.layout import EXPANDERS
|
||||
from app.actions.registry import apply_action
|
||||
from app.actions.update import fit_existing_path, same_value
|
||||
@@ -21,11 +22,17 @@ from app.schemas.screen import Screen, ScreenNode
|
||||
|
||||
|
||||
class BatchError(ValueError):
|
||||
"""整批中第 index 个动作(从 0 开始)失败,消息里带上序号和目标,模型据此修正"""
|
||||
"""整批中第 index 个动作(从 0 开始)失败,消息里带上序号和目标,模型据此修正
|
||||
|
||||
def __init__(self, index: int, action: AgentAction, reason: str):
|
||||
index 和 action 为 None 时是整批执行完后的检查失败(变量与交互的引用),不对应某一个动作
|
||||
"""
|
||||
|
||||
def __init__(self, index: int | None, action: AgentAction | None, reason: str):
|
||||
self.index = index
|
||||
self.reason = reason
|
||||
if action is None:
|
||||
super().__init__(f"整批执行后检查:{reason}")
|
||||
return
|
||||
if isinstance(action, AddAction):
|
||||
target = action.node.id
|
||||
elif ids := getattr(action, "target_ids", None):
|
||||
@@ -125,6 +132,8 @@ def run_batch(
|
||||
布局意图(arrange、align、distribute、set_layout)在这里按当时的大屏展开成 move 和 resize
|
||||
update 写入的值和字段现在的值完全一样时没有变化,跳过:不经过字段契约、也不交付;
|
||||
模型常把已经正确的值再写一遍(例如已经是 category 的轴类型),字段不在契约里时整批都会失败
|
||||
全部执行完再检查一次变量与交互的引用(interaction_problems):一批里常常先加组件后声明变量;
|
||||
只拦这一批新出现的问题,大屏原有的问题(用户在编辑器里保存的)不挡住无关的修改
|
||||
"""
|
||||
current = screen.model_copy(deep=True)
|
||||
completed: list[Action] = []
|
||||
@@ -154,4 +163,18 @@ def run_batch(
|
||||
except ValueError as error: # 含 pydantic ValidationError
|
||||
raise BatchError(index, action, str(error)) from error
|
||||
completed.append(action)
|
||||
# 按问题的 key 比较:措辞里的列清单这类上下文变了,原有的问题仍是原有的
|
||||
existing = {item.key for item in interaction_problems(screen, catalog)}
|
||||
if problems := [
|
||||
item.message
|
||||
for item in interaction_problems(current, catalog)
|
||||
if item.key not in existing
|
||||
]:
|
||||
# 这一批整个没写入:缺的部分要和原来的动作一起重新提交,分到下一批时这一批永远通不过
|
||||
raise BatchError(
|
||||
None,
|
||||
None,
|
||||
";".join(problems)
|
||||
+ "。这一批没有写入:把补上的部分和原来的动作放在同一批里重新提交",
|
||||
)
|
||||
return completed, current
|
||||
|
||||
@@ -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
|
||||
@@ -7,6 +7,7 @@ from app.actions.base import BaseActionHandler
|
||||
from app.actions.delete import DeleteHandler
|
||||
from app.actions.move import MoveHandler
|
||||
from app.actions.resize import ResizeHandler
|
||||
from app.actions.set_interaction import SetInteractionHandler
|
||||
from app.actions.update import UpdateHandler
|
||||
from app.actions.update_canvas import UpdateCanvasHandler
|
||||
from app.schemas.actions import Action
|
||||
@@ -20,6 +21,7 @@ ACTION_HANDLERS: dict[str, BaseActionHandler[Any]] = {
|
||||
"resize": ResizeHandler(),
|
||||
"update": UpdateHandler(),
|
||||
"update_canvas": UpdateCanvasHandler(),
|
||||
"set_interaction": SetInteractionHandler(),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
"""在大屏副本上设置组件的显示条件、数据筛选和事件;引用的变量是否存在由 interaction.py 在整批执行后检查"""
|
||||
|
||||
from copy import deepcopy
|
||||
|
||||
from app.actions.base import BaseActionHandler
|
||||
from app.schemas.actions import SetInteractionAction
|
||||
from app.schemas.actions.set_interaction import INTERACTION_KEYS
|
||||
from app.schemas.screen import Screen
|
||||
|
||||
|
||||
class SetInteractionHandler(BaseActionHandler[SetInteractionAction]):
|
||||
def apply(self, screen: Screen, action: SetInteractionAction) -> Screen:
|
||||
result, target = self.copy_target(screen, action.target_id)
|
||||
# 只处理写出的键,写 null 删除节点上的该字段;节点上的事件是开放的 JSON,按序列化结果存
|
||||
written = action.model_dump(mode="json", include=set(INTERACTION_KEYS))
|
||||
for key in INTERACTION_KEYS:
|
||||
if key in written:
|
||||
value = getattr(action, key)
|
||||
setattr(
|
||||
target,
|
||||
key,
|
||||
written[key] if key == "events" else deepcopy(value),
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
screen = Screen.model_validate(
|
||||
{
|
||||
"canvas": {"width": 1920, "height": 1080},
|
||||
"nodes": [
|
||||
{
|
||||
"id": "detail",
|
||||
"type": "stat-card",
|
||||
"layout": {"x": 0, "y": 0, "width": 320, "height": 150},
|
||||
"filters": [{"field": "model", "var": "model"}],
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
action = SetInteractionAction(
|
||||
target_id="detail", visibleWhen={"var": "page", "equals": "stock"}
|
||||
)
|
||||
print(
|
||||
SetInteractionHandler().apply(screen, action).nodes[0].model_dump_json(indent=2)
|
||||
)
|
||||
@@ -82,6 +82,7 @@ def _result_text(
|
||||
"""执行结果:除了动作数量,还给出执行后的画布和每个组件的布局
|
||||
|
||||
模型提交后才第一次看到结果,能自己发现位置超出画布、互相重叠、没排整齐等问题再修正;
|
||||
带显示条件的组件标出条件,不同时显示的组件重叠不算问题;
|
||||
这一批动过布局或新增的组件前面标 *,这一批删除的单独列出
|
||||
没有带来变化时也照样给出布局:只说"已经写入"会让模型以为达到了目标,可能当前大屏本来就是这样、并不符合要求
|
||||
统一说"写入草稿"、"回复 done 时交付":说"已生效"时模型回复 unable 也会说已经改了什么,
|
||||
@@ -150,15 +151,27 @@ def _result_text(
|
||||
)
|
||||
|
||||
old_layouts = {node.id: node.layout for node in before.nodes}
|
||||
# 整页切换时不同页的组件放在同一块区域,标出显示条件,模型才不会把它们当成重叠去挪开
|
||||
lines.append(
|
||||
f"执行后画布 {after.canvas.width:g}x{after.canvas.height:g},组件布局(* 为本次改动):"
|
||||
)
|
||||
if any(node.visibleWhen is not None for node in after.nodes):
|
||||
lines.append(
|
||||
"标了「仅 变量=值 时显示」的组件只在变量等于该值时出现,"
|
||||
"同一变量取不同值的组件不会同时显示,它们之间重叠不算问题"
|
||||
)
|
||||
for node in after.nodes:
|
||||
layout = node.layout
|
||||
mark = "*" if old_layouts.get(node.id) != layout else " "
|
||||
condition = (
|
||||
f" 仅 {node.visibleWhen.var}={json.dumps(node.visibleWhen.equals, ensure_ascii=False)} 时显示"
|
||||
if node.visibleWhen is not None
|
||||
else ""
|
||||
)
|
||||
lines.append(
|
||||
f"{mark} {node.id} {node.type} "
|
||||
f"x={layout.x:g} y={layout.y:g} width={layout.width:g} height={layout.height:g}"
|
||||
+ condition
|
||||
)
|
||||
if removed := [
|
||||
item for item in old_layouts if item not in {n.id for n in after.nodes}
|
||||
|
||||
+14
-6
@@ -1,15 +1,15 @@
|
||||
"""匿名接口,统一放在 /api/public 下,一眼能看出哪些不鉴权"""
|
||||
|
||||
from typing import Any
|
||||
from typing import Annotated, Any
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi import APIRouter, Query
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
from app.api.screens import Service
|
||||
from app.common.response import ApiResponse, success_response
|
||||
from app.core.exceptions import BizException
|
||||
from app.schemas.saved_screen import PublicScreen
|
||||
from app.services.screens import public_page
|
||||
from app.services.screens import LIVE_VARS_MAX_CHARS, public_page
|
||||
|
||||
router = APIRouter(prefix="/public", tags=["Public"])
|
||||
|
||||
@@ -32,11 +32,19 @@ async def get_public_screen(
|
||||
|
||||
@router.get("/screens/{share_id}/datasources/{datasource_id}")
|
||||
async def fetch_public_datasource(
|
||||
share_id: str, datasource_id: str, service: Service
|
||||
share_id: str,
|
||||
datasource_id: str,
|
||||
service: Service,
|
||||
variables: Annotated[
|
||||
str | None, Query(alias="vars", max_length=LIVE_VARS_MAX_CHARS)
|
||||
] = None,
|
||||
) -> ApiResponse[dict[str, Any] | list[Any]]:
|
||||
"""线上取数:地址取自已发布快照里的数据源配置,前端只传 ID"""
|
||||
"""线上取数:地址取自已发布快照里的数据源配置,前端只传 ID
|
||||
|
||||
vars 是 JSON 对象 {变量ID: 值},只传该数据源 params 里引用到的变量;服务端只代入 params
|
||||
"""
|
||||
return success_response(
|
||||
await service.fetch_live_datasource(share_id, datasource_id)
|
||||
await service.fetch_live_datasource(share_id, datasource_id, variables)
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ def build_checkpoint_serde() -> JsonPlusSerializer:
|
||||
("app.schemas.actions.resize", "ResizeAction"),
|
||||
("app.schemas.actions.update", "UpdateAction"),
|
||||
("app.schemas.actions.update_canvas", "UpdateCanvasAction"),
|
||||
("app.schemas.actions.set_interaction", "SetInteractionAction"),
|
||||
("app.schemas.clarification", "Clarification"),
|
||||
("app.schemas.clarification_reply", "ClarificationReply"),
|
||||
]
|
||||
|
||||
@@ -8,6 +8,7 @@ from app.resources.material_fields import identifying_texts
|
||||
from app.schemas.base import JsonObject
|
||||
from app.schemas.data_file import RequestDataFile
|
||||
from app.schemas.input_context import InputContext
|
||||
from app.schemas.screen import Canvas, ScreenNode
|
||||
|
||||
# 每张表在摘要里最多列出的列名,完整的列用 describe_data 查看
|
||||
SUMMARY_COLUMNS = 30
|
||||
@@ -27,6 +28,7 @@ def request_summary(context: InputContext) -> JsonObject:
|
||||
# 新物料不用在这里加判断。物料目录没载入时为空
|
||||
"titles": identifying_texts(catalog, node.model_dump(mode="json")),
|
||||
"layout": node.layout.model_dump(mode="json", exclude_unset=True),
|
||||
**interaction_summary(node),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -35,11 +37,7 @@ def request_summary(context: InputContext) -> JsonObject:
|
||||
"clarification_history": context.clarification_history,
|
||||
"context": context.context.model_dump(mode="json", exclude_unset=True),
|
||||
"screen_summary": {
|
||||
"canvas": context.screen.canvas.model_dump(
|
||||
mode="json",
|
||||
include={"width", "height", "theme", "backgroundColor"},
|
||||
exclude_none=True,
|
||||
),
|
||||
"canvas": canvas_summary(context.screen.canvas),
|
||||
"nodes": nodes,
|
||||
# 数据源只保留标识、名称和类型
|
||||
"data_sources": [
|
||||
@@ -57,6 +55,36 @@ def request_summary(context: InputContext) -> JsonObject:
|
||||
}
|
||||
|
||||
|
||||
def canvas_summary(canvas: Canvas) -> JsonObject:
|
||||
"""画布尺寸、主题和大屏变量;没有变量时不出现,变量的默认值为 null 也照样给出"""
|
||||
result = canvas.model_dump(
|
||||
mode="json",
|
||||
include={"width", "height", "theme", "backgroundColor"},
|
||||
exclude_none=True,
|
||||
)
|
||||
if canvas.variables:
|
||||
result["variables"] = [
|
||||
item.model_dump(mode="json") for item in canvas.variables
|
||||
]
|
||||
return result
|
||||
|
||||
|
||||
def interaction_summary(node: ScreenNode) -> JsonObject:
|
||||
"""组件的显示条件、数据筛选和写变量的事件;事件代码不给,没有时不出现"""
|
||||
result: JsonObject = {}
|
||||
if node.visibleWhen is not None:
|
||||
result["visibleWhen"] = node.visibleWhen.model_dump(mode="json")
|
||||
if node.filters:
|
||||
result["filters"] = [item.model_dump(mode="json") for item in node.filters]
|
||||
if events := [
|
||||
{"type": event.get("type"), "action": event["action"]}
|
||||
for event in node.events or []
|
||||
if "action" in event
|
||||
]:
|
||||
result["events"] = events
|
||||
return result
|
||||
|
||||
|
||||
def data_file_summary(item: RequestDataFile) -> JsonObject:
|
||||
"""每个文件只给表名、label、行数和列名;列概况由 describe_data 按需查看。问答节点也用它
|
||||
|
||||
|
||||
@@ -22,12 +22,13 @@ def get_catalog_cache() -> ComponentCatalog | None:
|
||||
# 手写的物料定义目录,只供导入脚本读取后写入 MongoDB,运行时不读取
|
||||
MATERIALS_DIR = Path(__file__).resolve().parent / "materials"
|
||||
FIELD_GROUPS_FILE = "field_groups.json"
|
||||
EVENT_GROUPS_FILE = "event_groups.json"
|
||||
|
||||
|
||||
def load_material_definitions(
|
||||
directory: str | Path = MATERIALS_DIR,
|
||||
) -> ComponentCatalog:
|
||||
"""读取物料定义目录:共享字段分组一个文件,每个物料一个文件;不合法直接报错
|
||||
"""读取物料定义目录:共享字段分组、共享事件组各一个文件,每个物料一个文件;不合法直接报错
|
||||
|
||||
version 为全部文件内容的摘要,定义有任何改动都会得到新版本
|
||||
"""
|
||||
@@ -38,13 +39,15 @@ def load_material_definitions(
|
||||
if not any(path.name == FIELD_GROUPS_FILE for path in files):
|
||||
raise FileNotFoundError(f"物料定义目录缺少 {FIELD_GROUPS_FILE}: {target}")
|
||||
digest = hashlib.sha256()
|
||||
components, field_groups = [], []
|
||||
components, field_groups, event_groups = [], [], []
|
||||
for path in files:
|
||||
# utf-8-sig 同时兼容带 BOM 和不带 BOM 的 UTF-8 文件
|
||||
text = path.read_text(encoding="utf-8-sig")
|
||||
digest.update(path.name.encode() + b"\0" + text.encode())
|
||||
if path.name == FIELD_GROUPS_FILE:
|
||||
field_groups = json.loads(text)
|
||||
elif path.name == EVENT_GROUPS_FILE:
|
||||
event_groups = json.loads(text)
|
||||
else:
|
||||
components.append(json.loads(text))
|
||||
# 文件名排序与展示顺序无关,物料按定义中的 order 排列,未写时排在最后
|
||||
@@ -57,5 +60,6 @@ def load_material_definitions(
|
||||
for item in components
|
||||
],
|
||||
"field_groups": field_groups,
|
||||
"event_groups": event_groups,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -4,6 +4,7 @@ import re
|
||||
|
||||
from pydantic import JsonValue
|
||||
|
||||
from app.schemas.actions.set_interaction import INTERACTION_KEYS
|
||||
from app.schemas.base import JsonObject
|
||||
from app.schemas.component_resource import (
|
||||
ComponentCatalog,
|
||||
@@ -17,6 +18,7 @@ from app.schemas.material_field import (
|
||||
MaterialField,
|
||||
lookup,
|
||||
)
|
||||
from app.schemas.screen import ScreenNode
|
||||
|
||||
|
||||
def resolve_field_groups(
|
||||
@@ -69,6 +71,8 @@ def frontend_catalog(catalog: ComponentCatalog) -> dict:
|
||||
for item, component in zip(data["components"], catalog.components, strict=True):
|
||||
item["setters"] = panel_setters(resolve_field_groups(catalog, component))
|
||||
item["defaults"] = structural_defaults(catalog, component)
|
||||
# 定义里引用共享事件组,前端仍拿展开后的数组
|
||||
item["eventOptions"] = catalog.event_options(component)
|
||||
return data
|
||||
|
||||
|
||||
@@ -108,9 +112,13 @@ def find_material(
|
||||
return matches[0]
|
||||
|
||||
|
||||
def material_payload(component: ComponentDefinition) -> dict:
|
||||
"""给模型看的物料配置:完整默认 schema 及使用说明,字段定义由字段查询按需提供"""
|
||||
return component.model_dump(mode="json", by_alias=True, exclude={"field_groups"})
|
||||
def material_payload(catalog: ComponentCatalog, component: ComponentDefinition) -> dict:
|
||||
"""给模型看的物料配置:完整默认 schema、可用事件及使用说明,字段定义由字段查询按需提供"""
|
||||
data = component.model_dump(
|
||||
mode="json", by_alias=True, exclude={"field_groups", "event_groups"}
|
||||
)
|
||||
data["eventOptions"] = catalog.event_options(component)
|
||||
return data
|
||||
|
||||
|
||||
def list_components(catalog: ComponentCatalog) -> dict:
|
||||
@@ -142,7 +150,9 @@ def get_materials(catalog: ComponentCatalog, component_types: list[str]) -> dict
|
||||
return {
|
||||
"catalog_version": catalog.version,
|
||||
"materials": [
|
||||
material_payload(item) for item in catalog.components if item.type in types
|
||||
material_payload(catalog, item)
|
||||
for item in catalog.components
|
||||
if item.type in types
|
||||
],
|
||||
}
|
||||
|
||||
@@ -161,6 +171,7 @@ def describe_field(
|
||||
"themed",
|
||||
"example",
|
||||
"identifies",
|
||||
"template",
|
||||
},
|
||||
exclude_none=True,
|
||||
exclude_defaults=True,
|
||||
@@ -278,6 +289,8 @@ def check_update_field(
|
||||
}
|
||||
path = action.field_path
|
||||
field = fields.get(path)
|
||||
if field is None and (top := _node_top_field(action)):
|
||||
raise ValueError(top)
|
||||
if field is None:
|
||||
message = f"{node_type} 没有字段 {path},只能修改字段定义中的路径"
|
||||
keyword = next(
|
||||
@@ -295,6 +308,31 @@ def check_update_field(
|
||||
raise ValueError(f"字段 {path}({field.label}){error}")
|
||||
|
||||
|
||||
# 组件顶层字段不能用 update 改时该用什么
|
||||
_TOP_FIELD_HINTS = {
|
||||
"layout": "位置和尺寸用 move、resize 或 set_layout",
|
||||
"name": "组件名称用 update 的 section=name、path=[]",
|
||||
}
|
||||
|
||||
|
||||
def _node_top_field(action) -> str | None:
|
||||
"""props / style 下的路径第一段是组件的顶层字段(visibleWhen、filters、layout……)时的说明,否则为 None
|
||||
|
||||
模型把 filters 写成 section=props、path=["filters"],只说"没有字段 props.filters"时它不知道该换哪个动作
|
||||
"""
|
||||
if action.section == "name" or not action.path:
|
||||
return None
|
||||
key = action.path[0]
|
||||
# props.style.color 这类多套了一层:props、style 本身能用 update 改,交给后面按字段名给建议
|
||||
if key not in ScreenNode.model_fields or key in ("props", "style"):
|
||||
return None
|
||||
if key in INTERACTION_KEYS:
|
||||
hint = "显示条件、数据筛选和事件用 set_interaction 设置"
|
||||
else:
|
||||
hint = _TOP_FIELD_HINTS.get(key, "不能用 update 修改")
|
||||
return f"{key} 是组件的顶层字段,不在 {action.section} 下;{hint}"
|
||||
|
||||
|
||||
def component_fields(
|
||||
catalog: ComponentCatalog, component: ComponentDefinition
|
||||
) -> list[MaterialField]:
|
||||
@@ -334,6 +372,7 @@ def retained_examples(
|
||||
"""组件上还和默认配置一样的示例内容(标题、系列名、数据……),按字段定义的 example 判断
|
||||
|
||||
只比较现在的值,不管是新增时没写、写了一样的值,还是之后又改回来;物料不存在时为空
|
||||
有数据行时被 props.bind 覆盖的字段不算:画面上显示的是数据行里的值,留着的示例值不会出现
|
||||
"""
|
||||
if catalog is None:
|
||||
return []
|
||||
@@ -342,9 +381,10 @@ def retained_examples(
|
||||
except ValueError:
|
||||
return []
|
||||
template = component.template()
|
||||
bound = bound_paths(component, node)
|
||||
retained = []
|
||||
for field in component_fields(catalog, component):
|
||||
if not field.example:
|
||||
if not field.example or field.path in bound:
|
||||
continue
|
||||
try:
|
||||
current = lookup(node, field.segments)
|
||||
@@ -356,6 +396,19 @@ def retained_examples(
|
||||
return retained
|
||||
|
||||
|
||||
def bound_paths(component: ComponentDefinition, node: dict) -> set[str]:
|
||||
"""props.bind 覆盖的字段路径
|
||||
|
||||
不看数据行是否已经写了:同一批里常先加卡片、再填 rows,中途提示"沿用了示例数据"会让模型去手写静态值;
|
||||
交付时 bind 配空数据行由 interaction_problems 拦下
|
||||
"""
|
||||
props = node.get("props") or {}
|
||||
bind = props.get("bind")
|
||||
if component.rows_path is None or not isinstance(bind, dict) or not bind:
|
||||
return set()
|
||||
return {f"props.{key}" for key in bind}
|
||||
|
||||
|
||||
# 摘要里每段文字的长度上限:文本组件的内容可能很长,认出组件只需要开头
|
||||
IDENTITY_MAX_CHARS = 40
|
||||
|
||||
@@ -378,12 +431,26 @@ def identifying_texts(catalog: ComponentCatalog | None, node: dict) -> list[str]
|
||||
text
|
||||
if len(text) <= IDENTITY_MAX_CHARS
|
||||
else text[:IDENTITY_MAX_CHARS] + "…"
|
||||
for value in _values_at(node, field.segments)
|
||||
if isinstance(value, str) and (text := value.strip())
|
||||
for value in _identity_values(node, field)
|
||||
if (text := value.strip())
|
||||
)
|
||||
return texts
|
||||
|
||||
|
||||
def _identity_values(node: dict, field: MaterialField) -> list[str]:
|
||||
"""识别字段的文字:字符串字段取它本身,选项数组取每项的 label"""
|
||||
values = values_at(node, field.segments)
|
||||
if field.value_type == "array":
|
||||
values = [
|
||||
item.get("label")
|
||||
for value in values
|
||||
if isinstance(value, list)
|
||||
for item in value
|
||||
if isinstance(item, dict)
|
||||
]
|
||||
return [value for value in values if isinstance(value, str)]
|
||||
|
||||
|
||||
# 识别字段(标题、名称、文本内容)在画面上是一行短文字。换行、超过这个长度或带句号分号时是一段话:
|
||||
# 线上问答之后"把结果放到大屏上",模型把"关键发现 1. …"、"注:……;……。"写进了文本组件
|
||||
LABEL_MAX_CHARS = 60
|
||||
@@ -413,19 +480,20 @@ def paragraph_texts(
|
||||
(field, value)
|
||||
for field in component_fields(catalog, component)
|
||||
if field.identifies and (paths is None or field.path in paths)
|
||||
for value in _values_at(node, field.segments)
|
||||
if isinstance(value, str) and not is_label(value)
|
||||
for value in _identity_values(node, field)
|
||||
if not is_label(value)
|
||||
]
|
||||
|
||||
|
||||
def _values_at(value: JsonValue, segments: list[str | int]) -> list[JsonValue]:
|
||||
def values_at(value: JsonValue, segments: list[str | int]) -> list[JsonValue]:
|
||||
"""路径上的所有值:路径途经数组而字段按对象写时(ECharts 的多个 title),每一项都取"""
|
||||
if not segments:
|
||||
return [value]
|
||||
head, *rest = segments
|
||||
if isinstance(value, dict) and isinstance(head, str) and head in value:
|
||||
return _values_at(value[head], rest)
|
||||
return values_at(value[head], rest)
|
||||
if isinstance(value, list) and isinstance(head, int) and 0 <= head < len(value):
|
||||
return _values_at(value[head], rest)
|
||||
return values_at(value[head], rest)
|
||||
if isinstance(value, list) and isinstance(head, str):
|
||||
return [found for item in value for found in _values_at(item, segments)]
|
||||
return [found for item in value for found in values_at(item, segments)]
|
||||
return []
|
||||
|
||||
@@ -44,44 +44,12 @@
|
||||
"chart-dataset",
|
||||
"chart-encode-xy"
|
||||
],
|
||||
"eventOptions": [
|
||||
{
|
||||
"label": "点击事件",
|
||||
"value": "click"
|
||||
},
|
||||
{
|
||||
"label": "双击事件",
|
||||
"value": "dblclick"
|
||||
},
|
||||
{
|
||||
"label": "鼠标进入事件",
|
||||
"value": "mouseenter"
|
||||
},
|
||||
{
|
||||
"label": "鼠标离开事件",
|
||||
"value": "mouseleave"
|
||||
},
|
||||
{
|
||||
"label": "鼠标按下事件",
|
||||
"value": "mousedown"
|
||||
},
|
||||
{
|
||||
"label": "鼠标抬起事件",
|
||||
"value": "mouseup"
|
||||
},
|
||||
{
|
||||
"label": "鼠标移动事件",
|
||||
"value": "mousemove"
|
||||
},
|
||||
{
|
||||
"label": "鼠标滚轮事件",
|
||||
"value": "mousewheel"
|
||||
},
|
||||
{
|
||||
"label": "生命周期事件",
|
||||
"value": "vnodeMounted"
|
||||
}
|
||||
"event_groups": [
|
||||
"dom-events",
|
||||
"chart-events"
|
||||
],
|
||||
"rows_path": "props.option.dataset.source",
|
||||
"data_source": true,
|
||||
"schema": {
|
||||
"type": "area-chart",
|
||||
"name": "面积图",
|
||||
|
||||
@@ -121,44 +121,12 @@
|
||||
"chart-dataset",
|
||||
"chart-encode-xy"
|
||||
],
|
||||
"eventOptions": [
|
||||
{
|
||||
"label": "点击事件",
|
||||
"value": "click"
|
||||
},
|
||||
{
|
||||
"label": "双击事件",
|
||||
"value": "dblclick"
|
||||
},
|
||||
{
|
||||
"label": "鼠标进入事件",
|
||||
"value": "mouseenter"
|
||||
},
|
||||
{
|
||||
"label": "鼠标离开事件",
|
||||
"value": "mouseleave"
|
||||
},
|
||||
{
|
||||
"label": "鼠标按下事件",
|
||||
"value": "mousedown"
|
||||
},
|
||||
{
|
||||
"label": "鼠标抬起事件",
|
||||
"value": "mouseup"
|
||||
},
|
||||
{
|
||||
"label": "鼠标移动事件",
|
||||
"value": "mousemove"
|
||||
},
|
||||
{
|
||||
"label": "鼠标滚轮事件",
|
||||
"value": "mousewheel"
|
||||
},
|
||||
{
|
||||
"label": "生命周期事件",
|
||||
"value": "vnodeMounted"
|
||||
}
|
||||
"event_groups": [
|
||||
"dom-events",
|
||||
"chart-events"
|
||||
],
|
||||
"rows_path": "props.option.dataset.source",
|
||||
"data_source": true,
|
||||
"schema": {
|
||||
"type": "bar-chart",
|
||||
"name": "柱状图",
|
||||
|
||||
@@ -205,44 +205,12 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"eventOptions": [
|
||||
{
|
||||
"label": "点击事件",
|
||||
"value": "click"
|
||||
},
|
||||
{
|
||||
"label": "双击事件",
|
||||
"value": "dblclick"
|
||||
},
|
||||
{
|
||||
"label": "鼠标进入事件",
|
||||
"value": "mouseenter"
|
||||
},
|
||||
{
|
||||
"label": "鼠标离开事件",
|
||||
"value": "mouseleave"
|
||||
},
|
||||
{
|
||||
"label": "鼠标按下事件",
|
||||
"value": "mousedown"
|
||||
},
|
||||
{
|
||||
"label": "鼠标抬起事件",
|
||||
"value": "mouseup"
|
||||
},
|
||||
{
|
||||
"label": "鼠标移动事件",
|
||||
"value": "mousemove"
|
||||
},
|
||||
{
|
||||
"label": "鼠标滚轮事件",
|
||||
"value": "mousewheel"
|
||||
},
|
||||
{
|
||||
"label": "生命周期事件",
|
||||
"value": "vnodeMounted"
|
||||
}
|
||||
"event_groups": [
|
||||
"dom-events",
|
||||
"chart-events"
|
||||
],
|
||||
"rows_path": "props.option.dataset.source",
|
||||
"data_source": true,
|
||||
"schema": {
|
||||
"type": "bar-line-chart",
|
||||
"name": "柱线混合图",
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
[
|
||||
{
|
||||
"key": "dom-events",
|
||||
"options": [
|
||||
{
|
||||
"label": "点击事件",
|
||||
"value": "click"
|
||||
},
|
||||
{
|
||||
"label": "双击事件",
|
||||
"value": "dblclick"
|
||||
},
|
||||
{
|
||||
"label": "鼠标进入事件",
|
||||
"value": "mouseenter"
|
||||
},
|
||||
{
|
||||
"label": "鼠标离开事件",
|
||||
"value": "mouseleave"
|
||||
},
|
||||
{
|
||||
"label": "鼠标按下事件",
|
||||
"value": "mousedown"
|
||||
},
|
||||
{
|
||||
"label": "鼠标抬起事件",
|
||||
"value": "mouseup"
|
||||
},
|
||||
{
|
||||
"label": "鼠标移动事件",
|
||||
"value": "mousemove"
|
||||
},
|
||||
{
|
||||
"label": "鼠标滚轮事件",
|
||||
"value": "mousewheel"
|
||||
},
|
||||
{
|
||||
"label": "生命周期事件",
|
||||
"value": "vnodeMounted"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "chart-events",
|
||||
"options": [
|
||||
{
|
||||
"label": "点击数据项",
|
||||
"value": "itemClick"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -71,44 +71,12 @@
|
||||
"chart-dataset",
|
||||
"chart-encode-item"
|
||||
],
|
||||
"eventOptions": [
|
||||
{
|
||||
"label": "点击事件",
|
||||
"value": "click"
|
||||
},
|
||||
{
|
||||
"label": "双击事件",
|
||||
"value": "dblclick"
|
||||
},
|
||||
{
|
||||
"label": "鼠标进入事件",
|
||||
"value": "mouseenter"
|
||||
},
|
||||
{
|
||||
"label": "鼠标离开事件",
|
||||
"value": "mouseleave"
|
||||
},
|
||||
{
|
||||
"label": "鼠标按下事件",
|
||||
"value": "mousedown"
|
||||
},
|
||||
{
|
||||
"label": "鼠标抬起事件",
|
||||
"value": "mouseup"
|
||||
},
|
||||
{
|
||||
"label": "鼠标移动事件",
|
||||
"value": "mousemove"
|
||||
},
|
||||
{
|
||||
"label": "鼠标滚轮事件",
|
||||
"value": "mousewheel"
|
||||
},
|
||||
{
|
||||
"label": "生命周期事件",
|
||||
"value": "vnodeMounted"
|
||||
}
|
||||
"event_groups": [
|
||||
"dom-events",
|
||||
"chart-events"
|
||||
],
|
||||
"rows_path": "props.option.dataset.source",
|
||||
"data_source": true,
|
||||
"schema": {
|
||||
"type": "funnel-chart",
|
||||
"name": "漏斗图",
|
||||
|
||||
@@ -100,44 +100,12 @@
|
||||
"chart-dataset",
|
||||
"chart-encode-item"
|
||||
],
|
||||
"eventOptions": [
|
||||
{
|
||||
"label": "点击事件",
|
||||
"value": "click"
|
||||
},
|
||||
{
|
||||
"label": "双击事件",
|
||||
"value": "dblclick"
|
||||
},
|
||||
{
|
||||
"label": "鼠标进入事件",
|
||||
"value": "mouseenter"
|
||||
},
|
||||
{
|
||||
"label": "鼠标离开事件",
|
||||
"value": "mouseleave"
|
||||
},
|
||||
{
|
||||
"label": "鼠标按下事件",
|
||||
"value": "mousedown"
|
||||
},
|
||||
{
|
||||
"label": "鼠标抬起事件",
|
||||
"value": "mouseup"
|
||||
},
|
||||
{
|
||||
"label": "鼠标移动事件",
|
||||
"value": "mousemove"
|
||||
},
|
||||
{
|
||||
"label": "鼠标滚轮事件",
|
||||
"value": "mousewheel"
|
||||
},
|
||||
{
|
||||
"label": "生命周期事件",
|
||||
"value": "vnodeMounted"
|
||||
}
|
||||
"event_groups": [
|
||||
"dom-events",
|
||||
"chart-events"
|
||||
],
|
||||
"rows_path": "props.option.dataset.source",
|
||||
"data_source": true,
|
||||
"schema": {
|
||||
"type": "gauge-chart",
|
||||
"name": "仪表盘",
|
||||
|
||||
@@ -205,44 +205,12 @@
|
||||
"chart-dataset",
|
||||
"chart-encode-xy"
|
||||
],
|
||||
"eventOptions": [
|
||||
{
|
||||
"label": "点击事件",
|
||||
"value": "click"
|
||||
},
|
||||
{
|
||||
"label": "双击事件",
|
||||
"value": "dblclick"
|
||||
},
|
||||
{
|
||||
"label": "鼠标进入事件",
|
||||
"value": "mouseenter"
|
||||
},
|
||||
{
|
||||
"label": "鼠标离开事件",
|
||||
"value": "mouseleave"
|
||||
},
|
||||
{
|
||||
"label": "鼠标按下事件",
|
||||
"value": "mousedown"
|
||||
},
|
||||
{
|
||||
"label": "鼠标抬起事件",
|
||||
"value": "mouseup"
|
||||
},
|
||||
{
|
||||
"label": "鼠标移动事件",
|
||||
"value": "mousemove"
|
||||
},
|
||||
{
|
||||
"label": "鼠标滚轮事件",
|
||||
"value": "mousewheel"
|
||||
},
|
||||
{
|
||||
"label": "生命周期事件",
|
||||
"value": "vnodeMounted"
|
||||
}
|
||||
"event_groups": [
|
||||
"dom-events",
|
||||
"chart-events"
|
||||
],
|
||||
"rows_path": "props.option.dataset.source",
|
||||
"data_source": true,
|
||||
"schema": {
|
||||
"type": "hbar-chart",
|
||||
"name": "条形图",
|
||||
|
||||
@@ -198,44 +198,12 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"eventOptions": [
|
||||
{
|
||||
"label": "点击事件",
|
||||
"value": "click"
|
||||
},
|
||||
{
|
||||
"label": "双击事件",
|
||||
"value": "dblclick"
|
||||
},
|
||||
{
|
||||
"label": "鼠标进入事件",
|
||||
"value": "mouseenter"
|
||||
},
|
||||
{
|
||||
"label": "鼠标离开事件",
|
||||
"value": "mouseleave"
|
||||
},
|
||||
{
|
||||
"label": "鼠标按下事件",
|
||||
"value": "mousedown"
|
||||
},
|
||||
{
|
||||
"label": "鼠标抬起事件",
|
||||
"value": "mouseup"
|
||||
},
|
||||
{
|
||||
"label": "鼠标移动事件",
|
||||
"value": "mousemove"
|
||||
},
|
||||
{
|
||||
"label": "鼠标滚轮事件",
|
||||
"value": "mousewheel"
|
||||
},
|
||||
{
|
||||
"label": "生命周期事件",
|
||||
"value": "vnodeMounted"
|
||||
}
|
||||
"event_groups": [
|
||||
"dom-events",
|
||||
"chart-events"
|
||||
],
|
||||
"rows_path": "props.option.dataset.source",
|
||||
"data_source": true,
|
||||
"schema": {
|
||||
"type": "heatmap-chart",
|
||||
"name": "热力图",
|
||||
|
||||
@@ -26,44 +26,12 @@
|
||||
"chart-dataset",
|
||||
"chart-encode-xy"
|
||||
],
|
||||
"eventOptions": [
|
||||
{
|
||||
"label": "点击事件",
|
||||
"value": "click"
|
||||
},
|
||||
{
|
||||
"label": "双击事件",
|
||||
"value": "dblclick"
|
||||
},
|
||||
{
|
||||
"label": "鼠标进入事件",
|
||||
"value": "mouseenter"
|
||||
},
|
||||
{
|
||||
"label": "鼠标离开事件",
|
||||
"value": "mouseleave"
|
||||
},
|
||||
{
|
||||
"label": "鼠标按下事件",
|
||||
"value": "mousedown"
|
||||
},
|
||||
{
|
||||
"label": "鼠标抬起事件",
|
||||
"value": "mouseup"
|
||||
},
|
||||
{
|
||||
"label": "鼠标移动事件",
|
||||
"value": "mousemove"
|
||||
},
|
||||
{
|
||||
"label": "鼠标滚轮事件",
|
||||
"value": "mousewheel"
|
||||
},
|
||||
{
|
||||
"label": "生命周期事件",
|
||||
"value": "vnodeMounted"
|
||||
}
|
||||
"event_groups": [
|
||||
"dom-events",
|
||||
"chart-events"
|
||||
],
|
||||
"rows_path": "props.option.dataset.source",
|
||||
"data_source": true,
|
||||
"schema": {
|
||||
"type": "line-chart",
|
||||
"name": "折线图",
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
{
|
||||
"order": 18,
|
||||
"key": "nav",
|
||||
"type": "nav",
|
||||
"name": "选项导航",
|
||||
"description": "一组可点击的选项(页签、模型选择器),点击某项把它的 value 写进变量,值等于当前变量的项高亮;配合组件的显示条件做整页切换,配合筛选做钻取",
|
||||
"tags": [
|
||||
"info",
|
||||
"导航",
|
||||
"页签",
|
||||
"切换",
|
||||
"选择器",
|
||||
"tabs",
|
||||
"nav"
|
||||
],
|
||||
"renderer": "nav",
|
||||
"group": "info",
|
||||
"icon": "solar:list-bold",
|
||||
"usage_notes": [
|
||||
"props.var 写变量 ID,变量先在画布 variables 里声明;点击选项写变量不需要配事件",
|
||||
"items 每项 {label, value},value 不能为空且不重复;可以写 $query,例如 SELECT DISTINCT model AS label, model AS value FROM t ORDER BY 1",
|
||||
"变量的默认值取 items 里的某个 value,打开大屏时那一项高亮"
|
||||
],
|
||||
"field_groups": [
|
||||
"node-base",
|
||||
{
|
||||
"key": "nav-content",
|
||||
"label": "选项",
|
||||
"description": "写哪个变量、有哪些选项、横排还是竖排",
|
||||
"fields": [
|
||||
{
|
||||
"path": "props.var",
|
||||
"label": "变量",
|
||||
"description": "点击选项时写入的变量 ID,值等于该变量的选项高亮",
|
||||
"value_type": "string",
|
||||
"aliases": [
|
||||
"绑定变量",
|
||||
"变量名"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "props.items",
|
||||
"label": "选项",
|
||||
"description": "选项列表,每项 {label: 显示文字, value: 写入变量的值}",
|
||||
"value_type": "array",
|
||||
"item_type": "object",
|
||||
"panel": false,
|
||||
"example": "label",
|
||||
"identifies": true,
|
||||
"aliases": [
|
||||
"选项列表",
|
||||
"页签",
|
||||
"标签页",
|
||||
"菜单项"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "props.direction",
|
||||
"label": "排列方向",
|
||||
"description": "选项横排还是竖排",
|
||||
"value_type": "enum",
|
||||
"options": [
|
||||
{
|
||||
"value": "horizontal",
|
||||
"label": "横排"
|
||||
},
|
||||
{
|
||||
"value": "vertical",
|
||||
"label": "竖排"
|
||||
}
|
||||
],
|
||||
"aliases": [
|
||||
"方向",
|
||||
"横向",
|
||||
"纵向"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "nav-style",
|
||||
"label": "样式",
|
||||
"description": "选项文字的字号、颜色、对齐和选中项的颜色",
|
||||
"fields": [
|
||||
{
|
||||
"path": "style.fontSize",
|
||||
"label": "字号",
|
||||
"description": "选项文字的大小",
|
||||
"value_type": "number",
|
||||
"minimum": 8,
|
||||
"maximum": 100,
|
||||
"unit": "px",
|
||||
"aliases": [
|
||||
"字体大小",
|
||||
"文字大小"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "style.color",
|
||||
"label": "文字颜色",
|
||||
"description": "未选中选项的文字颜色",
|
||||
"value_type": "color",
|
||||
"themed": true,
|
||||
"aliases": [
|
||||
"字体颜色",
|
||||
"颜色"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "style.activeColor",
|
||||
"label": "选中文字色",
|
||||
"description": "选中选项的文字颜色",
|
||||
"value_type": "color",
|
||||
"themed": true,
|
||||
"aliases": [
|
||||
"高亮颜色",
|
||||
"选中颜色"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "style.activeBackground",
|
||||
"label": "选中背景色",
|
||||
"description": "选中选项的背景颜色",
|
||||
"value_type": "color",
|
||||
"themed": true,
|
||||
"aliases": [
|
||||
"高亮背景",
|
||||
"选中底色"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "style.textAlign",
|
||||
"label": "对齐",
|
||||
"description": "选项在组件内的水平位置",
|
||||
"value_type": "enum",
|
||||
"options": [
|
||||
{
|
||||
"value": "left",
|
||||
"label": "左对齐"
|
||||
},
|
||||
{
|
||||
"value": "center",
|
||||
"label": "居中"
|
||||
},
|
||||
{
|
||||
"value": "right",
|
||||
"label": "右对齐"
|
||||
}
|
||||
],
|
||||
"aliases": [
|
||||
"水平对齐"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"event_groups": [
|
||||
"dom-events"
|
||||
],
|
||||
"schema": {
|
||||
"type": "nav",
|
||||
"name": "选项导航",
|
||||
"layout": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 480,
|
||||
"height": 48
|
||||
},
|
||||
"props": {
|
||||
"var": "",
|
||||
"items": [
|
||||
{
|
||||
"label": "选项一",
|
||||
"value": "a"
|
||||
},
|
||||
{
|
||||
"label": "选项二",
|
||||
"value": "b"
|
||||
},
|
||||
{
|
||||
"label": "选项三",
|
||||
"value": "c"
|
||||
}
|
||||
],
|
||||
"direction": "horizontal"
|
||||
},
|
||||
"style": {
|
||||
"fontSize": 16,
|
||||
"textAlign": "center"
|
||||
},
|
||||
"events": []
|
||||
}
|
||||
}
|
||||
@@ -127,44 +127,12 @@
|
||||
"chart-dataset",
|
||||
"chart-encode-xy"
|
||||
],
|
||||
"eventOptions": [
|
||||
{
|
||||
"label": "点击事件",
|
||||
"value": "click"
|
||||
},
|
||||
{
|
||||
"label": "双击事件",
|
||||
"value": "dblclick"
|
||||
},
|
||||
{
|
||||
"label": "鼠标进入事件",
|
||||
"value": "mouseenter"
|
||||
},
|
||||
{
|
||||
"label": "鼠标离开事件",
|
||||
"value": "mouseleave"
|
||||
},
|
||||
{
|
||||
"label": "鼠标按下事件",
|
||||
"value": "mousedown"
|
||||
},
|
||||
{
|
||||
"label": "鼠标抬起事件",
|
||||
"value": "mouseup"
|
||||
},
|
||||
{
|
||||
"label": "鼠标移动事件",
|
||||
"value": "mousemove"
|
||||
},
|
||||
{
|
||||
"label": "鼠标滚轮事件",
|
||||
"value": "mousewheel"
|
||||
},
|
||||
{
|
||||
"label": "生命周期事件",
|
||||
"value": "vnodeMounted"
|
||||
}
|
||||
"event_groups": [
|
||||
"dom-events",
|
||||
"chart-events"
|
||||
],
|
||||
"rows_path": "props.option.dataset.source",
|
||||
"data_source": true,
|
||||
"schema": {
|
||||
"type": "pictorial-bar-chart",
|
||||
"name": "象形柱图",
|
||||
|
||||
@@ -25,44 +25,12 @@
|
||||
"chart-dataset",
|
||||
"chart-encode-item"
|
||||
],
|
||||
"eventOptions": [
|
||||
{
|
||||
"label": "点击事件",
|
||||
"value": "click"
|
||||
},
|
||||
{
|
||||
"label": "双击事件",
|
||||
"value": "dblclick"
|
||||
},
|
||||
{
|
||||
"label": "鼠标进入事件",
|
||||
"value": "mouseenter"
|
||||
},
|
||||
{
|
||||
"label": "鼠标离开事件",
|
||||
"value": "mouseleave"
|
||||
},
|
||||
{
|
||||
"label": "鼠标按下事件",
|
||||
"value": "mousedown"
|
||||
},
|
||||
{
|
||||
"label": "鼠标抬起事件",
|
||||
"value": "mouseup"
|
||||
},
|
||||
{
|
||||
"label": "鼠标移动事件",
|
||||
"value": "mousemove"
|
||||
},
|
||||
{
|
||||
"label": "鼠标滚轮事件",
|
||||
"value": "mousewheel"
|
||||
},
|
||||
{
|
||||
"label": "生命周期事件",
|
||||
"value": "vnodeMounted"
|
||||
}
|
||||
"event_groups": [
|
||||
"dom-events",
|
||||
"chart-events"
|
||||
],
|
||||
"rows_path": "props.option.dataset.source",
|
||||
"data_source": true,
|
||||
"schema": {
|
||||
"type": "pie-chart",
|
||||
"name": "饼图",
|
||||
|
||||
@@ -67,44 +67,12 @@
|
||||
"chart-dataset",
|
||||
"chart-encode-item"
|
||||
],
|
||||
"eventOptions": [
|
||||
{
|
||||
"label": "点击事件",
|
||||
"value": "click"
|
||||
},
|
||||
{
|
||||
"label": "双击事件",
|
||||
"value": "dblclick"
|
||||
},
|
||||
{
|
||||
"label": "鼠标进入事件",
|
||||
"value": "mouseenter"
|
||||
},
|
||||
{
|
||||
"label": "鼠标离开事件",
|
||||
"value": "mouseleave"
|
||||
},
|
||||
{
|
||||
"label": "鼠标按下事件",
|
||||
"value": "mousedown"
|
||||
},
|
||||
{
|
||||
"label": "鼠标抬起事件",
|
||||
"value": "mouseup"
|
||||
},
|
||||
{
|
||||
"label": "鼠标移动事件",
|
||||
"value": "mousemove"
|
||||
},
|
||||
{
|
||||
"label": "鼠标滚轮事件",
|
||||
"value": "mousewheel"
|
||||
},
|
||||
{
|
||||
"label": "生命周期事件",
|
||||
"value": "vnodeMounted"
|
||||
}
|
||||
"event_groups": [
|
||||
"dom-events",
|
||||
"chart-events"
|
||||
],
|
||||
"rows_path": "props.option.dataset.source",
|
||||
"data_source": true,
|
||||
"schema": {
|
||||
"type": "progress-ring-chart",
|
||||
"name": "进度环",
|
||||
|
||||
@@ -148,44 +148,12 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"eventOptions": [
|
||||
{
|
||||
"label": "点击事件",
|
||||
"value": "click"
|
||||
},
|
||||
{
|
||||
"label": "双击事件",
|
||||
"value": "dblclick"
|
||||
},
|
||||
{
|
||||
"label": "鼠标进入事件",
|
||||
"value": "mouseenter"
|
||||
},
|
||||
{
|
||||
"label": "鼠标离开事件",
|
||||
"value": "mouseleave"
|
||||
},
|
||||
{
|
||||
"label": "鼠标按下事件",
|
||||
"value": "mousedown"
|
||||
},
|
||||
{
|
||||
"label": "鼠标抬起事件",
|
||||
"value": "mouseup"
|
||||
},
|
||||
{
|
||||
"label": "鼠标移动事件",
|
||||
"value": "mousemove"
|
||||
},
|
||||
{
|
||||
"label": "鼠标滚轮事件",
|
||||
"value": "mousewheel"
|
||||
},
|
||||
{
|
||||
"label": "生命周期事件",
|
||||
"value": "vnodeMounted"
|
||||
}
|
||||
"event_groups": [
|
||||
"dom-events",
|
||||
"chart-events"
|
||||
],
|
||||
"rows_path": "props.option.dataset.source",
|
||||
"data_source": true,
|
||||
"schema": {
|
||||
"type": "radar-chart",
|
||||
"name": "雷达图",
|
||||
|
||||
@@ -45,44 +45,12 @@
|
||||
"chart-dataset",
|
||||
"chart-encode-item"
|
||||
],
|
||||
"eventOptions": [
|
||||
{
|
||||
"label": "点击事件",
|
||||
"value": "click"
|
||||
},
|
||||
{
|
||||
"label": "双击事件",
|
||||
"value": "dblclick"
|
||||
},
|
||||
{
|
||||
"label": "鼠标进入事件",
|
||||
"value": "mouseenter"
|
||||
},
|
||||
{
|
||||
"label": "鼠标离开事件",
|
||||
"value": "mouseleave"
|
||||
},
|
||||
{
|
||||
"label": "鼠标按下事件",
|
||||
"value": "mousedown"
|
||||
},
|
||||
{
|
||||
"label": "鼠标抬起事件",
|
||||
"value": "mouseup"
|
||||
},
|
||||
{
|
||||
"label": "鼠标移动事件",
|
||||
"value": "mousemove"
|
||||
},
|
||||
{
|
||||
"label": "鼠标滚轮事件",
|
||||
"value": "mousewheel"
|
||||
},
|
||||
{
|
||||
"label": "生命周期事件",
|
||||
"value": "vnodeMounted"
|
||||
}
|
||||
"event_groups": [
|
||||
"dom-events",
|
||||
"chart-events"
|
||||
],
|
||||
"rows_path": "props.option.dataset.source",
|
||||
"data_source": true,
|
||||
"schema": {
|
||||
"type": "ring-chart",
|
||||
"name": "环形图",
|
||||
|
||||
@@ -66,44 +66,12 @@
|
||||
"chart-dataset",
|
||||
"chart-encode-item"
|
||||
],
|
||||
"eventOptions": [
|
||||
{
|
||||
"label": "点击事件",
|
||||
"value": "click"
|
||||
},
|
||||
{
|
||||
"label": "双击事件",
|
||||
"value": "dblclick"
|
||||
},
|
||||
{
|
||||
"label": "鼠标进入事件",
|
||||
"value": "mouseenter"
|
||||
},
|
||||
{
|
||||
"label": "鼠标离开事件",
|
||||
"value": "mouseleave"
|
||||
},
|
||||
{
|
||||
"label": "鼠标按下事件",
|
||||
"value": "mousedown"
|
||||
},
|
||||
{
|
||||
"label": "鼠标抬起事件",
|
||||
"value": "mouseup"
|
||||
},
|
||||
{
|
||||
"label": "鼠标移动事件",
|
||||
"value": "mousemove"
|
||||
},
|
||||
{
|
||||
"label": "鼠标滚轮事件",
|
||||
"value": "mousewheel"
|
||||
},
|
||||
{
|
||||
"label": "生命周期事件",
|
||||
"value": "vnodeMounted"
|
||||
}
|
||||
"event_groups": [
|
||||
"dom-events",
|
||||
"chart-events"
|
||||
],
|
||||
"rows_path": "props.option.dataset.source",
|
||||
"data_source": true,
|
||||
"schema": {
|
||||
"type": "rose-chart",
|
||||
"name": "玫瑰图",
|
||||
|
||||
@@ -70,44 +70,12 @@
|
||||
"chart-dataset",
|
||||
"chart-encode-xy"
|
||||
],
|
||||
"eventOptions": [
|
||||
{
|
||||
"label": "点击事件",
|
||||
"value": "click"
|
||||
},
|
||||
{
|
||||
"label": "双击事件",
|
||||
"value": "dblclick"
|
||||
},
|
||||
{
|
||||
"label": "鼠标进入事件",
|
||||
"value": "mouseenter"
|
||||
},
|
||||
{
|
||||
"label": "鼠标离开事件",
|
||||
"value": "mouseleave"
|
||||
},
|
||||
{
|
||||
"label": "鼠标按下事件",
|
||||
"value": "mousedown"
|
||||
},
|
||||
{
|
||||
"label": "鼠标抬起事件",
|
||||
"value": "mouseup"
|
||||
},
|
||||
{
|
||||
"label": "鼠标移动事件",
|
||||
"value": "mousemove"
|
||||
},
|
||||
{
|
||||
"label": "鼠标滚轮事件",
|
||||
"value": "mousewheel"
|
||||
},
|
||||
{
|
||||
"label": "生命周期事件",
|
||||
"value": "vnodeMounted"
|
||||
}
|
||||
"event_groups": [
|
||||
"dom-events",
|
||||
"chart-events"
|
||||
],
|
||||
"rows_path": "props.option.dataset.source",
|
||||
"data_source": true,
|
||||
"schema": {
|
||||
"type": "scatter-chart",
|
||||
"name": "散点图",
|
||||
|
||||
@@ -151,44 +151,12 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"eventOptions": [
|
||||
{
|
||||
"label": "点击事件",
|
||||
"value": "click"
|
||||
},
|
||||
{
|
||||
"label": "双击事件",
|
||||
"value": "dblclick"
|
||||
},
|
||||
{
|
||||
"label": "鼠标进入事件",
|
||||
"value": "mouseenter"
|
||||
},
|
||||
{
|
||||
"label": "鼠标离开事件",
|
||||
"value": "mouseleave"
|
||||
},
|
||||
{
|
||||
"label": "鼠标按下事件",
|
||||
"value": "mousedown"
|
||||
},
|
||||
{
|
||||
"label": "鼠标抬起事件",
|
||||
"value": "mouseup"
|
||||
},
|
||||
{
|
||||
"label": "鼠标移动事件",
|
||||
"value": "mousemove"
|
||||
},
|
||||
{
|
||||
"label": "鼠标滚轮事件",
|
||||
"value": "mousewheel"
|
||||
},
|
||||
{
|
||||
"label": "生命周期事件",
|
||||
"value": "vnodeMounted"
|
||||
}
|
||||
"event_groups": [
|
||||
"dom-events",
|
||||
"chart-events"
|
||||
],
|
||||
"rows_path": "props.option.dataset.source",
|
||||
"data_source": true,
|
||||
"schema": {
|
||||
"type": "stacked-bar-chart",
|
||||
"name": "堆叠柱状图",
|
||||
|
||||
@@ -23,7 +23,9 @@
|
||||
"成本、退款率、投诉量等越低越好的指标把 downIsGood 设为 true,涨跌颜色随之反过来",
|
||||
"数值、对比的变化量和趋势数据可以写 $query:数值和变化量的结果要一行一列,趋势数据只选一列",
|
||||
"迷你趋势线的 trend.data 是按时间先后排列的一组数字;trend.show 设为 true 才显示",
|
||||
"前缀、后缀、说明为空字符串时不显示;对比用 show 控制是否显示"
|
||||
"前缀、后缀、说明为空字符串时不显示;对比用 show 控制是否显示",
|
||||
"明细指标卡:rows 写按筛选字段汇总好的数据行(可写 $query),bind 把列绑定到字段,例如 {\"value\": \"calls\", \"compare.value\": \"calls_mom\", \"trend.data\": \"trend\"};先按 filters 筛选,取第一行按 bind 覆盖对应字段",
|
||||
"rows 里每个筛选值只对应一行;趋势用数组列,例如 DuckDB 的 list(calls ORDER BY day) AS trend"
|
||||
],
|
||||
"field_groups": [
|
||||
"node-base",
|
||||
@@ -488,46 +490,42 @@
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "stat-rows",
|
||||
"label": "数据行",
|
||||
"description": "按数据行显示指标:rows 是数据行,bind 把列绑定到指标卡字段,配合 filters 按变量取对应的一行",
|
||||
"fields": [
|
||||
{
|
||||
"path": "props.rows",
|
||||
"label": "数据行",
|
||||
"description": "对象数组,每行一组指标值,按 filters 的字段汇总好,每个筛选值一行;可以写 $query",
|
||||
"value_type": "array",
|
||||
"item_type": "object",
|
||||
"panel": false,
|
||||
"aliases": [
|
||||
"明细数据",
|
||||
"行数据"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "props.bind",
|
||||
"label": "列绑定",
|
||||
"description": "键是 props 下的字段路径(value、compare.value、compare2.value、trend.data 等),值是数据行的列名;有数据行时取筛选后的第一行,按它覆盖这些字段",
|
||||
"value_type": "object",
|
||||
"panel": false,
|
||||
"aliases": [
|
||||
"绑定",
|
||||
"字段绑定"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"eventOptions": [
|
||||
{
|
||||
"label": "点击事件",
|
||||
"value": "click"
|
||||
},
|
||||
{
|
||||
"label": "双击事件",
|
||||
"value": "dblclick"
|
||||
},
|
||||
{
|
||||
"label": "鼠标进入事件",
|
||||
"value": "mouseenter"
|
||||
},
|
||||
{
|
||||
"label": "鼠标离开事件",
|
||||
"value": "mouseleave"
|
||||
},
|
||||
{
|
||||
"label": "鼠标按下事件",
|
||||
"value": "mousedown"
|
||||
},
|
||||
{
|
||||
"label": "鼠标抬起事件",
|
||||
"value": "mouseup"
|
||||
},
|
||||
{
|
||||
"label": "鼠标移动事件",
|
||||
"value": "mousemove"
|
||||
},
|
||||
{
|
||||
"label": "鼠标滚轮事件",
|
||||
"value": "mousewheel"
|
||||
},
|
||||
{
|
||||
"label": "生命周期事件",
|
||||
"value": "vnodeMounted"
|
||||
}
|
||||
"event_groups": [
|
||||
"dom-events"
|
||||
],
|
||||
"rows_path": "props.rows",
|
||||
"schema": {
|
||||
"type": "stat-card",
|
||||
"name": "指标卡",
|
||||
@@ -565,7 +563,9 @@
|
||||
"type": "area",
|
||||
"position": "right",
|
||||
"data": []
|
||||
}
|
||||
},
|
||||
"rows": [],
|
||||
"bind": {}
|
||||
},
|
||||
"style": {
|
||||
"textAlign": "left",
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
{
|
||||
"path": "props.content",
|
||||
"label": "内容",
|
||||
"description": "文本组件显示的文字",
|
||||
"description": "文本组件显示的文字;可以写 {{变量ID}} 引用大屏变量,例如“{{model}} 调用明细”,变量变化时文字跟着变",
|
||||
"value_type": "string",
|
||||
"aliases": [
|
||||
"文字",
|
||||
@@ -34,7 +34,8 @@
|
||||
"名字"
|
||||
],
|
||||
"example": "label",
|
||||
"identifies": true
|
||||
"identifies": true,
|
||||
"template": true
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -147,43 +148,8 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"eventOptions": [
|
||||
{
|
||||
"label": "点击事件",
|
||||
"value": "click"
|
||||
},
|
||||
{
|
||||
"label": "双击事件",
|
||||
"value": "dblclick"
|
||||
},
|
||||
{
|
||||
"label": "鼠标进入事件",
|
||||
"value": "mouseenter"
|
||||
},
|
||||
{
|
||||
"label": "鼠标离开事件",
|
||||
"value": "mouseleave"
|
||||
},
|
||||
{
|
||||
"label": "鼠标按下事件",
|
||||
"value": "mousedown"
|
||||
},
|
||||
{
|
||||
"label": "鼠标抬起事件",
|
||||
"value": "mouseup"
|
||||
},
|
||||
{
|
||||
"label": "鼠标移动事件",
|
||||
"value": "mousemove"
|
||||
},
|
||||
{
|
||||
"label": "鼠标滚轮事件",
|
||||
"value": "mousewheel"
|
||||
},
|
||||
{
|
||||
"label": "生命周期事件",
|
||||
"value": "vnodeMounted"
|
||||
}
|
||||
"event_groups": [
|
||||
"dom-events"
|
||||
],
|
||||
"schema": {
|
||||
"type": "text",
|
||||
@@ -202,13 +168,6 @@
|
||||
"textAlign": "center",
|
||||
"alignItems": "center"
|
||||
},
|
||||
"events": [
|
||||
{
|
||||
"type": "click",
|
||||
"title": "点击事件",
|
||||
"name": "fn",
|
||||
"code": "console.log(123)"
|
||||
}
|
||||
]
|
||||
"events": []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ from .delete import DeleteAction
|
||||
from .layout import AlignAction, ArrangeAction, DistributeAction, SetLayoutAction
|
||||
from .move import MoveAction
|
||||
from .resize import ResizeAction
|
||||
from .set_interaction import SetInteractionAction
|
||||
from .update import UpdateAction
|
||||
from .update_canvas import UpdateCanvasAction
|
||||
|
||||
@@ -20,7 +21,8 @@ Action = Annotated[
|
||||
| MoveAction
|
||||
| ResizeAction
|
||||
| UpdateAction
|
||||
| UpdateCanvasAction,
|
||||
| UpdateCanvasAction
|
||||
| SetInteractionAction,
|
||||
Field(discriminator="type"),
|
||||
]
|
||||
|
||||
@@ -33,6 +35,7 @@ AgentAction = Annotated[
|
||||
| ResizeAction
|
||||
| UpdateAction
|
||||
| UpdateCanvasAction
|
||||
| SetInteractionAction
|
||||
| ArrangeAction
|
||||
| AlignAction
|
||||
| DistributeAction
|
||||
@@ -53,6 +56,7 @@ __all__ = [
|
||||
"DeleteAction",
|
||||
"MoveAction",
|
||||
"ResizeAction",
|
||||
"SetInteractionAction",
|
||||
"UpdateAction",
|
||||
"UpdateCanvasAction",
|
||||
]
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
"""新增组件动作"""
|
||||
|
||||
from typing import Literal
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import Field
|
||||
from pydantic import Field, model_validator
|
||||
|
||||
from app.schemas.actions.base import ActionModel
|
||||
from app.schemas.screen import ScreenNode
|
||||
from app.schemas.screen import ScreenNode, json_equal
|
||||
|
||||
|
||||
class AddAction(ActionModel):
|
||||
@@ -14,11 +14,43 @@ class AddAction(ActionModel):
|
||||
type: Literal["add"] = "add"
|
||||
node: ScreenNode = Field(
|
||||
description="新组件。id、type、layout 必填,其余只写需要覆盖物料默认配置的字段;"
|
||||
"显示条件、数据筛选、事件(visibleWhen、filters、events)也写在 node 里,不写在动作这一层,也可以之后用 set_interaction 设置;"
|
||||
"后端按 type 找到物料,用默认配置补全:对象逐层覆盖,数组整体替换,null 明确覆盖。"
|
||||
"写出的字段和 update 一样按字段定义检查类型和取值(跟随主题的颜色可以写 null)。"
|
||||
"id 不能与当前大屏上的组件重复(包括之前提交里新增的),先 delete 的 ID 可以再用;type 用 list_components 返回的 type。"
|
||||
'任何字段值都可以写成 {"$query": "SELECT ..."},后端替换成上传数据的查询结果:写入单个值的字段时结果要一行一列,取那个值;写入数字、文字数组的字段时结果要只有一列;其余是对象数组,键是结果的列名'
|
||||
)
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def node_fields_into_node(cls, data: Any) -> Any:
|
||||
"""动作这一层写了节点字段时挪进 node:模型常照 set_interaction 的样子把 visibleWhen、filters、events
|
||||
写在 node 旁边,严格校验只报"Extra inputs are not permitted",它看不懂,反复提交直到放弃。
|
||||
按 ScreenNode 的字段判断,不列举;交付的动作里这些字段只在 node 里,前端协议不变;其他未知键照样报错
|
||||
"""
|
||||
if not isinstance(data, dict) or not isinstance(data.get("node"), dict):
|
||||
return data
|
||||
misplaced = [
|
||||
key
|
||||
for key in data
|
||||
if key in ScreenNode.model_fields and key not in cls.model_fields
|
||||
]
|
||||
if not misplaced:
|
||||
return data
|
||||
data = dict(data)
|
||||
node = dict(data["node"])
|
||||
for key in misplaced:
|
||||
value = data.pop(key)
|
||||
# 按 JSON 比较:Python 的 != 把 true 和 1 当成一样,会让外层的值静默覆盖
|
||||
if key not in node:
|
||||
node[key] = value
|
||||
elif not json_equal(node[key], value):
|
||||
raise ValueError(
|
||||
f"{key} 是组件的字段,同时写在 node 里和动作这一层且值不同;只写在 node 里"
|
||||
)
|
||||
data["node"] = node
|
||||
return data
|
||||
|
||||
sample_data: bool = Field(
|
||||
default=False,
|
||||
description="true 表示组件展示的是自己编写的数据(不是来自上传的文件或大屏上已有的组件),交付时标为示例数据。"
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
"""设置组件的交互:显示条件、数据筛选、事件;变量本身在画布上,用 update_canvas 写 variables"""
|
||||
|
||||
from typing import Literal, Self
|
||||
|
||||
from pydantic import Field, model_serializer, model_validator
|
||||
|
||||
from app.schemas.base import ExtensibleModel, NonBlankString
|
||||
from app.schemas.screen import EventAction, RowFilter, VisibleWhen
|
||||
|
||||
from .base import BaseAction
|
||||
|
||||
# 这三个键只处理写出的:没写保持原样,写 null 删除节点上的该字段
|
||||
INTERACTION_KEYS = ("visibleWhen", "filters", "events")
|
||||
|
||||
|
||||
class NodeEvent(ExtensibleModel):
|
||||
"""组件事件;写变量的事件只写 type 和 action,保留已有的代码事件时把它原样带上"""
|
||||
|
||||
type: NonBlankString = Field(
|
||||
description="事件类型,取物料 eventOptions 里的值:click 点击组件;itemClick 点击图表的某个数据项(只有图表有)"
|
||||
)
|
||||
action: EventAction | None = Field(
|
||||
default=None, description="写变量;有 action 时不执行事件代码"
|
||||
)
|
||||
|
||||
@model_serializer(mode="wrap")
|
||||
def without_empty_action(self, handler):
|
||||
# 代码事件没有 action,不补出 null
|
||||
data = handler(self)
|
||||
if self.action is None:
|
||||
data.pop("action", None)
|
||||
return data
|
||||
|
||||
|
||||
class SetInteractionAction(BaseAction):
|
||||
"""设置组件的显示条件、数据筛选和事件。只改写出的键,整体替换该键;写 null 删除该键;至少写一个键
|
||||
|
||||
变量要先在画布 variables 里声明(update_canvas),同一批里先后顺序不限,整批执行完再检查引用
|
||||
"""
|
||||
|
||||
type: Literal["set_interaction"] = "set_interaction"
|
||||
visibleWhen: VisibleWhen | None = Field(
|
||||
default=None,
|
||||
description="变量等于 equals 时才显示该组件,用于整页切换;同一变量取不同值的组件不会同时出现,可以放在同一位置",
|
||||
)
|
||||
filters: list[RowFilter] | None = Field(
|
||||
default=None,
|
||||
description="按变量筛选组件的数据行,多条为且,变量为 null 时不过滤;只用于有数据行的组件(图表的 dataset.source、指标卡的 rows),"
|
||||
"field 必须是数据行里的列。被筛选的图表的数据要带上这一列,查询时不要对它加 WHERE",
|
||||
)
|
||||
events: list[NodeEvent] | None = Field(
|
||||
default=None,
|
||||
description="组件的全部事件,整体替换:组件上已有的代码事件要保留时原样带上。"
|
||||
'例如 [{"type": "click", "action": {"setVar": "page", "value": "stock"}}]、'
|
||||
'[{"type": "itemClick", "action": {"setVar": "model", "fromField": "model"}}]',
|
||||
)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def some_key(self) -> Self:
|
||||
if not self.model_fields_set & set(INTERACTION_KEYS):
|
||||
raise ValueError(f"至少写出 {'、'.join(INTERACTION_KEYS)} 中的一个")
|
||||
return self
|
||||
|
||||
@model_serializer(mode="wrap")
|
||||
def written_keys_only(self, handler):
|
||||
# 交给前端时 null 表示删除,没写的键不能序列化成 null;检查点按序列化结果重建,也靠它保留"写了哪些键"
|
||||
data = handler(self)
|
||||
for key in INTERACTION_KEYS:
|
||||
if key not in self.model_fields_set:
|
||||
data.pop(key, None)
|
||||
return data
|
||||
@@ -16,5 +16,7 @@ class UpdateCanvasAction(ActionModel):
|
||||
properties: dict[NonBlankString, JsonValue] = Field(
|
||||
min_length=1,
|
||||
description="要替换的 canvas 顶层字段及最终值,如 width、height、theme(light 亮色 / dark 深色)、backgroundColor;未提供的字段保持不变。"
|
||||
"改 theme 会让组件里跟随主题的颜色一起变;backgroundColor 写 null 表示用主题的背景色。改变画布尺寸不会自动缩放或移动组件",
|
||||
"改 theme 会让组件里跟随主题的颜色一起变;backgroundColor 写 null 表示用主题的背景色。改变画布尺寸不会自动缩放或移动组件。"
|
||||
'variables 是大屏变量的完整列表(整组替换,保留已有的),例如 [{"id": "page", "name": "当前页", "default": "sales"}];'
|
||||
"组件怎么读写变量用 set_interaction 设置",
|
||||
)
|
||||
|
||||
@@ -27,6 +27,18 @@ class MaterialTemplate(ExtensibleModel):
|
||||
events: list[JsonObject] = Field(default_factory=list)
|
||||
|
||||
|
||||
class EventOption(ExtensibleModel):
|
||||
label: NonBlankString
|
||||
value: NonBlankString = Field(description="事件类型,即节点 events[].type")
|
||||
|
||||
|
||||
class EventGroup(ExtensibleModel):
|
||||
"""多个物料共用的一组事件,物料按 key 引用;前端接口展开成 eventOptions 数组"""
|
||||
|
||||
key: NonBlankString
|
||||
options: list[EventOption]
|
||||
|
||||
|
||||
class ComponentDefinition(ExtensibleModel):
|
||||
model_config = ConfigDict(serialize_by_alias=True)
|
||||
key: NonBlankString = Field(description="目录中的唯一编号,不是 Screen 节点 ID")
|
||||
@@ -49,7 +61,20 @@ class ComponentDefinition(ExtensibleModel):
|
||||
description="字段分组:字符串引用目录的共享分组,对象为本物料独有的分组;"
|
||||
"前端属性面板的 setters 由它派生,不单独保存",
|
||||
)
|
||||
eventOptions: list[JsonObject] = Field(default_factory=list)
|
||||
event_groups: list[NonBlankString] = Field(
|
||||
default_factory=list,
|
||||
description="引用目录的共享事件组,按顺序展开成前端的 eventOptions",
|
||||
)
|
||||
rows_path: NonBlankString | None = Field(
|
||||
default=None,
|
||||
description="组件数据行(对象数组)的点路径,例如 props.option.dataset.source;"
|
||||
"节点的 filters 作用在这里,后端检查按它取列;前端各物料自己知道数据行在哪",
|
||||
)
|
||||
data_source: bool = Field(
|
||||
default=False,
|
||||
description="组件可以绑定数据源(节点 dataId):绑定后运行时的数据行换成数据源的数据,不再读 rows_path;"
|
||||
"前端按它决定是否显示数据源绑定。需要 rows_path",
|
||||
)
|
||||
usage_notes: list[NonBlankString] = Field(
|
||||
default_factory=list, description="配置、数据绑定及使用限制说明"
|
||||
)
|
||||
@@ -67,8 +92,23 @@ class ComponentDefinition(ExtensibleModel):
|
||||
raise ValueError("目录 type 必须与 schema.type 一致")
|
||||
if "id" in self.schema_.model_extra:
|
||||
raise ValueError("默认物料配置不能包含实例 ID")
|
||||
if self.rows_path is not None:
|
||||
try:
|
||||
rows = lookup(self.template(), self.rows_segments)
|
||||
except ValueError:
|
||||
rows = MISSING
|
||||
if not isinstance(rows, list):
|
||||
raise ValueError(f"rows_path {self.rows_path} 在默认配置里不是数组")
|
||||
if self.data_source and self.rows_path is None:
|
||||
raise ValueError("data_source 的物料要声明 rows_path:绑定的数据源替换的是这里的数据行")
|
||||
return self
|
||||
|
||||
@property
|
||||
def rows_segments(self) -> list[str | int]:
|
||||
return [
|
||||
int(part) if part.isdigit() else part for part in self.rows_path.split(".")
|
||||
]
|
||||
|
||||
|
||||
def _default_problem(field, template: JsonObject) -> str | None:
|
||||
"""默认值只有一个来源:默认配置有该路径时取它,没有时字段必须声明 default;
|
||||
@@ -100,6 +140,18 @@ class ComponentCatalog(ExtensibleModel):
|
||||
field_groups: list[FieldGroup] = Field(
|
||||
default_factory=list, description="多个物料共用的字段分组,物料按 key 引用"
|
||||
)
|
||||
event_groups: list[EventGroup] = Field(
|
||||
default_factory=list, description="多个物料共用的事件组,物料按 key 引用"
|
||||
)
|
||||
|
||||
def event_options(self, component: ComponentDefinition) -> list[JsonObject]:
|
||||
"""物料引用的事件组按顺序展开,即前端的 eventOptions"""
|
||||
groups = {group.key: group for group in self.event_groups}
|
||||
return [
|
||||
option.model_dump(mode="json")
|
||||
for key in component.event_groups
|
||||
for option in groups[key].options
|
||||
]
|
||||
|
||||
@model_validator(mode="after")
|
||||
def unique_keys(self) -> Self:
|
||||
@@ -109,6 +161,9 @@ class ComponentCatalog(ExtensibleModel):
|
||||
shared = [group.key for group in self.field_groups]
|
||||
if len(shared) != len(set(shared)):
|
||||
raise ValueError("共享字段分组 key 不能重复")
|
||||
event_groups = [group.key for group in self.event_groups]
|
||||
if len(event_groups) != len(set(event_groups)):
|
||||
raise ValueError("共享事件组 key 不能重复")
|
||||
for component in self.components:
|
||||
groups = [
|
||||
group.key if isinstance(group, FieldGroup) else group
|
||||
@@ -132,6 +187,13 @@ class ComponentCatalog(ExtensibleModel):
|
||||
raise ValueError(
|
||||
f"物料 {component.key} 的独有分组与共享分组重名: {shadowed}"
|
||||
)
|
||||
if missing := [
|
||||
key for key in component.event_groups if key not in event_groups
|
||||
]:
|
||||
raise ValueError(f"物料 {component.key} 引用了不存在的事件组: {missing}")
|
||||
events = [option["value"] for option in self.event_options(component)]
|
||||
if len(events) != len(set(events)):
|
||||
raise ValueError(f"物料 {component.key} 的事件类型重复")
|
||||
by_key = {group.key: group for group in self.field_groups}
|
||||
fields = [
|
||||
field
|
||||
|
||||
@@ -113,7 +113,13 @@ class MaterialField(ExtensibleModel):
|
||||
)
|
||||
identifies: bool = Field(
|
||||
default=False,
|
||||
description="这个字段的文字是组件在画面上的名字或内容(图表标题、指标名、文本),大屏摘要用它帮助认出用户说的组件",
|
||||
description="这个字段的文字是组件在画面上的名字或内容(图表标题、指标名、文本),大屏摘要用它帮助认出用户说的组件;"
|
||||
"对象数组字段(导航的选项)取每项的 label",
|
||||
)
|
||||
template: bool = Field(
|
||||
default=False,
|
||||
description="前端渲染时把这个字段里的 {{变量ID}} 换成大屏变量的值(文本内容);其他字段里的 {{...}} 原样显示,"
|
||||
"批末检查只查这些字段和数据源 params 里引用的变量",
|
||||
)
|
||||
editable: bool = Field(default=True, description="是否允许修改")
|
||||
panel: bool = Field(default=True, description="是否显示在前端属性面板")
|
||||
@@ -133,8 +139,15 @@ class MaterialField(ExtensibleModel):
|
||||
raise ValueError(f"只有 color 字段可以跟随主题,且不能声明 default: {self.path}")
|
||||
if self.example and (self.themed or self.default is not None):
|
||||
raise ValueError(f"示例内容要写在默认配置里,不能跟随主题或声明 default: {self.path}")
|
||||
if self.identifies and self.value_type != "string":
|
||||
raise ValueError(f"只有 string 字段可以用来识别组件: {self.path}")
|
||||
if self.template and self.value_type != "string":
|
||||
raise ValueError(f"只有 string 字段可以写模板: {self.path}")
|
||||
if self.identifies and not (
|
||||
self.value_type == "string"
|
||||
or (self.value_type == "array" and self.item_type == "object")
|
||||
):
|
||||
raise ValueError(
|
||||
f"只有 string 字段或选项数组(取每项的 label)可以用来识别组件: {self.path}"
|
||||
)
|
||||
return self
|
||||
|
||||
def value_error(self, value: JsonValue) -> str | None:
|
||||
|
||||
+164
-2
@@ -1,13 +1,160 @@
|
||||
"""大屏公共结构;组件属性、事件与数据源配置保持开放"""
|
||||
|
||||
from typing import Annotated, Literal, Self
|
||||
import json
|
||||
import re
|
||||
from typing import Annotated, Any, Literal, Self
|
||||
|
||||
from pydantic import Field, model_validator
|
||||
from pydantic import (
|
||||
BaseModel,
|
||||
ConfigDict,
|
||||
Field,
|
||||
field_validator,
|
||||
model_serializer,
|
||||
model_validator,
|
||||
)
|
||||
|
||||
from .base import ExtensibleModel, JsonObject, NonBlankString
|
||||
|
||||
Number = int | float
|
||||
PositiveNumber = Annotated[Number, Field(gt=0)]
|
||||
# 变量的值:JSON 标量;相等按 JSON 严格比较,"1" 与 1 不等
|
||||
VarValue = str | int | float | bool | None
|
||||
|
||||
|
||||
# 模板占位符 {{变量ID}}:括号内可有空格,ID 由字母、数字、_、- 组成;和前端 fillTemplate 同一条规则
|
||||
# 用显式字符集而不是 \w:Python 的 \w 匹配中文,JS 的不匹配
|
||||
TEMPLATE = re.compile(r"\{\{\s*([A-Za-z0-9_-]+)\s*\}\}")
|
||||
|
||||
|
||||
def template_refs(value: Any) -> set[str]:
|
||||
"""值里(递归到字符串)引用到的变量 ID"""
|
||||
if isinstance(value, str):
|
||||
return set(TEMPLATE.findall(value))
|
||||
if isinstance(value, dict):
|
||||
return {ref for item in value.values() for ref in template_refs(item)}
|
||||
if isinstance(value, list):
|
||||
return {ref for item in value for ref in template_refs(item)}
|
||||
return set()
|
||||
|
||||
|
||||
def _as_text(value: Any) -> str:
|
||||
# 按 JS 的 String(value):null 为空串,布尔小写,整数值的小数不带 .0
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, bool):
|
||||
return "true" if value else "false"
|
||||
if isinstance(value, float) and value.is_integer():
|
||||
return str(int(value))
|
||||
return str(value)
|
||||
|
||||
|
||||
def fill_template(value: Any, variables: dict[str, Any]) -> Any:
|
||||
"""整个字符串就是一个占位符时换成变量原值(保留类型,null 也原样);嵌在文字中时转成文字拼接,
|
||||
null 和未定义为空串;对象和数组递归,其他原样返回"""
|
||||
if isinstance(value, str):
|
||||
if whole := TEMPLATE.fullmatch(value):
|
||||
return variables.get(whole.group(1))
|
||||
return TEMPLATE.sub(lambda m: _as_text(variables.get(m.group(1))), value)
|
||||
if isinstance(value, dict):
|
||||
return {key: fill_template(item, variables) for key, item in value.items()}
|
||||
if isinstance(value, list):
|
||||
return [fill_template(item, variables) for item in value]
|
||||
return value
|
||||
|
||||
|
||||
def json_key(value: Any) -> tuple[str, Any]:
|
||||
"""相等判断的键:键相同即相等。数字之间按值(1 与 1.0 相同),其余类型不同就不等("1" 与 1、true 与 1 都不等)
|
||||
|
||||
可以放进集合或字典里分组;对象和数组按内容逐层用同样的规则比较(前端的 === 不会认为它们相等,变量值只用标量)
|
||||
"""
|
||||
if isinstance(value, bool):
|
||||
return "boolean", value
|
||||
if isinstance(value, (int, float)):
|
||||
# int 与 float 值相等时哈希也相等,1 和 1.0 落在同一个键
|
||||
return "number", value
|
||||
if isinstance(value, str):
|
||||
return "string", value
|
||||
if value is None:
|
||||
return "null", None
|
||||
if isinstance(value, dict):
|
||||
return "object", tuple(sorted((key, json_key(item)) for key, item in value.items()))
|
||||
if isinstance(value, list):
|
||||
return "array", tuple(json_key(item) for item in value)
|
||||
return "json", json.dumps(value, sort_keys=True, ensure_ascii=False)
|
||||
|
||||
|
||||
def json_equal(left: Any, right: Any) -> bool:
|
||||
"""按 JSON 严格比较,规则见 json_key"""
|
||||
return json_key(left) == json_key(right)
|
||||
|
||||
|
||||
def is_scalar(value: Any) -> bool:
|
||||
"""变量的值只能是 JSON 标量"""
|
||||
return value is None or isinstance(value, (str, int, float, bool))
|
||||
|
||||
|
||||
class ProtocolModel(BaseModel):
|
||||
"""前后端约定的交互结构:字段固定,写错的键直接报错,不当作扩展字段保留"""
|
||||
|
||||
model_config = ConfigDict(extra="forbid", strict=True, allow_inf_nan=False)
|
||||
|
||||
|
||||
class Variable(ProtocolModel):
|
||||
"""大屏变量:点击写变量,组件读变量(显隐、筛选数据、填文字、取数参数)"""
|
||||
|
||||
id: NonBlankString = Field(
|
||||
description="变量 ID,组件按它引用;文本内容和数据源参数里写 {{ID}} 取它的值,所以只用字母、数字、_、-"
|
||||
)
|
||||
name: str | None = Field(
|
||||
default=None, description="给用户看的变量名,例如 选中模型"
|
||||
)
|
||||
default: VarValue = Field(
|
||||
default=None,
|
||||
description="打开大屏时的值:字符串、数字、布尔或 null;null 时按它筛选的组件不过滤。"
|
||||
"被 filters 引用时要取数据里存在的值",
|
||||
)
|
||||
|
||||
|
||||
class VisibleWhen(ProtocolModel):
|
||||
"""变量等于 equals 时组件才显示"""
|
||||
|
||||
var: NonBlankString = Field(description="变量 ID")
|
||||
equals: VarValue = Field(description="变量等于这个值时显示,按 JSON 严格比较")
|
||||
|
||||
|
||||
class RowFilter(ProtocolModel):
|
||||
"""只保留 row[field] 等于变量值的数据行;变量为 null 时不过滤"""
|
||||
|
||||
field: NonBlankString = Field(description="组件数据行里的列名")
|
||||
var: NonBlankString = Field(description="变量 ID")
|
||||
|
||||
|
||||
class EventAction(ProtocolModel):
|
||||
"""事件触发时写变量:value 写常量,fromField 写被点击数据行的某列(只用于 itemClick)"""
|
||||
|
||||
setVar: NonBlankString = Field(description="要写的变量 ID")
|
||||
value: VarValue = Field(default=None, description="写入的常量;与 fromField 二选一")
|
||||
fromField: NonBlankString | None = Field(
|
||||
default=None,
|
||||
description="写入被点击数据行这一列的值,只用于 itemClick 事件;与 value 二选一",
|
||||
)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def one_source(self) -> Self:
|
||||
# value 可以是 null,按是否写出判断,不按值判断
|
||||
if ("value" in self.model_fields_set) == (self.fromField is not None):
|
||||
raise ValueError("value 与 fromField 必须二选一")
|
||||
return self
|
||||
|
||||
@model_serializer(mode="wrap")
|
||||
def written_source_only(self, handler):
|
||||
# 二选一按"写了哪个键"区分,没写的那个不能序列化出来
|
||||
data = handler(self)
|
||||
if "value" not in self.model_fields_set:
|
||||
data.pop("value", None)
|
||||
if self.fromField is None:
|
||||
data.pop("fromField", None)
|
||||
return data
|
||||
|
||||
|
||||
class Canvas(ExtensibleModel):
|
||||
@@ -20,6 +167,10 @@ class Canvas(ExtensibleModel):
|
||||
backgroundColor: str | None = Field(
|
||||
default=None, description="画布背景色;为 null 时用主题的背景色"
|
||||
)
|
||||
variables: list[Variable] = Field(
|
||||
default_factory=list,
|
||||
description="大屏变量,整组替换;组件用 visibleWhen、filters、事件和 {{ID}} 模板读写它们",
|
||||
)
|
||||
|
||||
|
||||
class Layout(ExtensibleModel):
|
||||
@@ -38,7 +189,18 @@ class ScreenNode(ExtensibleModel):
|
||||
name: str | None = None
|
||||
props: JsonObject = Field(default_factory=dict)
|
||||
style: JsonObject | None = None
|
||||
# 事件保持开放(前端还有 title、name、code 等字段);带 action 时按 EventAction 校验结构
|
||||
events: list[JsonObject] | None = None
|
||||
visibleWhen: VisibleWhen | None = None
|
||||
filters: list[RowFilter] | None = None
|
||||
|
||||
@field_validator("events")
|
||||
@classmethod
|
||||
def event_actions(cls, events: list[JsonObject] | None):
|
||||
for event in events or []:
|
||||
if "action" in event:
|
||||
EventAction.model_validate(event["action"])
|
||||
return events
|
||||
|
||||
|
||||
class Screen(ExtensibleModel):
|
||||
|
||||
@@ -20,6 +20,7 @@ from urllib.parse import urljoin
|
||||
import httpx
|
||||
|
||||
from app.core.exceptions import BizException
|
||||
from app.schemas.screen import fill_template
|
||||
|
||||
TIMEOUT = 5.0
|
||||
MAX_BYTES = 2 * 1024 * 1024
|
||||
@@ -113,6 +114,12 @@ def _clean_headers(headers: dict[str, Any] | None) -> dict[str, str]:
|
||||
return cleaned
|
||||
|
||||
|
||||
def fill_params(params: dict[str, Any], variables: dict[str, Any]) -> dict[str, Any]:
|
||||
"""数据源 params 的值里的 {{变量ID}} 换成变量的值,规则同前端 fillTemplate;只代入 params,
|
||||
地址和请求头不代入:线上取数是匿名接口,代入地址就成了开放代理"""
|
||||
return {key: fill_template(value, variables) for key, value in params.items()}
|
||||
|
||||
|
||||
def _query_value(value: Any) -> Any:
|
||||
return value if isinstance(value, (str, int, float, bool)) else json.dumps(value)
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""物料目录的 MongoDB 存储:每个组件一条记录,目录版本放在元数据记录中,
|
||||
"""物料目录的 MongoDB 存储:每个组件一条记录,目录版本和共享事件组放在元数据记录中,
|
||||
多个物料共用的字段分组单独存放,每个分组一条记录"""
|
||||
|
||||
from loguru import logger
|
||||
@@ -22,6 +22,14 @@ class MongoMaterialRepository:
|
||||
meta = await self._materials.find_one({"_id": META_ID})
|
||||
if meta is None:
|
||||
return None
|
||||
if "event_groups" not in meta:
|
||||
# 共享事件组之前的导入:事件写在各物料上,按现在的格式读出来每个物料都没有事件。
|
||||
# 不兼容旧格式,按未导入处理,重新导入即可
|
||||
logger.error(
|
||||
"物料目录是旧格式导入的(元数据记录没有 event_groups),按未导入处理;"
|
||||
"运行 uv run python -m scripts.seed_materials 重新导入后重启"
|
||||
)
|
||||
return None
|
||||
documents = await self._materials.find({"_id": {"$ne": META_ID}}).to_list()
|
||||
groups = await self._field_groups.find({}).to_list()
|
||||
return ComponentCatalog.model_validate(
|
||||
@@ -29,6 +37,7 @@ class MongoMaterialRepository:
|
||||
"version": meta["version"],
|
||||
"components": _ordered(documents),
|
||||
"field_groups": _ordered(groups),
|
||||
"event_groups": meta["event_groups"],
|
||||
}
|
||||
)
|
||||
|
||||
@@ -41,7 +50,16 @@ class MongoMaterialRepository:
|
||||
await collection.delete_many({})
|
||||
if items:
|
||||
await collection.insert_many(_documents(items))
|
||||
await self._materials.insert_one({"_id": META_ID, "version": catalog.version})
|
||||
# 事件组只有几项,跟版本一起放在元数据记录里
|
||||
await self._materials.insert_one(
|
||||
{
|
||||
"_id": META_ID,
|
||||
"version": catalog.version,
|
||||
"event_groups": [
|
||||
group.model_dump(mode="json") for group in catalog.event_groups
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _documents(items) -> list[dict]:
|
||||
@@ -65,7 +83,7 @@ async def warm_material_cache(db: AsyncDatabase | None) -> ComponentCatalog | No
|
||||
if catalog is None:
|
||||
logger.error(
|
||||
"物料目录未载入:{},新增、更换组件等需要物料的修改将无法执行",
|
||||
"未配置 MONGODB_URI" if db is None else "materials 集合尚未导入",
|
||||
"未配置 MONGODB_URI" if db is None else "materials 集合尚未导入或格式过旧",
|
||||
)
|
||||
else:
|
||||
logger.info("已从 MongoDB 加载物料 {} 个", len(catalog.components))
|
||||
|
||||
+63
-6
@@ -5,6 +5,8 @@
|
||||
|
||||
import asyncio
|
||||
import copy
|
||||
import json
|
||||
import math
|
||||
import secrets
|
||||
import weakref
|
||||
from collections.abc import Callable
|
||||
@@ -26,8 +28,8 @@ from app.schemas.saved_screen import (
|
||||
ScreenSummary,
|
||||
SortField,
|
||||
)
|
||||
from app.schemas.screen import Screen
|
||||
from app.services.datasource_proxy import FetchCache, fetch_json
|
||||
from app.schemas.screen import Screen, is_scalar
|
||||
from app.services.datasource_proxy import FetchCache, fetch_json, fill_params
|
||||
from app.services.screen_store import ScreenStore
|
||||
from app.services.thumbnails import ThumbnailStorage
|
||||
|
||||
@@ -41,6 +43,10 @@ EMPTY_PAGE = {
|
||||
"dataSource": [],
|
||||
}
|
||||
|
||||
# 线上取数的 vars:整段 JSON 的长度上限(接口层检查)、每个字符串值的长度上限
|
||||
LIVE_VARS_MAX_CHARS = 2000
|
||||
LIVE_VAR_VALUE_MAX_CHARS = 200
|
||||
|
||||
# 回收站过期清理:一批最多清多少个,两次扫描的间隔(秒)
|
||||
PURGE_BATCH = 100
|
||||
TRASH_SWEEP_INTERVAL = 3600
|
||||
@@ -89,7 +95,10 @@ def _not_found() -> BizException:
|
||||
|
||||
|
||||
def validate_page(page: dict[str, Any]) -> dict[str, Any]:
|
||||
"""按 Screen 校验结构,但返回的仍是原始 JSON:model_dump 会给没传的字段补出 null,前端读回来就变了"""
|
||||
"""按 Screen 校验结构,但返回的仍是原始 JSON:model_dump 会给没传的字段补出 null,前端读回来就变了
|
||||
|
||||
只查结构,不查变量引用对不对得上:编辑器里常有配到一半的交互,不能因此存不了;引用检查只在 agent 的批末做
|
||||
"""
|
||||
try:
|
||||
Screen.model_validate(page)
|
||||
except ValidationError as error:
|
||||
@@ -117,6 +126,41 @@ def _check_script_length(page: dict[str, Any]) -> None:
|
||||
)
|
||||
|
||||
|
||||
def live_variables(page: dict[str, Any], raw: str | None) -> dict[str, Any]:
|
||||
"""线上取数用的变量值:快照里声明的变量取默认值,再用浏览器传来的 vars 覆盖
|
||||
|
||||
只接受快照里声明过的变量(其余忽略)、标量值、字符串限长;格式不对时 422
|
||||
"""
|
||||
canvas = page.get("canvas")
|
||||
declared = canvas.get("variables") if isinstance(canvas, dict) else None
|
||||
values = {
|
||||
item["id"]: item.get("default")
|
||||
for item in declared or []
|
||||
if isinstance(item, dict) and isinstance(item.get("id"), str)
|
||||
}
|
||||
if raw is None:
|
||||
return values
|
||||
try:
|
||||
given = json.loads(raw)
|
||||
except (ValueError, RecursionError): # 2000 字符内能嵌套近千层,超出递归深度,按非法输入处理
|
||||
raise BizException(422, "vars 不是合法的 JSON") from None
|
||||
if not isinstance(given, dict):
|
||||
raise BizException(422, "vars 必须是对象")
|
||||
for key, value in given.items():
|
||||
if key not in values:
|
||||
continue
|
||||
if not is_scalar(value):
|
||||
raise BizException(422, f"变量 {key} 的值必须是字符串、数字、布尔或 null")
|
||||
if isinstance(value, float) and not math.isfinite(value):
|
||||
raise BizException(422, f"变量 {key} 的值必须是有限的数字")
|
||||
if isinstance(value, str) and len(value) > LIVE_VAR_VALUE_MAX_CHARS:
|
||||
raise BizException(
|
||||
422, f"变量 {key} 的值不能超过 {LIVE_VAR_VALUE_MAX_CHARS} 个字符"
|
||||
)
|
||||
values[key] = value
|
||||
return values
|
||||
|
||||
|
||||
def public_page(page: dict[str, Any]) -> dict[str, Any]:
|
||||
"""线上页面里去掉数据源的 headers:它们常放 API key,线上取数由服务端按快照里的完整配置发起,浏览器用不着"""
|
||||
sources = page.get("dataSource")
|
||||
@@ -536,8 +580,13 @@ class ScreenService:
|
||||
async def get_live(self, share_id: str) -> tuple[ScreenSummary, PublishSnapshot]:
|
||||
return await self._live(share_id)
|
||||
|
||||
async def fetch_live_datasource(self, share_id: str, datasource_id: str):
|
||||
"""前端只传数据源 ID,请求地址取自已发布快照;不能做成"前端传 URL 后端转发",否则是匿名开放代理"""
|
||||
async def fetch_live_datasource(
|
||||
self, share_id: str, datasource_id: str, raw_vars: str | None = None
|
||||
):
|
||||
"""前端只传数据源 ID,请求地址取自已发布快照;不能做成"前端传 URL 后端转发",否则是匿名开放代理
|
||||
|
||||
raw_vars 是浏览器当前的变量值(JSON 对象),只代入 params 的值;缓存 key 用代入后的 params
|
||||
"""
|
||||
_, snapshot = await self._live(share_id)
|
||||
source = next(
|
||||
(
|
||||
@@ -550,10 +599,18 @@ class ScreenService:
|
||||
if source is None or source.get("type") != "api" or not source.get("url"):
|
||||
raise BizException(404, "数据源不存在")
|
||||
method = "POST" if source.get("method") == "POST" else "GET"
|
||||
variables = live_variables(snapshot.page, raw_vars)
|
||||
params = source.get("params")
|
||||
if isinstance(params, dict):
|
||||
params = fill_params(params, variables)
|
||||
headers = source.get("headers")
|
||||
return await self._datasource_cache.get(
|
||||
(share_id, snapshot.version, datasource_id),
|
||||
(
|
||||
share_id,
|
||||
snapshot.version,
|
||||
datasource_id,
|
||||
json.dumps(params, sort_keys=True, ensure_ascii=False),
|
||||
),
|
||||
lambda: fetch_json(
|
||||
str(source["url"]),
|
||||
method,
|
||||
|
||||
Reference in New Issue
Block a user