Major changes: - Shift from "OpenFang desktop client" to "independent AI Agent desktop app" - Add decision principle: "Is this useful for ZCLAW? Does it affect ZCLAW?" - Simplify project structure and tech stack sections - Replace OpenClaw vs OpenFang comparison with unified backend approach - Consolidate troubleshooting from scattered sections into organized FAQ - Update Hands system documentation with 8 capabilities and status - Stream
403 lines
12 KiB
TypeScript
403 lines
12 KiB
TypeScript
/**
|
|
* 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;
|