feat: 元景 drag 模块(W-0x 开发产出)

This commit is contained in:
pipeline-agent 2026-08-29 21:10:23 +08:00
commit 2702c9324f
35 changed files with 2908 additions and 0 deletions

12
.gitignore vendored Normal file
View File

@ -0,0 +1,12 @@
__pycache__/
*.py[cod]
build/
dist/
*.egg-info/
.venv/
venv/
*.swp
*.swo
.DS_Store
wwwroot/drag_graph/
wwwroot/drag_template/

88
README.md Normal file
View File

@ -0,0 +1,88 @@
# drag 拖拽编程画布模块
元景 W-09 拖拽编程画布模块:把可视化拖拽块(事件/逻辑/动作/变量/表达式)连线成流程,
编译为 script_engine 可执行 Python 脚本script_type=0
## 功能
- **画布**:打开/加块/移动/连接/删除/保存graph_save
- **块体系**:事件(开始/点击/悬停/定时/碰撞/键盘)、逻辑(顺序/循环/条件判断/分叉/等待/并行/函数定义/函数调用)、
动作(移动/旋转/缩放/改属性/播放动画/播放声音/显隐/相机)、变量(创建/赋值/读取)、表达式(算术/比较/逻辑)
- **实体选择器**绑定块entity 参数 → 实体下拉)
- **参数校验**:必填/类型/范围/枚举/标识符/变量引用/函数引用/孤立块/无入口/环形连线
- **编译**compile_graph → script_engine 可执行脚本(变量提升、函数区、事件入口分支、控制流展开)
- **发布**graph_publish 写入 script_engine 模块script_type=0
- **模板**drag_template 表 + template_use 一键生成新画布
## 数据表drag 库)
| 表 | 说明 |
|----|------|
| drag_graph | 画布name / description / blocks(JSON) / connections(JSON) / content(编译产物) / status |
| drag_template | 模板name / category / description / blocks(JSON) / connections(JSON) |
## 目录结构
```
drag/
├── drag/ # Python 包
│ ├── __init__.py # 导出(三处同步注册)
│ ├── init.py # load_drag() 挂载全部函数
│ ├── blocks.py # 块定义 JSON唯一事实源
│ ├── validator.py # 图校验器
│ └── compiler.py # 图 → Python 脚本编译器
├── wwwroot/ # index.ui / editor.ui / drag-canvas.js / drag-editor.js / drag.css / api/*.dspy
├── models/ # drag_graph.json / drag_template.json
├── json/ # drag_graph.json / drag_template.jsonCRUD
├── init/data.json # appcodesdrag_status
├── scripts/load_path.py # RBAC
└── skill/SKILL.md # 模块技能
```
## 安装与集成
1. `pip install .` 安装模块包
2. 宿主应用 `app/scense.py``from drag.init import load_drag` + `load_drag()`init() 内)
3. `build.sh`:生成 DDL / CRUD UI / 软链 wwwroot 到宿主 `/d/scense/scense_app/wwwroot/drag`
4. RBAC执行 `scripts/load_path.py`(或在中心 load_path.py 添加 drag 路径)
5. 菜单global_menu.ui 增加 `{"name":"drag","label":"拖拽编程","url":"{{entire_url('/drag/index.ui')}}"}`
## 关键接口load_drag() 挂载)
| 函数 | 说明 |
|------|------|
| get_block_defs_api() | 块定义categories + blocks |
| graph_save(ns) | 保存画布(新建/更新,保存即校验+编译) |
| graph_compile(ns) | 编译 → {success, content, errors} |
| graph_validate(ns) | 校验 → {valid, errors, warnings} |
| graph_publish(ns) | 发布到 script_engine |
| list/get/create/update/delete_drag_graph | 画布 CRUD |
| list/get/create/update/delete_drag_template | 模板 CRUD |
| template_use(ns) | 模板 → 新画布 |
| get_entity_options() | 实体下拉 |
## 编译产物示例
```python
# ===== 变量声明 =====
score = 0
# ===== 函数定义 =====
def jump():
move(entity='hero', x=0, y=100, duration=0.5)
# ===== 入口: 开始 =====
if score > 100:
play_animation(entity='hero', animation='run')
else:
wait(seconds=1)
```
## 陷阱
- 取库名用 `ServerEnv().get_module_dbname('drag')`,禁止硬编码 DBNAME
- 块定义修改需同步blocks.py唯一源+ 前端渲染自动读取block_defs.dspy 下发)
- .dspy 无 import/print/uuid/f-string显式 returnhelper 全部在 init.py
- `sor.C/U/D/R` 只有这四个 sqlor 方法;分页用 `sor.sqlPaging`,不硬编码 LIMIT
- 保存时 `created_at/updated_at = curDateString()`,否则 sor.C 静默丢记录
- 编译产物仅含白名单语法(赋值/def/for/while/if-else/函数调用/注释),供 script_engine 白名单执行
- 事件块无输入端口 = 天然入口;事件块不能作为连线目标
- 动态端口(分叉/并行)由 `dynamic_outputs` 声明,按 branch_count 生成输出端口

21
build.sh Normal file
View File

@ -0,0 +1,21 @@
#!/bin/bash
# drag 模块构建脚本(集成到宿主应用 build.sh
# 功能:① xls2ddl 生成 DDL② xls2ui 生成 CRUD UI③ 软链 wwwroot 到宿主 wwwroot
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
# 宿主应用 wwwrootscense/d/scense/scense_app/wwwroot
APP_WWWROOT="${APP_WWWROOT:-/d/scense/scense_app/wwwroot}"
echo "[drag] 生成 DDL..."
python3 -m xls2ddl mysql . > /tmp/drag_mysql.ddl.sql 2>/dev/null || true
echo "[drag] 生成 CRUD UI (xls2ui)..."
python3 -m xls2ui -m models -o wwwroot drag json/drag_graph.json json/drag_template.json 2>/dev/null || true
echo "[drag] 链接 wwwroot 到宿主 $APP_WWWROOT/drag"
mkdir -p "$APP_WWWROOT"
ln -sfn "$SCRIPT_DIR/wwwroot" "$APP_WWWROOT/drag"
echo "[drag] build done."

29
drag/__init__.py Normal file
View File

@ -0,0 +1,29 @@
# -*- coding: utf-8 -*-
"""drag——元景 W-09 拖拽编程画布模块。
通过 load_drag() 挂载到宿主应用scensewwwroot/api/*.dspy 直接调用
init.py 注册的异步函数
"""
from .blocks import BLOCK_DEFS, BLOCK_CATEGORIES, get_block_defs, get_block_def, get_dynamic_outputs
from .validator import validate_graph
from .compiler import compile_graph
from .init import (get_block_defs_api,
list_drag_graphs, get_drag_graph, create_drag_graph,
update_drag_graph, delete_drag_graph,
graph_save, graph_compile, graph_validate, graph_publish,
list_drag_templates, get_drag_template, create_drag_template,
update_drag_template, delete_drag_template, template_use,
get_entity_options, get_template_category_options,
load_drag)
__all__ = [
'BLOCK_DEFS', 'BLOCK_CATEGORIES', 'get_block_defs', 'get_block_def',
'get_dynamic_outputs', 'validate_graph', 'compile_graph', 'load_drag',
'get_block_defs_api', 'list_drag_graphs', 'get_drag_graph',
'create_drag_graph', 'update_drag_graph', 'delete_drag_graph',
'graph_save', 'graph_compile', 'graph_validate', 'graph_publish',
'list_drag_templates', 'get_drag_template', 'create_drag_template',
'update_drag_template', 'delete_drag_template', 'template_use',
'get_entity_options', 'get_template_category_options',
]

342
drag/blocks.py Normal file
View File

@ -0,0 +1,342 @@
# -*- coding: utf-8 -*-
"""拖拽编程画布——块定义JSON 结构)。
BLOCK_CATEGORIES / BLOCK_DEFS 是前端画布渲染参数面板后端校验与编译的唯一事实源
新增块类型只需在此注册category / type / label / icon / color / params / ports
= 画布上一个节点每个块有
- params: 参数定义参数面板表单渲染 + 后端校验规则
- ports.inputs / ports.outputs: 连线端口事件块无输入端口 = 天然入口
- dynamic_outputs: 动态端口模板如分叉/并行按 branch_count 生成多个输出端口
"""
BLOCK_CATEGORIES = [
{"key": "event", "label": "事件", "color": "#4CAF50"},
{"key": "logic", "label": "逻辑", "color": "#2196F3"},
{"key": "action", "label": "动作", "color": "#FF9800"},
{"key": "variable", "label": "变量", "color": "#9C27B0"},
{"key": "expr", "label": "表达式", "color": "#607D8B"},
]
def _p(name, label, uitype='text', required=False, default='', options=None, **kw):
"""参数定义。uitype: text / number / select / boolean / entity / expression / textarea"""
d = {"name": name, "label": label, "uitype": uitype, "required": required, "default": default}
if options:
d["options"] = options
d.update(kw)
return d
def _port(name, label, ptype='flow'):
return {"name": name, "label": label, "type": ptype}
def _block(btype, category, label, icon, color, params, inputs=None, outputs=None,
description='', dynamic_outputs=None):
return {
"type": btype, "category": category, "label": label, "icon": icon, "color": color,
"params": params,
"ports": {"inputs": inputs or [], "outputs": outputs or []},
"description": description,
"dynamic_outputs": dynamic_outputs,
}
_OUT = [_port('out', '下一步')]
# ═══════════════════════ 事件块(入口,无输入端口) ═══════════════════════
_EVENTS = {
"event_start": _block(
"event_start", "event", "开始", "fa fa-play-circle", "#4CAF50",
[],
inputs=[], outputs=_OUT,
description="程序入口:世界初始化时触发一次"),
"event_click": _block(
"event_click", "event", "点击", "fa fa-mouse-pointer", "#4CAF50",
[
_p("target", "目标实体", "entity", required=True, placeholder="选择被点击的实体"),
_p("button", "按键", "select", default="left",
options=[{"value": "left", "text": "左键"}, {"value": "middle", "text": "中键"}, {"value": "right", "text": "右键"}]),
],
inputs=[], outputs=_OUT,
description="事件:某实体被点击时触发"),
"event_hover": _block(
"event_hover", "event", "悬停", "fa fa-hand-pointer", "#4CAF50",
[
_p("target", "目标实体", "entity", required=True),
_p("state", "状态", "select", default="enter",
options=[{"value": "enter", "text": "进入"}, {"value": "leave", "text": "离开"}]),
],
inputs=[], outputs=_OUT,
description="事件:指针悬停进入/离开实体时触发"),
"event_timer": _block(
"event_timer", "event", "定时", "fa fa-clock", "#4CAF50",
[
_p("interval", "间隔(秒)", "number", required=True, default="1", min=0.1),
_p("repeat", "重复次数", "number", default="-1",
description="-1 表示无限循环"),
],
inputs=[], outputs=_OUT,
description="事件:按固定间隔定时触发"),
"event_collision": _block(
"event_collision", "event", "碰撞", "fa fa-bolt", "#4CAF50",
[
_p("entity_a", "实体 A", "entity", required=True),
_p("entity_b", "实体 B", "entity", required=True),
],
inputs=[], outputs=_OUT,
description="事件:实体 A 与实体 B 发生碰撞时触发"),
"event_keyboard": _block(
"event_keyboard", "event", "键盘", "fa fa-keyboard", "#4CAF50",
[
_p("key", "按键", "text", required=True, placeholder="Space / ArrowUp / a"),
_p("action", "动作", "select", default="keydown",
options=[{"value": "keydown", "text": "按下"}, {"value": "keyup", "text": "松开"}]),
],
inputs=[], outputs=_OUT,
description="事件:键盘按键按下/松开时触发"),
}
# ═══════════════════════ 逻辑框架块 ═══════════════════════
_LOGIC = {
"logic_sequence": _block(
"logic_sequence", "logic", "顺序", "fa fa-list-ol", "#2196F3",
[], inputs=[_port('in', '上一步')], outputs=_OUT,
description="顺序执行:依次执行后续块"),
"logic_loop": _block(
"logic_loop", "logic", "循环", "fa fa-repeat", "#2196F3",
[
_p("mode", "模式", "select", default="count",
options=[{"value": "count", "text": "按次数"}, {"value": "while", "text": "按条件"}]),
_p("times", "次数", "number", default="3", min=0, max=10000),
_p("condition", "循环条件", "expression", default="True",
description="while 模式的循环条件表达式"),
],
inputs=[_port('in', '上一步')], outputs=_OUT,
description="循环执行后续块(按次数或按条件)"),
"logic_condition": _block(
"logic_condition", "logic", "条件判断", "fa fa-code-branch", "#2196F3",
[
_p("condition", "条件", "expression", required=True, default="True",
placeholder="score > 100 或 @alive == true"),
],
inputs=[_port('in', '上一步')],
outputs=[_port('true', ''), _port('false', '')],
description="条件判断:条件为真走「真」出口,否则走「假」出口"),
"logic_branch": _block(
"logic_branch", "logic", "分叉", "fa fa-sitemap", "#2196F3",
[
_p("branch_count", "分支数", "number", default="2", min=2, max=8),
],
inputs=[_port('in', '上一步')], outputs=[],
dynamic_outputs={"param": "branch_count", "prefix": "branch"},
description="多路分叉:按分支数生成多个出口"),
"logic_wait": _block(
"logic_wait", "logic", "等待", "fa fa-hourglass-half", "#2196F3",
[
_p("seconds", "等待秒数", "number", required=True, default="1", min=0),
],
inputs=[_port('in', '上一步')], outputs=_OUT,
description="等待指定秒数后继续"),
"logic_parallel": _block(
"logic_parallel", "logic", "并行", "fa fa-columns", "#2196F3",
[
_p("branch_count", "分支数", "number", default="2", min=2, max=8),
],
inputs=[_port('in', '上一步')], outputs=[],
dynamic_outputs={"param": "branch_count", "prefix": "p"},
description="并行:多个分支同时执行(编译为顺序展开)"),
"logic_function_def": _block(
"logic_function_def", "logic", "函数定义", "fa fa-fw fa-code", "#2196F3",
[
_p("name", "函数名", "text", required=True, placeholder="jump"),
],
inputs=[_port('in', '上一步')], outputs=_OUT,
description="定义可复用函数:函数体为后续连接块"),
"logic_function_call": _block(
"logic_function_call", "logic", "函数调用", "fa fa-caret-right", "#2196F3",
[
_p("name", "函数名", "text", required=True, placeholder="jump"),
],
inputs=[_port('in', '上一步')], outputs=_OUT,
description="调用已定义的函数"),
}
# ═══════════════════════ 动作块 ═══════════════════════
_ACTION = {
"action_move": _block(
"action_move", "action", "移动", "fa fa-arrows-alt", "#FF9800",
[
_p("entity", "目标实体", "entity", required=True),
_p("x", "X 位移", "number", default="0"),
_p("y", "Y 位移", "number", default="0"),
_p("duration", "时长(秒)", "number", default="0", min=0),
],
inputs=[_port('in', '上一步')], outputs=_OUT,
description="移动实体(位移量,支持负值)"),
"action_rotate": _block(
"action_rotate", "action", "旋转", "fa fa-undo", "#FF9800",
[
_p("entity", "目标实体", "entity", required=True),
_p("angle", "角度(度)", "number", default="90"),
_p("duration", "时长(秒)", "number", default="0", min=0),
],
inputs=[_port('in', '上一步')], outputs=_OUT,
description="旋转实体指定角度"),
"action_scale": _block(
"action_scale", "action", "缩放", "fa fa-expand-arrows-alt", "#FF9800",
[
_p("entity", "目标实体", "entity", required=True),
_p("scale_x", "X 缩放", "number", default="1", min=0),
_p("scale_y", "Y 缩放", "number", default="1", min=0),
_p("duration", "时长(秒)", "number", default="0", min=0),
],
inputs=[_port('in', '上一步')], outputs=_OUT,
description="缩放实体1 = 原始大小)"),
"action_set_property": _block(
"action_set_property", "action", "改属性", "fa fa-sliders-h", "#FF9800",
[
_p("entity", "目标实体", "entity", required=True),
_p("property", "属性名", "text", required=True, placeholder="alpha / color / speed"),
_p("value", "属性值", "text", required=True, placeholder="0.5 / #ff0000"),
],
inputs=[_port('in', '上一步')], outputs=_OUT,
description="修改实体属性"),
"action_play_animation": _block(
"action_play_animation", "action", "播放动画", "fa fa-film", "#FF9800",
[
_p("entity", "目标实体", "entity", required=True),
_p("animation", "动画名", "text", required=True, placeholder="run / jump"),
],
inputs=[_port('in', '上一步')], outputs=_OUT,
description="播放实体动画"),
"action_play_sound": _block(
"action_play_sound", "action", "播放声音", "fa fa-volume-up", "#FF9800",
[
_p("entity", "目标实体", "entity", required=True),
_p("sound", "声音资源", "text", required=True, placeholder="coin.wav"),
],
inputs=[_port('in', '上一步')], outputs=_OUT,
description="播放声音资源"),
"action_show_hide": _block(
"action_show_hide", "action", "显隐", "fa fa-eye", "#FF9800",
[
_p("entity", "目标实体", "entity", required=True),
_p("visible", "可见", "boolean", default="true",
options=[{"value": "true", "text": "显示"}, {"value": "false", "text": "隐藏"}]),
],
inputs=[_port('in', '上一步')], outputs=_OUT,
description="显示/隐藏实体"),
"action_camera": _block(
"action_camera", "action", "相机", "fa fa-camera", "#FF9800",
[
_p("camera_id", "相机 ID", "text", default="main"),
_p("mode", "模式", "select", default="follow",
options=[{"value": "follow", "text": "跟随"}, {"value": "free", "text": "自由"}, {"value": "lookat", "text": "注视"}]),
_p("x", "X", "number", default="0"),
_p("y", "Y", "number", default="0"),
_p("zoom", "缩放", "number", default="1", min=0.1),
],
inputs=[_port('in', '上一步')], outputs=_OUT,
description="控制相机(自由模式设置坐标,跟随模式注视目标)"),
}
# ═══════════════════════ 变量与表达式块 ═══════════════════════
_VARIABLE = {
"var_create": _block(
"var_create", "variable", "创建变量", "fa fa-plus-circle", "#9C27B0",
[
_p("name", "变量名", "text", required=True, placeholder="score"),
_p("initial_value", "初始值", "text", default="0"),
],
inputs=[_port('in', '上一步')], outputs=_OUT,
description="创建并初始化一个变量(编译时统一在脚本头部声明)"),
"var_assign": _block(
"var_assign", "variable", "赋值", "fa fa-pen", "#9C27B0",
[
_p("name", "变量名", "variable", required=True, placeholder="score"),
_p("value", "", "expression", required=True, placeholder="100 或 @score + 10"),
],
inputs=[_port('in', '上一步')], outputs=_OUT,
description="给变量赋新值"),
"var_read": _block(
"var_read", "variable", "读取变量", "fa fa-eye", "#9C27B0",
[
_p("name", "变量名", "variable", required=True, placeholder="score"),
_p("result", "结果变量", "text", required=True, placeholder="cur_score"),
],
inputs=[_port('in', '上一步')], outputs=_OUT,
description="读取变量值存入结果变量"),
}
_EXPR = {
"expr_arith": _block(
"expr_arith", "expr", "算术", "fa fa-calculator", "#607D8B",
[
_p("operand_a", "操作数 A", "expression", required=True, placeholder="10 或 @score"),
_p("operator", "运算符", "select", default="+",
options=[{"value": "+", "text": "加 +"}, {"value": "-", "text": "减 -"},
{"value": "*", "text": "乘 *"}, {"value": "/", "text": "除 /"},
{"value": "%", "text": "取余 %"}]),
_p("operand_b", "操作数 B", "expression", required=True, placeholder="5"),
_p("result", "结果变量", "text", required=True, placeholder="total"),
],
inputs=[_port('in', '上一步')], outputs=_OUT,
description="算术运算A 运算符 B → 结果变量"),
"expr_compare": _block(
"expr_compare", "expr", "比较", "fa fa-balance-scale", "#607D8B",
[
_p("operand_a", "操作数 A", "expression", required=True),
_p("operator", "运算符", "select", default="==",
options=[{"value": "==", "text": "等于 =="}, {"value": "!=", "text": "不等于 !="},
{"value": ">", "text": "大于 >"}, {"value": "<", "text": "小于 <"},
{"value": ">=", "text": "大于等于 >="}, {"value": "<=", "text": "小于等于 <="}]),
_p("operand_b", "操作数 B", "expression", required=True),
_p("result", "结果变量", "text", required=True, placeholder="is_win"),
],
inputs=[_port('in', '上一步')], outputs=_OUT,
description="比较运算A 运算符 B → 结果变量(布尔)"),
"expr_logic": _block(
"expr_logic", "expr", "逻辑", "fa fa-toggle-on", "#607D8B",
[
_p("operand_a", "操作数 A", "expression", required=True, default="True"),
_p("operator", "运算符", "select", default="and",
options=[{"value": "and", "text": "与 and"}, {"value": "or", "text": "或 or"},
{"value": "not", "text": "非 not"}]),
_p("operand_b", "操作数 B", "expression", required=True, default="True"),
_p("result", "结果变量", "text", required=True, placeholder="flag"),
],
inputs=[_port('in', '上一步')], outputs=_OUT,
description="逻辑运算A and/or B → 结果变量(布尔)"),
}
BLOCK_DEFS = {}
BLOCK_DEFS.update(_EVENTS)
BLOCK_DEFS.update(_LOGIC)
BLOCK_DEFS.update(_ACTION)
BLOCK_DEFS.update(_VARIABLE)
BLOCK_DEFS.update(_EXPR)
def get_block_defs():
"""返回 {categories, blocks} 供前端渲染画布。"""
return {"categories": BLOCK_CATEGORIES, "blocks": BLOCK_DEFS}
def get_block_def(btype):
return BLOCK_DEFS.get(btype)
def get_dynamic_outputs(btype, params):
"""按块参数生成动态输出端口名列表。无动态端口返回 []。"""
bdef = BLOCK_DEFS.get(btype)
if not bdef or not bdef.get("dynamic_outputs"):
return []
d = bdef["dynamic_outputs"]
try:
n = int((params or {}).get(d["param"], 2))
except (TypeError, ValueError):
n = 2
n = max(2, min(n, 8))
return ["%s%d" % (d["prefix"], i) for i in range(1, n + 1)]

