Files
zclaw_openfang/docs/superpowers/plans/2026-03-18-automation-system-redesign.md
iven dfeb286591 docs(plan): add automation system redesign implementation plan
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>
2026-03-18 15:55:18 +08:00

1334 lines
42 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 自动化系统重设计实现计划
> **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/WorkflowWebSocket 事件驱动实时状态;结果流注入聊天消息
**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**
```typescript
// 在 HandsPanel 组件内添加
const [paramValues, setParamValues] = useState<Record<string, unknown>>({});
```
- [ ] **Step 2: 修改 HandDetailsModal 的 onActivate 回调**
```typescript
// 修改 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 传递参数**
```typescript
// 修改 handleModalActivate
const handleModalActivate = useCallback(async (params: Record<string, unknown>) => {
if (!selectedHand) return;
setShowModal(false);
await handleActivate(selectedHand, params); // 传递参数
}, [selectedHand, handleActivate]);
```
- [ ] **Step 4: 修改 handleActivate 接收并传递参数**
```typescript
// 修改 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**
```bash
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**
```typescript
// 修改第 11 行
// 从:
import { useGatewayStore, type Hand, type HandRequirement } from '../store/gatewayStore';
// 改为:
import { useHandStore, type Hand, type HandRequirement } from '../store/handStore';
```
- [ ] **Step 2: 修改组件内的 store 调用**
```typescript
// 修改第 458 行
// 从:
const { hands, loadHands, triggerHand, isLoading } = useGatewayStore();
// 改为:
const { hands, loadHands, triggerHand, isLoading } = useHandStore();
```
- [ ] **Step 3: 修改 getHandDetails 调用**
```typescript
// 修改第 471 行
// 从:
const { getHandDetails } = useGatewayStore.getState();
// 改为:
const { getHandDetails } = useHandStore.getState();
```
- [ ] **Step 4: 确保 store/index.ts 正确初始化 handStore client**
```typescript
// 检查 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**
```bash
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: 创建类型适配器文件**
```typescript
// 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: 写测试**
```typescript
// 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**
```bash
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**
```typescript
// 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**
```typescript
// 在 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**
```bash
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 组件**
```typescript
// 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 导出**
```typescript
// 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**
```bash
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: 创建卡片组件**
```typescript
// 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**
```bash
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: 创建筛选器组件**
```typescript
// 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**
```bash
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: 创建调度器组件**
```typescript
// 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**
```bash
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: 创建批量操作栏**
```typescript
// 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**
```bash
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 接口**
```typescript
// 在 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: 实现批量操作方法**
```typescript
// 在 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**
```bash
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: 创建审批队列组件**
```typescript
// 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**
```bash
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: 创建结果展示组件**
```typescript
// 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**
```bash
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: 添加自动化导航入口**
```typescript
// 在 Sidebar 导航项中添加或修改
// 将 "Hands" 改为 "自动化"
{
id: 'automation',
label: '自动化',
icon: Zap,
view: 'automation',
}
```
- [ ] **Step 2: Commit**
```bash
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: 添加自动化视图**
```typescript
// 在 App.tsx 中添加 automation 视图的处理
import { AutomationPanel } from './components/Automation';
// 在视图切换逻辑中
case 'automation':
return <AutomationPanel />;
```
- [ ] **Step 2: Commit**
```bash
git add desktop/src/App.tsx
git commit -m "feat(app): add automation view routing"
```
---
## 验收测试清单
完成所有任务后,运行以下验收测试:
- [ ] **验收 1: Hand 执行流程**
1. 打开自动化面板
2. 选择一个 Hand
3. 填写参数表单
4. 点击执行
5. 确认参数正确传递到 API
6. 确认看到实时状态更新
7. 确认结果显示在聊天流
- [ ] **验收 2: 批量操作**
1. 选择 3 个 Hands
2. 点击批量执行
3. 确认全部成功或看到失败报告
- [ ] **验收 3: 调度器**
1. 点击调度按钮
2. 设置"每天 9:00"
3. 保存
4. 确认在调度列表看到正确预览
- [ ] **验收 4: 审批流程**
1. 触发需要审批的 Hand
2. 确认弹出审批请求
3. 批准
4. 确认继续执行
---
## 风险与缓解
| 风险 | 缓解措施 |
|------|---------|
| 后端 API 未完全实现 | 使用 fallback 数据,标注"后端未实现" |
| WebSocket 不稳定 | 心跳机制 + 自动重连 |
| 迁移影响现有功能 | 保留旧组件作为 fallback分阶段迁移 |
---
## 完成标志
当以下条件全部满足时,视为实现完成:
1. ✅ 所有 Phase 1-4 任务完成
2. ✅ 验收测试 1-4 全部通过
3.`pnpm vitest run` 全部通过
4.`pnpm tsc --noEmit` 无错误
5. ✅ 所有 commits 已推送到分支