This is a viewer only at the moment see the article on how this works.
To update the preview hit Ctrl-Alt-R (or ⌘-Alt-R on Mac) or Enter to refresh. The Save icon lets you save the markdown file to disk
This is a preview from the server running through my markdig pipeline
以 HTMX 和 ASP @.NET Core {-}建立工作流程系统
Wednesday, 15 January 2025
一. 导言导言 导言 导言 导言
内 部分#2@,#我们建造了一个强大的工作流程引擎@.}但是Jsson定义的工作流程是: '%t 非常非常用户=-(很友好的).}在本文中,我们会创建一个惊人的视觉工作流程编辑{'}
@,您将拥有#:@
- 拖放工作流程画布@ label
- 与 SVG 的视觉节点连接
- Real @-}时间节点配置
- 美丽的, @ 主题@ - @ switchable UI
- HTMX 完全集成
愿景
我们希望用户to:
- 从调色板拖到画布上的节点
- 视觉连接节点
- 在侧面面板中配置节点属性
- 保存和执行工作流程
全部不写 JSON!的单行
技术堆叠
为什么这个Stack?
HTMX 2.0:伺服器@-}沒有寫入 JavaScript 的自動互動
- 用于保存@/ @Iload 操作的完美
- 降低客户端@- @side 复杂性
- 服务器维护真理源
阿尔卑斯山“: 轻量级对画布的回动性”
- 只有 ~15 @QKB 放大
- 用于交互式UI组件的完美
- 简单
x-data指示和指示号
尾风CS @ +# DisiUI@: @ beautiful_,}主题界面
- 首个 CSS 工具
- Dark/ 光模式走出盒子
-
- 制造组件 @(卡,}按键@, 徽章_)
SVG公司: 土著连接
- 缩放大小@ , @ crisp 线条
- CSS - 风格化
- 不需要外部图书馆
建筑结构概览
我们的编辑有三个主要面板
┌─────────────────────────────────────────────────────┐
│ Top Toolbar (Name, Save, Run, Cancel) │
├─────────┬───────────────────────────┬───────────────┤
│ Node │ Canvas │ Node │
│ Palette │ (Drag & Drop) │ Inspector │
│ │ │ │
│ [HTTP] │ ┌─────┐ │ Name: ___ │
│ [Xform] │ │Node1│─┐ │ Type: ___ │
│ [Delay] │ └─────┘ │ │ Config: ___ │
│ [Cond] │ ▼ │ Style: ___ │
│ │ ┌─────┐ │ │
│ │ │Node2│ │ │
│ │ └─────┘ │ │
└─────────┴───────────────────────────┴───────────────┘
主计长
建立我们的控制器端点@: @
[Route("workflow")]
public class WorkflowController : BaseController
{
private readonly WorkflowService _workflowService;
private readonly WorkflowExecutionService _executionService;
[HttpGet]
public async Task<IActionResult> Index()
{
var workflows = await _workflowService.GetAllAsync();
return View("Index", workflows);
}
[HttpGet("create")]
public IActionResult Create()
{
var workflow = new WorkflowDefinition
{
Name = "New Workflow",
Nodes = new List<WorkflowNode>(),
Connections = new List<NodeConnection>()
};
return View("Editor", workflow);
}
[HttpGet("edit/{id}")]
public async Task<IActionResult> Edit(string id)
{
var workflow = await _workflowService.GetByIdAsync(id);
return workflow == null ? NotFound() : View("Editor", workflow);
}
[HttpPost("save")]
public async Task<IActionResult> Save([FromBody] WorkflowDefinition workflow)
{
var saved = await _workflowService.CreateOrUpdateAsync(workflow);
return Json(new { success = true, workflowId = saved.Id });
}
[HttpPost("execute/{id}")]
public async Task<IActionResult> Execute(string id,
[FromBody] Dictionary<string, object>? inputData = null)
{
var execution = await _executionService.ExecuteWorkflowAsync(
id, inputData, User.Identity?.Name ?? "Anonymous");
return Json(new
{
success = true,
executionId = execution.Id,
status = execution.Status.ToString()
});
}
}
编辑器视图
我们的编辑视图使用 Alpine.js 来管理国家管理层@:
@model Mostlylucid.Workflow.Shared.Models.WorkflowDefinition
<div class="container-fluid"
x-data="workflowEditor(@Html.Raw(JsonSerializer.Serialize(Model)))"
x-init="init()">
<!-- Top Toolbar -->
<div class="navbar bg-base-200 rounded-box shadow-lg mb-6">
<div class="flex-1">
<input type="text"
x-model="workflow.name"
placeholder="Workflow Name"
class="input input-ghost text-2xl font-bold w-96" />
</div>
<div class="flex-none gap-2">
<button @click="saveWorkflow()" class="btn btn-success">
Save
</button>
<button @click="runWorkflow()" class="btn btn-primary">
Run
</button>
</div>
</div>
<!-- Three-panel layout -->
<div class="flex gap-4 h-[calc(100vh-200px)]">
<!-- Left: Node Palette -->
<!-- Center: Canvas -->
<!-- Right: Node Inspector -->
</div>
</div>
AlpineMSMK0js州管理
缩略 workflowEditor Alpine 组件管理所有编辑器状态@: @%
function workflowEditor(initialWorkflow) {
return {
workflow: initialWorkflow || {
id: crypto.randomUUID(),
name: 'New Workflow',
nodes: [],
connections: [],
isEnabled: true
},
selectedNode: null,
draggedNodeType: null,
connecting: false,
init() {
// Initialize node inputs as JSON for editing
this.workflow.nodes.forEach(node => {
node.inputsJson = JSON.stringify(node.inputs || {}, null, 2);
});
},
// ... methods below
};
}
节点调色板
左侧栏包含拖动节点类型@: @%
<div class="w-64 bg-base-200 rounded-box p-4 overflow-y-auto">
<h3 class="text-lg font-bold mb-4">📦 Available Nodes</h3>
<div class="space-y-3">
<div class="card bg-base-100 shadow cursor-pointer"
draggable="true"
@dragstart="dragStart($event, 'HttpRequest', '🌐', '#10B981')">
<div class="card-body p-4">
<h4 class="font-semibold">🌐 HTTP Request</h4>
<p class="text-xs">Make API calls</p>
</div>
</div>
<div class="card bg-base-100 shadow cursor-pointer"
draggable="true"
@dragstart="dragStart($event, 'Transform', '🔄', '#3B82F6')">
<div class="card-body p-4">
<h4 class="font-semibold">🔄 Transform</h4>
<p class="text-xs">Process data</p>
</div>
</div>
<!-- More node types... -->
</div>
</div>
拖放启动处理器
dragStart(event, nodeType, icon, color) {
this.draggedNodeType = { type: nodeType, icon, color };
}
Canvas 画室
画布就是魔法发生的地方
<div class="flex-1 bg-base-300 rounded-box relative overflow-hidden"
@drop="dropNode($event)"
@dragover.prevent
id="workflow-canvas">
<div class="absolute inset-0 overflow-auto p-8">
<!-- Grid background -->
<div class="absolute inset-0 opacity-20"
style="background-image: repeating-linear-gradient(...);
background-size: 20px 20px;">
</div>
<!-- Render nodes -->
<template x-for="node in workflow.nodes" :key="node.id">
<div :id="'node-' + node.id"
class="absolute card shadow-2xl cursor-move"
:style="`left: ${node.position.x}px;
top: ${node.position.y}px;
width: ${node.style.width}px;
background-color: ${node.style.backgroundColor};`"
@mousedown="selectNode(node)"
draggable="true"
@dragstart="dragNode($event, node)">
<div class="card-body p-4">
<div class="flex items-center gap-2">
<span x-text="node.style.icon || '📦'"></span>
<h3 x-text="node.name"></h3>
</div>
<!-- Connection points -->
<div class="flex justify-between mt-2">
<div class="badge badge-primary"
@click.stop="startConnection(node, 'input')">
◀
</div>
<div class="badge badge-success"
@click.stop="startConnection(node, 'output')">
▶
</div>
</div>
</div>
</div>
</template>
<!-- SVG connections layer -->
<svg class="absolute inset-0 pointer-events-none"
style="width: 100%; height: 100%;">
<template x-for="conn in workflow.connections" :key="conn.id">
<path :d="getConnectionPath(conn)"
stroke="#94A3B8"
stroke-width="3"
fill="none"
marker-end="url(#arrowhead)"/>
</template>
<defs>
<marker id="arrowhead" markerWidth="10" markerHeight="10">
<polygon points="0 0, 10 3, 0 6" fill="#94A3B8" />
</marker>
</defs>
</svg>
</div>
</div>
丢弃处理器
当节点掉到画布上时\ :
dropNode(event) {
if (!this.draggedNodeType) return;
const canvas = document.getElementById('canvas-container');
const rect = canvas.getBoundingClientRect();
// Calculate drop position
const x = event.clientX - rect.left + canvas.scrollLeft - 100;
const y = event.clientY - rect.top + canvas.scrollTop - 50;
// Create new node
const newNode = {
id: crypto.randomUUID(),
type: this.draggedNodeType.type,
name: this.draggedNodeType.type,
inputs: {},
outputs: {},
position: { x, y },
style: {
backgroundColor: this.draggedNodeType.color,
textColor: '#FFFFFF',
borderColor: this.draggedNodeType.color,
icon: this.draggedNodeType.icon,
width: 200
},
inputsJson: '{}'
};
this.workflow.nodes.push(newNode);
// Set as start node if first node
if (this.workflow.nodes.length === 1) {
this.workflow.startNodeId = newNode.id;
}
this.draggedNodeType = null;
}
SVG 连接文件
用 SVG 路径绘制节点之间的连接@:
getConnectionPath(conn) {
const source = this.workflow.nodes.find(n => n.id === conn.sourceNodeId);
const target = this.workflow.nodes.find(n => n.id === conn.targetNodeId);
if (!source || !target) return '';
// Calculate connection points
const x1 = source.position.x + (source.style?.width || 200);
const y1 = source.position.y + 50;
const x2 = target.position.x;
const y2 = target.position.y + 50;
// Create curved Bezier path
const midX = (x1 + x2) / 2;
return `M ${x1} ${y1} C ${midX} ${y1}, ${midX} ${y2}, ${x2} ${y2}`;
}
这将创建结点之间的平滑连接@ ,
节点检查员
右面板允许用户配置选中的节点@: @%
<div class="w-80 bg-base-200 rounded-box p-4 overflow-y-auto"
x-show="selectedNode">
<h3 class="text-lg font-bold mb-4">⚙️ Node Settings</h3>
<template x-if="selectedNode">
<div class="space-y-4">
<div class="form-control">
<label class="label">Node Name</label>
<input type="text"
x-model="selectedNode.name"
class="input input-bordered" />
</div>
<div class="form-control">
<label class="label">Background Color</label>
<input type="color"
x-model="selectedNode.style.backgroundColor"
class="input w-full h-12" />
</div>
<div class="form-control">
<label class="label">Icon (emoji)</label>
<input type="text"
x-model="selectedNode.style.icon"
class="input input-bordered"
maxlength="2" />
</div>
<div class="form-control">
<label class="label">Inputs (JSON)</label>
<textarea x-model="selectedNode.inputsJson"
@input="updateNodeInputs()"
class="textarea textarea-bordered font-mono"
rows="6"></textarea>
</div>
<button @click="setStartNode(selectedNode)"
class="btn btn-sm"
:class="workflow.startNodeId === selectedNode.id ?
'btn-success' : 'btn-outline'">
Set as Start Node
</button>
</div>
</template>
</div>
JSON 现场分析
当用户编辑 JSON 输入时 @, @ 我们用 real@-_time:}来分析它们
updateNodeInputs() {
if (!this.selectedNode) return;
try {
this.selectedNode.inputs = JSON.parse(this.selectedNode.inputsJson);
} catch (e) {
console.error('Invalid JSON:', e);
// Could show error badge
}
}
连接节点
用户通过点击连接点连接节点@: @%
startConnection(node, type) {
if (this.connecting) {
// Complete connection
if (this.connectionStart.node.id !== node.id) {
this.workflow.connections.push({
id: crypto.randomUUID(),
sourceNodeId: this.connectionStart.node.id,
targetNodeId: node.id,
sourceOutput: 'default',
targetInput: 'default'
});
}
this.connecting = false;
this.connectionStart = null;
} else {
// Start connection
this.connecting = true;
this.connectionStart = { node, type };
}
}
与HTMX一起节省的工作流程
当用户点击“ "” 时, 我们使用一个简单的抓取调用电话“ @ MS K2” 可以是 HTMX too):@
async saveWorkflow() {
try {
const response = await fetch('/workflow/save', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(this.workflow)
});
const result = await response.json();
if (result.success) {
alert('Workflow saved!');
window.location.href = '/workflow';
} else {
alert('Error: ' + result.message);
}
} catch (error) {
alert('Error saving: ' + error.message);
}
}
流动的工作流程
从编辑器@: 执行工作流程
async runWorkflow() {
try {
// Save first
await this.saveWorkflow();
// Then execute
const response = await fetch(`/workflow/execute/${this.workflow.id}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({})
});
const result = await response.json();
if (result.success) {
alert(`Workflow started! Execution ID: ${result.executionId}`);
}
} catch (error) {
alert('Error: ' + error.message);
}
}
主题切换 DisaiUI
DaisiUI 提供自动主题切换@ . @ 用户可以在光和暗模式之间切换\ MS K1} & all 工作流程节点都自动适应\ MPK2
<!-- In your layout -->
<select data-choose-theme class="select select-bordered">
<option value="light">Light</option>
<option value="dark">Dark</option>
<option value="cupcake">Cupcake</option>
<option value="synthwave">Synthwave</option>
</select>
我们所有的颜色都使用 DaisyUI' 语义类bg-base-200, text-base-content@),}所以它们自动适应所选主题 =\ !#
响应设计
我们的三-panel布局 适应不同的屏幕大小
<div class="flex flex-col lg:flex-row gap-4">
<!-- On mobile: stack vertically -->
<!-- On desktop: side-by-side -->
</div>
业绩优化
虚拟滚动
对于有数百个节点的工作流程, 我们可以执行虚拟滚动“ (” 只能显示可见的节点@ MS K2。 但对于大多数使用的案例来说, “ ,” 转换“ I50-100" 节点与现代浏览器非常适合@ MPK5}
跳跃更新
当拖曳节点@ ,时, 我们可以跳过位置更新@ MPK1
let dragTimeout;
dragNode(event, node) {
clearTimeout(dragTimeout);
dragTimeout = setTimeout(() => {
// Update position
}, 16); // ~60fps
}
无障碍
我们添加了适当的 ARIA 标签和键盘导航@:
<button @click="deleteNode(node)"
class="btn"
aria-label="Delete node"
@keydown.delete="deleteNode(node)">
🗑️
</button>
我们所建造的
我们创建了“:” 。
✅ 拖放节点调色板@ action - 拖放节点类型 ✅ 视觉画布 - 定位节点的网格背景 ✅ SVG 连接 - 节点之间的曲线路径 ✅ 节点检查员 -Real-时间配置面板 ✅ 主题支助 -工作与所有DaisyUI主题 ✅ 响应布局 - 适应屏幕大小 ✅ HTMX一体化 -伺服器 @-}驱动保存@/load ✅ AlpineMSMK0js 反活动 -+Sliple=,+反应UI
工作流量实例
您可以在编辑器中建立完整的工作流程 :\
{
"name": "GitHub Stars Monitor",
"nodes": [
{
"id": "1",
"type": "HttpRequest",
"name": "Fetch Repo Data",
"inputs": {
"url": "https://api.github.com/repos/{{owner}}/{{repo}}",
"method": "GET"
},
"position": { "x": 100, "y": 100 },
"style": { "backgroundColor": "#10B981", "icon": "🌐" }
},
{
"id": "2",
"type": "Condition",
"name": "Check Stars",
"inputs": {
"condition": "{{stargazers_count}} > 100"
},
"position": { "x": 100, "y": 250 },
"style": { "backgroundColor": "#8B5CF6", "icon": "🔀" }
},
{
"id": "3",
"type": "Log",
"name": "Log Success",
"inputs": {
"message": "Repo has {{stargazers_count}} stars!",
"level": "info"
},
"position": { "x": 100, "y": 400 },
"style": { "backgroundColor": "#3B82F6", "icon": "📝" }
}
],
"connections": [
{ "sourceNodeId": "1", "targetNodeId": "2" },
{ "sourceNodeId": "2", "targetNodeId": "3" }
],
"startNodeId": "1"
}
什么是' 下一步 @?
内 部分#4@, @ we'#Lall 整合为#:}的绞刑
- 排定的工作流程执行
- API投票触发器
- 触发州管理
- 背景工作处理
- 监测仪表板
将使我们的工作流程真正自主
结论结论
我们've 建造了一个制作@- ready 视觉工作流程编辑器,使用最小 JavaScript @!}通过将 HTMX 组合为服务器通讯@MS K3Q Alpine.js 用于互动 ,}和 TadiwindCSS=/#DaisyUI 用于打字_,}我们MSKO8+Ve 创建了一种感觉现代且可以维护的事物.}
关键洞察力:
- SVG公司 用于连接转换的完美
- 阿尔卑斯山 仅提供了足够的反应
- DisayUI 调音界面 使他们变得微不足道,
- 拖放 API 令人惊讶的简单
继续调用 {4} 部件, 我们让工作流程运行在自动导航\ !