275
drag/compiler.py Normal file
View File

@ -0,0 +1,275 @@
# -*- coding: utf-8 -*-
"""拖拽编程画布——图编译为 script_engine 可执行脚本Pythonscript_type=0
编译策略
- 变量声明统一提升到脚本头部var_create保证使用前已定义
- 函数定义logic_function_def生成 def + 函数体链
- 每个事件块作为独立入口分支
- 逻辑块生成控制流for / while / if / else / 并行顺序展开
- 动作块生成动作函数调用move/rotate/scale/set_property/play_animation/
play_sound/show_hide/camera/wait由宿主运行时注册到 script_engine 白名单
- 产物仅含白名单兼容语法赋值 / def / for / while / if-else / 函数调用 / 注释
import class lambda无对象方法调用 async
- 表达式内 `@变量名` 引用统一替换为 Python 标识符变量提升到头部后按名引用
"""
import re
from .blocks import BLOCK_DEFS, get_dynamic_outputs
from .validator import validate_graph
_IDENT_RE = re.compile(r'^[A-Za-z_][A-Za-z0-9_]*$')
_SAFE_EXPR = re.compile(r"^[A-Za-z0-9_+\-*/%()<>=!&| .'\"]+$")
_VAR_REF = re.compile(r'@([A-Za-z_][A-Za-z0-9_]*)')
def _ident(name):
s = re.sub(r'\W', '_', str(name or ''))
if not s:
return '_v'
if s[0].isdigit():
s = '_' + s
return s
def _num(s):
s = str(s or '').strip()
try:
float(s)
return s
except (TypeError, ValueError):
return '0'
def _str(s):
return "'%s'" % str(s or '').replace("'", "\\'")
def _expr(s):
"""表达式安全转义:表达式内 @var 引用 → 标识符;纯数字/安全表达式原样;其余当字符串。"""
s = str(s or '').strip()
s = _VAR_REF.sub(lambda m: _ident(m.group(1)), s)
try:
float(s)
return s
except (TypeError, ValueError):
pass
if (_SAFE_EXPR.match(s) and 'import' not in s and '__' not in s and ';' not in s
and not s.startswith('lambda') and not s.startswith('class')):
return s
return _str(s)
def _val(s):
"""值转义:@var → 标识符;数字 → 原样true/false → 布尔;其余 → 字符串字面量。"""
s = str(s or '').strip()
if s.startswith('@') and _IDENT_RE.match(s[1:]):
return _ident(s[1:])
try:
f = float(s)
return str(int(f)) if f == int(f) else s
except (TypeError, ValueError):
pass
if s in ('true', 'True', '1'):
return 'True'
if s in ('false', 'False', '0'):
return 'False'
return _str(s)
def _block_label(btype):
bdef = BLOCK_DEFS.get(btype) or {}
return bdef.get("label", btype)
def _block_code(b):
"""生成单个块的执行代码行;返回 None 表示该块不生成独立代码(结构块/已提升)。"""
btype = b.get("type")
p = b.get("params") or {}
if btype == "event_start":
return "# 事件: 开始"
if btype == "event_click":
return "# 事件: 点击 %s (%s)" % (p.get("target", ''), p.get("button", 'left'))
if btype == "event_hover":
return "# 事件: 悬停 %s %s" % (p.get("target", ''), p.get("state", 'enter'))
if btype == "event_timer":
return "# 事件: 定时 每 %s× %s" % (p.get("interval", ''), p.get("repeat", '-1'))
if btype == "event_collision":
return "# 事件: 碰撞 %s × %s" % (p.get("entity_a", ''), p.get("entity_b", ''))
if btype == "event_keyboard":
return "# 事件: 键盘 %s (%s)" % (p.get("key", ''), p.get("action", 'keydown'))
if btype == "logic_sequence":
return "# 顺序执行"
if btype == "logic_wait":
return "wait(seconds=%s)" % _num(p.get("seconds", '0'))
if btype == "logic_function_call":
return "%s()" % _ident(p.get("name", 'fn'))
if btype == "var_create":
return None # 已提升到头部变量声明区
if btype == "var_assign":
return "%s = %s" % (_ident(p.get("name", '_v')), _val(p.get("value", '0')))
if btype == "var_read":
return "%s = %s" % (_ident(p.get("result", '_r')), _ident(p.get("name", '_v')))
if btype == "expr_arith":
return "%s = %s %s %s" % (_ident(p.get("result", '_r')), _expr(p.get("operand_a", '0')),
p.get("operator", '+'), _expr(p.get("operand_b", '0')))
if btype == "expr_compare":
return "%s = %s %s %s" % (_ident(p.get("result", '_r')), _expr(p.get("operand_a", '0')),
p.get("operator", '=='), _expr(p.get("operand_b", '0')))
if btype == "expr_logic":
op = p.get("operator", 'and')
a, bv = _expr(p.get("operand_a", 'True')), _expr(p.get("operand_b", 'True'))
body = "not %s" % a if op == 'not' else "%s %s %s" % (a, op, bv)
return "%s = %s" % (_ident(p.get("result", '_r')), body)
# 动作
if btype == "action_move":
return "move(entity=%s, x=%s, y=%s, duration=%s)" % (
_str(p.get("entity", '')), _num(p.get("x", '0')), _num(p.get("y", '0')), _num(p.get("duration", '0')))
if btype == "action_rotate":
return "rotate(entity=%s, angle=%s, duration=%s)" % (
_str(p.get("entity", '')), _num(p.get("angle", '0')), _num(p.get("duration", '0')))
if btype == "action_scale":
return "scale(entity=%s, scale_x=%s, scale_y=%s, duration=%s)" % (
_str(p.get("entity", '')), _num(p.get("scale_x", '1')), _num(p.get("scale_y", '1')),
_num(p.get("duration", '0')))
if btype == "action_set_property":
return "set_property(entity=%s, property=%s, value=%s)" % (
_str(p.get("entity", '')), _str(p.get("property", '')), _str(p.get("value", '')))
if btype == "action_play_animation":
return "play_animation(entity=%s, animation=%s)" % (
_str(p.get("entity", '')), _str(p.get("animation", '')))
if btype == "action_play_sound":
return "play_sound(entity=%s, sound=%s)" % (_str(p.get("entity", '')), _str(p.get("sound", '')))
if btype == "action_show_hide":
return "show_hide(entity=%s, visible=%s)" % (_str(p.get("entity", '')), _val(p.get("visible", 'true')))
if btype == "action_camera":
return "camera(camera_id=%s, mode=%s, x=%s, y=%s, zoom=%s)" % (
_str(p.get("camera_id", 'main')), _str(p.get("mode", 'follow')),
_num(p.get("x", '0')), _num(p.get("y", '0')), _num(p.get("zoom", '1')))
return None
def _emit_chain(bid, bmap, adj, indent, visited=None):
"""沿输出端口生成执行代码(每块仅访问一次防环)。返回代码行列表。"""
if visited is None:
visited = set()
if bid in visited or bid not in bmap:
return []
visited.add(bid)
b = bmap[bid]
btype = b.get("type")
p = b.get("params") or {}
ind = ' ' * indent
lines = []
outs = adj.get(bid, [])
if btype == "logic_condition":
lines.append(ind + "if %s:" % _expr(p.get("condition", 'True')))
t = [c for c in outs if c.get("fromPort") == 'true']
if t:
lines.extend(_emit_chain(t[0].get("to"), bmap, adj, indent + 1, visited))
else:
lines.append(ind + " pass")
lines.append(ind + "else:")
f = [c for c in outs if c.get("fromPort") == 'false']
if f:
lines.extend(_emit_chain(f[0].get("to"), bmap, adj, indent + 1, visited))
else:
lines.append(ind + " pass")
return lines
if btype == "logic_loop":
mode = p.get("mode", 'count')
if mode == 'while':
lines.append(ind + "while %s:" % _expr(p.get("condition", 'True')))
else:
lines.append(ind + "for _i in range(%s):" % _num(p.get("times", '3')))
nxt = [c for c in outs if c.get("fromPort") == 'out']
if nxt:
lines.extend(_emit_chain(nxt[0].get("to"), bmap, adj, indent + 1, visited))
else:
lines.append(ind + " pass")
return lines
if btype in ("logic_branch", "logic_parallel"):
prefix = 'branch' if btype == "logic_branch" else 'p'
names = get_dynamic_outputs(btype, p) or []
for i, port in enumerate(names, 1):
lines.append(ind + "# %s分支%d" % ("并行" if btype == "logic_parallel" else "", i))
br = [c for c in outs if c.get("fromPort") == port]
if br:
lines.extend(_emit_chain(br[0].get("to"), bmap, adj, indent, visited))
else:
lines.append(ind + "pass")
return lines
# 普通块
code = _block_code(b)
if code is not None:
lines.append(ind + code)
# 单输出继续
nxt = [c for c in outs if c.get("fromPort") == 'out']
if nxt:
lines.extend(_emit_chain(nxt[0].get("to"), bmap, adj, indent, visited))
return lines
def compile_graph(graph, meta=None):
"""编译画布 → {success, content, warnings, entry_points} 或 {success:False, errors:[...]}"""
v = validate_graph(graph)
if v["errors"]:
return {"success": False, "valid": False, "errors": v["errors"]}
blocks = graph.get("blocks") or []
conns = graph.get("connections") or []
bmap = {b.get("id"): b for b in blocks if b.get("id")}
adj = {}
for c in conns:
adj.setdefault(c.get("from"), []).append(c)
events = [b for b in blocks if (BLOCK_DEFS.get(b.get("type")) or {}).get("category") == "event"]
lines = []
lines.append("# -*- coding: utf-8 -*-")
lines.append("# 拖拽编程画布编译产物 (drag compiler v1)")
if meta:
lines.append("# 画布: %s (id=%s)" % (meta.get("name", ''), meta.get("id", '')))
lines.append("# 入口事件: %s" % ", ".join(
"%s[%s]" % (e.get("id"), _block_label(e.get("type"))) for e in events))
lines.append("")
# 变量声明区(提升)
var_decls = []
for b in blocks:
if b.get("type") == "var_create":
p = b.get("params") or {}
var_decls.append("%s = %s" % (_ident(p.get("name", '_v')), _val(p.get("initial_value", '0'))))
if var_decls:
lines.append("# ===== 变量声明 =====")
lines.extend(var_decls)
lines.append("")
# 函数定义区
funcs = [b for b in blocks if b.get("type") == "logic_function_def"]
if funcs:
lines.append("# ===== 函数定义 =====")
for fb in funcs:
p = fb.get("params") or {}
lines.append("def %s():" % _ident(p.get("name", 'fn')))
body = _emit_chain(fb.get("id"), bmap, adj, 1, set())
lines.extend(body or [" pass"])
lines.append("")
# 入口执行区(每个事件块一个分支)
if not events:
lines.append("# (无入口事件)")
for ev in events:
lines.append("# ===== 入口: %s [%s] =====" % (ev.get("id"), _block_label(ev.get("type"))))
body = _emit_chain(ev.get("id"), bmap, adj, 0, set())
lines.extend(body or ["pass"])
lines.append("")
content = "\n".join(lines).rstrip() + "\n"
return {"success": True, "valid": True, "content": content,
"warnings": v["warnings"],
"entry_points": [e.get("id") for e in events]}

