Κατασκευή ενός συστήματος ροής εργασίας με HTMX και ASP.NET πυρήνα - Μέρος 3: Κατασκευή του επεξεργαστή εικόνας
Εισαγωγή
ΤοΜέρος 2, φτιάξαμε μια ισχυρή μηχανή ροής εργασίας.
Αλλά οι ροές εργασίας που ορίζονται στο JSON δεν είναι πολύ φιλικές προς το χρήστη.
- Σε αυτή τη δημοσίευση, θα δημιουργήσουμε ένα εκπληκτικό επεξεργαστή οπτικής ροής εργασίας - σκεφτείτε "Νόδος-κόκκινος" - χρησιμοποιώντας HTMX, Alpine.js, Tailwindss, και DaisyUI.
- Μέχρι το τέλος αυτής της ανάρτησης, θα έχετε:
- Ένας καμβάς ροής εργασίας σύρσης και πτώσης
- Οπτική σύνδεση κόμβου με SVG
- Ρύθμιση κόμβου πραγματικού χρόνου
Όμορφη, θέμα-διακόπτεται UI
Πλήρης ενσωμάτωση HTMX
- Το Όραμα
- Θέλουμε οι χρήστες:
- Σύρετε κόμβους από μια παλέτα σε έναν καμβά
- Συνδέστε τους κόμβους οπτικά
Ρύθμιση ιδιοτήτων κόμβου σε έναν πλευρικό πίνακα
Αποθήκευση και εκτέλεση ροών εργασίας
Όλοι χωρίς να γράψουμε ούτε μια γραμμή του JSON!
Στοίβα τεχνολογίαςNameΓιατί αυτή η Στοίβα;
- HTMX 2. 0
- : Διακομιστές-οδηγούμενες αλληλεπιδράσεις χωρίς γράψιμο JavaScript
- Ιδανικό για λειτουργίες αποθήκευσης/φόρτωσης
Μειώνει την πολυπλοκότητα της πλευράς του πελάτηΟ εξυπηρετητής διατηρεί την πηγή της αλήθειας
- Alpine.js
- : Ελαφρύ αντιδραστικό για τον καμβά
- Μόνον ~15KB
x-dataΙδανικό για διαδραστικά συστατικά UI
Απλόοδηγίες
- WailwindCSS + DaisyUI
- : Όμορφη, θεματική UI
- Utility- first CSS
Σκοτεινή/φωτεινή λειτουργία εκτός του πλαισίουΠροκατασκευασμένα εξαρτήματα (κάρτες, κουμπιά, σήματα)
- SVG
- : Native connection rendering
- Εύκαμπτες, τραγανές γραμμές
CSS-styleable
Δεν χρειάζονται εξωτερικές βιβλιοθήκες
┌─────────────────────────────────────────────────────┐
│ 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()
});
}
}
Ο Ελεγκτής
Πρώτα, ας ρυθμίσουμε τα τελικά σημεία ελέγχου μας:
@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>
Η προβολή επεξεργαστή
Η άποψη του εκδότη μας χρησιμοποιεί Alpine.js για τη διαχείριση του κράτους:workflowEditorAlpine.js State Management
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>
Drag and Drop: The Node Palette
dragStart(event, nodeType, icon, color) {
this.draggedNodeType = { type: nodeType, icon, color };
}
Η αριστερή πλευρική ράβδος περιέχει σύρσιμο τύπου κόμβου:
Χειριστής εκκίνησης σύρετε
<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;
}
Πετάξτε τον χειριστή
Όταν ένας κόμβος πέσει στον καμβά:
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}`;
}
Rendering σύνδεσης SVG
Οι συνδέσεις μεταξύ κόμβων σχεδιάζονται με μονοπάτια SVG:
Αυτό δημιουργεί λείες, καμπυλωτές συνδέσεις μεταξύ κόμβων!
<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>
Ο επιθεωρητής κόμβου
Ο δεξιός πίνακας επιτρέπει στους χρήστες να ρυθμίσουν επιλεγμένους κόμβους:
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
}
}
Live JSON Parsing
Καθώς οι χρήστες επεξεργαστούν τις εισόδους JSON, τους αναλύουμε σε πραγματικό χρόνο:
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 };
}
}
Σύνδεση κόμβων
Οι χρήστες συνδέουν κόμβους κάνοντας κλικ στα σημεία σύνδεσης:
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);
}
}
Εξοικονόμηση ροών εργασίας με HTMX
Όταν ο χρήστης κάνει κλικ στο "Αποθήκευση," χρησιμοποιούμε μια απλή κλήση (μπορεί να είναι και HTMX):
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);
}
}
Τρέχων Ροές Εργασίας
Εκτελέστε τις ροές εργασίας δεξιά από τον επεξεργαστή:
<!-- 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>
Θέμα Αλλαγή θέματος με DaisyUIbg-base-200, text-base-contentDaisyUI παρέχει αυτόματη αλλαγή θέματος.
Οι χρήστες μπορούν να εναλλαγή μεταξύ του φωτός και των σκοτεινών τρόπων, και όλοι οι κόμβοι ροής εργασίας μας προσαρμόζονται αυτόματα!
Όλα τα χρώματα μας χρησιμοποιούν τα σημασιολογικά μαθήματα της DaisyUI (
<div class="flex flex-col lg:flex-row gap-4">
<!-- On mobile: stack vertically -->
<!-- On desktop: side-by-side -->
</div>
), έτσι προσαρμόζονται αυτόματα στο επιλεγμένο θέμα!
Αναπνευστικός σχεδιασμός
Η τρισδιάστατη διάταξη μας προσαρμόζεται σε διαφορετικά μεγέθη οθόνης:
Βελτιστοποιήσεις επιδόσεων
Virtual Scrolling
let dragTimeout;
dragNode(event, node) {
clearTimeout(dragTimeout);
dragTimeout = setTimeout(() => {
// Update position
}, 16); // ~60fps
}
Για τις ροές εργασίας με εκατοντάδες κόμβους, θα μπορούσαμε να εφαρμόσουμε την εικονική κύλιση (μόνο να αποδώσουμε ορατούς κόμβους).
Αλλά για τις περισσότερες περιπτώσεις χρήσης, η απόδοση 50-100 κόμβους είναι τέλεια με τους σύγχρονους browser.
<button @click="deleteNode(node)"
class="btn"
aria-label="Delete node"
@keydown.delete="deleteNode(node)">
🗑️
</button>
Απενεργοποιημένες ενημερώσεις@ info: whatsthis
Όταν σύρετε κόμβους, θα μπορούσαμε να απενεργοποιήσετε ενημερώσεις θέσης:
✅ ΠροσβασιμότηταΈχουμε προσθέσει σωστές ετικέτες ARIA και πλοήγηση πληκτρολογίου: ✅ **Τι φτιάξαμε;**Σε αυτή την ανάρτηση, δημιουργήσαμε: ✅ Παλέτα με κόμπους με σύρσιμο και σταγόνα- Σύρσιμο τύπου κόμβου ✅ Οπτικός καμβάς- Πλέγμα φόντο με τοποθετημένους κόμβους ✅ Συνδέσεις SVG- Καμπυλωτά μονοπάτια μεταξύ κόμβων ✅ Επιθεωρητής κόμβου- Πίνακας ρυθμίσεων σε πραγματικό χρόνο ✅ Υποστήριξη θέματος- Λειτουργεί με όλα τα θέματα DaisyUI ✅ Ανταλλακτική διάταξη- Προσαρμόζεται στο μέγεθος της οθόνης
Ενσωμάτωση HTMX
- Εξοικονομήστε/φορτώστε τον εξυπηρετητή
{
"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"
}
Alpine.js reactivity
Απαλό, αντιδραστικό UIΠαράδειγμα ροής εργασίαςΕδώ είναι μια πλήρης ροή εργασίας που μπορείτε να οικοδομήσετε στον επεξεργαστή:
Ποιο είναι το επόμενο;
Το
Μέρος 4
, θα ενσωματώσουμε Hangfire για:
Εκτέλεση προγραμματισμένης ροής εργασίας
Σκανδάλες δημοσκοπήσεων API
Διαχείριση του κράτους ενεργοποίησης
Επεξεργασία του ιστορικού της εργασίας
Ταμπλό παρακολούθησης
- **Θα κάνουμε τη δουλειά μας να ρέει πραγματικά αυτόνομα!**Συμπέρασμα
- **Κατασκευάσαμε έναν επεξεργαστή οπτικής ροής παραγωγής με minimal JavaScript!**Συνδυάζοντας HTMX για επικοινωνία διακομιστή, Alpine.js για διαδραστική δραστηριότητα, και TailwindsS/DaisyUI για styling, έχουμε δημιουργήσει κάτι που αισθάνεται μοντέρνα και μπορεί να διατηρηθεί.
- **Οι βασικές ιδέες:**SVG
- είναι ιδανικό για την απόδοση σύνδεσηςAlpine.js
παρέχει αρκετή αντιδραστικότητα