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

42 KiB
Raw Permalink Blame History

自动化系统重设计实现计划

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
// 在 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 执行流程

    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 已推送到分支