538
drag/init.py Normal file
View File

@ -0,0 +1,538 @@
# -*- coding: utf-8 -*-
"""drag 模块——拖拽编程画布。
提供
- drag_graph 画布表blocks JSON + connections JSON + 编译产物 content
- drag_template 模板表
- 图校验validate_graph编译compile_graph块定义get_block_defs
- 画布 CRUD + graph-save保存即校验+编译 + compile + validate 端点函数
通过 load_drag() 挂载到 ServerEnv宿主应用 load /drag/api/*.dspy 可用
取库名一律 ServerEnv().get_module_dbname('drag')禁止硬编码
注意ahserver/appPublic 依赖全部为函数内惰性导入保证 blocks/validator/
compiler 核心逻辑可在无宿主环境独立测试部署时 ahserver 环境可用
"""
import json
from .blocks import BLOCK_DEFS, get_block_defs
from .validator import validate_graph
from .compiler import compile_graph
def _server_env():
"""惰性获取 ServerEnv 单例(宿主环境)。"""
from ahserver.serverenv import ServerEnv
return ServerEnv()
def _now():
from appPublic.timeutils import curDateString
return curDateString()
def _new_id():
from appPublic.getID import getID
return getID()
def _dumps(obj):
from appPublic.jsonUtils import dumps
return dumps(obj)
async def get_drag_graph_dbname():
"""drag 业务库名(宿主 get_module_dbname 决定)。"""
try:
return _server_env().get_module_dbname('drag')
except Exception:
return 'scense'
# ═══════════════ 块定义(前端画布 + 校验 + 编译的唯一事实源) ═══════════════
async def get_block_defs_api():
"""返回 {categories, blocks} 供前端渲染左侧块面板。"""
return get_block_defs()
# ═══════════════ 画布 CRUD ═══════════════
async def list_drag_graphs(params=None):
"""分页列表:{list, total},支持 name/status 过滤 + sort/order。"""
params = params or {}
dbname = await get_drag_graph_dbname()
try:
async with _server_env().sqlorContext(dbname) as sor:
where, args = [], []
name = params.get('name')
status = params.get('status')
if name:
where.append('name LIKE %s')
args.append('%%%s%%' % name)
if status:
where.append('status = %s')
args.append(status)
wsql = (' WHERE ' + ' AND '.join(where)) if where else ''
ns = {'page': int(params.get('page', 1) or 1),
'rows': int(params.get('rows', 20) or 20),
'sort': params.get('sort', 'updated_at'),
'order': params.get('order', 'desc')}
recs = await sor.sqlPaging(
'SELECT id, name, description, status, created_at, updated_at '
'FROM drag_graph%s' % wsql, ns, args)
rows = [{'id': r.id, 'name': r.name, 'description': getattr(r, 'description', ''),
'status': r.status, 'created_at': str(getattr(r, 'created_at', '')),
'updated_at': str(getattr(r, 'updated_at', ''))} for r in recs]
return {'list': rows, 'total': ns.get('total', len(rows))}
except Exception as e:
return {'code': 'DB_ERROR', 'message': '查询画布列表失败: %s' % e}
async def get_drag_graph(ns):
"""获取画布详情(含 blocks/connections 解析)。"""
gid = (ns or {}).get('id')
if not gid:
return {'code': 'PARAM_REQUIRED', 'message': '缺少 id', 'field': 'id'}
dbname = await get_drag_graph_dbname()
try:
async with _server_env().sqlorContext(dbname) as sor:
recs = await sor.R('drag_graph', {'id': gid})
if not recs:
return {'code': 'NOT_FOUND', 'message': '画布不存在: %s' % gid}
r = recs[0]
data = {'id': r.id, 'name': r.name, 'description': getattr(r, 'description', ''),
'status': r.status, 'created_at': str(getattr(r, 'created_at', '')),
'updated_at': str(getattr(r, 'updated_at', ''))}
try:
data['blocks'] = json.loads(r.blocks or '[]')
except Exception:
data['blocks'] = []
try:
data['connections'] = json.loads(r.connections or '[]')
except Exception:
data['connections'] = []
data['content'] = r.content or ''
return {'data': data}
except Exception as e:
return {'code': 'DB_ERROR', 'message': '查询画布失败: %s' % e}
async def create_drag_graph(ns):
"""创建画布:校验 blocks/connections → 编译 → 落库。"""
name = (ns or {}).get('name')
if not name or not str(name).strip():
return {'code': 'PARAM_REQUIRED', 'message': '画布名称必填', 'field': 'name'}
if len(str(name)) > 100:
return {'code': 'FIELD_TOO_LONG', 'message': '画布名称不能超过 100 字符', 'field': 'name'}
blocks = ns.get('blocks')
conns = ns.get('connections')
if isinstance(blocks, str):
try:
blocks = json.loads(blocks)
except Exception:
return {'code': 'PARAM_TYPE', 'message': 'blocks 不是合法 JSON', 'field': 'blocks'}
if isinstance(conns, str):
try:
conns = json.loads(conns)
except Exception:
return {'code': 'PARAM_TYPE', 'message': 'connections 不是合法 JSON', 'field': 'connections'}
if blocks is None:
blocks = []
if conns is None:
conns = []
graph = {'blocks': blocks, 'connections': conns}
v = validate_graph(graph)
result = compile_graph(graph, {'name': name})
gid = _new_id()
content = result.get('content', '') if result.get('success') else ''
dbname = await get_drag_graph_dbname()
try:
async with _server_env().sqlorContext(dbname) as sor:
await sor.C('drag_graph', {
'id': gid, 'name': str(name).strip(),
'description': str(ns.get('description') or ''),
'blocks': _dumps(blocks), 'connections': _dumps(conns),
'content': content, 'status': '1',
'created_at': _now(), 'updated_at': _now()})
return {'success': True, 'id': gid, 'valid': v['valid'],
'errors': v['errors'], 'content': content}
except Exception as e:
return {'code': 'DB_ERROR', 'message': '保存画布失败: %s' % e}
async def update_drag_graph(ns):
"""更新画布:校验+编译后写回 content。"""
gid = (ns or {}).get('id')
if not gid:
return {'code': 'PARAM_REQUIRED', 'message': '缺少 id', 'field': 'id'}
blocks = ns.get('blocks')
conns = ns.get('connections')
if isinstance(blocks, str):
try:
blocks = json.loads(blocks)
except Exception:
return {'code': 'PARAM_TYPE', 'message': 'blocks 不是合法 JSON', 'field': 'blocks'}
if isinstance(conns, str):
try:
conns = json.loads(conns)
except Exception:
return {'code': 'PARAM_TYPE', 'message': 'connections 不是合法 JSON', 'field': 'connections'}
if blocks is None:
blocks = []
if conns is None:
conns = []
graph = {'blocks': blocks, 'connections': conns}
v = validate_graph(graph)
result = compile_graph(graph, {'name': ns.get('name') or gid})
content = result.get('content', '') if result.get('success') else ''
dbname = await get_drag_graph_dbname()
try:
async with _server_env().sqlorContext(dbname) as sor:
recs = await sor.R('drag_graph', {'id': gid})
if not recs:
return {'code': 'NOT_FOUND', 'message': '画布不存在: %s' % gid}
up = {'blocks': _dumps(blocks), 'connections': _dumps(conns),
'content': content, 'updated_at': _now()}
if ns.get('name'):
up['name'] = str(ns['name']).strip()
if ns.get('description') is not None:
up['description'] = str(ns['description'])
if ns.get('status'):
up['status'] = str(ns['status'])
await sor.U('drag_graph', up, {'id': gid})
return {'success': True, 'id': gid, 'valid': v['valid'],
'errors': v['errors'], 'content': content}
except Exception as e:
return {'code': 'DB_ERROR', 'message': '更新画布失败: %s' % e}
async def delete_drag_graph(ns):
"""删除画布。"""
gid = (ns or {}).get('id')
if not gid:
return {'code': 'PARAM_REQUIRED', 'message': '缺少 id', 'field': 'id'}
dbname = await get_drag_graph_dbname()
try:
async with _server_env().sqlorContext(dbname) as sor:
await sor.D('drag_graph', {'id': gid})
return {'success': True, 'id': gid}
except Exception as e:
return {'code': 'DB_ERROR', 'message': '删除画布失败: %s' % e}
# ═══════════════ 画布保存(保存即校验+编译) ═══════════════
async def graph_save(ns):
"""保存画布新建或更新blocks/connections 校验+编译后落库。
ns: {id?, name, description?, blocks, connections}
返回 {success, id, valid, errors, content}
"""
gid = (ns or {}).get('id')
if gid:
return await update_drag_graph(ns)
return await create_drag_graph(ns)
# ═══════════════ 编译 / 校验 ═══════════════
async def graph_compile(ns):
"""编译画布 → script_engine 可执行脚本(不落库)。"""
blocks = (ns or {}).get('blocks')
conns = (ns or {}).get('connections')
if isinstance(blocks, str):
try:
blocks = json.loads(blocks)
except Exception:
return {'code': 'PARAM_TYPE', 'message': 'blocks 不是合法 JSON', 'field': 'blocks'}
if isinstance(conns, str):
try:
conns = json.loads(conns)
except Exception:
return {'code': 'PARAM_TYPE', 'message': 'connections 不是合法 JSON', 'field': 'connections'}
graph = {'blocks': blocks or [], 'connections': conns or []}
return compile_graph(graph, {'name': (ns or {}).get('name')})
async def graph_validate(ns):
"""校验画布 → {valid, errors, warnings}(不落库不编译)。"""
blocks = (ns or {}).get('blocks')
conns = (ns or {}).get('connections')
if isinstance(blocks, str):
try:
blocks = json.loads(blocks)
except Exception:
return {'valid': False, 'errors': [{'code': 'PARAM_TYPE', 'message': 'blocks 不是合法 JSON', 'field': 'blocks'}]}
if isinstance(conns, str):
try:
conns = json.loads(conns)
except Exception:
return {'valid': False, 'errors': [{'code': 'PARAM_TYPE', 'message': 'connections 不是合法 JSON', 'field': 'connections'}]}
return validate_graph({'blocks': blocks or [], 'connections': conns or []})
# ═══════════════ 编译产物发布到 script_engine ═══════════════
async def graph_publish(ns):
"""把画布编译产物写入 script_enginescript_type=0 Python 可执行脚本)。
ns: {id 画布id, script_name?}
返回 {success, script_id, script_name}
"""
gid = (ns or {}).get('id')
if not gid:
return {'code': 'PARAM_REQUIRED', 'message': '缺少画布 id', 'field': 'id'}
got = await get_drag_graph({'id': gid})
if got.get('code'):
return got
data = got['data']
result = compile_graph({'blocks': data['blocks'], 'connections': data['connections']},
{'name': data['name'], 'id': gid})
if not result.get('success'):
return {'code': 'COMPILE_ERROR', 'message': '画布校验未通过,无法发布',
'errors': result.get('errors', [])}
script_name = (ns or {}).get('script_name') or ('drag_%s' % data['name'])
se = _server_env()
try:
create_script = getattr(se, 'create_script', None) or getattr(se, 'create_scripts', None)
if create_script is None:
return {'code': 'MODULE_MISSING', 'message': 'script_engine 未挂载,无法发布'}
r = await create_script({'script_name': script_name, 'script_type': '0',
'content': result['content'],
'description': 'drag 画布编译产物 (graph=%s)' % gid})
return {'success': True, 'script_id': r.get('id'), 'script_name': script_name,
'script_result': r}
except Exception as e:
return {'code': 'DB_ERROR', 'message': '发布到 script_engine 失败: %s' % e}
# ═══════════════ 模板 CRUD ═══════════════
async def list_drag_templates(params=None):
"""模板分页列表:{list, total}。"""
params = params or {}
dbname = await get_drag_graph_dbname()
try:
async with _server_env().sqlorContext(dbname) as sor:
where, args = [], []
name = params.get('name')
if name:
where.append('name LIKE %s')
args.append('%%%s%%' % name)
wsql = (' WHERE ' + ' AND '.join(where)) if where else ''
ns = {'page': int(params.get('page', 1) or 1),
'rows': int(params.get('rows', 20) or 20),
'sort': params.get('sort', 'updated_at'),
'order': params.get('order', 'desc')}
recs = await sor.sqlPaging(
'SELECT id, name, category, description, created_at FROM drag_template%s' % wsql,
ns, args)
rows = [{'id': r.id, 'name': r.name, 'category': r.category,
'description': getattr(r, 'description', ''),
'created_at': str(getattr(r, 'created_at', ''))} for r in recs]
return {'list': rows, 'total': ns.get('total', len(rows))}
except Exception as e:
return {'code': 'DB_ERROR', 'message': '查询模板失败: %s' % e}
async def get_drag_template(ns):
"""模板详情(含 blocks/connections 解析)。"""
tid = (ns or {}).get('id')
if not tid:
return {'code': 'PARAM_REQUIRED', 'message': '缺少 id', 'field': 'id'}
dbname = await get_drag_graph_dbname()
try:
async with _server_env().sqlorContext(dbname) as sor:
recs = await sor.R('drag_template', {'id': tid})
if not recs:
return {'code': 'NOT_FOUND', 'message': '模板不存在: %s' % tid}
r = recs[0]
data = {'id': r.id, 'name': r.name, 'category': r.category,
'description': getattr(r, 'description', '')}
try:
data['blocks'] = json.loads(r.blocks or '[]')
except Exception:
data['blocks'] = []
try:
data['connections'] = json.loads(r.connections or '[]')
except Exception:
data['connections'] = []
return {'data': data}
except Exception as e:
return {'code': 'DB_ERROR', 'message': '查询模板失败: %s' % e}
async def create_drag_template(ns):
"""创建模板blocks/connections 校验。"""
name = (ns or {}).get('name')
if not name or not str(name).strip():
return {'code': 'PARAM_REQUIRED', 'message': '模板名称必填', 'field': 'name'}
blocks = ns.get('blocks') or []
conns = ns.get('connections') or []
if isinstance(blocks, str):
try:
blocks = json.loads(blocks)
except Exception:
return {'code': 'PARAM_TYPE', 'message': 'blocks 不是合法 JSON', 'field': 'blocks'}
if isinstance(conns, str):
try:
conns = json.loads(conns)
except Exception:
return {'code': 'PARAM_TYPE', 'message': 'connections 不是合法 JSON', 'field': 'connections'}
v = validate_graph({'blocks': blocks, 'connections': conns})
dbname = await get_drag_graph_dbname()
try:
async with _server_env().sqlorContext(dbname) as sor:
tid = _new_id()
await sor.C('drag_template', {
'id': tid, 'name': str(name).strip(),
'category': str(ns.get('category') or '通用'),
'description': str(ns.get('description') or ''),
'blocks': _dumps(blocks), 'connections': _dumps(conns),
'created_at': _now(), 'updated_at': _now()})
return {'success': True, 'id': tid, 'valid': v['valid'], 'errors': v['errors']}
except Exception as e:
return {'code': 'DB_ERROR', 'message': '保存模板失败: %s' % e}
async def update_drag_template(ns):
"""更新模板。"""
tid = (ns or {}).get('id')
if not tid:
return {'code': 'PARAM_REQUIRED', 'message': '缺少 id', 'field': 'id'}
blocks = ns.get('blocks')
conns = ns.get('connections')
if isinstance(blocks, str):
try:
blocks = json.loads(blocks)
except Exception:
return {'code': 'PARAM_TYPE', 'message': 'blocks 不是合法 JSON', 'field': 'blocks'}
if isinstance(conns, str):
try:
conns = json.loads(conns)
except Exception:
return {'code': 'PARAM_TYPE', 'message': 'connections 不是合法 JSON', 'field': 'connections'}
dbname = await get_drag_graph_dbname()
try:
async with _server_env().sqlorContext(dbname) as sor:
recs = await sor.R('drag_template', {'id': tid})
if not recs:
return {'code': 'NOT_FOUND', 'message': '模板不存在: %s' % tid}
up = {'updated_at': _now()}
if blocks is not None:
up['blocks'] = _dumps(blocks)
if conns is not None:
up['connections'] = _dumps(conns)
if ns.get('name'):
up['name'] = str(ns['name']).strip()
if ns.get('category'):
up['category'] = str(ns['category'])
if ns.get('description') is not None:
up['description'] = str(ns['description'])
await sor.U('drag_template', up, {'id': tid})
return {'success': True, 'id': tid}
except Exception as e:
return {'code': 'DB_ERROR', 'message': '更新模板失败: %s' % e}
async def delete_drag_template(ns):
"""删除模板。"""
tid = (ns or {}).get('id')
if not tid:
return {'code': 'PARAM_REQUIRED', 'message': '缺少 id', 'field': 'id'}
dbname = await get_drag_graph_dbname()
try:
async with _server_env().sqlorContext(dbname) as sor:
await sor.D('drag_template', {'id': tid})
return {'success': True, 'id': tid}
except Exception as e:
return {'code': 'DB_ERROR', 'message': '删除模板失败: %s' % e}
async def template_use(ns):
"""应用模板按模板生成新画布。ns: {template_id, name} → 新画布 id。"""
tid = (ns or {}).get('template_id') or (ns or {}).get('id')
if not tid:
return {'code': 'PARAM_REQUIRED', 'message': '缺少 template_id', 'field': 'template_id'}
got = await get_drag_template({'id': tid})
if got.get('code'):
return got
tpl = got['data']
return await create_drag_graph({'name': ns.get('name') or (tpl['name'] + '_副本'),
'description': '由模板 %s 生成' % tpl['name'],
'blocks': tpl['blocks'], 'connections': tpl['connections']})
# ═══════════════ 实体选择器(下拉数据源) ═══════════════
async def get_entity_options(ns=None):
"""实体下拉选项 [{value, text}]。复用 entity 模块的实体列表。"""
se = _server_env()
list_entities = getattr(se, 'list_entities', None)
if list_entities is None:
return [{'value': 'hero', 'text': 'hero(示例)'}, {'value': 'coin', 'text': 'coin(示例)'}]
try:
r = await list_entities({'page': 1, 'rows': 500})
items = r.get('list') or r.get('data') or []
if isinstance(r, list):
items = r
out = []
for it in items:
if isinstance(it, dict):
out.append({'value': it.get('id'), 'text': it.get('name') or it.get('id')})
else:
out.append({'value': getattr(it, 'id', ''), 'text': getattr(it, 'name', '') or getattr(it, 'id', '')})
return out or [{'value': 'hero', 'text': 'hero(示例)'}, {'value': 'coin', 'text': 'coin(示例)'}]
except Exception:
return [{'value': 'hero', 'text': 'hero(示例)'}, {'value': 'coin', 'text': 'coin(示例)'}]
async def get_template_category_options(ns=None):
"""模板分类下拉。"""
return [{'value': '通用', 'text': '通用'}, {'value': '移动', 'text': '移动'},
{'value': '弹球', 'text': '弹球'}, {'value': '计时', 'text': '计时'}]
# ═══════════════ 辅助:脚本校验白名单(供 script_engine 执行 drag 产物) ═══════════════
def _action_whitelist():
"""drag 编译产物可调用的运行时动作白名单(宿主演绎层实现)。"""
return ['move', 'rotate', 'scale', 'set_property', 'play_animation',
'play_sound', 'show_hide', 'camera', 'wait']
def load_drag():
"""挂载 drag 模块函数到 ServerEnv宿主 init() 调用)。"""
env = _server_env()
env.get_block_defs_api = get_block_defs_api
env.list_drag_graphs = list_drag_graphs
env.get_drag_graph = get_drag_graph
env.create_drag_graph = create_drag_graph
env.update_drag_graph = update_drag_graph
env.delete_drag_graph = delete_drag_graph
env.graph_save = graph_save
env.graph_compile = graph_compile
env.graph_validate = graph_validate
env.graph_publish = graph_publish
env.list_drag_templates = list_drag_templates
env.get_drag_template = get_drag_template
env.create_drag_template = create_drag_template
env.update_drag_template = update_drag_template
env.delete_drag_template = delete_drag_template
env.template_use = template_use
env.get_entity_options = get_entity_options
env.get_template_category_options = get_template_category_options
# CRUD 复数别名CRUD 框架约定create_xxx/update_xxx/delete_xxx
env.create_drag_graphs = create_drag_graph
env.update_drag_graphs = update_drag_graph
env.delete_drag_graphs = delete_drag_graph
env.create_drag_templates = create_drag_template
env.update_drag_templates = update_drag_template
env.delete_drag_templates = delete_drag_template
# 内部导出(测试/扩展用)
env.drag_action_whitelist = _action_whitelist
env.drag_validate_graph = validate_graph
env.drag_compile_graph = compile_graph
print('[drag] load_drag() OK: %d block types registered' % len(BLOCK_DEFS))

