Files
zclaw_openfang/desktop/src/components/WorkflowHistory.tsx
iven 0d4fa96b82
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
refactor: 统一项目名称从OpenFang到ZCLAW
重构所有代码和文档中的项目名称,将OpenFang统一更新为ZCLAW。包括:
- 配置文件中的项目名称
- 代码注释和文档引用
- 环境变量和路径
- 类型定义和接口名称
- 测试用例和模拟数据

同时优化部分代码结构,移除未使用的模块,并更新相关依赖项。
2026-03-27 07:36:03 +08:00

282 lines
11 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.

/**
* WorkflowHistory - ZCLAW Workflow Execution History Component
*
* Displays the execution history of a specific workflow,
* showing run details, status, and results.
*
* Design based on ZCLAW Dashboard v0.4.0
*/
import { useState, useEffect, useCallback } from 'react';
import { useWorkflowStore, type Workflow, type WorkflowRun } from '../store/workflowStore';
import {
ArrowLeft,
Clock,
CheckCircle,
XCircle,
AlertCircle,
Loader2,
ChevronRight,
RefreshCw,
History,
} from 'lucide-react';
interface WorkflowHistoryProps {
workflow: Workflow;
isOpen?: boolean;
onClose?: () => void;
onBack?: () => void;
}
// Status configuration
const STATUS_CONFIG: Record<string, { label: string; className: string; icon: React.ComponentType<{ className?: string }> }> = {
pending: { label: '等待中', className: 'text-gray-500 bg-gray-100', icon: Clock },
running: { label: '运行中', className: 'text-blue-600 bg-blue-100', icon: Loader2 },
completed: { label: '已完成', className: 'text-green-600 bg-green-100', icon: CheckCircle },
success: { label: '成功', className: 'text-green-600 bg-green-100', icon: CheckCircle },
failed: { label: '失败', className: 'text-red-600 bg-red-100', icon: XCircle },
error: { label: '错误', className: 'text-red-600 bg-red-100', icon: XCircle },
cancelled: { label: '已取消', className: 'text-gray-500 bg-gray-100', icon: XCircle },
paused: { label: '已暂停', className: 'text-yellow-600 bg-yellow-100', icon: AlertCircle },
};
// Run Card Component
interface RunCardProps {
run: WorkflowRun;
index: number;
}
function RunCard({ run, index }: RunCardProps) {
const [isExpanded, setIsExpanded] = useState(false);
const config = STATUS_CONFIG[run.status] || STATUS_CONFIG.pending;
const StatusIcon = config.icon;
// Format result for display
const resultText = run.result
? (typeof run.result === 'string' ? run.result : JSON.stringify(run.result, null, 2))
: undefined;
return (
<div className="bg-gray-50 dark:bg-gray-900 rounded-lg border border-gray-100 dark:border-gray-700">
<div
className="flex items-center justify-between p-3 cursor-pointer"
onClick={() => setIsExpanded(!isExpanded)}
>
<div className="flex items-center gap-3 min-w-0">
<span className="flex-shrink-0 w-6 h-6 bg-gray-200 dark:bg-gray-700 rounded-full flex items-center justify-center text-xs font-medium text-gray-600 dark:text-gray-400">
{index + 1}
</span>
<StatusIcon className={`w-4 h-4 flex-shrink-0 ${run.status === 'running' ? 'animate-spin' : ''}`} />
<span className="text-sm font-medium text-gray-800 dark:text-gray-200 truncate">
#{run.runId.slice(0, 8)}
</span>
{run.step && (
<span className="text-xs text-gray-500 dark:text-gray-400 truncate">
: {run.step}
</span>
)}
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<span className={`text-xs px-2 py-0.5 rounded ${config.className}`}>
{config.label}
</span>
<ChevronRight className={`w-4 h-4 text-gray-400 transition-transform ${isExpanded ? 'rotate-90' : ''}`} />
</div>
</div>
{/* Expanded Details */}
{isExpanded && (
<div className="px-3 pb-3 pt-0 border-t border-gray-200 dark:border-gray-700 space-y-2">
<div className="mt-3 text-xs text-gray-500 dark:text-gray-400 space-y-1">
<div className="flex justify-between">
<span> ID</span>
<span className="font-mono">{run.runId}</span>
</div>
</div>
{resultText && (
<div className="p-2 bg-green-50 dark:bg-green-900/20 rounded border border-green-200 dark:border-green-800 text-green-700 dark:text-green-400 text-xs whitespace-pre-wrap max-h-60 overflow-auto">
<div className="font-medium mb-1">:</div>
{resultText}
</div>
)}
{run.status === 'failed' && !resultText && (
<div className="p-2 bg-red-50 dark:bg-red-900/20 rounded border border-red-200 dark:border-red-800 text-red-700 dark:text-red-400 text-xs">
</div>
)}
</div>
)}
</div>
);
}
export function WorkflowHistory({ workflow, onBack }: WorkflowHistoryProps) {
const loadWorkflowRuns = useWorkflowStore((s) => s.loadWorkflowRuns);
const cancelWorkflow = useWorkflowStore((s) => s.cancelWorkflow);
const isLoading = useWorkflowStore((s) => s.isLoading);
const [runs, setRuns] = useState<WorkflowRun[]>([]);
const [isRefreshing, setIsRefreshing] = useState(false);
const [cancellingRunId, setCancellingRunId] = useState<string | null>(null);
// Load workflow runs
const loadRuns = useCallback(async () => {
try {
const result = await loadWorkflowRuns(workflow.id, { limit: 50 });
setRuns(result || []);
} catch {
setRuns([]);
}
}, [workflow.id, loadWorkflowRuns]);
useEffect(() => {
loadRuns();
}, [loadRuns]);
// Refresh runs
const handleRefresh = useCallback(async () => {
setIsRefreshing(true);
try {
await loadRuns();
} finally {
setIsRefreshing(false);
}
}, [loadRuns]);
// Cancel running workflow
const handleCancel = useCallback(async (runId: string) => {
if (!confirm('确定要取消这个正在运行的工作流吗?')) return;
setCancellingRunId(runId);
try {
await cancelWorkflow(workflow.id, runId);
await loadRuns();
} catch (error) {
console.error('Failed to cancel workflow:', error);
alert(`取消失败: ${error instanceof Error ? error.message : '未知错误'}`);
} finally {
setCancellingRunId(null);
}
}, [workflow.id, cancelWorkflow, loadRuns]);
// Categorize runs
const runningRuns = runs.filter(r => r.status === 'running');
const completedRuns = runs.filter(r => ['completed', 'success', 'failed', 'error', 'cancelled'].includes(r.status));
const pendingRuns = runs.filter(r => ['pending', 'paused'].includes(r.status));
return (
<div className="h-full flex flex-col">
{/* Header */}
<div className="p-4 border-b border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 flex-shrink-0">
<div className="flex items-center gap-3">
<button
onClick={onBack}
className="p-1.5 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
>
<ArrowLeft className="w-4 h-4" />
</button>
<div className="w-8 h-8 bg-blue-100 dark:bg-blue-900/30 rounded-lg flex items-center justify-center flex-shrink-0">
<History className="w-4 h-4 text-blue-600 dark:text-blue-400" />
</div>
<div className="flex-1 min-w-0">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white truncate">
{workflow.name}
</h2>
<p className="text-xs text-gray-500 dark:text-gray-400">
({runs.length} )
</p>
</div>
<button
onClick={handleRefresh}
disabled={isRefreshing}
className="p-1.5 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors disabled:opacity-50"
title="刷新"
>
<RefreshCw className={`w-4 h-4 ${isRefreshing ? 'animate-spin' : ''}`} />
</button>
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{/* Loading State */}
{isLoading && runs.length === 0 && (
<div className="text-center py-8">
<Loader2 className="w-8 h-8 mx-auto text-gray-400 animate-spin mb-3" />
<p className="text-sm text-gray-500 dark:text-gray-400">...</p>
</div>
)}
{/* Running Runs */}
{runningRuns.length > 0 && (
<div className="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-4 border border-blue-200 dark:border-blue-800">
<h3 className="text-sm font-semibold text-blue-700 dark:text-blue-400 mb-3 flex items-center gap-2">
<Loader2 className="w-4 h-4 animate-spin" />
({runningRuns.length})
</h3>
<div className="space-y-2">
{runningRuns.map((run, index) => (
<div key={run.runId} className="flex items-center justify-between">
<RunCard run={run} index={index} />
<button
onClick={() => handleCancel(run.runId)}
disabled={cancellingRunId === run.runId}
className="ml-2 px-2 py-1 text-xs text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 rounded disabled:opacity-50"
>
{cancellingRunId === run.runId ? '取消中...' : '取消'}
</button>
</div>
))}
</div>
</div>
)}
{/* Pending Runs */}
{pendingRuns.length > 0 && (
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3 flex items-center gap-2">
<Clock className="w-4 h-4" />
({pendingRuns.length})
</h3>
<div className="space-y-2">
{pendingRuns.map((run, index) => (
<RunCard key={run.runId} run={run} index={index} />
))}
</div>
</div>
)}
{/* Completed Runs */}
{completedRuns.length > 0 && (
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">
({completedRuns.length})
</h3>
<div className="space-y-2">
{completedRuns.map((run, index) => (
<RunCard key={run.runId} run={run} index={index} />
))}
</div>
</div>
)}
{/* Empty State */}
{!isLoading && runs.length === 0 && (
<div className="text-center py-12">
<div className="w-16 h-16 bg-gray-100 dark:bg-gray-800 rounded-full flex items-center justify-center mx-auto mb-4">
<History className="w-8 h-8 text-gray-400" />
</div>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-1"></p>
<p className="text-xs text-gray-400 dark:text-gray-500">
</p>
</div>
)}
</div>
</div>
);
}
export default WorkflowHistory;