From 2702c9324f85cccde6b674d37cd47681f9af9496 Mon Sep 17 00:00:00 2001 From: pipeline-agent Date: Sat, 29 Aug 2026 21:10:23 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=85=83=E6=99=AF=20drag=20=E6=A8=A1?= =?UTF-8?q?=E5=9D=97=EF=BC=88W-0x=20=E5=BC=80=E5=8F=91=E4=BA=A7=E5=87=BA?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 12 + README.md | 88 +++ build.sh | 21 + drag/__init__.py | 29 + drag/blocks.py | 342 +++++++++++ drag/compiler.py | 275 +++++++++ drag/init.py | 538 ++++++++++++++++++ drag/validator.py | 276 +++++++++ init/data.json | 12 + json/drag_graph.json | 23 + json/drag_template.json | 22 + models/drag_graph.json | 34 ++ pyproject.toml | 14 + scripts/load_path.py | 79 +++ skill/SKILL.md | 89 +++ wwwroot/api/block_defs.dspy | 8 + wwwroot/api/get_search_drag_status.dspy | 3 + wwwroot/api/get_search_entity.dspy | 8 + wwwroot/api/get_search_template_category.dspy | 3 + wwwroot/api/graph_compile.dspy | 9 + wwwroot/api/graph_delete.dspy | 11 + wwwroot/api/graph_get.dspy | 11 + wwwroot/api/graph_list.dspy | 9 + wwwroot/api/graph_publish.dspy | 9 + wwwroot/api/graph_save.dspy | 11 + wwwroot/api/graph_validate.dspy | 9 + wwwroot/api/template_delete.dspy | 11 + wwwroot/api/template_list.dspy | 9 + wwwroot/api/template_save.dspy | 15 + wwwroot/api/template_use.dspy | 9 + wwwroot/drag-canvas.js | 400 +++++++++++++ wwwroot/drag-editor.js | 322 +++++++++++ wwwroot/drag.css | 86 +++ wwwroot/editor.ui | 70 +++ wwwroot/index.ui | 41 ++ 35 files changed, 2908 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 build.sh create mode 100644 drag/__init__.py create mode 100644 drag/blocks.py create mode 100644 drag/compiler.py create mode 100644 drag/init.py create mode 100644 drag/validator.py create mode 100644 init/data.json create mode 100644 json/drag_graph.json create mode 100644 json/drag_template.json create mode 100644 models/drag_graph.json create mode 100644 pyproject.toml create mode 100644 scripts/load_path.py create mode 100644 skill/SKILL.md create mode 100644 wwwroot/api/block_defs.dspy create mode 100644 wwwroot/api/get_search_drag_status.dspy create mode 100644 wwwroot/api/get_search_entity.dspy create mode 100644 wwwroot/api/get_search_template_category.dspy create mode 100644 wwwroot/api/graph_compile.dspy create mode 100644 wwwroot/api/graph_delete.dspy create mode 100644 wwwroot/api/graph_get.dspy create mode 100644 wwwroot/api/graph_list.dspy create mode 100644 wwwroot/api/graph_publish.dspy create mode 100644 wwwroot/api/graph_save.dspy create mode 100644 wwwroot/api/graph_validate.dspy create mode 100644 wwwroot/api/template_delete.dspy create mode 100644 wwwroot/api/template_list.dspy create mode 100644 wwwroot/api/template_save.dspy create mode 100644 wwwroot/api/template_use.dspy create mode 100644 wwwroot/drag-canvas.js create mode 100644 wwwroot/drag-editor.js create mode 100644 wwwroot/drag.css create mode 100644 wwwroot/editor.ui create mode 100644 wwwroot/index.ui diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c4e02d6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +__pycache__/ +*.py[cod] +build/ +dist/ +*.egg-info/ +.venv/ +venv/ +*.swp +*.swo +.DS_Store +wwwroot/drag_graph/ +wwwroot/drag_template/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..b78f716 --- /dev/null +++ b/README.md @@ -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 生成输出端口 diff --git a/build.sh b/build.sh new file mode 100644 index 0000000..ae7db4c --- /dev/null +++ b/build.sh @@ -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." diff --git a/drag/__init__.py b/drag/__init__.py new file mode 100644 index 0000000..d95e332 --- /dev/null +++ b/drag/__init__.py @@ -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', +] diff --git a/drag/blocks.py b/drag/blocks.py new file mode 100644 index 0000000..973f4cd --- /dev/null +++ b/drag/blocks.py @@ -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)] diff --git a/drag/compiler.py b/drag/compiler.py new file mode 100644 index 0000000..4a46dc0 --- /dev/null +++ b/drag/compiler.py @@ -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]} diff --git a/drag/init.py b/drag/init.py new file mode 100644 index 0000000..29d8bfc --- /dev/null +++ b/drag/init.py @@ -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)) diff --git a/drag/validator.py b/drag/validator.py new file mode 100644 index 0000000..346c9a3 --- /dev/null +++ b/drag/validator.py @@ -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} diff --git a/init/data.json b/init/data.json new file mode 100644 index 0000000..bbe35b5 --- /dev/null +++ b/init/data.json @@ -0,0 +1,12 @@ +{ + "appcodes": [ + { + "parentid": "drag_status", + "parentname": "拖拽画布状态", + "items": [ + {"k": "0", "v": "草稿"}, + {"k": "1", "v": "启用"} + ] + } + ] +} diff --git a/json/drag_graph.json b/json/drag_graph.json new file mode 100644 index 0000000..21185c6 --- /dev/null +++ b/json/drag_graph.json @@ -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')}}" + } +} diff --git a/json/drag_template.json b/json/drag_template.json new file mode 100644 index 0000000..f1c8ebb --- /dev/null +++ b/json/drag_template.json @@ -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')}}" + } +} diff --git a/models/drag_graph.json b/models/drag_graph.json new file mode 100644 index 0000000..9516e88 --- /dev/null +++ b/models/drag_graph.json @@ -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'"} + ] +} diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..9352612 --- /dev/null +++ b/pyproject.toml @@ -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*"] diff --git a/scripts/load_path.py b/scripts/load_path.py new file mode 100644 index 0000000..cea7055 --- /dev/null +++ b/scripts/load_path.py @@ -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() diff --git a/skill/SKILL.md b/skill/SKILL.md new file mode 100644 index 0000000..bb81a03 --- /dev/null +++ b/skill/SKILL.md @@ -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 反例。 diff --git a/wwwroot/api/block_defs.dspy b/wwwroot/api/block_defs.dspy new file mode 100644 index 0000000..cab2eee --- /dev/null +++ b/wwwroot/api/block_defs.dspy @@ -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)} diff --git a/wwwroot/api/get_search_drag_status.dspy b/wwwroot/api/get_search_drag_status.dspy new file mode 100644 index 0000000..dce2235 --- /dev/null +++ b/wwwroot/api/get_search_drag_status.dspy @@ -0,0 +1,3 @@ +# 画布状态下拉 +result = [{'value': '0', 'text': '草稿'}, {'value': '1', 'text': '启用'}] +return {'status': 200, 'data': result} diff --git a/wwwroot/api/get_search_entity.dspy b/wwwroot/api/get_search_entity.dspy new file mode 100644 index 0000000..a0ae8ef --- /dev/null +++ b/wwwroot/api/get_search_entity.dspy @@ -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)} diff --git a/wwwroot/api/get_search_template_category.dspy b/wwwroot/api/get_search_template_category.dspy new file mode 100644 index 0000000..fa3756b --- /dev/null +++ b/wwwroot/api/get_search_template_category.dspy @@ -0,0 +1,3 @@ +# 模板分类下拉 +result = await get_template_category_options(params_kw) +return {'status': 200, 'data': result} diff --git a/wwwroot/api/graph_compile.dspy b/wwwroot/api/graph_compile.dspy new file mode 100644 index 0000000..b66bad7 --- /dev/null +++ b/wwwroot/api/graph_compile.dspy @@ -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)} diff --git a/wwwroot/api/graph_delete.dspy b/wwwroot/api/graph_delete.dspy new file mode 100644 index 0000000..cd16ab4 --- /dev/null +++ b/wwwroot/api/graph_delete.dspy @@ -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)} diff --git a/wwwroot/api/graph_get.dspy b/wwwroot/api/graph_get.dspy new file mode 100644 index 0000000..7b22d73 --- /dev/null +++ b/wwwroot/api/graph_get.dspy @@ -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)} diff --git a/wwwroot/api/graph_list.dspy b/wwwroot/api/graph_list.dspy new file mode 100644 index 0000000..33a5b6f --- /dev/null +++ b/wwwroot/api/graph_list.dspy @@ -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)} diff --git a/wwwroot/api/graph_publish.dspy b/wwwroot/api/graph_publish.dspy new file mode 100644 index 0000000..55a653d --- /dev/null +++ b/wwwroot/api/graph_publish.dspy @@ -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)} diff --git a/wwwroot/api/graph_save.dspy b/wwwroot/api/graph_save.dspy new file mode 100644 index 0000000..c08fcc3 --- /dev/null +++ b/wwwroot/api/graph_save.dspy @@ -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)} diff --git a/wwwroot/api/graph_validate.dspy b/wwwroot/api/graph_validate.dspy new file mode 100644 index 0000000..1d55137 --- /dev/null +++ b/wwwroot/api/graph_validate.dspy @@ -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)} diff --git a/wwwroot/api/template_delete.dspy b/wwwroot/api/template_delete.dspy new file mode 100644 index 0000000..95c0148 --- /dev/null +++ b/wwwroot/api/template_delete.dspy @@ -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)} diff --git a/wwwroot/api/template_list.dspy b/wwwroot/api/template_list.dspy new file mode 100644 index 0000000..ce5c0f8 --- /dev/null +++ b/wwwroot/api/template_list.dspy @@ -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)} diff --git a/wwwroot/api/template_save.dspy b/wwwroot/api/template_save.dspy new file mode 100644 index 0000000..53e7e7c --- /dev/null +++ b/wwwroot/api/template_save.dspy @@ -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)} diff --git a/wwwroot/api/template_use.dspy b/wwwroot/api/template_use.dspy new file mode 100644 index 0000000..c702227 --- /dev/null +++ b/wwwroot/api/template_use.dspy @@ -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)} diff --git a/wwwroot/drag-canvas.js b/wwwroot/drag-canvas.js new file mode 100644 index 0000000..0db9487 --- /dev/null +++ b/wwwroot/drag-canvas.js @@ -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 = ' ' + 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 += '
' + pd.label + '' + + (opt ? opt.text : (params[pd.name] !== undefined ? params[pd.name] : '')) + '
'; + } else if (params[pd.name] !== undefined && String(params[pd.name]) !== '') { + body.innerHTML += '
' + pd.label + '' + params[pd.name] + '
'; + } + }); + + 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); diff --git a/wwwroot/drag-editor.js b/wwwroot/drag-editor.js new file mode 100644 index 0000000..608009d --- /dev/null +++ b/wwwroot/drag-editor.js @@ -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 = '' + def.label + ''; + 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 = '
选中块后在此编辑参数
'; + return; + } + var def = (state.blockDefs.blocks || {})[block.type]; + if (!def) { return; } + var title = document.createElement('div'); + title.className = 'drag-param-title'; + title.innerHTML = ' ' + def.label + + ' ' + (def.description || '') + ''; + 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 = ''; + 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 = '
校验通过:无错误
'; + return; + } + var html = '
校验发现 ' + errors.length + ' 个问题:
'; + 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 = '
' + escapeHtml(d.content || '') + '
'; + 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, '>'); + } + + /* ─────────────── 初始化 ─────────────── */ + + 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(); + } +})(); diff --git a/wwwroot/drag.css b/wwwroot/drag.css new file mode 100644 index 0000000..eb286b6 --- /dev/null +++ b/wwwroot/drag.css @@ -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,"); +} +.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; } diff --git a/wwwroot/editor.ui b/wwwroot/editor.ui new file mode 100644 index 0000000..4a76b35 --- /dev/null +++ b/wwwroot/editor.ui @@ -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"}} + ] + } + ] + } + ] + } + ] +} diff --git a/wwwroot/index.ui b/wwwroot/index.ui new file mode 100644 index 0000000..4ea9f63 --- /dev/null +++ b/wwwroot/index.ui @@ -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"}} + ] +}