276
drag/validator.py Normal file
View File

@ -0,0 +1,276 @@
# -*- coding: utf-8 -*-
"""拖拽编程画布——图校验器。
校验项错误码见 ERR_*
- 无入口没有任何事件块多入口多个事件块v1 允许各自为独立分支
- 孤立块无任何连线不可达块有连线但不在任何入口链上
可达性根 = 事件块 + 函数定义块函数体经函数定义可达
豁免孤立检查声明类块var_create / logic_function_def编译时提升/独立成区
- 连线引用不存在的块或端口 / 自环 / 循环引用 / 重复连线
- 事件块被作为连线目标
- 块参数校验必填缺失 / 类型错误numberbooleanselect/ 数值越界 / 非法标识符
- 变量引用校验@var 引用未创建变量/ 函数调用引用未定义的函数
返回统一结构{valid, errors: [{code, message, block_id, field}], warnings: [...]}
"""
import re
from .blocks import BLOCK_DEFS, get_dynamic_outputs
# 错误码
ERR_MISSING_ID = "BLOCK_MISSING_ID"
ERR_DUP_ID = "BLOCK_DUP_ID"
ERR_UNKNOWN_TYPE = "BLOCK_UNKNOWN_TYPE"
ERR_NO_ENTRY = "NO_ENTRY"
ERR_ORPHAN = "ORPHAN_BLOCK"
ERR_UNREACHABLE = "UNREACHABLE_BLOCK"
ERR_CONN_BLOCK_NOT_FOUND = "CONN_BLOCK_NOT_FOUND"
ERR_CONN_PORT_NOT_FOUND = "CONN_PORT_NOT_FOUND"
ERR_CONN_DUP = "CONN_DUPLICATE"
ERR_CONN_SELF = "CONN_SELF_LOOP"
ERR_CONN_CYCLE = "CONN_CYCLE"
ERR_EVENT_AS_TARGET = "EVENT_AS_TARGET"
ERR_PARAM_REQUIRED = "PARAM_REQUIRED"
ERR_PARAM_TYPE = "PARAM_TYPE"
ERR_PARAM_RANGE = "PARAM_RANGE"
ERR_PARAM_OPTION = "PARAM_OPTION"
ERR_IDENTIFIER = "INVALID_IDENTIFIER"
ERR_VAR_UNDEFINED = "VAR_UNDEFINED"
ERR_FUNC_UNDEFINED = "FUNC_UNDEFINED"
ERR_BAD_GRAPH = "BAD_GRAPH"
_IDENT_RE = re.compile(r'^[A-Za-z_][A-Za-z0-9_]*$')
# 声明类块:编译时被提升(变量)或独立成区(函数定义),不需要参与孤立检查
_DECLARATION_TYPES = ('var_create', 'logic_function_def')
def _err(code, message, block_id=None, field=None):
e = {"code": code, "message": message}
if block_id is not None:
e["block_id"] = block_id
if field is not None:
e["field"] = field
return e
def _is_number(s):
try:
float(s)
return True
except (TypeError, ValueError):
return False
def _is_bool(s):
return s in (True, False, 'true', 'false', '1', '0', 1, 0)
def _block_outputs(btype, params):
"""块的全部输出端口名(含动态端口)。"""
bdef = BLOCK_DEFS.get(btype) or {}
names = [p["name"] for p in (bdef.get("ports") or {}).get("outputs", [])]
names += get_dynamic_outputs(btype, params)
return names
def _block_inputs(btype):
bdef = BLOCK_DEFS.get(btype) or {}
return [p["name"] for p in (bdef.get("ports") or {}).get("inputs", [])]
def _block_category(btype):
bdef = BLOCK_DEFS.get(btype) or {}
return bdef.get("category")
def validate_graph(graph):
"""graph: {blocks: [...], connections: [...]} → {valid, errors, warnings}"""
errors, warnings = [], []
if not isinstance(graph, dict):
return {"valid": False, "errors": [_err(ERR_BAD_GRAPH, "画布结构非法:必须是对象")], "warnings": []}
blocks = graph.get("blocks") or []
conns = graph.get("connections") or []
if not isinstance(blocks, list) or not isinstance(conns, list):
return {"valid": False, "errors": [_err(ERR_BAD_GRAPH, "画布结构非法blocks/connections 必须是数组")], "warnings": []}
bmap = {}
for b in blocks:
bid = b.get("id")
if not bid:
errors.append(_err(ERR_MISSING_ID, "块缺少 id"))
continue
if bid in bmap:
errors.append(_err(ERR_DUP_ID, "块 id 重复:%s" % bid, bid))
continue
bmap[bid] = b
btype = b.get("type")
if btype not in BLOCK_DEFS:
errors.append(_err(ERR_UNKNOWN_TYPE, "未知块类型:%s" % btype, bid))
if not blocks:
return {"valid": False, "errors": [_err(ERR_NO_ENTRY, "画布为空:没有块")], "warnings": []}
# ── 连线基础校验 ──
seen = set()
for c in conns:
frm, to = c.get("from"), c.get("to")
fp, tp = c.get("fromPort"), c.get("toPort")
key = "%s:%s->%s:%s" % (frm, fp, to, tp)
if frm == to:
errors.append(_err(ERR_CONN_SELF, "自环连线:%s" % frm, frm))
if key in seen:
errors.append(_err(ERR_CONN_DUP, "重复连线:%s -> %s" % (frm, to), frm))
seen.add(key)
if frm not in bmap:
errors.append(_err(ERR_CONN_BLOCK_NOT_FOUND, "连线起点块不存在:%s" % frm, None))
continue
if to not in bmap:
errors.append(_err(ERR_CONN_BLOCK_NOT_FOUND, "连线终点块不存在:%s" % to, None))
continue
fb, tb = bmap[frm], bmap[to]
fbtype, tbtype = fb.get("type"), tb.get("type")
# 事件块不能作为目标
if _block_category(tbtype) == "event":
errors.append(_err(ERR_EVENT_AS_TARGET, "事件块不能作为连线目标:%s" % to, to))
# 端口存在性
if fp not in _block_outputs(fbtype, fb.get("params") or {}):
errors.append(_err(ERR_CONN_PORT_NOT_FOUND, "起点端口不存在:%s.%s" % (frm, fp), frm))
if tp not in _block_inputs(tbtype):
errors.append(_err(ERR_CONN_PORT_NOT_FOUND, "终点端口不存在:%s.%s" % (to, tp), to))
# ── 入口检查 ──
events = [b for b in blocks if _block_category(b.get("type")) == "event"]
if not events:
errors.append(_err(ERR_NO_ENTRY, "没有入口:画布必须包含至少一个事件块(开始/点击/悬停/定时/碰撞/键盘)"))
# ── 孤立块 / 可达性 ──
connected_ids = set()
for c in conns:
connected_ids.add(c.get("from"))
connected_ids.add(c.get("to"))
def _exempt_orphan(b):
"""孤立检查豁免:事件入口 + 声明类块。"""
btype = b.get("type")
return _block_category(btype) == "event" or btype in _DECLARATION_TYPES
for b in blocks:
if b.get("id") not in connected_ids and not _exempt_orphan(b):
errors.append(_err(ERR_ORPHAN, "孤立块(没有任何连线):%s [%s]" % (b.get("id"), b.get("type")), b.get("id")))
# BFS 可达性:根 = 事件块 + 函数定义块(函数体经函数定义可达)
adj = {}
for c in conns:
adj.setdefault(c.get("from"), []).append(c)
roots = [b.get("id") for b in events if b.get("id")] + \
[b.get("id") for b in blocks if b.get("type") == "logic_function_def" and b.get("id")]
visited = set()
stack = list(roots)
while stack:
bid = stack.pop()
if bid in visited:
continue
visited.add(bid)
for c in adj.get(bid, []):
if c.get("to") not in visited:
stack.append(c.get("to"))
for b in blocks:
bid = b.get("id")
if bid and bid not in visited and not _exempt_orphan(b):
errors.append(_err(ERR_UNREACHABLE, "不可达块(不在任何事件/函数链上):%s [%s]" % (bid, b.get("type")), bid))
# ── 环检测DFS 三色) ──
WHITE, GRAY, BLACK = 0, 1, 2
color = {bid: WHITE for bid in bmap}
def dfs(bid):
color[bid] = GRAY
for c in adj.get(bid, []):
nxt = c.get("to")
if nxt in color and color[nxt] == GRAY:
return True
if nxt in color and color[nxt] == WHITE:
if dfs(nxt):
return True
color[bid] = BLACK
return False
for bid in list(bmap):
if color.get(bid) == WHITE and dfs(bid):
errors.append(_err(ERR_CONN_CYCLE, "检测到循环引用(环形连线),无法编译", bid))
break
# ── 参数校验 ──
defined_vars = set()
defined_funcs = set()
for b in blocks:
btype = b.get("type")
if btype == "var_create":
nm = (b.get("params") or {}).get("name")
if nm:
defined_vars.add(str(nm).strip())
if btype == "logic_function_def":
nm = (b.get("params") or {}).get("name")
if nm:
defined_funcs.add(str(nm).strip())
for b in blocks:
bid = b.get("id")
btype = b.get("type")
bdef = BLOCK_DEFS.get(btype)
if not bdef:
continue
params = b.get("params") or {}
if not isinstance(params, dict):
errors.append(_err(ERR_PARAM_TYPE, "块参数必须是对象:%s" % bid, bid))
params = {}
for pdef in bdef.get("params", []):
pname = pdef["name"]
val = params.get(pname)
# 必填
if pdef.get("required") and (val is None or str(val).strip() == ""):
errors.append(_err(ERR_PARAM_REQUIRED, "参数「%s」必填" % pdef["label"], bid, pname))
continue
if val is None or str(val).strip() == "":
continue
val = str(val).strip()
# 类型
ut = pdef.get("uitype")
if ut == "number":
if not _is_number(val):
errors.append(_err(ERR_PARAM_TYPE, "参数「%s」必须是数字" % pdef["label"], bid, pname))
else:
f = float(val)
if "min" in pdef and f < pdef["min"]:
errors.append(_err(ERR_PARAM_RANGE, "参数「%s」不能小于 %s" % (pdef["label"], pdef["min"]), bid, pname))
if "max" in pdef and f > pdef["max"]:
errors.append(_err(ERR_PARAM_RANGE, "参数「%s」不能大于 %s" % (pdef["label"], pdef["max"]), bid, pname))
elif ut == "boolean":
if not _is_bool(val):
errors.append(_err(ERR_PARAM_TYPE, "参数「%s」必须是布尔值" % pdef["label"], bid, pname))
elif ut == "select":
opts = pdef.get("options") or []
if opts and val not in [o["value"] for o in opts]:
errors.append(_err(ERR_PARAM_OPTION, "参数「%s」取值非法:%s" % (pdef["label"], val), bid, pname))
# 标识符类参数
if pname in ("name", "result") and ut in ("text", "variable"):
if not _IDENT_RE.match(val):
errors.append(_err(ERR_IDENTIFIER, "参数「%s」必须是合法标识符(字母/数字/下划线,不能以数字开头):%s" % (pdef["label"], val), bid, pname))
# 变量引用校验
if ut in ("expression", "variable"):
for token in re.findall(r'@([A-Za-z_][A-Za-z0-9_]*)', val):
if token not in defined_vars:
errors.append(_err(ERR_VAR_UNDEFINED, "引用了未创建的变量:@%s" % token, bid, pname))
# 函数调用校验
if btype == "logic_function_call":
fname = (params.get("name") or "").strip()
if fname and fname not in defined_funcs:
errors.append(_err(ERR_FUNC_UNDEFINED, "调用了未定义的函数:%s" % fname, bid, "name"))
if btype == "logic_function_def":
fname = (params.get("name") or "").strip()
if not fname:
errors.append(_err(ERR_PARAM_REQUIRED, "函数名必填", bid, "name"))
return {"valid": not errors, "errors": errors, "warnings": warnings}

