Four phases: - P1: Fix core issues (param passing, store migration, WebSocket events) - P2: UI refactor (AutomationPanel, AutomationCard, filters) - P3: Advanced features (ScheduleEditor, batch operations) - P4: Workflow integration (ApprovalQueue, ExecutionResult) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
42 KiB
自动化系统重设计实现计划
For agentic workers: REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: 修复并重构 ZCLAW Hands 系统,使其成为完整可用的自动化平台
Architecture: 扩展现有 handStore/chatStore,不创建新 store;通过类型适配器统一 Hand/Workflow;WebSocket 事件驱动实时状态;结果流注入聊天消息
Tech Stack: React, TypeScript, Zustand, Tailwind CSS, WebSocket
文件结构
desktop/src/
├── components/
│ ├── Automation/ # 新建目录
│ │ ├── AutomationPanel.tsx # 统一入口(组合层)
│ │ ├── AutomationCard.tsx # Hand/Workflow 统一卡片
│ │ ├── AutomationFilters.tsx # 分类筛选器
│ │ ├── ScheduleEditor.tsx # 可视化调度器
│ │ ├── ExecutionResult.tsx # 执行结果展示
│ │ ├── BatchActionBar.tsx # 批量操作栏
│ │ └── ApprovalQueue.tsx # 审批队列
│ └── HandsPanel.tsx # 修改:迁移到 handStore
├── store/
│ └── handStore.ts # 扩展:添加批量操作
├── types/
│ └── automation.ts # 新建:类型适配器
└── hooks/
└── useAutomationEvents.ts # 新建:WebSocket 事件 Hook
Phase 1: 基础修复(1-2 天)
Task 1.1: 修复参数传递问题
Files:
- Modify:
desktop/src/components/HandsPanel.tsx:477-514 - Test:
tests/desktop/hands/param-passing.test.ts
问题: handleActivate 调用 triggerHand(hand.id) 时未传递参数
- Step 1: 添加参数状态到 HandsPanel
// 在 HandsPanel 组件内添加
const [paramValues, setParamValues] = useState<Record<string, unknown>>({});
- Step 2: 修改 HandDetailsModal 的 onActivate 回调
// 修改 HandDetailsModalProps 接口
interface HandDetailsModalProps {
hand: Hand;
isOpen: boolean;
onClose: () => void;
onActivate: (params?: Record<string, unknown>) => void; // 添加 params
isActivating: boolean;
paramValues: Record<string, unknown>; // 新增
onParamChange: (values: Record<string, unknown>) => void; // 新增
}
- Step 3: 修改 handleModalActivate 传递参数
// 修改 handleModalActivate
const handleModalActivate = useCallback(async (params: Record<string, unknown>) => {
if (!selectedHand) return;
setShowModal(false);
await handleActivate(selectedHand, params); // 传递参数
}, [selectedHand, handleActivate]);
- Step 4: 修改 handleActivate 接收并传递参数
// 修改 handleActivate
const handleActivate = useCallback(async (hand: Hand, params?: Record<string, unknown>) => {
setActivatingHandId(hand.id);
try {
const result = await triggerHand(hand.id, params); // 传递参数到 store
// ... 其余逻辑
}
}, [triggerHand, loadHands, toast]);
- Step 5: 运行测试验证
Run: cd desktop && pnpm vitest run tests/desktop/hands/param-passing.test.ts
Expected: PASS
- Step 6: Commit
git add desktop/src/components/HandsPanel.tsx tests/desktop/hands/param-passing.test.ts
git commit -m "fix(hands): pass form parameters to triggerHand API"
Task 1.2: 迁移 HandsPanel 到 handStore
Files:
- Modify:
desktop/src/components/HandsPanel.tsx:11,458 - Modify:
desktop/src/store/index.ts
问题: HandsPanel 使用 gatewayStore 而非独立的 handStore
- Step 1: 修改 import 从 gatewayStore 到 handStore
// 修改第 11 行
// 从:
import { useGatewayStore, type Hand, type HandRequirement } from '../store/gatewayStore';
// 改为:
import { useHandStore, type Hand, type HandRequirement } from '../store/handStore';
- Step 2: 修改组件内的 store 调用
// 修改第 458 行
// 从:
const { hands, loadHands, triggerHand, isLoading } = useGatewayStore();
// 改为:
const { hands, loadHands, triggerHand, isLoading } = useHandStore();
- Step 3: 修改 getHandDetails 调用
// 修改第 471 行
// 从:
const { getHandDetails } = useGatewayStore.getState();
// 改为:
const { getHandDetails } = useHandStore.getState();
- Step 4: 确保 store/index.ts 正确初始化 handStore client
// 检查 store/index.ts 中是否有:
import { setHandStoreClient } from './handStore';
// 在初始化时调用 setHandStoreClient(client)
- Step 5: 运行测试验证
Run: cd desktop && pnpm vitest run tests/desktop/handStore.test.ts
Expected: PASS
- Step 6: Commit
git add desktop/src/components/HandsPanel.tsx desktop/src/store/index.ts
git commit -m "refactor(hands): migrate HandsPanel to use handStore"
Task 1.3: 创建类型适配器和分类映射
Files:
-
Create:
desktop/src/types/automation.ts -
Test:
tests/desktop/types/automation.test.ts -
Step 1: 创建类型适配器文件
// desktop/src/types/automation.ts
import type { Hand, HandStatus } from './hands';
import type { Workflow, WorkflowStatus } from './workflow';
// 分类类型
export type CategoryType = 'all' | 'research' | 'data' | 'automation' | 'communication' | 'content';
// 统一自动化项
export type AutomationItem =
| (Hand & { type: 'hand'; category: CategoryType })
| (Workflow & { type: 'workflow'; category: CategoryType });
// 7 个 Hand 的分类映射
export const HAND_CATEGORIES: Record<string, CategoryType> = {
researcher: 'research',
browser: 'automation',
lead: 'automation',
clip: 'content',
collector: 'data',
predictor: 'data',
twitter: 'communication',
};
// Hand 转 AutomationItem
export function handToAutomationItem(hand: Hand): AutomationItem {
return {
...hand,
type: 'hand',
category: HAND_CATEGORIES[hand.id] || 'automation',
};
}
// Workflow 转 AutomationItem
export function workflowToAutomationItem(workflow: Workflow): AutomationItem {
return {
...workflow,
type: 'workflow',
category: 'automation', // 默认分类
};
}
// 获取分类统计
export function getCategoryStats(items: AutomationItem[]): Record<CategoryType, number> {
const stats: Record<CategoryType, number> = {
all: items.length,
research: 0,
data: 0,
automation: 0,
communication: 0,
content: 0,
};
items.forEach(item => {
if (item.category && stats[item.category] !== undefined) {
stats[item.category]++;
}
});
return stats;
}
- Step 2: 写测试
// tests/desktop/types/automation.test.ts
import { describe, it, expect } from 'vitest';
import { handToAutomationItem, HAND_CATEGORIES, getCategoryStats } from '../../src/types/automation';
describe('automation types', () => {
it('should map researcher to research category', () => {
const hand = { id: 'researcher', name: 'Researcher', description: '', status: 'idle' as const };
const item = handToAutomationItem(hand);
expect(item.category).toBe('research');
expect(item.type).toBe('hand');
});
it('should calculate category stats correctly', () => {
const items = [
{ id: '1', type: 'hand' as const, category: 'research' as const, name: '', description: '', status: 'idle' as const },
{ id: '2', type: 'hand' as const, category: 'research' as const, name: '', description: '', status: 'idle' as const },
{ id: '3', type: 'hand' as const, category: 'data' as const, name: '', description: '', status: 'idle' as const },
];
const stats = getCategoryStats(items);
expect(stats.all).toBe(3);
expect(stats.research).toBe(2);
expect(stats.data).toBe(1);
});
});
- Step 3: 运行测试
Run: cd desktop && pnpm vitest run tests/desktop/types/automation.test.ts
Expected: PASS
- Step 4: Commit
git add desktop/src/types/automation.ts tests/desktop/types/automation.test.ts
git commit -m "feat(types): add automation type adapters and category mapping"
Task 1.4: 创建 WebSocket 事件 Hook
Files:
-
Create:
desktop/src/hooks/useAutomationEvents.ts -
Test:
tests/desktop/hooks/useAutomationEvents.test.ts -
Step 1: 创建事件 Hook
// desktop/src/hooks/useAutomationEvents.ts
import { useEffect, useCallback } from 'react';
import { useHandStore } from '../store/handStore';
import { useChatStore, type Message } from '../store/chatStore';
import { useGatewayStore } from '../store/gatewayStore';
interface HandEvent {
hand_name: string;
hand_status: string;
hand_result?: unknown;
run_id?: string;
}
export function useAutomationEvents() {
const updateHandStatus = useHandStore(state => state.getHandDetails);
const addMessage = useChatStore(state => state.addMessage);
const client = useGatewayStore(state => state.client);
// 处理 Hand 事件
const handleHandEvent = useCallback((event: HandEvent) => {
// 更新 handStore 中的 Hand 状态
updateHandStatus(event.hand_name);
// 如果有结果,注入到聊天流
if (event.hand_result && event.run_id) {
const message: Message = {
id: `hand-${event.run_id}`,
role: 'hand',
content: typeof event.hand_result === 'string'
? event.hand_result
: JSON.stringify(event.hand_result, null, 2),
timestamp: Date.now(),
handName: event.hand_name,
handStatus: event.hand_status,
handResult: event.hand_result,
};
addMessage(message);
}
}, [updateHandStatus, addMessage]);
// 订阅 WebSocket 事件
useEffect(() => {
if (!client) return;
// 订阅 hand 事件
const unsubscribe = client.subscribeToEvents({
onHand: (name, status, result) => {
handleHandEvent({
hand_name: name,
hand_status: status,
hand_result: result,
});
},
});
return unsubscribe;
}, [client, handleHandEvent]);
return { handleHandEvent };
}
- Step 2: 在 App.tsx 中初始化 Hook
// 在 App.tsx 中添加
import { useAutomationEvents } from './hooks/useAutomationEvents';
// 在 App 组件内
function App() {
// 初始化自动化事件监听
useAutomationEvents();
// ... 其余代码
}
- Step 3: 运行测试
Run: cd desktop && pnpm vitest run tests/desktop/hooks/useAutomationEvents.test.ts
Expected: PASS
- Step 4: Commit
git add desktop/src/hooks/useAutomationEvents.ts desktop/src/App.tsx tests/desktop/hooks/
git commit -m "feat(hooks): add useAutomationEvents for WebSocket hand events"
Phase 2: UI 重构(2-3 天)
Task 2.1: 创建 AutomationPanel 统一入口
Files:
-
Create:
desktop/src/components/Automation/AutomationPanel.tsx -
Create:
desktop/src/components/Automation/index.ts -
Step 1: 创建 AutomationPanel 组件
// desktop/src/components/Automation/AutomationPanel.tsx
import { useState, useEffect, useMemo } from 'react';
import { Zap, RefreshCw, Loader2, Plus } from 'lucide-react';
import { useHandStore } from '../../store/handStore';
import {
handToAutomationItem,
getCategoryStats,
type AutomationItem,
type CategoryType
} from '../../types/automation';
import { AutomationCard } from './AutomationCard';
import { AutomationFilters } from './AutomationFilters';
import { BatchActionBar } from './BatchActionBar';
export function AutomationPanel() {
const { hands, loadHands, isLoading } = useHandStore();
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [activeCategory, setActiveCategory] = useState<CategoryType>('all');
// 转换为 AutomationItems
const items = useMemo(() => hands.map(handToAutomationItem), [hands]);
// 分类统计
const categoryStats = useMemo(() => getCategoryStats(items), [items]);
// 过滤后的项目
const filteredItems = useMemo(() => {
if (activeCategory === 'all') return items;
return items.filter(item => item.category === activeCategory);
}, [items, activeCategory]);
useEffect(() => {
loadHands();
}, [loadHands]);
// 批量选择
const toggleSelect = (id: string) => {
setSelectedIds(prev => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const selectAll = () => setSelectedIds(new Set(filteredItems.map(i => i.id)));
const clearSelection = () => setSelectedIds(new Set());
return (
<div className="space-y-4 p-4">
{/* Header */}
<div className="flex items-center justify-between">
<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">Hands & Workflows</p>
</div>
<div className="flex items-center gap-2">
<button onClick={() => loadHands()} disabled={isLoading} className="...">
{isLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <RefreshCw className="w-4 h-4" />}
</button>
<button className="...">
<Plus className="w-4 h-4" />
</button>
</div>
</div>
{/* Filters */}
<AutomationFilters
stats={categoryStats}
activeCategory={activeCategory}
onCategoryChange={setActiveCategory}
/>
{/* Batch Actions */}
{selectedIds.size > 0 && (
<BatchActionBar
selectedCount={selectedIds.size}
onExecute={() => {/* TODO */}}
onApprove={() => {/* TODO */}}
onSchedule={() => {/* TODO */}}
onClear={clearSelection}
/>
)}
{/* Cards Grid */}
<div className="grid gap-3">
{filteredItems.map(item => (
<AutomationCard
key={item.id}
item={item}
selected={selectedIds.has(item.id)}
onSelect={() => toggleSelect(item.id)}
/>
))}
</div>
{filteredItems.length === 0 && (
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
暂无可用项目
</div>
)}
</div>
);
}
- Step 2: 创建 index.ts 导出
// desktop/src/components/Automation/index.ts
export { AutomationPanel } from './AutomationPanel';
export { AutomationCard } from './AutomationCard';
export { AutomationFilters } from './AutomationFilters';
export { BatchActionBar } from './BatchActionBar';
export { ScheduleEditor } from './ScheduleEditor';
export { ExecutionResult } from './ExecutionResult';
export { ApprovalQueue } from './ApprovalQueue';
- Step 3: Commit
git add desktop/src/components/Automation/
git commit -m "feat(ui): add AutomationPanel unified entry component"
Task 2.2: 创建 AutomationCard 统一卡片
Files:
-
Create:
desktop/src/components/Automation/AutomationCard.tsx -
Step 1: 创建卡片组件
// desktop/src/components/Automation/AutomationCard.tsx
import { Zap, Play, Clock, Settings, CheckCircle, XCircle, Loader2 } from 'lucide-react';
import type { AutomationItem } from '../../types/automation';
import { useHandStore } from '../../store/handStore';
interface AutomationCardProps {
item: AutomationItem;
selected: boolean;
onSelect: () => void;
}
const STATUS_CONFIG = {
idle: { label: '就绪', className: 'bg-green-100 text-green-700', dot: 'bg-green-500' },
running: { label: '运行中', className: 'bg-blue-100 text-blue-700', dot: 'bg-blue-500 animate-pulse' },
needs_approval: { label: '待审批', className: 'bg-yellow-100 text-yellow-700', dot: 'bg-yellow-500' },
error: { label: '错误', className: 'bg-red-100 text-red-700', dot: 'bg-red-500' },
unavailable: { label: '不可用', className: 'bg-gray-100 text-gray-500', dot: 'bg-gray-400' },
setup_needed: { label: '需配置', className: 'bg-orange-100 text-orange-700', dot: 'bg-orange-500' },
completed: { label: '完成', className: 'bg-green-100 text-green-700', dot: 'bg-green-500' },
paused: { label: '暂停', className: 'bg-gray-100 text-gray-500', dot: 'bg-gray-400' },
};
export function AutomationCard({ item, selected, onSelect }: AutomationCardProps) {
const { triggerHand, isLoading } = useHandStore();
const status = STATUS_CONFIG[item.status] || STATUS_CONFIG.unavailable;
const handleExecute = async () => {
if (item.type === 'hand') {
await triggerHand(item.id);
}
};
const canActivate = item.status === 'idle' || item.status === 'setup_needed';
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">
{/* Header */}
<div className="flex items-start justify-between gap-3 mb-2">
<div className="flex items-center gap-2">
<input
type="checkbox"
checked={selected}
onChange={onSelect}
className="w-4 h-4 rounded border-gray-300"
/>
<span className="text-xl">{item.icon || '🤖'}</span>
<h3 className="font-medium text-gray-900 dark:text-white">{item.name}</h3>
</div>
<span className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium ${status.className}`}>
<span className={`w-1.5 h-1.5 rounded-full ${status.dot}`} />
{status.label}
</span>
</div>
{/* Description */}
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3 ml-9">{item.description}</p>
{/* Actions */}
<div className="flex items-center gap-2 ml-9">
<button
onClick={handleExecute}
disabled={!canActivate || isLoading}
className="px-3 py-1.5 text-sm bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50 flex items-center gap-1"
>
{isLoading ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Play className="w-3.5 h-3.5" />}
执行
</button>
<button 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 flex items-center gap-1">
<Clock className="w-3.5 h-3.5" />
调度
</button>
<button 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 flex items-center gap-1">
<Settings className="w-3.5 h-3.5" />
配置
</button>
</div>
</div>
);
}
- Step 2: Commit
git add desktop/src/components/Automation/AutomationCard.tsx
git commit -m "feat(ui): add AutomationCard unified card component"
Task 2.3: 创建分类筛选器
Files:
-
Create:
desktop/src/components/Automation/AutomationFilters.tsx -
Step 1: 创建筛选器组件
// desktop/src/components/Automation/AutomationFilters.tsx
import type { CategoryType } from '../../types/automation';
interface AutomationFiltersProps {
stats: Record<CategoryType, number>;
activeCategory: CategoryType;
onCategoryChange: (category: CategoryType) => void;
}
const CATEGORIES: { key: CategoryType; label: string }[] = [
{ key: 'all', label: '全部' },
{ key: 'research', label: '研究' },
{ key: 'data', label: '数据' },
{ key: 'automation', label: '自动化' },
{ key: 'communication', label: '通信' },
{ key: 'content', label: '内容' },
];
export function AutomationFilters({ stats, activeCategory, onCategoryChange }: AutomationFiltersProps) {
return (
<div className="flex flex-wrap gap-2">
{CATEGORIES.map(cat => (
<button
key={cat.key}
onClick={() => onCategoryChange(cat.key)}
className={`px-3 py-1.5 text-sm rounded-full transition-colors ${
activeCategory === cat.key
? 'bg-blue-600 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'
}`}
>
{cat.label}
<span className="ml-1.5 text-xs opacity-70">({stats[cat.key] || 0})</span>
</button>
))}
</div>
);
}
- Step 2: Commit
git add desktop/src/components/Automation/AutomationFilters.tsx
git commit -m "feat(ui): add AutomationFilters category filter component"
Phase 3: 高级功能(2-3 天)
Task 3.1: 创建可视化调度器
Files:
-
Create:
desktop/src/components/Automation/ScheduleEditor.tsx -
Step 1: 创建调度器组件
// desktop/src/components/Automation/ScheduleEditor.tsx
import { useState, useMemo } from 'react';
import { X, Save, Clock } from 'lucide-react';
export interface ScheduleInfo {
enabled: boolean;
frequency: 'once' | 'daily' | 'weekly' | 'monthly' | 'custom';
time: { hour: number; minute: number };
daysOfWeek?: number[];
dayOfMonth?: number;
customCron?: string;
timezone: string;
endDate?: Date;
}
interface ScheduleEditorProps {
schedule?: ScheduleInfo;
onSave: (schedule: ScheduleInfo) => void;
onCancel: () => void;
}
const DAYS = ['一', '二', '三', '四', '五', '六', '日'];
const TIMEZONES = ['Asia/Shanghai', 'UTC', 'America/New_York', 'Europe/London'];
export function ScheduleEditor({ schedule, onSave, onCancel }: ScheduleEditorProps) {
const [enabled, setEnabled] = useState(schedule?.enabled ?? true);
const [frequency, setFrequency] = useState<ScheduleInfo['frequency']>(schedule?.frequency ?? 'daily');
const [hour, setHour] = useState(schedule?.time.hour ?? 9);
const [minute, setMinute] = useState(schedule?.time.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 preview = useMemo(() => {
const timeStr = `${hour.toString().padStart(2, '0')}:${minute.toString().padStart(2, '0')}`;
const freqText = {
once: '一次',
daily: '每天',
weekly: `每周 ${daysOfWeek.map(d => DAYS[d]).join('、')}`,
monthly: `每月 ${dayOfMonth} 日`,
custom: '自定义',
}[frequency];
return `${freqText} ${timeStr} (${timezone})`;
}, [frequency, hour, minute, daysOfWeek, dayOfMonth, timezone]);
const toggleDay = (day: number) => {
setDaysOfWeek(prev =>
prev.includes(day) ? prev.filter(d => d !== day) : [...prev, day].sort()
);
};
const handleSave = () => {
onSave({
enabled,
frequency,
time: { hour, minute },
daysOfWeek: frequency === 'weekly' ? daysOfWeek : undefined,
dayOfMonth: frequency === 'monthly' ? dayOfMonth : undefined,
timezone,
});
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/50" onClick={onCancel} />
<div className="relative bg-white dark:bg-gray-800 rounded-xl shadow-2xl w-full max-w-md mx-4 p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">调度设置</h3>
<button onClick={onCancel} className="text-gray-400 hover:text-gray-600"><X className="w-5 h-5" /></button>
</div>
{/* 频率选择 */}
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">频率</label>
<div className="flex flex-wrap gap-2">
{(['once', 'daily', 'weekly', 'monthly'] as const).map(f => (
<button
key={f}
onClick={() => setFrequency(f)}
className={`px-3 py-1.5 text-sm rounded-md ${
frequency === f ? 'bg-blue-600 text-white' : 'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300'
}`}
>
{{ once: '一次', daily: '每天', weekly: '每周', monthly: '每月' }[f]}
</button>
))}
</div>
</div>
{/* 时间选择 */}
<div className="mb-4 flex gap-4">
<div className="flex-1">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">时间</label>
<div className="flex items-center gap-2">
<input type="number" min="0" max="23" value={hour} onChange={e => setHour(+e.target.value)}
className="w-16 px-2 py-1.5 text-sm border rounded-md bg-white dark:bg-gray-900" />
<span>:</span>
<input type="number" min="0" max="59" value={minute} onChange={e => setMinute(+e.target.value)}
className="w-16 px-2 py-1.5 text-sm border rounded-md bg-white dark:bg-gray-900" />
</div>
</div>
<div className="flex-1">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">时区</label>
<select value={timezone} onChange={e => setTimezone(e.target.value)}
className="w-full px-2 py-1.5 text-sm border rounded-md bg-white dark:bg-gray-900">
{TIMEZONES.map(tz => <option key={tz} value={tz}>{tz}</option>)}
</select>
</div>
</div>
{/* 每周选择 */}
{frequency === 'weekly' && (
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">重复日期</label>
<div className="flex gap-2">
{DAYS.map((day, i) => (
<button
key={i}
onClick={() => toggleDay(i)}
className={`w-8 h-8 text-sm rounded-full ${
daysOfWeek.includes(i) ? 'bg-blue-600 text-white' : 'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300'
}`}
>
{day}
</button>
))}
</div>
</div>
)}
{/* 预览 */}
<div className="mb-4 p-3 bg-gray-50 dark:bg-gray-900 rounded-lg">
<div className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400">
<Clock className="w-4 h-4" />
<span>{preview}</span>
</div>
</div>
{/* 操作按钮 */}
<div className="flex justify-end gap-2">
<button onClick={onCancel} className="px-4 py-2 text-sm border rounded-md hover:bg-gray-50 dark:hover:bg-gray-700">取消</button>
<button onClick={handleSave} className="px-4 py-2 text-sm bg-blue-600 text-white rounded-md hover:bg-blue-700 flex items-center gap-1">
<Save className="w-4 h-4" />保存
</button>
</div>
</div>
</div>
);
}
- Step 2: Commit
git add desktop/src/components/Automation/ScheduleEditor.tsx
git commit -m "feat(ui): add ScheduleEditor visual scheduler component"
Task 3.2: 创建批量操作栏
Files:
-
Create:
desktop/src/components/Automation/BatchActionBar.tsx -
Step 1: 创建批量操作栏
// desktop/src/components/Automation/BatchActionBar.tsx
import { Play, Check, X, Clock, XCircle } from 'lucide-react';
interface BatchActionBarProps {
selectedCount: number;
onExecute: () => void;
onApprove: () => void;
onReject: () => void;
onSchedule: () => void;
onClear: () => void;
}
export function BatchActionBar({ selectedCount, onExecute, onApprove, onReject, onSchedule, onClear }: BatchActionBarProps) {
return (
<div className="flex items-center justify-between p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-blue-700 dark:text-blue-300">
已选择 {selectedCount} 项
</span>
<button onClick={onClear} className="text-sm text-blue-600 dark:text-blue-400 hover:underline">
取消选择
</button>
</div>
<div className="flex items-center gap-2">
<button onClick={onExecute} className="px-3 py-1.5 text-sm bg-blue-600 text-white rounded-md hover:bg-blue-700 flex items-center gap-1">
<Play className="w-3.5 h-3.5" />执行选中
</button>
<button onClick={onApprove} className="px-3 py-1.5 text-sm bg-green-600 text-white rounded-md hover:bg-green-700 flex items-center gap-1">
<Check className="w-3.5 h-3.5" />批量审批
</button>
<button onClick={onReject} className="px-3 py-1.5 text-sm bg-red-600 text-white rounded-md hover:bg-red-700 flex items-center gap-1">
<X className="w-3.5 h-3.5" />批量拒绝
</button>
<button onClick={onSchedule} 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 flex items-center gap-1">
<Clock className="w-3.5 h-3.5" />批量调度
</button>
</div>
</div>
);
}
- Step 2: Commit
git add desktop/src/components/Automation/BatchActionBar.tsx
git commit -m "feat(ui): add BatchActionBar for batch operations"
Task 3.3: 扩展 handStore 支持批量操作
Files:
-
Modify:
desktop/src/store/handStore.ts:166-184 -
Step 1: 添加批量操作到 store 接口
// 在 HandActionsSlice 接口中添加
export interface HandActionsSlice {
// ... 现有方法
// 批量操作
executeBatch: (ids: string[], params?: Record<string, unknown>) => Promise<BatchResult>;
approveBatch: (approvals: { handName: string; runId: string }[]) => Promise<BatchResult>;
rejectBatch: (approvals: { handName: string; runId: string; reason: string }[]) => Promise<BatchResult>;
}
interface BatchResult {
successful: string[];
failed: Array<{ id: string; error: string }>;
}
- Step 2: 实现批量操作方法
// 在 useHandStore create 中添加
executeBatch: async (ids: string[], params?: Record<string, unknown>) => {
const client = get().client;
if (!client) return { successful: [], failed: ids.map(id => ({ id, error: 'No client' })) };
const results = await Promise.allSettled(
ids.map(id => client.triggerHand(id, params))
);
const successful: string[] = [];
const failed: Array<{ id: string; error: string }> = [];
results.forEach((result, i) => {
if (result.status === 'fulfilled' && result.value) {
successful.push(ids[i]);
} else {
failed.push({ id: ids[i], error: result.status === 'rejected' ? String(result.reason) : 'Unknown error' });
}
});
await get().loadHands();
return { successful, failed };
},
approveBatch: async (approvals: { handName: string; runId: string }[]) => {
const client = get().client;
if (!client) return { successful: [], failed: approvals.map(a => ({ id: a.runId, error: 'No client' })) };
const results = await Promise.allSettled(
approvals.map(a => client.approveHand(a.handName, a.runId, true))
);
const successful: string[] = [];
const failed: Array<{ id: string; error: string }> = [];
results.forEach((result, i) => {
if (result.status === 'fulfilled') {
successful.push(approvals[i].runId);
} else {
failed.push({ id: approvals[i].runId, error: String(result.reason) });
}
});
await get().loadApprovals();
return { successful, failed };
},
rejectBatch: async (approvals: { handName: string; runId: string; reason: string }[]) => {
const client = get().client;
if (!client) return { successful: [], failed: approvals.map(a => ({ id: a.runId, error: 'No client' })) };
const results = await Promise.allSettled(
approvals.map(a => client.approveHand(a.handName, a.runId, false, a.reason))
);
const successful: string[] = [];
const failed: Array<{ id: string; error: string }> = [];
results.forEach((result, i) => {
if (result.status === 'fulfilled') {
successful.push(approvals[i].runId);
} else {
failed.push({ id: approvals[i].runId, error: String(result.reason) });
}
});
await get().loadApprovals();
return { successful, failed };
},
- Step 3: Commit
git add desktop/src/store/handStore.ts
git commit -m "feat(store): add batch operations to handStore"
Phase 4: 工作流集成(2-3 天)
Task 4.1: 创建审批队列组件
Files:
-
Create:
desktop/src/components/Automation/ApprovalQueue.tsx -
Step 1: 创建审批队列组件
// desktop/src/components/Automation/ApprovalQueue.tsx
import { useEffect, useState } from 'react';
import { Check, X, Clock } from 'lucide-react';
import { useHandStore, type Approval } from '../../store/handStore';
export function ApprovalQueue() {
const { approvals, loadApprovals, respondToApproval, isLoading } = useHandStore();
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
useEffect(() => {
loadApprovals('pending');
}, [loadApprovals]);
const pendingApprovals = approvals.filter(a => a.status === 'pending');
const toggleSelect = (id: string) => {
setSelectedIds(prev => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const handleApproveAll = async () => {
for (const id of selectedIds) {
await respondToApproval(id, true);
}
setSelectedIds(new Set());
};
const handleRejectAll = async () => {
for (const id of selectedIds) {
await respondToApproval(id, false, '批量拒绝');
}
setSelectedIds(new Set());
};
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">
审批队列 ({pendingApprovals.length} 待处理)
</h3>
{selectedIds.size > 0 && (
<div className="flex gap-2">
<button onClick={handleApproveAll} className="px-3 py-1 text-sm bg-green-600 text-white rounded-md">
全部批准
</button>
<button onClick={handleRejectAll} className="px-3 py-1 text-sm bg-red-600 text-white rounded-md">
全部拒绝
</button>
</div>
)}
</div>
{pendingApprovals.length === 0 ? (
<div className="text-center py-8 text-gray-500 dark:text-gray-400 text-sm">
暂无待处理的审批请求
</div>
) : (
<div className="space-y-2">
{pendingApprovals.map(approval => (
<div key={approval.id} className="flex items-start gap-3 p-3 bg-gray-50 dark:bg-gray-900 rounded-lg">
<input
type="checkbox"
checked={selectedIds.has(approval.id)}
onChange={() => toggleSelect(approval.id)}
className="mt-1"
/>
<div className="flex-1 min-w-0">
<p className="font-medium text-gray-900 dark:text-white">{approval.handName}</p>
<p className="text-sm text-gray-500 dark:text-gray-400">{approval.reason}</p>
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">
{new Date(approval.requestedAt).toLocaleString()}
</p>
</div>
<div className="flex gap-1">
<button
onClick={() => respondToApproval(approval.id, true)}
className="p-1.5 text-green-600 hover:bg-green-50 dark:hover:bg-green-900/20 rounded"
>
<Check className="w-4 h-4" />
</button>
<button
onClick={() => respondToApproval(approval.id, false)}
className="p-1.5 text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 rounded"
>
<X className="w-4 h-4" />
</button>
</div>
</div>
))}
</div>
)}
</div>
);
}
- Step 2: Commit
git add desktop/src/components/Automation/ApprovalQueue.tsx
git commit -m "feat(ui): add ApprovalQueue component for batch approvals"
Task 4.2: 创建执行结果展示组件
Files:
-
Create:
desktop/src/components/Automation/ExecutionResult.tsx -
Step 1: 创建结果展示组件
// desktop/src/components/Automation/ExecutionResult.tsx
import { CheckCircle, XCircle, Clock, Loader2, Download, RotateCcw } from 'lucide-react';
interface ExecutionResultProps {
type: 'hand' | 'workflow';
name: string;
status: 'running' | 'completed' | 'failed' | 'needs_approval';
duration?: number;
output?: unknown;
error?: string;
onRetry?: () => void;
onDownload?: () => void;
}
export function ExecutionResult({ type, name, status, duration, output, error, onRetry, onDownload }: ExecutionResultProps) {
const statusConfig = {
running: { icon: Loader2, color: 'text-blue-500', label: '运行中', animate: true },
completed: { icon: CheckCircle, color: 'text-green-500', label: '完成', animate: false },
failed: { icon: XCircle, color: 'text-red-500', label: '失败', animate: false },
needs_approval: { icon: Clock, color: 'text-yellow-500', label: '待审批', animate: false },
};
const config = statusConfig[status];
const Icon = config.icon;
const formatDuration = (ms: number) => {
if (ms < 1000) return `${ms}ms`;
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
return `${Math.floor(ms / 60000)}m ${Math.floor((ms % 60000) / 1000)}s`;
};
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-center justify-between mb-3">
<div className="flex items-center gap-2">
<span className="text-xl">{type === 'hand' ? '🔍' : '📋'}</span>
<span className="font-medium text-gray-900 dark:text-white">{name}</span>
</div>
<div className={`flex items-center gap-1.5 ${config.color}`}>
<Icon className={`w-4 h-4 ${config.animate ? 'animate-spin' : ''}`} />
<span className="text-sm font-medium">{config.label}</span>
</div>
</div>
{/* Meta */}
{duration !== undefined && (
<div className="text-xs text-gray-500 dark:text-gray-400 mb-3">
耗时: {formatDuration(duration)}
</div>
)}
{/* Output */}
{output && (
<div className="bg-gray-50 dark:bg-gray-900 rounded-lg p-3 mb-3">
<pre className="text-sm text-gray-700 dark:text-gray-300 whitespace-pre-wrap overflow-auto max-h-60">
{typeof output === 'string' ? output : JSON.stringify(output, null, 2)}
</pre>
</div>
)}
{/* Error */}
{error && (
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-3 mb-3">
<p className="text-sm text-red-700 dark:text-red-400">{error}</p>
</div>
)}
{/* Actions */}
<div className="flex gap-2">
{onDownload && output && (
<button onClick={onDownload} className="px-3 py-1.5 text-sm border rounded-md flex items-center gap-1">
<Download className="w-3.5 h-3.5" />下载结果
</button>
)}
{onRetry && status === 'failed' && (
<button onClick={onRetry} className="px-3 py-1.5 text-sm bg-blue-600 text-white rounded-md flex items-center gap-1">
<RotateCcw className="w-3.5 h-3.5" />重试
</button>
)}
</div>
</div>
);
}
- Step 2: Commit
git add desktop/src/components/Automation/ExecutionResult.tsx
git commit -m "feat(ui): add ExecutionResult display component"
Task 4.3: 更新 Sidebar 导航
Files:
-
Modify:
desktop/src/components/Sidebar.tsx -
Step 1: 添加自动化导航入口
// 在 Sidebar 导航项中添加或修改
// 将 "Hands" 改为 "自动化"
{
id: 'automation',
label: '自动化',
icon: Zap,
view: 'automation',
}
- Step 2: Commit
git add desktop/src/components/Sidebar.tsx
git commit -m "feat(ui): update Sidebar with automation navigation"
Task 4.4: 更新 App.tsx 路由
Files:
-
Modify:
desktop/src/App.tsx -
Step 1: 添加自动化视图
// 在 App.tsx 中添加 automation 视图的处理
import { AutomationPanel } from './components/Automation';
// 在视图切换逻辑中
case 'automation':
return <AutomationPanel />;
- Step 2: Commit
git add desktop/src/App.tsx
git commit -m "feat(app): add automation view routing"
验收测试清单
完成所有任务后,运行以下验收测试:
-
验收 1: Hand 执行流程
- 打开自动化面板
- 选择一个 Hand
- 填写参数表单
- 点击执行
- 确认参数正确传递到 API
- 确认看到实时状态更新
- 确认结果显示在聊天流
-
验收 2: 批量操作
- 选择 3 个 Hands
- 点击批量执行
- 确认全部成功或看到失败报告
-
验收 3: 调度器
- 点击调度按钮
- 设置"每天 9:00"
- 保存
- 确认在调度列表看到正确预览
-
验收 4: 审批流程
- 触发需要审批的 Hand
- 确认弹出审批请求
- 批准
- 确认继续执行
风险与缓解
| 风险 | 缓解措施 |
|---|---|
| 后端 API 未完全实现 | 使用 fallback 数据,标注"后端未实现" |
| WebSocket 不稳定 | 心跳机制 + 自动重连 |
| 迁移影响现有功能 | 保留旧组件作为 fallback,分阶段迁移 |
完成标志
当以下条件全部满足时,视为实现完成:
- ✅ 所有 Phase 1-4 任务完成
- ✅ 验收测试 1-4 全部通过
- ✅
pnpm vitest run全部通过 - ✅
pnpm tsc --noEmit无错误 - ✅ 所有 commits 已推送到分支