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>
1334 lines
42 KiB
Markdown
1334 lines
42 KiB
Markdown
# 自动化系统重设计实现计划
|
||
|
||
> **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**
|
||
|
||
```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 已推送到分支
|