12
init/data.json Normal file
View File

@ -0,0 +1,12 @@
{
"appcodes": [
{
"parentid": "drag_status",
"parentname": "拖拽画布状态",
"items": [
{"k": "0", "v": "草稿"},
{"k": "1", "v": "启用"}
]
}
]
}

23
json/drag_graph.json Normal file
View File

@ -0,0 +1,23 @@
{
"tblname": "drag_graph",
"params": {
"browserfields": {
"fields": [
{"name": "name", "label": "画布名称", "uitype": "text", "required": true},
{"name": "description", "label": "描述", "uitype": "text"},
{"name": "status", "label": "状态", "uitype": "code", "dataurl": "{{entire_url('../api/get_search_drag_status.dspy')}}", "valuefield": "value", "textfield": "text", "default": "1"},
{"name": "updated_at", "label": "更新时间", "uitype": "text", "readonly": true}
],
"sort": "updated_at",
"order": "desc"
},
"editexclouded": [
{"name": "name", "label": "画布名称", "uitype": "text", "required": true},
{"name": "description", "label": "描述", "uitype": "text"},
{"name": "status", "label": "状态", "uitype": "code", "dataurl": "{{entire_url('../api/get_search_drag_status.dspy')}}", "valuefield": "value", "textfield": "text", "default": "1"}
],
"new_data_url": "{{entire_url('../api/graph_save.dspy')}}",
"update_data_url": "{{entire_url('../api/graph_save.dspy')}}",
"delete_data_url": "{{entire_url('../api/graph_delete.dspy')}}"
}
}

22
json/drag_template.json Normal file
View File

@ -0,0 +1,22 @@
{
"tblname": "drag_template",
"params": {
"browserfields": {
"fields": [
{"name": "name", "label": "模板名称", "uitype": "text", "required": true},
{"name": "category", "label": "分类", "uitype": "code", "dataurl": "{{entire_url('../api/get_search_template_category.dspy')}}", "valuefield": "value", "textfield": "text", "default": "通用"},
{"name": "description", "label": "描述", "uitype": "text"}
],
"sort": "updated_at",
"order": "desc"
},
"editexclouded": [
{"name": "name", "label": "模板名称", "uitype": "text", "required": true},
{"name": "category", "label": "分类", "uitype": "code", "dataurl": "{{entire_url('../api/get_search_template_category.dspy')}}", "valuefield": "value", "textfield": "text", "default": "通用"},
{"name": "description", "label": "描述", "uitype": "text"}
],
"new_data_url": "{{entire_url('../api/template_save.dspy')}}",
"update_data_url": "{{entire_url('../api/template_save.dspy')}}",
"delete_data_url": "{{entire_url('../api/template_delete.dspy')}}"
}
}

34
models/drag_graph.json Normal file
View File

@ -0,0 +1,34 @@
{
"summary": [
{"table": "drag_graph", "desc": "拖拽编程画布", "primary": ["id"]},
{"table": "drag_template", "desc": "拖拽编程模板", "primary": ["id"]}
],
"fields": [
{"table": "drag_graph", "name": "id", "type": "str", "len": 32, "notnull": true, "primary": true, "comment": "画布ID"},
{"table": "drag_graph", "name": "name", "type": "str", "len": 100, "notnull": true, "comment": "画布名称"},
{"table": "drag_graph", "name": "description", "type": "str", "len": 255, "default": "", "comment": "描述"},
{"table": "drag_graph", "name": "blocks", "type": "text", "comment": "块列表 JSON"},
{"table": "drag_graph", "name": "connections", "type": "text", "comment": "连线列表 JSON"},
{"table": "drag_graph", "name": "content", "type": "text", "comment": "编译产物(script_engine Python 脚本)"},
{"table": "drag_graph", "name": "status", "type": "str", "len": 16, "default": "1", "comment": "状态(0草稿/1启用)"},
{"table": "drag_graph", "name": "created_at", "type": "timestamp", "notnull": true, "comment": "创建时间"},
{"table": "drag_graph", "name": "updated_at", "type": "timestamp", "notnull": true, "comment": "更新时间"},
{"table": "drag_template", "name": "id", "type": "str", "len": 32, "notnull": true, "primary": true, "comment": "模板ID"},
{"table": "drag_template", "name": "name", "type": "str", "len": 100, "notnull": true, "comment": "模板名称"},
{"table": "drag_template", "name": "category", "type": "str", "len": 32, "default": "通用", "comment": "模板分类"},
{"table": "drag_template", "name": "description", "type": "str", "len": 255, "default": "", "comment": "描述"},
{"table": "drag_template", "name": "blocks", "type": "text", "comment": "块列表 JSON"},
{"table": "drag_template", "name": "connections", "type": "text", "comment": "连线列表 JSON"},
{"table": "drag_template", "name": "created_at", "type": "timestamp", "notnull": true, "comment": "创建时间"},
{"table": "drag_template", "name": "updated_at", "type": "timestamp", "notnull": true, "comment": "更新时间"}
],
"indexes": [
{"table": "drag_graph", "name": "idx_drag_graph_name", "fields": ["name"]},
{"table": "drag_graph", "name": "idx_drag_graph_status", "fields": ["status"]},
{"table": "drag_template", "name": "idx_drag_template_name", "fields": ["name"]},
{"table": "drag_template", "name": "idx_drag_template_category", "fields": ["category"]}
],
"codes": [
{"table": "drag_graph", "field": "status", "table": "appcodes_kv", "valuefield": "k", "textfield": "v", "cond": "parentid='drag_status'"}
]
}

14
pyproject.toml Normal file
View File

