chore: 清理40个死代码文件 (~9,639行)
删除无任何活跃渲染路径引用的组件: - Automation/ 全目录 (7文件, 2,598行) - WorkflowBuilder/ 全目录 (14文件, 1,539行) - SchedulerPanel + 依赖树 (5文件, 2,595行) - 独立死组件 (14文件, 2,907行) 含 SkillMarket, HandsPanel, ErrorNotification 等 - PipelineResultPreview 根目录副本 (534行, 活跃版在 pipeline/)
This commit is contained in:
@@ -1,352 +0,0 @@
|
|||||||
/**
|
|
||||||
* ApprovalQueue - Approval Management Component
|
|
||||||
*
|
|
||||||
* Displays pending approvals for hand executions that require
|
|
||||||
* human approval, with approve/reject actions.
|
|
||||||
*
|
|
||||||
* @module components/Automation/ApprovalQueue
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from 'react';
|
|
||||||
import { useHandStore } from '../../store/handStore';
|
|
||||||
import type { Approval, ApprovalStatus } from '../../store/handStore';
|
|
||||||
import {
|
|
||||||
Clock,
|
|
||||||
CheckCircle,
|
|
||||||
XCircle,
|
|
||||||
AlertTriangle,
|
|
||||||
RefreshCw,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { useToast } from '../ui/Toast';
|
|
||||||
|
|
||||||
// === Status Config ===
|
|
||||||
|
|
||||||
const STATUS_CONFIG: Record<ApprovalStatus, {
|
|
||||||
label: string;
|
|
||||||
className: string;
|
|
||||||
icon: typeof CheckCircle;
|
|
||||||
}> = {
|
|
||||||
pending: {
|
|
||||||
label: '待处理',
|
|
||||||
className: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400',
|
|
||||||
icon: Clock,
|
|
||||||
},
|
|
||||||
approved: {
|
|
||||||
label: '已批准',
|
|
||||||
className: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400',
|
|
||||||
icon: CheckCircle,
|
|
||||||
},
|
|
||||||
rejected: {
|
|
||||||
label: '已拒绝',
|
|
||||||
className: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400',
|
|
||||||
icon: XCircle,
|
|
||||||
},
|
|
||||||
expired: {
|
|
||||||
label: '已过期',
|
|
||||||
className: 'bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-400',
|
|
||||||
icon: AlertTriangle,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// === Component Props ===
|
|
||||||
|
|
||||||
interface ApprovalQueueProps {
|
|
||||||
showFilters?: boolean;
|
|
||||||
maxHeight?: string;
|
|
||||||
onApprove?: (approval: Approval) => void;
|
|
||||||
onReject?: (approval: Approval) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
// === Approval Card Component ===
|
|
||||||
|
|
||||||
interface ApprovalCardProps {
|
|
||||||
approval: Approval;
|
|
||||||
onApprove: () => Promise<void>;
|
|
||||||
onReject: (reason: string) => Promise<void>;
|
|
||||||
isProcessing: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ApprovalCard({ approval, onApprove, onReject, isProcessing }: ApprovalCardProps) {
|
|
||||||
const [showRejectInput, setShowRejectInput] = useState(false);
|
|
||||||
const [rejectReason, setRejectReason] = useState('');
|
|
||||||
const StatusIcon = STATUS_CONFIG[approval.status].icon;
|
|
||||||
|
|
||||||
const handleReject = useCallback(async () => {
|
|
||||||
if (!rejectReason.trim()) {
|
|
||||||
setShowRejectInput(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await onReject(rejectReason);
|
|
||||||
setShowRejectInput(false);
|
|
||||||
setRejectReason('');
|
|
||||||
}, [rejectReason, onReject]);
|
|
||||||
|
|
||||||
const timeAgo = useCallback((dateStr: string) => {
|
|
||||||
const date = new Date(dateStr);
|
|
||||||
const now = new Date();
|
|
||||||
const diffMs = now.getTime() - date.getTime();
|
|
||||||
const diffMins = Math.floor(diffMs / 60000);
|
|
||||||
const diffHours = Math.floor(diffMins / 60);
|
|
||||||
const diffDays = Math.floor(diffHours / 24);
|
|
||||||
|
|
||||||
if (diffMins < 1) return '刚刚';
|
|
||||||
if (diffMins < 60) return `${diffMins} 分钟前`;
|
|
||||||
if (diffHours < 24) return `${diffHours} 小时前`;
|
|
||||||
return `${diffDays} 天前`;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-start justify-between gap-3 mb-3">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className={`px-2 py-0.5 rounded text-xs ${STATUS_CONFIG[approval.status].className}`}>
|
|
||||||
<StatusIcon className="w-3 h-3 inline mr-1" />
|
|
||||||
{STATUS_CONFIG[approval.status].label}
|
|
||||||
</span>
|
|
||||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
|
||||||
{timeAgo(approval.requestedAt)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Content */}
|
|
||||||
<div className="mb-3">
|
|
||||||
<h4 className="font-medium text-gray-900 dark:text-white mb-1">
|
|
||||||
{approval.handName}
|
|
||||||
</h4>
|
|
||||||
{approval.reason && (
|
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400">{approval.reason}</p>
|
|
||||||
)}
|
|
||||||
{approval.action && (
|
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-500 mt-1">
|
|
||||||
操作: {approval.action}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Params Preview */}
|
|
||||||
{approval.params && Object.keys(approval.params).length > 0 && (
|
|
||||||
<div className="mb-3 p-2 bg-gray-50 dark:bg-gray-900 rounded text-xs">
|
|
||||||
<p className="text-gray-500 dark:text-gray-400 mb-1">参数:</p>
|
|
||||||
<pre className="text-gray-700 dark:text-gray-300 overflow-x-auto">
|
|
||||||
{JSON.stringify(approval.params, null, 2)}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Reject Input */}
|
|
||||||
{showRejectInput && (
|
|
||||||
<div className="mb-3">
|
|
||||||
<textarea
|
|
||||||
value={rejectReason}
|
|
||||||
onChange={(e) => setRejectReason(e.target.value)}
|
|
||||||
placeholder="请输入拒绝原因..."
|
|
||||||
className="w-full px-3 py-2 text-sm border border-gray-200 dark:border-gray-700 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white resize-none"
|
|
||||||
rows={2}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Actions */}
|
|
||||||
{approval.status === 'pending' && (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<button
|
|
||||||
onClick={onApprove}
|
|
||||||
disabled={isProcessing}
|
|
||||||
className="flex-1 px-3 py-1.5 text-sm bg-green-500 text-white rounded-md hover:bg-green-600 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-1"
|
|
||||||
>
|
|
||||||
{isProcessing ? (
|
|
||||||
<RefreshCw className="w-3.5 h-3.5 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<CheckCircle className="w-3.5 h-3.5" />
|
|
||||||
)}
|
|
||||||
批准
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={handleReject}
|
|
||||||
disabled={isProcessing}
|
|
||||||
className="flex-1 px-3 py-1.5 text-sm bg-red-500 text-white rounded-md hover:bg-red-600 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-1"
|
|
||||||
>
|
|
||||||
{isProcessing ? (
|
|
||||||
<RefreshCw className="w-3.5 h-3.5 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<XCircle className="w-3.5 h-3.5" />
|
|
||||||
)}
|
|
||||||
拒绝
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Response Info */}
|
|
||||||
{approval.status !== 'pending' && approval.respondedAt && (
|
|
||||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
|
||||||
{approval.respondedBy && `由 ${approval.respondedBy} `}
|
|
||||||
{STATUS_CONFIG[approval.status].label}
|
|
||||||
{approval.responseReason && ` - ${approval.responseReason}`}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// === Main Component ===
|
|
||||||
|
|
||||||
export function ApprovalQueue({
|
|
||||||
showFilters = true,
|
|
||||||
maxHeight = '400px',
|
|
||||||
onApprove,
|
|
||||||
onReject,
|
|
||||||
}: ApprovalQueueProps) {
|
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
// Store state
|
|
||||||
const approvals = useHandStore(s => s.approvals);
|
|
||||||
const loadApprovals = useHandStore(s => s.loadApprovals);
|
|
||||||
const respondToApproval = useHandStore(s => s.respondToApproval);
|
|
||||||
const isLoading = useHandStore(s => s.isLoading);
|
|
||||||
|
|
||||||
// Local state
|
|
||||||
const [statusFilter, setStatusFilter] = useState<ApprovalStatus | 'all'>('pending');
|
|
||||||
const [processingIds, setProcessingIds] = useState<Set<string>>(new Set());
|
|
||||||
|
|
||||||
// Load approvals on mount
|
|
||||||
useEffect(() => {
|
|
||||||
loadApprovals(statusFilter === 'all' ? undefined : statusFilter);
|
|
||||||
}, [loadApprovals, statusFilter]);
|
|
||||||
|
|
||||||
// Handle approve
|
|
||||||
const handleApprove = useCallback(async (approval: Approval) => {
|
|
||||||
setProcessingIds(prev => new Set(prev).add(approval.id));
|
|
||||||
try {
|
|
||||||
await respondToApproval(approval.id, true);
|
|
||||||
toast(`已批准: ${approval.handName}`, 'success');
|
|
||||||
onApprove?.(approval);
|
|
||||||
} catch (err) {
|
|
||||||
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
||||||
toast(`批准失败: ${errorMsg}`, 'error');
|
|
||||||
} finally {
|
|
||||||
setProcessingIds(prev => {
|
|
||||||
const next = new Set(prev);
|
|
||||||
next.delete(approval.id);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [respondToApproval, toast, onApprove]);
|
|
||||||
|
|
||||||
// Handle reject
|
|
||||||
const handleReject = useCallback(async (approval: Approval, reason: string) => {
|
|
||||||
setProcessingIds(prev => new Set(prev).add(approval.id));
|
|
||||||
try {
|
|
||||||
await respondToApproval(approval.id, false, reason);
|
|
||||||
toast(`已拒绝: ${approval.handName}`, 'success');
|
|
||||||
onReject?.(approval);
|
|
||||||
} catch (err) {
|
|
||||||
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
||||||
toast(`拒绝失败: ${errorMsg}`, 'error');
|
|
||||||
} finally {
|
|
||||||
setProcessingIds(prev => {
|
|
||||||
const next = new Set(prev);
|
|
||||||
next.delete(approval.id);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [respondToApproval, toast, onReject]);
|
|
||||||
|
|
||||||
// Filter approvals
|
|
||||||
const filteredApprovals = statusFilter === 'all'
|
|
||||||
? approvals
|
|
||||||
: approvals.filter(a => a.status === statusFilter);
|
|
||||||
|
|
||||||
// Stats
|
|
||||||
const stats = {
|
|
||||||
pending: approvals.filter(a => a.status === 'pending').length,
|
|
||||||
approved: approvals.filter(a => a.status === 'approved').length,
|
|
||||||
rejected: approvals.filter(a => a.status === 'rejected').length,
|
|
||||||
expired: approvals.filter(a => a.status === 'expired').length,
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col h-full">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-200 dark:border-gray-700">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Clock className="w-5 h-5 text-orange-500" />
|
|
||||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
|
|
||||||
审批队列
|
|
||||||
</h2>
|
|
||||||
{stats.pending > 0 && (
|
|
||||||
<span className="px-2 py-0.5 text-xs font-medium bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400 rounded-full">
|
|
||||||
{stats.pending} 待处理
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => loadApprovals(statusFilter === 'all' ? undefined : statusFilter)}
|
|
||||||
disabled={isLoading}
|
|
||||||
className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 disabled:opacity-50"
|
|
||||||
title="刷新"
|
|
||||||
>
|
|
||||||
<RefreshCw className={`w-4 h-4 ${isLoading ? 'animate-spin' : ''}`} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Filters */}
|
|
||||||
{showFilters && (
|
|
||||||
<div className="flex items-center gap-2 px-4 py-2 border-b border-gray-200 dark:border-gray-700 overflow-x-auto">
|
|
||||||
{[
|
|
||||||
{ value: 'pending', label: '待处理', count: stats.pending },
|
|
||||||
{ value: 'approved', label: '已批准', count: stats.approved },
|
|
||||||
{ value: 'rejected', label: '已拒绝', count: stats.rejected },
|
|
||||||
{ value: 'all', label: '全部', count: approvals.length },
|
|
||||||
].map(option => (
|
|
||||||
<button
|
|
||||||
key={option.value}
|
|
||||||
onClick={() => setStatusFilter(option.value as ApprovalStatus | 'all')}
|
|
||||||
className={`flex items-center gap-1 px-3 py-1 text-sm rounded-full whitespace-nowrap transition-colors ${
|
|
||||||
statusFilter === option.value
|
|
||||||
? 'bg-orange-500 text-white'
|
|
||||||
: 'bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{option.label}
|
|
||||||
<span className={`text-xs ${statusFilter === option.value ? 'text-white/80' : 'text-gray-500 dark:text-gray-400'}`}>
|
|
||||||
({option.count})
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Content */}
|
|
||||||
<div className="flex-1 overflow-y-auto p-4" style={{ maxHeight }}>
|
|
||||||
{isLoading && approvals.length === 0 ? (
|
|
||||||
<div className="flex items-center justify-center h-32">
|
|
||||||
<RefreshCw className="w-6 h-6 animate-spin text-gray-400" />
|
|
||||||
</div>
|
|
||||||
) : filteredApprovals.length === 0 ? (
|
|
||||||
<div className="flex flex-col items-center justify-center h-32 text-center">
|
|
||||||
<Clock className="w-8 h-8 text-gray-400 mb-2" />
|
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
|
||||||
{statusFilter === 'pending' ? '暂无待处理的审批' : '暂无审批记录'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-3">
|
|
||||||
{filteredApprovals.map(approval => (
|
|
||||||
<ApprovalCard
|
|
||||||
key={approval.id}
|
|
||||||
approval={approval}
|
|
||||||
onApprove={() => handleApprove(approval)}
|
|
||||||
onReject={(reason) => handleReject(approval, reason)}
|
|
||||||
isProcessing={processingIds.has(approval.id)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default ApprovalQueue;
|
|
||||||
@@ -1,402 +0,0 @@
|
|||||||
/**
|
|
||||||
* AutomationCard - Unified Card for Hands and Workflows
|
|
||||||
*
|
|
||||||
* Displays automation items with status, parameters, and actions.
|
|
||||||
* Supports both grid and list view modes.
|
|
||||||
*
|
|
||||||
* @module components/Automation/AutomationCard
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useState, useCallback } from 'react';
|
|
||||||
import type { AutomationItem, AutomationStatus } from '../../types/automation';
|
|
||||||
import { CATEGORY_CONFIGS } from '../../types/automation';
|
|
||||||
import type { HandParameter } from '../../types/hands';
|
|
||||||
import { HandParamsForm } from '../HandParamsForm';
|
|
||||||
import {
|
|
||||||
Zap,
|
|
||||||
Clock,
|
|
||||||
CheckCircle,
|
|
||||||
XCircle,
|
|
||||||
AlertTriangle,
|
|
||||||
Loader2,
|
|
||||||
Settings,
|
|
||||||
Play,
|
|
||||||
MoreVertical,
|
|
||||||
} from 'lucide-react';
|
|
||||||
|
|
||||||
// === Status Config ===
|
|
||||||
|
|
||||||
const STATUS_CONFIG: Record<AutomationStatus, {
|
|
||||||
label: string;
|
|
||||||
className: string;
|
|
||||||
dotClass: string;
|
|
||||||
icon?: typeof CheckCircle;
|
|
||||||
}> = {
|
|
||||||
idle: {
|
|
||||||
label: '就绪',
|
|
||||||
className: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400',
|
|
||||||
dotClass: 'bg-green-500',
|
|
||||||
},
|
|
||||||
running: {
|
|
||||||
label: '运行中',
|
|
||||||
className: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400',
|
|
||||||
dotClass: 'bg-blue-500 animate-pulse',
|
|
||||||
icon: Loader2,
|
|
||||||
},
|
|
||||||
needs_approval: {
|
|
||||||
label: '待审批',
|
|
||||||
className: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400',
|
|
||||||
dotClass: 'bg-yellow-500',
|
|
||||||
icon: AlertTriangle,
|
|
||||||
},
|
|
||||||
error: {
|
|
||||||
label: '错误',
|
|
||||||
className: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400',
|
|
||||||
dotClass: 'bg-red-500',
|
|
||||||
icon: XCircle,
|
|
||||||
},
|
|
||||||
unavailable: {
|
|
||||||
label: '不可用',
|
|
||||||
className: 'bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-400',
|
|
||||||
dotClass: 'bg-gray-400',
|
|
||||||
},
|
|
||||||
setup_needed: {
|
|
||||||
label: '需配置',
|
|
||||||
className: 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400',
|
|
||||||
dotClass: 'bg-orange-500',
|
|
||||||
icon: Settings,
|
|
||||||
},
|
|
||||||
completed: {
|
|
||||||
label: '已完成',
|
|
||||||
className: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400',
|
|
||||||
dotClass: 'bg-green-500',
|
|
||||||
icon: CheckCircle,
|
|
||||||
},
|
|
||||||
paused: {
|
|
||||||
label: '已暂停',
|
|
||||||
className: 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400',
|
|
||||||
dotClass: 'bg-gray-400',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// === Component Props ===
|
|
||||||
|
|
||||||
interface AutomationCardProps {
|
|
||||||
item: AutomationItem;
|
|
||||||
viewMode?: 'grid' | 'list';
|
|
||||||
isSelected?: boolean;
|
|
||||||
isExecuting?: boolean;
|
|
||||||
onSelect?: (selected: boolean) => void;
|
|
||||||
onExecute?: (params?: Record<string, unknown>) => void;
|
|
||||||
onClick?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
// === Status Badge Component ===
|
|
||||||
|
|
||||||
function StatusBadge({ status }: { status: AutomationStatus }) {
|
|
||||||
const config = STATUS_CONFIG[status] || STATUS_CONFIG.unavailable;
|
|
||||||
const Icon = config.icon;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<span className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium ${config.className}`}>
|
|
||||||
{Icon ? (
|
|
||||||
<Icon className={`w-3 h-3 ${status === 'running' ? 'animate-spin' : ''}`} />
|
|
||||||
) : (
|
|
||||||
<span className={`w-1.5 h-1.5 rounded-full ${config.dotClass}`} />
|
|
||||||
)}
|
|
||||||
{config.label}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// === Type Badge Component ===
|
|
||||||
|
|
||||||
function TypeBadge({ type }: { type: 'hand' | 'workflow' }) {
|
|
||||||
const isHand = type === 'hand';
|
|
||||||
return (
|
|
||||||
<span className={`px-2 py-0.5 rounded text-xs ${
|
|
||||||
isHand
|
|
||||||
? 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400'
|
|
||||||
: 'bg-cyan-100 text-cyan-700 dark:bg-cyan-900/30 dark:text-cyan-400'
|
|
||||||
}`}>
|
|
||||||
{isHand ? '自主能力' : '工作流'}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// === Category Badge Component ===
|
|
||||||
|
|
||||||
function CategoryBadge({ category }: { category: string }) {
|
|
||||||
const config = CATEGORY_CONFIGS[category as keyof typeof CATEGORY_CONFIGS];
|
|
||||||
if (!config) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<span className="px-2 py-0.5 rounded text-xs bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400">
|
|
||||||
{config.label}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// === Main Component ===
|
|
||||||
|
|
||||||
export function AutomationCard({
|
|
||||||
item,
|
|
||||||
viewMode = 'grid',
|
|
||||||
isSelected = false,
|
|
||||||
isExecuting = false,
|
|
||||||
onSelect,
|
|
||||||
onExecute,
|
|
||||||
onClick,
|
|
||||||
}: AutomationCardProps) {
|
|
||||||
const [showParams, setShowParams] = useState(false);
|
|
||||||
const [paramValues, setParamValues] = useState<Record<string, unknown>>({});
|
|
||||||
const [paramErrors, setParamErrors] = useState<Record<string, string>>({});
|
|
||||||
|
|
||||||
const hasParameters = item.parameters && item.parameters.length > 0;
|
|
||||||
const canActivate = item.status === 'idle' || item.status === 'setup_needed';
|
|
||||||
|
|
||||||
// Initialize default parameter values
|
|
||||||
const initializeDefaults = useCallback(() => {
|
|
||||||
if (item.parameters) {
|
|
||||||
const defaults: Record<string, unknown> = {};
|
|
||||||
item.parameters.forEach(p => {
|
|
||||||
if (p.defaultValue !== undefined) {
|
|
||||||
defaults[p.name] = p.defaultValue;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
setParamValues(defaults);
|
|
||||||
}
|
|
||||||
}, [item.parameters]);
|
|
||||||
|
|
||||||
// Handle execute click
|
|
||||||
const handleExecuteClick = useCallback(() => {
|
|
||||||
if (hasParameters && !showParams) {
|
|
||||||
initializeDefaults();
|
|
||||||
setShowParams(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate parameters
|
|
||||||
if (showParams && item.parameters) {
|
|
||||||
const errors: Record<string, string> = {};
|
|
||||||
item.parameters.forEach(param => {
|
|
||||||
if (param.required) {
|
|
||||||
const value = paramValues[param.name];
|
|
||||||
if (value === undefined || value === null || value === '') {
|
|
||||||
errors[param.name] = `${param.label} is required`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (Object.keys(errors).length > 0) {
|
|
||||||
setParamErrors(errors);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onExecute?.(showParams ? paramValues : undefined);
|
|
||||||
setShowParams(false);
|
|
||||||
setParamErrors({});
|
|
||||||
}, [hasParameters, showParams, initializeDefaults, item.parameters, paramValues, onExecute]);
|
|
||||||
|
|
||||||
// Handle checkbox change
|
|
||||||
const handleCheckboxChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onSelect?.(e.target.checked);
|
|
||||||
}, [onSelect]);
|
|
||||||
|
|
||||||
// Get icon for item
|
|
||||||
const getItemIcon = () => {
|
|
||||||
if (item.icon) {
|
|
||||||
// Map string icon names to components
|
|
||||||
const iconMap: Record<string, string> = {
|
|
||||||
Video: '🎬',
|
|
||||||
UserPlus: '👤',
|
|
||||||
Database: '🗄️',
|
|
||||||
TrendingUp: '📈',
|
|
||||||
Search: '🔍',
|
|
||||||
Twitter: '🐦',
|
|
||||||
Globe: '🌐',
|
|
||||||
Zap: '⚡',
|
|
||||||
};
|
|
||||||
return iconMap[item.icon] || '🤖';
|
|
||||||
}
|
|
||||||
return item.type === 'hand' ? '🤖' : '📋';
|
|
||||||
};
|
|
||||||
|
|
||||||
if (viewMode === 'list') {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`flex items-center gap-3 p-3 bg-white dark:bg-gray-800 rounded-lg border transition-all cursor-pointer ${
|
|
||||||
isSelected
|
|
||||||
? 'border-orange-500 ring-1 ring-orange-500'
|
|
||||||
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
|
|
||||||
}`}
|
|
||||||
onClick={onClick}
|
|
||||||
>
|
|
||||||
{/* Checkbox */}
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={isSelected}
|
|
||||||
onChange={handleCheckboxChange}
|
|
||||||
className="w-4 h-4 rounded border-gray-300 text-orange-500 focus:ring-orange-500"
|
|
||||||
onClick={e => e.stopPropagation()}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Icon */}
|
|
||||||
<span className="text-xl flex-shrink-0">{getItemIcon()}</span>
|
|
||||||
|
|
||||||
{/* Info */}
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<h3 className="font-medium text-gray-900 dark:text-white truncate">{item.name}</h3>
|
|
||||||
<TypeBadge type={item.type} />
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400 truncate">{item.description}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Status */}
|
|
||||||
<StatusBadge status={item.status} />
|
|
||||||
|
|
||||||
{/* Actions */}
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<button
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
handleExecuteClick();
|
|
||||||
}}
|
|
||||||
disabled={!canActivate || isExecuting}
|
|
||||||
className="px-3 py-1.5 text-sm bg-orange-500 text-white rounded-md hover:bg-orange-600 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-1"
|
|
||||||
>
|
|
||||||
{isExecuting ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
|
||||||
执行中
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Play className="w-3.5 h-3.5" />
|
|
||||||
执行
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={e => e.stopPropagation()}
|
|
||||||
className="p-1.5 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
|
||||||
>
|
|
||||||
<MoreVertical className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Grid view
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`relative bg-white dark:bg-gray-800 rounded-lg border transition-all ${
|
|
||||||
isSelected
|
|
||||||
? 'border-orange-500 ring-1 ring-orange-500'
|
|
||||||
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
|
|
||||||
}`}
|
|
||||||
onClick={onClick}
|
|
||||||
>
|
|
||||||
{/* Selection checkbox */}
|
|
||||||
<div className="absolute top-2 left-2">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={isSelected}
|
|
||||||
onChange={handleCheckboxChange}
|
|
||||||
className="w-4 h-4 rounded border-gray-300 text-orange-500 focus:ring-orange-500"
|
|
||||||
onClick={e => e.stopPropagation()}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Content */}
|
|
||||||
<div className="p-4 pt-8">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-start justify-between gap-3 mb-2">
|
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
|
||||||
<span className="text-xl flex-shrink-0">{getItemIcon()}</span>
|
|
||||||
<h3 className="font-medium text-gray-900 dark:text-white truncate">{item.name}</h3>
|
|
||||||
</div>
|
|
||||||
<StatusBadge status={item.status} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Description */}
|
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3 line-clamp-2">{item.description}</p>
|
|
||||||
|
|
||||||
{/* Meta */}
|
|
||||||
<div className="flex items-center gap-2 mb-3">
|
|
||||||
<TypeBadge type={item.type} />
|
|
||||||
<CategoryBadge category={item.category} />
|
|
||||||
{item.schedule?.enabled && (
|
|
||||||
<span className="flex items-center gap-1 text-xs text-gray-500 dark:text-gray-400">
|
|
||||||
<Clock className="w-3 h-3" />
|
|
||||||
已调度
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Parameters Form (shown when activating) */}
|
|
||||||
{showParams && item.parameters && item.parameters.length > 0 && (
|
|
||||||
<div className="mb-3 p-3 bg-gray-50 dark:bg-gray-900 rounded-lg">
|
|
||||||
<HandParamsForm
|
|
||||||
parameters={item.parameters as HandParameter[]}
|
|
||||||
values={paramValues}
|
|
||||||
onChange={setParamValues}
|
|
||||||
errors={paramErrors}
|
|
||||||
disabled={isExecuting}
|
|
||||||
presetKey={`${item.type}-${item.id}`}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Actions */}
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<button
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
handleExecuteClick();
|
|
||||||
}}
|
|
||||||
disabled={!canActivate || isExecuting}
|
|
||||||
className="flex-1 px-3 py-1.5 text-sm bg-orange-500 text-white rounded-md hover:bg-orange-600 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-1"
|
|
||||||
>
|
|
||||||
{isExecuting ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
|
||||||
执行中...
|
|
||||||
</>
|
|
||||||
) : showParams ? (
|
|
||||||
<>
|
|
||||||
<Play className="w-3.5 h-3.5" />
|
|
||||||
确认执行
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Zap className="w-3.5 h-3.5" />
|
|
||||||
执行
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={e => e.stopPropagation()}
|
|
||||||
className="p-1.5 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
|
||||||
title="更多选项"
|
|
||||||
>
|
|
||||||
<MoreVertical className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Schedule indicator */}
|
|
||||||
{item.schedule?.nextRun && (
|
|
||||||
<div className="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
|
||||||
下次运行: {new Date(item.schedule.nextRun).toLocaleString('zh-CN')}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default AutomationCard;
|
|
||||||
@@ -1,204 +0,0 @@
|
|||||||
/**
|
|
||||||
* AutomationFilters - Category and Search Filters
|
|
||||||
*
|
|
||||||
* Provides category tabs, search input, and view mode toggle
|
|
||||||
* for the automation panel.
|
|
||||||
*
|
|
||||||
* @module components/Automation/AutomationFilters
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useState, useCallback } from 'react';
|
|
||||||
import type { CategoryType, CategoryStats } from '../../types/automation';
|
|
||||||
import { CATEGORY_CONFIGS } from '../../types/automation';
|
|
||||||
import {
|
|
||||||
Search,
|
|
||||||
Grid,
|
|
||||||
List,
|
|
||||||
Layers,
|
|
||||||
Database,
|
|
||||||
MessageSquare,
|
|
||||||
Video,
|
|
||||||
TrendingUp,
|
|
||||||
Zap,
|
|
||||||
ChevronDown,
|
|
||||||
} from 'lucide-react';
|
|
||||||
|
|
||||||
// === Icon Map ===
|
|
||||||
|
|
||||||
const CATEGORY_ICONS: Record<CategoryType, typeof Layers> = {
|
|
||||||
all: Layers,
|
|
||||||
research: Search,
|
|
||||||
data: Database,
|
|
||||||
automation: Zap,
|
|
||||||
communication: MessageSquare,
|
|
||||||
content: Video,
|
|
||||||
productivity: TrendingUp,
|
|
||||||
};
|
|
||||||
|
|
||||||
// === Component Props ===
|
|
||||||
|
|
||||||
interface AutomationFiltersProps {
|
|
||||||
selectedCategory: CategoryType;
|
|
||||||
onCategoryChange: (category: CategoryType) => void;
|
|
||||||
searchQuery: string;
|
|
||||||
onSearchChange: (query: string) => void;
|
|
||||||
viewMode: 'grid' | 'list';
|
|
||||||
onViewModeChange: (mode: 'grid' | 'list') => void;
|
|
||||||
categoryStats: CategoryStats;
|
|
||||||
}
|
|
||||||
|
|
||||||
// === Main Component ===
|
|
||||||
|
|
||||||
export function AutomationFilters({
|
|
||||||
selectedCategory,
|
|
||||||
onCategoryChange,
|
|
||||||
searchQuery,
|
|
||||||
onSearchChange,
|
|
||||||
viewMode,
|
|
||||||
onViewModeChange,
|
|
||||||
categoryStats,
|
|
||||||
}: AutomationFiltersProps) {
|
|
||||||
const [showCategoryDropdown, setShowCategoryDropdown] = useState(false);
|
|
||||||
|
|
||||||
// Handle search input
|
|
||||||
const handleSearchChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
onSearchChange(e.target.value);
|
|
||||||
}, [onSearchChange]);
|
|
||||||
|
|
||||||
// Handle category click
|
|
||||||
const handleCategoryClick = useCallback((category: CategoryType) => {
|
|
||||||
onCategoryChange(category);
|
|
||||||
setShowCategoryDropdown(false);
|
|
||||||
}, [onCategoryChange]);
|
|
||||||
|
|
||||||
// Get categories with counts
|
|
||||||
const categories = Object.entries(CATEGORY_CONFIGS).map(([key, config]) => ({
|
|
||||||
...config,
|
|
||||||
count: categoryStats[key as CategoryType] || 0,
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Selected category config
|
|
||||||
const selectedConfig = CATEGORY_CONFIGS[selectedCategory];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="px-4 py-3 border-b border-gray-200 dark:border-gray-700 space-y-3">
|
|
||||||
{/* Search and View Mode Row */}
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
{/* Search Input */}
|
|
||||||
<div className="flex-1 relative">
|
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="搜索 Hands 或工作流..."
|
|
||||||
value={searchQuery}
|
|
||||||
onChange={handleSearchChange}
|
|
||||||
className="w-full pl-10 pr-4 py-2 text-sm border border-gray-200 dark:border-gray-700 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-transparent"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* View Mode Toggle */}
|
|
||||||
<div className="flex items-center border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
|
||||||
<button
|
|
||||||
onClick={() => onViewModeChange('grid')}
|
|
||||||
className={`p-2 ${
|
|
||||||
viewMode === 'grid'
|
|
||||||
? 'bg-orange-500 text-white'
|
|
||||||
: 'bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700'
|
|
||||||
}`}
|
|
||||||
title="网格视图"
|
|
||||||
>
|
|
||||||
<Grid className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => onViewModeChange('list')}
|
|
||||||
className={`p-2 ${
|
|
||||||
viewMode === 'list'
|
|
||||||
? 'bg-orange-500 text-white'
|
|
||||||
: 'bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700'
|
|
||||||
}`}
|
|
||||||
title="列表视图"
|
|
||||||
>
|
|
||||||
<List className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Category Tabs (Desktop) */}
|
|
||||||
<div className="hidden md:flex items-center gap-1 overflow-x-auto pb-1">
|
|
||||||
{categories.map(({ id, label, count }) => {
|
|
||||||
const Icon = CATEGORY_ICONS[id];
|
|
||||||
const isSelected = selectedCategory === id;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={id}
|
|
||||||
onClick={() => onCategoryChange(id)}
|
|
||||||
className={`flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium rounded-full whitespace-nowrap transition-colors ${
|
|
||||||
isSelected
|
|
||||||
? 'bg-orange-500 text-white'
|
|
||||||
: 'bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<Icon className="w-3.5 h-3.5" />
|
|
||||||
{label}
|
|
||||||
{count > 0 && (
|
|
||||||
<span className={`text-xs ${isSelected ? 'text-white/80' : 'text-gray-500 dark:text-gray-400'}`}>
|
|
||||||
({count})
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Category Dropdown (Mobile) */}
|
|
||||||
<div className="md:hidden relative">
|
|
||||||
<button
|
|
||||||
onClick={() => setShowCategoryDropdown(!showCategoryDropdown)}
|
|
||||||
className="w-full flex items-center justify-between px-3 py-2 text-sm border border-gray-200 dark:border-gray-700 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{(() => {
|
|
||||||
const Icon = CATEGORY_ICONS[selectedCategory];
|
|
||||||
return <Icon className="w-4 h-4" />;
|
|
||||||
})()}
|
|
||||||
<span>{selectedConfig.label}</span>
|
|
||||||
<span className="text-gray-500 dark:text-gray-400">
|
|
||||||
({categoryStats[selectedCategory] || 0})
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<ChevronDown className={`w-4 h-4 transition-transform ${showCategoryDropdown ? 'rotate-180' : ''}`} />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{showCategoryDropdown && (
|
|
||||||
<div className="absolute top-full left-0 right-0 mt-1 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg z-10 max-h-64 overflow-y-auto">
|
|
||||||
{categories.map(({ id, label, count }) => {
|
|
||||||
const Icon = CATEGORY_ICONS[id];
|
|
||||||
const isSelected = selectedCategory === id;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={id}
|
|
||||||
onClick={() => handleCategoryClick(id)}
|
|
||||||
className={`w-full flex items-center justify-between px-3 py-2 text-sm ${
|
|
||||||
isSelected
|
|
||||||
? 'bg-orange-50 dark:bg-orange-900/20 text-orange-600 dark:text-orange-400'
|
|
||||||
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Icon className="w-4 h-4" />
|
|
||||||
<span>{label}</span>
|
|
||||||
</div>
|
|
||||||
<span className="text-gray-500 dark:text-gray-400">{count}</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default AutomationFilters;
|
|
||||||
@@ -1,653 +0,0 @@
|
|||||||
/**
|
|
||||||
* AutomationPanel - Unified Automation Entry Point
|
|
||||||
*
|
|
||||||
* Combines Pipelines, Hands and Workflows into a single unified view,
|
|
||||||
* with category filtering, batch operations, and scheduling.
|
|
||||||
*
|
|
||||||
* @module components/Automation/AutomationPanel
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
|
||||||
import { useHandStore } from '../../store/handStore';
|
|
||||||
import { useWorkflowStore } from '../../store/workflowStore';
|
|
||||||
import {
|
|
||||||
type AutomationItem,
|
|
||||||
type CategoryType,
|
|
||||||
type CategoryStats,
|
|
||||||
adaptToAutomationItems,
|
|
||||||
calculateCategoryStats,
|
|
||||||
filterByCategory,
|
|
||||||
searchAutomationItems,
|
|
||||||
} from '../../types/automation';
|
|
||||||
import { AutomationCard } from './AutomationCard';
|
|
||||||
import { AutomationFilters } from './AutomationFilters';
|
|
||||||
import { BatchActionBar } from './BatchActionBar';
|
|
||||||
import { ScheduleEditor } from './ScheduleEditor';
|
|
||||||
import { PipelinesPanel } from '../PipelinesPanel';
|
|
||||||
import {
|
|
||||||
Zap,
|
|
||||||
RefreshCw,
|
|
||||||
Plus,
|
|
||||||
Calendar,
|
|
||||||
Search,
|
|
||||||
X,
|
|
||||||
Package,
|
|
||||||
Bot,
|
|
||||||
Workflow,
|
|
||||||
Trash2,
|
|
||||||
Clock,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { useToast } from '../ui/Toast';
|
|
||||||
import type { ScheduleInfo } from '../../types/automation';
|
|
||||||
|
|
||||||
// === View Mode ===
|
|
||||||
|
|
||||||
type ViewMode = 'grid' | 'list';
|
|
||||||
|
|
||||||
// === Tab Type ===
|
|
||||||
|
|
||||||
type AutomationTab = 'pipelines' | 'hands' | 'workflows';
|
|
||||||
|
|
||||||
// === Component Props ===
|
|
||||||
|
|
||||||
interface AutomationPanelProps {
|
|
||||||
initialCategory?: CategoryType;
|
|
||||||
initialTab?: AutomationTab;
|
|
||||||
onSelect?: (item: AutomationItem) => void;
|
|
||||||
showBatchActions?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
// === Tab Configuration ===
|
|
||||||
|
|
||||||
const TAB_CONFIG: { key: AutomationTab; label: string; icon: React.ComponentType<{ className?: string }> }[] = [
|
|
||||||
{ key: 'pipelines', label: 'Pipelines', icon: Package },
|
|
||||||
{ key: 'hands', label: 'Hands', icon: Bot },
|
|
||||||
{ key: 'workflows', label: 'Workflows', icon: Workflow },
|
|
||||||
];
|
|
||||||
|
|
||||||
// === Main Component ===
|
|
||||||
|
|
||||||
export function AutomationPanel({
|
|
||||||
initialCategory = 'all',
|
|
||||||
initialTab = 'pipelines',
|
|
||||||
onSelect,
|
|
||||||
showBatchActions = true,
|
|
||||||
}: AutomationPanelProps) {
|
|
||||||
// Store state - use domain stores
|
|
||||||
const hands = useHandStore((s) => s.hands);
|
|
||||||
const workflows = useWorkflowStore((s) => s.workflows);
|
|
||||||
const handLoading = useHandStore((s) => s.isLoading);
|
|
||||||
const workflowLoading = useWorkflowStore((s) => s.isLoading);
|
|
||||||
const isLoading = handLoading || workflowLoading;
|
|
||||||
const loadHands = useHandStore((s) => s.loadHands);
|
|
||||||
const loadWorkflows = useWorkflowStore((s) => s.loadWorkflows);
|
|
||||||
const triggerHand = useHandStore((s) => s.triggerHand);
|
|
||||||
const triggerWorkflow = useWorkflowStore((s) => s.triggerWorkflow);
|
|
||||||
|
|
||||||
// UI state
|
|
||||||
const [activeTab, setActiveTab] = useState<AutomationTab>(initialTab);
|
|
||||||
const [selectedCategory, setSelectedCategory] = useState<CategoryType>(initialCategory);
|
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
|
||||||
const [viewMode, setViewMode] = useState<ViewMode>('grid');
|
|
||||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
|
||||||
const [executingIds, setExecutingIds] = useState<Set<string>>(new Set());
|
|
||||||
const [showWorkflowDialog, setShowWorkflowDialog] = useState(false);
|
|
||||||
const [showSchedulerDialog, setShowSchedulerDialog] = useState(false);
|
|
||||||
const [showBatchScheduleDialog, setShowBatchScheduleDialog] = useState(false);
|
|
||||||
const [workflowName, setWorkflowName] = useState('');
|
|
||||||
const [workflowDescription, setWorkflowDescription] = useState('');
|
|
||||||
const [isCreating, setIsCreating] = useState(false);
|
|
||||||
const [schedules, setSchedules] = useState<Record<string, ScheduleInfo>>({});
|
|
||||||
|
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
// Load data on mount
|
|
||||||
useEffect(() => {
|
|
||||||
loadHands();
|
|
||||||
loadWorkflows();
|
|
||||||
}, [loadHands, loadWorkflows]);
|
|
||||||
|
|
||||||
// Adapt hands and workflows to automation items
|
|
||||||
const automationItems = useMemo<AutomationItem[]>(() => {
|
|
||||||
return adaptToAutomationItems(hands, workflows);
|
|
||||||
}, [hands, workflows]);
|
|
||||||
|
|
||||||
// Calculate category stats
|
|
||||||
const categoryStats = useMemo<CategoryStats>(() => {
|
|
||||||
return calculateCategoryStats(automationItems);
|
|
||||||
}, [automationItems]);
|
|
||||||
|
|
||||||
// Filter and search items
|
|
||||||
const filteredItems = useMemo<AutomationItem[]>(() => {
|
|
||||||
let items = filterByCategory(automationItems, selectedCategory);
|
|
||||||
if (searchQuery.trim()) {
|
|
||||||
items = searchAutomationItems(items, searchQuery);
|
|
||||||
}
|
|
||||||
// Filter by tab
|
|
||||||
if (activeTab === 'hands') {
|
|
||||||
items = items.filter(item => item.type === 'hand');
|
|
||||||
} else if (activeTab === 'workflows') {
|
|
||||||
items = items.filter(item => item.type === 'workflow');
|
|
||||||
}
|
|
||||||
return items;
|
|
||||||
}, [automationItems, selectedCategory, searchQuery, activeTab]);
|
|
||||||
|
|
||||||
// Selection handlers
|
|
||||||
const handleSelect = useCallback((id: string, selected: boolean) => {
|
|
||||||
setSelectedIds(prev => {
|
|
||||||
const next = new Set(prev);
|
|
||||||
if (selected) {
|
|
||||||
next.add(id);
|
|
||||||
} else {
|
|
||||||
next.delete(id);
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleSelectAll = useCallback(() => {
|
|
||||||
setSelectedIds(new Set(filteredItems.map(item => item.id)));
|
|
||||||
}, [filteredItems]);
|
|
||||||
|
|
||||||
const handleDeselectAll = useCallback(() => {
|
|
||||||
setSelectedIds(new Set());
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Workflow dialog handlers
|
|
||||||
const handleCreateWorkflow = useCallback(() => {
|
|
||||||
setShowWorkflowDialog(true);
|
|
||||||
setWorkflowName('');
|
|
||||||
setWorkflowDescription('');
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleSchedulerManage = useCallback(() => {
|
|
||||||
setShowSchedulerDialog(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Create workflow handler
|
|
||||||
const handleWorkflowCreate = useCallback(async () => {
|
|
||||||
if (!workflowName.trim()) {
|
|
||||||
toast('请输入工作流名称', 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsCreating(true);
|
|
||||||
try {
|
|
||||||
const createWorkflow = useWorkflowStore.getState().createWorkflow;
|
|
||||||
const result = await createWorkflow({
|
|
||||||
name: workflowName.trim(),
|
|
||||||
description: workflowDescription.trim() || undefined,
|
|
||||||
steps: [], // Empty workflow, user will add steps later
|
|
||||||
});
|
|
||||||
|
|
||||||
if (result) {
|
|
||||||
toast(`工作流 "${result.name}" 创建成功`, 'success');
|
|
||||||
setShowWorkflowDialog(false);
|
|
||||||
setWorkflowName('');
|
|
||||||
setWorkflowDescription('');
|
|
||||||
// Reload workflows
|
|
||||||
await loadWorkflows();
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
const errorMsg = err instanceof Error ? err.message : '创建工作流失败';
|
|
||||||
toast(errorMsg, 'error');
|
|
||||||
} finally {
|
|
||||||
setIsCreating(false);
|
|
||||||
}
|
|
||||||
}, [workflowName, workflowDescription, toast, loadWorkflows]);
|
|
||||||
|
|
||||||
// Batch schedule handler
|
|
||||||
const handleBatchSchedule = useCallback(() => {
|
|
||||||
if (selectedIds.size === 0) {
|
|
||||||
toast('请先选择要调度的项目', 'info');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setShowBatchScheduleDialog(true);
|
|
||||||
}, [selectedIds.size, toast]);
|
|
||||||
|
|
||||||
// Save batch schedule
|
|
||||||
const handleSaveBatchSchedule = useCallback((schedule: ScheduleInfo) => {
|
|
||||||
// Save schedule for all selected items
|
|
||||||
const newSchedules: Record<string, ScheduleInfo> = {};
|
|
||||||
selectedIds.forEach(id => {
|
|
||||||
newSchedules[id] = schedule;
|
|
||||||
});
|
|
||||||
|
|
||||||
setSchedules(prev => ({ ...prev, ...newSchedules }));
|
|
||||||
setShowBatchScheduleDialog(false);
|
|
||||||
setSelectedIds(new Set());
|
|
||||||
|
|
||||||
const frequencyLabels = {
|
|
||||||
once: '一次性',
|
|
||||||
daily: '每天',
|
|
||||||
weekly: '每周',
|
|
||||||
monthly: '每月',
|
|
||||||
custom: '自定义',
|
|
||||||
};
|
|
||||||
|
|
||||||
toast(`已为 ${Object.keys(newSchedules).length} 个项目设置${frequencyLabels[schedule.frequency]}调度`, 'success');
|
|
||||||
}, [selectedIds, toast]);
|
|
||||||
|
|
||||||
// Delete schedule
|
|
||||||
const handleDeleteSchedule = useCallback((itemId: string) => {
|
|
||||||
setSchedules(prev => {
|
|
||||||
const { [itemId]: _, ...rest } = prev;
|
|
||||||
return rest;
|
|
||||||
});
|
|
||||||
toast('调度已删除', 'success');
|
|
||||||
}, [toast]);
|
|
||||||
|
|
||||||
// Toggle schedule enabled
|
|
||||||
const handleToggleScheduleEnabled = useCallback((itemId: string, enabled: boolean) => {
|
|
||||||
setSchedules(prev => ({
|
|
||||||
...prev,
|
|
||||||
[itemId]: { ...prev[itemId], enabled },
|
|
||||||
}));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Execute handler
|
|
||||||
const handleExecute = useCallback(async (item: AutomationItem, params?: Record<string, unknown>) => {
|
|
||||||
setExecutingIds(prev => new Set(prev).add(item.id));
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (item.type === 'hand') {
|
|
||||||
await triggerHand(item.id, params);
|
|
||||||
} else {
|
|
||||||
await triggerWorkflow(item.id, params);
|
|
||||||
}
|
|
||||||
toast(`${item.name} 执行成功`, 'success');
|
|
||||||
} catch (err) {
|
|
||||||
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
||||||
toast(`${item.name} 执行失败: ${errorMsg}`, 'error');
|
|
||||||
} finally {
|
|
||||||
setExecutingIds(prev => {
|
|
||||||
const next = new Set(prev);
|
|
||||||
next.delete(item.id);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [triggerHand, triggerWorkflow, toast]);
|
|
||||||
|
|
||||||
// Batch execute
|
|
||||||
const handleBatchExecute = useCallback(async () => {
|
|
||||||
const itemsToExecute = filteredItems.filter(item => selectedIds.has(item.id));
|
|
||||||
let successCount = 0;
|
|
||||||
let failCount = 0;
|
|
||||||
|
|
||||||
for (const item of itemsToExecute) {
|
|
||||||
try {
|
|
||||||
if (item.type === 'hand') {
|
|
||||||
await triggerHand(item.id);
|
|
||||||
} else {
|
|
||||||
await triggerWorkflow(item.id);
|
|
||||||
}
|
|
||||||
successCount++;
|
|
||||||
} catch {
|
|
||||||
failCount++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (successCount > 0) {
|
|
||||||
toast(`成功执行 ${successCount} 个项目`, 'success');
|
|
||||||
}
|
|
||||||
if (failCount > 0) {
|
|
||||||
toast(`${failCount} 个项目执行失败`, 'error');
|
|
||||||
}
|
|
||||||
|
|
||||||
setSelectedIds(new Set());
|
|
||||||
}, [filteredItems, selectedIds, triggerHand, triggerWorkflow, toast]);
|
|
||||||
|
|
||||||
// Refresh handler
|
|
||||||
const handleRefresh = useCallback(async () => {
|
|
||||||
await Promise.all([loadHands(), loadWorkflows()]);
|
|
||||||
toast('数据已刷新', 'success');
|
|
||||||
}, [loadHands, loadWorkflows, toast]);
|
|
||||||
|
|
||||||
// If Pipelines tab is active, show PipelinesPanel directly
|
|
||||||
if (activeTab === 'pipelines') {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col h-full">
|
|
||||||
{/* Header with Tabs */}
|
|
||||||
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-200 dark:border-gray-700">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Package className="w-5 h-5 text-blue-500" />
|
|
||||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
|
|
||||||
自动化
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
{/* Tab Switcher */}
|
|
||||||
<div className="flex items-center bg-gray-100 dark:bg-gray-800 rounded-lg p-1">
|
|
||||||
{TAB_CONFIG.map(({ key, label, icon: Icon }) => (
|
|
||||||
<button
|
|
||||||
key={key}
|
|
||||||
onClick={() => setActiveTab(key)}
|
|
||||||
className={`flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium rounded-md transition-colors ${
|
|
||||||
activeTab === key
|
|
||||||
? '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-gray-200'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<Icon className="w-4 h-4" />
|
|
||||||
{label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Pipelines Panel */}
|
|
||||||
<div className="flex-1 overflow-hidden">
|
|
||||||
<PipelinesPanel />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hands and Workflows tabs
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col h-full">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-200 dark:border-gray-700">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Zap className="w-5 h-5 text-orange-500" />
|
|
||||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
|
|
||||||
自动化
|
|
||||||
</h2>
|
|
||||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
|
||||||
({automationItems.length})
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{/* Tab Switcher */}
|
|
||||||
<div className="flex items-center bg-gray-100 dark:bg-gray-800 rounded-lg p-1 mr-2">
|
|
||||||
{TAB_CONFIG.map(({ key, label, icon: Icon }) => (
|
|
||||||
<button
|
|
||||||
key={key}
|
|
||||||
onClick={() => setActiveTab(key)}
|
|
||||||
className={`flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium rounded-md transition-colors ${
|
|
||||||
activeTab === key
|
|
||||||
? '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-gray-200'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<Icon className="w-4 h-4" />
|
|
||||||
{label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={handleRefresh}
|
|
||||||
disabled={isLoading}
|
|
||||||
className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 disabled:opacity-50"
|
|
||||||
title="刷新"
|
|
||||||
>
|
|
||||||
<RefreshCw className={`w-4 h-4 ${isLoading ? 'animate-spin' : ''}`} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={handleCreateWorkflow}
|
|
||||||
className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300"
|
|
||||||
title="新建工作流"
|
|
||||||
>
|
|
||||||
<Plus className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={handleSchedulerManage}
|
|
||||||
className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300"
|
|
||||||
title="调度管理"
|
|
||||||
>
|
|
||||||
<Calendar className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Filters */}
|
|
||||||
<AutomationFilters
|
|
||||||
selectedCategory={selectedCategory}
|
|
||||||
onCategoryChange={setSelectedCategory}
|
|
||||||
searchQuery={searchQuery}
|
|
||||||
onSearchChange={setSearchQuery}
|
|
||||||
viewMode={viewMode}
|
|
||||||
onViewModeChange={setViewMode}
|
|
||||||
categoryStats={categoryStats}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Content */}
|
|
||||||
<div className="flex-1 overflow-y-auto p-4">
|
|
||||||
{isLoading && automationItems.length === 0 ? (
|
|
||||||
<div className="flex items-center justify-center h-32">
|
|
||||||
<RefreshCw className="w-6 h-6 animate-spin text-gray-400" />
|
|
||||||
</div>
|
|
||||||
) : filteredItems.length === 0 ? (
|
|
||||||
<div className="flex flex-col items-center justify-center h-32 text-center">
|
|
||||||
<Search className="w-8 h-8 text-gray-400 mb-2" />
|
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
|
||||||
{searchQuery ? '没有找到匹配的项目' : '暂无自动化项目'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className={
|
|
||||||
viewMode === 'grid'
|
|
||||||
? 'grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4'
|
|
||||||
: 'flex flex-col gap-2'
|
|
||||||
}>
|
|
||||||
{filteredItems.map(item => (
|
|
||||||
<AutomationCard
|
|
||||||
key={item.id}
|
|
||||||
item={item}
|
|
||||||
viewMode={viewMode}
|
|
||||||
isSelected={selectedIds.has(item.id)}
|
|
||||||
isExecuting={executingIds.has(item.id)}
|
|
||||||
onSelect={(selected) => handleSelect(item.id, selected)}
|
|
||||||
onExecute={(params) => handleExecute(item, params)}
|
|
||||||
onClick={() => onSelect?.(item)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Batch Actions */}
|
|
||||||
{showBatchActions && selectedIds.size > 0 && (
|
|
||||||
<BatchActionBar
|
|
||||||
selectedCount={selectedIds.size}
|
|
||||||
totalCount={filteredItems.length}
|
|
||||||
onSelectAll={handleSelectAll}
|
|
||||||
onDeselectAll={handleDeselectAll}
|
|
||||||
onBatchExecute={handleBatchExecute}
|
|
||||||
onBatchSchedule={handleBatchSchedule}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Create Workflow Dialog */}
|
|
||||||
{showWorkflowDialog && (
|
|
||||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-md mx-4">
|
|
||||||
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700">
|
|
||||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">新建工作流</h3>
|
|
||||||
<button
|
|
||||||
onClick={() => setShowWorkflowDialog(false)}
|
|
||||||
className="p-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
|
||||||
>
|
|
||||||
<X className="w-5 h-5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="p-4">
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
|
||||||
工作流名称 <span className="text-red-500">*</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={workflowName}
|
|
||||||
onChange={(e) => setWorkflowName(e.target.value)}
|
|
||||||
placeholder="输入工作流名称..."
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 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"
|
|
||||||
disabled={isCreating}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
|
||||||
描述
|
|
||||||
</label>
|
|
||||||
<textarea
|
|
||||||
value={workflowDescription}
|
|
||||||
onChange={(e) => setWorkflowDescription(e.target.value)}
|
|
||||||
placeholder="描述这个工作流的用途..."
|
|
||||||
rows={3}
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 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 resize-none"
|
|
||||||
disabled={isCreating}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
|
||||||
创建后可在工作流编辑器中添加步骤
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-end gap-2 p-4 border-t border-gray-200 dark:border-gray-700">
|
|
||||||
<button
|
|
||||||
onClick={() => setShowWorkflowDialog(false)}
|
|
||||||
disabled={isCreating}
|
|
||||||
className="px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg disabled:opacity-50"
|
|
||||||
>
|
|
||||||
取消
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={handleWorkflowCreate}
|
|
||||||
disabled={isCreating || !workflowName.trim()}
|
|
||||||
className="px-4 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
|
||||||
>
|
|
||||||
{isCreating && <RefreshCw className="w-4 h-4 animate-spin" />}
|
|
||||||
{isCreating ? '创建中...' : '创建'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Scheduler Dialog */}
|
|
||||||
{showSchedulerDialog && (
|
|
||||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-2xl mx-4 max-h-[80vh] flex flex-col">
|
|
||||||
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Calendar className="w-5 h-5 text-orange-500" />
|
|
||||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">调度管理</h3>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => setShowSchedulerDialog(false)}
|
|
||||||
className="p-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
|
||||||
>
|
|
||||||
<X className="w-5 h-5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 overflow-y-auto p-4">
|
|
||||||
{Object.keys(schedules).length === 0 ? (
|
|
||||||
<div className="text-center py-12 text-gray-500 dark:text-gray-400">
|
|
||||||
<Clock className="w-16 h-16 mx-auto mb-4 opacity-30" />
|
|
||||||
<p className="text-lg font-medium mb-1">暂无调度任务</p>
|
|
||||||
<p className="text-sm">选择自动化项目后点击"批量调度"来创建定时任务</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-3">
|
|
||||||
{Object.entries(schedules).map(([itemId, schedule]) => {
|
|
||||||
const item = automationItems.find(i => i.id === itemId);
|
|
||||||
if (!item) return null;
|
|
||||||
|
|
||||||
const frequencyLabels = {
|
|
||||||
once: '一次性',
|
|
||||||
daily: '每天',
|
|
||||||
weekly: '每周',
|
|
||||||
monthly: '每月',
|
|
||||||
custom: '自定义',
|
|
||||||
};
|
|
||||||
|
|
||||||
const timeStr = `${schedule.time.hour.toString().padStart(2, '0')}:${schedule.time.minute.toString().padStart(2, '0')}`;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={itemId}
|
|
||||||
className={`flex items-center justify-between p-4 rounded-lg border ${
|
|
||||||
schedule.enabled
|
|
||||||
? 'bg-orange-50 dark:bg-orange-900/20 border-orange-200 dark:border-orange-800'
|
|
||||||
: 'bg-gray-50 dark:bg-gray-800/50 border-gray-200 dark:border-gray-700'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className={`p-2 rounded-lg ${
|
|
||||||
schedule.enabled
|
|
||||||
? 'bg-orange-100 dark:bg-orange-900/30'
|
|
||||||
: 'bg-gray-200 dark:bg-gray-700'
|
|
||||||
}`}>
|
|
||||||
<Clock className={`w-4 h-4 ${
|
|
||||||
schedule.enabled
|
|
||||||
? 'text-orange-600 dark:text-orange-400'
|
|
||||||
: 'text-gray-400'
|
|
||||||
}`} />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="font-medium text-gray-900 dark:text-white">
|
|
||||||
{item.name}
|
|
||||||
</p>
|
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
|
||||||
{frequencyLabels[schedule.frequency]} · {timeStr}
|
|
||||||
{schedule.nextRun && ` · 下次: ${new Date(schedule.nextRun).toLocaleString()}`}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{/* Toggle Enabled */}
|
|
||||||
<button
|
|
||||||
onClick={() => handleToggleScheduleEnabled(itemId, !schedule.enabled)}
|
|
||||||
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${
|
|
||||||
schedule.enabled ? 'bg-orange-500' : 'bg-gray-300 dark:bg-gray-600'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className={`inline-block h-3.5 w-3.5 transform rounded-full bg-white transition-transform ${
|
|
||||||
schedule.enabled ? 'translate-x-5' : 'translate-x-1'
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
{/* Delete */}
|
|
||||||
<button
|
|
||||||
onClick={() => handleDeleteSchedule(itemId)}
|
|
||||||
className="p-1.5 text-gray-400 hover:text-red-500 dark:hover:text-red-400 rounded-md hover:bg-red-50 dark:hover:bg-red-900/20"
|
|
||||||
title="删除调度"
|
|
||||||
>
|
|
||||||
<Trash2 className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between items-center p-4 border-t border-gray-200 dark:border-gray-700">
|
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
|
||||||
共 {Object.keys(schedules).length} 个调度任务
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
onClick={() => setShowSchedulerDialog(false)}
|
|
||||||
className="px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg"
|
|
||||||
>
|
|
||||||
关闭
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Batch Schedule Dialog */}
|
|
||||||
{showBatchScheduleDialog && (
|
|
||||||
<ScheduleEditor
|
|
||||||
itemName={`已选择 ${selectedIds.size} 个项目`}
|
|
||||||
onSave={handleSaveBatchSchedule}
|
|
||||||
onCancel={() => setShowBatchScheduleDialog(false)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default AutomationPanel;
|
|
||||||
@@ -1,216 +0,0 @@
|
|||||||
/**
|
|
||||||
* BatchActionBar - Batch Operations Action Bar
|
|
||||||
*
|
|
||||||
* Provides batch action buttons for selected automation items.
|
|
||||||
* Supports batch execute, approve, reject, and schedule.
|
|
||||||
*
|
|
||||||
* @module components/Automation/BatchActionBar
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useState, useCallback } from 'react';
|
|
||||||
import {
|
|
||||||
Play,
|
|
||||||
Check,
|
|
||||||
X,
|
|
||||||
Clock,
|
|
||||||
XCircle,
|
|
||||||
MoreHorizontal,
|
|
||||||
Trash2,
|
|
||||||
Copy,
|
|
||||||
} from 'lucide-react';
|
|
||||||
|
|
||||||
// === Component Props ===
|
|
||||||
|
|
||||||
interface BatchActionBarProps {
|
|
||||||
selectedCount: number;
|
|
||||||
totalCount?: number; // Optional - for "select all X items" display
|
|
||||||
onSelectAll: () => void;
|
|
||||||
onDeselectAll: () => void;
|
|
||||||
onBatchExecute: () => Promise<void>;
|
|
||||||
onBatchApprove?: () => Promise<void>;
|
|
||||||
onBatchReject?: () => Promise<void>;
|
|
||||||
onBatchSchedule?: () => void;
|
|
||||||
onBatchDelete?: () => Promise<void>;
|
|
||||||
onBatchDuplicate?: () => Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// === Main Component ===
|
|
||||||
|
|
||||||
export function BatchActionBar({
|
|
||||||
selectedCount,
|
|
||||||
totalCount: _totalCount, // Used for future "select all X items" display
|
|
||||||
onSelectAll,
|
|
||||||
onDeselectAll,
|
|
||||||
onBatchExecute,
|
|
||||||
onBatchApprove,
|
|
||||||
onBatchReject,
|
|
||||||
onBatchSchedule,
|
|
||||||
onBatchDelete,
|
|
||||||
onBatchDuplicate,
|
|
||||||
}: BatchActionBarProps) {
|
|
||||||
const [isExecuting, setIsExecuting] = useState(false);
|
|
||||||
const [showMoreMenu, setShowMoreMenu] = useState(false);
|
|
||||||
|
|
||||||
// Handle batch execute
|
|
||||||
const handleExecute = useCallback(async () => {
|
|
||||||
setIsExecuting(true);
|
|
||||||
try {
|
|
||||||
await onBatchExecute();
|
|
||||||
} finally {
|
|
||||||
setIsExecuting(false);
|
|
||||||
}
|
|
||||||
}, [onBatchExecute]);
|
|
||||||
|
|
||||||
// Handle batch approve
|
|
||||||
const handleApprove = useCallback(async () => {
|
|
||||||
if (onBatchApprove) {
|
|
||||||
setIsExecuting(true);
|
|
||||||
try {
|
|
||||||
await onBatchApprove();
|
|
||||||
} finally {
|
|
||||||
setIsExecuting(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [onBatchApprove]);
|
|
||||||
|
|
||||||
// Handle batch reject
|
|
||||||
const handleReject = useCallback(async () => {
|
|
||||||
if (onBatchReject) {
|
|
||||||
setIsExecuting(true);
|
|
||||||
try {
|
|
||||||
await onBatchReject();
|
|
||||||
} finally {
|
|
||||||
setIsExecuting(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [onBatchReject]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="sticky bottom-0 left-0 right-0 bg-orange-50 dark:bg-orange-900/20 border-t border-orange-200 dark:border-orange-800 px-4 py-3">
|
|
||||||
<div className="flex items-center justify-between gap-4">
|
|
||||||
{/* Selection Info */}
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<span className="text-sm text-orange-700 dark:text-orange-300">
|
|
||||||
已选择 <span className="font-medium">{selectedCount}</span> 项
|
|
||||||
</span>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<button
|
|
||||||
onClick={onSelectAll}
|
|
||||||
className="text-xs text-orange-600 dark:text-orange-400 hover:underline"
|
|
||||||
>
|
|
||||||
全选
|
|
||||||
</button>
|
|
||||||
<span className="text-orange-400 dark:text-orange-600">|</span>
|
|
||||||
<button
|
|
||||||
onClick={onDeselectAll}
|
|
||||||
className="text-xs text-orange-600 dark:text-orange-400 hover:underline"
|
|
||||||
>
|
|
||||||
取消选择
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Action Buttons */}
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{/* Execute */}
|
|
||||||
<button
|
|
||||||
onClick={handleExecute}
|
|
||||||
disabled={isExecuting}
|
|
||||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm bg-orange-500 text-white rounded-md hover:bg-orange-600 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
<Play className="w-3.5 h-3.5" />
|
|
||||||
批量执行
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Approve (if handler provided) */}
|
|
||||||
{onBatchApprove && (
|
|
||||||
<button
|
|
||||||
onClick={handleApprove}
|
|
||||||
disabled={isExecuting}
|
|
||||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm bg-green-500 text-white rounded-md hover:bg-green-600 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
<Check className="w-3.5 h-3.5" />
|
|
||||||
批量审批
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Reject (if handler provided) */}
|
|
||||||
{onBatchReject && (
|
|
||||||
<button
|
|
||||||
onClick={handleReject}
|
|
||||||
disabled={isExecuting}
|
|
||||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm bg-red-500 text-white rounded-md hover:bg-red-600 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
<X className="w-3.5 h-3.5" />
|
|
||||||
批量拒绝
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Schedule */}
|
|
||||||
{onBatchSchedule && (
|
|
||||||
<button
|
|
||||||
onClick={onBatchSchedule}
|
|
||||||
disabled={isExecuting}
|
|
||||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm border border-orange-300 dark:border-orange-700 text-orange-600 dark:text-orange-400 rounded-md hover:bg-orange-100 dark:hover:bg-orange-900/30 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
<Clock className="w-3.5 h-3.5" />
|
|
||||||
批量调度
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* More Actions */}
|
|
||||||
{(onBatchDelete || onBatchDuplicate) && (
|
|
||||||
<div className="relative">
|
|
||||||
<button
|
|
||||||
onClick={() => setShowMoreMenu(!showMoreMenu)}
|
|
||||||
className="p-1.5 text-orange-600 dark:text-orange-400 hover:bg-orange-100 dark:hover:bg-orange-900/30 rounded-md"
|
|
||||||
>
|
|
||||||
<MoreHorizontal className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{showMoreMenu && (
|
|
||||||
<div className="absolute bottom-full right-0 mb-1 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg py-1 min-w-[150px] z-10">
|
|
||||||
{onBatchDuplicate && (
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
onBatchDuplicate();
|
|
||||||
setShowMoreMenu(false);
|
|
||||||
}}
|
|
||||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
|
|
||||||
>
|
|
||||||
<Copy className="w-4 h-4" />
|
|
||||||
复制
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{onBatchDelete && (
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
onBatchDelete();
|
|
||||||
setShowMoreMenu(false);
|
|
||||||
}}
|
|
||||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20"
|
|
||||||
>
|
|
||||||
<Trash2 className="w-4 h-4" />
|
|
||||||
删除
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Close */}
|
|
||||||
<button
|
|
||||||
onClick={onDeselectAll}
|
|
||||||
className="p-1.5 text-orange-600 dark:text-orange-400 hover:bg-orange-100 dark:hover:bg-orange-900/30 rounded-md"
|
|
||||||
title="取消选择"
|
|
||||||
>
|
|
||||||
<XCircle className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default BatchActionBar;
|
|
||||||
@@ -1,395 +0,0 @@
|
|||||||
/**
|
|
||||||
* ExecutionResult - Execution Result Display Component
|
|
||||||
*
|
|
||||||
* Displays the result of hand or workflow executions with
|
|
||||||
* status, output, and error information.
|
|
||||||
*
|
|
||||||
* @module components/Automation/ExecutionResult
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useState, useCallback, useMemo } from 'react';
|
|
||||||
import type { RunInfo } from '../../types/automation';
|
|
||||||
import {
|
|
||||||
CheckCircle,
|
|
||||||
XCircle,
|
|
||||||
Clock,
|
|
||||||
AlertTriangle,
|
|
||||||
ChevronDown,
|
|
||||||
ChevronUp,
|
|
||||||
Copy,
|
|
||||||
Download,
|
|
||||||
RefreshCw,
|
|
||||||
ExternalLink,
|
|
||||||
FileText,
|
|
||||||
Code,
|
|
||||||
Image,
|
|
||||||
FileSpreadsheet,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { useToast } from '../ui/Toast';
|
|
||||||
|
|
||||||
// === Status Config ===
|
|
||||||
|
|
||||||
const STATUS_CONFIG = {
|
|
||||||
completed: {
|
|
||||||
label: '完成',
|
|
||||||
icon: CheckCircle,
|
|
||||||
className: 'text-green-500',
|
|
||||||
bgClass: 'bg-green-50 dark:bg-green-900/20',
|
|
||||||
},
|
|
||||||
failed: {
|
|
||||||
label: '失败',
|
|
||||||
icon: XCircle,
|
|
||||||
className: 'text-red-500',
|
|
||||||
bgClass: 'bg-red-50 dark:bg-red-900/20',
|
|
||||||
},
|
|
||||||
running: {
|
|
||||||
label: '运行中',
|
|
||||||
icon: RefreshCw,
|
|
||||||
className: 'text-blue-500 animate-spin',
|
|
||||||
bgClass: 'bg-blue-50 dark:bg-blue-900/20',
|
|
||||||
},
|
|
||||||
needs_approval: {
|
|
||||||
label: '待审批',
|
|
||||||
icon: AlertTriangle,
|
|
||||||
className: 'text-yellow-500',
|
|
||||||
bgClass: 'bg-yellow-50 dark:bg-yellow-900/20',
|
|
||||||
},
|
|
||||||
cancelled: {
|
|
||||||
label: '已取消',
|
|
||||||
icon: XCircle,
|
|
||||||
className: 'text-gray-500',
|
|
||||||
bgClass: 'bg-gray-50 dark:bg-gray-900/20',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// === Component Props ===
|
|
||||||
|
|
||||||
interface ExecutionResultProps {
|
|
||||||
run: RunInfo;
|
|
||||||
itemType: 'hand' | 'workflow';
|
|
||||||
itemName: string;
|
|
||||||
onRerun?: () => void;
|
|
||||||
onViewDetails?: () => void;
|
|
||||||
compact?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
// === Helper Functions ===
|
|
||||||
|
|
||||||
function formatDuration(startedAt: string, completedAt?: string): string {
|
|
||||||
const start = new Date(startedAt).getTime();
|
|
||||||
const end = completedAt ? new Date(completedAt).getTime() : Date.now();
|
|
||||||
const diffMs = end - start;
|
|
||||||
|
|
||||||
const seconds = Math.floor(diffMs / 1000);
|
|
||||||
const minutes = Math.floor(seconds / 60);
|
|
||||||
const hours = Math.floor(minutes / 60);
|
|
||||||
|
|
||||||
if (hours > 0) {
|
|
||||||
return `${hours}h ${minutes % 60}m`;
|
|
||||||
}
|
|
||||||
if (minutes > 0) {
|
|
||||||
return `${minutes}m ${seconds % 60}s`;
|
|
||||||
}
|
|
||||||
return `${seconds}s`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function detectOutputType(output: unknown): 'text' | 'json' | 'markdown' | 'code' | 'image' | 'data' {
|
|
||||||
if (!output) return 'text';
|
|
||||||
|
|
||||||
if (typeof output === 'string') {
|
|
||||||
// Check for image URL
|
|
||||||
if (output.match(/\.(png|jpg|jpeg|gif|webp|svg)$/i)) {
|
|
||||||
return 'image';
|
|
||||||
}
|
|
||||||
// Check for markdown
|
|
||||||
if (output.includes('#') || output.includes('**') || output.includes('```')) {
|
|
||||||
return 'markdown';
|
|
||||||
}
|
|
||||||
// Check for code
|
|
||||||
if (output.includes('function ') || output.includes('import ') || output.includes('class ')) {
|
|
||||||
return 'code';
|
|
||||||
}
|
|
||||||
// Try to parse as JSON
|
|
||||||
try {
|
|
||||||
JSON.parse(output);
|
|
||||||
return 'json';
|
|
||||||
} catch {
|
|
||||||
return 'text';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Object/array types
|
|
||||||
if (typeof output === 'object') {
|
|
||||||
return 'json';
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'text';
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatOutput(output: unknown, type: string): string {
|
|
||||||
if (!output) return '无输出';
|
|
||||||
|
|
||||||
if (type === 'json') {
|
|
||||||
try {
|
|
||||||
return JSON.stringify(output, null, 2);
|
|
||||||
} catch {
|
|
||||||
return String(output);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return String(output);
|
|
||||||
}
|
|
||||||
|
|
||||||
// === Output Viewer Component ===
|
|
||||||
|
|
||||||
interface OutputViewerProps {
|
|
||||||
output: unknown;
|
|
||||||
type: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function OutputViewer({ output, type }: OutputViewerProps) {
|
|
||||||
const [copied, setCopied] = useState(false);
|
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
const handleCopy = useCallback(async () => {
|
|
||||||
const text = formatOutput(output, type);
|
|
||||||
await navigator.clipboard.writeText(text);
|
|
||||||
setCopied(true);
|
|
||||||
toast('已复制到剪贴板', 'success');
|
|
||||||
setTimeout(() => setCopied(false), 2000);
|
|
||||||
}, [output, type, toast]);
|
|
||||||
|
|
||||||
const handleDownload = useCallback(() => {
|
|
||||||
const text = formatOutput(output, type);
|
|
||||||
const blob = new Blob([text], { type: 'text/plain' });
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = url;
|
|
||||||
a.download = `output-${Date.now()}.${type === 'json' ? 'json' : 'txt'}`;
|
|
||||||
a.click();
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
}, [output, type]);
|
|
||||||
|
|
||||||
// Image preview
|
|
||||||
if (type === 'image' && typeof output === 'string') {
|
|
||||||
return (
|
|
||||||
<div className="relative">
|
|
||||||
<img
|
|
||||||
src={output}
|
|
||||||
alt="Output"
|
|
||||||
className="max-w-full rounded-lg"
|
|
||||||
/>
|
|
||||||
<div className="absolute top-2 right-2 flex gap-1">
|
|
||||||
<button
|
|
||||||
onClick={() => window.open(output, '_blank')}
|
|
||||||
className="p-1.5 bg-black/50 rounded hover:bg-black/70 text-white"
|
|
||||||
>
|
|
||||||
<ExternalLink className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Text/JSON/Code output
|
|
||||||
const content = formatOutput(output, type);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="relative">
|
|
||||||
<pre className="p-3 bg-gray-900 dark:bg-gray-950 rounded-lg text-sm text-gray-100 overflow-x-auto max-h-64 overflow-y-auto">
|
|
||||||
{content}
|
|
||||||
</pre>
|
|
||||||
<div className="absolute top-2 right-2 flex gap-1">
|
|
||||||
<button
|
|
||||||
onClick={handleCopy}
|
|
||||||
className="p-1.5 bg-gray-700 rounded hover:bg-gray-600 text-gray-300"
|
|
||||||
title="复制"
|
|
||||||
>
|
|
||||||
{copied ? <CheckCircle className="w-4 h-4 text-green-400" /> : <Copy className="w-4 h-4" />}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={handleDownload}
|
|
||||||
className="p-1.5 bg-gray-700 rounded hover:bg-gray-600 text-gray-300"
|
|
||||||
title="下载"
|
|
||||||
>
|
|
||||||
<Download className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// === Main Component ===
|
|
||||||
|
|
||||||
export function ExecutionResult({
|
|
||||||
run,
|
|
||||||
itemType,
|
|
||||||
itemName,
|
|
||||||
onRerun,
|
|
||||||
onViewDetails,
|
|
||||||
compact = false,
|
|
||||||
}: ExecutionResultProps) {
|
|
||||||
const [expanded, setExpanded] = useState(!compact);
|
|
||||||
|
|
||||||
const statusConfig = STATUS_CONFIG[run.status as keyof typeof STATUS_CONFIG] || STATUS_CONFIG.completed;
|
|
||||||
const StatusIcon = statusConfig.icon;
|
|
||||||
|
|
||||||
// Safely extract error message as string
|
|
||||||
const getErrorMessage = (): string | null => {
|
|
||||||
if (typeof run.error === 'string' && run.error.length > 0) {
|
|
||||||
return run.error;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
const errorMessage = getErrorMessage();
|
|
||||||
|
|
||||||
const outputType = useMemo(() => detectOutputType(run.output), [run.output]);
|
|
||||||
const duration = useMemo(() => {
|
|
||||||
if (run.duration) return `${run.duration}s`;
|
|
||||||
if (run.completedAt && run.startedAt) {
|
|
||||||
return formatDuration(run.startedAt, run.completedAt);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}, [run.duration, run.startedAt, run.completedAt]);
|
|
||||||
|
|
||||||
// Compact mode
|
|
||||||
if (compact && !expanded) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`flex items-center gap-3 p-3 rounded-lg ${statusConfig.bgClass} cursor-pointer`}
|
|
||||||
onClick={() => setExpanded(true)}
|
|
||||||
>
|
|
||||||
<StatusIcon className={`w-5 h-5 ${statusConfig.className}`} />
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="font-medium text-gray-900 dark:text-white truncate">
|
|
||||||
{itemName}
|
|
||||||
</span>
|
|
||||||
<span className={`text-xs ${statusConfig.className}`}>
|
|
||||||
{statusConfig.label}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{duration && (
|
|
||||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
|
||||||
耗时: {duration}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<ChevronDown className="w-4 h-4 text-gray-400" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={`rounded-lg border ${statusConfig.bgClass} border-gray-200 dark:border-gray-700 overflow-hidden`}>
|
|
||||||
{/* Header */}
|
|
||||||
<div
|
|
||||||
className="flex items-center justify-between px-4 py-3 cursor-pointer"
|
|
||||||
onClick={compact ? () => setExpanded(false) : undefined}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<StatusIcon className={`w-5 h-5 ${statusConfig.className}`} />
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="font-medium text-gray-900 dark:text-white">
|
|
||||||
{itemName}
|
|
||||||
</span>
|
|
||||||
<span className={`text-xs px-2 py-0.5 rounded-full ${statusConfig.className} ${statusConfig.bgClass}`}>
|
|
||||||
{statusConfig.label}
|
|
||||||
</span>
|
|
||||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
|
||||||
{itemType === 'hand' ? '自主能力' : '工作流'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{run.runId && (
|
|
||||||
<span className="text-xs text-gray-400 dark:text-gray-500">
|
|
||||||
执行ID: {run.runId}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{duration && (
|
|
||||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
|
||||||
耗时: {duration}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{compact && (
|
|
||||||
<ChevronUp className="w-4 h-4 text-gray-400" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Body */}
|
|
||||||
{expanded && (
|
|
||||||
<div className="px-4 pb-4 space-y-3">
|
|
||||||
{/* Error */}
|
|
||||||
{(() => {
|
|
||||||
if (!errorMessage) return null;
|
|
||||||
return (
|
|
||||||
<div className="p-3 bg-red-50 dark:bg-red-900/20 rounded-lg">
|
|
||||||
<p className="text-sm font-medium text-red-700 dark:text-red-400 mb-1">错误信息</p>
|
|
||||||
<p className="text-sm text-red-600 dark:text-red-300">{errorMessage}</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
|
|
||||||
{/* Output */}
|
|
||||||
{run.output !== undefined && run.output !== null && (
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center justify-between mb-2">
|
|
||||||
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">输出结果</p>
|
|
||||||
<span className="text-xs text-gray-500 dark:text-gray-400 flex items-center gap-1">
|
|
||||||
{outputType === 'json' && <Code className="w-3 h-3" />}
|
|
||||||
{outputType === 'markdown' && <FileText className="w-3 h-3" />}
|
|
||||||
{outputType === 'image' && <Image className="w-3 h-3" />}
|
|
||||||
{outputType === 'data' && <FileSpreadsheet className="w-3 h-3" />}
|
|
||||||
{outputType.toUpperCase()}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<OutputViewer output={run.output} type={outputType} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Timestamps */}
|
|
||||||
<div className="flex items-center gap-4 text-xs text-gray-500 dark:text-gray-400">
|
|
||||||
<span className="flex items-center gap-1">
|
|
||||||
<Clock className="w-3 h-3" />
|
|
||||||
开始: {new Date(run.startedAt).toLocaleString('zh-CN')}
|
|
||||||
</span>
|
|
||||||
{run.completedAt && (
|
|
||||||
<span>
|
|
||||||
完成: {new Date(run.completedAt).toLocaleString('zh-CN')}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Actions */}
|
|
||||||
<div className="flex items-center gap-2 pt-2">
|
|
||||||
{onRerun && (
|
|
||||||
<button
|
|
||||||
onClick={onRerun}
|
|
||||||
className="px-3 py-1.5 text-sm bg-orange-500 text-white rounded-md hover:bg-orange-600 flex items-center gap-1"
|
|
||||||
>
|
|
||||||
<RefreshCw className="w-3.5 h-3.5" />
|
|
||||||
重新执行
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{onViewDetails && (
|
|
||||||
<button
|
|
||||||
onClick={onViewDetails}
|
|
||||||
className="px-3 py-1.5 text-sm border border-gray-200 dark:border-gray-700 rounded-md hover:bg-gray-50 dark:hover:bg-gray-800 text-gray-700 dark:text-gray-300 flex items-center gap-1"
|
|
||||||
>
|
|
||||||
<ExternalLink className="w-3.5 h-3.5" />
|
|
||||||
查看详情
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default ExecutionResult;
|
|
||||||
@@ -1,378 +0,0 @@
|
|||||||
/**
|
|
||||||
* ScheduleEditor - Visual Schedule Configuration
|
|
||||||
*
|
|
||||||
* Provides a visual interface for configuring schedules
|
|
||||||
* without requiring knowledge of cron syntax.
|
|
||||||
*
|
|
||||||
* @module components/Automation/ScheduleEditor
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useState, useCallback, useMemo } from 'react';
|
|
||||||
import type { ScheduleInfo } from '../../types/automation';
|
|
||||||
import {
|
|
||||||
Calendar,
|
|
||||||
Info,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { useToast } from '../ui/Toast';
|
|
||||||
|
|
||||||
// === Frequency Types ===
|
|
||||||
|
|
||||||
type Frequency = 'once' | 'daily' | 'weekly' | 'monthly' | 'custom';
|
|
||||||
|
|
||||||
// === Timezones ===
|
|
||||||
|
|
||||||
const COMMON_TIMEZONES = [
|
|
||||||
{ value: 'Asia/Shanghai', label: '北京时间 (UTC+8)' },
|
|
||||||
{ value: 'Asia/Tokyo', label: '东京时间 (UTC+9)' },
|
|
||||||
{ value: 'Asia/Singapore', label: '新加坡时间 (UTC+8)' },
|
|
||||||
{ value: 'America/New_York', label: '纽约时间 (UTC-5)' },
|
|
||||||
{ value: 'America/Los_Angeles', label: '洛杉矶时间 (UTC-8)' },
|
|
||||||
{ value: 'Europe/London', label: '伦敦时间 (UTC+0)' },
|
|
||||||
{ value: 'UTC', label: '协调世界时 (UTC)' },
|
|
||||||
];
|
|
||||||
|
|
||||||
// === Day Names ===
|
|
||||||
|
|
||||||
const DAY_NAMES = ['日', '一', '二', '三', '四', '五', '六'];
|
|
||||||
const DAY_NAMES_FULL = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'];
|
|
||||||
|
|
||||||
// === Component Props ===
|
|
||||||
|
|
||||||
interface ScheduleEditorProps {
|
|
||||||
schedule?: ScheduleInfo;
|
|
||||||
onSave: (schedule: ScheduleInfo) => void;
|
|
||||||
onCancel: () => void;
|
|
||||||
itemName?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// === Helper Functions ===
|
|
||||||
|
|
||||||
function formatSchedulePreview(schedule: ScheduleInfo): string {
|
|
||||||
const { frequency, time, daysOfWeek, dayOfMonth, timezone } = schedule;
|
|
||||||
const timeStr = `${time.hour.toString().padStart(2, '0')}:${time.minute.toString().padStart(2, '0')}`;
|
|
||||||
const tzLabel = COMMON_TIMEZONES.find(tz => tz.value === timezone)?.label || timezone;
|
|
||||||
|
|
||||||
switch (frequency) {
|
|
||||||
case 'once':
|
|
||||||
return `一次性执行于 ${timeStr} (${tzLabel})`;
|
|
||||||
case 'daily':
|
|
||||||
return `每天 ${timeStr} (${tzLabel})`;
|
|
||||||
case 'weekly':
|
|
||||||
const days = (daysOfWeek || []).map(d => DAY_NAMES_FULL[d]).join('、');
|
|
||||||
return `每${days} ${timeStr} (${tzLabel})`;
|
|
||||||
case 'monthly':
|
|
||||||
return `每月${dayOfMonth || 1}日 ${timeStr} (${tzLabel})`;
|
|
||||||
case 'custom':
|
|
||||||
return schedule.customCron || '自定义调度';
|
|
||||||
default:
|
|
||||||
return '未设置';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// === Main Component ===
|
|
||||||
|
|
||||||
export function ScheduleEditor({
|
|
||||||
schedule,
|
|
||||||
onSave,
|
|
||||||
onCancel,
|
|
||||||
itemName = '自动化项目',
|
|
||||||
}: ScheduleEditorProps) {
|
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
// Initialize state from existing schedule
|
|
||||||
const [frequency, setFrequency] = useState<Frequency>(schedule?.frequency || 'daily');
|
|
||||||
const [time, setTime] = useState(schedule?.time || { hour: 9, minute: 0 });
|
|
||||||
const [daysOfWeek, setDaysOfWeek] = useState<number[]>(schedule?.daysOfWeek || [1, 2, 3, 4, 5]);
|
|
||||||
const [dayOfMonth, setDayOfMonth] = useState(schedule?.dayOfMonth || 1);
|
|
||||||
const [timezone, setTimezone] = useState(schedule?.timezone || 'Asia/Shanghai');
|
|
||||||
const [endDate, setEndDate] = useState(schedule?.endDate || '');
|
|
||||||
const [customCron, setCustomCron] = useState(schedule?.customCron || '');
|
|
||||||
const [enabled, setEnabled] = useState(schedule?.enabled ?? true);
|
|
||||||
|
|
||||||
// Toggle day of week
|
|
||||||
const toggleDayOfWeek = useCallback((day: number) => {
|
|
||||||
setDaysOfWeek(prev =>
|
|
||||||
prev.includes(day)
|
|
||||||
? prev.filter(d => d !== day)
|
|
||||||
: [...prev, day].sort()
|
|
||||||
);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Handle save
|
|
||||||
const handleSave = useCallback(() => {
|
|
||||||
// Validate
|
|
||||||
if (frequency === 'weekly' && daysOfWeek.length === 0) {
|
|
||||||
toast('请选择至少一个重复日期', 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (frequency === 'custom' && !customCron) {
|
|
||||||
toast('请输入自定义 cron 表达式', 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const newSchedule: ScheduleInfo = {
|
|
||||||
enabled,
|
|
||||||
frequency,
|
|
||||||
time,
|
|
||||||
daysOfWeek: frequency === 'weekly' ? daysOfWeek : undefined,
|
|
||||||
dayOfMonth: frequency === 'monthly' ? dayOfMonth : undefined,
|
|
||||||
customCron: frequency === 'custom' ? customCron : undefined,
|
|
||||||
timezone,
|
|
||||||
endDate: endDate || undefined,
|
|
||||||
};
|
|
||||||
|
|
||||||
onSave(newSchedule);
|
|
||||||
toast('调度设置已保存', 'success');
|
|
||||||
}, [frequency, daysOfWeek, customCron, enabled, time, dayOfMonth, timezone, endDate, onSave, toast]);
|
|
||||||
|
|
||||||
// Generate preview
|
|
||||||
const preview = useMemo(() => {
|
|
||||||
return formatSchedulePreview({
|
|
||||||
enabled,
|
|
||||||
frequency,
|
|
||||||
time,
|
|
||||||
daysOfWeek,
|
|
||||||
dayOfMonth,
|
|
||||||
customCron,
|
|
||||||
timezone,
|
|
||||||
});
|
|
||||||
}, [enabled, frequency, time, daysOfWeek, dayOfMonth, customCron, timezone]);
|
|
||||||
|
|
||||||
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={onCancel}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Modal */}
|
|
||||||
<div className="relative bg-white dark:bg-gray-800 rounded-xl shadow-2xl w-full max-w-lg mx-4 max-h-[90vh] overflow-hidden flex flex-col">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-start justify-between p-4 border-b border-gray-200 dark:border-gray-700">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="p-2 bg-orange-100 dark:bg-orange-900/30 rounded-lg">
|
|
||||||
<Calendar className="w-5 h-5 text-orange-600 dark:text-orange-400" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
|
|
||||||
调度设置
|
|
||||||
</h2>
|
|
||||||
{itemName && (
|
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400">{itemName}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={onCancel}
|
|
||||||
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 p-1"
|
|
||||||
>
|
|
||||||
<span className="text-xl">×</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Body */}
|
|
||||||
<div className="flex-1 overflow-y-auto p-4 space-y-6">
|
|
||||||
{/* Enable Toggle */}
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium text-gray-900 dark:text-white">
|
|
||||||
启用调度
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
|
||||||
开启后,此项目将按照设定的时间自动执行
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => setEnabled(!enabled)}
|
|
||||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
|
||||||
enabled ? 'bg-orange-500' : 'bg-gray-200 dark:bg-gray-700'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
|
||||||
enabled ? 'translate-x-6' : 'translate-x-1'
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Frequency Selection */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-900 dark:text-white mb-2">
|
|
||||||
频率
|
|
||||||
</label>
|
|
||||||
<div className="grid grid-cols-5 gap-2">
|
|
||||||
{[
|
|
||||||
{ value: 'once', label: '一次' },
|
|
||||||
{ value: 'daily', label: '每天' },
|
|
||||||
{ value: 'weekly', label: '每周' },
|
|
||||||
{ value: 'monthly', label: '每月' },
|
|
||||||
{ value: 'custom', label: '自定义' },
|
|
||||||
].map(option => (
|
|
||||||
<button
|
|
||||||
key={option.value}
|
|
||||||
onClick={() => setFrequency(option.value as Frequency)}
|
|
||||||
className={`px-3 py-2 text-sm font-medium rounded-lg border transition-colors ${
|
|
||||||
frequency === option.value
|
|
||||||
? 'bg-orange-500 text-white border-orange-500'
|
|
||||||
: 'bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 border-gray-200 dark:border-gray-700 hover:border-orange-300 dark:hover:border-orange-700'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{option.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Time Selection */}
|
|
||||||
{frequency !== 'custom' && (
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<div className="flex-1">
|
|
||||||
<label className="block text-sm font-medium text-gray-900 dark:text-white mb-2">
|
|
||||||
时间
|
|
||||||
</label>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min="0"
|
|
||||||
max="23"
|
|
||||||
value={time.hour}
|
|
||||||
onChange={(e) => setTime(prev => ({ ...prev, hour: parseInt(e.target.value) || 0 }))}
|
|
||||||
className="w-16 px-3 py-2 text-center border border-gray-200 dark:border-gray-700 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
|
|
||||||
/>
|
|
||||||
<span className="text-gray-500 dark:text-gray-400">:</span>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min="0"
|
|
||||||
max="59"
|
|
||||||
value={time.minute}
|
|
||||||
onChange={(e) => setTime(prev => ({ ...prev, minute: parseInt(e.target.value) || 0 }))}
|
|
||||||
className="w-16 px-3 py-2 text-center border border-gray-200 dark:border-gray-700 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex-1">
|
|
||||||
<label className="block text-sm font-medium text-gray-900 dark:text-white mb-2">
|
|
||||||
时区
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
value={timezone}
|
|
||||||
onChange={(e) => setTimezone(e.target.value)}
|
|
||||||
className="w-full px-3 py-2 border border-gray-200 dark:border-gray-700 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
|
|
||||||
>
|
|
||||||
{COMMON_TIMEZONES.map(tz => (
|
|
||||||
<option key={tz.value} value={tz.value}>{tz.label}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Weekly Days Selection */}
|
|
||||||
{frequency === 'weekly' && (
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-900 dark:text-white mb-2">
|
|
||||||
重复日期
|
|
||||||
</label>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
{DAY_NAMES.map((day, index) => (
|
|
||||||
<button
|
|
||||||
key={index}
|
|
||||||
onClick={() => toggleDayOfWeek(index)}
|
|
||||||
className={`w-10 h-10 rounded-full text-sm font-medium transition-colors ${
|
|
||||||
daysOfWeek.includes(index)
|
|
||||||
? 'bg-orange-500 text-white'
|
|
||||||
: 'bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{day}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Monthly Day Selection */}
|
|
||||||
{frequency === 'monthly' && (
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-900 dark:text-white mb-2">
|
|
||||||
每月日期
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
value={dayOfMonth}
|
|
||||||
onChange={(e) => setDayOfMonth(parseInt(e.target.value))}
|
|
||||||
className="w-full px-3 py-2 border border-gray-200 dark:border-gray-700 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
|
|
||||||
>
|
|
||||||
{Array.from({ length: 31 }, (_, i) => i + 1).map(day => (
|
|
||||||
<option key={day} value={day}>每月 {day} 日</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Custom Cron Input */}
|
|
||||||
{frequency === 'custom' && (
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-900 dark:text-white mb-2">
|
|
||||||
Cron 表达式
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={customCron}
|
|
||||||
onChange={(e) => setCustomCron(e.target.value)}
|
|
||||||
placeholder="* * * * * (分 时 日 月 周)"
|
|
||||||
className="w-full px-3 py-2 border border-gray-200 dark:border-gray-700 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white font-mono text-sm"
|
|
||||||
/>
|
|
||||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400 flex items-center gap-1">
|
|
||||||
<Info className="w-3 h-3" />
|
|
||||||
示例: "0 9 * * *" 表示每天 9:00 执行
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* End Date */}
|
|
||||||
{frequency !== 'once' && (
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-900 dark:text-white mb-2">
|
|
||||||
结束日期 (可选)
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
value={endDate}
|
|
||||||
onChange={(e) => setEndDate(e.target.value)}
|
|
||||||
className="w-full px-3 py-2 border border-gray-200 dark:border-gray-700 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Preview */}
|
|
||||||
<div className="p-3 bg-gray-50 dark:bg-gray-900 rounded-lg">
|
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-400 mb-1">预览</p>
|
|
||||||
<p className="text-sm font-medium text-gray-900 dark:text-white">{preview}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Footer */}
|
|
||||||
<div className="flex items-center justify-end gap-2 p-4 border-t border-gray-200 dark:border-gray-700">
|
|
||||||
<button
|
|
||||||
onClick={onCancel}
|
|
||||||
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={handleSave}
|
|
||||||
className="px-4 py-2 text-sm bg-orange-500 text-white rounded-lg hover:bg-orange-600"
|
|
||||||
>
|
|
||||||
保存
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default ScheduleEditor;
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
/**
|
|
||||||
* Automation Components
|
|
||||||
*
|
|
||||||
* Unified automation system components for Hands and Workflows.
|
|
||||||
*
|
|
||||||
* @module components/Automation
|
|
||||||
*/
|
|
||||||
|
|
||||||
export { AutomationPanel, default as AutomationPanelDefault } from './AutomationPanel';
|
|
||||||
export { AutomationCard } from './AutomationCard';
|
|
||||||
export { AutomationFilters } from './AutomationFilters';
|
|
||||||
export { BatchActionBar } from './BatchActionBar';
|
|
||||||
export { ScheduleEditor } from './ScheduleEditor';
|
|
||||||
export { ApprovalQueue } from './ApprovalQueue';
|
|
||||||
export { ExecutionResult } from './ExecutionResult';
|
|
||||||
|
|
||||||
// Re-export types
|
|
||||||
export type {
|
|
||||||
AutomationItem,
|
|
||||||
AutomationStatus,
|
|
||||||
AutomationType,
|
|
||||||
CategoryType,
|
|
||||||
CategoryStats,
|
|
||||||
RunInfo,
|
|
||||||
ScheduleInfo,
|
|
||||||
} from '../../types/automation';
|
|
||||||
@@ -1,132 +0,0 @@
|
|||||||
import { useEffect } from 'react';
|
|
||||||
import { useConnectionStore } from '../store/connectionStore';
|
|
||||||
import { useAgentStore } from '../store/agentStore';
|
|
||||||
import { useConfigStore } from '../store/configStore';
|
|
||||||
import { Radio, RefreshCw, MessageCircle, Settings } from 'lucide-react';
|
|
||||||
|
|
||||||
const CHANNEL_ICONS: Record<string, string> = {
|
|
||||||
feishu: '飞',
|
|
||||||
qqbot: 'QQ',
|
|
||||||
wechat: '微',
|
|
||||||
};
|
|
||||||
|
|
||||||
// 可用频道类型(用于显示未配置的频道)
|
|
||||||
const AVAILABLE_CHANNEL_TYPES = [
|
|
||||||
{ type: 'feishu', name: '飞书 (Feishu)' },
|
|
||||||
{ type: 'wechat', name: '微信' },
|
|
||||||
{ type: 'qqbot', name: 'QQ 机器人' },
|
|
||||||
];
|
|
||||||
|
|
||||||
interface ChannelListProps {
|
|
||||||
onOpenSettings?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ChannelList({ onOpenSettings }: ChannelListProps) {
|
|
||||||
const connectionState = useConnectionStore((s) => s.connectionState);
|
|
||||||
const loadPluginStatus = useAgentStore((s) => s.loadPluginStatus);
|
|
||||||
const channels = useConfigStore((s) => s.channels);
|
|
||||||
const loadChannels = useConfigStore((s) => s.loadChannels);
|
|
||||||
|
|
||||||
const connected = connectionState === 'connected';
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (connected) {
|
|
||||||
loadPluginStatus().then(() => loadChannels());
|
|
||||||
}
|
|
||||||
}, [connected]);
|
|
||||||
|
|
||||||
const handleRefresh = () => {
|
|
||||||
loadPluginStatus().then(() => loadChannels());
|
|
||||||
};
|
|
||||||
|
|
||||||
// 去重:基于 channel id
|
|
||||||
const uniqueChannels = channels.filter((ch, index, self) =>
|
|
||||||
index === self.findIndex(c => c.id === ch.id)
|
|
||||||
);
|
|
||||||
|
|
||||||
// 获取已配置的频道类型
|
|
||||||
const configuredTypes = new Set(uniqueChannels.map(c => c.type));
|
|
||||||
|
|
||||||
// 未配置的频道类型
|
|
||||||
const unconfiguredTypes = AVAILABLE_CHANNEL_TYPES.filter(ct => !configuredTypes.has(ct.type));
|
|
||||||
|
|
||||||
if (!connected) {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col items-center justify-center h-full text-gray-400 text-xs px-4 text-center">
|
|
||||||
<Radio className="w-8 h-8 mb-2 opacity-30" />
|
|
||||||
<p>IM 频道</p>
|
|
||||||
<p className="mt-1">连接 Gateway 后可用</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="h-full flex flex-col">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center justify-between px-3 py-2 border-b border-gray-200">
|
|
||||||
<span className="text-xs font-medium text-gray-500">频道列表</span>
|
|
||||||
<button
|
|
||||||
onClick={handleRefresh}
|
|
||||||
className="p-1 text-gray-400 hover:text-orange-500 rounded"
|
|
||||||
title="刷新"
|
|
||||||
>
|
|
||||||
<RefreshCw className="w-3.5 h-3.5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto custom-scrollbar">
|
|
||||||
{/* Configured channels */}
|
|
||||||
{uniqueChannels.map((ch) => (
|
|
||||||
<div
|
|
||||||
key={ch.id}
|
|
||||||
className="flex items-center gap-3 px-3 py-3 hover:bg-gray-100 border-b border-gray-50"
|
|
||||||
>
|
|
||||||
<div className={`w-8 h-8 rounded-lg flex items-center justify-center text-white text-xs font-bold flex-shrink-0 ${
|
|
||||||
ch.status === 'active'
|
|
||||||
? 'bg-gradient-to-br from-blue-500 to-indigo-500'
|
|
||||||
: 'bg-gray-300'
|
|
||||||
}`}>
|
|
||||||
{CHANNEL_ICONS[ch.type] || <MessageCircle className="w-4 h-4" />}
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="text-xs font-medium text-gray-900 truncate">{ch.label}</div>
|
|
||||||
<div className={`text-[11px] ${
|
|
||||||
ch.status === 'active' ? 'text-green-500' : ch.status === 'error' ? 'text-red-500' : 'text-gray-400'
|
|
||||||
}`}>
|
|
||||||
{ch.status === 'active' ? '已连接' : ch.status === 'error' ? ch.error || '错误' : '未配置'}
|
|
||||||
{ch.accounts !== undefined && ch.accounts > 0 && ` · ${ch.accounts} 个账号`}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{/* Unconfigured channels - 只显示一次 */}
|
|
||||||
{unconfiguredTypes.map((ct) => (
|
|
||||||
<div key={ct.type} className="flex items-center gap-3 px-3 py-3 hover:bg-gray-100 border-b border-gray-50 opacity-60">
|
|
||||||
<div className="w-8 h-8 rounded-lg flex items-center justify-center text-white text-xs font-bold flex-shrink-0 bg-gray-300">
|
|
||||||
{CHANNEL_ICONS[ct.type] || <MessageCircle className="w-4 h-4" />}
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="text-xs font-medium text-gray-600">{ct.name}</div>
|
|
||||||
<div className="text-[11px] text-gray-400">未配置</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{/* Help text */}
|
|
||||||
<div className="px-3 py-4 text-center">
|
|
||||||
<p className="text-[11px] text-gray-400 mb-2">在设置中配置 IM 频道</p>
|
|
||||||
{onOpenSettings && (
|
|
||||||
<button
|
|
||||||
onClick={onOpenSettings}
|
|
||||||
className="inline-flex items-center gap-1 text-xs text-orange-500 hover:text-orange-600"
|
|
||||||
>
|
|
||||||
<Settings className="w-3 h-3" />
|
|
||||||
打开设置
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,286 +0,0 @@
|
|||||||
/**
|
|
||||||
* ConnectionStatus Component
|
|
||||||
*
|
|
||||||
* Displays the current Gateway connection status with visual indicators.
|
|
||||||
* Supports automatic reconnect and manual reconnect button.
|
|
||||||
* Includes health status indicator for ZCLAW backend.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
|
||||||
import { Wifi, WifiOff, Loader2, RefreshCw, Heart, HeartPulse } from 'lucide-react';
|
|
||||||
import { useConnectionStore, getClient } from '../store/connectionStore';
|
|
||||||
import {
|
|
||||||
createHealthCheckScheduler,
|
|
||||||
getHealthStatusLabel,
|
|
||||||
formatHealthCheckTime,
|
|
||||||
type HealthCheckResult,
|
|
||||||
type HealthStatus,
|
|
||||||
} from '../lib/health-check';
|
|
||||||
|
|
||||||
interface ConnectionStatusProps {
|
|
||||||
/** Show compact version (just icon and status text) */
|
|
||||||
compact?: boolean;
|
|
||||||
/** Show reconnect button when disconnected */
|
|
||||||
showReconnectButton?: boolean;
|
|
||||||
/** Additional CSS classes */
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ReconnectInfo {
|
|
||||||
attempt: number;
|
|
||||||
delay: number;
|
|
||||||
maxAttempts: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
type StatusType = 'disconnected' | 'connecting' | 'handshaking' | 'connected' | 'reconnecting';
|
|
||||||
|
|
||||||
const statusConfig: Record<StatusType, {
|
|
||||||
color: string;
|
|
||||||
bgColor: string;
|
|
||||||
label: string;
|
|
||||||
icon: typeof Wifi;
|
|
||||||
animate?: boolean;
|
|
||||||
}> = {
|
|
||||||
disconnected: {
|
|
||||||
color: 'text-red-500',
|
|
||||||
bgColor: 'bg-red-50 dark:bg-red-900/20',
|
|
||||||
label: '已断开',
|
|
||||||
icon: WifiOff,
|
|
||||||
},
|
|
||||||
connecting: {
|
|
||||||
color: 'text-yellow-500',
|
|
||||||
bgColor: 'bg-yellow-50 dark:bg-yellow-900/20',
|
|
||||||
label: '连接中...',
|
|
||||||
icon: Loader2,
|
|
||||||
animate: true,
|
|
||||||
},
|
|
||||||
handshaking: {
|
|
||||||
color: 'text-yellow-500',
|
|
||||||
bgColor: 'bg-yellow-50 dark:bg-yellow-900/20',
|
|
||||||
label: '认证中...',
|
|
||||||
icon: Loader2,
|
|
||||||
animate: true,
|
|
||||||
},
|
|
||||||
connected: {
|
|
||||||
color: 'text-green-500',
|
|
||||||
bgColor: 'bg-green-50 dark:bg-green-900/20',
|
|
||||||
label: '已连接',
|
|
||||||
icon: Wifi,
|
|
||||||
},
|
|
||||||
reconnecting: {
|
|
||||||
color: 'text-orange-500',
|
|
||||||
bgColor: 'bg-orange-50 dark:bg-orange-900/20',
|
|
||||||
label: '重连中...',
|
|
||||||
icon: RefreshCw,
|
|
||||||
animate: true,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export function ConnectionStatus({
|
|
||||||
compact = false,
|
|
||||||
showReconnectButton = true,
|
|
||||||
className = '',
|
|
||||||
}: ConnectionStatusProps) {
|
|
||||||
const connectionState = useConnectionStore((s) => s.connectionState);
|
|
||||||
const connect = useConnectionStore((s) => s.connect);
|
|
||||||
const [showPrompt, setShowPrompt] = useState(false);
|
|
||||||
const [reconnectInfo, setReconnectInfo] = useState<ReconnectInfo | null>(null);
|
|
||||||
|
|
||||||
// Listen for reconnect events
|
|
||||||
useEffect(() => {
|
|
||||||
const client = getClient();
|
|
||||||
|
|
||||||
const unsubReconnecting = client.on('reconnecting', (info) => {
|
|
||||||
setReconnectInfo(info as ReconnectInfo);
|
|
||||||
});
|
|
||||||
|
|
||||||
const unsubFailed = client.on('reconnect_failed', () => {
|
|
||||||
setShowPrompt(true);
|
|
||||||
setReconnectInfo(null);
|
|
||||||
});
|
|
||||||
|
|
||||||
const unsubConnected = client.on('connected', () => {
|
|
||||||
setShowPrompt(false);
|
|
||||||
setReconnectInfo(null);
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
unsubReconnecting();
|
|
||||||
unsubFailed();
|
|
||||||
unsubConnected();
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const config = statusConfig[connectionState];
|
|
||||||
const Icon = config.icon;
|
|
||||||
const isDisconnected = connectionState === 'disconnected';
|
|
||||||
const isReconnecting = connectionState === 'reconnecting';
|
|
||||||
|
|
||||||
const handleReconnect = async () => {
|
|
||||||
setShowPrompt(false);
|
|
||||||
try {
|
|
||||||
await connect();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Manual reconnect failed:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Compact version
|
|
||||||
if (compact) {
|
|
||||||
return (
|
|
||||||
<div className={`flex items-center gap-1.5 ${className}`}>
|
|
||||||
<Icon
|
|
||||||
className={`w-3.5 h-3.5 ${config.color} ${config.animate ? 'animate-spin' : ''}`}
|
|
||||||
/>
|
|
||||||
<span className={`text-xs ${config.color}`}>
|
|
||||||
{isReconnecting && reconnectInfo
|
|
||||||
? `${config.label} (${reconnectInfo.attempt}/${reconnectInfo.maxAttempts})`
|
|
||||||
: config.label}
|
|
||||||
</span>
|
|
||||||
{showPrompt && showReconnectButton && (
|
|
||||||
<button
|
|
||||||
onClick={handleReconnect}
|
|
||||||
className="text-xs text-blue-500 hover:text-blue-600 ml-1"
|
|
||||||
>
|
|
||||||
重连
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Full version
|
|
||||||
return (
|
|
||||||
<div className={`flex items-center gap-3 ${config.bgColor} rounded-lg px-3 py-2 ${className}`}>
|
|
||||||
<motion.div
|
|
||||||
initial={false}
|
|
||||||
animate={{ rotate: config.animate ? 360 : 0 }}
|
|
||||||
transition={config.animate ? { duration: 1, repeat: Infinity, ease: 'linear' } : {}}
|
|
||||||
>
|
|
||||||
<Icon className={`w-5 h-5 ${config.color}`} />
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className={`text-sm font-medium ${config.color}`}>
|
|
||||||
{isReconnecting && reconnectInfo
|
|
||||||
? `${config.label} (${reconnectInfo.attempt}/${reconnectInfo.maxAttempts})`
|
|
||||||
: config.label}
|
|
||||||
</div>
|
|
||||||
{reconnectInfo && (
|
|
||||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
|
||||||
{Math.round(reconnectInfo.delay / 1000)}秒后重试
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<AnimatePresence>
|
|
||||||
{showPrompt && isDisconnected && showReconnectButton && (
|
|
||||||
<motion.button
|
|
||||||
initial={{ opacity: 0, scale: 0.9 }}
|
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
|
||||||
exit={{ opacity: 0, scale: 0.9 }}
|
|
||||||
onClick={handleReconnect}
|
|
||||||
className="flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium text-white bg-blue-500 hover:bg-blue-600 rounded-md transition-colors"
|
|
||||||
>
|
|
||||||
<RefreshCw className="w-4 h-4" />
|
|
||||||
重新连接
|
|
||||||
</motion.button>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* ConnectionIndicator - Minimal connection indicator for headers
|
|
||||||
*/
|
|
||||||
export function ConnectionIndicator({ className = '' }: { className?: string }) {
|
|
||||||
const connectionState = useConnectionStore((s) => s.connectionState);
|
|
||||||
|
|
||||||
const isConnected = connectionState === 'connected';
|
|
||||||
const isReconnecting = connectionState === 'reconnecting';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<span className={`text-xs flex items-center gap-1 ${className}`}>
|
|
||||||
<span
|
|
||||||
className={`w-1.5 h-1.5 rounded-full ${
|
|
||||||
isConnected
|
|
||||||
? 'bg-green-400'
|
|
||||||
: isReconnecting
|
|
||||||
? 'bg-orange-400 animate-pulse'
|
|
||||||
: 'bg-red-400'
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
<span className={
|
|
||||||
isConnected
|
|
||||||
? 'text-green-500'
|
|
||||||
: isReconnecting
|
|
||||||
? 'text-orange-500'
|
|
||||||
: 'text-red-500'
|
|
||||||
}>
|
|
||||||
{isConnected
|
|
||||||
? 'Gateway 已连接'
|
|
||||||
: isReconnecting
|
|
||||||
? '重连中...'
|
|
||||||
: 'Gateway 未连接'}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* HealthStatusIndicator - Displays ZCLAW backend health status
|
|
||||||
*/
|
|
||||||
export function HealthStatusIndicator({
|
|
||||||
className = '',
|
|
||||||
showDetails = false,
|
|
||||||
}: {
|
|
||||||
className?: string;
|
|
||||||
showDetails?: boolean;
|
|
||||||
}) {
|
|
||||||
const [healthResult, setHealthResult] = useState<HealthCheckResult | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// Start periodic health checks
|
|
||||||
const cleanup = createHealthCheckScheduler((result) => {
|
|
||||||
setHealthResult(result);
|
|
||||||
}, 30000); // Check every 30 seconds
|
|
||||||
|
|
||||||
return cleanup;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (!healthResult) {
|
|
||||||
return (
|
|
||||||
<span className={`text-xs flex items-center gap-1 ${className}`}>
|
|
||||||
<Heart className="w-3.5 h-3.5 text-gray-400" />
|
|
||||||
<span className="text-gray-400">检查中...</span>
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const statusColors: Record<HealthStatus, { dot: string; text: string; icon: typeof Heart }> = {
|
|
||||||
healthy: { dot: 'bg-green-400', text: 'text-green-500', icon: Heart },
|
|
||||||
unhealthy: { dot: 'bg-red-400', text: 'text-red-500', icon: HeartPulse },
|
|
||||||
unknown: { dot: 'bg-gray-400', text: 'text-gray-500', icon: Heart },
|
|
||||||
};
|
|
||||||
|
|
||||||
const config = statusColors[healthResult.status];
|
|
||||||
const Icon = config.icon;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<span className={`text-xs flex items-center gap-1 ${className}`}>
|
|
||||||
<Icon className={`w-3.5 h-3.5 ${config.text}`} />
|
|
||||||
<span className={config.text}>
|
|
||||||
{getHealthStatusLabel(healthResult.status)}
|
|
||||||
</span>
|
|
||||||
{showDetails && healthResult.message && (
|
|
||||||
<span className="text-gray-400 ml-1" title={healthResult.message}>
|
|
||||||
({formatHealthCheckTime(healthResult.timestamp)})
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default ConnectionStatus;
|
|
||||||
@@ -1,270 +0,0 @@
|
|||||||
/**
|
|
||||||
* ErrorNotification Component
|
|
||||||
*
|
|
||||||
* Displays error notifications as toast-style messages.
|
|
||||||
* Integrates with the centralized error handling system.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from 'react';
|
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
|
||||||
import {
|
|
||||||
X,
|
|
||||||
AlertCircle,
|
|
||||||
AlertTriangle,
|
|
||||||
Bug,
|
|
||||||
WifiOff,
|
|
||||||
ShieldAlert,
|
|
||||||
Clock,
|
|
||||||
ChevronDown,
|
|
||||||
ChevronUp,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import {
|
|
||||||
getUndismissedErrors,
|
|
||||||
dismissError,
|
|
||||||
dismissAll,
|
|
||||||
type StoredError,
|
|
||||||
} from '../lib/error-handling';
|
|
||||||
import {
|
|
||||||
ErrorCategory,
|
|
||||||
ErrorSeverity,
|
|
||||||
formatErrorForToast,
|
|
||||||
} from '../lib/error-types';
|
|
||||||
|
|
||||||
interface ErrorNotificationProps {
|
|
||||||
/** Maximum number of visible notifications */
|
|
||||||
maxVisible?: number;
|
|
||||||
/** Position on screen */
|
|
||||||
position?: 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left';
|
|
||||||
/** Auto dismiss timeout in ms (0 = no auto dismiss) */
|
|
||||||
autoDismissMs?: number;
|
|
||||||
/** Additional CSS classes */
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const categoryIcons: Record<ErrorCategory, typeof AlertCircle> = {
|
|
||||||
network: WifiOff,
|
|
||||||
auth: ShieldAlert,
|
|
||||||
permission: ShieldAlert,
|
|
||||||
validation: AlertTriangle,
|
|
||||||
config: AlertTriangle,
|
|
||||||
server: Bug,
|
|
||||||
client: AlertCircle,
|
|
||||||
timeout: Clock,
|
|
||||||
system: Bug,
|
|
||||||
};
|
|
||||||
|
|
||||||
const severityColors: Record<ErrorSeverity, {
|
|
||||||
bg: string;
|
|
||||||
border: string;
|
|
||||||
text: string;
|
|
||||||
icon: string;
|
|
||||||
}> = {
|
|
||||||
critical: {
|
|
||||||
bg: 'bg-red-50 dark:bg-red-900/20',
|
|
||||||
border: 'border-red-200 dark:border-red-800',
|
|
||||||
text: 'text-red-800 dark:text-red-200',
|
|
||||||
icon: 'text-red-500',
|
|
||||||
},
|
|
||||||
high: {
|
|
||||||
bg: 'bg-orange-50 dark:bg-orange-900/20',
|
|
||||||
border: 'border-orange-200 dark:border-orange-800',
|
|
||||||
text: 'text-orange-800 dark:text-orange-200',
|
|
||||||
icon: 'text-orange-500',
|
|
||||||
},
|
|
||||||
medium: {
|
|
||||||
bg: 'bg-yellow-50 dark:bg-yellow-900/20',
|
|
||||||
border: 'border-yellow-200 dark:border-yellow-800',
|
|
||||||
text: 'text-yellow-800 dark:text-yellow-200',
|
|
||||||
icon: 'text-yellow-500',
|
|
||||||
},
|
|
||||||
low: {
|
|
||||||
bg: 'bg-blue-50 dark:bg-blue-900/20',
|
|
||||||
border: 'border-blue-200 dark:border-blue-800',
|
|
||||||
text: 'text-blue-800 dark:text-blue-200',
|
|
||||||
icon: 'text-blue-500',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
function ErrorItem({
|
|
||||||
error,
|
|
||||||
onDismiss,
|
|
||||||
autoDismissMs,
|
|
||||||
}: {
|
|
||||||
error: StoredError;
|
|
||||||
onDismiss: (id: string) => void;
|
|
||||||
autoDismissMs: number;
|
|
||||||
}) {
|
|
||||||
const [expanded, setExpanded] = useState(false);
|
|
||||||
const Icon = categoryIcons[error.category] || AlertCircle;
|
|
||||||
const colors = severityColors[error.severity] || severityColors.medium;
|
|
||||||
const { title, message } = formatErrorForToast(error);
|
|
||||||
|
|
||||||
// Auto dismiss
|
|
||||||
useEffect(() => {
|
|
||||||
if (autoDismissMs > 0 && error.severity !== 'critical') {
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
onDismiss(error.id);
|
|
||||||
}, autoDismissMs);
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}
|
|
||||||
}, [autoDismissMs, error.id, error.severity, onDismiss]);
|
|
||||||
|
|
||||||
const hasDetails = error.stack || error.context;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, x: 300, scale: 0.9 }}
|
|
||||||
animate={{ opacity: 1, x: 0, scale: 1 }}
|
|
||||||
exit={{ opacity: 0, x: 300, scale: 0.9 }}
|
|
||||||
className={`
|
|
||||||
${colors.bg} ${colors.border} ${colors.text}
|
|
||||||
border rounded-lg shadow-lg p-4 min-w-[320px] max-w-[420px]
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<Icon className={`w-5 h-5 mt-0.5 flex-shrink-0 ${colors.icon}`} />
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="flex items-start justify-between gap-2">
|
|
||||||
<h4 className="font-medium text-sm">{title}</h4>
|
|
||||||
<button
|
|
||||||
onClick={() => onDismiss(error.id)}
|
|
||||||
className="p-1 rounded hover:bg-black/10 dark:hover:bg-white/10 flex-shrink-0"
|
|
||||||
>
|
|
||||||
<X className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm mt-1 opacity-90">{message}</p>
|
|
||||||
|
|
||||||
{hasDetails && (
|
|
||||||
<button
|
|
||||||
onClick={() => setExpanded(!expanded)}
|
|
||||||
className="flex items-center gap-1 text-xs mt-2 opacity-70 hover:opacity-100"
|
|
||||||
>
|
|
||||||
{expanded ? (
|
|
||||||
<>
|
|
||||||
<ChevronUp className="w-3 h-3" />
|
|
||||||
隐藏详情
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<ChevronDown className="w-3 h-3" />
|
|
||||||
显示详情
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{expanded && hasDetails && (
|
|
||||||
<motion.div
|
|
||||||
initial={{ height: 0, opacity: 0 }}
|
|
||||||
animate={{ height: 'auto', opacity: 1 }}
|
|
||||||
exit={{ height: 0, opacity: 0 }}
|
|
||||||
className="mt-2 p-2 bg-black/5 dark:bg-white/5 rounded text-xs font-mono overflow-auto max-h-32"
|
|
||||||
>
|
|
||||||
{error.context && (
|
|
||||||
<div className="mb-1">
|
|
||||||
<span className="opacity-70">Context: </span>
|
|
||||||
{JSON.stringify(error.context, null, 2)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{error.stack && (
|
|
||||||
<pre className="whitespace-pre-wrap opacity-70">{error.stack}</pre>
|
|
||||||
)}
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2 mt-2 text-xs opacity-60">
|
|
||||||
<span>{error.category}</span>
|
|
||||||
<span>•</span>
|
|
||||||
<span>{new Date(error.timestamp).toLocaleTimeString()}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ErrorNotification({
|
|
||||||
maxVisible = 3,
|
|
||||||
position = 'top-right',
|
|
||||||
autoDismissMs = 10000,
|
|
||||||
className = '',
|
|
||||||
}: ErrorNotificationProps) {
|
|
||||||
const [errors, setErrors] = useState<StoredError[]>([]);
|
|
||||||
|
|
||||||
// Poll for new errors
|
|
||||||
useEffect(() => {
|
|
||||||
const updateErrors = () => {
|
|
||||||
setErrors(getUndismissedErrors().slice(0, maxVisible));
|
|
||||||
};
|
|
||||||
|
|
||||||
updateErrors();
|
|
||||||
const interval = setInterval(updateErrors, 1000);
|
|
||||||
return () => clearInterval(interval);
|
|
||||||
}, [maxVisible]);
|
|
||||||
|
|
||||||
const handleDismiss = useCallback((id: string) => {
|
|
||||||
dismissError(id);
|
|
||||||
setErrors(prev => prev.filter(e => e.id !== id));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleDismissAll = useCallback(() => {
|
|
||||||
dismissAll();
|
|
||||||
setErrors([]);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const positionClasses: Record<string, string> = {
|
|
||||||
'top-right': 'top-4 right-4',
|
|
||||||
'top-left': 'top-4 left-4',
|
|
||||||
'bottom-right': 'bottom-4 right-4',
|
|
||||||
'bottom-left': 'bottom-4 left-4',
|
|
||||||
};
|
|
||||||
|
|
||||||
if (errors.length === 0) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`fixed ${positionClasses[position]} z-50 flex flex-col gap-2 ${className}`}
|
|
||||||
>
|
|
||||||
<AnimatePresence mode="popLayout">
|
|
||||||
{errors.map(error => (
|
|
||||||
<ErrorItem
|
|
||||||
key={error.id}
|
|
||||||
error={error}
|
|
||||||
onDismiss={handleDismiss}
|
|
||||||
autoDismissMs={autoDismissMs}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</AnimatePresence>
|
|
||||||
|
|
||||||
{errors.length > 1 && (
|
|
||||||
<motion.button
|
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={{ opacity: 1 }}
|
|
||||||
onClick={handleDismissAll}
|
|
||||||
className="text-xs text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 text-center py-1"
|
|
||||||
>
|
|
||||||
清除全部 ({errors.length})
|
|
||||||
</motion.button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* ErrorNotificationProvider - Include at app root
|
|
||||||
*/
|
|
||||||
export function ErrorNotificationProvider({
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
children: React.ReactNode;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{children}
|
|
||||||
<ErrorNotification />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default ErrorNotification;
|
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
/**
|
|
||||||
* HandList - 左侧导航的 Hands 列表
|
|
||||||
*
|
|
||||||
* 显示所有可用的 Hands(自主能力包),
|
|
||||||
* 允许用户选择一个 Hand 来查看其任务和结果。
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useEffect } from 'react';
|
|
||||||
import { useHandStore, type Hand } from '../store/handStore';
|
|
||||||
import { Zap, Loader2, RefreshCw, CheckCircle, XCircle, AlertTriangle } from 'lucide-react';
|
|
||||||
|
|
||||||
interface HandListProps {
|
|
||||||
selectedHandId?: string;
|
|
||||||
onSelectHand?: (handId: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 状态图标
|
|
||||||
function HandStatusIcon({ status }: { status: Hand['status'] }) {
|
|
||||||
switch (status) {
|
|
||||||
case 'running':
|
|
||||||
return <Loader2 className="w-3.5 h-3.5 text-blue-500 animate-spin" />;
|
|
||||||
case 'needs_approval':
|
|
||||||
return <AlertTriangle className="w-3.5 h-3.5 text-yellow-500" />;
|
|
||||||
case 'error':
|
|
||||||
return <XCircle className="w-3.5 h-3.5 text-red-500" />;
|
|
||||||
case 'setup_needed':
|
|
||||||
case 'unavailable':
|
|
||||||
return <AlertTriangle className="w-3.5 h-3.5 text-orange-500" />;
|
|
||||||
default:
|
|
||||||
return <CheckCircle className="w-3.5 h-3.5 text-green-500" />;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 状态标签
|
|
||||||
const STATUS_LABELS: Record<Hand['status'], string> = {
|
|
||||||
idle: '就绪',
|
|
||||||
running: '运行中',
|
|
||||||
needs_approval: '待审批',
|
|
||||||
error: '错误',
|
|
||||||
unavailable: '不可用',
|
|
||||||
setup_needed: '需配置',
|
|
||||||
};
|
|
||||||
|
|
||||||
export function HandList({ selectedHandId, onSelectHand }: HandListProps) {
|
|
||||||
const hands = useHandStore((s) => s.hands);
|
|
||||||
const loadHands = useHandStore((s) => s.loadHands);
|
|
||||||
const isLoading = useHandStore((s) => s.isLoading);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadHands();
|
|
||||||
}, [loadHands]);
|
|
||||||
|
|
||||||
if (isLoading && hands.length === 0) {
|
|
||||||
return (
|
|
||||||
<div className="p-4 text-center">
|
|
||||||
<Loader2 className="w-5 h-5 animate-spin mx-auto text-gray-400 mb-2" />
|
|
||||||
<p className="text-xs text-gray-400">加载中...</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hands.length === 0) {
|
|
||||||
return (
|
|
||||||
<div className="p-4 text-center">
|
|
||||||
<Zap className="w-8 h-8 mx-auto text-gray-300 mb-2" />
|
|
||||||
<p className="text-xs text-gray-400 mb-1">暂无可用 Hands</p>
|
|
||||||
<p className="text-xs text-gray-300">连接 ZCLAW 后显示</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col h-full">
|
|
||||||
{/* 头部 */}
|
|
||||||
<div className="p-3 border-b border-gray-200 flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h3 className="text-xs font-semibold text-gray-700">自主能力包</h3>
|
|
||||||
<p className="text-xs text-gray-400">{hands.length} 个可用</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => loadHands()}
|
|
||||||
disabled={isLoading}
|
|
||||||
className="p-1.5 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded transition-colors disabled:opacity-50"
|
|
||||||
title="刷新"
|
|
||||||
>
|
|
||||||
<RefreshCw className={`w-3.5 h-3.5 ${isLoading ? 'animate-spin' : ''}`} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Hands 列表 */}
|
|
||||||
<div className="flex-1 overflow-y-auto">
|
|
||||||
{hands.map((hand) => (
|
|
||||||
<button
|
|
||||||
key={hand.id}
|
|
||||||
onClick={() => onSelectHand?.(hand.id)}
|
|
||||||
className={`w-full text-left p-3 border-b border-gray-100 hover:bg-gray-100 transition-colors ${
|
|
||||||
selectedHandId === hand.id ? 'bg-blue-50 border-l-2 border-l-blue-500' : ''
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="flex items-start gap-2">
|
|
||||||
<span className="text-lg flex-shrink-0">{hand.icon || '🤖'}</span>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<span className="font-medium text-gray-800 text-sm truncate">
|
|
||||||
{hand.name}
|
|
||||||
</span>
|
|
||||||
<HandStatusIcon status={hand.status} />
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-gray-400 truncate mt-0.5">
|
|
||||||
{hand.description}
|
|
||||||
</p>
|
|
||||||
<div className="flex items-center gap-2 mt-1">
|
|
||||||
<span className="text-xs text-gray-400">
|
|
||||||
{STATUS_LABELS[hand.status]}
|
|
||||||
</span>
|
|
||||||
{hand.toolCount !== undefined && (
|
|
||||||
<span className="text-xs text-gray-300">
|
|
||||||
{hand.toolCount} 工具
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default HandList;
|
|
||||||
@@ -1,326 +0,0 @@
|
|||||||
/**
|
|
||||||
* HandTaskPanel - Hand 任务和结果面板
|
|
||||||
*
|
|
||||||
* 显示选中 Hand 的任务清单、执行历史和结果。
|
|
||||||
* 使用真实 API 数据,移除了 Mock 数据。
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from 'react';
|
|
||||||
import { useHandStore, type Hand, type HandRun } from '../store/handStore';
|
|
||||||
import {
|
|
||||||
Zap,
|
|
||||||
Loader2,
|
|
||||||
Clock,
|
|
||||||
CheckCircle,
|
|
||||||
XCircle,
|
|
||||||
AlertCircle,
|
|
||||||
ChevronRight,
|
|
||||||
Play,
|
|
||||||
ArrowLeft,
|
|
||||||
RefreshCw,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { useToast } from './ui/Toast';
|
|
||||||
|
|
||||||
interface HandTaskPanelProps {
|
|
||||||
handId: string;
|
|
||||||
onBack?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 任务状态配置
|
|
||||||
const RUN_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 },
|
|
||||||
failed: { label: '失败', className: 'text-red-600 bg-red-100', icon: XCircle },
|
|
||||||
cancelled: { label: '已取消', className: 'text-gray-500 bg-gray-100', icon: XCircle },
|
|
||||||
needs_approval: { label: '待审批', className: 'text-yellow-600 bg-yellow-100', icon: AlertCircle },
|
|
||||||
success: { label: '成功', className: 'text-green-600 bg-green-100', icon: CheckCircle },
|
|
||||||
error: { label: '错误', className: 'text-red-600 bg-red-100', icon: XCircle },
|
|
||||||
};
|
|
||||||
|
|
||||||
export function HandTaskPanel({ handId, onBack }: HandTaskPanelProps) {
|
|
||||||
const hands = useHandStore((s) => s.hands);
|
|
||||||
const handRuns = useHandStore((s) => s.handRuns);
|
|
||||||
const loadHands = useHandStore((s) => s.loadHands);
|
|
||||||
const loadHandRuns = useHandStore((s) => s.loadHandRuns);
|
|
||||||
const triggerHand = useHandStore((s) => s.triggerHand);
|
|
||||||
const isLoading = useHandStore((s) => s.isLoading);
|
|
||||||
const { toast } = useToast();
|
|
||||||
const [selectedHand, setSelectedHand] = useState<Hand | null>(null);
|
|
||||||
const [isActivating, setIsActivating] = useState(false);
|
|
||||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
|
||||||
|
|
||||||
// Load hands on mount
|
|
||||||
useEffect(() => {
|
|
||||||
loadHands();
|
|
||||||
}, [loadHands]);
|
|
||||||
|
|
||||||
// Find selected hand
|
|
||||||
useEffect(() => {
|
|
||||||
const hand = hands.find(h => h.id === handId || h.name === handId);
|
|
||||||
setSelectedHand(hand || null);
|
|
||||||
}, [hands, handId]);
|
|
||||||
|
|
||||||
// Load task history when hand is selected
|
|
||||||
useEffect(() => {
|
|
||||||
if (selectedHand) {
|
|
||||||
loadHandRuns(selectedHand.id, { limit: 50 });
|
|
||||||
}
|
|
||||||
}, [selectedHand, loadHandRuns]);
|
|
||||||
|
|
||||||
// Get runs for this hand from store
|
|
||||||
const tasks: HandRun[] = selectedHand ? (handRuns[selectedHand.id] || []) : [];
|
|
||||||
|
|
||||||
// Refresh task history
|
|
||||||
const handleRefresh = useCallback(async () => {
|
|
||||||
if (!selectedHand) return;
|
|
||||||
setIsRefreshing(true);
|
|
||||||
try {
|
|
||||||
await loadHandRuns(selectedHand.id, { limit: 50 });
|
|
||||||
} finally {
|
|
||||||
setIsRefreshing(false);
|
|
||||||
}
|
|
||||||
}, [selectedHand, loadHandRuns]);
|
|
||||||
|
|
||||||
// Trigger hand execution
|
|
||||||
const handleActivate = useCallback(async () => {
|
|
||||||
if (!selectedHand) return;
|
|
||||||
|
|
||||||
// Check if hand is already running
|
|
||||||
if (selectedHand.status === 'running') {
|
|
||||||
toast(`Hand "${selectedHand.name}" 正在运行中,请等待完成`, 'warning');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsActivating(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await triggerHand(selectedHand.id);
|
|
||||||
|
|
||||||
if (result) {
|
|
||||||
toast(`Hand "${selectedHand.name}" 已成功启动`, 'success');
|
|
||||||
// Refresh hands list and task history
|
|
||||||
await Promise.all([
|
|
||||||
loadHands(),
|
|
||||||
loadHandRuns(selectedHand.id, { limit: 50 }),
|
|
||||||
]);
|
|
||||||
} else {
|
|
||||||
// Check for specific error in store
|
|
||||||
const storeError = useHandStore.getState().error;
|
|
||||||
if (storeError?.includes('already active')) {
|
|
||||||
toast(`Hand "${selectedHand.name}" 已在运行中`, 'warning');
|
|
||||||
} else {
|
|
||||||
toast(`Hand "${selectedHand.name}" 启动失败: ${storeError || '未知错误'}`, 'error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
||||||
console.error(`[HandTaskPanel] Activation error:`, errorMsg);
|
|
||||||
|
|
||||||
if (errorMsg.includes('already active')) {
|
|
||||||
toast(`Hand "${selectedHand.name}" 已在运行中`, 'warning');
|
|
||||||
} else {
|
|
||||||
toast(`Hand "${selectedHand.name}" 启动异常: ${errorMsg}`, 'error');
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
setIsActivating(false);
|
|
||||||
}
|
|
||||||
}, [selectedHand, triggerHand, loadHands, loadHandRuns, toast]);
|
|
||||||
|
|
||||||
if (!selectedHand) {
|
|
||||||
return (
|
|
||||||
<div className="p-8 text-center">
|
|
||||||
<Zap className="w-12 h-12 mx-auto text-gray-300 mb-3" />
|
|
||||||
<p className="text-sm text-gray-400">请从左侧选择一个 Hand</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const runningTasks = tasks.filter(t => t.status === 'running');
|
|
||||||
const completedTasks = tasks.filter(t => ['completed', 'success', 'failed', 'error', 'cancelled'].includes(t.status));
|
|
||||||
const pendingTasks = tasks.filter(t => ['pending', 'needs_approval'].includes(t.status));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="h-full flex flex-col">
|
|
||||||
{/* 头部 */}
|
|
||||||
<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">
|
|
||||||
{onBack && (
|
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
<span className="text-2xl">{selectedHand.icon || '🤖'}</span>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white truncate">
|
|
||||||
{selectedHand.name}
|
|
||||||
</h2>
|
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-400 truncate">{selectedHand.description}</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>
|
|
||||||
<button
|
|
||||||
onClick={handleActivate}
|
|
||||||
disabled={selectedHand.status !== 'idle' || isActivating}
|
|
||||||
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 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
|
||||||
>
|
|
||||||
{isActivating ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="w-4 h-4 animate-spin" />
|
|
||||||
启动中...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Play className="w-4 h-4" />
|
|
||||||
执行任务
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 内容区域 */}
|
|
||||||
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
|
||||||
{/* 加载状态 */}
|
|
||||||
{isLoading && tasks.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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 运行中的任务 */}
|
|
||||||
{runningTasks.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" />
|
|
||||||
运行中 ({runningTasks.length})
|
|
||||||
</h3>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{runningTasks.map(task => (
|
|
||||||
<TaskCard key={task.runId} task={task} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 待处理任务 */}
|
|
||||||
{pendingTasks.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" />
|
|
||||||
待处理 ({pendingTasks.length})
|
|
||||||
</h3>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{pendingTasks.map(task => (
|
|
||||||
<TaskCard key={task.runId} task={task} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 已完成任务 */}
|
|
||||||
{completedTasks.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">
|
|
||||||
历史记录 ({completedTasks.length})
|
|
||||||
</h3>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{completedTasks.map(task => (
|
|
||||||
<TaskCard key={task.runId} task={task} expanded />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 空状态 */}
|
|
||||||
{!isLoading && tasks.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">
|
|
||||||
<Zap 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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 任务卡片组件
|
|
||||||
function TaskCard({ task, expanded = false }: { task: HandRun; expanded?: boolean }) {
|
|
||||||
const [isExpanded, setIsExpanded] = useState(expanded);
|
|
||||||
const config = RUN_STATUS_CONFIG[task.status] || RUN_STATUS_CONFIG.pending;
|
|
||||||
const StatusIcon = config.icon;
|
|
||||||
|
|
||||||
// Format result for display
|
|
||||||
const resultText = task.result
|
|
||||||
? (typeof task.result === 'string' ? task.result : JSON.stringify(task.result, null, 2))
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="bg-gray-50 dark:bg-gray-900 rounded-lg p-3 border border-gray-100 dark:border-gray-700">
|
|
||||||
<div
|
|
||||||
className="flex items-center justify-between cursor-pointer"
|
|
||||||
onClick={() => setIsExpanded(!isExpanded)}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
|
||||||
<StatusIcon className={`w-4 h-4 flex-shrink-0 ${task.status === 'running' ? 'animate-spin' : ''}`} />
|
|
||||||
<span className="text-sm font-medium text-gray-800 dark:text-gray-200 truncate">
|
|
||||||
运行 #{task.runId.slice(0, 8)}
|
|
||||||
</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>
|
|
||||||
|
|
||||||
{/* 展开详情 */}
|
|
||||||
{isExpanded && (
|
|
||||||
<div className="mt-3 pt-3 border-t border-gray-200 dark:border-gray-700 space-y-2 text-xs text-gray-500 dark:text-gray-400">
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span>运行 ID</span>
|
|
||||||
<span className="font-mono">{task.runId}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span>开始时间</span>
|
|
||||||
<span>{new Date(task.startedAt).toLocaleString()}</span>
|
|
||||||
</div>
|
|
||||||
{task.completedAt && (
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span>完成时间</span>
|
|
||||||
<span>{new Date(task.completedAt).toLocaleString()}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{resultText && (
|
|
||||||
<div className="mt-2 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 whitespace-pre-wrap max-h-40 overflow-auto">
|
|
||||||
{resultText}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{task.error && (
|
|
||||||
<div className="mt-2 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">
|
|
||||||
{task.error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default HandTaskPanel;
|
|
||||||
@@ -1,641 +0,0 @@
|
|||||||
/**
|
|
||||||
* HandsPanel - ZCLAW Hands Management UI
|
|
||||||
*
|
|
||||||
* Displays available ZCLAW Hands (autonomous capability packages)
|
|
||||||
* with detailed status, requirements, and activation controls.
|
|
||||||
*
|
|
||||||
* Design based on ZCLAW Dashboard v0.4.0
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from 'react';
|
|
||||||
import { useHandStore, type Hand, type HandRequirement } from '../store/handStore';
|
|
||||||
import { Zap, RefreshCw, ChevronRight, CheckCircle, XCircle, Loader2, AlertTriangle, Settings, Play, Clock } from 'lucide-react';
|
|
||||||
import { BrowserHandCard } from './BrowserHand';
|
|
||||||
import type { HandParameter } from '../types/hands';
|
|
||||||
import { HAND_DEFINITIONS } from '../types/hands';
|
|
||||||
import { HandParamsForm } from './HandParamsForm';
|
|
||||||
import { ApprovalsPanel } from './ApprovalsPanel';
|
|
||||||
import { useToast } from './ui/Toast';
|
|
||||||
|
|
||||||
// === Tab Type ===
|
|
||||||
type TabType = 'hands' | 'approvals';
|
|
||||||
|
|
||||||
// === Status Badge Component ===
|
|
||||||
|
|
||||||
type HandStatus = 'idle' | 'running' | 'needs_approval' | 'error' | 'unavailable' | 'setup_needed';
|
|
||||||
|
|
||||||
// === Parameter Validation Helper ===
|
|
||||||
|
|
||||||
function validateAllParameters(
|
|
||||||
parameters: HandParameter[],
|
|
||||||
values: Record<string, unknown>
|
|
||||||
): Record<string, string> {
|
|
||||||
const errors: Record<string, string> = {};
|
|
||||||
parameters.forEach(param => {
|
|
||||||
if (param.required) {
|
|
||||||
const value = values[param.name];
|
|
||||||
if (value === undefined || value === null || value === '') {
|
|
||||||
errors[param.name] = `${param.label} is required`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return errors;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface StatusConfig {
|
|
||||||
label: string;
|
|
||||||
className: string;
|
|
||||||
dotClass: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const STATUS_CONFIG: Record<HandStatus, StatusConfig> = {
|
|
||||||
idle: {
|
|
||||||
label: '就绪',
|
|
||||||
className: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400',
|
|
||||||
dotClass: 'bg-green-500',
|
|
||||||
},
|
|
||||||
running: {
|
|
||||||
label: '运行中',
|
|
||||||
className: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400',
|
|
||||||
dotClass: 'bg-blue-500 animate-pulse',
|
|
||||||
},
|
|
||||||
needs_approval: {
|
|
||||||
label: '待审批',
|
|
||||||
className: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400',
|
|
||||||
dotClass: 'bg-yellow-500',
|
|
||||||
},
|
|
||||||
error: {
|
|
||||||
label: '错误',
|
|
||||||
className: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400',
|
|
||||||
dotClass: 'bg-red-500',
|
|
||||||
},
|
|
||||||
unavailable: {
|
|
||||||
label: '不可用',
|
|
||||||
className: 'bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-400',
|
|
||||||
dotClass: 'bg-gray-400',
|
|
||||||
},
|
|
||||||
setup_needed: {
|
|
||||||
label: '需配置',
|
|
||||||
className: 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400',
|
|
||||||
dotClass: 'bg-orange-500',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
function HandStatusBadge({ status }: { status: string }) {
|
|
||||||
const config = STATUS_CONFIG[status as HandStatus] || STATUS_CONFIG.unavailable;
|
|
||||||
return (
|
|
||||||
<span className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium ${config.className}`}>
|
|
||||||
<span className={`w-1.5 h-1.5 rounded-full ${config.dotClass}`} />
|
|
||||||
{config.label}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// === Category Badge Component ===
|
|
||||||
|
|
||||||
const CATEGORY_CONFIG: Record<string, { label: string; className: string }> = {
|
|
||||||
productivity: { label: '生产力', className: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400' },
|
|
||||||
data: { label: '数据', className: 'bg-cyan-100 text-cyan-700 dark:bg-cyan-900/30 dark:text-cyan-400' },
|
|
||||||
content: { label: '内容', className: 'bg-pink-100 text-pink-700 dark:bg-pink-900/30 dark:text-pink-400' },
|
|
||||||
communication: { label: '通信', className: 'bg-indigo-100 text-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-400' },
|
|
||||||
};
|
|
||||||
|
|
||||||
function CategoryBadge({ category }: { category?: string }) {
|
|
||||||
if (!category) return null;
|
|
||||||
const config = CATEGORY_CONFIG[category] || { label: category, className: 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400' };
|
|
||||||
return (
|
|
||||||
<span className={`px-2 py-0.5 rounded text-xs ${config.className}`}>
|
|
||||||
{config.label}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// === Requirement Item Component ===
|
|
||||||
|
|
||||||
function RequirementItem({ requirement }: { requirement: HandRequirement }) {
|
|
||||||
return (
|
|
||||||
<div className={`flex items-start gap-2 text-sm py-1 ${requirement.met ? 'text-green-700 dark:text-green-400' : 'text-red-600 dark:text-red-400'}`}>
|
|
||||||
<span className="flex-shrink-0 mt-0.5">
|
|
||||||
{requirement.met ? (
|
|
||||||
<CheckCircle className="w-4 h-4" />
|
|
||||||
) : (
|
|
||||||
<XCircle className="w-4 h-4" />
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<span className="break-words">{requirement.description}</span>
|
|
||||||
{requirement.details && (
|
|
||||||
<span className="text-gray-400 dark:text-gray-500 text-xs ml-1">({requirement.details})</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// === Hand Details Modal Component ===
|
|
||||||
|
|
||||||
interface HandDetailsModalProps {
|
|
||||||
hand: Hand;
|
|
||||||
isOpen: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
onActivate: (params?: Record<string, unknown>) => void;
|
|
||||||
isActivating: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
function HandDetailsModal({ hand, isOpen, onClose, onActivate, isActivating }: HandDetailsModalProps) {
|
|
||||||
// Get Hand parameters from definitions
|
|
||||||
const handDefinition = HAND_DEFINITIONS.find(h => h.id === hand.id);
|
|
||||||
const parameters: HandParameter[] = handDefinition?.parameters || [];
|
|
||||||
|
|
||||||
// Form state for parameters
|
|
||||||
const [paramValues, setParamValues] = useState<Record<string, unknown>>({});
|
|
||||||
const [paramErrors, setParamErrors] = useState<Record<string, string>>({});
|
|
||||||
const [showParamsForm, setShowParamsForm] = useState(false);
|
|
||||||
|
|
||||||
// Initialize default values
|
|
||||||
useEffect(() => {
|
|
||||||
if (parameters.length > 0) {
|
|
||||||
const defaults: Record<string, unknown> = {};
|
|
||||||
parameters.forEach(p => {
|
|
||||||
if (p.defaultValue !== undefined) {
|
|
||||||
defaults[p.name] = p.defaultValue;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
setParamValues(defaults);
|
|
||||||
}
|
|
||||||
}, [parameters]);
|
|
||||||
|
|
||||||
// Reset form when modal opens/closes
|
|
||||||
useEffect(() => {
|
|
||||||
if (isOpen) {
|
|
||||||
setShowParamsForm(false);
|
|
||||||
setParamErrors({});
|
|
||||||
}
|
|
||||||
}, [isOpen]);
|
|
||||||
|
|
||||||
const handleActivateClick = useCallback(() => {
|
|
||||||
if (parameters.length > 0 && !showParamsForm) {
|
|
||||||
// Show params form first
|
|
||||||
setShowParamsForm(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate parameters if showing form
|
|
||||||
if (showParamsForm) {
|
|
||||||
const errors = validateAllParameters(parameters, paramValues);
|
|
||||||
setParamErrors(errors);
|
|
||||||
if (Object.keys(errors).length > 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Pass parameters to onActivate
|
|
||||||
onActivate(paramValues);
|
|
||||||
} else {
|
|
||||||
onActivate();
|
|
||||||
}
|
|
||||||
}, [parameters, showParamsForm, paramValues, onActivate]);
|
|
||||||
|
|
||||||
if (!isOpen) return null;
|
|
||||||
|
|
||||||
const canActivate = hand.status === 'idle' || hand.status === 'setup_needed';
|
|
||||||
const hasUnmetRequirements = hand.requirements?.some(r => !r.met);
|
|
||||||
|
|
||||||
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-lg mx-4 max-h-[90vh] overflow-hidden flex flex-col">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-start justify-between p-4 border-b border-gray-200 dark:border-gray-700">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<span className="text-2xl">{hand.icon || '🤖'}</span>
|
|
||||||
<div>
|
|
||||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">{hand.name}</h2>
|
|
||||||
<HandStatusBadge status={hand.status} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 p-1"
|
|
||||||
>
|
|
||||||
<span className="text-xl">×</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Body */}
|
|
||||||
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
|
||||||
{/* Description */}
|
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400">{hand.description}</p>
|
|
||||||
|
|
||||||
{/* Agent Config */}
|
|
||||||
{(hand.provider || hand.model) && (
|
|
||||||
<div className="bg-gray-50 dark:bg-gray-900 rounded-lg p-3">
|
|
||||||
<h3 className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-2">
|
|
||||||
代理配置
|
|
||||||
</h3>
|
|
||||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
|
||||||
{hand.provider && (
|
|
||||||
<div>
|
|
||||||
<span className="text-gray-500 dark:text-gray-400">提供商</span>
|
|
||||||
<p className="font-medium text-gray-900 dark:text-white">{hand.provider}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{hand.model && (
|
|
||||||
<div>
|
|
||||||
<span className="text-gray-500 dark:text-gray-400">模型</span>
|
|
||||||
<p className="font-medium text-gray-900 dark:text-white">{hand.model}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Requirements */}
|
|
||||||
{hand.requirements && hand.requirements.length > 0 && (
|
|
||||||
<div className="bg-gray-50 dark:bg-gray-900 rounded-lg p-3">
|
|
||||||
<h3 className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-2">
|
|
||||||
环境要求
|
|
||||||
</h3>
|
|
||||||
<div className="space-y-1">
|
|
||||||
{hand.requirements.map((req, idx) => (
|
|
||||||
<RequirementItem key={idx} requirement={req} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Tools */}
|
|
||||||
{hand.tools && hand.tools.length > 0 && (
|
|
||||||
<div className="bg-gray-50 dark:bg-gray-900 rounded-lg p-3">
|
|
||||||
<h3 className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-2">
|
|
||||||
工具 ({hand.tools.length})
|
|
||||||
</h3>
|
|
||||||
<div className="flex flex-wrap gap-1.5">
|
|
||||||
{hand.tools.map((tool, idx) => (
|
|
||||||
<span
|
|
||||||
key={idx}
|
|
||||||
className="px-2 py-0.5 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded text-xs text-gray-700 dark:text-gray-300 font-mono"
|
|
||||||
>
|
|
||||||
{tool}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Parameters Form (shown when activating) */}
|
|
||||||
{showParamsForm && parameters.length > 0 && (
|
|
||||||
<div className="bg-gray-50 dark:bg-gray-900 rounded-lg p-3">
|
|
||||||
<h3 className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-3">
|
|
||||||
执行参数
|
|
||||||
</h3>
|
|
||||||
<HandParamsForm
|
|
||||||
parameters={parameters}
|
|
||||||
values={paramValues}
|
|
||||||
onChange={setParamValues}
|
|
||||||
errors={paramErrors}
|
|
||||||
disabled={isActivating}
|
|
||||||
presetKey={`hand-${hand.id}`}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Dashboard Metrics */}
|
|
||||||
{hand.metrics && hand.metrics.length > 0 && (
|
|
||||||
<div className="bg-gray-50 dark:bg-gray-900 rounded-lg p-3">
|
|
||||||
<h3 className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-2">
|
|
||||||
仪表盘指标 ({hand.metrics.length})
|
|
||||||
</h3>
|
|
||||||
<div className="grid grid-cols-3 gap-2">
|
|
||||||
{hand.metrics.map((metric, idx) => (
|
|
||||||
<div
|
|
||||||
key={idx}
|
|
||||||
className="bg-white dark:bg-gray-800 rounded p-2 text-center border border-gray-200 dark:border-gray-700"
|
|
||||||
>
|
|
||||||
<div className="text-xs text-gray-400 dark:text-gray-500 truncate">{metric}</div>
|
|
||||||
<div className="text-lg font-semibold text-gray-400 dark:text-gray-500">-</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Footer */}
|
|
||||||
<div className="flex items-center justify-end gap-2 p-4 border-t border-gray-200 dark:border-gray-700">
|
|
||||||
<button
|
|
||||||
onClick={showParamsForm ? () => setShowParamsForm(false) : 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"
|
|
||||||
>
|
|
||||||
{showParamsForm ? '返回' : '关闭'}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={handleActivateClick}
|
|
||||||
disabled={!canActivate || hasUnmetRequirements || isActivating}
|
|
||||||
className="px-4 py-2 text-sm bg-gray-700 dark:bg-gray-600 text-white rounded-lg hover:bg-gray-800 dark:hover:bg-gray-500 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
|
||||||
>
|
|
||||||
{isActivating ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="w-4 h-4 animate-spin" />
|
|
||||||
激活中...
|
|
||||||
</>
|
|
||||||
) : hasUnmetRequirements ? (
|
|
||||||
<>
|
|
||||||
<Settings className="w-4 h-4" />
|
|
||||||
需要配置
|
|
||||||
</>
|
|
||||||
) : showParamsForm ? (
|
|
||||||
<>
|
|
||||||
<Play className="w-4 h-4" />
|
|
||||||
执行
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Zap className="w-4 h-4" />
|
|
||||||
激活
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// === Hand Card Component ===
|
|
||||||
|
|
||||||
interface HandCardProps {
|
|
||||||
hand: Hand;
|
|
||||||
onDetails: (hand: Hand) => void;
|
|
||||||
onActivate: (hand: Hand, params?: Record<string, unknown>) => void;
|
|
||||||
isActivating: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
function HandCard({ hand, onDetails, onActivate, isActivating }: HandCardProps) {
|
|
||||||
const canActivate = hand.status === 'idle';
|
|
||||||
const hasUnmetRequirements = hand.requirements_met === false;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4 shadow-sm hover:shadow-md transition-shadow">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-start justify-between gap-3 mb-2">
|
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
|
||||||
<span className="text-xl flex-shrink-0">{hand.icon || '🤖'}</span>
|
|
||||||
<h3 className="font-medium text-gray-900 dark:text-white truncate">{hand.name}</h3>
|
|
||||||
</div>
|
|
||||||
<HandStatusBadge status={hand.status} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Description */}
|
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3 line-clamp-2">{hand.description}</p>
|
|
||||||
|
|
||||||
{/* Requirements Summary (if any unmet) */}
|
|
||||||
{hasUnmetRequirements && (
|
|
||||||
<div className="mb-3 p-2 bg-orange-50 dark:bg-orange-900/20 rounded border border-orange-200 dark:border-orange-800">
|
|
||||||
<div className="flex items-center gap-2 text-orange-700 dark:text-orange-400 text-xs font-medium">
|
|
||||||
<AlertTriangle className="w-3.5 h-3.5" />
|
|
||||||
<span>部分环境要求未满足</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Meta Info */}
|
|
||||||
<div className="flex items-center gap-3 text-xs text-gray-500 dark:text-gray-400 mb-3">
|
|
||||||
{hand.toolCount !== undefined && (
|
|
||||||
<span>{hand.toolCount} 个工具</span>
|
|
||||||
)}
|
|
||||||
{hand.metricCount !== undefined && (
|
|
||||||
<span>{hand.metricCount} 个指标</span>
|
|
||||||
)}
|
|
||||||
{hand.category && (
|
|
||||||
<CategoryBadge category={hand.category} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Actions */}
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<button
|
|
||||||
onClick={() => onDetails(hand)}
|
|
||||||
className="px-3 py-1.5 text-sm border border-gray-200 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300 flex items-center gap-1"
|
|
||||||
>
|
|
||||||
详情
|
|
||||||
<ChevronRight className="w-3.5 h-3.5" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => onActivate(hand)}
|
|
||||||
disabled={!canActivate || hasUnmetRequirements || isActivating}
|
|
||||||
className="px-3 py-1.5 text-sm bg-gray-700 dark:bg-gray-600 text-white rounded-md hover:bg-gray-800 dark:hover:bg-gray-500 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-1"
|
|
||||||
>
|
|
||||||
{isActivating ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
|
||||||
激活中...
|
|
||||||
</>
|
|
||||||
) : hand.status === 'running' ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
|
||||||
运行中...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Zap className="w-3.5 h-3.5" />
|
|
||||||
激活
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// === Main HandsPanel Component ===
|
|
||||||
|
|
||||||
export function HandsPanel() {
|
|
||||||
const { hands, loadHands, triggerHand, isLoading, error: storeError, getHandDetails } = useHandStore();
|
|
||||||
const [selectedHand, setSelectedHand] = useState<Hand | null>(null);
|
|
||||||
const [activatingHandId, setActivatingHandId] = useState<string | null>(null);
|
|
||||||
const [showModal, setShowModal] = useState(false);
|
|
||||||
const [activeTab, setActiveTab] = useState<TabType>('hands');
|
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadHands();
|
|
||||||
}, [loadHands]);
|
|
||||||
|
|
||||||
const handleDetails = useCallback(async (hand: Hand) => {
|
|
||||||
// Load full details before showing modal
|
|
||||||
const details = await getHandDetails(hand.id);
|
|
||||||
setSelectedHand(details || hand);
|
|
||||||
setShowModal(true);
|
|
||||||
}, [getHandDetails]);
|
|
||||||
|
|
||||||
const handleActivate = useCallback(async (hand: Hand, params?: Record<string, unknown>) => {
|
|
||||||
setActivatingHandId(hand.id);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await triggerHand(hand.id, params);
|
|
||||||
|
|
||||||
if (result) {
|
|
||||||
toast(`Hand "${hand.name}" 已成功激活`, 'success');
|
|
||||||
// Refresh hands after activation
|
|
||||||
await loadHands();
|
|
||||||
} else {
|
|
||||||
// Check if there's an error in the store
|
|
||||||
const errorMsg = storeError || '激活失败,请检查后端连接';
|
|
||||||
console.error(`[HandsPanel] Hand activation failed:`, errorMsg);
|
|
||||||
toast(`Hand "${hand.name}" 激活失败: ${errorMsg}`, 'error');
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
||||||
console.error(`[HandsPanel] Hand activation error:`, errorMsg);
|
|
||||||
toast(`Hand "${hand.name}" 激活异常: ${errorMsg}`, 'error');
|
|
||||||
} finally {
|
|
||||||
setActivatingHandId(null);
|
|
||||||
}
|
|
||||||
}, [triggerHand, loadHands, toast, storeError]);
|
|
||||||
|
|
||||||
const handleCloseModal = useCallback(() => {
|
|
||||||
setShowModal(false);
|
|
||||||
setSelectedHand(null);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleModalActivate = useCallback(async (params?: Record<string, unknown>) => {
|
|
||||||
if (!selectedHand) return;
|
|
||||||
setShowModal(false);
|
|
||||||
await handleActivate(selectedHand, params);
|
|
||||||
}, [selectedHand, handleActivate]);
|
|
||||||
|
|
||||||
if (isLoading && hands.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">加载 Hands 中...</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hands.length === 0) {
|
|
||||||
return (
|
|
||||||
<div className="p-4 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">
|
|
||||||
<Zap className="w-6 h-6 text-gray-400" />
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-3">暂无可用的 Hands</p>
|
|
||||||
<p className="text-xs text-gray-400 dark:text-gray-500">
|
|
||||||
请连接到 ZCLAW 以查看可用的自主能力包。
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
|
|
||||||
Hands
|
|
||||||
</h2>
|
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
|
||||||
自主能力包
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => loadHands()}
|
|
||||||
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>
|
|
||||||
|
|
||||||
{/* Tabs */}
|
|
||||||
<div className="flex border-b border-gray-200 dark:border-gray-700">
|
|
||||||
<button
|
|
||||||
onClick={() => setActiveTab('hands')}
|
|
||||||
className={`flex items-center gap-1.5 px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
|
||||||
activeTab === 'hands'
|
|
||||||
? 'text-orange-600 dark:text-orange-400 border-orange-500'
|
|
||||||
: 'text-gray-500 dark:text-gray-400 border-transparent hover:text-gray-700 dark:hover:text-gray-300'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<Zap className="w-4 h-4" />
|
|
||||||
能力包
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setActiveTab('approvals')}
|
|
||||||
className={`flex items-center gap-1.5 px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
|
||||||
activeTab === 'approvals'
|
|
||||||
? 'text-orange-600 dark:text-orange-400 border-orange-500'
|
|
||||||
: 'text-gray-500 dark:text-gray-400 border-transparent hover:text-gray-700 dark:hover:text-gray-300'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<Clock className="w-4 h-4" />
|
|
||||||
审批历史
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tab Content */}
|
|
||||||
{activeTab === 'approvals' ? (
|
|
||||||
<ApprovalsPanel />
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{/* Stats */}
|
|
||||||
<div className="flex items-center gap-4 text-sm">
|
|
||||||
<span className="text-gray-500 dark:text-gray-400">
|
|
||||||
可用 <span className="font-medium text-gray-900 dark:text-white">{hands.length}</span>
|
|
||||||
</span>
|
|
||||||
<span className="text-gray-500 dark:text-gray-400">
|
|
||||||
就绪 <span className="font-medium text-green-600 dark:text-green-400">{hands.filter(h => h.status === 'idle').length}</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Hand Cards Grid */}
|
|
||||||
<div className="grid gap-3">
|
|
||||||
{hands.map((hand) => {
|
|
||||||
// Check if this is a Browser Hand
|
|
||||||
const isBrowserHand = hand.id === 'browser' || hand.name === 'Browser' || hand.name?.toLowerCase().includes('browser');
|
|
||||||
|
|
||||||
return isBrowserHand ? (
|
|
||||||
<BrowserHandCard
|
|
||||||
key={hand.id}
|
|
||||||
hand={hand}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<HandCard
|
|
||||||
key={hand.id}
|
|
||||||
hand={hand}
|
|
||||||
onDetails={handleDetails}
|
|
||||||
onActivate={handleActivate}
|
|
||||||
isActivating={activatingHandId === hand.id}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Details Modal */}
|
|
||||||
{selectedHand && (
|
|
||||||
<HandDetailsModal
|
|
||||||
hand={selectedHand}
|
|
||||||
isOpen={showModal}
|
|
||||||
onClose={handleCloseModal}
|
|
||||||
onActivate={handleModalActivate}
|
|
||||||
isActivating={activatingHandId === selectedHand.id}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default HandsPanel;
|
|
||||||
@@ -1,534 +0,0 @@
|
|||||||
/**
|
|
||||||
* PipelineResultPreview - Pipeline 执行结果预览组件
|
|
||||||
*
|
|
||||||
* 展示 Pipeline 执行完成后的结果,支持多种预览模式:
|
|
||||||
* - JSON 数据预览
|
|
||||||
* - Markdown 渲染
|
|
||||||
* - 文件下载列表
|
|
||||||
* - 课堂预览器(特定 Pipeline)
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useState } from 'react';
|
|
||||||
import {
|
|
||||||
FileText,
|
|
||||||
Download,
|
|
||||||
ExternalLink,
|
|
||||||
Copy,
|
|
||||||
Check,
|
|
||||||
Code,
|
|
||||||
File,
|
|
||||||
Presentation,
|
|
||||||
FileSpreadsheet,
|
|
||||||
X,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { PipelineRunResponse } from '../lib/pipeline-client';
|
|
||||||
import { useToast } from './ui/Toast';
|
|
||||||
import DOMPurify from 'dompurify';
|
|
||||||
import { ClassroomPreviewer, type ClassroomData } from './ClassroomPreviewer';
|
|
||||||
import { useClassroomStore } from '../store/classroomStore';
|
|
||||||
import { adaptToClassroom } from '../lib/classroom-adapter';
|
|
||||||
|
|
||||||
// === Types ===
|
|
||||||
|
|
||||||
interface PipelineResultPreviewProps {
|
|
||||||
result: PipelineRunResponse;
|
|
||||||
pipelineId: string;
|
|
||||||
onClose?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
type PreviewMode = 'auto' | 'json' | 'markdown' | 'classroom' | 'files';
|
|
||||||
|
|
||||||
// === Utility Functions ===
|
|
||||||
|
|
||||||
function getFileIcon(filename: string): React.ReactNode {
|
|
||||||
const ext = filename.split('.').pop()?.toLowerCase();
|
|
||||||
switch (ext) {
|
|
||||||
case 'pptx':
|
|
||||||
case 'ppt':
|
|
||||||
return <Presentation className="w-5 h-5 text-orange-500" />;
|
|
||||||
case 'xlsx':
|
|
||||||
case 'xls':
|
|
||||||
return <FileSpreadsheet className="w-5 h-5 text-green-500" />;
|
|
||||||
case 'pdf':
|
|
||||||
return <FileText className="w-5 h-5 text-red-500" />;
|
|
||||||
case 'html':
|
|
||||||
return <Code className="w-5 h-5 text-blue-500" />;
|
|
||||||
case 'md':
|
|
||||||
case 'markdown':
|
|
||||||
return <FileText className="w-5 h-5 text-gray-500" />;
|
|
||||||
default:
|
|
||||||
return <File className="w-5 h-5 text-gray-400" />;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatFileSize(bytes: number): string {
|
|
||||||
if (bytes < 1024) return `${bytes} B`;
|
|
||||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
||||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// === Sub-Components ===
|
|
||||||
|
|
||||||
interface FileDownloadCardProps {
|
|
||||||
file: {
|
|
||||||
name: string;
|
|
||||||
url: string;
|
|
||||||
size?: number;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function FileDownloadCard({ file }: FileDownloadCardProps) {
|
|
||||||
const handleDownload = () => {
|
|
||||||
// Create download link
|
|
||||||
const link = document.createElement('a');
|
|
||||||
link.href = file.url;
|
|
||||||
link.download = file.name;
|
|
||||||
document.body.appendChild(link);
|
|
||||||
link.click();
|
|
||||||
document.body.removeChild(link);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-3 p-3 bg-gray-50 dark:bg-gray-800 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
|
|
||||||
{getFileIcon(file.name)}
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<p className="text-sm font-medium text-gray-900 dark:text-white truncate">
|
|
||||||
{file.name}
|
|
||||||
</p>
|
|
||||||
{file.size && (
|
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
|
||||||
{formatFileSize(file.size)}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<button
|
|
||||||
onClick={() => window.open(file.url, '_blank')}
|
|
||||||
className="p-1.5 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
|
|
||||||
title="在新窗口打开"
|
|
||||||
>
|
|
||||||
<ExternalLink className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={handleDownload}
|
|
||||||
className="flex items-center gap-1 px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-md transition-colors"
|
|
||||||
>
|
|
||||||
<Download className="w-4 h-4" />
|
|
||||||
下载
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface JsonPreviewProps {
|
|
||||||
data: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
function JsonPreview({ data }: JsonPreviewProps) {
|
|
||||||
const [copied, setCopied] = useState(false);
|
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
const jsonString = JSON.stringify(data, null, 2);
|
|
||||||
|
|
||||||
const handleCopy = async () => {
|
|
||||||
await navigator.clipboard.writeText(jsonString);
|
|
||||||
setCopied(true);
|
|
||||||
toast('已复制到剪贴板', 'success');
|
|
||||||
setTimeout(() => setCopied(false), 2000);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="relative">
|
|
||||||
<button
|
|
||||||
onClick={handleCopy}
|
|
||||||
className="absolute top-2 right-2 p-1.5 bg-gray-200 dark:bg-gray-700 rounded hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors"
|
|
||||||
title="复制"
|
|
||||||
>
|
|
||||||
{copied ? <Check className="w-4 h-4 text-green-500" /> : <Copy className="w-4 h-4" />}
|
|
||||||
</button>
|
|
||||||
<pre className="p-4 bg-gray-900 text-gray-100 rounded-lg overflow-auto text-sm max-h-96">
|
|
||||||
{jsonString}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MarkdownPreviewProps {
|
|
||||||
content: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function MarkdownPreview({ content }: MarkdownPreviewProps) {
|
|
||||||
// Simple markdown rendering with XSS protection
|
|
||||||
const renderMarkdown = (md: string): string => {
|
|
||||||
// First, escape HTML entities to prevent XSS
|
|
||||||
const escaped = md
|
|
||||||
.replace(/&/g, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/"/g, '"')
|
|
||||||
.replace(/'/g, ''');
|
|
||||||
|
|
||||||
return escaped
|
|
||||||
// Headers
|
|
||||||
.replace(/^### (.*$)/gim, '<h3 class="text-lg font-semibold mt-4 mb-2">$1</h3>')
|
|
||||||
.replace(/^## (.*$)/gim, '<h2 class="text-xl font-semibold mt-4 mb-2">$1</h2>')
|
|
||||||
.replace(/^# (.*$)/gim, '<h1 class="text-2xl font-bold mt-4 mb-2">$1</h1>')
|
|
||||||
// Bold
|
|
||||||
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
|
|
||||||
// Italic
|
|
||||||
.replace(/\*(.*?)\*/g, '<em>$1</em>')
|
|
||||||
// Lists
|
|
||||||
.replace(/^- (.*$)/gim, '<li class="ml-4">$1</li>')
|
|
||||||
// Paragraphs
|
|
||||||
.replace(/\n\n/g, '</p><p class="my-2">')
|
|
||||||
// Line breaks
|
|
||||||
.replace(/\n/g, '<br>');
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="prose dark:prose-invert max-w-none p-4 bg-white dark:bg-gray-800 rounded-lg"
|
|
||||||
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(renderMarkdown(content)) }}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// === Main Component ===
|
|
||||||
|
|
||||||
export function PipelineResultPreview({
|
|
||||||
result,
|
|
||||||
pipelineId,
|
|
||||||
onClose,
|
|
||||||
}: PipelineResultPreviewProps) {
|
|
||||||
const [mode, setMode] = useState<PreviewMode>('auto');
|
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
// Determine the best preview mode
|
|
||||||
const outputs = result.outputs as Record<string, unknown> | undefined;
|
|
||||||
const exportFiles = (outputs?.export_files as Array<{ name: string; url: string; size?: number }>) || [];
|
|
||||||
|
|
||||||
// Check if this is a classroom pipeline
|
|
||||||
const isClassroom = pipelineId === 'classroom-generator' || pipelineId.includes('classroom');
|
|
||||||
|
|
||||||
// Auto-detect preview mode
|
|
||||||
const autoMode: PreviewMode = isClassroom ? 'classroom' :
|
|
||||||
exportFiles.length > 0 ? 'files' :
|
|
||||||
typeof outputs === 'object' ? 'json' : 'json';
|
|
||||||
|
|
||||||
const activeMode = mode === 'auto' ? autoMode : mode;
|
|
||||||
|
|
||||||
// Handle classroom export
|
|
||||||
const handleClassroomExport = (format: 'pptx' | 'html' | 'pdf', data: ClassroomData) => {
|
|
||||||
toast(`正在导出 ${format.toUpperCase()} 格式...`, 'info');
|
|
||||||
|
|
||||||
// Create downloadable content based on format
|
|
||||||
setTimeout(() => {
|
|
||||||
try {
|
|
||||||
if (format === 'html') {
|
|
||||||
// Generate HTML content
|
|
||||||
const htmlContent = generateClassroomHTML(data);
|
|
||||||
downloadFile(htmlContent, `${data.title}.html`, 'text/html');
|
|
||||||
toast('HTML 导出成功', 'success');
|
|
||||||
} else if (format === 'pptx') {
|
|
||||||
// For PPTX, we would need a library like pptxgenjs
|
|
||||||
// For now, export as JSON that can be converted
|
|
||||||
const pptxData = JSON.stringify(data, null, 2);
|
|
||||||
downloadFile(pptxData, `${data.title}.slides.json`, 'application/json');
|
|
||||||
toast('幻灯片数据已导出(JSON格式)', 'success');
|
|
||||||
} else if (format === 'pdf') {
|
|
||||||
// For PDF, we would need a library like jspdf
|
|
||||||
// For now, export as printable HTML
|
|
||||||
const htmlContent = generatePrintableHTML(data);
|
|
||||||
const printWindow = window.open('', '_blank');
|
|
||||||
if (printWindow) {
|
|
||||||
printWindow.document.write(htmlContent);
|
|
||||||
printWindow.document.close();
|
|
||||||
printWindow.print();
|
|
||||||
toast('已打开打印预览', 'success');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
const errorMsg = err instanceof Error ? err.message : '导出失败';
|
|
||||||
toast(`导出失败: ${errorMsg}`, 'error');
|
|
||||||
}
|
|
||||||
}, 500);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Render based on mode
|
|
||||||
const renderContent = () => {
|
|
||||||
switch (activeMode) {
|
|
||||||
case 'json':
|
|
||||||
return <JsonPreview data={outputs} />;
|
|
||||||
|
|
||||||
case 'markdown': {
|
|
||||||
const mdContent = (outputs?.summary || outputs?.report || JSON.stringify(outputs, null, 2)) as string;
|
|
||||||
return <MarkdownPreview content={mdContent} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'classroom': {
|
|
||||||
// Convert outputs to ClassroomData format
|
|
||||||
const classroomData: ClassroomData | null = outputs ? {
|
|
||||||
id: result.pipelineId || 'classroom',
|
|
||||||
title: (outputs.title as string) || '课堂内容',
|
|
||||||
subject: (outputs.subject as string) || '通用',
|
|
||||||
difficulty: (outputs.difficulty as '初级' | '中级' | '高级') || '中级',
|
|
||||||
duration: (outputs.duration as number) || 30,
|
|
||||||
scenes: Array.isArray(outputs.scenes) ? (outputs.scenes as ClassroomData['scenes']) : [],
|
|
||||||
outline: (outputs.outline as ClassroomData['outline']) || { sections: [] },
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
} : null;
|
|
||||||
|
|
||||||
if (classroomData && classroomData.scenes.length > 0) {
|
|
||||||
return (
|
|
||||||
<div className="-m-4">
|
|
||||||
<ClassroomPreviewer
|
|
||||||
data={classroomData}
|
|
||||||
onExport={(format) => {
|
|
||||||
// Handle export
|
|
||||||
handleClassroomExport(format, classroomData);
|
|
||||||
}}
|
|
||||||
onOpenFullPlayer={() => {
|
|
||||||
const classroom = adaptToClassroom(classroomData);
|
|
||||||
useClassroomStore.getState().setActiveClassroom(classroom);
|
|
||||||
useClassroomStore.getState().openClassroom();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="text-center py-8 text-gray-500">
|
|
||||||
<Presentation className="w-12 h-12 mx-auto mb-3 text-gray-400" />
|
|
||||||
<p>无法解析课堂数据</p>
|
|
||||||
<p className="text-sm mt-2">您可以在下方下载生成的文件</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
default:
|
|
||||||
return <JsonPreview data={outputs} />;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-xl max-w-3xl w-full max-h-[90vh] overflow-hidden">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
|
|
||||||
Pipeline 执行完成
|
|
||||||
</h2>
|
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
|
||||||
{result.pipelineId} · {result.status === 'completed' ? '成功' : result.status}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{onClose && (
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
className="p-1 hover:bg-gray-100 dark:hover:bg-gray-800 rounded"
|
|
||||||
>
|
|
||||||
<X className="w-5 h-5 text-gray-500" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Mode Tabs */}
|
|
||||||
<div className="flex items-center gap-2 p-2 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800">
|
|
||||||
<button
|
|
||||||
onClick={() => setMode('auto')}
|
|
||||||
className={`px-3 py-1.5 text-sm rounded-md transition-colors ${
|
|
||||||
mode === 'auto'
|
|
||||||
? 'bg-white dark:bg-gray-700 text-blue-600 dark:text-blue-400 shadow-sm'
|
|
||||||
: 'text-gray-600 dark:text-gray-300 hover:bg-white dark:hover:bg-gray-700'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
自动
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setMode('json')}
|
|
||||||
className={`px-3 py-1.5 text-sm rounded-md transition-colors ${
|
|
||||||
mode === 'json'
|
|
||||||
? 'bg-white dark:bg-gray-700 text-blue-600 dark:text-blue-400 shadow-sm'
|
|
||||||
: 'text-gray-600 dark:text-gray-300 hover:bg-white dark:hover:bg-gray-700'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
JSON
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setMode('markdown')}
|
|
||||||
className={`px-3 py-1.5 text-sm rounded-md transition-colors ${
|
|
||||||
mode === 'markdown'
|
|
||||||
? 'bg-white dark:bg-gray-700 text-blue-600 dark:text-blue-400 shadow-sm'
|
|
||||||
: 'text-gray-600 dark:text-gray-300 hover:bg-white dark:hover:bg-gray-700'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Markdown
|
|
||||||
</button>
|
|
||||||
{isClassroom && (
|
|
||||||
<button
|
|
||||||
onClick={() => setMode('classroom')}
|
|
||||||
className={`px-3 py-1.5 text-sm rounded-md transition-colors ${
|
|
||||||
mode === 'classroom'
|
|
||||||
? 'bg-white dark:bg-gray-700 text-blue-600 dark:text-blue-400 shadow-sm'
|
|
||||||
: 'text-gray-600 dark:text-gray-300 hover:bg-white dark:hover:bg-gray-700'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
课堂预览
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Content */}
|
|
||||||
<div className="p-4 overflow-auto max-h-96">
|
|
||||||
{renderContent()}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Export Files */}
|
|
||||||
{exportFiles.length > 0 && (
|
|
||||||
<div className="p-4 border-t border-gray-200 dark:border-gray-700">
|
|
||||||
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
|
|
||||||
导出文件 ({exportFiles.length})
|
|
||||||
</h3>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{exportFiles.map((file, index) => (
|
|
||||||
<FileDownloadCard key={index} file={file} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Footer */}
|
|
||||||
<div className="flex items-center justify-end gap-3 p-4 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800">
|
|
||||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
|
||||||
执行时间: {new Date(result.startedAt).toLocaleString()}
|
|
||||||
</span>
|
|
||||||
{onClose && (
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
className="px-4 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-md"
|
|
||||||
>
|
|
||||||
关闭
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default PipelineResultPreview;
|
|
||||||
|
|
||||||
// === Helper Functions ===
|
|
||||||
|
|
||||||
function downloadFile(content: string, filename: string, mimeType: string) {
|
|
||||||
const blob = new Blob([content], { type: mimeType });
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const link = document.createElement('a');
|
|
||||||
link.href = url;
|
|
||||||
link.download = filename;
|
|
||||||
document.body.appendChild(link);
|
|
||||||
link.click();
|
|
||||||
document.body.removeChild(link);
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
}
|
|
||||||
|
|
||||||
function generateClassroomHTML(data: ClassroomData): string {
|
|
||||||
const scenesHTML = data.scenes.map((scene, index) => `
|
|
||||||
<section class="slide ${index === 0 ? 'active' : ''}" data-index="${index}">
|
|
||||||
<div class="slide-content ${scene.type}">
|
|
||||||
<h2>${scene.content.heading || scene.title}</h2>
|
|
||||||
${scene.content.bullets ? `
|
|
||||||
<ul>
|
|
||||||
${scene.content.bullets.map(b => `<li>${b}</li>`).join('')}
|
|
||||||
</ul>
|
|
||||||
` : ''}
|
|
||||||
${scene.narration ? `<p class="narration">${scene.narration}</p>` : ''}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
`).join('');
|
|
||||||
|
|
||||||
return `<!DOCTYPE html>
|
|
||||||
<html lang="zh-CN">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>${data.title}</title>
|
|
||||||
<style>
|
|
||||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
||||||
body {
|
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
||||||
background: linear-gradient(135deg, #3b82f6, #8b5cf6, #6366f1);
|
|
||||||
min-height: 100vh;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
.presentation {
|
|
||||||
max-width: 1200px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 2rem;
|
|
||||||
}
|
|
||||||
header {
|
|
||||||
text-align: center;
|
|
||||||
padding: 2rem 0;
|
|
||||||
border-bottom: 1px solid rgba(255,255,255,0.2);
|
|
||||||
margin-bottom: 2rem;
|
|
||||||
}
|
|
||||||
h1 { font-size: 2.5rem; margin-bottom: 0.5rem; }
|
|
||||||
.meta { opacity: 0.8; font-size: 0.9rem; }
|
|
||||||
.slide {
|
|
||||||
background: rgba(255,255,255,0.1);
|
|
||||||
border-radius: 1rem;
|
|
||||||
padding: 2rem;
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
backdrop-filter: blur(10px);
|
|
||||||
}
|
|
||||||
.slide h2 { font-size: 1.8rem; margin-bottom: 1rem; }
|
|
||||||
.slide ul { list-style: none; padding-left: 1rem; }
|
|
||||||
.slide li { margin-bottom: 0.75rem; font-size: 1.1rem; }
|
|
||||||
.slide li::before { content: '•'; color: #60a5fa; margin-right: 0.5rem; }
|
|
||||||
.narration {
|
|
||||||
background: rgba(0,0,0,0.3);
|
|
||||||
padding: 1rem;
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
margin-top: 1rem;
|
|
||||||
font-style: italic;
|
|
||||||
opacity: 0.9;
|
|
||||||
}
|
|
||||||
.title .slide-content {
|
|
||||||
text-align: center;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: center;
|
|
||||||
min-height: 300px;
|
|
||||||
}
|
|
||||||
.quiz { background: rgba(34, 197, 94, 0.2); }
|
|
||||||
.summary { background: rgba(168, 85, 247, 0.2); }
|
|
||||||
footer {
|
|
||||||
text-align: center;
|
|
||||||
padding: 2rem 0;
|
|
||||||
border-top: 1px solid rgba(255,255,255,0.2);
|
|
||||||
margin-top: 2rem;
|
|
||||||
opacity: 0.6;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="presentation">
|
|
||||||
<header>
|
|
||||||
<h1>${data.title}</h1>
|
|
||||||
<p class="meta">${data.subject} · ${data.difficulty} · ${data.duration} 分钟</p>
|
|
||||||
</header>
|
|
||||||
<main>
|
|
||||||
${scenesHTML}
|
|
||||||
</main>
|
|
||||||
<footer>
|
|
||||||
<p>由 ZCLAW 课堂生成器创建</p>
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function generatePrintableHTML(data: ClassroomData): string {
|
|
||||||
return generateClassroomHTML(data);
|
|
||||||
}
|
|
||||||
@@ -1,567 +0,0 @@
|
|||||||
/**
|
|
||||||
* PipelinesPanel - Pipeline Discovery and Execution UI
|
|
||||||
*
|
|
||||||
* Displays available Pipelines (DSL-based workflows) with
|
|
||||||
* category filtering, search, and execution capabilities.
|
|
||||||
*
|
|
||||||
* Pipelines orchestrate Skills and Hands to accomplish complex tasks.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
|
||||||
import {
|
|
||||||
Play,
|
|
||||||
RefreshCw,
|
|
||||||
Search,
|
|
||||||
Loader2,
|
|
||||||
XCircle,
|
|
||||||
Package,
|
|
||||||
Filter,
|
|
||||||
X,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { PipelineResultPreview } from './PipelineResultPreview';
|
|
||||||
import {
|
|
||||||
PipelineClient,
|
|
||||||
PipelineInfo,
|
|
||||||
PipelineRunResponse,
|
|
||||||
usePipelines,
|
|
||||||
validateInputs,
|
|
||||||
getDefaultForType,
|
|
||||||
formatInputType,
|
|
||||||
} from '../lib/pipeline-client';
|
|
||||||
import { useToast } from './ui/Toast';
|
|
||||||
import { saasClient } from '../lib/saas-client';
|
|
||||||
|
|
||||||
// === Category Badge Component ===
|
|
||||||
|
|
||||||
const CATEGORY_CONFIG: Record<string, { label: string; className: string }> = {
|
|
||||||
education: { label: '教育', className: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400' },
|
|
||||||
marketing: { label: '营销', className: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400' },
|
|
||||||
legal: { label: '法律', className: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400' },
|
|
||||||
productivity: { label: '生产力', className: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400' },
|
|
||||||
research: { label: '研究', className: 'bg-cyan-100 text-cyan-700 dark:bg-cyan-900/30 dark:text-cyan-400' },
|
|
||||||
sales: { label: '销售', className: 'bg-rose-100 text-rose-700 dark:bg-rose-900/30 dark:text-rose-400' },
|
|
||||||
hr: { label: '人力', className: 'bg-teal-100 text-teal-700 dark:bg-teal-900/30 dark:text-teal-400' },
|
|
||||||
finance: { label: '财务', className: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400' },
|
|
||||||
default: { label: '其他', className: 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-400' },
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
function CategoryBadge({ category }: { category: string }) {
|
|
||||||
const config = CATEGORY_CONFIG[category] || CATEGORY_CONFIG.default;
|
|
||||||
return (
|
|
||||||
<span className={`px-2 py-0.5 rounded text-xs font-medium ${config.className}`}>
|
|
||||||
{config.label}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// === Pipeline Card Component ===
|
|
||||||
|
|
||||||
interface PipelineCardProps {
|
|
||||||
pipeline: PipelineInfo;
|
|
||||||
onRun: (pipeline: PipelineInfo) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function PipelineCard({ pipeline, onRun }: PipelineCardProps) {
|
|
||||||
return (
|
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4 hover:shadow-md transition-shadow">
|
|
||||||
<div className="flex items-start justify-between mb-3">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<span className="text-2xl">{pipeline.icon}</span>
|
|
||||||
<div>
|
|
||||||
<h3 className="font-medium text-gray-900 dark:text-white">
|
|
||||||
{pipeline.displayName}
|
|
||||||
</h3>
|
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
|
||||||
{pipeline.id} · v{pipeline.version}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<CategoryBadge category={pipeline.category} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-300 mb-3 line-clamp-2">
|
|
||||||
{pipeline.description}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{pipeline.tags.length > 0 && (
|
|
||||||
<div className="flex flex-wrap gap-1 mb-3">
|
|
||||||
{pipeline.tags.slice(0, 3).map((tag) => (
|
|
||||||
<span
|
|
||||||
key={tag}
|
|
||||||
className="px-1.5 py-0.5 bg-gray-100 dark:bg-gray-700 rounded text-xs text-gray-600 dark:text-gray-300"
|
|
||||||
>
|
|
||||||
{tag}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
{pipeline.tags.length > 3 && (
|
|
||||||
<span className="px-1.5 py-0.5 text-xs text-gray-400">
|
|
||||||
+{pipeline.tags.length - 3}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between pt-2 border-t border-gray-100 dark:border-gray-700">
|
|
||||||
<span className="text-xs text-gray-400">
|
|
||||||
{pipeline.inputs.length} 个输入参数
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
onClick={() => onRun(pipeline)}
|
|
||||||
className="flex items-center gap-1.5 px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-md transition-colors"
|
|
||||||
>
|
|
||||||
<Play className="w-4 h-4" />
|
|
||||||
运行
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// === Pipeline Run Modal ===
|
|
||||||
|
|
||||||
interface RunModalProps {
|
|
||||||
pipeline: PipelineInfo;
|
|
||||||
onClose: () => void;
|
|
||||||
onComplete: (result: PipelineRunResponse) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function RunModal({ pipeline, onClose, onComplete }: RunModalProps) {
|
|
||||||
const [values, setValues] = useState<Record<string, unknown>>(() => {
|
|
||||||
const defaults: Record<string, unknown> = {};
|
|
||||||
pipeline.inputs.forEach((input) => {
|
|
||||||
defaults[input.name] = input.default ?? getDefaultForType(input.inputType);
|
|
||||||
});
|
|
||||||
return defaults;
|
|
||||||
});
|
|
||||||
const [errors, setErrors] = useState<string[]>([]);
|
|
||||||
const [running, setRunning] = useState(false);
|
|
||||||
const [progress, setProgress] = useState<PipelineRunResponse | null>(null);
|
|
||||||
|
|
||||||
const handleInputChange = (name: string, value: unknown) => {
|
|
||||||
setValues((prev) => ({ ...prev, [name]: value }));
|
|
||||||
setErrors([]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRun = async () => {
|
|
||||||
// Validate inputs
|
|
||||||
const validation = validateInputs(pipeline.inputs, values);
|
|
||||||
if (!validation.valid) {
|
|
||||||
setErrors(validation.errors);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setRunning(true);
|
|
||||||
setProgress(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await PipelineClient.runAndWait(
|
|
||||||
{ pipelineId: pipeline.id, inputs: values },
|
|
||||||
(p) => setProgress(p)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (result.status === 'completed') {
|
|
||||||
onComplete(result);
|
|
||||||
} else if (result.error) {
|
|
||||||
setErrors([result.error]);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
setErrors([err instanceof Error ? err.message : String(err)]);
|
|
||||||
} finally {
|
|
||||||
setRunning(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderInput = (input: typeof pipeline.inputs[0]) => {
|
|
||||||
const value = values[input.name];
|
|
||||||
|
|
||||||
switch (input.inputType) {
|
|
||||||
case 'string':
|
|
||||||
case 'text':
|
|
||||||
return input.inputType === 'text' ? (
|
|
||||||
<textarea
|
|
||||||
value={(value as string) || ''}
|
|
||||||
onChange={(e) => handleInputChange(input.name, e.target.value)}
|
|
||||||
placeholder={input.placeholder}
|
|
||||||
rows={3}
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={(value as string) || ''}
|
|
||||||
onChange={(e) => handleInputChange(input.name, e.target.value)}
|
|
||||||
placeholder={input.placeholder}
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
case 'number':
|
|
||||||
return (
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={(value as number) ?? ''}
|
|
||||||
onChange={(e) => handleInputChange(input.name, e.target.valueAsNumber || 0)}
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
case 'boolean':
|
|
||||||
return (
|
|
||||||
<label className="flex items-center gap-2">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={(value as boolean) || false}
|
|
||||||
onChange={(e) => handleInputChange(input.name, e.target.checked)}
|
|
||||||
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
|
||||||
/>
|
|
||||||
<span className="text-sm text-gray-600 dark:text-gray-300">启用</span>
|
|
||||||
</label>
|
|
||||||
);
|
|
||||||
|
|
||||||
case 'select':
|
|
||||||
return (
|
|
||||||
<select
|
|
||||||
value={(value as string) || ''}
|
|
||||||
onChange={(e) => handleInputChange(input.name, e.target.value)}
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
|
|
||||||
>
|
|
||||||
<option value="">请选择...</option>
|
|
||||||
{input.options.map((opt) => (
|
|
||||||
<option key={opt} value={opt}>
|
|
||||||
{opt}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
);
|
|
||||||
|
|
||||||
case 'multi-select':
|
|
||||||
return (
|
|
||||||
<div className="space-y-2">
|
|
||||||
{input.options.map((opt) => (
|
|
||||||
<label key={opt} className="flex items-center gap-2">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={((value as string[]) || []).includes(opt)}
|
|
||||||
onChange={(e) => {
|
|
||||||
const current = (value as string[]) || [];
|
|
||||||
const updated = e.target.checked
|
|
||||||
? [...current, opt]
|
|
||||||
: current.filter((v) => v !== opt);
|
|
||||||
handleInputChange(input.name, updated);
|
|
||||||
}}
|
|
||||||
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
|
||||||
/>
|
|
||||||
<span className="text-sm text-gray-600 dark:text-gray-300">{opt}</span>
|
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
default:
|
|
||||||
return (
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={(value as string) || ''}
|
|
||||||
onChange={(e) => handleInputChange(input.name, e.target.value)}
|
|
||||||
placeholder={input.placeholder}
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-lg w-full mx-4 max-h-[90vh] overflow-y-auto">
|
|
||||||
{/* 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">
|
|
||||||
<span className="text-2xl">{pipeline.icon}</span>
|
|
||||||
<div>
|
|
||||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
|
|
||||||
{pipeline.displayName}
|
|
||||||
</h2>
|
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
|
||||||
{pipeline.description}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
className="p-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded"
|
|
||||||
>
|
|
||||||
<X className="w-5 h-5 text-gray-500" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Form */}
|
|
||||||
<div className="p-4 space-y-4">
|
|
||||||
{pipeline.inputs.map((input) => (
|
|
||||||
<div key={input.name}>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
|
||||||
{input.label}
|
|
||||||
{input.required && <span className="text-red-500 ml-1">*</span>}
|
|
||||||
<span className="text-xs text-gray-400 ml-2">
|
|
||||||
({formatInputType(input.inputType)})
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
{renderInput(input)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{errors.length > 0 && (
|
|
||||||
<div className="p-3 bg-red-50 dark:bg-red-900/20 rounded-md">
|
|
||||||
{errors.map((error, i) => (
|
|
||||||
<p key={i} className="text-sm text-red-600 dark:text-red-400">
|
|
||||||
{error}
|
|
||||||
</p>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Progress */}
|
|
||||||
{running && progress && (
|
|
||||||
<div className="p-3 bg-blue-50 dark:bg-blue-900/20 rounded-md">
|
|
||||||
<div className="flex items-center gap-2 mb-2">
|
|
||||||
<Loader2 className="w-4 h-4 animate-spin text-blue-600" />
|
|
||||||
<span className="text-sm font-medium text-blue-700 dark:text-blue-300">
|
|
||||||
{progress.message || '运行中...'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="w-full bg-blue-200 dark:bg-blue-800 rounded-full h-2">
|
|
||||||
<div
|
|
||||||
className="bg-blue-600 h-2 rounded-full transition-all"
|
|
||||||
style={{ width: `${progress.percentage}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Footer */}
|
|
||||||
<div className="flex items-center justify-end gap-3 p-4 border-t border-gray-200 dark:border-gray-700">
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
disabled={running}
|
|
||||||
className="px-4 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md disabled:opacity-50"
|
|
||||||
>
|
|
||||||
取消
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={handleRun}
|
|
||||||
disabled={running}
|
|
||||||
className="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-md disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{running ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="w-4 h-4 animate-spin" />
|
|
||||||
运行中...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Play className="w-4 h-4" />
|
|
||||||
开始运行
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// === Main Pipelines Panel ===
|
|
||||||
|
|
||||||
export function PipelinesPanel() {
|
|
||||||
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
|
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
|
||||||
const [selectedPipeline, setSelectedPipeline] = useState<PipelineInfo | null>(null);
|
|
||||||
const [runResult, setRunResult] = useState<{ result: PipelineRunResponse; pipeline: PipelineInfo } | null>(null);
|
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
// Subscribe to pipeline-complete push events (for background completion)
|
|
||||||
useEffect(() => {
|
|
||||||
let unlisten: (() => void) | undefined;
|
|
||||||
PipelineClient.onComplete((event) => {
|
|
||||||
// Only show notification if we're not already tracking this run
|
|
||||||
// (the polling path handles in-flight runs via handleRunComplete)
|
|
||||||
if (selectedPipeline?.id === event.pipelineId) return;
|
|
||||||
if (event.status === 'completed') {
|
|
||||||
toast(`Pipeline "${event.pipelineId}" 后台执行完成`, 'success');
|
|
||||||
} else if (event.status === 'failed') {
|
|
||||||
toast(`Pipeline "${event.pipelineId}" 后台执行失败: ${event.error ?? ''}`, 'error');
|
|
||||||
}
|
|
||||||
}).then((fn) => { unlisten = fn; });
|
|
||||||
return () => { unlisten?.(); };
|
|
||||||
}, [selectedPipeline, toast]);
|
|
||||||
|
|
||||||
// Fetch all pipelines without filtering
|
|
||||||
const { pipelines, loading, error, refresh } = usePipelines({});
|
|
||||||
|
|
||||||
// Get unique categories from ALL pipelines (not filtered)
|
|
||||||
const categories = Array.from(
|
|
||||||
new Set(pipelines.map((p) => p.category).filter(Boolean))
|
|
||||||
);
|
|
||||||
|
|
||||||
// Filter pipelines by selected category and search
|
|
||||||
const filteredPipelines = pipelines.filter((p) => {
|
|
||||||
// Category filter
|
|
||||||
if (selectedCategory && p.category !== selectedCategory) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
// Search filter
|
|
||||||
if (searchQuery) {
|
|
||||||
const query = searchQuery.toLowerCase();
|
|
||||||
return (
|
|
||||||
p.displayName.toLowerCase().includes(query) ||
|
|
||||||
p.description.toLowerCase().includes(query) ||
|
|
||||||
p.tags.some((t) => t.toLowerCase().includes(query))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
const handleRunPipeline = (pipeline: PipelineInfo) => {
|
|
||||||
setSelectedPipeline(pipeline);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRunComplete = (result: PipelineRunResponse) => {
|
|
||||||
setSelectedPipeline(null);
|
|
||||||
if (result.status === 'completed') {
|
|
||||||
toast('Pipeline 执行完成', 'success');
|
|
||||||
setRunResult({ result, pipeline: selectedPipeline! });
|
|
||||||
|
|
||||||
// Report pipeline execution to billing (fire-and-forget)
|
|
||||||
try {
|
|
||||||
if (saasClient.isAuthenticated()) {
|
|
||||||
saasClient.reportUsageFireAndForget('pipeline_runs');
|
|
||||||
}
|
|
||||||
} catch { /* billing reporting must never block */ }
|
|
||||||
} else {
|
|
||||||
toast(`Pipeline 执行失败: ${result.error}`, 'error');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="h-full flex flex-col">
|
|
||||||
{/* 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-2">
|
|
||||||
<Package className="w-5 h-5 text-gray-500" />
|
|
||||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
|
|
||||||
Pipelines
|
|
||||||
</h2>
|
|
||||||
<span className="px-2 py-0.5 bg-gray-100 dark:bg-gray-700 rounded-full text-xs text-gray-600 dark:text-gray-300">
|
|
||||||
{pipelines.length}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={refresh}
|
|
||||||
disabled={loading}
|
|
||||||
className="flex items-center gap-1.5 px-3 py-1.5 text-sm text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md"
|
|
||||||
>
|
|
||||||
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
|
|
||||||
刷新
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Filters */}
|
|
||||||
<div className="p-4 border-b border-gray-200 dark:border-gray-700 space-y-3">
|
|
||||||
{/* Search */}
|
|
||||||
<div className="relative">
|
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="搜索 Pipelines..."
|
|
||||||
value={searchQuery}
|
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
|
||||||
className="w-full pl-9 pr-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Category filters */}
|
|
||||||
{categories.length > 0 && (
|
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
|
||||||
<Filter className="w-4 h-4 text-gray-400" />
|
|
||||||
<button
|
|
||||||
onClick={() => setSelectedCategory(null)}
|
|
||||||
className={`px-2 py-1 text-xs rounded-md transition-colors ${
|
|
||||||
selectedCategory === null
|
|
||||||
? 'bg-blue-600 text-white'
|
|
||||||
: 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
全部
|
|
||||||
</button>
|
|
||||||
{categories.map((cat) => (
|
|
||||||
<button
|
|
||||||
key={cat}
|
|
||||||
onClick={() => setSelectedCategory(cat)}
|
|
||||||
className={`px-2 py-1 text-xs rounded-md transition-colors ${
|
|
||||||
selectedCategory === cat
|
|
||||||
? 'bg-blue-600 text-white'
|
|
||||||
: 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{CATEGORY_CONFIG[cat]?.label || cat}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Content */}
|
|
||||||
<div className="flex-1 overflow-y-auto p-4">
|
|
||||||
{loading ? (
|
|
||||||
<div className="flex items-center justify-center h-32">
|
|
||||||
<Loader2 className="w-6 h-6 animate-spin text-gray-400" />
|
|
||||||
</div>
|
|
||||||
) : error ? (
|
|
||||||
<div className="text-center py-8 text-red-500">
|
|
||||||
<XCircle className="w-8 h-8 mx-auto mb-2" />
|
|
||||||
<p>{error}</p>
|
|
||||||
</div>
|
|
||||||
) : filteredPipelines.length === 0 ? (
|
|
||||||
<div className="text-center py-8 text-gray-500">
|
|
||||||
<Package className="w-8 h-8 mx-auto mb-2" />
|
|
||||||
<p>没有找到 Pipeline</p>
|
|
||||||
{searchQuery && <p className="text-sm mt-1">尝试修改搜索条件</p>}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
{filteredPipelines.map((pipeline) => (
|
|
||||||
<PipelineCard
|
|
||||||
key={pipeline.id}
|
|
||||||
pipeline={pipeline}
|
|
||||||
onRun={handleRunPipeline}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Run Modal */}
|
|
||||||
{selectedPipeline && (
|
|
||||||
<RunModal
|
|
||||||
pipeline={selectedPipeline}
|
|
||||||
onClose={() => setSelectedPipeline(null)}
|
|
||||||
onComplete={handleRunComplete}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Result Preview */}
|
|
||||||
{runResult && (
|
|
||||||
<PipelineResultPreview
|
|
||||||
result={runResult.result}
|
|
||||||
pipelineId={runResult.pipeline.id}
|
|
||||||
onClose={() => setRunResult(null)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default PipelinesPanel;
|
|
||||||
@@ -1,173 +0,0 @@
|
|||||||
/**
|
|
||||||
* SubscriptionPanel — 当前订阅详情面板
|
|
||||||
*
|
|
||||||
* 展示当前计划、试用/到期状态、用量配额和操作入口。
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useEffect } from 'react';
|
|
||||||
import { useSaaSStore } from '../../store/saasStore';
|
|
||||||
import {
|
|
||||||
CheckCircle,
|
|
||||||
Clock,
|
|
||||||
AlertTriangle,
|
|
||||||
Loader2,
|
|
||||||
CreditCard,
|
|
||||||
Crown,
|
|
||||||
} from 'lucide-react';
|
|
||||||
|
|
||||||
const PLAN_LABELS: Record<string, string> = {
|
|
||||||
free: '免费版',
|
|
||||||
pro: '专业版',
|
|
||||||
team: '团队版',
|
|
||||||
};
|
|
||||||
|
|
||||||
const PLAN_COLORS: Record<string, string> = {
|
|
||||||
free: '#8c8c8c',
|
|
||||||
pro: '#863bff',
|
|
||||||
team: '#47bfff',
|
|
||||||
};
|
|
||||||
|
|
||||||
function UsageBar({ label, current, max }: { label: string; current: number; max: number | null }) {
|
|
||||||
const pct = max ? Math.min((current / max) * 100, 100) : 0;
|
|
||||||
const displayMax = max ? max.toLocaleString() : '∞';
|
|
||||||
const barColor = pct >= 90 ? 'bg-red-500' : pct >= 70 ? 'bg-amber-500' : 'bg-emerald-500';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mb-3">
|
|
||||||
<div className="flex justify-between text-xs text-gray-500 mb-1">
|
|
||||||
<span>{label}</span>
|
|
||||||
<span>{current.toLocaleString()} / {displayMax}</span>
|
|
||||||
</div>
|
|
||||||
<div className="h-2 bg-gray-200 rounded-full overflow-hidden">
|
|
||||||
<div
|
|
||||||
className={`h-full rounded-full transition-all duration-500 ${barColor}`}
|
|
||||||
style={{ width: `${Math.max(pct, 1)}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SubscriptionPanel() {
|
|
||||||
const subscription = useSaaSStore((s) => s.subscription);
|
|
||||||
const billingLoading = useSaaSStore((s) => s.billingLoading);
|
|
||||||
const fetchBillingOverview = useSaaSStore((s) => s.fetchBillingOverview);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchBillingOverview().catch(() => {});
|
|
||||||
}, [fetchBillingOverview]);
|
|
||||||
|
|
||||||
if (billingLoading && !subscription) {
|
|
||||||
return (
|
|
||||||
<div className="flex justify-center py-12">
|
|
||||||
<Loader2 className="w-6 h-6 animate-spin text-emerald-600" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const plan = subscription?.plan;
|
|
||||||
const sub = subscription?.subscription;
|
|
||||||
const usage = subscription?.usage;
|
|
||||||
const planName = plan?.name || 'free';
|
|
||||||
const color = PLAN_COLORS[planName] || '#666';
|
|
||||||
const label = PLAN_LABELS[planName] || planName;
|
|
||||||
|
|
||||||
// Trial / expiry status
|
|
||||||
const isTrialing = sub?.status === 'trialing';
|
|
||||||
const isActive = sub?.status === 'active';
|
|
||||||
const trialEnd = sub?.trial_end ? new Date(sub.trial_end) : null;
|
|
||||||
const periodEnd = sub?.current_period_end ? new Date(sub.current_period_end) : null;
|
|
||||||
|
|
||||||
const now = Date.now();
|
|
||||||
const daysLeft = trialEnd
|
|
||||||
? Math.max(0, Math.ceil((trialEnd.getTime() - now) / (1000 * 60 * 60 * 24)))
|
|
||||||
: periodEnd
|
|
||||||
? Math.max(0, Math.ceil((periodEnd.getTime() - now) / (1000 * 60 * 60 * 24)))
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const showExpiryWarning = daysLeft !== null && daysLeft <= 3;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-5">
|
|
||||||
{/* Plan badge */}
|
|
||||||
<div className="bg-white rounded-xl border border-gray-200 p-5 shadow-sm">
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div
|
|
||||||
className="w-10 h-10 rounded-lg flex items-center justify-center text-white"
|
|
||||||
style={{ background: color }}
|
|
||||||
>
|
|
||||||
<Crown className="w-5 h-5" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h2 className="text-lg font-bold text-gray-900">{label}</h2>
|
|
||||||
{isTrialing && (
|
|
||||||
<span className="inline-flex items-center gap-1 text-xs text-amber-600 bg-amber-50 px-2 py-0.5 rounded-full">
|
|
||||||
<Clock className="w-3 h-3" />
|
|
||||||
试用中
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{isActive && (
|
|
||||||
<span className="inline-flex items-center gap-1 text-xs text-emerald-600 bg-emerald-50 px-2 py-0.5 rounded-full">
|
|
||||||
<CheckCircle className="w-3 h-3" />
|
|
||||||
已激活
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<CreditCard className="w-5 h-5 text-gray-400" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Expiry / trial warning */}
|
|
||||||
{showExpiryWarning && (
|
|
||||||
<div className={`flex items-center gap-2 p-3 rounded-lg text-sm mb-3 ${
|
|
||||||
isTrialing
|
|
||||||
? 'bg-amber-50 text-amber-700 border border-amber-200'
|
|
||||||
: 'bg-red-50 text-red-700 border border-red-200'
|
|
||||||
}`}>
|
|
||||||
<AlertTriangle className="w-4 h-4 flex-shrink-0" />
|
|
||||||
<span>
|
|
||||||
{isTrialing
|
|
||||||
? `试用还剩 ${daysLeft} 天,请尽快升级以保留 Pro 功能`
|
|
||||||
: `订阅将在 ${daysLeft} 天后到期`}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{daysLeft !== null && !showExpiryWarning && (
|
|
||||||
<p className="text-xs text-gray-400">
|
|
||||||
{isTrialing ? `试用剩余 ${daysLeft} 天` : `订阅到期: ${periodEnd?.toLocaleDateString()}`}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Usage */}
|
|
||||||
{usage && (
|
|
||||||
<div className="bg-white rounded-xl border border-gray-200 p-5 shadow-sm">
|
|
||||||
<h3 className="text-sm font-semibold text-gray-900 mb-4">用量配额</h3>
|
|
||||||
<UsageBar label="中转请求" current={usage.relay_requests} max={usage.max_relay_requests} />
|
|
||||||
<UsageBar label="Hand 执行" current={usage.hand_executions} max={usage.max_hand_executions} />
|
|
||||||
<UsageBar label="Pipeline 运行" current={usage.pipeline_runs} max={usage.max_pipeline_runs} />
|
|
||||||
{sub && (
|
|
||||||
<p className="mt-2 text-xs text-gray-400">
|
|
||||||
周期: {new Date(sub.current_period_start).toLocaleDateString()} — {new Date(sub.current_period_end).toLocaleDateString()}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Free plan upgrade prompt */}
|
|
||||||
{planName === 'free' && (
|
|
||||||
<div className="bg-gradient-to-r from-purple-50 to-blue-50 rounded-xl border border-purple-200 p-5">
|
|
||||||
<h3 className="text-sm font-semibold text-gray-900 mb-2">升级到专业版</h3>
|
|
||||||
<p className="text-xs text-gray-600 mb-3">
|
|
||||||
解锁全部 9 个 Hands、无限 Pipeline 和完整记忆系统
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-purple-600">
|
|
||||||
请前往「订阅与计费」页面选择计划并完成支付
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,990 +0,0 @@
|
|||||||
/**
|
|
||||||
* SchedulerPanel - ZCLAW Scheduler UI
|
|
||||||
*
|
|
||||||
* Displays scheduled jobs, event triggers, workflows, and run history.
|
|
||||||
*
|
|
||||||
* Design based on ZCLAW Dashboard v0.4.0
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from 'react';
|
|
||||||
import { useHandStore } from '../store/handStore';
|
|
||||||
import { useWorkflowStore, type Workflow } from '../store/workflowStore';
|
|
||||||
import { useAgentStore } from '../store/agentStore';
|
|
||||||
import { useConfigStore } from '../store/configStore';
|
|
||||||
import { WorkflowEditor } from './WorkflowEditor';
|
|
||||||
import { WorkflowHistory } from './WorkflowHistory';
|
|
||||||
import { TriggersPanel } from './TriggersPanel';
|
|
||||||
import {
|
|
||||||
Clock,
|
|
||||||
Zap,
|
|
||||||
History,
|
|
||||||
Plus,
|
|
||||||
RefreshCw,
|
|
||||||
Loader2,
|
|
||||||
Calendar,
|
|
||||||
X,
|
|
||||||
AlertCircle,
|
|
||||||
CheckCircle,
|
|
||||||
GitBranch,
|
|
||||||
Play,
|
|
||||||
} from 'lucide-react';
|
|
||||||
|
|
||||||
// === Tab Types ===
|
|
||||||
|
|
||||||
type TabType = 'scheduled' | 'triggers' | 'workflows' | 'history';
|
|
||||||
|
|
||||||
// === Schedule Type ===
|
|
||||||
|
|
||||||
type ScheduleType = 'cron' | 'interval' | 'once';
|
|
||||||
type TargetType = 'agent' | 'hand' | 'workflow';
|
|
||||||
|
|
||||||
// === Form State Interface ===
|
|
||||||
|
|
||||||
interface JobFormData {
|
|
||||||
name: string;
|
|
||||||
scheduleType: ScheduleType;
|
|
||||||
cronExpression: string;
|
|
||||||
intervalValue: number;
|
|
||||||
intervalUnit: 'minutes' | 'hours' | 'days';
|
|
||||||
runOnceDate: string;
|
|
||||||
runOnceTime: string;
|
|
||||||
targetType: TargetType;
|
|
||||||
targetId: string;
|
|
||||||
description: string;
|
|
||||||
enabled: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const initialFormData: JobFormData = {
|
|
||||||
name: '',
|
|
||||||
scheduleType: 'cron',
|
|
||||||
cronExpression: '',
|
|
||||||
intervalValue: 30,
|
|
||||||
intervalUnit: 'minutes',
|
|
||||||
runOnceDate: '',
|
|
||||||
runOnceTime: '',
|
|
||||||
targetType: 'hand',
|
|
||||||
targetId: '',
|
|
||||||
description: '',
|
|
||||||
enabled: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
// === Tab Button Component ===
|
|
||||||
|
|
||||||
function TabButton({
|
|
||||||
active,
|
|
||||||
onClick,
|
|
||||||
icon: Icon,
|
|
||||||
label,
|
|
||||||
}: {
|
|
||||||
active: boolean;
|
|
||||||
onClick: () => void;
|
|
||||||
icon: React.ComponentType<{ className?: string }>;
|
|
||||||
label: string;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
onClick={onClick}
|
|
||||||
className={`flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-md transition-colors ${
|
|
||||||
active
|
|
||||||
? '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'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<Icon className="w-3.5 h-3.5" />
|
|
||||||
{label}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// === Empty State Component ===
|
|
||||||
|
|
||||||
function EmptyState({
|
|
||||||
icon: Icon,
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
actionLabel,
|
|
||||||
onAction,
|
|
||||||
}: {
|
|
||||||
icon: React.ComponentType<{ className?: string }>;
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
actionLabel?: string;
|
|
||||||
onAction?: () => void;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="text-center py-8">
|
|
||||||
<div className="w-12 h-12 bg-gray-100 dark:bg-gray-800 rounded-full flex items-center justify-center mx-auto mb-3">
|
|
||||||
<Icon className="w-6 h-6 text-gray-400" />
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-2">{title}</p>
|
|
||||||
<p className="text-xs text-gray-400 dark:text-gray-500 mb-4 max-w-sm mx-auto">
|
|
||||||
{description}
|
|
||||||
</p>
|
|
||||||
{actionLabel && onAction && (
|
|
||||||
<button
|
|
||||||
onClick={onAction}
|
|
||||||
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" />
|
|
||||||
{actionLabel}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// === Create Job Modal Component ===
|
|
||||||
|
|
||||||
interface CreateJobModalProps {
|
|
||||||
isOpen: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
onSuccess: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function CreateJobModal({ isOpen, onClose, onSuccess }: CreateJobModalProps) {
|
|
||||||
// Store state - use domain stores
|
|
||||||
const hands = useHandStore((s) => s.hands);
|
|
||||||
const workflows = useWorkflowStore((s) => s.workflows);
|
|
||||||
const clones = useAgentStore((s) => s.clones);
|
|
||||||
const createScheduledTask = useConfigStore((s) => s.createScheduledTask);
|
|
||||||
const loadHands = useHandStore((s) => s.loadHands);
|
|
||||||
const loadWorkflows = useWorkflowStore((s) => s.loadWorkflows);
|
|
||||||
const loadClones = useAgentStore((s) => s.loadClones);
|
|
||||||
const [formData, setFormData] = useState<JobFormData>(initialFormData);
|
|
||||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
||||||
const [submitStatus, setSubmitStatus] = useState<'idle' | 'success' | 'error'>('idle');
|
|
||||||
const [errorMessage, setErrorMessage] = useState('');
|
|
||||||
|
|
||||||
// Load available targets on mount
|
|
||||||
useEffect(() => {
|
|
||||||
if (isOpen) {
|
|
||||||
loadHands();
|
|
||||||
loadWorkflows();
|
|
||||||
loadClones();
|
|
||||||
}
|
|
||||||
}, [isOpen, loadHands, loadWorkflows, loadClones]);
|
|
||||||
|
|
||||||
// Reset form when modal opens
|
|
||||||
useEffect(() => {
|
|
||||||
if (isOpen) {
|
|
||||||
setFormData(initialFormData);
|
|
||||||
setErrors({});
|
|
||||||
setSubmitStatus('idle');
|
|
||||||
setErrorMessage('');
|
|
||||||
}
|
|
||||||
}, [isOpen]);
|
|
||||||
|
|
||||||
// Generate schedule string based on type
|
|
||||||
const generateScheduleString = useCallback((): string => {
|
|
||||||
switch (formData.scheduleType) {
|
|
||||||
case 'cron':
|
|
||||||
return formData.cronExpression;
|
|
||||||
case 'interval': {
|
|
||||||
const unitMap = { minutes: 'm', hours: 'h', days: 'd' };
|
|
||||||
return `every ${formData.intervalValue}${unitMap[formData.intervalUnit]}`;
|
|
||||||
}
|
|
||||||
case 'once': {
|
|
||||||
if (formData.runOnceDate && formData.runOnceTime) {
|
|
||||||
return `once ${formData.runOnceDate}T${formData.runOnceTime}`;
|
|
||||||
}
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
}, [formData]);
|
|
||||||
|
|
||||||
// Validate form
|
|
||||||
const validateForm = useCallback((): boolean => {
|
|
||||||
const newErrors: Record<string, string> = {};
|
|
||||||
|
|
||||||
if (!formData.name.trim()) {
|
|
||||||
newErrors.name = '任务名称不能为空';
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (formData.scheduleType) {
|
|
||||||
case 'cron':
|
|
||||||
if (!formData.cronExpression.trim()) {
|
|
||||||
newErrors.cronExpression = 'Cron 表达式不能为空';
|
|
||||||
} else if (!isValidCron(formData.cronExpression)) {
|
|
||||||
newErrors.cronExpression = 'Cron 表达式格式无效';
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case 'interval':
|
|
||||||
if (formData.intervalValue <= 0) {
|
|
||||||
newErrors.intervalValue = '间隔时间必须大于 0';
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case 'once':
|
|
||||||
if (!formData.runOnceDate) {
|
|
||||||
newErrors.runOnceDate = '请选择执行日期';
|
|
||||||
}
|
|
||||||
if (!formData.runOnceTime) {
|
|
||||||
newErrors.runOnceTime = '请选择执行时间';
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!formData.targetId) {
|
|
||||||
newErrors.targetId = '请选择要执行的目标';
|
|
||||||
}
|
|
||||||
|
|
||||||
setErrors(newErrors);
|
|
||||||
return Object.keys(newErrors).length === 0;
|
|
||||||
}, [formData]);
|
|
||||||
|
|
||||||
// Simple cron expression validator
|
|
||||||
const isValidCron = (expression: string): boolean => {
|
|
||||||
// Basic validation: 5 or 6 fields separated by spaces
|
|
||||||
const parts = expression.trim().split(/\s+/);
|
|
||||||
return parts.length >= 5 && parts.length <= 6;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Handle form submission
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
if (!validateForm()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsSubmitting(true);
|
|
||||||
setSubmitStatus('idle');
|
|
||||||
setErrorMessage('');
|
|
||||||
|
|
||||||
try {
|
|
||||||
const schedule = generateScheduleString();
|
|
||||||
await createScheduledTask({
|
|
||||||
name: formData.name.trim(),
|
|
||||||
schedule,
|
|
||||||
scheduleType: formData.scheduleType,
|
|
||||||
target: {
|
|
||||||
type: formData.targetType,
|
|
||||||
id: formData.targetId,
|
|
||||||
},
|
|
||||||
description: formData.description.trim() || undefined,
|
|
||||||
enabled: formData.enabled,
|
|
||||||
});
|
|
||||||
|
|
||||||
setSubmitStatus('success');
|
|
||||||
setTimeout(() => {
|
|
||||||
onSuccess();
|
|
||||||
onClose();
|
|
||||||
}, 1500);
|
|
||||||
} catch (err) {
|
|
||||||
setSubmitStatus('error');
|
|
||||||
setErrorMessage(err instanceof Error ? err.message : '创建任务失败');
|
|
||||||
} finally {
|
|
||||||
setIsSubmitting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Update form field
|
|
||||||
const updateField = <K extends keyof JobFormData>(field: K, value: JobFormData[K]) => {
|
|
||||||
setFormData(prev => ({ ...prev, [field]: value }));
|
|
||||||
// Clear error when field is updated
|
|
||||||
if (errors[field]) {
|
|
||||||
setErrors(prev => {
|
|
||||||
const newErrors = { ...prev };
|
|
||||||
delete newErrors[field];
|
|
||||||
return newErrors;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Get available targets based on type
|
|
||||||
const getAvailableTargets = (): Array<{ id: string; name?: string }> => {
|
|
||||||
switch (formData.targetType) {
|
|
||||||
case 'agent':
|
|
||||||
return clones.map(c => ({ id: c.id, name: c.name }));
|
|
||||||
case 'hand':
|
|
||||||
return hands.map(h => ({ id: h.id, name: h.name }));
|
|
||||||
case 'workflow':
|
|
||||||
return workflows.map(w => ({ id: w.id, name: w.name }));
|
|
||||||
default:
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
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-lg mx-4 max-h-[90vh] overflow-hidden flex flex-col">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700 flex-shrink-0">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="w-8 h-8 bg-blue-100 dark:bg-blue-900/30 rounded-lg flex items-center justify-center">
|
|
||||||
<Clock className="w-4 h-4 text-blue-600 dark:text-blue-400" />
|
|
||||||
</div>
|
|
||||||
<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">
|
|
||||||
创建一个定时执行的任务
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
className="p-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
|
|
||||||
>
|
|
||||||
<X className="w-5 h-5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Form */}
|
|
||||||
<form onSubmit={handleSubmit} className="flex-1 overflow-y-auto p-4 space-y-4">
|
|
||||||
{/* Task Name */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
|
||||||
任务名称 <span className="text-red-500">*</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={formData.name}
|
|
||||||
onChange={(e) => updateField('name', e.target.value)}
|
|
||||||
placeholder="例如: 每日数据同步"
|
|
||||||
className={`w-full px-3 py-2 text-sm border rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 ${
|
|
||||||
errors.name ? 'border-red-500' : 'border-gray-300 dark:border-gray-600'
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
{errors.name && (
|
|
||||||
<p className="mt-1 text-xs text-red-500 flex items-center gap-1">
|
|
||||||
<AlertCircle className="w-3 h-3" />
|
|
||||||
{errors.name}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Schedule Type */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
|
||||||
调度类型 <span className="text-red-500">*</span>
|
|
||||||
</label>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
{[
|
|
||||||
{ value: 'cron', label: 'Cron 表达式' },
|
|
||||||
{ value: 'interval', label: '固定间隔' },
|
|
||||||
{ value: 'once', label: '执行一次' },
|
|
||||||
].map((option) => (
|
|
||||||
<button
|
|
||||||
key={option.value}
|
|
||||||
type="button"
|
|
||||||
onClick={() => updateField('scheduleType', option.value as ScheduleType)}
|
|
||||||
className={`flex-1 px-3 py-2 text-sm rounded-lg border transition-colors ${
|
|
||||||
formData.scheduleType === option.value
|
|
||||||
? 'bg-blue-600 text-white border-blue-600'
|
|
||||||
: 'bg-white dark:bg-gray-900 text-gray-700 dark:text-gray-300 border-gray-300 dark:border-gray-600 hover:border-blue-500'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{option.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Cron Expression */}
|
|
||||||
{formData.scheduleType === 'cron' && (
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
|
||||||
Cron 表达式 <span className="text-red-500">*</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={formData.cronExpression}
|
|
||||||
onChange={(e) => updateField('cronExpression', e.target.value)}
|
|
||||||
placeholder="0 9 * * * (每天 9:00)"
|
|
||||||
className={`w-full px-3 py-2 text-sm border rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 font-mono ${
|
|
||||||
errors.cronExpression ? 'border-red-500' : 'border-gray-300 dark:border-gray-600'
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
{errors.cronExpression && (
|
|
||||||
<p className="mt-1 text-xs text-red-500 flex items-center gap-1">
|
|
||||||
<AlertCircle className="w-3 h-3" />
|
|
||||||
{errors.cronExpression}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<p className="mt-1 text-xs text-gray-400">
|
|
||||||
格式: 分 时 日 月 周 (例如: 0 9 * * * 表示每天 9:00)
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Interval Settings */}
|
|
||||||
{formData.scheduleType === 'interval' && (
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<div className="flex-1">
|
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
|
||||||
间隔时间 <span className="text-red-500">*</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min="1"
|
|
||||||
value={formData.intervalValue}
|
|
||||||
onChange={(e) => updateField('intervalValue', parseInt(e.target.value) || 0)}
|
|
||||||
className={`w-full px-3 py-2 text-sm border rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500 ${
|
|
||||||
errors.intervalValue ? 'border-red-500' : 'border-gray-300 dark:border-gray-600'
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
{errors.intervalValue && (
|
|
||||||
<p className="mt-1 text-xs text-red-500 flex items-center gap-1">
|
|
||||||
<AlertCircle className="w-3 h-3" />
|
|
||||||
{errors.intervalValue}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="w-32">
|
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
|
||||||
单位
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
value={formData.intervalUnit}
|
|
||||||
onChange={(e) => updateField('intervalUnit', e.target.value as 'minutes' | 'hours' | 'days')}
|
|
||||||
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
||||||
>
|
|
||||||
<option value="minutes">分钟</option>
|
|
||||||
<option value="hours">小时</option>
|
|
||||||
<option value="days">天</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Run Once Settings */}
|
|
||||||
{formData.scheduleType === 'once' && (
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<div className="flex-1">
|
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
|
||||||
执行日期 <span className="text-red-500">*</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
value={formData.runOnceDate}
|
|
||||||
onChange={(e) => updateField('runOnceDate', e.target.value)}
|
|
||||||
className={`w-full px-3 py-2 text-sm border rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500 ${
|
|
||||||
errors.runOnceDate ? 'border-red-500' : 'border-gray-300 dark:border-gray-600'
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
{errors.runOnceDate && (
|
|
||||||
<p className="mt-1 text-xs text-red-500 flex items-center gap-1">
|
|
||||||
<AlertCircle className="w-3 h-3" />
|
|
||||||
{errors.runOnceDate}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex-1">
|
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
|
||||||
执行时间 <span className="text-red-500">*</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="time"
|
|
||||||
value={formData.runOnceTime}
|
|
||||||
onChange={(e) => updateField('runOnceTime', e.target.value)}
|
|
||||||
className={`w-full px-3 py-2 text-sm border rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500 ${
|
|
||||||
errors.runOnceTime ? 'border-red-500' : 'border-gray-300 dark:border-gray-600'
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
{errors.runOnceTime && (
|
|
||||||
<p className="mt-1 text-xs text-red-500 flex items-center gap-1">
|
|
||||||
<AlertCircle className="w-3 h-3" />
|
|
||||||
{errors.runOnceTime}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Target Selection */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
|
||||||
执行目标类型
|
|
||||||
</label>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
{[
|
|
||||||
{ value: 'hand', label: 'Hand' },
|
|
||||||
{ value: 'workflow', label: 'Workflow' },
|
|
||||||
{ value: 'agent', label: 'Agent' },
|
|
||||||
].map((option) => (
|
|
||||||
<button
|
|
||||||
key={option.value}
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
updateField('targetType', option.value as TargetType);
|
|
||||||
updateField('targetId', ''); // Reset target when type changes
|
|
||||||
}}
|
|
||||||
className={`flex-1 px-3 py-2 text-sm rounded-lg border transition-colors ${
|
|
||||||
formData.targetType === option.value
|
|
||||||
? 'bg-blue-600 text-white border-blue-600'
|
|
||||||
: 'bg-white dark:bg-gray-900 text-gray-700 dark:text-gray-300 border-gray-300 dark:border-gray-600 hover:border-blue-500'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{option.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Target Selection Dropdown */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
|
||||||
选择目标 <span className="text-red-500">*</span>
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
value={formData.targetId}
|
|
||||||
onChange={(e) => updateField('targetId', e.target.value)}
|
|
||||||
className={`w-full px-3 py-2 text-sm border rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500 ${
|
|
||||||
errors.targetId ? 'border-red-500' : 'border-gray-300 dark:border-gray-600'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<option value="">-- 请选择 --</option>
|
|
||||||
{getAvailableTargets().map((target) => (
|
|
||||||
<option key={target.id} value={target.id}>
|
|
||||||
{target.name || target.id}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
{errors.targetId && (
|
|
||||||
<p className="mt-1 text-xs text-red-500 flex items-center gap-1">
|
|
||||||
<AlertCircle className="w-3 h-3" />
|
|
||||||
{errors.targetId}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{getAvailableTargets().length === 0 && (
|
|
||||||
<p className="mt-1 text-xs text-gray-400">
|
|
||||||
当前没有可用的 {formData.targetType === 'hand' ? 'Hands' : formData.targetType === 'workflow' ? 'Workflows' : 'Agents'}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Description */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
|
||||||
任务描述
|
|
||||||
</label>
|
|
||||||
<textarea
|
|
||||||
value={formData.description}
|
|
||||||
onChange={(e) => updateField('description', e.target.value)}
|
|
||||||
placeholder="可选的任务描述..."
|
|
||||||
rows={2}
|
|
||||||
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Enabled Toggle */}
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
id="enabled"
|
|
||||||
checked={formData.enabled}
|
|
||||||
onChange={(e) => updateField('enabled', e.target.checked)}
|
|
||||||
className="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
|
|
||||||
/>
|
|
||||||
<label htmlFor="enabled" className="text-sm text-gray-700 dark:text-gray-300">
|
|
||||||
创建后立即启用
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Status Messages */}
|
|
||||||
{submitStatus === 'success' && (
|
|
||||||
<div className="flex items-center gap-2 p-3 bg-green-50 dark:bg-green-900/20 rounded-lg text-green-700 dark:text-green-400">
|
|
||||||
<CheckCircle className="w-5 h-5 flex-shrink-0" />
|
|
||||||
<span className="text-sm">任务创建成功!</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{submitStatus === 'error' && (
|
|
||||||
<div className="flex items-center gap-2 p-3 bg-red-50 dark:bg-red-900/20 rounded-lg text-red-700 dark:text-red-400">
|
|
||||||
<AlertCircle className="w-5 h-5 flex-shrink-0" />
|
|
||||||
<span className="text-sm">{errorMessage}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</form>
|
|
||||||
|
|
||||||
{/* Footer */}
|
|
||||||
<div className="flex items-center justify-end gap-2 p-4 border-t border-gray-200 dark:border-gray-700 flex-shrink-0">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onClose}
|
|
||||||
disabled={isSubmitting}
|
|
||||||
className="px-4 py-2 text-sm text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors disabled:opacity-50"
|
|
||||||
>
|
|
||||||
取消
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
form="job-form"
|
|
||||||
onClick={handleSubmit}
|
|
||||||
disabled={isSubmitting || submitStatus === 'success'}
|
|
||||||
className="px-4 py-2 text-sm text-white bg-blue-600 rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 flex items-center gap-2"
|
|
||||||
>
|
|
||||||
{isSubmitting ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="w-4 h-4 animate-spin" />
|
|
||||||
创建中...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Plus className="w-4 h-4" />
|
|
||||||
创建任务
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// === Main SchedulerPanel Component ===
|
|
||||||
|
|
||||||
export function SchedulerPanel() {
|
|
||||||
// Store state - use domain stores
|
|
||||||
const scheduledTasks = useConfigStore((s) => s.scheduledTasks);
|
|
||||||
const loadScheduledTasks = useConfigStore((s) => s.loadScheduledTasks);
|
|
||||||
const workflows = useWorkflowStore((s) => s.workflows);
|
|
||||||
const loadWorkflows = useWorkflowStore((s) => s.loadWorkflows);
|
|
||||||
const createWorkflow = useWorkflowStore((s) => s.createWorkflow);
|
|
||||||
const updateWorkflow = useWorkflowStore((s) => s.updateWorkflow);
|
|
||||||
const executeWorkflow = useWorkflowStore((s) => s.triggerWorkflow);
|
|
||||||
const handLoading = useHandStore((s) => s.isLoading);
|
|
||||||
const workflowLoading = useWorkflowStore((s) => s.isLoading);
|
|
||||||
const configLoading = useConfigStore((s) => s.isLoading);
|
|
||||||
const isLoading = handLoading || workflowLoading || configLoading;
|
|
||||||
const [activeTab, setActiveTab] = useState<TabType>('scheduled');
|
|
||||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
|
||||||
const [isWorkflowEditorOpen, setIsWorkflowEditorOpen] = useState(false);
|
|
||||||
const [editingWorkflow, setEditingWorkflow] = useState<Workflow | undefined>(undefined);
|
|
||||||
const [selectedWorkflow, setSelectedWorkflow] = useState<Workflow | null>(null);
|
|
||||||
const [isSavingWorkflow, setIsSavingWorkflow] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadScheduledTasks();
|
|
||||||
loadWorkflows();
|
|
||||||
}, [loadScheduledTasks, loadWorkflows]);
|
|
||||||
|
|
||||||
const handleCreateJob = useCallback(() => {
|
|
||||||
setIsCreateModalOpen(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleCreateSuccess = useCallback(() => {
|
|
||||||
loadScheduledTasks();
|
|
||||||
}, [loadScheduledTasks]);
|
|
||||||
|
|
||||||
// Workflow handlers
|
|
||||||
const handleCreateWorkflow = useCallback(() => {
|
|
||||||
setEditingWorkflow(undefined);
|
|
||||||
setIsWorkflowEditorOpen(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleEditWorkflow = useCallback((workflow: Workflow) => {
|
|
||||||
setEditingWorkflow(workflow);
|
|
||||||
setIsWorkflowEditorOpen(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleViewWorkflowHistory = useCallback((workflow: Workflow) => {
|
|
||||||
setSelectedWorkflow(workflow);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleSaveWorkflow = useCallback(async (data: {
|
|
||||||
name: string;
|
|
||||||
description?: string;
|
|
||||||
steps: Array<{
|
|
||||||
handName: string;
|
|
||||||
name?: string;
|
|
||||||
params?: Record<string, unknown>;
|
|
||||||
condition?: string;
|
|
||||||
}>;
|
|
||||||
}) => {
|
|
||||||
setIsSavingWorkflow(true);
|
|
||||||
try {
|
|
||||||
if (editingWorkflow) {
|
|
||||||
// Update existing workflow
|
|
||||||
await updateWorkflow(editingWorkflow.id, data);
|
|
||||||
} else {
|
|
||||||
// Create new workflow
|
|
||||||
await createWorkflow(data);
|
|
||||||
}
|
|
||||||
setIsWorkflowEditorOpen(false);
|
|
||||||
setEditingWorkflow(undefined);
|
|
||||||
await loadWorkflows();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to save workflow:', error);
|
|
||||||
throw error;
|
|
||||||
} finally {
|
|
||||||
setIsSavingWorkflow(false);
|
|
||||||
}
|
|
||||||
}, [editingWorkflow, createWorkflow, updateWorkflow, loadWorkflows]);
|
|
||||||
|
|
||||||
const handleExecuteWorkflow = useCallback(async (workflowId: string) => {
|
|
||||||
try {
|
|
||||||
await executeWorkflow(workflowId);
|
|
||||||
await loadWorkflows();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to execute workflow:', error);
|
|
||||||
}
|
|
||||||
}, [executeWorkflow, loadWorkflows]);
|
|
||||||
|
|
||||||
if (isLoading && scheduledTasks.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={() => loadScheduledTasks()}
|
|
||||||
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>
|
|
||||||
|
|
||||||
{/* Tab Navigation */}
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center bg-gray-100 dark:bg-gray-800 rounded-lg p-1">
|
|
||||||
<TabButton
|
|
||||||
active={activeTab === 'scheduled'}
|
|
||||||
onClick={() => setActiveTab('scheduled')}
|
|
||||||
icon={Clock}
|
|
||||||
label="定时任务"
|
|
||||||
/>
|
|
||||||
<TabButton
|
|
||||||
active={activeTab === 'triggers'}
|
|
||||||
onClick={() => setActiveTab('triggers')}
|
|
||||||
icon={Zap}
|
|
||||||
label="事件触发器"
|
|
||||||
/>
|
|
||||||
<TabButton
|
|
||||||
active={activeTab === 'workflows'}
|
|
||||||
onClick={() => setActiveTab('workflows')}
|
|
||||||
icon={GitBranch}
|
|
||||||
label="工作流"
|
|
||||||
/>
|
|
||||||
<TabButton
|
|
||||||
active={activeTab === 'history'}
|
|
||||||
onClick={() => setActiveTab('history')}
|
|
||||||
icon={History}
|
|
||||||
label="运行历史"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{activeTab === 'scheduled' && (
|
|
||||||
<button
|
|
||||||
onClick={handleCreateJob}
|
|
||||||
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>
|
|
||||||
)}
|
|
||||||
{activeTab === 'workflows' && (
|
|
||||||
<button
|
|
||||||
onClick={handleCreateWorkflow}
|
|
||||||
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>
|
|
||||||
|
|
||||||
{/* Tab Content */}
|
|
||||||
{activeTab === 'scheduled' && (
|
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
|
|
||||||
{scheduledTasks.length === 0 ? (
|
|
||||||
<EmptyState
|
|
||||||
icon={Calendar}
|
|
||||||
title="暂无定时任务"
|
|
||||||
description="创建一个定时任务来定期运行代理。"
|
|
||||||
actionLabel="创建定时任务"
|
|
||||||
onAction={handleCreateJob}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-2">
|
|
||||||
{scheduledTasks.map((task) => (
|
|
||||||
<div
|
|
||||||
key={task.id}
|
|
||||||
className="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-900 rounded-lg"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="w-8 h-8 bg-blue-100 dark:bg-blue-900/30 rounded-lg flex items-center justify-center">
|
|
||||||
<Clock className="w-4 h-4 text-blue-600 dark:text-blue-400" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="font-medium text-gray-900 dark:text-white">
|
|
||||||
{task.name}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
|
||||||
{task.schedule}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span
|
|
||||||
className={`px-2 py-0.5 rounded text-xs ${
|
|
||||||
task.status === 'active'
|
|
||||||
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
|
|
||||||
: task.status === 'paused'
|
|
||||||
? 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400'
|
|
||||||
: 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{task.status === 'active' ? '运行中' : task.status === 'paused' ? '已暂停' : task.status}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeTab === 'triggers' && (
|
|
||||||
<TriggersPanel />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Workflows Tab */}
|
|
||||||
{activeTab === 'workflows' && (
|
|
||||||
selectedWorkflow ? (
|
|
||||||
<WorkflowHistory
|
|
||||||
workflow={selectedWorkflow}
|
|
||||||
onBack={() => setSelectedWorkflow(null)}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
|
|
||||||
{workflows.length === 0 ? (
|
|
||||||
<EmptyState
|
|
||||||
icon={GitBranch}
|
|
||||||
title="暂无工作流"
|
|
||||||
description="工作流可以将多个 Hand 组合成自动化流程,实现复杂的任务编排。"
|
|
||||||
actionLabel="创建工作流"
|
|
||||||
onAction={handleCreateWorkflow}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-2">
|
|
||||||
{workflows.map((workflow) => (
|
|
||||||
<div
|
|
||||||
key={workflow.id}
|
|
||||||
className="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-900 rounded-lg"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="w-8 h-8 bg-purple-100 dark:bg-purple-900/30 rounded-lg flex items-center justify-center">
|
|
||||||
<GitBranch className="w-4 h-4 text-purple-600 dark:text-purple-400" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="font-medium text-gray-900 dark:text-white">
|
|
||||||
{workflow.name}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
|
||||||
{workflow.description || '无描述'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{workflow.steps && (
|
|
||||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
|
||||||
{workflow.steps} 步骤
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
onClick={() => handleExecuteWorkflow(workflow.id)}
|
|
||||||
className="p-1.5 text-green-600 hover:bg-green-50 dark:hover:bg-green-900/20 rounded"
|
|
||||||
title="执行"
|
|
||||||
>
|
|
||||||
<Play className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => handleEditWorkflow(workflow)}
|
|
||||||
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"
|
|
||||||
title="编辑"
|
|
||||||
>
|
|
||||||
<GitBranch className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => handleViewWorkflowHistory(workflow)}
|
|
||||||
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"
|
|
||||||
title="历史"
|
|
||||||
>
|
|
||||||
<History className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
<button
|
|
||||||
onClick={handleCreateWorkflow}
|
|
||||||
className="w-full flex items-center justify-center gap-2 p-3 border-2 border-dashed border-gray-200 dark:border-gray-700 rounded-lg text-gray-500 dark:text-gray-400 hover:border-blue-500 hover:text-blue-500 dark:hover:border-blue-400 dark:hover:text-blue-400 transition-colors"
|
|
||||||
>
|
|
||||||
<Plus className="w-4 h-4" />
|
|
||||||
创建新工作流
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeTab === 'history' && (
|
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
|
|
||||||
<EmptyState
|
|
||||||
icon={History}
|
|
||||||
title="暂无运行历史"
|
|
||||||
description="当定时任务或事件触发器执行时,运行记录将显示在这里,包括状态和日志。"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Create Job Modal */}
|
|
||||||
<CreateJobModal
|
|
||||||
isOpen={isCreateModalOpen}
|
|
||||||
onClose={() => setIsCreateModalOpen(false)}
|
|
||||||
onSuccess={handleCreateSuccess}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Workflow Editor Modal */}
|
|
||||||
<WorkflowEditor
|
|
||||||
workflow={editingWorkflow}
|
|
||||||
isOpen={isWorkflowEditorOpen}
|
|
||||||
onClose={() => {
|
|
||||||
setIsWorkflowEditorOpen(false);
|
|
||||||
setEditingWorkflow(undefined);
|
|
||||||
}}
|
|
||||||
onSave={handleSaveWorkflow}
|
|
||||||
isSaving={isSavingWorkflow}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default SchedulerPanel;
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
/**
|
|
||||||
* SimpleTopBar - Minimal top bar for simple UI mode
|
|
||||||
*
|
|
||||||
* Shows only the ZCLAW logo and a mode-toggle button.
|
|
||||||
* Designed for the streamlined "simple" experience.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { LayoutGrid } from 'lucide-react';
|
|
||||||
import { motion } from 'framer-motion';
|
|
||||||
|
|
||||||
// === Types ===
|
|
||||||
|
|
||||||
interface SimpleTopBarProps {
|
|
||||||
onToggleMode: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
// === Component ===
|
|
||||||
|
|
||||||
export function SimpleTopBar({ onToggleMode }: SimpleTopBarProps) {
|
|
||||||
return (
|
|
||||||
<header className="h-10 bg-white dark:bg-gray-900 border-b border-gray-100 dark:border-gray-800 flex items-center px-4 flex-shrink-0 select-none">
|
|
||||||
{/* Logo */}
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<span className="font-bold text-lg bg-gradient-to-r from-orange-500 to-amber-500 bg-clip-text text-transparent">
|
|
||||||
ZCLAW
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Spacer */}
|
|
||||||
<div className="flex-1" />
|
|
||||||
|
|
||||||
{/* Mode toggle button */}
|
|
||||||
<motion.button
|
|
||||||
type="button"
|
|
||||||
onClick={onToggleMode}
|
|
||||||
className="
|
|
||||||
flex items-center gap-1.5 px-2.5 py-1 rounded-md
|
|
||||||
text-xs text-gray-500 dark:text-gray-400
|
|
||||||
hover:text-gray-900 dark:hover:text-gray-100
|
|
||||||
hover:bg-gray-100 dark:hover:bg-gray-800
|
|
||||||
transition-colors duration-150
|
|
||||||
"
|
|
||||||
whileHover={{ scale: 1.04 }}
|
|
||||||
whileTap={{ scale: 0.97 }}
|
|
||||||
title="切换到专业模式"
|
|
||||||
>
|
|
||||||
<LayoutGrid className="w-3.5 h-3.5" />
|
|
||||||
<span>更多功能</span>
|
|
||||||
</motion.button>
|
|
||||||
</header>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default SimpleTopBar;
|
|
||||||
@@ -1,211 +0,0 @@
|
|||||||
/**
|
|
||||||
* * SkillCard - 技能卡片组件
|
|
||||||
*
|
|
||||||
* * 展示单个技能的基本信息,包括名称、描述、能力和安装状态
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { motion } from 'framer-motion';
|
|
||||||
import {
|
|
||||||
Package,
|
|
||||||
Check,
|
|
||||||
Star,
|
|
||||||
MoreHorizontal,
|
|
||||||
Clock,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import type { Skill } from '../../types/skill-market';
|
|
||||||
import { useState } from 'react';
|
|
||||||
|
|
||||||
// === 类型定义 ===
|
|
||||||
|
|
||||||
interface SkillCardProps {
|
|
||||||
/** 技能数据 */
|
|
||||||
skill: Skill;
|
|
||||||
/** 是否选中 */
|
|
||||||
isSelected?: boolean;
|
|
||||||
/** 点击回调 */
|
|
||||||
onClick?: () => void;
|
|
||||||
/** 安装/卸载回调 */
|
|
||||||
onToggleInstall?: () => void;
|
|
||||||
/** 显示更多操作 */
|
|
||||||
onShowMore?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
// === 分类配置 ===
|
|
||||||
|
|
||||||
const CATEGORY_CONFIG: Record<string, { color: string; bgColor: string }> = {
|
|
||||||
development: { color: 'text-blue-600 dark:text-blue-400', bgColor: 'bg-blue-100 dark:bg-blue-900/30' },
|
|
||||||
security: { color: 'text-red-600 dark:text-red-400', bgColor: 'bg-red-100 dark:bg-red-900/30' },
|
|
||||||
analytics: { color: 'text-purple-600 dark:text-purple-400', bgColor: 'bg-purple-100 dark:bg-purple-900/30' },
|
|
||||||
content: { color: 'text-pink-600 dark:text-pink-400', bgColor: 'bg-pink-100 dark:bg-pink-900/30' },
|
|
||||||
ops: { color: 'text-orange-600 dark:text-orange-400', bgColor: 'bg-orange-100 dark:bg-orange-900/30' },
|
|
||||||
management: { color: 'text-cyan-600 dark:text-cyan-400', bgColor: 'bg-cyan-100 dark:bg-cyan-900/30' },
|
|
||||||
testing: { color: 'text-emerald-600 dark:text-emerald-400', bgColor: 'bg-emerald-100 dark:bg-emerald-900/30' },
|
|
||||||
business: { color: 'text-amber-600 dark:text-amber-400', bgColor: 'bg-amber-100 dark:bg-amber-900/30' },
|
|
||||||
marketing: { color: 'text-rose-600 dark:text-rose-400', bgColor: 'bg-rose-100 dark:bg-rose-900/30' },
|
|
||||||
};
|
|
||||||
|
|
||||||
// === 分类名称映射 ===
|
|
||||||
|
|
||||||
const CATEGORY_NAMES: Record<string, string> = {
|
|
||||||
development: '开发',
|
|
||||||
security: '安全',
|
|
||||||
analytics: '分析',
|
|
||||||
content: '内容',
|
|
||||||
ops: '运维',
|
|
||||||
management: '管理',
|
|
||||||
testing: '测试',
|
|
||||||
business: '商务',
|
|
||||||
marketing: '营销',
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* SkillCard - 技能卡片组件
|
|
||||||
*/
|
|
||||||
export function SkillCard({
|
|
||||||
skill,
|
|
||||||
isSelected = false,
|
|
||||||
onClick,
|
|
||||||
onToggleInstall,
|
|
||||||
onShowMore,
|
|
||||||
}: SkillCardProps) {
|
|
||||||
const [isHovered, setIsHovered] = useState(false);
|
|
||||||
const categoryConfig = CATEGORY_CONFIG[skill.category] || {
|
|
||||||
color: 'text-gray-600 dark:text-gray-400',
|
|
||||||
bgColor: 'bg-gray-100 dark:bg-gray-800/30',
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, y: 10 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
whileHover={{ scale: 1.02 }}
|
|
||||||
onHoverStart={() => setIsHovered(true)}
|
|
||||||
onHoverEnd={() => setIsHovered(false)}
|
|
||||||
onClick={onClick}
|
|
||||||
className={`
|
|
||||||
relative p-4 rounded-lg border cursor-pointer transition-all duration-200
|
|
||||||
${isSelected
|
|
||||||
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
|
|
||||||
: 'border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 hover:border-gray-300 dark:hover:border-gray-600'
|
|
||||||
}
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
{/* 顶部:图标和名称 */}
|
|
||||||
<div className="flex items-start justify-between mb-2">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div
|
|
||||||
className={`w-10 h-10 rounded-lg flex items-center justify-center ${categoryConfig.bgColor}`}
|
|
||||||
>
|
|
||||||
<Package className={`w-5 h-5 ${categoryConfig.color}`} />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
|
||||||
{skill.name}
|
|
||||||
</h3>
|
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
|
||||||
{skill.author || '官方'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 安装按钮 */}
|
|
||||||
<motion.button
|
|
||||||
whileHover={{ scale: 1.1 }}
|
|
||||||
whileTap={{ scale: 0.95 }}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onToggleInstall?.();
|
|
||||||
}}
|
|
||||||
className={`
|
|
||||||
px-3 py-1.5 rounded-lg text-xs font-medium transition-all duration-200
|
|
||||||
${skill.installed
|
|
||||||
? 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-600'
|
|
||||||
: 'bg-gray-700 dark:bg-gray-600 text-white hover:bg-gray-800 dark:hover:bg-gray-500'
|
|
||||||
}
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
{skill.installed ? (
|
|
||||||
<span className="flex items-center gap-1">
|
|
||||||
<Check className="w-3 h-3" />
|
|
||||||
已安装
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="flex items-center gap-1">
|
|
||||||
<Package className="w-3 h-3" />
|
|
||||||
安装
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</motion.button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 描述 */}
|
|
||||||
<p className="text-xs text-gray-600 dark:text-gray-300 mb-3 line-clamp-2">
|
|
||||||
{skill.description}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* 标签和能力 */}
|
|
||||||
<div className="flex flex-wrap gap-1 mb-3">
|
|
||||||
{skill.capabilities.slice(0, 3).map((cap) => (
|
|
||||||
<span
|
|
||||||
key={cap}
|
|
||||||
className="px-2 py-0.5 rounded text-xs bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400"
|
|
||||||
>
|
|
||||||
{cap}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
{skill.capabilities.length > 3 && (
|
|
||||||
<span className="px-2 py-0.5 rounded text-xs bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400">
|
|
||||||
+{skill.capabilities.length - 3}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 底部:分类、评分和统计 */}
|
|
||||||
<div className="flex items-center justify-between pt-2 border-t border-gray-100 dark:border-gray-700">
|
|
||||||
<span
|
|
||||||
className={`px-2 py-0.5 rounded text-xs ${categoryConfig.bgColor} ${categoryConfig.color}`}
|
|
||||||
>
|
|
||||||
{CATEGORY_NAMES[skill.category] || skill.category}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-3 text-xs text-gray-500 dark:text-gray-400">
|
|
||||||
{skill.rating !== undefined && (
|
|
||||||
<span className="flex items-center gap-1">
|
|
||||||
<Star className="w-3 h-3 text-yellow-500 fill-current" />
|
|
||||||
{skill.rating.toFixed(1)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{skill.reviewCount !== undefined && skill.reviewCount > 0 && (
|
|
||||||
<span>{skill.reviewCount} 评价</span>
|
|
||||||
)}
|
|
||||||
{skill.installedAt && (
|
|
||||||
<span className="flex items-center gap-1">
|
|
||||||
<Clock className="w-3 h-3" />
|
|
||||||
{new Date(skill.installedAt).toLocaleDateString()}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 悬停时显示更多按钮 */}
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={{ opacity: isHovered ? 1 : 0 }}
|
|
||||||
className="absolute top-2 right-2"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onShowMore?.();
|
|
||||||
}}
|
|
||||||
className="p-1.5 rounded-lg bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
|
|
||||||
title="更多操作"
|
|
||||||
>
|
|
||||||
<MoreHorizontal className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</motion.div>
|
|
||||||
</motion.div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default SkillCard;
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
/**
|
|
||||||
* Node Palette Component
|
|
||||||
*
|
|
||||||
* Draggable palette of available node types.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { DragEvent } from 'react';
|
|
||||||
import type { NodePaletteItem, NodeCategory } from '../../lib/workflow-builder/types';
|
|
||||||
|
|
||||||
interface NodePaletteProps {
|
|
||||||
categories: Record<NodeCategory, NodePaletteItem[]>;
|
|
||||||
onDragStart: (type: string) => void;
|
|
||||||
onDragEnd: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const categoryLabels: Record<NodeCategory, { label: string; color: string }> = {
|
|
||||||
input: { label: 'Input', color: 'emerald' },
|
|
||||||
ai: { label: 'AI & Skills', color: 'violet' },
|
|
||||||
action: { label: 'Actions', color: 'amber' },
|
|
||||||
control: { label: 'Control Flow', color: 'orange' },
|
|
||||||
output: { label: 'Output', color: 'blue' },
|
|
||||||
};
|
|
||||||
|
|
||||||
export function NodePalette({ categories, onDragStart, onDragEnd }: NodePaletteProps) {
|
|
||||||
const handleDragStart = (event: DragEvent, type: string) => {
|
|
||||||
event.dataTransfer.setData('application/reactflow', type);
|
|
||||||
event.dataTransfer.effectAllowed = 'move';
|
|
||||||
onDragStart(type);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDragEnd = () => {
|
|
||||||
onDragEnd();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="w-64 bg-white border-r border-gray-200 overflow-y-auto">
|
|
||||||
<div className="p-4 border-b border-gray-200">
|
|
||||||
<h2 className="font-semibold text-gray-800">Nodes</h2>
|
|
||||||
<p className="text-sm text-gray-500">Drag nodes to canvas</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="p-2">
|
|
||||||
{(Object.keys(categories) as NodeCategory[]).map((category) => {
|
|
||||||
const items = categories[category];
|
|
||||||
if (items.length === 0) return null;
|
|
||||||
|
|
||||||
const { label, color } = categoryLabels[category];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={category} className="mb-4">
|
|
||||||
<h3
|
|
||||||
className={`text-sm font-medium text-${color}-700 mb-2 px-2`}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<div className="space-y-1">
|
|
||||||
{items.map((item) => (
|
|
||||||
<div
|
|
||||||
key={item.type}
|
|
||||||
draggable
|
|
||||||
onDragStart={(e) => handleDragStart(e, item.type)}
|
|
||||||
onDragEnd={handleDragEnd}
|
|
||||||
className={`
|
|
||||||
flex items-center gap-3 px-3 py-2 rounded-lg
|
|
||||||
bg-gray-50 hover:bg-gray-100 cursor-grab
|
|
||||||
border border-transparent hover:border-gray-200
|
|
||||||
transition-all duration-150
|
|
||||||
active:cursor-grabbing
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
<span className="text-lg">{item.icon}</span>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="font-medium text-gray-700 text-sm">
|
|
||||||
{item.label}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-gray-500 truncate">
|
|
||||||
{item.description}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default NodePalette;
|
|
||||||
@@ -1,301 +0,0 @@
|
|||||||
/**
|
|
||||||
* Property Panel Component
|
|
||||||
*
|
|
||||||
* Panel for editing node properties.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
|
||||||
import type { WorkflowNodeData } from '../../lib/workflow-builder/types';
|
|
||||||
|
|
||||||
interface PropertyPanelProps {
|
|
||||||
nodeId: string;
|
|
||||||
nodeData: WorkflowNodeData | undefined;
|
|
||||||
onUpdate: (data: Partial<WorkflowNodeData>) => void;
|
|
||||||
onDelete: () => void;
|
|
||||||
onClose: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PropertyPanel({
|
|
||||||
nodeData,
|
|
||||||
onUpdate,
|
|
||||||
onDelete,
|
|
||||||
onClose,
|
|
||||||
}: PropertyPanelProps) {
|
|
||||||
const [localData, setLocalData] = useState<Partial<WorkflowNodeData>>({});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (nodeData) {
|
|
||||||
setLocalData(nodeData);
|
|
||||||
}
|
|
||||||
}, [nodeData]);
|
|
||||||
|
|
||||||
if (!nodeData) return null;
|
|
||||||
|
|
||||||
const handleChange = (field: string, value: unknown) => {
|
|
||||||
const updated = { ...localData, [field]: value };
|
|
||||||
setLocalData(updated);
|
|
||||||
onUpdate({ [field]: value } as Partial<WorkflowNodeData>);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="w-80 bg-white border-l border-gray-200 overflow-y-auto">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-200">
|
|
||||||
<h2 className="font-semibold text-gray-800">Properties</h2>
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
className="text-gray-400 hover:text-gray-600"
|
|
||||||
>
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Content */}
|
|
||||||
<div className="p-4 space-y-4">
|
|
||||||
{/* Common Fields */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
Label
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={localData.label || ''}
|
|
||||||
onChange={(e) => handleChange('label', e.target.value)}
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Type-specific Fields */}
|
|
||||||
{renderTypeSpecificFields(nodeData.type, localData, handleChange)}
|
|
||||||
|
|
||||||
{/* Delete Button */}
|
|
||||||
<div className="pt-4 border-t border-gray-200">
|
|
||||||
<button
|
|
||||||
onClick={onDelete}
|
|
||||||
className="w-full px-4 py-2 text-red-600 bg-red-50 border border-red-200 rounded-lg hover:bg-red-100"
|
|
||||||
>
|
|
||||||
Delete Node
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderTypeSpecificFields(
|
|
||||||
type: string,
|
|
||||||
data: Partial<WorkflowNodeData>,
|
|
||||||
onChange: (field: string, value: unknown) => void
|
|
||||||
) {
|
|
||||||
// Type-safe property accessors for union-typed node data
|
|
||||||
const d = data as Record<string, unknown>;
|
|
||||||
const str = (key: string): string => (d[key] as string) || '';
|
|
||||||
const num = (key: string): number | string => (d[key] as number) ?? '';
|
|
||||||
const bool = (key: string): boolean => (d[key] as boolean) || false;
|
|
||||||
const arr = (key: string): string[] => (d[key] as string[]) || [];
|
|
||||||
const obj = (key: string): Record<string, unknown> => (d[key] as Record<string, unknown>) || {};
|
|
||||||
switch (type) {
|
|
||||||
case 'input':
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
Variable Name
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={str('variableName')}
|
|
||||||
onChange={(e) => onChange('variableName', e.target.value)}
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg font-mono"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
Default Value
|
|
||||||
</label>
|
|
||||||
<textarea
|
|
||||||
value={str('defaultValue')}
|
|
||||||
onChange={(e) => {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(e.target.value);
|
|
||||||
onChange('defaultValue', parsed);
|
|
||||||
} catch {
|
|
||||||
onChange('defaultValue', e.target.value);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg font-mono text-sm"
|
|
||||||
rows={3}
|
|
||||||
placeholder="JSON or string value"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
||||||
case 'llm':
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
Template
|
|
||||||
</label>
|
|
||||||
<textarea
|
|
||||||
value={str('template')}
|
|
||||||
onChange={(e) => onChange('template', e.target.value)}
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg font-mono text-sm"
|
|
||||||
rows={6}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
Model Override
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={str('model')}
|
|
||||||
onChange={(e) => onChange('model', e.target.value)}
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
|
|
||||||
placeholder="e.g., gpt-4"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
Temperature
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min="0"
|
|
||||||
max="2"
|
|
||||||
step="0.1"
|
|
||||||
value={num('temperature')}
|
|
||||||
onChange={(e) => onChange('temperature', parseFloat(e.target.value))}
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={bool('jsonMode')}
|
|
||||||
onChange={(e) => onChange('jsonMode', e.target.checked)}
|
|
||||||
className="w-4 h-4 text-blue-600 rounded"
|
|
||||||
/>
|
|
||||||
<label className="text-sm text-gray-700">JSON Mode</label>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
||||||
case 'skill':
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
Skill ID
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={str('skillId')}
|
|
||||||
onChange={(e) => onChange('skillId', e.target.value)}
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg font-mono"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
Input Mappings (JSON)
|
|
||||||
</label>
|
|
||||||
<textarea
|
|
||||||
value={JSON.stringify(obj('inputMappings'), null, 2)}
|
|
||||||
onChange={(e) => {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(e.target.value);
|
|
||||||
onChange('inputMappings', parsed);
|
|
||||||
} catch {
|
|
||||||
// Invalid JSON, ignore
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg font-mono text-sm"
|
|
||||||
rows={4}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
||||||
case 'hand':
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
Hand ID
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={str('handId')}
|
|
||||||
onChange={(e) => onChange('handId', e.target.value)}
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg font-mono"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
Action
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={str('action')}
|
|
||||||
onChange={(e) => onChange('action', e.target.value)}
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
||||||
case 'export':
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
Formats
|
|
||||||
</label>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{['json', 'markdown', 'html', 'pptx', 'pdf'].map((format) => (
|
|
||||||
<label key={format} className="flex items-center gap-2">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={arr('formats').includes(format)}
|
|
||||||
onChange={(e) => {
|
|
||||||
const formats = arr('formats');
|
|
||||||
if (e.target.checked) {
|
|
||||||
onChange('formats', [...formats, format]);
|
|
||||||
} else {
|
|
||||||
onChange('formats', formats.filter((f) => f !== format));
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="w-4 h-4 text-blue-600 rounded"
|
|
||||||
/>
|
|
||||||
<span className="text-sm text-gray-700 capitalize">{format}</span>
|
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
Output Directory
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={str('outputDir')}
|
|
||||||
onChange={(e) => onChange('outputDir', e.target.value)}
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
|
|
||||||
placeholder="./output"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
||||||
default:
|
|
||||||
return (
|
|
||||||
<div className="text-sm text-gray-500 italic">
|
|
||||||
No additional properties for this node type.
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default PropertyPanel;
|
|
||||||
@@ -1,324 +0,0 @@
|
|||||||
/**
|
|
||||||
* Workflow Builder Component
|
|
||||||
*
|
|
||||||
* Visual workflow editor using React Flow for creating and editing
|
|
||||||
* Pipeline DSL configurations.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useCallback, useRef, useEffect } from 'react';
|
|
||||||
import {
|
|
||||||
ReactFlow,
|
|
||||||
Controls,
|
|
||||||
Background,
|
|
||||||
MiniMap,
|
|
||||||
BackgroundVariant,
|
|
||||||
Connection,
|
|
||||||
addEdge,
|
|
||||||
useNodesState,
|
|
||||||
useEdgesState,
|
|
||||||
Node,
|
|
||||||
NodeChange,
|
|
||||||
EdgeChange,
|
|
||||||
Edge,
|
|
||||||
NodeTypes,
|
|
||||||
ReactFlowProvider,
|
|
||||||
useReactFlow,
|
|
||||||
} from '@xyflow/react';
|
|
||||||
import '@xyflow/react/dist/style.css';
|
|
||||||
|
|
||||||
import { useWorkflowBuilderStore, paletteCategories } from '../../store/workflowBuilderStore';
|
|
||||||
import type { WorkflowNodeData, WorkflowNodeType } from '../../lib/workflow-builder/types';
|
|
||||||
|
|
||||||
// Import custom node components
|
|
||||||
import { InputNode } from './nodes/InputNode';
|
|
||||||
import { LlmNode } from './nodes/LlmNode';
|
|
||||||
import { SkillNode } from './nodes/SkillNode';
|
|
||||||
import { HandNode } from './nodes/HandNode';
|
|
||||||
import { ConditionNode } from './nodes/ConditionNode';
|
|
||||||
import { ParallelNode } from './nodes/ParallelNode';
|
|
||||||
import { ExportNode } from './nodes/ExportNode';
|
|
||||||
import { HttpNode } from './nodes/HttpNode';
|
|
||||||
import { OrchestrationNode } from './nodes/OrchestrationNode';
|
|
||||||
|
|
||||||
import { NodePalette } from './NodePalette';
|
|
||||||
import { PropertyPanel } from './PropertyPanel';
|
|
||||||
import { WorkflowToolbar } from './WorkflowToolbar';
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// Node Types Configuration
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
const nodeTypes: NodeTypes = {
|
|
||||||
input: InputNode,
|
|
||||||
llm: LlmNode,
|
|
||||||
skill: SkillNode,
|
|
||||||
hand: HandNode,
|
|
||||||
condition: ConditionNode,
|
|
||||||
parallel: ParallelNode,
|
|
||||||
export: ExportNode,
|
|
||||||
http: HttpNode,
|
|
||||||
orchestration: OrchestrationNode,
|
|
||||||
};
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// Main Component
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
export function WorkflowBuilderInternal() {
|
|
||||||
const reactFlowWrapper = useRef<HTMLDivElement>(null);
|
|
||||||
const { screenToFlowPosition } = useReactFlow();
|
|
||||||
|
|
||||||
const {
|
|
||||||
canvas,
|
|
||||||
isDirty,
|
|
||||||
selectedNodeId,
|
|
||||||
validation,
|
|
||||||
addNode,
|
|
||||||
updateNode,
|
|
||||||
deleteNode,
|
|
||||||
addEdge: addStoreEdge,
|
|
||||||
selectNode,
|
|
||||||
saveWorkflow,
|
|
||||||
validate,
|
|
||||||
setDragging,
|
|
||||||
} = useWorkflowBuilderStore();
|
|
||||||
|
|
||||||
// Local state for React Flow
|
|
||||||
const [nodes, setNodes, onNodesChange] = useNodesState<Node<WorkflowNodeData>>([]);
|
|
||||||
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
|
|
||||||
|
|
||||||
// Sync canvas state with React Flow
|
|
||||||
useEffect(() => {
|
|
||||||
if (canvas) {
|
|
||||||
setNodes(canvas.nodes.map(n => ({
|
|
||||||
id: n.id,
|
|
||||||
type: n.type,
|
|
||||||
position: n.position,
|
|
||||||
data: n.data as WorkflowNodeData,
|
|
||||||
})));
|
|
||||||
setEdges(canvas.edges.map(e => ({
|
|
||||||
id: e.id,
|
|
||||||
source: e.source,
|
|
||||||
target: e.target,
|
|
||||||
type: e.type || 'default',
|
|
||||||
animated: true,
|
|
||||||
})));
|
|
||||||
} else {
|
|
||||||
setNodes([]);
|
|
||||||
setEdges([]);
|
|
||||||
}
|
|
||||||
}, [canvas?.id]);
|
|
||||||
|
|
||||||
// Handle node changes (position, selection)
|
|
||||||
const handleNodesChange = useCallback(
|
|
||||||
(changes: NodeChange<Node<WorkflowNodeData>>[]) => {
|
|
||||||
onNodesChange(changes);
|
|
||||||
|
|
||||||
// Sync position changes back to store
|
|
||||||
for (const change of changes) {
|
|
||||||
if (change.type === 'position' && change.position) {
|
|
||||||
const node = nodes.find(n => n.id === change.id);
|
|
||||||
if (node) {
|
|
||||||
// Position updates are handled by React Flow internally
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (change.type === 'select') {
|
|
||||||
selectNode(change.selected ? change.id : null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[onNodesChange, nodes, selectNode]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Handle edge changes
|
|
||||||
const handleEdgesChange = useCallback(
|
|
||||||
(changes: EdgeChange[]) => {
|
|
||||||
onEdgesChange(changes);
|
|
||||||
},
|
|
||||||
[onEdgesChange]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Handle new connections
|
|
||||||
const onConnect = useCallback(
|
|
||||||
(connection: Connection) => {
|
|
||||||
if (connection.source && connection.target) {
|
|
||||||
addStoreEdge(connection.source, connection.target);
|
|
||||||
setEdges((eds) =>
|
|
||||||
addEdge(
|
|
||||||
{
|
|
||||||
...connection,
|
|
||||||
type: 'default',
|
|
||||||
animated: true,
|
|
||||||
},
|
|
||||||
eds
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[addStoreEdge, setEdges]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Handle node click
|
|
||||||
const onNodeClick = useCallback(
|
|
||||||
(_event: React.MouseEvent, node: Node) => {
|
|
||||||
selectNode(node.id);
|
|
||||||
},
|
|
||||||
[selectNode]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Handle pane click (deselect)
|
|
||||||
const onPaneClick = useCallback(() => {
|
|
||||||
selectNode(null);
|
|
||||||
}, [selectNode]);
|
|
||||||
|
|
||||||
// Handle drag over for palette items
|
|
||||||
const onDragOver = useCallback((event: React.DragEvent) => {
|
|
||||||
event.preventDefault();
|
|
||||||
event.dataTransfer.dropEffect = 'move';
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Handle drop from palette
|
|
||||||
const onDrop = useCallback(
|
|
||||||
(event: React.DragEvent) => {
|
|
||||||
event.preventDefault();
|
|
||||||
|
|
||||||
const type = event.dataTransfer.getData('application/reactflow') as WorkflowNodeType;
|
|
||||||
if (!type) return;
|
|
||||||
|
|
||||||
const position = screenToFlowPosition({
|
|
||||||
x: event.clientX,
|
|
||||||
y: event.clientY,
|
|
||||||
});
|
|
||||||
|
|
||||||
addNode(type, position);
|
|
||||||
},
|
|
||||||
[screenToFlowPosition, addNode]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Handle keyboard shortcuts
|
|
||||||
useEffect(() => {
|
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
|
||||||
// Delete selected node
|
|
||||||
if ((event.key === 'Delete' || event.key === 'Backspace') && selectedNodeId) {
|
|
||||||
deleteNode(selectedNodeId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save workflow
|
|
||||||
if ((event.ctrlKey || event.metaKey) && event.key === 's') {
|
|
||||||
event.preventDefault();
|
|
||||||
saveWorkflow();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener('keydown', handleKeyDown);
|
|
||||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
|
||||||
}, [selectedNodeId, deleteNode, saveWorkflow]);
|
|
||||||
|
|
||||||
if (!canvas) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-center h-full bg-gray-50">
|
|
||||||
<div className="text-center">
|
|
||||||
<p className="text-gray-500 mb-4">No workflow loaded</p>
|
|
||||||
<button
|
|
||||||
onClick={() => useWorkflowBuilderStore.getState().createNewWorkflow('New Workflow')}
|
|
||||||
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
|
|
||||||
>
|
|
||||||
Create New Workflow
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex h-full">
|
|
||||||
{/* Node Palette */}
|
|
||||||
<NodePalette
|
|
||||||
categories={paletteCategories}
|
|
||||||
onDragStart={() => {
|
|
||||||
setDragging(true);
|
|
||||||
}}
|
|
||||||
onDragEnd={() => {
|
|
||||||
setDragging(false);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Canvas */}
|
|
||||||
<div className="flex-1 flex flex-col">
|
|
||||||
<WorkflowToolbar
|
|
||||||
workflowName={canvas.name}
|
|
||||||
isDirty={isDirty}
|
|
||||||
validation={validation}
|
|
||||||
onSave={saveWorkflow}
|
|
||||||
onValidate={validate}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div ref={reactFlowWrapper} className="flex-1">
|
|
||||||
<ReactFlow
|
|
||||||
nodes={nodes}
|
|
||||||
edges={edges}
|
|
||||||
onNodesChange={handleNodesChange}
|
|
||||||
onEdgesChange={handleEdgesChange}
|
|
||||||
onConnect={onConnect}
|
|
||||||
onNodeClick={onNodeClick}
|
|
||||||
onPaneClick={onPaneClick}
|
|
||||||
onDragOver={onDragOver}
|
|
||||||
onDrop={onDrop}
|
|
||||||
nodeTypes={nodeTypes}
|
|
||||||
fitView
|
|
||||||
snapToGrid
|
|
||||||
snapGrid={[15, 15]}
|
|
||||||
defaultEdgeOptions={{
|
|
||||||
animated: true,
|
|
||||||
type: 'smoothstep',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Controls />
|
|
||||||
<MiniMap
|
|
||||||
nodeColor={(node) => {
|
|
||||||
switch (node.type) {
|
|
||||||
case 'input':
|
|
||||||
return '#10b981';
|
|
||||||
case 'llm':
|
|
||||||
return '#8b5cf6';
|
|
||||||
case 'skill':
|
|
||||||
return '#f59e0b';
|
|
||||||
case 'hand':
|
|
||||||
return '#ef4444';
|
|
||||||
case 'export':
|
|
||||||
return '#3b82f6';
|
|
||||||
default:
|
|
||||||
return '#6b7280';
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
maskColor="rgba(0, 0, 0, 0.1)"
|
|
||||||
/>
|
|
||||||
<Background variant={BackgroundVariant.Dots} gap={20} size={1} />
|
|
||||||
</ReactFlow>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Property Panel */}
|
|
||||||
{selectedNodeId && (
|
|
||||||
<PropertyPanel
|
|
||||||
nodeId={selectedNodeId}
|
|
||||||
nodeData={nodes.find(n => n.id === selectedNodeId)?.data as WorkflowNodeData}
|
|
||||||
onUpdate={(data) => updateNode(selectedNodeId, data)}
|
|
||||||
onDelete={() => deleteNode(selectedNodeId)}
|
|
||||||
onClose={() => selectNode(null)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Export with provider
|
|
||||||
export function WorkflowBuilder() {
|
|
||||||
return (
|
|
||||||
<ReactFlowProvider>
|
|
||||||
<WorkflowBuilderInternal />
|
|
||||||
</ReactFlowProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default WorkflowBuilder;
|
|
||||||
@@ -1,166 +0,0 @@
|
|||||||
/**
|
|
||||||
* Workflow Toolbar Component
|
|
||||||
*
|
|
||||||
* Toolbar with actions for the workflow builder.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useState } from 'react';
|
|
||||||
import type { ValidationResult } from '../../lib/workflow-builder/types';
|
|
||||||
import { canvasToYaml } from '../../lib/workflow-builder/yaml-converter';
|
|
||||||
import { useWorkflowBuilderStore } from '../../store/workflowBuilderStore';
|
|
||||||
|
|
||||||
interface WorkflowToolbarProps {
|
|
||||||
workflowName: string;
|
|
||||||
isDirty: boolean;
|
|
||||||
validation: ValidationResult | null;
|
|
||||||
onSave: () => void;
|
|
||||||
onValidate: () => ValidationResult;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function WorkflowToolbar({
|
|
||||||
workflowName,
|
|
||||||
isDirty,
|
|
||||||
validation,
|
|
||||||
onSave,
|
|
||||||
onValidate,
|
|
||||||
}: WorkflowToolbarProps) {
|
|
||||||
const [isPreviewOpen, setIsPreviewOpen] = useState(false);
|
|
||||||
const [yamlPreview, setYamlPreview] = useState('');
|
|
||||||
const canvas = useWorkflowBuilderStore(state => state.canvas);
|
|
||||||
|
|
||||||
const handlePreviewYaml = () => {
|
|
||||||
if (canvas) {
|
|
||||||
const yaml = canvasToYaml(canvas);
|
|
||||||
setYamlPreview(yaml);
|
|
||||||
setIsPreviewOpen(true);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCopyYaml = async () => {
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(yamlPreview);
|
|
||||||
alert('YAML copied to clipboard!');
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to copy:', err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDownloadYaml = () => {
|
|
||||||
const blob = new Blob([yamlPreview], { type: 'text/yaml' });
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = url;
|
|
||||||
a.download = `${workflowName.replace(/\s+/g, '-').toLowerCase()}.yaml`;
|
|
||||||
a.click();
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="flex items-center justify-between px-4 py-2 bg-white border-b border-gray-200">
|
|
||||||
{/* Left: Workflow Name */}
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<h1 className="font-semibold text-gray-800">{workflowName}</h1>
|
|
||||||
{isDirty && (
|
|
||||||
<span className="text-sm text-amber-600 flex items-center gap-1">
|
|
||||||
<span className="w-2 h-2 bg-amber-400 rounded-full animate-pulse" />
|
|
||||||
Unsaved
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Center: Validation Status */}
|
|
||||||
{validation && (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{validation.valid ? (
|
|
||||||
<span className="text-sm text-green-600 flex items-center gap-1">
|
|
||||||
✓ Valid
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="text-sm text-red-600 flex items-center gap-1">
|
|
||||||
✕ {validation.errors.length} error(s)
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{validation.warnings.length > 0 && (
|
|
||||||
<span className="text-sm text-amber-600">
|
|
||||||
{validation.warnings.length} warning(s)
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Right: Actions */}
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<button
|
|
||||||
onClick={onValidate}
|
|
||||||
className="px-3 py-1.5 text-sm text-gray-600 hover:text-gray-800 hover:bg-gray-100 rounded-lg"
|
|
||||||
>
|
|
||||||
Validate
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={handlePreviewYaml}
|
|
||||||
className="px-3 py-1.5 text-sm text-gray-600 hover:text-gray-800 hover:bg-gray-100 rounded-lg"
|
|
||||||
>
|
|
||||||
Preview YAML
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={onSave}
|
|
||||||
disabled={!isDirty}
|
|
||||||
className={`
|
|
||||||
px-4 py-1.5 text-sm rounded-lg font-medium
|
|
||||||
${isDirty
|
|
||||||
? 'bg-blue-500 text-white hover:bg-blue-600'
|
|
||||||
: 'bg-gray-100 text-gray-400 cursor-not-allowed'
|
|
||||||
}
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
Save
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* YAML Preview Modal */}
|
|
||||||
{isPreviewOpen && (
|
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
|
||||||
<div className="bg-white rounded-xl shadow-xl w-[800px] max-h-[80vh] overflow-hidden">
|
|
||||||
{/* Modal Header */}
|
|
||||||
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-200">
|
|
||||||
<h2 className="font-semibold text-gray-800">Pipeline YAML</h2>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<button
|
|
||||||
onClick={handleCopyYaml}
|
|
||||||
className="px-3 py-1.5 text-sm text-gray-600 hover:bg-gray-100 rounded-lg"
|
|
||||||
>
|
|
||||||
Copy
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={handleDownloadYaml}
|
|
||||||
className="px-3 py-1.5 text-sm text-gray-600 hover:bg-gray-100 rounded-lg"
|
|
||||||
>
|
|
||||||
Download
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setIsPreviewOpen(false)}
|
|
||||||
className="px-3 py-1.5 text-sm text-gray-400 hover:text-gray-600"
|
|
||||||
>
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* YAML Content */}
|
|
||||||
<div className="p-4 overflow-y-auto max-h-[60vh]">
|
|
||||||
<pre className="text-sm font-mono text-gray-800 whitespace-pre-wrap">
|
|
||||||
{yamlPreview}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default WorkflowToolbar;
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
/**
|
|
||||||
* Workflow Builder Components
|
|
||||||
*
|
|
||||||
* Export all workflow builder components.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export { WorkflowBuilder, WorkflowBuilderInternal } from './WorkflowBuilder';
|
|
||||||
export { NodePalette } from './NodePalette';
|
|
||||||
export { PropertyPanel } from './PropertyPanel';
|
|
||||||
export { WorkflowToolbar } from './WorkflowToolbar';
|
|
||||||
|
|
||||||
// Node components
|
|
||||||
export { InputNode } from './nodes/InputNode';
|
|
||||||
export { LlmNode } from './nodes/LlmNode';
|
|
||||||
export { SkillNode } from './nodes/SkillNode';
|
|
||||||
export { HandNode } from './nodes/HandNode';
|
|
||||||
export { ConditionNode } from './nodes/ConditionNode';
|
|
||||||
export { ParallelNode } from './nodes/ParallelNode';
|
|
||||||
export { ExportNode } from './nodes/ExportNode';
|
|
||||||
export { HttpNode } from './nodes/HttpNode';
|
|
||||||
export { OrchestrationNode } from './nodes/OrchestrationNode';
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
/**
|
|
||||||
* Condition Node Component
|
|
||||||
*
|
|
||||||
* Node for conditional branching.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { memo } from 'react';
|
|
||||||
import { Handle, Position, NodeProps, Node } from '@xyflow/react';
|
|
||||||
import type { ConditionNodeData } from '../../../lib/workflow-builder/types';
|
|
||||||
|
|
||||||
type ConditionNodeType = Node<ConditionNodeData>;
|
|
||||||
|
|
||||||
export const ConditionNode = memo(({ data, selected }: NodeProps<ConditionNodeType>) => {
|
|
||||||
const branchCount = data.branches.length + (data.hasDefault ? 1 : 0);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`
|
|
||||||
px-4 py-3 rounded-lg border-2 min-w-[200px]
|
|
||||||
bg-orange-50 border-orange-300
|
|
||||||
${selected ? 'border-orange-500 shadow-lg shadow-orange-200' : ''}
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
{/* Input Handle */}
|
|
||||||
<Handle
|
|
||||||
type="target"
|
|
||||||
position={Position.Left}
|
|
||||||
className="w-3 h-3 bg-orange-400 border-2 border-white"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center gap-2 mb-2">
|
|
||||||
<span className="text-lg">🔀</span>
|
|
||||||
<span className="font-medium text-orange-800">{data.label}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Condition Preview */}
|
|
||||||
<div className="text-sm text-orange-600 bg-orange-100 rounded px-2 py-1 font-mono mb-2">
|
|
||||||
{data.condition || 'No condition'}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Branches */}
|
|
||||||
<div className="space-y-1">
|
|
||||||
{data.branches.map((branch: { label?: string; when: string }, index: number) => (
|
|
||||||
<div key={index} className="flex items-center justify-between">
|
|
||||||
<div className="relative">
|
|
||||||
{/* Branch Output Handle */}
|
|
||||||
<Handle
|
|
||||||
type="source"
|
|
||||||
position={Position.Right}
|
|
||||||
id={`branch-${index}`}
|
|
||||||
style={{ top: `${((index + 1) / (branchCount + 1)) * 100}%` }}
|
|
||||||
className="w-3 h-3 bg-orange-400 border-2 border-white"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<span className="text-xs text-orange-500 truncate max-w-[120px]">
|
|
||||||
{branch.label || branch.when}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{data.hasDefault && (
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<Handle
|
|
||||||
type="source"
|
|
||||||
position={Position.Right}
|
|
||||||
id="default"
|
|
||||||
style={{ top: '100%' }}
|
|
||||||
className="w-3 h-3 bg-gray-400 border-2 border-white"
|
|
||||||
/>
|
|
||||||
<span className="text-xs text-gray-500">Default</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
ConditionNode.displayName = 'ConditionNode';
|
|
||||||
|
|
||||||
export default ConditionNode;
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
/**
|
|
||||||
* Export Node Component
|
|
||||||
*
|
|
||||||
* Node for exporting workflow results to various formats.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { memo } from 'react';
|
|
||||||
import { Handle, Position, NodeProps, Node } from '@xyflow/react';
|
|
||||||
import type { ExportNodeData } from '../../../lib/workflow-builder/types';
|
|
||||||
|
|
||||||
export const ExportNode = memo(({ data, selected }: NodeProps<Node<ExportNodeData>>) => {
|
|
||||||
const formatLabels: Record<string, string> = {
|
|
||||||
pptx: 'PowerPoint',
|
|
||||||
html: 'HTML',
|
|
||||||
pdf: 'PDF',
|
|
||||||
markdown: 'Markdown',
|
|
||||||
json: 'JSON',
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`
|
|
||||||
px-4 py-3 rounded-lg border-2 min-w-[180px]
|
|
||||||
bg-blue-50 border-blue-300
|
|
||||||
${selected ? 'border-blue-500 shadow-lg shadow-blue-200' : ''}
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
{/* Input Handle */}
|
|
||||||
<Handle
|
|
||||||
type="target"
|
|
||||||
position={Position.Left}
|
|
||||||
className="w-3 h-3 bg-blue-400 border-2 border-white"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Output Handle */}
|
|
||||||
<Handle
|
|
||||||
type="source"
|
|
||||||
position={Position.Right}
|
|
||||||
className="w-3 h-3 bg-blue-500 border-2 border-white"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center gap-2 mb-2">
|
|
||||||
<span className="text-lg">📤</span>
|
|
||||||
<span className="font-medium text-blue-800">{data.label}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Formats */}
|
|
||||||
<div className="flex flex-wrap gap-1">
|
|
||||||
{data.formats.map((format) => (
|
|
||||||
<span
|
|
||||||
key={format}
|
|
||||||
className="text-xs bg-blue-100 text-blue-700 px-2 py-0.5 rounded"
|
|
||||||
>
|
|
||||||
{formatLabels[format] || format}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Output Directory */}
|
|
||||||
{data.outputDir && (
|
|
||||||
<div className="text-xs text-blue-500 mt-2 truncate">
|
|
||||||
📁 {data.outputDir}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
ExportNode.displayName = 'ExportNode';
|
|
||||||
|
|
||||||
export default ExportNode;
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
/**
|
|
||||||
* Hand Node Component
|
|
||||||
*
|
|
||||||
* Node for executing hand actions.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { memo } from 'react';
|
|
||||||
import { Handle, Position, NodeProps, Node } from '@xyflow/react';
|
|
||||||
import type { HandNodeData } from '../../../lib/workflow-builder/types';
|
|
||||||
|
|
||||||
type HandNodeType = Node<HandNodeData>;
|
|
||||||
|
|
||||||
export const HandNode = memo(({ data, selected }: NodeProps<HandNodeType>) => {
|
|
||||||
const hasHand = Boolean(data.handId);
|
|
||||||
const hasAction = Boolean(data.action);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`
|
|
||||||
px-4 py-3 rounded-lg border-2 min-w-[180px]
|
|
||||||
bg-rose-50 border-rose-300
|
|
||||||
${selected ? 'border-rose-500 shadow-lg shadow-rose-200' : ''}
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
{/* Input Handle */}
|
|
||||||
<Handle
|
|
||||||
type="target"
|
|
||||||
position={Position.Left}
|
|
||||||
className="w-3 h-3 bg-rose-400 border-2 border-white"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Output Handle */}
|
|
||||||
<Handle
|
|
||||||
type="source"
|
|
||||||
position={Position.Right}
|
|
||||||
className="w-3 h-3 bg-rose-500 border-2 border-white"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center gap-2 mb-2">
|
|
||||||
<span className="text-lg">✋</span>
|
|
||||||
<span className="font-medium text-rose-800">{data.label}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Hand Info */}
|
|
||||||
<div className="space-y-1">
|
|
||||||
<div className={`text-sm ${hasHand ? 'text-rose-600' : 'text-rose-400 italic'}`}>
|
|
||||||
{hasHand ? (
|
|
||||||
<span className="font-mono bg-rose-100 px-1.5 py-0.5 rounded">
|
|
||||||
{data.handName || data.handId}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
'No hand selected'
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{hasAction && (
|
|
||||||
<div className="text-xs text-rose-500">
|
|
||||||
Action: <span className="font-mono">{data.action}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Params Count */}
|
|
||||||
{Object.keys(data.params).length > 0 && (
|
|
||||||
<div className="text-xs text-rose-500 mt-1">
|
|
||||||
{Object.keys(data.params).length} param(s)
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
HandNode.displayName = 'HandNode';
|
|
||||||
|
|
||||||
export default HandNode;
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
/**
|
|
||||||
* HTTP Node Component
|
|
||||||
*
|
|
||||||
* Node for making HTTP requests.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { memo } from 'react';
|
|
||||||
import { Handle, Position, NodeProps, Node } from '@xyflow/react';
|
|
||||||
import type { HttpNodeData } from '../../../lib/workflow-builder/types';
|
|
||||||
|
|
||||||
const methodColors: Record<string, string> = {
|
|
||||||
GET: 'bg-green-100 text-green-700',
|
|
||||||
POST: 'bg-blue-100 text-blue-700',
|
|
||||||
PUT: 'bg-yellow-100 text-yellow-700',
|
|
||||||
DELETE: 'bg-red-100 text-red-700',
|
|
||||||
PATCH: 'bg-purple-100 text-purple-700',
|
|
||||||
};
|
|
||||||
|
|
||||||
export const HttpNode = memo(({ data, selected }: NodeProps<Node<HttpNodeData>>) => {
|
|
||||||
const hasUrl = Boolean(data.url);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`
|
|
||||||
px-4 py-3 rounded-lg border-2 min-w-[200px]
|
|
||||||
bg-slate-50 border-slate-300
|
|
||||||
${selected ? 'border-slate-500 shadow-lg shadow-slate-200' : ''}
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
{/* Input Handle */}
|
|
||||||
<Handle
|
|
||||||
type="target"
|
|
||||||
position={Position.Left}
|
|
||||||
className="w-3 h-3 bg-slate-400 border-2 border-white"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Output Handle */}
|
|
||||||
<Handle
|
|
||||||
type="source"
|
|
||||||
position={Position.Right}
|
|
||||||
className="w-3 h-3 bg-slate-500 border-2 border-white"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center gap-2 mb-2">
|
|
||||||
<span className="text-lg">🌐</span>
|
|
||||||
<span className="font-medium text-slate-800">{data.label}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Method Badge */}
|
|
||||||
<div className="flex items-center gap-2 mb-2">
|
|
||||||
<span className={`text-xs font-bold px-2 py-0.5 rounded ${methodColors[data.method]}`}>
|
|
||||||
{data.method}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* URL */}
|
|
||||||
<div className={`text-sm font-mono bg-slate-100 rounded px-2 py-1 truncate ${hasUrl ? 'text-slate-600' : 'text-slate-400 italic'}`}>
|
|
||||||
{hasUrl ? data.url : 'No URL specified'}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Headers Count */}
|
|
||||||
{Object.keys(data.headers).length > 0 && (
|
|
||||||
<div className="text-xs text-slate-500 mt-2">
|
|
||||||
{Object.keys(data.headers).length} header(s)
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Body Indicator */}
|
|
||||||
{data.body && (
|
|
||||||
<div className="text-xs text-slate-500 mt-1">
|
|
||||||
Has body content
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
HttpNode.displayName = 'HttpNode';
|
|
||||||
|
|
||||||
export default HttpNode;
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
/**
|
|
||||||
* Input Node Component
|
|
||||||
*
|
|
||||||
* Node for defining workflow input variables.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { memo } from 'react';
|
|
||||||
import { Handle, Position, NodeProps, Node } from '@xyflow/react';
|
|
||||||
import type { InputNodeData } from '../../../lib/workflow-builder/types';
|
|
||||||
|
|
||||||
export const InputNode = memo(({ data, selected }: NodeProps<Node<InputNodeData>>) => {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`
|
|
||||||
px-4 py-3 rounded-lg border-2 min-w-[180px]
|
|
||||||
bg-emerald-50 border-emerald-300
|
|
||||||
${selected ? 'border-emerald-500 shadow-lg shadow-emerald-200' : ''}
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
{/* Output Handle */}
|
|
||||||
<Handle
|
|
||||||
type="source"
|
|
||||||
position={Position.Right}
|
|
||||||
className="w-3 h-3 bg-emerald-500 border-2 border-white"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center gap-2 mb-2">
|
|
||||||
<span className="text-lg">📥</span>
|
|
||||||
<span className="font-medium text-emerald-800">{data.label}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Variable Name */}
|
|
||||||
<div className="text-sm text-emerald-600">
|
|
||||||
<span className="font-mono bg-emerald-100 px-1.5 py-0.5 rounded">
|
|
||||||
{data.variableName}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Default Value Indicator */}
|
|
||||||
{data.defaultValue !== undefined && (
|
|
||||||
<div className="text-xs text-emerald-500 mt-1">
|
|
||||||
default: {typeof data.defaultValue === 'string'
|
|
||||||
? `"${data.defaultValue}"`
|
|
||||||
: JSON.stringify(data.defaultValue)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
InputNode.displayName = 'InputNode';
|
|
||||||
|
|
||||||
export default InputNode;
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
/**
|
|
||||||
* LLM Node Component
|
|
||||||
*
|
|
||||||
* Node for LLM generation actions.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { memo } from 'react';
|
|
||||||
import { Handle, Position, NodeProps, Node } from '@xyflow/react';
|
|
||||||
import type { LlmNodeData } from '../../../lib/workflow-builder/types';
|
|
||||||
|
|
||||||
export const LlmNode = memo(({ data, selected }: NodeProps<Node<LlmNodeData>>) => {
|
|
||||||
const templatePreview = data.template.length > 50
|
|
||||||
? data.template.slice(0, 50) + '...'
|
|
||||||
: data.template || 'No template';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`
|
|
||||||
px-4 py-3 rounded-lg border-2 min-w-[200px]
|
|
||||||
bg-violet-50 border-violet-300
|
|
||||||
${selected ? 'border-violet-500 shadow-lg shadow-violet-200' : ''}
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
{/* Input Handle */}
|
|
||||||
<Handle
|
|
||||||
type="target"
|
|
||||||
position={Position.Left}
|
|
||||||
className="w-3 h-3 bg-violet-400 border-2 border-white"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Output Handle */}
|
|
||||||
<Handle
|
|
||||||
type="source"
|
|
||||||
position={Position.Right}
|
|
||||||
className="w-3 h-3 bg-violet-500 border-2 border-white"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center gap-2 mb-2">
|
|
||||||
<span className="text-lg">🤖</span>
|
|
||||||
<span className="font-medium text-violet-800">{data.label}</span>
|
|
||||||
{data.jsonMode && (
|
|
||||||
<span className="text-xs bg-violet-200 text-violet-700 px-1.5 py-0.5 rounded">
|
|
||||||
JSON
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Template Preview */}
|
|
||||||
<div className="text-sm text-violet-600 bg-violet-100 rounded px-2 py-1 font-mono">
|
|
||||||
{data.isTemplateFile ? '📄 ' : ''}
|
|
||||||
{templatePreview}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Model Info */}
|
|
||||||
{(data.model || data.temperature !== undefined) && (
|
|
||||||
<div className="flex gap-2 mt-2 text-xs text-violet-500">
|
|
||||||
{data.model && <span>Model: {data.model}</span>}
|
|
||||||
{data.temperature !== undefined && (
|
|
||||||
<span>Temp: {data.temperature}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
LlmNode.displayName = 'LlmNode';
|
|
||||||
|
|
||||||
export default LlmNode;
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
/**
|
|
||||||
* Orchestration Node Component
|
|
||||||
*
|
|
||||||
* Node for executing skill orchestration graphs (DAGs).
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { memo } from 'react';
|
|
||||||
import { Handle, Position, NodeProps, Node } from '@xyflow/react';
|
|
||||||
import type { OrchestrationNodeData } from '../../../lib/workflow-builder/types';
|
|
||||||
|
|
||||||
export const OrchestrationNode = memo(({ data, selected }: NodeProps<Node<OrchestrationNodeData>>) => {
|
|
||||||
const hasGraphId = Boolean(data.graphId);
|
|
||||||
const hasGraph = Boolean(data.graph);
|
|
||||||
const inputCount = Object.keys(data.inputMappings).length;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`
|
|
||||||
px-4 py-3 rounded-lg border-2 min-w-[200px]
|
|
||||||
bg-gradient-to-br from-indigo-50 to-purple-50
|
|
||||||
border-indigo-300
|
|
||||||
${selected ? 'border-indigo-500 shadow-lg shadow-indigo-200' : ''}
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
{/* Input Handle */}
|
|
||||||
<Handle
|
|
||||||
type="target"
|
|
||||||
position={Position.Left}
|
|
||||||
className="w-3 h-3 bg-indigo-400 border-2 border-white"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Output Handle */}
|
|
||||||
<Handle
|
|
||||||
type="source"
|
|
||||||
position={Position.Right}
|
|
||||||
className="w-3 h-3 bg-indigo-500 border-2 border-white"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center gap-2 mb-2">
|
|
||||||
<span className="text-lg">🔀</span>
|
|
||||||
<span className="font-medium text-indigo-800">{data.label}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Graph Reference */}
|
|
||||||
<div className={`text-sm mb-2 ${hasGraphId || hasGraph ? 'text-indigo-600' : 'text-indigo-400 italic'}`}>
|
|
||||||
{hasGraphId ? (
|
|
||||||
<div className="flex items-center gap-1.5 bg-indigo-100 rounded px-2 py-1">
|
|
||||||
<span className="text-xs">📋</span>
|
|
||||||
<span className="font-mono text-xs">{data.graphId}</span>
|
|
||||||
</div>
|
|
||||||
) : hasGraph ? (
|
|
||||||
<div className="flex items-center gap-1.5 bg-indigo-100 rounded px-2 py-1">
|
|
||||||
<span className="text-xs">📊</span>
|
|
||||||
<span className="text-xs">Inline graph</span>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
'No graph configured'
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Input Mappings */}
|
|
||||||
{inputCount > 0 && (
|
|
||||||
<div className="text-xs text-indigo-500 mt-2">
|
|
||||||
{inputCount} input mapping(s)
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Description */}
|
|
||||||
{data.description && (
|
|
||||||
<div className="text-xs text-indigo-400 mt-2 line-clamp-2">
|
|
||||||
{data.description}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
OrchestrationNode.displayName = 'OrchestrationNode';
|
|
||||||
|
|
||||||
export default OrchestrationNode;
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
/**
|
|
||||||
* Parallel Node Component
|
|
||||||
*
|
|
||||||
* Node for parallel execution of steps.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { memo } from 'react';
|
|
||||||
import { Handle, Position, NodeProps, Node } from '@xyflow/react';
|
|
||||||
import type { ParallelNodeData } from '../../../lib/workflow-builder/types';
|
|
||||||
|
|
||||||
export const ParallelNode = memo(({ data, selected }: NodeProps<Node<ParallelNodeData>>) => {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`
|
|
||||||
px-4 py-3 rounded-lg border-2 min-w-[180px]
|
|
||||||
bg-cyan-50 border-cyan-300
|
|
||||||
${selected ? 'border-cyan-500 shadow-lg shadow-cyan-200' : ''}
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
{/* Input Handle */}
|
|
||||||
<Handle
|
|
||||||
type="target"
|
|
||||||
position={Position.Left}
|
|
||||||
className="w-3 h-3 bg-cyan-400 border-2 border-white"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Output Handle */}
|
|
||||||
<Handle
|
|
||||||
type="source"
|
|
||||||
position={Position.Right}
|
|
||||||
className="w-3 h-3 bg-cyan-500 border-2 border-white"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center gap-2 mb-2">
|
|
||||||
<span className="text-lg">⚡</span>
|
|
||||||
<span className="font-medium text-cyan-800">{data.label}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Each Expression */}
|
|
||||||
<div className="text-sm text-cyan-600 bg-cyan-100 rounded px-2 py-1 font-mono">
|
|
||||||
each: {data.each || '${inputs.items}'}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Max Workers */}
|
|
||||||
<div className="text-xs text-cyan-500 mt-2">
|
|
||||||
Max workers: {data.maxWorkers}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
ParallelNode.displayName = 'ParallelNode';
|
|
||||||
|
|
||||||
export default ParallelNode;
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
/**
|
|
||||||
* Skill Node Component
|
|
||||||
*
|
|
||||||
* Node for executing skills.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { memo } from 'react';
|
|
||||||
import { Handle, Position, NodeProps, Node } from '@xyflow/react';
|
|
||||||
import type { SkillNodeData } from '../../../lib/workflow-builder/types';
|
|
||||||
|
|
||||||
export const SkillNode = memo(({ data, selected }: NodeProps<Node<SkillNodeData>>) => {
|
|
||||||
const hasSkill = Boolean(data.skillId);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`
|
|
||||||
px-4 py-3 rounded-lg border-2 min-w-[180px]
|
|
||||||
bg-amber-50 border-amber-300
|
|
||||||
${selected ? 'border-amber-500 shadow-lg shadow-amber-200' : ''}
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
{/* Input Handle */}
|
|
||||||
<Handle
|
|
||||||
type="target"
|
|
||||||
position={Position.Left}
|
|
||||||
className="w-3 h-3 bg-amber-400 border-2 border-white"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Output Handle */}
|
|
||||||
<Handle
|
|
||||||
type="source"
|
|
||||||
position={Position.Right}
|
|
||||||
className="w-3 h-3 bg-amber-500 border-2 border-white"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center gap-2 mb-2">
|
|
||||||
<span className="text-lg">⚡</span>
|
|
||||||
<span className="font-medium text-amber-800">{data.label}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Skill ID */}
|
|
||||||
<div className={`text-sm ${hasSkill ? 'text-amber-600' : 'text-amber-400 italic'}`}>
|
|
||||||
{hasSkill ? (
|
|
||||||
<span className="font-mono bg-amber-100 px-1.5 py-0.5 rounded">
|
|
||||||
{data.skillName || data.skillId}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
'No skill selected'
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Input Mappings Count */}
|
|
||||||
{Object.keys(data.inputMappings).length > 0 && (
|
|
||||||
<div className="text-xs text-amber-500 mt-1">
|
|
||||||
{Object.keys(data.inputMappings).length} input mapping(s)
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
SkillNode.displayName = 'SkillNode';
|
|
||||||
|
|
||||||
export default SkillNode;
|
|
||||||
@@ -1,479 +0,0 @@
|
|||||||
/**
|
|
||||||
* WorkflowEditor - ZCLAW Workflow Editor Component
|
|
||||||
*
|
|
||||||
* Allows creating and editing multi-step workflows that chain
|
|
||||||
* multiple Hands together for complex task automation.
|
|
||||||
*
|
|
||||||
* Design based on ZCLAW Dashboard v0.4.0
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from 'react';
|
|
||||||
import { useHandStore, type Hand } from '../store/handStore';
|
|
||||||
import { useWorkflowStore, type Workflow } from '../store/workflowStore';
|
|
||||||
import {
|
|
||||||
X,
|
|
||||||
Plus,
|
|
||||||
Trash2,
|
|
||||||
GripVertical,
|
|
||||||
ChevronDown,
|
|
||||||
ChevronUp,
|
|
||||||
ChevronLeft,
|
|
||||||
ChevronRight,
|
|
||||||
Save,
|
|
||||||
Loader2,
|
|
||||||
AlertCircle,
|
|
||||||
GitBranch,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { safeJsonParse } from '../lib/json-utils';
|
|
||||||
|
|
||||||
// === Types ===
|
|
||||||
|
|
||||||
interface WorkflowStep {
|
|
||||||
id: string;
|
|
||||||
handName: string;
|
|
||||||
name?: string;
|
|
||||||
params?: Record<string, unknown>;
|
|
||||||
condition?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface WorkflowEditorProps {
|
|
||||||
workflow?: Workflow; // If provided, edit mode; otherwise create mode
|
|
||||||
isOpen: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
onSave: (data: {
|
|
||||||
name: string;
|
|
||||||
description?: string;
|
|
||||||
steps: Array<{
|
|
||||||
handName: string;
|
|
||||||
name?: string;
|
|
||||||
params?: Record<string, unknown>;
|
|
||||||
condition?: string;
|
|
||||||
}>;
|
|
||||||
}) => Promise<void>;
|
|
||||||
isSaving: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
// === Step Editor Component ===
|
|
||||||
|
|
||||||
interface StepEditorProps {
|
|
||||||
step: WorkflowStep;
|
|
||||||
hands: Hand[];
|
|
||||||
index: number;
|
|
||||||
onUpdate: (step: WorkflowStep) => void;
|
|
||||||
onRemove: () => void;
|
|
||||||
onMoveUp?: () => void;
|
|
||||||
onMoveDown?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function StepEditor({ step, hands, index, onUpdate, onRemove, onMoveUp, onMoveDown }: StepEditorProps) {
|
|
||||||
const [isExpanded, setIsExpanded] = useState(true);
|
|
||||||
const selectedHand = hands.find(h => h.name === step.handName);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="bg-gray-50 dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-700">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center gap-2 p-3 border-b border-gray-200 dark:border-gray-700">
|
|
||||||
<GripVertical className="w-4 h-4 text-gray-400 cursor-grab" />
|
|
||||||
<span className="flex-shrink-0 w-6 h-6 bg-blue-100 dark:bg-blue-900/30 rounded-full flex items-center justify-center text-xs font-medium text-blue-600 dark:text-blue-400">
|
|
||||||
{index + 1}
|
|
||||||
</span>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<select
|
|
||||||
value={step.handName}
|
|
||||||
onChange={(e) => onUpdate({ ...step, handName: e.target.value })}
|
|
||||||
className="w-full px-2 py-1.5 text-sm border border-gray-200 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
||||||
>
|
|
||||||
<option value="">选择 Hand...</option>
|
|
||||||
{hands.map(hand => (
|
|
||||||
<option key={hand.id} value={hand.id}>
|
|
||||||
{hand.name} - {hand.description}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => setIsExpanded(!isExpanded)}
|
|
||||||
className="p-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
|
||||||
>
|
|
||||||
{isExpanded ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
|
|
||||||
</button>
|
|
||||||
{onMoveUp && (
|
|
||||||
<button
|
|
||||||
onClick={onMoveUp}
|
|
||||||
className="p-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 rounded"
|
|
||||||
title="上移"
|
|
||||||
>
|
|
||||||
<ChevronLeft className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{onMoveDown && (
|
|
||||||
<button
|
|
||||||
onClick={onMoveDown}
|
|
||||||
className="p-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 rounded"
|
|
||||||
title="下移"
|
|
||||||
>
|
|
||||||
<ChevronRight className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
onClick={onRemove}
|
|
||||||
className="p-1 text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 rounded"
|
|
||||||
>
|
|
||||||
<Trash2 className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Expanded Content */}
|
|
||||||
{isExpanded && (
|
|
||||||
<div className="p-3 space-y-3">
|
|
||||||
{/* Step Name */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">
|
|
||||||
步骤名称 (可选)
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={step.name || ''}
|
|
||||||
onChange={(e) => onUpdate({ ...step, name: e.target.value || undefined })}
|
|
||||||
placeholder={`${step.handName || '步骤'} ${index + 1}`}
|
|
||||||
className="w-full px-2 py-1.5 text-sm border border-gray-200 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Condition */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">
|
|
||||||
执行条件 (可选)
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={step.condition || ''}
|
|
||||||
onChange={(e) => onUpdate({ ...step, condition: e.target.value || undefined })}
|
|
||||||
placeholder="例如: previous_result.success == true"
|
|
||||||
className="w-full px-2 py-1.5 text-sm border border-gray-200 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
||||||
/>
|
|
||||||
<p className="mt-1 text-xs text-gray-400 dark:text-gray-500">
|
|
||||||
使用表达式决定是否执行此步骤
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Parameters */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">
|
|
||||||
参数 (JSON 格式)
|
|
||||||
</label>
|
|
||||||
<textarea
|
|
||||||
value={step.params ? JSON.stringify(step.params, null, 2) : ''}
|
|
||||||
onChange={(e) => {
|
|
||||||
const text = e.target.value.trim();
|
|
||||||
if (text) {
|
|
||||||
const result = safeJsonParse<Record<string, unknown>>(text);
|
|
||||||
if (result.success) {
|
|
||||||
onUpdate({ ...step, params: result.data });
|
|
||||||
}
|
|
||||||
// If parse fails, keep current params
|
|
||||||
} else {
|
|
||||||
onUpdate({ ...step, params: undefined });
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
placeholder='{"key": "value"}'
|
|
||||||
rows={3}
|
|
||||||
className="w-full px-2 py-1.5 text-sm font-mono border border-gray-200 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Hand Info */}
|
|
||||||
{selectedHand && (
|
|
||||||
<div className="p-2 bg-blue-50 dark:bg-blue-900/20 rounded-md text-xs text-blue-700 dark:text-blue-400">
|
|
||||||
<div className="font-medium mb-1">{selectedHand.description}</div>
|
|
||||||
{selectedHand.toolCount && (
|
|
||||||
<div>工具数量: {selectedHand.toolCount}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// === Main WorkflowEditor Component ===
|
|
||||||
|
|
||||||
export function WorkflowEditor({ workflow, isOpen, onClose, onSave, isSaving }: WorkflowEditorProps) {
|
|
||||||
const hands = useHandStore((s) => s.hands);
|
|
||||||
const loadHands = useHandStore((s) => s.loadHands);
|
|
||||||
const getWorkflowDetail = useWorkflowStore((s) => s.getWorkflowDetail);
|
|
||||||
const [name, setName] = useState('');
|
|
||||||
const [description, setDescription] = useState('');
|
|
||||||
const [steps, setSteps] = useState<WorkflowStep[]>([]);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const isEditMode = !!workflow;
|
|
||||||
|
|
||||||
// Load hands on mount
|
|
||||||
useEffect(() => {
|
|
||||||
loadHands();
|
|
||||||
}, [loadHands]);
|
|
||||||
|
|
||||||
// Initialize form when workflow changes (edit mode)
|
|
||||||
useEffect(() => {
|
|
||||||
if (workflow) {
|
|
||||||
setName(workflow.name);
|
|
||||||
setDescription(workflow.description || '');
|
|
||||||
|
|
||||||
// Load full workflow details including steps
|
|
||||||
getWorkflowDetail(workflow.id)
|
|
||||||
.then((detail: { steps: Array<{ handName: string; name?: string; params?: Record<string, unknown>; condition?: string }> } | undefined) => {
|
|
||||||
if (detail && Array.isArray(detail.steps)) {
|
|
||||||
const editorSteps: WorkflowStep[] = detail.steps.map((step: { handName: string; name?: string; params?: Record<string, unknown>; condition?: string }, index: number) => ({
|
|
||||||
id: `step-${workflow.id}-${index}`,
|
|
||||||
handName: step.handName || '',
|
|
||||||
name: step.name,
|
|
||||||
params: step.params,
|
|
||||||
condition: step.condition,
|
|
||||||
}));
|
|
||||||
setSteps(editorSteps);
|
|
||||||
} else {
|
|
||||||
setSteps([]);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(() => setSteps([]));
|
|
||||||
} else {
|
|
||||||
setName('');
|
|
||||||
setDescription('');
|
|
||||||
setSteps([]);
|
|
||||||
}
|
|
||||||
setError(null);
|
|
||||||
}, [workflow, getWorkflowDetail]);
|
|
||||||
|
|
||||||
// Add new step
|
|
||||||
const handleAddStep = useCallback(() => {
|
|
||||||
const newStep: WorkflowStep = {
|
|
||||||
id: `step-${Date.now()}`,
|
|
||||||
handName: '',
|
|
||||||
};
|
|
||||||
setSteps(prev => [...prev, newStep]);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Update step
|
|
||||||
const handleUpdateStep = useCallback((index: number, updatedStep: WorkflowStep) => {
|
|
||||||
setSteps(prev => prev.map((s, i) => i === index ? updatedStep : s));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Remove step
|
|
||||||
const handleRemoveStep = useCallback((index: number) => {
|
|
||||||
setSteps(prev => prev.filter((_, i) => i !== index));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Move step up
|
|
||||||
const handleMoveStepUp = useCallback((index: number) => {
|
|
||||||
if (index === 0) return;
|
|
||||||
setSteps(prev => {
|
|
||||||
const newSteps = [...prev];
|
|
||||||
[newSteps[index - 1], newSteps[index]] = [newSteps[index], newSteps[index - 1]];
|
|
||||||
return newSteps;
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Move step down
|
|
||||||
const handleMoveStepDown = useCallback((index: number) => {
|
|
||||||
if (index === steps.length - 1) return;
|
|
||||||
setSteps(prev => {
|
|
||||||
const newSteps = [...prev];
|
|
||||||
[newSteps[index], newSteps[index + 1]] = [newSteps[index + 1], newSteps[index]];
|
|
||||||
return newSteps;
|
|
||||||
});
|
|
||||||
}, [steps.length]);
|
|
||||||
|
|
||||||
// Validate and save
|
|
||||||
const handleSave = async () => {
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
// Validation
|
|
||||||
if (!name.trim()) {
|
|
||||||
setError('请输入工作流名称');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (steps.length === 0) {
|
|
||||||
setError('请至少添加一个步骤');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const invalidSteps = steps.filter(s => !s.handName);
|
|
||||||
if (invalidSteps.length > 0) {
|
|
||||||
setError('所有步骤都必须选择一个 Hand');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await onSave({
|
|
||||||
name: name.trim(),
|
|
||||||
description: description.trim() || undefined,
|
|
||||||
steps: steps.map(s => ({
|
|
||||||
handName: s.handName,
|
|
||||||
name: s.name,
|
|
||||||
params: s.params,
|
|
||||||
condition: s.condition,
|
|
||||||
})),
|
|
||||||
});
|
|
||||||
onClose();
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : '保存失败');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
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-2xl mx-4 max-h-[90vh] overflow-hidden flex flex-col">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700 flex-shrink-0">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="w-8 h-8 bg-blue-100 dark:bg-blue-900/30 rounded-lg flex items-center justify-center">
|
|
||||||
<GitBranch className="w-4 h-4 text-blue-600 dark:text-blue-400" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
|
|
||||||
{isEditMode ? '编辑工作流' : '新建工作流'}
|
|
||||||
</h2>
|
|
||||||
{workflow && (
|
|
||||||
<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="flex-1 overflow-y-auto p-4 space-y-4">
|
|
||||||
{/* Error */}
|
|
||||||
{error && (
|
|
||||||
<div className="p-3 bg-red-50 dark:bg-red-900/20 rounded-lg border border-red-200 dark:border-red-800 text-red-700 dark:text-red-400 text-sm flex items-center gap-2">
|
|
||||||
<AlertCircle className="w-4 h-4 flex-shrink-0" />
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Basic Info */}
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
|
||||||
工作流名称 *
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={name}
|
|
||||||
onChange={(e) => setName(e.target.value)}
|
|
||||||
placeholder="我的工作流"
|
|
||||||
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"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
|
||||||
描述
|
|
||||||
</label>
|
|
||||||
<textarea
|
|
||||||
value={description}
|
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
|
||||||
placeholder="描述这个工作流的用途..."
|
|
||||||
rows={2}
|
|
||||||
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"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Steps */}
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center justify-between mb-3">
|
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
|
||||||
工作流步骤 ({steps.length})
|
|
||||||
</label>
|
|
||||||
<button
|
|
||||||
onClick={handleAddStep}
|
|
||||||
className="flex items-center gap-1 px-2 py-1 text-xs bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400 rounded-md hover:bg-blue-200 dark:hover:bg-blue-900/50"
|
|
||||||
>
|
|
||||||
<Plus className="w-3 h-3" />
|
|
||||||
添加步骤
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{steps.length === 0 ? (
|
|
||||||
<div className="p-8 text-center border-2 border-dashed border-gray-200 dark:border-gray-700 rounded-lg">
|
|
||||||
<GitBranch className="w-8 h-8 mx-auto text-gray-300 dark:text-gray-600 mb-2" />
|
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-2">
|
|
||||||
还没有添加步骤
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
onClick={handleAddStep}
|
|
||||||
className="inline-flex items-center gap-1 px-3 py-1.5 text-sm bg-gray-700 dark:bg-gray-600 text-white rounded-lg hover:bg-gray-800 dark:hover:bg-gray-500"
|
|
||||||
>
|
|
||||||
<Plus className="w-4 h-4" />
|
|
||||||
添加第一个步骤
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-2">
|
|
||||||
{steps.map((step, index) => (
|
|
||||||
<StepEditor
|
|
||||||
key={step.id}
|
|
||||||
step={step}
|
|
||||||
hands={hands}
|
|
||||||
index={index}
|
|
||||||
onUpdate={(s) => handleUpdateStep(index, s)}
|
|
||||||
onRemove={() => handleRemoveStep(index)}
|
|
||||||
onMoveUp={index > 0 ? () => handleMoveStepUp(index) : undefined}
|
|
||||||
onMoveDown={index < steps.length - 1 ? () => handleMoveStepDown(index) : undefined}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Footer */}
|
|
||||||
<div className="flex items-center justify-end gap-2 p-4 border-t border-gray-200 dark:border-gray-700 flex-shrink-0">
|
|
||||||
<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={handleSave}
|
|
||||||
disabled={isSaving}
|
|
||||||
className="px-4 py-2 text-sm bg-gray-700 dark:bg-gray-600 text-white rounded-lg hover:bg-gray-800 dark:hover:bg-gray-500 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
|
||||||
>
|
|
||||||
{isSaving ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="w-4 h-4 animate-spin" />
|
|
||||||
保存中...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Save className="w-4 h-4" />
|
|
||||||
保存工作流
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default WorkflowEditor;
|
|
||||||
@@ -1,281 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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;
|
|
||||||
@@ -1,510 +0,0 @@
|
|||||||
/**
|
|
||||||
* WorkflowList - ZCLAW Workflow Management UI
|
|
||||||
*
|
|
||||||
* Displays available ZCLAW Workflows and allows executing them.
|
|
||||||
*
|
|
||||||
* Design based on ZCLAW 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 { WorkflowBuilder } from './WorkflowBuilder';
|
|
||||||
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
|
|
||||||
<WorkflowBuilder />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 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;
|
|
||||||
@@ -1,400 +0,0 @@
|
|||||||
/**
|
|
||||||
* IntentInput - 智能输入组件
|
|
||||||
*
|
|
||||||
* 提供自然语言触发 Pipeline 的入口:
|
|
||||||
* - 支持关键词/模式快速匹配
|
|
||||||
* - 显示匹配建议
|
|
||||||
* - 参数收集(对话式/表单式)
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useState, useCallback, useRef, useEffect } from 'react';
|
|
||||||
import {
|
|
||||||
Send,
|
|
||||||
Sparkles,
|
|
||||||
Loader2,
|
|
||||||
ChevronRight,
|
|
||||||
X,
|
|
||||||
MessageSquare,
|
|
||||||
FileText,
|
|
||||||
Zap,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { invoke } from '@tauri-apps/api/core';
|
|
||||||
|
|
||||||
// === Types ===
|
|
||||||
|
|
||||||
/** 路由结果 */
|
|
||||||
interface RouteResult {
|
|
||||||
type: 'matched' | 'ambiguous' | 'no_match' | 'need_more_info';
|
|
||||||
pipeline_id?: string;
|
|
||||||
display_name?: string;
|
|
||||||
mode?: 'conversation' | 'form' | 'hybrid' | 'auto';
|
|
||||||
params?: Record<string, unknown>;
|
|
||||||
confidence?: number;
|
|
||||||
missing_params?: MissingParam[];
|
|
||||||
candidates?: PipelineCandidate[];
|
|
||||||
suggestions?: PipelineCandidate[];
|
|
||||||
prompt?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 缺失参数 */
|
|
||||||
interface MissingParam {
|
|
||||||
name: string;
|
|
||||||
label?: string;
|
|
||||||
param_type: string;
|
|
||||||
required: boolean;
|
|
||||||
default?: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Pipeline 候选 */
|
|
||||||
interface PipelineCandidate {
|
|
||||||
id: string;
|
|
||||||
display_name?: string;
|
|
||||||
description?: string;
|
|
||||||
icon?: string;
|
|
||||||
category?: string;
|
|
||||||
match_reason?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 组件 Props */
|
|
||||||
export interface IntentInputProps {
|
|
||||||
/** 匹配成功回调 */
|
|
||||||
onMatch?: (pipelineId: string, params: Record<string, unknown>, mode: string) => void;
|
|
||||||
/** 取消回调 */
|
|
||||||
onCancel?: () => void;
|
|
||||||
/** 占位符文本 */
|
|
||||||
placeholder?: string;
|
|
||||||
/** 是否禁用 */
|
|
||||||
disabled?: boolean;
|
|
||||||
/** 自定义类名 */
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// === IntentInput Component ===
|
|
||||||
|
|
||||||
export function IntentInput({
|
|
||||||
onMatch,
|
|
||||||
onCancel,
|
|
||||||
placeholder = '输入你想做的事情,如"帮我做一个Python入门课程"...',
|
|
||||||
disabled = false,
|
|
||||||
className = '',
|
|
||||||
}: IntentInputProps) {
|
|
||||||
const [input, setInput] = useState('');
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [result, setResult] = useState<RouteResult | null>(null);
|
|
||||||
const [paramValues, setParamValues] = useState<Record<string, unknown>>({});
|
|
||||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
|
||||||
|
|
||||||
// Focus input on mount
|
|
||||||
useEffect(() => {
|
|
||||||
inputRef.current?.focus();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Handle route request
|
|
||||||
const handleRoute = useCallback(async () => {
|
|
||||||
if (!input.trim() || loading) return;
|
|
||||||
|
|
||||||
setLoading(true);
|
|
||||||
setResult(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const routeResult = await invoke<RouteResult>('route_intent', {
|
|
||||||
userInput: input.trim(),
|
|
||||||
});
|
|
||||||
|
|
||||||
setResult(routeResult);
|
|
||||||
|
|
||||||
// Initialize param values from extracted params
|
|
||||||
if (routeResult.params) {
|
|
||||||
setParamValues(routeResult.params);
|
|
||||||
}
|
|
||||||
|
|
||||||
// If high confidence and no missing params, auto-execute
|
|
||||||
if (
|
|
||||||
routeResult.type === 'matched' &&
|
|
||||||
routeResult.confidence &&
|
|
||||||
routeResult.confidence >= 0.9 &&
|
|
||||||
(!routeResult.missing_params || routeResult.missing_params.length === 0)
|
|
||||||
) {
|
|
||||||
handleExecute(routeResult.pipeline_id!, routeResult.params || {}, routeResult.mode!);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Route error:', error);
|
|
||||||
setResult({
|
|
||||||
type: 'no_match',
|
|
||||||
suggestions: [],
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, [input, loading]);
|
|
||||||
|
|
||||||
// Handle execute
|
|
||||||
const handleExecute = useCallback(
|
|
||||||
(pipelineId: string, params: Record<string, unknown>, mode: string) => {
|
|
||||||
onMatch?.(pipelineId, params, mode);
|
|
||||||
// Reset state
|
|
||||||
setInput('');
|
|
||||||
setResult(null);
|
|
||||||
setParamValues({});
|
|
||||||
},
|
|
||||||
[onMatch]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Handle param change
|
|
||||||
const handleParamChange = useCallback((name: string, value: unknown) => {
|
|
||||||
setParamValues((prev) => ({ ...prev, [name]: value }));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Handle key press
|
|
||||||
const handleKeyPress = useCallback(
|
|
||||||
(e: React.KeyboardEvent) => {
|
|
||||||
if (e.key === 'Enter' && !e.shiftKey) {
|
|
||||||
e.preventDefault();
|
|
||||||
if (result?.type === 'matched') {
|
|
||||||
handleExecute(result.pipeline_id!, paramValues, result.mode!);
|
|
||||||
} else {
|
|
||||||
handleRoute();
|
|
||||||
}
|
|
||||||
} else if (e.key === 'Escape') {
|
|
||||||
onCancel?.();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[result, paramValues, handleRoute, handleExecute, onCancel]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Render input area
|
|
||||||
const renderInput = () => (
|
|
||||||
<div className="relative">
|
|
||||||
<textarea
|
|
||||||
ref={inputRef}
|
|
||||||
value={input}
|
|
||||||
onChange={(e) => setInput(e.target.value)}
|
|
||||||
onKeyDown={handleKeyPress}
|
|
||||||
placeholder={placeholder}
|
|
||||||
disabled={disabled || loading}
|
|
||||||
rows={2}
|
|
||||||
className={`w-full px-4 py-3 pr-12 border border-gray-300 dark:border-gray-600 rounded-xl resize-none focus:ring-2 focus:ring-blue-500 focus:border-transparent dark:bg-gray-800 dark:text-white disabled:opacity-50 ${className}`}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
onClick={result?.type === 'matched' ? undefined : handleRoute}
|
|
||||||
disabled={!input.trim() || disabled || loading}
|
|
||||||
className="absolute right-3 bottom-3 p-2 rounded-lg bg-blue-600 hover:bg-blue-700 text-white disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
|
||||||
>
|
|
||||||
{loading ? (
|
|
||||||
<Loader2 className="w-5 h-5 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Send className="w-5 h-5" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
// Render matched result
|
|
||||||
const renderMatched = () => {
|
|
||||||
if (!result || result.type !== 'matched') return null;
|
|
||||||
|
|
||||||
const { pipeline_id, display_name, mode, missing_params, confidence } = result;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mt-3 p-4 bg-blue-50 dark:bg-blue-900/20 rounded-xl border border-blue-200 dark:border-blue-800">
|
|
||||||
<div className="flex items-start justify-between mb-3">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Sparkles className="w-5 h-5 text-blue-600" />
|
|
||||||
<span className="font-medium text-blue-700 dark:text-blue-300">
|
|
||||||
{display_name || pipeline_id}
|
|
||||||
</span>
|
|
||||||
{confidence && (
|
|
||||||
<span className="text-xs text-blue-500 dark:text-blue-400">
|
|
||||||
({Math.round(confidence * 100)}% 匹配)
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => setResult(null)}
|
|
||||||
className="p-1 hover:bg-blue-100 dark:hover:bg-blue-800 rounded"
|
|
||||||
>
|
|
||||||
<X className="w-4 h-4 text-blue-500" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Mode indicator */}
|
|
||||||
<div className="flex items-center gap-2 mb-3 text-sm">
|
|
||||||
<span className="text-gray-500 dark:text-gray-400">输入模式:</span>
|
|
||||||
<span className="flex items-center gap-1 px-2 py-0.5 bg-blue-100 dark:bg-blue-800 rounded">
|
|
||||||
{mode === 'conversation' && <MessageSquare className="w-3 h-3" />}
|
|
||||||
{mode === 'form' && <FileText className="w-3 h-3" />}
|
|
||||||
{mode === 'hybrid' && <Zap className="w-3 h-3" />}
|
|
||||||
{mode === 'conversation' && '对话式'}
|
|
||||||
{mode === 'form' && '表单式'}
|
|
||||||
{mode === 'hybrid' && '混合式'}
|
|
||||||
{mode === 'auto' && '自动'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Missing params form */}
|
|
||||||
{missing_params && missing_params.length > 0 && (
|
|
||||||
<div className="space-y-3 mb-4">
|
|
||||||
{missing_params.map((param) => (
|
|
||||||
<div key={param.name}>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
|
||||||
{param.label || param.name}
|
|
||||||
{param.required && <span className="text-red-500 ml-1">*</span>}
|
|
||||||
</label>
|
|
||||||
{renderParamInput(param)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Execute button */}
|
|
||||||
<button
|
|
||||||
onClick={() => handleExecute(pipeline_id!, paramValues, mode!)}
|
|
||||||
className="w-full flex items-center justify-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-lg transition-colors"
|
|
||||||
>
|
|
||||||
<Zap className="w-4 h-4" />
|
|
||||||
开始执行
|
|
||||||
<ChevronRight className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Render param input
|
|
||||||
const renderParamInput = (param: MissingParam) => {
|
|
||||||
const value = paramValues[param.name] ?? param.default ?? '';
|
|
||||||
|
|
||||||
switch (param.param_type) {
|
|
||||||
case 'text':
|
|
||||||
return (
|
|
||||||
<textarea
|
|
||||||
value={(value as string) || ''}
|
|
||||||
onChange={(e) => handleParamChange(param.name, e.target.value)}
|
|
||||||
rows={3}
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
case 'number':
|
|
||||||
return (
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={(value as number) ?? ''}
|
|
||||||
onChange={(e) => handleParamChange(param.name, e.target.valueAsNumber)}
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
case 'boolean':
|
|
||||||
return (
|
|
||||||
<label className="flex items-center gap-2">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={(value as boolean) || false}
|
|
||||||
onChange={(e) => handleParamChange(param.name, e.target.checked)}
|
|
||||||
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
|
||||||
/>
|
|
||||||
<span className="text-sm text-gray-600 dark:text-gray-300">启用</span>
|
|
||||||
</label>
|
|
||||||
);
|
|
||||||
default:
|
|
||||||
return (
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={(value as string) || ''}
|
|
||||||
onChange={(e) => handleParamChange(param.name, e.target.value)}
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Render suggestions
|
|
||||||
const renderSuggestions = () => {
|
|
||||||
if (!result || result.type !== 'no_match') return null;
|
|
||||||
|
|
||||||
const { suggestions } = result;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mt-3 p-4 bg-gray-50 dark:bg-gray-800/50 rounded-xl border border-gray-200 dark:border-gray-700">
|
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3">
|
|
||||||
没有找到完全匹配的 Pipeline,试试这些:
|
|
||||||
</p>
|
|
||||||
{suggestions && suggestions.length > 0 ? (
|
|
||||||
<div className="space-y-2">
|
|
||||||
{suggestions.map((candidate) => (
|
|
||||||
<button
|
|
||||||
key={candidate.id}
|
|
||||||
onClick={() => {
|
|
||||||
setInput('');
|
|
||||||
handleExecute(candidate.id, {}, 'form');
|
|
||||||
}}
|
|
||||||
className="w-full flex items-center justify-between p-3 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 hover:border-blue-300 dark:hover:border-blue-600 transition-colors text-left"
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<span className="font-medium text-gray-900 dark:text-white">
|
|
||||||
{candidate.display_name || candidate.id}
|
|
||||||
</span>
|
|
||||||
{candidate.description && (
|
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
|
|
||||||
{candidate.description}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<ChevronRight className="w-5 h-5 text-gray-400" />
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
|
||||||
暂无建议,请尝试其他描述
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Render ambiguous results
|
|
||||||
const renderAmbiguous = () => {
|
|
||||||
if (!result || result.type !== 'ambiguous') return null;
|
|
||||||
|
|
||||||
const { candidates } = result;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mt-3 p-4 bg-amber-50 dark:bg-amber-900/20 rounded-xl border border-amber-200 dark:border-amber-800">
|
|
||||||
<p className="text-sm text-amber-700 dark:text-amber-300 mb-3">
|
|
||||||
找到多个可能的 Pipeline,请选择:
|
|
||||||
</p>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{candidates?.map((candidate) => (
|
|
||||||
<button
|
|
||||||
key={candidate.id}
|
|
||||||
onClick={() => handleExecute(candidate.id, paramValues, 'form')}
|
|
||||||
className="w-full flex items-center justify-between p-3 bg-white dark:bg-gray-800 rounded-lg border border-amber-200 dark:border-amber-700 hover:border-amber-300 dark:hover:border-amber-600 transition-colors text-left"
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<span className="font-medium text-gray-900 dark:text-white">
|
|
||||||
{candidate.display_name || candidate.id}
|
|
||||||
</span>
|
|
||||||
{candidate.match_reason && (
|
|
||||||
<p className="text-sm text-amber-600 dark:text-amber-400 mt-0.5">
|
|
||||||
{candidate.match_reason}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<ChevronRight className="w-5 h-5 text-amber-500" />
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="intent-input">
|
|
||||||
{renderInput()}
|
|
||||||
{renderMatched()}
|
|
||||||
{renderSuggestions()}
|
|
||||||
{renderAmbiguous()}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default IntentInput;
|
|
||||||
@@ -1,345 +0,0 @@
|
|||||||
import { useState, useCallback } from 'react';
|
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
|
||||||
import {
|
|
||||||
AlertTriangle,
|
|
||||||
Wifi,
|
|
||||||
Shield,
|
|
||||||
Clock,
|
|
||||||
Settings,
|
|
||||||
AlertCircle,
|
|
||||||
ChevronDown,
|
|
||||||
ChevronUp,
|
|
||||||
Copy,
|
|
||||||
CheckCircle,
|
|
||||||
ExternalLink,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { cn } from '../../lib/utils';
|
|
||||||
import { Button } from './Button';
|
|
||||||
import {
|
|
||||||
AppError,
|
|
||||||
ErrorCategory,
|
|
||||||
classifyError,
|
|
||||||
formatErrorForClipboard,
|
|
||||||
getErrorIcon as getIconByCategory,
|
|
||||||
getErrorColor as getColorByCategory,
|
|
||||||
} from '../../lib/error-types';
|
|
||||||
|
|
||||||
import { reportError } from '../../lib/error-handling';
|
|
||||||
|
|
||||||
// === Props ===
|
|
||||||
|
|
||||||
export interface ErrorAlertProps {
|
|
||||||
error: AppError | string | Error | null;
|
|
||||||
onDismiss?: () => void;
|
|
||||||
onRetry?: () => void;
|
|
||||||
showTechnicalDetails?: boolean;
|
|
||||||
className?: string;
|
|
||||||
compact?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ErrorAlertState {
|
|
||||||
showDetails: boolean;
|
|
||||||
copied: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
// === Category Configuration ===
|
|
||||||
|
|
||||||
const CATEGORY_CONFIG: Record<ErrorCategory, {
|
|
||||||
icon: typeof Wifi | typeof Shield | typeof Clock | typeof Settings | typeof AlertCircle | typeof AlertTriangle;
|
|
||||||
color: string;
|
|
||||||
bgColor: string;
|
|
||||||
label: string;
|
|
||||||
}> = {
|
|
||||||
network: {
|
|
||||||
icon: Wifi,
|
|
||||||
color: 'text-orange-500',
|
|
||||||
bgColor: 'bg-orange-50 dark:bg-orange-900/20',
|
|
||||||
label: 'Network',
|
|
||||||
},
|
|
||||||
auth: {
|
|
||||||
icon: Shield,
|
|
||||||
color: 'text-red-500',
|
|
||||||
bgColor: 'bg-red-50 dark:bg-red-900/20',
|
|
||||||
label: 'Authentication',
|
|
||||||
},
|
|
||||||
permission: {
|
|
||||||
icon: Shield,
|
|
||||||
color: 'text-purple-500',
|
|
||||||
bgColor: 'bg-purple-50 dark:bg-purple-900/20',
|
|
||||||
label: 'Permission',
|
|
||||||
},
|
|
||||||
validation: {
|
|
||||||
icon: AlertCircle,
|
|
||||||
color: 'text-yellow-600',
|
|
||||||
bgColor: 'bg-yellow-50 dark:bg-yellow-900/20',
|
|
||||||
label: 'Validation',
|
|
||||||
},
|
|
||||||
timeout: {
|
|
||||||
icon: Clock,
|
|
||||||
color: 'text-amber-500',
|
|
||||||
bgColor: 'bg-amber-50 dark:bg-amber-900/20',
|
|
||||||
label: 'Timeout',
|
|
||||||
},
|
|
||||||
server: {
|
|
||||||
icon: AlertTriangle,
|
|
||||||
color: 'text-red-500',
|
|
||||||
bgColor: 'bg-red-50 dark:bg-red-900/20',
|
|
||||||
label: 'Server',
|
|
||||||
},
|
|
||||||
client: {
|
|
||||||
icon: AlertCircle,
|
|
||||||
color: 'text-blue-500',
|
|
||||||
bgColor: 'bg-blue-50 dark:bg-blue-900/20',
|
|
||||||
label: 'Client',
|
|
||||||
},
|
|
||||||
config: {
|
|
||||||
icon: Settings,
|
|
||||||
color: 'text-gray-500',
|
|
||||||
bgColor: 'bg-gray-50 dark:bg-gray-900/20',
|
|
||||||
label: 'Configuration',
|
|
||||||
},
|
|
||||||
system: {
|
|
||||||
icon: AlertTriangle,
|
|
||||||
color: 'text-red-600',
|
|
||||||
bgColor: 'bg-red-50 dark:bg-red-900/20',
|
|
||||||
label: 'System',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get icon component for error category
|
|
||||||
*/
|
|
||||||
export function getIconByCategory(category: ErrorCategory): typeof Wifi | typeof Shield | typeof Clock | typeof Settings | typeof AlertCircle | typeof AlertTriangle {
|
|
||||||
return CATEGORY_CONFIG[category]?.icon ?? AlertCircle;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get color class for error category
|
|
||||||
*/
|
|
||||||
export function getColorByCategory(category: ErrorCategory): string {
|
|
||||||
return CATEGORY_CONFIG[category]?.color ?? 'text-gray-500';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* ErrorAlert Component
|
|
||||||
*
|
|
||||||
* Displays detailed error information with recovery suggestions,
|
|
||||||
* technical details, and action buttons.
|
|
||||||
*/
|
|
||||||
export function ErrorAlert({
|
|
||||||
error: errorProp,
|
|
||||||
onDismiss,
|
|
||||||
onRetry,
|
|
||||||
showTechnicalDetails = true,
|
|
||||||
className,
|
|
||||||
compact = false,
|
|
||||||
}: ErrorAlertProps) {
|
|
||||||
const [state, setState] = useState<ErrorAlertState>({
|
|
||||||
showDetails: false,
|
|
||||||
copied: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Normalize error input
|
|
||||||
const appError = typeof errorProp === 'string'
|
|
||||||
? classifyError(new Error(errorProp))
|
|
||||||
: errorProp instanceof Error
|
|
||||||
? classifyError(errorProp)
|
|
||||||
: errorProp;
|
|
||||||
|
|
||||||
const {
|
|
||||||
category,
|
|
||||||
title,
|
|
||||||
message,
|
|
||||||
technicalDetails,
|
|
||||||
recoverable,
|
|
||||||
recoverySteps,
|
|
||||||
timestamp,
|
|
||||||
} = appError;
|
|
||||||
|
|
||||||
const config = CATEGORY_CONFIG[category] || CATEGORY_CONFIG.system!;
|
|
||||||
const IconComponent = config.icon;
|
|
||||||
|
|
||||||
const handleCopyDetails = useCallback(async () => {
|
|
||||||
const text = formatErrorForClipboard(appError);
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(text);
|
|
||||||
setState({ copied: true });
|
|
||||||
setTimeout(() => setState({ copied: false }), 2000);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to copy error details:', err);
|
|
||||||
}
|
|
||||||
}, [appError]);
|
|
||||||
|
|
||||||
const handleReport = useCallback(() => {
|
|
||||||
reportError(appError.originalError || appError, {
|
|
||||||
errorId: appError.id,
|
|
||||||
category: appError.category,
|
|
||||||
title: appError.title,
|
|
||||||
message: appError.message,
|
|
||||||
timestamp: appError.timestamp.toISOString(),
|
|
||||||
});
|
|
||||||
}, [appError]);
|
|
||||||
|
|
||||||
const toggleDetails = useCallback(() => {
|
|
||||||
setState((prev) => ({ showDetails: !prev.showDetails }));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleRetry = useCallback(() => {
|
|
||||||
onRetry?.();
|
|
||||||
}, [onRetry]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, y: -10 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
exit={{ opacity: 0, y: -10 }}
|
|
||||||
className={cn(
|
|
||||||
'rounded-lg border overflow-hidden',
|
|
||||||
config.bgColor,
|
|
||||||
'border-gray-200 dark:border-gray-700',
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-start gap-3 p-3 bg-white/50 dark:bg-gray-800/50">
|
|
||||||
<div className={cn('p-2 rounded-lg', config.bgColor)}>
|
|
||||||
<IconComponent className={cn('w-5 h-5', config.color)} />
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className={cn('text-xs font-medium', config.color)}>
|
|
||||||
{config.label}
|
|
||||||
</span>
|
|
||||||
<span className="text-xs text-gray-400">
|
|
||||||
{timestamp.toLocaleTimeString()}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<h4 className="text-sm font-medium text-gray-900 dark:text-gray-100 mt-1">
|
|
||||||
{title}
|
|
||||||
</h4>
|
|
||||||
</div>
|
|
||||||
{onDismiss && (
|
|
||||||
<button
|
|
||||||
onClick={onDismiss}
|
|
||||||
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 p-1"
|
|
||||||
aria-label="Dismiss"
|
|
||||||
>
|
|
||||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Body */}
|
|
||||||
<div className="px-3 pb-2">
|
|
||||||
<p className={cn(
|
|
||||||
'text-gray-700 dark:text-gray-300',
|
|
||||||
compact ? 'text-sm line-clamp-2' : 'text-sm'
|
|
||||||
)}>
|
|
||||||
{message}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* Recovery Steps */}
|
|
||||||
{recoverySteps.length > 0 && !compact && (
|
|
||||||
<div className="mt-3 space-y-2">
|
|
||||||
<p className="text-xs font-medium text-gray-500 dark:text-gray-400 flex items-center gap-1">
|
|
||||||
<CheckCircle className="w-3 h-3" />
|
|
||||||
Recovery Suggestions
|
|
||||||
</p>
|
|
||||||
<ul className="space-y-1">
|
|
||||||
{recoverySteps.slice(0, 3).map((step, index) => (
|
|
||||||
<li key={index} className="text-xs text-gray-600 dark:text-gray-400 flex items-start gap-2">
|
|
||||||
<span className="text-gray-400">-</span>
|
|
||||||
{step.description}
|
|
||||||
{step.action && step.label && (
|
|
||||||
<button
|
|
||||||
onClick={step.action}
|
|
||||||
className="text-blue-500 hover:text-blue-600 ml-1"
|
|
||||||
>
|
|
||||||
{step.label}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Technical Details Toggle */}
|
|
||||||
{showTechnicalDetails && technicalDetails && !compact && (
|
|
||||||
<div className="mt-2">
|
|
||||||
<button
|
|
||||||
onClick={toggleDetails}
|
|
||||||
className="flex items-center gap-1 text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300"
|
|
||||||
>
|
|
||||||
{state.showDetails ? (
|
|
||||||
<ChevronUp className="w-3 h-3" />
|
|
||||||
) : (
|
|
||||||
<ChevronDown className="w-3 h-3" />
|
|
||||||
)}
|
|
||||||
Technical Details
|
|
||||||
</button>
|
|
||||||
<AnimatePresence>
|
|
||||||
{state.showDetails && (
|
|
||||||
<motion.div
|
|
||||||
initial={{ height: 0, opacity: 0 }}
|
|
||||||
animate={{ height: 'auto', opacity: 1 }}
|
|
||||||
exit={{ height: 0, opacity: 0 }}
|
|
||||||
className="overflow-hidden"
|
|
||||||
>
|
|
||||||
<pre className="mt-2 p-2 bg-gray-100 dark:bg-gray-800 rounded text-xs text-gray-600 dark:text-gray-400 overflow-x-auto whitespace-pre-wrap break-all">
|
|
||||||
{technicalDetails}
|
|
||||||
</pre>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Actions */}
|
|
||||||
<div className="flex items-center justify-between gap-2 p-3 pt-2 border-t border-gray-100 dark:border-gray-700 bg-white/30 dark:bg-gray-800/30">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleCopyDetails}
|
|
||||||
className="text-xs"
|
|
||||||
>
|
|
||||||
{state.copied ? (
|
|
||||||
<>
|
|
||||||
<CheckCircle className="w-3 h-3 mr-1" />
|
|
||||||
Copied
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Copy className="w-3 h-3 mr-1" />
|
|
||||||
Copy
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleReport}
|
|
||||||
className="text-xs"
|
|
||||||
>
|
|
||||||
<ExternalLink className="w-3 h-3 mr-1" />
|
|
||||||
Report
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{recoverable && onRetry && (
|
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleRetry}
|
|
||||||
className="text-xs"
|
|
||||||
>
|
|
||||||
Try Again
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user