Compare commits
2 Commits
e790cf171a
...
9772d6ec94
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9772d6ec94 | ||
|
|
717f2eab4f |
@@ -3,6 +3,7 @@ import './index.css';
|
||||
import { Sidebar } from './components/Sidebar';
|
||||
import { ChatArea } from './components/ChatArea';
|
||||
import { RightPanel } from './components/RightPanel';
|
||||
import { ErrorBoundary } from './components/ui/ErrorBoundary';
|
||||
import { SettingsLayout } from './components/Settings/SettingsLayout';
|
||||
import { AgentOnboardingWizard } from './components/AgentOnboardingWizard';
|
||||
import { HandApprovalModal } from './components/HandApprovalModal';
|
||||
@@ -458,7 +459,9 @@ function App() {
|
||||
|
||||
{/* 主聊天区域 */}
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
<ChatArea compact onOpenDetail={() => setShowDetailDrawer(true)} />
|
||||
<ErrorBoundary fallback={<div className="flex-1 flex items-center justify-center text-gray-500">聊天区域加载失败</div>}>
|
||||
<ChatArea compact onOpenDetail={() => setShowDetailDrawer(true)} />
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
|
||||
{/* 详情抽屉 - 简洁模式仅 状态/Agent/管家 */}
|
||||
@@ -467,7 +470,9 @@ function App() {
|
||||
onClose={() => setShowDetailDrawer(false)}
|
||||
title="详情"
|
||||
>
|
||||
<RightPanel simpleMode />
|
||||
<ErrorBoundary fallback={<div className="p-6 text-center text-gray-500">详情面板加载失败</div>}>
|
||||
<RightPanel simpleMode />
|
||||
</ErrorBoundary>
|
||||
</DetailDrawer>
|
||||
|
||||
{/* Hand Approval Modal (global) */}
|
||||
@@ -502,7 +507,9 @@ function App() {
|
||||
/>
|
||||
|
||||
{/* 聊天区域 */}
|
||||
<ChatArea />
|
||||
<ErrorBoundary fallback={<div className="flex-1 flex items-center justify-center text-gray-500">聊天区域加载失败,请刷新页面</div>}>
|
||||
<ChatArea />
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
|
||||
{/* 详情抽屉 - 按需显示 */}
|
||||
@@ -511,7 +518,9 @@ function App() {
|
||||
onClose={() => setShowDetailDrawer(false)}
|
||||
title="详情"
|
||||
>
|
||||
<RightPanel />
|
||||
<ErrorBoundary fallback={<div className="p-6 text-center text-gray-500">详情面板加载失败</div>}>
|
||||
<RightPanel />
|
||||
</ErrorBoundary>
|
||||
</DetailDrawer>
|
||||
|
||||
{/* Hand Approval Modal (global) */}
|
||||
|
||||
@@ -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;
|
||||
@@ -49,7 +49,8 @@ export function SaaSStatus({ isLoggedIn, account, saasUrl, onLogout, onLogin }:
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
setHealthOk(response.ok);
|
||||
} catch {
|
||||
} catch (e) {
|
||||
console.warn('[SaaSStatus] Health check failed:', e);
|
||||
setHealthOk(false);
|
||||
} finally {
|
||||
setCheckingHealth(false);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -30,7 +30,8 @@ export function TOTPSettings() {
|
||||
setVerifyCode('');
|
||||
try {
|
||||
await setupTotp();
|
||||
} catch {
|
||||
} catch (e) {
|
||||
console.warn('[TOTP] Setup failed:', e);
|
||||
// error already in store
|
||||
}
|
||||
};
|
||||
@@ -43,7 +44,8 @@ export function TOTPSettings() {
|
||||
await verifyTotp(verifyCode);
|
||||
setVerifyCode('');
|
||||
setSuccess('TOTP 已成功启用');
|
||||
} catch {
|
||||
} catch (e) {
|
||||
console.warn('[TOTP] Enable failed:', e);
|
||||
// error already in store
|
||||
}
|
||||
};
|
||||
@@ -60,7 +62,8 @@ export function TOTPSettings() {
|
||||
setDisablePassword('');
|
||||
setShowDisable(false);
|
||||
setSuccess('TOTP 已成功禁用');
|
||||
} catch {
|
||||
} catch (e) {
|
||||
console.warn('[TOTP] Disable failed:', e);
|
||||
// error already in store
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
@@ -25,7 +25,8 @@ export function MCPServices() {
|
||||
try {
|
||||
const running = await listMcpServices();
|
||||
setRunningServices(running);
|
||||
} catch {
|
||||
} catch (e) {
|
||||
console.warn('[MCPServices] Failed to list services:', e);
|
||||
// MCP might not be available yet
|
||||
setRunningServices([]);
|
||||
}
|
||||
|
||||
@@ -80,8 +80,8 @@ function loadEmbeddingConfigBase(): Omit<EmbeddingConfig, 'apiKey'> & { apiKey:
|
||||
const parsed = JSON.parse(stored);
|
||||
return { ...parsed, apiKey: '' };
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
} catch (e) {
|
||||
console.warn('[ModelsAPI] Failed to load embedding config:', e);
|
||||
}
|
||||
return {
|
||||
provider: 'local',
|
||||
@@ -99,8 +99,8 @@ function loadEmbeddingConfigBase(): Omit<EmbeddingConfig, 'apiKey'> & { apiKey:
|
||||
function saveEmbeddingConfigBase(config: Omit<EmbeddingConfig, 'apiKey'>): void {
|
||||
try {
|
||||
localStorage.setItem(EMBEDDING_STORAGE_KEY, JSON.stringify(config));
|
||||
} catch {
|
||||
// ignore
|
||||
} catch (e) {
|
||||
console.warn('[ModelsAPI] Failed to save embedding config:', e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,8 +129,8 @@ function loadCustomModelsBase(): CustomModel[] {
|
||||
if (stored) {
|
||||
return JSON.parse(stored);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
} catch (e) {
|
||||
console.warn('[ModelsAPI] Failed to load model config:', e);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
@@ -143,8 +143,8 @@ function saveCustomModelsBase(models: CustomModel[]): void {
|
||||
return rest;
|
||||
});
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(sanitized));
|
||||
} catch {
|
||||
// ignore
|
||||
} catch (e) {
|
||||
console.warn('[ModelsAPI] Failed to save model config:', e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -149,7 +149,14 @@ export function SettingsLayout({ onBack }: SettingsLayoutProps) {
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
case 'audit': return <AuditLogsPanel />;
|
||||
case 'audit': return (
|
||||
<ErrorBoundary
|
||||
fallback={<div className="p-6 text-center text-gray-500">审计日志加载失败</div>}
|
||||
onError={(err, info) => console.error('[Settings] Audit page error:', err, info.componentStack)}
|
||||
>
|
||||
<AuditLogsPanel />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
case 'tasks': return (
|
||||
<div className="max-w-3xl">
|
||||
<h1 className="text-xl font-bold text-gray-900 mb-6">定时任务</h1>
|
||||
@@ -159,11 +166,23 @@ export function SettingsLayout({ onBack }: SettingsLayoutProps) {
|
||||
</div>
|
||||
);
|
||||
case 'heartbeat': return (
|
||||
<div className="max-w-3xl h-full">
|
||||
<HeartbeatConfig />
|
||||
</div>
|
||||
<ErrorBoundary
|
||||
fallback={<div className="p-6 text-center text-gray-500">心跳配置加载失败</div>}
|
||||
onError={(err, info) => console.error('[Settings] Heartbeat page error:', err, info.componentStack)}
|
||||
>
|
||||
<div className="max-w-3xl h-full">
|
||||
<HeartbeatConfig />
|
||||
</div>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
case 'viking': return (
|
||||
<ErrorBoundary
|
||||
fallback={<div className="p-6 text-center text-gray-500">语义记忆加载失败</div>}
|
||||
onError={(err, info) => console.error('[Settings] Viking page error:', err, info.componentStack)}
|
||||
>
|
||||
<VikingPanel />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
case 'viking': return <VikingPanel />;
|
||||
case 'feedback': return <Feedback />;
|
||||
case 'about': return <About />;
|
||||
default: return <General />;
|
||||
|
||||
@@ -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;
|
||||
@@ -50,7 +50,8 @@ export function VikingPanel() {
|
||||
try {
|
||||
const resources = await listVikingResources('');
|
||||
setMemoryCount(resources.length);
|
||||
} catch {
|
||||
} catch (e) {
|
||||
console.warn('[VikingPanel] Failed to list resources:', e);
|
||||
setMemoryCount(null);
|
||||
}
|
||||
}
|
||||
@@ -99,7 +100,8 @@ export function VikingPanel() {
|
||||
try {
|
||||
const fullContent = await readVikingResource(uri, 'L2');
|
||||
setExpandedContent(fullContent);
|
||||
} catch {
|
||||
} catch (e) {
|
||||
console.warn('[VikingPanel] Failed to read resource:', e);
|
||||
setExpandedContent(null);
|
||||
} finally {
|
||||
setIsLoadingL2(false);
|
||||
|
||||
@@ -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