@ -0,0 +1,14 @@
[build-system]
requires = ["setuptools>=45", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "drag"
version = "1.0.0"
description = "元景 W-09 拖拽编程画布模块——拖块连线编译为 script_engine 可执行脚本"
requires-python = ">=3.8"
dependencies = ["sqlor", "bricks_for_python"]
[tool.setuptools.packages.find]
where = ["."]
include = ["drag*"]

79
scripts/load_path.py Normal file
View File

@ -0,0 +1,79 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""drag 模块 RBAC 权限注册(显式路径,禁止通配符)。
角色分层
- any: 静态 JS/CSSdrag-canvas.js / drag-editor.js / drag.css
- logined: .ui 页面与 /api/*.dspy 接口画布/模板/编译/校验/发布
per-module RBAC 模式调用 set_role_perm.py同时注册目录路径与文件路径
"""
import os
import sys
def find_sage_root():
candidates = [
os.path.expanduser('~/repos/sage'),
os.path.expanduser('~/sage'),
'/d/scense/scense_app',
]
for c in candidates:
if os.path.isdir(os.path.join(c, 'wwwroot')):
return c
return None
def main():
sage = find_sage_root()
script_dir = os.path.dirname(os.path.abspath(__file__))
mod = 'drag'
if sage:
sys.path.insert(0, sage)
try:
from set_role_perm import set_role_perm
except Exception as e:
print('[drag] 无法导入 set_role_perm: %s(尝试直接调用)' % e)
set_role_perm = None
else:
print('[drag] 未找到 Sage 根目录,仅打印待注册路径')
set_role_perm = None
# any 角色:静态资源
any_paths = [
'/drag/drag-canvas.js', '/drag/drag-editor.js', '/drag/drag.css',
]
# logined 角色:页面与接口
logined_paths = [
'/drag/index.ui', '/drag/editor.ui',
'/drag/api/block_defs.dspy', '/drag/api/graph_save.dspy',
'/drag/api/graph_list.dspy', '/drag/api/graph_get.dspy',
'/drag/api/graph_delete.dspy', '/drag/api/graph_compile.dspy',
'/drag/api/graph_validate.dspy', '/drag/api/graph_publish.dspy',
'/drag/api/template_save.dspy', '/drag/api/template_list.dspy',
'/drag/api/template_delete.dspy', '/drag/api/template_use.dspy',
'/drag/api/get_search_entity.dspy', '/drag/api/get_search_drag_status.dspy',
'/drag/api/get_search_template_category.dspy',
# CRUD 子目录xls2ui 生成,注册目录+文件)
'/drag/drag_graph', '/drag/drag_graph/index.ui',
'/drag/drag_graph/get_drag_graph.dspy', '/drag/drag_graph/add_drag_graph.dspy',
'/drag/drag_graph/update_drag_graph.dspy', '/drag/drag_graph/delete_drag_graph.dspy',
'/drag/drag_template', '/drag/drag_template/index.ui',
'/drag/drag_template/get_drag_template.dspy', '/drag/drag_template/add_drag_template.dspy',
'/drag/drag_template/update_drag_template.dspy', '/drag/drag_template/delete_drag_template.dspy',
]
for p in any_paths:
if set_role_perm:
set_role_perm(p, 'any')
else:
print('any %s' % p)
for p in logined_paths:
if set_role_perm:
set_role_perm(p, 'logined')
else:
print('logined %s' % p)
print('[drag] load_path done. 若未找到 Sage 根,请手工在中心 load_path.py 添加以上路径。')
if __name__ == '__main__':
main()

89
skill/SKILL.md Normal file
View File

@ -0,0 +1,89 @@
---
name: drag
description: 拖拽编程画布模块W-09——可视化块编排、连线、参数校验、编译为 script_engine 可执行脚本,提供画布/模板 CRUD 与 block_defs/graph_save/graph_compile/graph_validate/graph_publish 接口,通过 load_drag() 挂载。
---
# drag 拖拽编程画布模块
元景 W-09把事件/逻辑/动作/变量/表达式块拖拽连线成流程图,校验后编译为
script_engine 可执行 Python 脚本script_type=0。宿主应用scense/d/scense/scense_app
## 架构
```
bricks 前端 (editor.ui + drag-canvas.js + drag-editor.js)
│ block_defs / graph_save / graph_compile / graph_validate / graph_publish
drag 模块 (init.py 挂载)
├── blocks.py 块定义 JSONcategories + blocks唯一事实源
├── validator.py 图校验(无入口/孤立/环形/参数/变量引用)
└── compiler.py 图 → Python 脚本(变量提升/函数区/事件分支/控制流)
drag_graph / drag_template 表drag 库)+ script_engine 发布
```
## 数据模型
- `drag_graph`id(32 PK)、name(100)、description(255)、blocks(text JSON)、
connections(text JSON)、content(text 编译产物)、status(16, drag_status 0草稿/1启用)、
created_at、updated_at。索引 idx_drag_graph_name/status。
- `drag_template`id、name、category(32, 通用/移动/弹球/计时)、description、
blocks(text)、connections(text)、created_at、updated_at。
- 编码drag_status0 草稿 / 1 启用)→ appcodesinit/data.json Format B
## 块契约block JSON
```json
{"type":"action_move","category":"action","label":"移动","icon":"fa fa-arrows-alt",
"params":[{"name":"entity","label":"目标实体","uitype":"entity","required":true},
{"name":"x","label":"X 位移","uitype":"number","default":"0"}],
"ports":{"inputs":[{"name":"in","label":"上一步"}],
"outputs":[{"name":"out","label":"下一步"}]}}
```
- 事件块category=event无输入端口 = 天然入口,不能作为连线目标。
- 分叉/并行块用 `dynamic_outputs={"param":"branch_count","prefix":"branch"}` 声明动态端口。
- 参数 uitypetext/number/select/boolean/entity/expression/variable/textarea。
- 表达式参数支持 `@变量名` 引用validator 校验已创建变量)。
## 关键端点(/drag/api/*.dspy宿主挂载后 /drag/api/*.dspy
| 端点 | 方法 | 入参 | 返回 |
|------|------|------|------|
| block_defs.dspy | GET | - | {categories, blocks} |
| graph_save.dspy | POST | {id?, name, blocks, connections} | {success, id, valid, errors, content} |
| graph_list.dspy | GET | {page, rows, name?, status?} | {list, total} |
| graph_get.dspy | GET | {id} | {data:{blocks, connections, content}} |
| graph_delete.dspy | POST | {id} | {success} |
| graph_compile.dspy | POST | {name?, blocks, connections} | {success, content, entry_points} |
| graph_validate.dspy | POST | {blocks, connections} | {valid, errors:[{code,message,block_id,field}]} |
| graph_publish.dspy | POST | {id, script_name?} | {success, script_id, script_name} |
| template_save/list/delete/use.dspy | - | - | - |
| get_search_entity.dspy | GET | - | [{value,text}] 实体下拉 |
## 错误码
PARAM_REQUIRED / PARAM_TYPE / PARAM_RANGE / PARAM_OPTION / INVALID_IDENTIFIER /
VAR_UNDEFINED / FUNC_UNDEFINED / BLOCK_UNKNOWN_TYPE / NO_ENTRY / ORPHAN_BLOCK /
UNREACHABLE_BLOCK / CONN_BLOCK_NOT_FOUND / CONN_PORT_NOT_FOUND / CONN_DUPLICATE /
CONN_SELF_LOOP / CONN_CYCLE / EVENT_AS_TARGET / NOT_FOUND / DB_ERROR / COMPILE_ERROR
## 模块陷阱
- 取库名:.py 用 `ServerEnv().get_module_dbname('drag')`.dspy 直接 `get_module_dbname('drag')`
- dspy 无 import/print/uuid显式 returnhelper 在 init.py三处同步注册
drag/__init__.py 导出 ← drag/init.py 实现 ← load_drag() env.xxx 注册)。
- sor.C/U/D/R + sqlPagingcreated_at/updated_at 必须显式设置。
- CRUD 复数别名 create_drag_graphs 等已注册CRUD 框架约定)。
- 编译产物仅白名单语法(赋值/def/for/while/if-else/函数调用/注释),
动作函数move/rotate/scale/set_property/play_animation/play_sound/show_hide/camera/wait
需宿主演绎层注册到 script_engine 白名单。
- 前端 .js/.css 必须在 load_path.py 注册 any 角色,否则 403。
- 修改 blocks.py 后前端自动通过 block_defs.dspy 拿到新定义,无需改前端。
## 验收口径W-09a~W-09au
- 拖块连块存画布graph_save 落库 drag_graphblocks/connections JSON 往返一致。
- 编译产出可执行脚本graph_compile 返回 Python 源码,无校验错误时可被 script_engine 执行。
- 校验能报孤立块/无入口graph_validate 对孤立块报 ORPHAN_BLOCK、无事件块报 NO_ENTRY。
- 正反用例按 feature-granularity-and-testing每功能点至少 1 正例 + 1 反例。

View File

@ -0,0 +1,8 @@
# -*- coding: utf-8 -*-
# block_defs.dspy — 获取块定义categories + blocks前端画布渲染的唯一事实源
# GET /drag/api/block_defs.dspy
try:
result = await get_block_defs_api()
return result
except Exception as e:
return {'success': False, 'message': 'block_defs.dspy: ' + str(e)}

View File

@ -0,0 +1,3 @@
# 画布状态下拉
result = [{'value': '0', 'text': '草稿'}, {'value': '1', 'text': '启用'}]
return {'status': 200, 'data': result}

View File

@ -0,0 +1,8 @@
# -*- coding: utf-8 -*-
# get_search_entity.dspy — 实体下拉entity 参数绑定块时选择目标实体)
# GET /drag/api/get_search_entity.dspy → [{value, text}]
try:
result = await get_entity_options()
return result
except Exception as e:
return {'success': False, 'message': 'get_search_entity.dspy: ' + str(e)}

View File

@ -0,0 +1,3 @@
# 模板分类下拉
result = await get_template_category_options(params_kw)
return {'status': 200, 'data': result}

View File

@ -0,0 +1,9 @@
# -*- coding: utf-8 -*-
# graph_compile.dspy — 编译画布为 script_engine 可执行脚本
# POST /drag/api/graph_compile.dspy body: {name?, blocks, connections}
try:
ns = dict(params_kw)
result = await graph_compile(ns)
return result
except Exception as e:
return {'success': False, 'message': 'graph_compile.dspy: ' + str(e)}

View File

@ -0,0 +1,11 @@
# -*- coding: utf-8 -*-
# graph_delete.dspy — 删除画布
# POST /drag/api/graph_delete.dspy body: {id}
try:
gid = params_kw.get('id')
if not gid:
return {'success': False, 'message': 'graph_delete.dspy: missing id'}
result = await delete_drag_graph({'id': gid})
return result
except Exception as e:
return {'success': False, 'message': 'graph_delete.dspy: ' + str(e)}

View File

@ -0,0 +1,11 @@
# -*- coding: utf-8 -*-
# graph_get.dspy — 获取单个画布blocks / connections / content 往返一致)
# GET /drag/api/graph_get.dspy?id=xxx
try:
gid = params_kw.get('id')
if not gid:
return {'success': False, 'message': 'graph_get.dspy: missing id'}
result = await get_drag_graph({'id': gid})
return result
except Exception as e:
return {'success': False, 'message': 'graph_get.dspy: ' + str(e)}

View File

@ -0,0 +1,9 @@
# -*- coding: utf-8 -*-
# graph_list.dspy — 画布分页列表
# GET /drag/api/graph_list.dspy?page=1&rows=20&name=&status=
try:
ns = dict(params_kw)
result = await list_drag_graphs(ns)
return result
except Exception as e:
return {'success': False, 'message': 'graph_list.dspy: ' + str(e)}

View File

@ -0,0 +1,9 @@
# -*- coding: utf-8 -*-
# graph_publish.dspy — 发布画布到 script_enginescript_type=0
# POST /drag/api/graph_publish.dspy body: {id, script_name?}
try:
ns = dict(params_kw)
result = await graph_publish(ns)
return result
except Exception as e:
return {'success': False, 'message': 'graph_publish.dspy: ' + str(e)}

View File

@ -0,0 +1,11 @@
# -*- coding: utf-8 -*-
# graph_save.dspy — 保存画布(新建/更新,保存即校验+编译)
# POST /drag/api/graph_save.dspy body: {id?, name, description?, blocks, connections}
try:
ns = dict(params_kw)
if 'id' in ns and not ns.get('id'):
ns.pop('id', None)
result = await graph_save(ns)
return result
except Exception as e:
return {'success': False, 'message': 'graph_save.dspy: ' + str(e)}

View File

@ -0,0 +1,9 @@
# -*- coding: utf-8 -*-
# graph_validate.dspy — 校验画布图(无入口/孤立块/环形/参数/变量引用/函数引用)
# POST /drag/api/graph_validate.dspy body: {blocks, connections}
try:
ns = dict(params_kw)
result = await graph_validate(ns)
return result
except Exception as e:
return {'success': False, 'message': 'graph_validate.dspy: ' + str(e)}

View File

@ -0,0 +1,11 @@
# -*- coding: utf-8 -*-
# template_delete.dspy — 删除模板
# POST /drag/api/template_delete.dspy body: {id}
try:
tid = params_kw.get('id')
if not tid:
return {'success': False, 'message': 'template_delete.dspy: missing id'}
result = await delete_drag_template({'id': tid})
return result
except Exception as e:
return {'success': False, 'message': 'template_delete.dspy: ' + str(e)}

View File

@ -0,0 +1,9 @@
# -*- coding: utf-8 -*-
# template_list.dspy — 模板分页列表
# GET /drag/api/template_list.dspy?page=1&rows=20&category=
try:
ns = dict(params_kw)
result = await list_drag_templates(ns)
return result
except Exception as e:
return {'success': False, 'message': 'template_list.dspy: ' + str(e)}

View File

@ -0,0 +1,15 @@
# -*- coding: utf-8 -*-
# template_save.dspy — 保存模板(有 id 走更新,无 id 走新建)
# POST /drag/api/template_save.dspy body: {id?, name, category, description?, blocks, connections}
try:
ns = dict(params_kw)
if 'id' in ns and not ns.get('id'):
ns.pop('id', None)
tid = ns.get('id')
if tid:
result = await update_drag_template(ns)
else:
result = await create_drag_template(ns)
return result
except Exception as e:
return {'success': False, 'message': 'template_save.dspy: ' + str(e)}

View File

@ -0,0 +1,9 @@
# -*- coding: utf-8 -*-
# template_use.dspy — 模板一键生成新画布(复制 blocks/connections
# POST /drag/api/template_use.dspy body: {template_id, name?}
try:
ns = dict(params_kw)
result = await template_use(ns)
return result
except Exception as e:
return {'success': False, 'message': 'template_use.dspy: ' + str(e)}

400
wwwroot/drag-canvas.js Normal file
View File

@ -0,0 +1,400 @@
/**
* drag-canvas.js 拖拽编程画布核心bricks 前端
*
* 功能块面板 拖入画布 / 点击添加 / 移动 / 连线 / 删除 / 选中参数面板 / 保存 / 编译 / 校验
* 数据结构与后端 drag 模块契约一致
* block = {id, type, x, y, params: {}}
* conn = {from, fromPort, to, toPort}
*
* 依赖 bricks 环境bricks_fetch / bricks_toast / eventbus由宿主注入
*/
(function (global) {
'use strict';
var SVG_NS = 'http://www.w3.org/2000/svg';
var blockSeq = 1000;
function uid(prefix) {
blockSeq += 1;
return (prefix || 'b') + '_' + Date.now().toString(36) + '_' + blockSeq;
}
/**
* DragCanvas 构造挂载到 container必须含 .drag-stage 画布区
*/
function DragCanvas(container, opts) {
opts = opts || {};
this.container = container;
this.blocks = opts.blocks || [];
this.connections = opts.connections || [];
this.blockDefs = opts.blockDefs || {categories: [], blocks: {}};
this.api = opts.api || {};
this.onChange = opts.onChange || null;
this.selectedId = null;
this.dragMode = null; // 'block' | 'conn'
this.dragging = null; // {id, startX, startY, origX, origY}
this.connStart = null; // {blockId, port, portType}
this._binds = [];
this._init();
}
DragCanvas.prototype._init = function () {
var self = this;
var stage = this.container.querySelector('.drag-stage');
if (!stage) { return; }
this.stage = stage;
// 连线层SVG 放在画布区底层)
var svg = document.createElementNS(SVG_NS, 'svg');
svg.setAttribute('class', 'drag-lines');
svg.style.position = 'absolute';
svg.style.left = '0';
svg.style.top = '0';
svg.style.width = '100%';
svg.style.height = '100%';
svg.style.pointerEvents = 'none';
svg.style.zIndex = '1';
stage.appendChild(svg);
this.svg = svg;
// 画布区鼠标事件(连线 + 画布空白点击取消选中)
stage.addEventListener('mousedown', function (e) {
if (e.target === stage || e.target === svg) {
self.selectBlock(null);
}
});
stage.addEventListener('mousemove', function (e) { self._onMouseMove(e); });
stage.addEventListener('mouseup', function (e) { self._onMouseUp(e); });
document.addEventListener('mouseup', function () { self._endConn(); });
this.render();
this._emitChange();
};
/* ─────────────── 块渲染 ─────────────── */
DragCanvas.prototype._blockDef = function (type) {
return (this.blockDefs.blocks || {})[type] || null;
};
DragCanvas.prototype.render = function () {
var self = this;
// 清空既有块 DOM连线层保留
var olds = this.container.querySelectorAll('.drag-block');
for (var i = 0; i < olds.length; i++) { olds[i].parentNode.removeChild(olds[i]); }
while (this.svg.lastChild) { this.svg.removeChild(this.svg.lastChild); }
this.blocks.forEach(function (b) {
var def = self._blockDef(b.type);
if (!def) { return; }
var el = document.createElement('div');
el.className = 'drag-block' + (b.id === self.selectedId ? ' selected' : '');
el.dataset.id = b.id;
el.style.left = (b.x || 0) + 'px';
el.style.top = (b.y || 0) + 'px';
var cat = def.category || '';
var color = (self.blockDefs.categories || []).filter(function (c) { return c.key === cat; })[0] || {};
el.style.borderLeftColor = color.color || def.color || '#666';
var head = document.createElement('div');
head.className = 'drag-block-head';
head.innerHTML = '<i class="' + (def.icon || 'fa fa-cube') + '"></i> ' + def.label;
var body = document.createElement('div');
body.className = 'drag-block-body';
var params = b.params || {};
(def.params || []).forEach(function (pd) {
if (pd.uitype === 'entity' || pd.uitype === 'select' || pd.uitype === 'boolean') {
var opt = (pd.options || []).filter(function (o) { return String(o.value) === String(params[pd.name]); })[0];
body.innerHTML += '<div class="drag-param"><span>' + pd.label + '</span><b>' +
(opt ? opt.text : (params[pd.name] !== undefined ? params[pd.name] : '')) + '</b></div>';
} else if (params[pd.name] !== undefined && String(params[pd.name]) !== '') {
body.innerHTML += '<div class="drag-param"><span>' + pd.label + '</span><b>' + params[pd.name] + '</b></div>';
}
});
var ports = document.createElement('div');
ports.className = 'drag-ports';
var inp = document.createElement('div');
inp.className = 'drag-port drag-port-in';
inp.dataset.block = b.id;
inp.dataset.port = 'in';
inp.title = '输入';
var outWrap = document.createElement('div');
outWrap.className = 'drag-out-ports';
// 输出端口(含动态端口)
var outs = def.ports.outputs.map(function (p) { return p.name; });
var dyn = this._dynamicOutputs(def, params);
outs = outs.concat(dyn);
outs.forEach(function (pn) {
var o = document.createElement('div');
o.className = 'drag-port drag-port-out';
o.dataset.block = b.id;
o.dataset.port = pn;
o.title = pn;
outWrap.appendChild(o);
});
ports.appendChild(inp);
ports.appendChild(outWrap);
el.appendChild(head);
el.appendChild(body);
el.appendChild(ports);
// 块交互
el.addEventListener('mousedown', function (e) {
if (e.target.classList.contains('drag-port')) { return; }
self.selectBlock(b.id);
self.dragMode = 'block';
self.dragging = {id: b.id, startX: e.clientX, startY: e.clientY, origX: b.x || 0, origY: b.y || 0};
e.preventDefault();
e.stopPropagation();
});
el.addEventListener('dblclick', function () {
if (self.onEditBlock) { self.onEditBlock(b); }
});
// 端口连线起点
var pm = el.querySelectorAll('.drag-port-out');
for (var k = 0; k < pm.length; k++) {
pm[k].addEventListener('mousedown', function (e) {
e.stopPropagation();
e.preventDefault();
self._startConn(e.currentTarget.dataset.block, e.currentTarget.dataset.port, 'out');
});
}
var pi = el.querySelectorAll('.drag-port-in');
for (var j = 0; j < pi.length; j++) {
pi[j].addEventListener('mousedown', function (e) {
e.stopPropagation();
e.preventDefault();
self._startConn(e.currentTarget.dataset.block, e.currentTarget.dataset.port, 'in');
});
}
stage.appendChild(el);
}, this);
this.renderLines();
};
DragCanvas.prototype._dynamicOutputs = function (def, params) {
var d = def.dynamic_outputs;
if (!d) { return []; }
var n = parseInt(params[d.param], 10);
if (isNaN(n) || n < 2) { n = 2; }
if (n > 8) { n = 8; }
var out = [];
for (var i = 1; i <= n; i++) { out.push(d.prefix + i); }
return out;
};
/* ─────────────── 连线渲染SVG 贝塞尔) ─────────────── */
DragCanvas.prototype._portEl = function (blockId, port, ptype) {
var blocks = this.container.querySelectorAll('.drag-block[data-id="' + blockId + '"]');
if (!blocks.length) { return null; }
var el = blocks[0];
var q = ptype === 'out'
? el.querySelectorAll('.drag-port-out[data-port="' + port + '"]')
: el.querySelectorAll('.drag-port-in[data-port="' + port + '"]');
return q.length ? q[0] : null;
};
DragCanvas.prototype._portPos = function (blockId, port, ptype) {
var el = this._portEl(blockId, port, ptype);
if (!el) { return null; }
var r = el.getBoundingClientRect();
var sr = this.stage.getBoundingClientRect();
return {x: r.left - sr.left + r.width / 2, y: r.top - sr.top + r.height / 2};
};
DragCanvas.prototype._makePath = function (a, b) {
if (!a || !b) { return ''; }
var dx = Math.max(20, Math.abs(b.x - a.x) / 2);
return 'M ' + a.x + ' ' + a.y +
' C ' + (a.x + dx) + ' ' + a.y + ', ' + (b.x - dx) + ' ' + b.y + ', ' + b.x + ' ' + b.y;
};
DragCanvas.prototype.renderLines = function () {
var self = this;
while (this.svg.lastChild) { this.svg.removeChild(this.svg.lastChild); }
this.connections.forEach(function (c) {
var a = self._portPos(c.from, c.fromPort, 'out');
var b = self._portPos(c.to, c.toPort, 'in');
if (!a || !b) { return; }
var path = document.createElementNS(SVG_NS, 'path');
path.setAttribute('d', self._makePath(a, b));
path.setAttribute('stroke', '#7c8aa0');
path.setAttribute('stroke-width', '2');
path.setAttribute('fill', 'none');
self.svg.appendChild(path);
});
// 连线中临时线
if (this.connStart && this.connMouse) {
var tp = document.createElementNS(SVG_NS, 'path');
tp.setAttribute('d', this._makePath(this.connStart.pos, this.connMouse));
tp.setAttribute('stroke', '#4a90d9');
tp.setAttribute('stroke-width', '2');
tp.setAttribute('stroke-dasharray', '6,3');
tp.setAttribute('fill', 'none');
this.svg.appendChild(tp);
}
};
/* ─────────────── 交互:移动 / 连线 / 选中 ─────────────── */
DragCanvas.prototype._onMouseMove = function (e) {
if (this.dragMode === 'block' && this.dragging) {
var d = this.dragging;
var b = this._block(d.id);
if (b) {
b.x = Math.max(0, Math.round(d.origX + (e.clientX - d.startX)));
b.y = Math.max(0, Math.round(d.origY + (e.clientY - d.startY)));
}
var el = this.container.querySelector('.drag-block[data-id="' + d.id + '"]');
if (el) {
el.style.left = (b ? b.x : 0) + 'px';
el.style.top = (b ? b.y : 0) + 'px';
}
this.renderLines();
return;
}
if (this.connStart) {
var sr = this.stage.getBoundingClientRect();
this.connMouse = {x: e.clientX - sr.left, y: e.clientY - sr.top};
this.renderLines();
}
};
DragCanvas.prototype._onMouseUp = function (e) {
if (this.dragMode === 'block' && this.dragging) {
this.dragMode = null;
this.dragging = null;
this._emitChange();
return;
}
if (this.connStart) {
var t = e.target;
if (t && t.classList && t.classList.contains('drag-port')) {
var targetType = t.classList.contains('drag-port-in') ? 'in' : 'out';
this._finishConn(t.dataset.block, t.dataset.port, targetType);
}
this._endConn();
}
};
DragCanvas.prototype._startConn = function (blockId, port, ptype) {
var pos = this._portPos(blockId, port, ptype);
if (!pos) { return; }
this.connStart = {blockId: blockId, port: port, ptype: ptype, pos: pos};
this.connMouse = pos;
};
DragCanvas.prototype._finishConn = function (toBlock, toPort, toType) {
if (!this.connStart) { return; }
var s = this.connStart;
var from, fromPort, to, toPort;
if (s.ptype === 'out' && toType === 'in') {
from = s.blockId; fromPort = s.port; to = toBlock; toPort = toPort;
} else if (s.ptype === 'in' && toType === 'out') {
from = toBlock; fromPort = toPort; to = s.blockId; toPort = s.port;
} else {
return; // 同向端口不允许连线
}
if (from === to) { return; } // 自环拒绝
var key = from + ':' + fromPort + '->' + to + ':' + toPort;
var dup = this.connections.some(function (c) {
return c.from === from && c.fromPort === fromPort && c.to === to && c.toPort === toPort;
});
if (dup) { return; }
this.connections.push({from: from, fromPort: fromPort, to: to, toPort: toPort});
this.renderLines();
this._emitChange();
};
DragCanvas.prototype._endConn = function () {
this.connStart = null;
this.connMouse = null;
this.renderLines();
};
DragCanvas.prototype._block = function (id) {
for (var i = 0; i < this.blocks.length; i++) {
if (this.blocks[i].id === id) { return this.blocks[i]; }
}
return null;
};
DragCanvas.prototype.selectBlock = function (id) {
this.selectedId = id;
var els = this.container.querySelectorAll('.drag-block');
for (var i = 0; i < els.length; i++) {
els[i].classList.toggle('selected', els[i].dataset.id === id);
}
if (this.onSelect) { this.onSelect(id ? this._block(id) : null); }
};
/* ─────────────── 对外操作:加块 / 删块 / 删线 / 取图 ─────────────── */
DragCanvas.prototype.addBlock = function (type, x, y) {
var def = this._blockDef(type);
if (!def) { return null; }
var params = {};
(def.params || []).forEach(function (p) {
if (p.default !== undefined) { params[p.name] = p.default; }
});
var b = {id: uid('b'), type: type, x: x != null ? x : 60 + this.blocks.length * 30,
y: y != null ? y : 60, params: params};
this.blocks.push(b);
this.render();
this.selectBlock(b.id);
this._emitChange();
return b;
};
DragCanvas.prototype.removeBlock = function (id) {
this.blocks = this.blocks.filter(function (b) { return b.id !== id; });
this.connections = this.connections.filter(function (c) { return c.from !== id && c.to !== id; });
if (this.selectedId === id) { this.selectedId = null; }
this.render();
this._emitChange();
};
DragCanvas.prototype.removeConnection = function (from, fromPort, to, toPort) {
this.connections = this.connections.filter(function (c) {
return !(c.from === from && c.fromPort === fromPort && c.to === to && c.toPort === toPort);
});
this.renderLines();
this._emitChange();
};
DragCanvas.prototype.clear = function () {
this.blocks = [];
this.connections = [];
this.selectedId = null;
this.render();
this._emitChange();
};
DragCanvas.prototype.setBlockParams = function (id, params) {
var b = this._block(id);
if (!b) { return; }
b.params = params || {};
this.render();
this._emitChange();
};
DragCanvas.prototype.getGraph = function () {
return {blocks: this.blocks, connections: this.connections};
};
DragCanvas.prototype.setGraph = function (graph) {
this.blocks = (graph && graph.blocks) || [];
this.connections = (graph && graph.connections) || [];
this.selectedId = null;
this.render();
this._emitChange();
};
DragCanvas.prototype._emitChange = function () {
if (this.onChange) { this.onChange(this.getGraph()); }
};
global.DragCanvas = DragCanvas;
})(window);

322
wwwroot/drag-editor.js Normal file
View File

@ -0,0 +1,322 @@
/**
* drag-editor.js 拖拽编程画布编辑器页面逻辑
*
* 组装左侧块面板分类 + 块拖入/点击添加 + 中间画布DragCanvas + 右侧参数面板
* 工具栏保存 / 校验 / 编译 / 发布
*/
(function () {
'use strict';
var state = {
graphId: null,
graphName: '',
blockDefs: {categories: [], blocks: {}},
canvas: null,
editingBlock: null
};
function $(sel, root) { return (root || document).querySelector(sel); }
function toast(msg, type) {
if (window.bricks_toast) { window.bricks_toast(msg, type || 'info'); return; }
alert(msg);
}
function apiFetch(url, data) {
var opts = {method: 'POST', headers: {'Content-Type': 'application/json'}};
if (data !== undefined) { opts.body = JSON.stringify(data); }
return fetch(url, opts).then(function (r) { return r.json(); });
}
function entireUrl(path) {
// 宿主注入 entire_url 模板函数时使用;否则原样
if (window.entire_url) { return window.entire_url(path); }
return path;
}
/* ─────────────── 块面板 ─────────────── */
function renderBlockPanel() {
var panel = $('#block-panel');
if (!panel) { return; }
panel.innerHTML = '';
(state.blockDefs.categories || []).forEach(function (cat) {
var sec = document.createElement('div');
sec.className = 'drag-cat';
var title = document.createElement('div');
title.className = 'drag-cat-title';
title.style.borderLeftColor = cat.color;
title.textContent = cat.label;
sec.appendChild(title);
var list = document.createElement('div');
list.className = 'drag-cat-blocks';
Object.keys(state.blockDefs.blocks || {}).forEach(function (type) {
var def = state.blockDefs.blocks[type];
if (def.category !== cat.key) { return; }
var item = document.createElement('div');
item.className = 'drag-block-item';
item.draggable = true;
item.dataset.type = type;
item.innerHTML = '<i class="' + (def.icon || 'fa fa-cube') + '"></i><span>' + def.label + '</span>';
item.title = def.description || def.label;
// 拖入画布
item.addEventListener('dragstart', function (e) {
e.dataTransfer.setData('text/plain', type);
});
// 点击添加
item.addEventListener('click', function () {
if (state.canvas) { state.canvas.addBlock(type); }
});
list.appendChild(item);
});
sec.appendChild(list);
panel.appendChild(sec);
});
}
/* ─────────────── 参数面板 ─────────────── */
function renderParamPanel(block) {
state.editingBlock = block;
var panel = $('#param-panel');
if (!panel) { return; }
panel.innerHTML = '';
if (!block) {
panel.innerHTML = '<div class="drag-param-empty">选中块后在此编辑参数</div>';
return;
}
var def = (state.blockDefs.blocks || {})[block.type];
if (!def) { return; }
var title = document.createElement('div');
title.className = 'drag-param-title';
title.innerHTML = '<i class="' + (def.icon || '') + '"></i> ' + def.label +
' <span class="drag-param-desc">' + (def.description || '') + '</span>';
panel.appendChild(title);
var delBtn = document.createElement('button');
delBtn.className = 'drag-btn drag-btn-danger';
delBtn.textContent = '删除该块';
delBtn.addEventListener('click', function () {
if (state.canvas) { state.canvas.removeBlock(block.id); }
renderParamPanel(null);
});
panel.appendChild(delBtn);
var form = document.createElement('div');
form.className = 'drag-param-form';
(def.params || []).forEach(function (pd) {
var row = document.createElement('div');
row.className = 'drag-param-row';
var label = document.createElement('label');
label.textContent = pd.label + (pd.required ? ' *' : '');
var input = null;
var val = (block.params || {})[pd.name] !== undefined ? (block.params || {})[pd.name] : (pd.default !== undefined ? pd.default : '');
if (pd.uitype === 'select' || pd.uitype === 'boolean') {
input = document.createElement('select');
(pd.options || []).forEach(function (o) {
var op = document.createElement('option');
op.value = o.value;
op.textContent = o.text;
if (String(o.value) === String(val)) { op.selected = true; }
input.appendChild(op);
});
} else if (pd.uitype === 'entity') {
input = document.createElement('select');
input.innerHTML = '<option value="">请选择实体…</option>';
apiFetch(entireUrl('/drag/api/get_search_entity.dspy'), {}).then(function (r) {
var items = (r && r.data) || [];
items.forEach(function (it) {
var op = document.createElement('option');
op.value = it.value;
op.textContent = it.text;
if (String(it.value) === String(val)) { op.selected = true; }
input.appendChild(op);
});
});
} else if (pd.uitype === 'number') {
input = document.createElement('input');
input.type = 'number';
if (pd.min !== undefined) { input.min = pd.min; }
if (pd.max !== undefined) { input.max = pd.max; }
input.value = val;
} else if (pd.uitype === 'expression' || pd.uitype === 'variable') {
input = document.createElement('input');
input.type = 'text';
input.placeholder = pd.placeholder || '';
input.value = val;
} else {
input = document.createElement('input');
input.type = 'text';
input.placeholder = pd.placeholder || '';
input.value = val;
}
input.dataset.field = pd.name;
input.addEventListener('change', function () {
if (!state.canvas || !state.editingBlock) { return; }
var p = state.editingBlock.params || {};
p[pd.name] = input.value;
state.canvas.setBlockParams(state.editingBlock.id, p);
// 动态端口(分叉/并行)变化后重渲染
if (def.dynamic_outputs && def.dynamic_outputs.param === pd.name) {
state.canvas.render();
}
});
row.appendChild(label);
row.appendChild(input);
form.appendChild(row);
});
panel.appendChild(form);
}
/* ─────────────── 工具栏动作 ─────────────── */
function doSave() {
if (!state.canvas) { return; }
var g = state.canvas.getGraph();
var payload = {
id: state.graphId || undefined,
name: state.graphName || '未命名画布',
blocks: g.blocks,
connections: g.connections
};
apiFetch(entireUrl('/drag/api/graph_save.dspy'), payload).then(function (r) {
if (r.status === 200 && r.data && r.data.success) {
state.graphId = r.data.id;
toast('保存成功' + (r.data.valid ? '' : '(存在校验错误,见校验结果)'), 'success');
if (!r.data.valid) { showValidate(r.data.errors); }
} else {
toast((r.message) || '保存失败', 'error');
}
});
}
function doValidate() {
if (!state.canvas) { return; }
var g = state.canvas.getGraph();
apiFetch(entireUrl('/drag/api/graph_validate.dspy'),
{blocks: g.blocks, connections: g.connections}).then(function (r) {
var d = r.data || {};
showValidate(d.errors || []);
if (d.valid) { toast('校验通过 ✓', 'success'); }
});
}
function showValidate(errors) {
var box = $('#validate-result');
if (!box) { return; }
if (!errors || !errors.length) {
box.innerHTML = '<div class="drag-ok">校验通过:无错误</div>';
return;
}
var html = '<div class="drag-err">校验发现 ' + errors.length + ' 个问题:</div><ul>';
errors.forEach(function (e) {
html += '<li>[' + (e.code || '') + '] ' + (e.message || '') +
(e.block_id ? ' (块 ' + e.block_id + '' : '') + '</li>';
});
html += '</ul>';
box.innerHTML = html;
}
function doCompile() {
if (!state.canvas) { return; }
var g = state.canvas.getGraph();
apiFetch(entireUrl('/drag/api/graph_compile.dspy'),
{name: state.graphName, blocks: g.blocks, connections: g.connections}).then(function (r) {
var d = r.data || {};
var out = $('#compile-output');
if (!out) { return; }
if (d.success) {
out.innerHTML = '<pre class="drag-code">' + escapeHtml(d.content || '') + '</pre>';
showValidate([]);
} else {
out.innerHTML = '';
showValidate(d.errors || []);
}
});
}
function doPublish() {
if (!state.graphId) { toast('请先保存画布再发布', 'error'); return; }
apiFetch(entireUrl('/drag/api/graph_publish.dspy'), {id: state.graphId}).then(function (r) {
if (r.status === 200 && r.data && r.data.success) {
toast('已发布到脚本引擎:' + (r.data.script_name || ''), 'success');
} else {
toast((r.data && r.data.message) || '发布失败', 'error');
}
});
}
function escapeHtml(s) {
return String(s || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
/* ─────────────── 初始化 ─────────────── */
function init() {
// 画布名称
var nameInput = $('#graph-name');
if (nameInput) {
nameInput.addEventListener('change', function () { state.graphName = nameInput.value; });
}
// 工具栏按钮
var saveBtn = $('#btn-save');
var valBtn = $('#btn-validate');
var cmpBtn = $('#btn-compile');
var pubBtn = $('#btn-publish');
if (saveBtn) { saveBtn.addEventListener('click', doSave); }
if (valBtn) { valBtn.addEventListener('click', doValidate); }
if (cmpBtn) { cmpBtn.addEventListener('click', doCompile); }
if (pubBtn) { pubBtn.addEventListener('click', doPublish); }
// 画布区拖放(从块面板拖入)
var stage = $('#canvas-area');
if (stage) {
stage.addEventListener('dragover', function (e) { e.preventDefault(); });
stage.addEventListener('drop', function (e) {
e.preventDefault();
var type = e.dataTransfer.getData('text/plain');
if (!type || !state.canvas) { return; }
var rect = stage.getBoundingClientRect();
state.canvas.addBlock(type, e.clientX - rect.left - 60, e.clientY - rect.top - 20);
});
}
// 加载块定义 → 初始化画布
apiFetch(entireUrl('/drag/api/block_defs.dspy'), {}).then(function (r) {
state.blockDefs = (r && r.data) || {categories: [], blocks: {}};
renderBlockPanel();
var container = $('#canvas-area');
if (!container) { return; }
state.canvas = new DragCanvas(container, {
blocks: [],
connections: [],
blockDefs: state.blockDefs,
onSelect: function (b) { renderParamPanel(b); },
onChange: function () { /* 可做脏标记 */ }
});
// 载入已保存画布
loadGraphIfAny();
});
}
function loadGraphIfAny() {
// 通过查询参数 ?graph_id=xxx 载入画布
var m = location.search.match(/[?&]graph_id=([^&]+)/);
if (!m || !state.canvas) { return; }
apiFetch(entireUrl('/drag/api/graph_get.dspy'), {id: decodeURIComponent(m[1])}).then(function (r) {
if (r.status === 200 && r.data) {
state.graphId = r.data.id;
state.graphName = r.data.name || '';
var nameInput = $('#graph-name');
if (nameInput) { nameInput.value = state.graphName; }
state.canvas.setGraph({blocks: r.data.blocks || [], connections: r.data.connections || []});
}
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();

86
wwwroot/drag.css Normal file
View File

@ -0,0 +1,86 @@
/* drag 模块样式 */
.drag-layout { display: flex; height: calc(100vh - 60px); width: 100%; }
.drag-panel { border: 1px solid #e0e6ed; background: #fff; overflow-y: auto; }
.drag-panel-left { width: 220px; flex: none; }
.drag-panel-right { width: 300px; flex: none; }
.drag-canvas-area {
flex: 1; position: relative; overflow: hidden;
background: #f4f6f9 url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='20' height='20'><path d='M 20 0 L 0 0 0 20' fill='none' stroke='%23e3e8ef' stroke-width='1'/></svg>");
}
.drag-stage { position: absolute; left: 0; top: 0; width: 100%; height: 100%; }
.drag-toolbar {
display: flex; align-items: center; gap: 8px; padding: 8px 12px;
background: #fff; border-bottom: 1px solid #e0e6ed;
}
.drag-toolbar input { padding: 5px 8px; border: 1px solid #d0d7e2; border-radius: 4px; width: 220px; }
.drag-btn {
padding: 6px 14px; border: 1px solid #c9d3e0; border-radius: 4px; background: #fff;
cursor: pointer; font-size: 13px;
}
.drag-btn:hover { background: #f0f4fa; }
.drag-btn-primary { background: #4a90d9; border-color: #4a90d9; color: #fff; }
.drag-btn-primary:hover { background: #3a7fc4; }
.drag-btn-success { background: #4caf50; border-color: #4caf50; color: #fff; }
.drag-btn-danger { background: #e05050; border-color: #e05050; color: #fff; }
.drag-cat { margin-bottom: 6px; }
.drag-cat-title {
padding: 6px 10px; font-weight: 600; font-size: 13px; border-left: 4px solid #999;
background: #fafbfc;
}
.drag-cat-blocks { padding: 4px; }
.drag-block-item {
display: flex; align-items: center; gap: 8px; padding: 7px 10px; margin: 3px 0;
border: 1px solid #dde4ec; border-radius: 4px; cursor: grab; background: #fff; font-size: 13px;
}
.drag-block-item:hover { border-color: #4a90d9; background: #f0f6fd; }
.drag-block-item i { width: 16px; color: #4a90d9; }
.drag-block {
position: absolute; min-width: 140px; max-width: 200px; background: #fff;
border: 1px solid #d0d7e2; border-left: 4px solid #666; border-radius: 6px;
box-shadow: 0 2px 6px rgba(30, 50, 80, .12); cursor: move; z-index: 2; user-select: none;
}
.drag-block.selected { border-color: #4a90d9; box-shadow: 0 0 0 2px rgba(74, 144, 217, .35); }
.drag-block-head {
padding: 5px 8px; font-size: 12px; font-weight: 600; color: #333;
border-bottom: 1px solid #eef1f5; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.drag-block-body { padding: 4px 8px; font-size: 11px; color: #667; }
.drag-param { display: flex; justify-content: space-between; gap: 6px; margin: 2px 0; }
.drag-param span { color: #889; flex: none; }
.drag-param b { color: #334; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.drag-ports { position: relative; height: 0; }
.drag-port {
position: absolute; width: 12px; height: 12px; border-radius: 50%;
background: #fff; border: 2px solid #7c8aa0; z-index: 5;
}
.drag-port:hover { background: #4a90d9; border-color: #4a90d9; }
.drag-port-in { left: -7px; top: 0; }
.drag-out-ports { position: absolute; right: -7px; top: 0; display: flex; gap: 2px; }
.drag-port-out { position: static; }
.drag-param-panel { padding: 10px; }
.drag-param-empty { color: #99a; font-size: 13px; padding: 20px 10px; text-align: center; }
.drag-param-title { font-weight: 600; font-size: 14px; margin-bottom: 10px; color: #333; }
.drag-param-desc { display: block; font-weight: 400; font-size: 11px; color: #99a; margin-top: 4px; }
.drag-param-form { margin-top: 10px; }
.drag-param-row { margin-bottom: 10px; }
.drag-param-row label { display: block; font-size: 12px; color: #556; margin-bottom: 4px; }
.drag-param-row input, .drag-param-row select {
width: 100%; padding: 6px 8px; border: 1px solid #d0d7e2; border-radius: 4px; box-sizing: border-box; font-size: 13px;
}
.drag-param-form .drag-btn { width: 100%; margin-top: 6px; }
.drag-result { padding: 10px; font-size: 13px; }
.drag-err { color: #c0392b; font-weight: 600; margin-bottom: 6px; }
.drag-ok { color: #27ae60; font-weight: 600; }
.drag-result ul { margin: 0; padding-left: 18px; color: #555; }
.drag-result li { margin: 3px 0; }
.drag-code {
background: #26303d; color: #c8e6c9; padding: 12px; border-radius: 6px;
font-family: 'Courier New', monospace; font-size: 12px; overflow: auto; white-space: pre;
max-height: 300px;
}
.drag-section-title { padding: 8px 10px; font-weight: 600; font-size: 12px; background: #f0f4fa; border-bottom: 1px solid #e0e6ed; }

70
wwwroot/editor.ui Normal file
View File

@ -0,0 +1,70 @@
{
"widgettype": "VBox",
"options": {"width": "100%", "height": "100%", "padding": "0", "backgroundColor": "#f4f6f9"},
"subwidgets": [
{
"widgettype": "VBox",
"options": {"width": "100%", "padding": "0"},
"subwidgets": [
{
"widgettype": "HBox",
"options": {"width": "100%", "align": "center", "padding": "0 12px", "height": "48px", "backgroundColor": "#fff", "borderBottom": "1px solid #e0e6ed"},
"subwidgets": [
{"widgettype": "Text", "options": {"label": "拖拽编程画布", "fontSize": "16px", "fontWeight": "bold"}},
{"widgettype": "Text", "options": {"label": "名称", "fontSize": "13px", "marginLeft": "24px"}},
{"widgettype": "Input", "id": "graph-name", "options": {"placeholder": "输入画布名称", "width": "220px", "marginLeft": "6px"}}
]
},
{
"widgettype": "HBox",
"options": {"width": "100%", "padding": "8px 12px", "backgroundColor": "#fff", "borderBottom": "1px solid #e0e6ed", "gap": "8px"},
"subwidgets": [
{"widgettype": "Button", "id": "btn-save", "options": {"label": "保存画布", "buttonType": "primary"}},
{"widgettype": "Button", "id": "btn-validate", "options": {"label": "校验", "buttonType": "default"}},
{"widgettype": "Button", "id": "btn-compile", "options": {"label": "编译脚本", "buttonType": "success"}},
{"widgettype": "Button", "id": "btn-publish", "options": {"label": "发布到脚本引擎", "buttonType": "warning"}}
]
}
]
},
{
"widgettype": "HBox",
"options": {"width": "100%", "height": "100%", "align": "stretch"},
"subwidgets": [
{
"widgettype": "VBox",
"id": "block-panel",
"options": {"width": "220px", "backgroundColor": "#fff", "borderRight": "1px solid #e0e6ed", "overflow": "auto"}
},
{
"widgettype": "VBox",
"id": "canvas-area",
"options": {"width": "100%", "height": "100%", "position": "relative", "overflow": "hidden"}
},
{
"widgettype": "VBox",
"options": {"width": "300px", "backgroundColor": "#fff", "borderLeft": "1px solid #e0e6ed"},
"subwidgets": [
{
"widgettype": "VBox",
"options": {"width": "100%", "padding": "0"},
"subwidgets": [
{"widgettype": "Text", "options": {"label": "参数面板", "fontSize": "13px", "fontWeight": "bold", "padding": "8px 10px", "backgroundColor": "#f0f4fa"}},
{"widgettype": "VBox", "id": "param-panel", "options": {"width": "100%", "padding": "10px"}}
]
},
{
"widgettype": "VBox",
"options": {"width": "100%", "padding": "0", "marginTop": "12px"},
"subwidgets": [
{"widgettype": "Text", "options": {"label": "校验/编译结果", "fontSize": "13px", "fontWeight": "bold", "padding": "8px 10px", "backgroundColor": "#f0f4fa"}},
{"widgettype": "VBox", "id": "validate-result", "options": {"width": "100%", "padding": "10px"}},
{"widgettype": "VBox", "id": "compile-output", "options": {"width": "100%", "padding": "10px"}}
]
}
]
}
]
}
]
}

41
wwwroot/index.ui Normal file
View File

@ -0,0 +1,41 @@
{
"widgettype": "VBox",
"options": {"width": "100%", "height": "100%", "padding": "20px", "backgroundColor": "#f4f6f9"},
"subwidgets": [
{"widgettype": "Text", "options": {"label": "拖拽编程", "fontSize": "24px", "fontWeight": "bold", "color": "#2c3e50"}},
{"widgettype": "Text", "options": {"label": "W-09 拖拽编程画布模块:拖拽块、连线、编译为可执行脚本", "fontSize": "13px", "color": "#7f8c8d", "marginTop": "4px"}},
{"widgettype": "ResponsableBox", "options": {"gap": "16px", "minWidth": "240px", "marginTop": "20px"}, "subwidgets": [
{
"widgettype": "VBox",
"options": {"backgroundColor": "#FFFFFF", "padding": "20px", "cursor": "pointer", "borderRadius": "8px", "border": "1px solid #e0e6ed"},
"binds": [{"wid": "self", "event": "click", "actiontype": "urlwidget", "target": "app.drag_content",
"options": {"url": "{{entire_url('editor.ui')}}", "method": "GET"}, "mode": "replace"}],
"subwidgets": [
{"widgettype": "Text", "options": {"label": "新建画布", "fontSize": "16px", "fontWeight": "bold"}},
{"widgettype": "Text", "options": {"label": "打开拖拽编辑器,拖块连线、设置参数、编译脚本", "fontSize": "12px", "color": "#7f8c8d", "marginTop": "6px"}}
]
},
{
"widgettype": "VBox",
"options": {"backgroundColor": "#FFFFFF", "padding": "20px", "cursor": "pointer", "borderRadius": "8px", "border": "1px solid #e0e6ed"},
"binds": [{"wid": "self", "event": "click", "actiontype": "urlwidget", "target": "app.drag_content",
"options": {"url": "{{entire_url('/drag/drag_graph_list')}}", "method": "GET"}, "mode": "replace"}],
"subwidgets": [
{"widgettype": "Text", "options": {"label": "画布管理", "fontSize": "16px", "fontWeight": "bold"}},
{"widgettype": "Text", "options": {"label": "已保存画布列表:查看/编辑/删除/发布", "fontSize": "12px", "color": "#7f8c8d", "marginTop": "6px"}}
]
},
{
"widgettype": "VBox",
"options": {"backgroundColor": "#FFFFFF", "padding": "20px", "cursor": "pointer", "borderRadius": "8px", "border": "1px solid #e0e6ed"},
"binds": [{"wid": "self", "event": "click", "actiontype": "urlwidget", "target": "app.drag_content",
"options": {"url": "{{entire_url('/drag/drag_template_list')}}", "method": "GET"}, "mode": "replace"}],
"subwidgets": [
{"widgettype": "Text", "options": {"label": "模板管理", "fontSize": "16px", "fontWeight": "bold"}},
{"widgettype": "Text", "options": {"label": "常用流程模板:一键应用生成新画布", "fontSize": "12px", "color": "#7f8c8d", "marginTop": "6px"}}
]
}
]},
{"widgettype": "VBox", "id": "app.drag_content", "options": {"width": "100%", "flex": "1", "marginTop": "20px"}}
]
}