Files
zclaw_openfang/desktop/src/components/Automation/AutomationCard.tsx
iven 6f72442531 docs(guide): rewrite CLAUDE.md with ZCLAW-first perspective
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
2026-03-20 19:30:09 +08:00

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;