Files
zclaw_openfang/desktop/src/components/WorkflowList.tsx
iven 1cf3f585d3 refactor(store): split gatewayStore into specialized domain stores
Major restructuring:
- Split monolithic gatewayStore into 5 focused stores:
  - connectionStore: WebSocket connection and gateway lifecycle
  - configStore: quickConfig, workspaceInfo, MCP services
  - agentStore: clones, usage stats, agent management
  - handStore: hands, approvals, triggers, hand runs
  - workflowStore: workflows, workflow runs, execution

- Update all components to use new stores with selector pattern
- Remove
2026-03-20 22:14:13 +08:00

520 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* WorkflowList - OpenFang Workflow Management UI
*
* Displays available OpenFang Workflows and allows executing them.
*
* Design based on OpenFang Dashboard v0.4.0
*/
import { useState, useEffect, useCallback } from 'react';
import { useWorkflowStore, type Workflow } from '../store/workflowStore';
import { WorkflowEditor } from './WorkflowEditor';
import { WorkflowHistory } from './WorkflowHistory';
import {
Play,
Edit,
Trash2,
History,
Plus,
List,
GitBranch,
RefreshCw,
Loader2,
X,
} from 'lucide-react';
import { safeJsonParse } from '../lib/json-utils';
// === View Toggle Types ===
type ViewMode = 'list' | 'visual';
// === Workflow Execute Modal ===
interface ExecuteModalProps {
workflow: Workflow;
isOpen: boolean;
onClose: () => void;
onExecute: (id: string, input?: Record<string, unknown>) => Promise<void>;
isExecuting: boolean;
}
function ExecuteModal({ workflow, isOpen, onClose, onExecute, isExecuting }: ExecuteModalProps) {
const [input, setInput] = useState('');
const handleExecute = async () => {
let parsedInput: Record<string, unknown> | undefined;
if (input.trim()) {
const result = safeJsonParse<Record<string, unknown>>(input);
if (!result.success) {
alert('Input format error, please use valid JSON format.');
return;
}
parsedInput = result.data;
}
await onExecute(workflow.id, parsedInput);
setInput('');
onClose();
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
onClick={onClose}
/>
{/* Modal */}
<div className="relative bg-white dark:bg-gray-800 rounded-xl shadow-2xl w-full max-w-md mx-4 overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-green-100 dark:bg-green-900/30 rounded-lg flex items-center justify-center">
<Play className="w-4 h-4 text-green-600 dark:text-green-400" />
</div>
<div>
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
</h2>
<p className="text-sm text-gray-500 dark:text-gray-400">{workflow.name}</p>
</div>
</div>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 p-1"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Body */}
<div className="p-4">
<div className="text-sm text-gray-600 dark:text-gray-400 mb-2">
(JSON ):
</div>
<textarea
className="w-full px-3 py-2 text-sm border border-gray-200 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
rows={4}
placeholder='{"key": "value"}'
value={input}
onChange={(e) => setInput(e.target.value)}
/>
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-2 p-4 border-t border-gray-200 dark:border-gray-700">
<button
onClick={onClose}
className="px-4 py-2 text-sm border border-gray-200 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300"
>
</button>
<button
onClick={handleExecute}
disabled={isExecuting}
className="px-4 py-2 text-sm bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
{isExecuting ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
...
</>
) : (
<>
<Play className="w-4 h-4" />
</>
)}
</button>
</div>
</div>
</div>
);
}
// === Workflow Table Row ===
interface WorkflowRowProps {
workflow: Workflow;
onExecute: (workflow: Workflow) => void;
onEdit: (workflow: Workflow) => void;
onDelete: (workflow: Workflow) => void;
onHistory: (workflow: Workflow) => void;
isExecuting: boolean;
isDeleting: boolean;
}
function WorkflowRow({ workflow, onExecute, onEdit, onDelete, onHistory, isExecuting, isDeleting }: WorkflowRowProps) {
// Format created date if available
const createdDate = workflow.createdAt
? new Date(workflow.createdAt).toLocaleDateString('zh-CN')
: new Date().toLocaleDateString('zh-CN');
return (
<tr className="border-b border-gray-100 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors">
{/* Name */}
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<div className="w-8 h-8 bg-blue-100 dark:bg-blue-900/30 rounded-lg flex items-center justify-center flex-shrink-0">
<GitBranch className="w-4 h-4 text-blue-600 dark:text-blue-400" />
</div>
<div className="min-w-0">
<div className="font-medium text-gray-900 dark:text-white truncate">
{workflow.name}
</div>
{workflow.description && (
<div className="text-xs text-gray-500 dark:text-gray-400 truncate">
{workflow.description}
</div>
)}
</div>
</div>
</td>
{/* Steps */}
<td className="px-4 py-3 text-center">
<span className="inline-flex items-center justify-center w-8 h-8 bg-gray-100 dark:bg-gray-700 rounded-full text-sm font-medium text-gray-700 dark:text-gray-300">
{workflow.steps}
</span>
</td>
{/* Created */}
<td className="px-4 py-3 text-sm text-gray-500 dark:text-gray-400">
{createdDate}
</td>
{/* Actions */}
<td className="px-4 py-3">
<div className="flex items-center justify-end gap-1">
<button
onClick={() => onExecute(workflow)}
disabled={isExecuting}
className="p-1.5 text-green-600 hover:bg-green-50 dark:hover:bg-green-900/20 rounded-md disabled:opacity-50 disabled:cursor-not-allowed"
title="Run"
>
{isExecuting ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Play className="w-4 h-4" />
)}
</button>
<button
onClick={() => onEdit(workflow)}
className="p-1.5 text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded-md"
title="Edit"
>
<Edit className="w-4 h-4" />
</button>
<button
onClick={() => onHistory(workflow)}
className="p-1.5 text-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md"
title="History"
>
<History className="w-4 h-4" />
</button>
<button
onClick={() => onDelete(workflow)}
disabled={isDeleting}
className="p-1.5 text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-md disabled:opacity-50 disabled:cursor-not-allowed"
title="删除"
>
{isDeleting ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Trash2 className="w-4 h-4" />
)}
</button>
</div>
</td>
</tr>
);
}
// === Main WorkflowList Component ===
export function WorkflowList() {
const workflows = useWorkflowStore((s) => s.workflows);
const loadWorkflows = useWorkflowStore((s) => s.loadWorkflows);
const triggerWorkflow = useWorkflowStore((s) => s.triggerWorkflow);
const deleteWorkflow = useWorkflowStore((s) => s.deleteWorkflow);
const createWorkflow = useWorkflowStore((s) => s.createWorkflow);
const updateWorkflow = useWorkflowStore((s) => s.updateWorkflow);
const isLoading = useWorkflowStore((s) => s.isLoading);
const [viewMode, setViewMode] = useState<ViewMode>('list');
const [executingWorkflowId, setExecutingWorkflowId] = useState<string | null>(null);
const [deletingWorkflowId, setDeletingWorkflowId] = useState<string | null>(null);
const [selectedWorkflow, setSelectedWorkflow] = useState<Workflow | null>(null);
const [showExecuteModal, setShowExecuteModal] = useState(false);
const [showEditor, setShowEditor] = useState(false);
const [showHistory, setShowHistory] = useState(false);
const [editingWorkflow, setEditingWorkflow] = useState<Workflow | null>(null);
const [isSaving, setIsSaving] = useState(false);
useEffect(() => {
loadWorkflows();
}, [loadWorkflows]);
const handleExecute = useCallback(async (id: string, input?: Record<string, unknown>) => {
setExecutingWorkflowId(id);
try {
await triggerWorkflow(id, input);
} finally {
setExecutingWorkflowId(null);
}
}, [triggerWorkflow]);
const handleExecuteClick = useCallback((workflow: Workflow) => {
setSelectedWorkflow(workflow);
setShowExecuteModal(true);
}, []);
const handleEdit = useCallback((workflow: Workflow) => {
setEditingWorkflow(workflow);
setShowEditor(true);
}, []);
const handleDelete = useCallback(async (workflow: Workflow) => {
if (confirm(`确定要删除 "${workflow.name}" 吗?此操作不可撤销。`)) {
setDeletingWorkflowId(workflow.id);
try {
await deleteWorkflow(workflow.id);
// The store will update the workflows list automatically
} catch (error) {
console.error('Failed to delete workflow:', error);
alert(`删除工作流失败: ${error instanceof Error ? error.message : '未知错误'}`);
} finally {
setDeletingWorkflowId(null);
}
}
}, [deleteWorkflow]);
const handleHistory = useCallback((workflow: Workflow) => {
setSelectedWorkflow(workflow);
setShowHistory(true);
}, []);
const handleNewWorkflow = useCallback(() => {
setEditingWorkflow(null);
setShowEditor(true);
}, []);
const handleSaveWorkflow = useCallback(async (data: {
name: string;
description?: string;
steps: Array<{
handName: string;
name?: string;
params?: Record<string, unknown>;
condition?: string;
}>;
}) => {
setIsSaving(true);
try {
if (editingWorkflow) {
await updateWorkflow(editingWorkflow.id, data);
} else {
await createWorkflow(data);
}
await loadWorkflows();
} finally {
setIsSaving(false);
}
}, [editingWorkflow, createWorkflow, updateWorkflow, loadWorkflows]);
const handleCloseEditor = useCallback(() => {
setShowEditor(false);
setEditingWorkflow(null);
}, []);
const handleCloseModal = useCallback(() => {
setShowExecuteModal(false);
setSelectedWorkflow(null);
}, []);
// Loading state
if (isLoading && workflows.length === 0) {
return (
<div className="p-4 text-center">
<Loader2 className="w-6 h-6 animate-spin mx-auto text-gray-400 mb-2" />
<p className="text-sm text-gray-500 dark:text-gray-400">...</p>
</div>
);
}
return (
<div className="space-y-4">
{/* Header */}
<div className="flex items-start justify-between gap-4">
<div>
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
</h2>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
</p>
</div>
<button
onClick={() => loadWorkflows()}
disabled={isLoading}
className="text-sm text-blue-600 dark:text-blue-400 hover:underline flex items-center gap-1 disabled:opacity-50"
>
{isLoading ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<RefreshCw className="w-3.5 h-3.5" />
)}
</button>
</div>
{/* Toolbar */}
<div className="flex items-center justify-between">
{/* View Toggle */}
<div className="flex items-center bg-gray-100 dark:bg-gray-800 rounded-lg p-1">
<button
onClick={() => setViewMode('list')}
className={`flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-md transition-colors ${
viewMode === 'list'
? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
}`}
>
<List className="w-3.5 h-3.5" />
</button>
<button
onClick={() => setViewMode('visual')}
className={`flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-md transition-colors ${
viewMode === 'visual'
? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
}`}
>
<GitBranch className="w-3.5 h-3.5" />
</button>
</div>
{/* New Workflow Button */}
<button
onClick={handleNewWorkflow}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
<Plus className="w-4 h-4" />
</button>
</div>
{/* Content */}
{viewMode === 'list' ? (
workflows.length === 0 ? (
// Empty State
<div className="p-8 text-center">
<div className="w-12 h-12 bg-gray-100 dark:bg-gray-800 rounded-full flex items-center justify-center mx-auto mb-3">
<GitBranch className="w-6 h-6 text-gray-400" />
</div>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-3">
</p>
<p className="text-xs text-gray-400 dark:text-gray-500 mb-4">
</p>
<button
onClick={handleNewWorkflow}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
<Plus className="w-4 h-4" />
</button>
</div>
) : (
// Table View
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
<table className="w-full">
<thead>
<tr className="border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/50">
<th className="px-4 py-2.5 text-left text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">
</th>
<th className="px-4 py-2.5 text-center text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">
</th>
<th className="px-4 py-2.5 text-left text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">
</th>
<th className="px-4 py-2.5 text-right text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">
</th>
</tr>
</thead>
<tbody>
{workflows.map((workflow) => (
<WorkflowRow
key={workflow.id}
workflow={workflow}
onExecute={handleExecuteClick}
onEdit={handleEdit}
onDelete={handleDelete}
onHistory={handleHistory}
isExecuting={executingWorkflowId === workflow.id}
isDeleting={deletingWorkflowId === workflow.id}
/>
))}
</tbody>
</table>
</div>
)
) : (
// Visual Builder View (placeholder)
<div className="p-8 text-center bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
<div className="w-12 h-12 bg-gray-100 dark:bg-gray-700 rounded-full flex items-center justify-center mx-auto mb-3">
<GitBranch className="w-6 h-6 text-gray-400" />
</div>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-2">
</p>
<p className="text-xs text-gray-400 dark:text-gray-500">
</p>
</div>
)}
{/* Execute Modal */}
{selectedWorkflow && (
<ExecuteModal
workflow={selectedWorkflow}
isOpen={showExecuteModal}
onClose={handleCloseModal}
onExecute={handleExecute}
isExecuting={executingWorkflowId === selectedWorkflow.id}
/>
)}
{/* Workflow Editor */}
<WorkflowEditor
workflow={editingWorkflow || undefined}
isOpen={showEditor}
onClose={handleCloseEditor}
onSave={handleSaveWorkflow}
isSaving={isSaving}
/>
{/* Workflow History */}
{selectedWorkflow && (
<WorkflowHistory
workflow={selectedWorkflow}
isOpen={showHistory}
onClose={() => {
setShowHistory(false);
setSelectedWorkflow(null);
}}
/>
)}
</div>
);
}
export default WorkflowList;