feat:图表交互

This commit is contained in:
2026-10-15 03:38:20 -07:00
parent 565ecf4558
commit 9b64f9fc0b
43 changed files with 1537 additions and 727 deletions
+7
View File
@@ -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)
+21 -3
View File
@@ -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
View File
@@ -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,