Back to "بناء نظام لتدفق العمل مع HTMX وASP.net"

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

Alpine.js ASP.NET HTMX TailwindCSS Workflow

بناء نظام لتدفق العمل مع HTMX وASP.net

Wednesday, 15 January 2025

أولاً

داخلالجزء الثانيلقد بنينا محركاً قوياً لسير العمل

لكن سير العمل المعرّف في (جيسون) ليس سهل الاستخدام.

  • في هذه التدوينة، سنقوم بإنشاء محرّر تدفق بصري مذهل - فكر في "نود ريد الدمية" - باستخدام HTMX، Alpin.js، TailwindCSS، و DizeUI.
  • في نهاية هذا المنصب، سيكون لديك:
  • ألف - تدفق المعلومات عن طريق السحب والإسقاط
  • المراسل مع SVG
  • مُهْدِدّة

جميلة، ذات موضوع قابل للتسخين

كامل كامل كامل HTMX

  1. الرؤية
  2. نريد أن يقوم المستخدمون بما يلي:
  3. نُقَدَسُ مِنْ لوحة لوحة إلى لوحة
  4. الوصلات

في جزء

احفظ و نف نف جاري تنفيذ

كل هذا بدون كتابة سطر واحد من JOSON!

تكن تكن تكن تكن تكن تكن تكن تكن تكن تكن تكن تكن تكن تكن تكن تكن تكن تكن تكنلماذا هذا الإحتياط؟

  • HTMXS
  • : تفاعلات مدفوعة بالحاسوب بدون كتابة جافاسكربت
  • الكمال للحفظ/التفريغ

يُحدِّث التعقيدات المتعلقة بجانب عمل العملاءالخادم يحافظ على مصدر الحقيقة

  • الألابين
  • : رد فعل خفيف من أجل القماش
  • 15KB فقط تم تدقيقهاx-dataالكمال لمكونات UUI التفاعلية

شيء بسيطبالتوجيهات

  • TailwindCSS + DESUI
  • : جميلة ، موضوعة تحت الموضوع
  • SCS SCS في المرحلة الأولى

/الضوء / نمط الخروج من الصندوقمكونات ما قبل التركيب (البطاقات، والأزرز، والشارات)

  • SV
  • : مُنْسِج ارتباط السكان الأصليين
  • سطور

CSSS SSS

لا حاجة إلى مكتبات خارجية

┌─────────────────────────────────────────────────────┐
│  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>

ال_ م هذا

وجهة نظرنا المحررة تستخدم Alpin.js لإدارة الدولة:workflowEditorإدارة شؤون الدولة

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 };
}

الضلع الأيسر يحتوي على نُفَذ:

& جاري

<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>

الـ Canvas

الواجهة هي حيث يحدث السحر:

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}`;
}

SSSS

تم رسم المراسلات بين العقدات مع مسارات 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
    }
}

تَحِي الحياة

بينما يقوم المستخدمون بتحرير مدخلات 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

عندما ينقر المستخدم "Save"، نستخدم نداء جلب بسيط (يمكن أن يكون 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>

موضوع التحول مع ديزولbg-base-200, text-base-contentتقدم ديزيولي تحويلاً آلياً للموضوع.

يمكن للمستخدمين أن يتنقلوا بين الضوء والأنماط المظلمة، وجميع نُقَد سير العمل لدينا تتكيف تلقائياً!

كل ألواننا تستخدم فصول ديزول الدلالية (DesieUI)

<div class="flex flex-col lg:flex-row gap-4">
    <!-- On mobile: stack vertically -->
    <!-- On desktop: side-by-side -->
</div>

)، بحيث يتكيفون تلقائياً مع الموضوع المختار!

تكيّف تخطيطنا ثلاثي الفانل مع مختلف أحجام الشاشة:

أوجه الاستخدام الفعلية

افتراض هذا

let dragTimeout;
dragNode(event, node) {
    clearTimeout(dragTimeout);
    dragTimeout = setTimeout(() => {
        // Update position
    }, 16); // ~60fps
}

وفيما يتعلق بسير العمل مع مئات العقد، يمكننا تنفيذ التدرج الافتراضي (فقط جعل العقد مرئية).

ولكن بالنسبة لمعظم الحالات المستخدمة، جعل 50-100 عقدة هو على ما يرام تماما مع المتصفحات الحديثة.

<button @click="deleteNode(node)"
        class="btn"
        aria-label="Delete node"
        @keydown.delete="deleteNode(node)">
    🗑️
</button>

الاستكمالات

عند سحب العُقَد، يُمْكِنُنا أَنْ نَفْصلَ موقع التحديثات:

✅ إمكانية الوصوللقد أضفنا العلامات المناسبة لـ ARIA وملاحات لوحة المفاتيح: ✅ ما بنينا عليهوفي هذا المنصب، أنشأنا ما يلي: ✅ دُرْج وإسقاط- أنواع العقدة ✅ مرئ أمامي- خلفية الشبكة مع مراكز العقد ✅ ملحقات SSVG-المسارات المحنجرة بين العقدات ✅ رقم الشخص المُفَشِّر- فريق تشكيلات الوقت الحقيقي ✅ الدعم الموضوعي- العمل مع جميع مواضيع ديزولي ✅ ****- مُدخلات إلى حجم الشاشة

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"
}

دال- التفاعل مع الألبين. ج جس

-مُنَعَزَة، مُرَدَّةٌ مُرَدَّةٌ مُرَدَّةٌحال عملهنا a كامل سير العمل أنت يُمْكِنُ أَنْ تَبْني في المحرّرِ:

  • ما هو التالي؟
  • داخل
  • الجزء 4
  • سوف ندمج "هالفاير" من أجل:
  • تنفيذ التنفيذ في سير سير العمل

مُنازعات الاقتراع المُعَرَّضَة

إدارة الدولة المُطرج

تجهيز المعلومات الأساسية عن العمل

راصاص رصد الرصد

  • ! سنجعل سير عملنا مستقلاً حقاًثالثاً - استنتاج
  • **لقد بنينا محرّر سير عمل بصري جاهز للانتاج مع الحد الأدنى من جافاسكربت!**عن طريق الجمع بين HTMX للاتصالات الخادمة، Alpin.js للتفاعل، و TailwindCSS/DaisyUI لكتابة، لقد أنشأنا شيئاً يشعر بالحداثة ويمكن صيانته.
  • **الرؤى الرئيسية:**SV
  • هو مثالي للربطالألابين

معطى فقط بما يكفي

logo

© 2026 Scott Galloway — Unlicense — All content and source code on this site is free to use, copy, modify, and sell.