feat: 元景 drag 模块(W-0x 开发产出)
This commit is contained in:
commit
2702c9324f
12
.gitignore
vendored
Normal file
12
.gitignore
vendored
Normal 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
88
README.md
Normal 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.json(CRUD)
|
||||
├── init/data.json # appcodes(drag_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,显式 return;helper 全部在 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
21
build.sh
Normal 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"
|
||||
|
||||
# 宿主应用 wwwroot(scense:/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
29
drag/__init__.py
Normal file
@ -0,0 +1,29 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""drag——元景 W-09 拖拽编程画布模块。
|
||||
|
||||
通过 load_drag() 挂载到宿主应用(scense);wwwroot/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
342
drag/blocks.py
Normal 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
275
drag/compiler.py
Normal file
@ -0,0 +1,275 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""拖拽编程画布——图编译为 script_engine 可执行脚本(Python,script_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
538
drag/init.py
Normal 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_engine(script_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
276
drag/validator.py
Normal file
@ -0,0 +1,276 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""拖拽编程画布——图校验器。
|
||||
|
||||
校验项(错误码见 ERR_*):
|
||||
- 无入口(没有任何事件块)/多入口(多个事件块,v1 允许,各自为独立分支)
|
||||
- 孤立块(无任何连线)/不可达块(有连线但不在任何入口链上)
|
||||
—— 可达性根 = 事件块 + 函数定义块(函数体经函数定义可达)
|
||||
—— 豁免孤立检查:声明类块(var_create / logic_function_def,编译时提升/独立成区)
|
||||
- 连线引用不存在的块或端口 / 自环 / 循环引用 / 重复连线
|
||||
- 事件块被作为连线目标
|
||||
- 块参数校验:必填缺失 / 类型错误(number、boolean、select)/ 数值越界 / 非法标识符
|
||||
- 变量引用校验(@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
12
init/data.json
Normal file
@ -0,0 +1,12 @@
|
||||
{
|
||||
"appcodes": [
|
||||
{
|
||||
"parentid": "drag_status",
|
||||
"parentname": "拖拽画布状态",
|
||||
"items": [
|
||||
{"k": "0", "v": "草稿"},
|
||||
{"k": "1", "v": "启用"}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
23
json/drag_graph.json
Normal file
23
json/drag_graph.json
Normal 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
22
json/drag_template.json
Normal 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
34
models/drag_graph.json
Normal 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
14
pyproject.toml
Normal 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
79
scripts/load_path.py
Normal file
@ -0,0 +1,79 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""drag 模块 RBAC 权限注册(显式路径,禁止通配符)。
|
||||
|
||||
角色分层:
|
||||
- any: 静态 JS/CSS(drag-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
89
skill/SKILL.md
Normal 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 块定义 JSON(categories + 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_status(0 草稿 / 1 启用)→ appcodes(init/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"}` 声明动态端口。
|
||||
- 参数 uitype:text/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;显式 return;helper 在 init.py;三处同步注册
|
||||
(drag/__init__.py 导出 ← drag/init.py 实现 ← load_drag() env.xxx 注册)。
|
||||
- sor.C/U/D/R + sqlPaging;created_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_graph,blocks/connections JSON 往返一致。
|
||||
- 编译产出可执行脚本:graph_compile 返回 Python 源码,无校验错误时可被 script_engine 执行。
|
||||
- 校验能报孤立块/无入口:graph_validate 对孤立块报 ORPHAN_BLOCK、无事件块报 NO_ENTRY。
|
||||
- 正反用例按 feature-granularity-and-testing:每功能点至少 1 正例 + 1 反例。
|
||||
8
wwwroot/api/block_defs.dspy
Normal file
8
wwwroot/api/block_defs.dspy
Normal 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)}
|
||||
3
wwwroot/api/get_search_drag_status.dspy
Normal file
3
wwwroot/api/get_search_drag_status.dspy
Normal file
@ -0,0 +1,3 @@
|
||||
# 画布状态下拉
|
||||
result = [{'value': '0', 'text': '草稿'}, {'value': '1', 'text': '启用'}]
|
||||
return {'status': 200, 'data': result}
|
||||
8
wwwroot/api/get_search_entity.dspy
Normal file
8
wwwroot/api/get_search_entity.dspy
Normal 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)}
|
||||
3
wwwroot/api/get_search_template_category.dspy
Normal file
3
wwwroot/api/get_search_template_category.dspy
Normal file
@ -0,0 +1,3 @@
|
||||
# 模板分类下拉
|
||||
result = await get_template_category_options(params_kw)
|
||||
return {'status': 200, 'data': result}
|
||||
9
wwwroot/api/graph_compile.dspy
Normal file
9
wwwroot/api/graph_compile.dspy
Normal 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)}
|
||||
11
wwwroot/api/graph_delete.dspy
Normal file
11
wwwroot/api/graph_delete.dspy
Normal 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)}
|
||||
11
wwwroot/api/graph_get.dspy
Normal file
11
wwwroot/api/graph_get.dspy
Normal 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)}
|
||||
9
wwwroot/api/graph_list.dspy
Normal file
9
wwwroot/api/graph_list.dspy
Normal 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)}
|
||||
9
wwwroot/api/graph_publish.dspy
Normal file
9
wwwroot/api/graph_publish.dspy
Normal file
@ -0,0 +1,9 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# graph_publish.dspy — 发布画布到 script_engine(script_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)}
|
||||
11
wwwroot/api/graph_save.dspy
Normal file
11
wwwroot/api/graph_save.dspy
Normal 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)}
|
||||
9
wwwroot/api/graph_validate.dspy
Normal file
9
wwwroot/api/graph_validate.dspy
Normal 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)}
|
||||
11
wwwroot/api/template_delete.dspy
Normal file
11
wwwroot/api/template_delete.dspy
Normal 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)}
|
||||
9
wwwroot/api/template_list.dspy
Normal file
9
wwwroot/api/template_list.dspy
Normal 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)}
|
||||
15
wwwroot/api/template_save.dspy
Normal file
15
wwwroot/api/template_save.dspy
Normal 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)}
|
||||
9
wwwroot/api/template_use.dspy
Normal file
9
wwwroot/api/template_use.dspy
Normal 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
400
wwwroot/drag-canvas.js
Normal 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
322
wwwroot/drag-editor.js
Normal 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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
/* ─────────────── 初始化 ─────────────── */
|
||||
|
||||
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
86
wwwroot/drag.css
Normal 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
70
wwwroot/editor.ui
Normal 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
41
wwwroot/index.ui
Normal 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"}}
|
||||
]
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user