Files
zclaw_openfang/docs/superpowers/plans/2026-03-17-browser-hand-ui.md
iven 74dbf42644 refactor(startup): simplify stack to Tauri-managed OpenFang + optional ChromeDriver
- Remove OpenFang CLI dependency from startup scripts
- OpenFang now bundled with Tauri and managed via gateway_start/gateway_status commands
- Add bootstrap screen in App.tsx to auto-start local gateway before UI loads
- Update Makefile: replace start-no-gateway with start-desktop-only
- Fix gateway config endpoints: use /api/config instead of /api/config/quick
- Add Playwright dependencies for future E2E testing
2026-03-17 14:08:03 +08:00

1840 lines
53 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.

# Browser Hand UI 组件实现计划
> **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:** 实现 Browser Hand UI 组件,集成到 ZCLAW 的 HandsPanel支持任务模板执行和实时状态预览。
**Architecture:** 使用 Zustand 管理状态React 组件展示 UI通过 Tauri commands 调用 Fantoccini WebDriver。
**Tech Stack:** React, TypeScript, Zustand, Tailwind CSS, Lucide Icons, Tauri
**Spec:** [2026-03-17-browser-hand-ui-design.md](../specs/2026-03-17-browser-hand-ui-design.md)
---
## 文件结构
```
desktop/src/
├── store/
│ └── browserHandStore.ts # [CREATE] 状态管理
├── lib/
│ └── browser-templates.ts # [CREATE] 模板执行引擎
├── components/
│ ├── HandsPanel.tsx # [MODIFY] 集成 BrowserHandCard
│ └── BrowserHand/
│ ├── index.ts # [CREATE] 模块导出
│ ├── BrowserHandCard.tsx # [CREATE] 主卡片组件
│ ├── TaskTemplateModal.tsx # [CREATE] 模板选择模态框
│ ├── ScreenshotPreview.tsx # [CREATE] 截图预览
│ └── templates/
│ ├── index.ts # [CREATE] 模板注册
│ ├── types.ts # [CREATE] 类型定义
│ ├── basic.ts # [CREATE] 基础操作模板
│ ├── scraping.ts # [CREATE] 数据采集模板
│ └── automation.ts # [CREATE] 自动化流程模板
└── tests/
└── desktop/
└── browserHandStore.test.ts # [CREATE] Store 测试
```
---
## Chunk 1: 基础架构
### Task 1.1: 模板类型定义
**Files:**
- Create: `desktop/src/components/BrowserHand/templates/types.ts`
- [ ] **Step 1: 创建模板类型文件**
```typescript
// desktop/src/components/BrowserHand/templates/types.ts
export interface TaskTemplateParam {
key: string;
label: string;
type: 'text' | 'url' | 'number' | 'select' | 'textarea' | 'json';
required: boolean;
default?: unknown;
placeholder?: string;
options?: { value: string; label: string }[];
description?: string;
}
export interface TaskTemplate {
id: string;
name: string;
description: string;
category: 'basic' | 'scraping' | 'automation';
icon: string;
params: TaskTemplateParam[];
execute: (params: Record<string, unknown>, context: ExecutionContext) => Promise<unknown>;
}
export interface ExecutionContext {
browser: import('../../lib/browser-client').Browser;
onProgress: (action: string, progress: number) => void;
onLog: (level: 'info' | 'warn' | 'error' | 'action', message: string, details?: Record<string, unknown>) => void;
}
export interface TemplateCategory {
id: 'basic' | 'scraping' | 'automation';
name: string;
description: string;
}
```
- [ ] **Step 2: Commit**
```bash
git add desktop/src/components/BrowserHand/templates/types.ts
git commit -m "feat(browser-hand): add template type definitions"
```
---
### Task 1.2: 基础操作模板
**Files:**
- Create: `desktop/src/components/BrowserHand/templates/basic.ts`
- [ ] **Step 1: 实现基础操作模板**
```typescript
// desktop/src/components/BrowserHand/templates/basic.ts
import type { TaskTemplate, ExecutionContext } from './types';
export const basicTemplates: TaskTemplate[] = [
{
id: 'basic_navigate_screenshot',
name: '打开网页并截图',
description: '访问指定 URL 并截图保存',
category: 'basic',
icon: '📸',
params: [
{
key: 'url',
label: '网页地址',
type: 'url',
required: true,
placeholder: 'https://example.com',
description: '要访问的网页 URL',
},
],
execute: async (params, context) => {
const { browser, onProgress, onLog } = context;
const url = params.url as string;
onProgress('导航到页面', 20);
onLog('info', `正在打开: ${url}`);
await browser.goto(url);
onProgress('等待页面加载', 50);
await new Promise(r => setTimeout(r, 1000));
onProgress('截取屏幕', 80);
const screenshot = await browser.screenshot();
onLog('info', '截图完成');
onProgress('完成', 100);
return { url, screenshot };
},
},
{
id: 'basic_fill_form',
name: '填写表单',
description: '访问页面并填写表单字段',
category: 'basic',
icon: '📝',
params: [
{
key: 'url',
label: '网页地址',
type: 'url',
required: true,
},
{
key: 'fields',
label: '表单字段',
type: 'json',
required: true,
placeholder: '[{"selector": "input[name=email]", "value": "test@example.com"}]',
description: 'JSON 格式的字段配置',
},
{
key: 'submitSelector',
label: '提交按钮',
type: 'text',
required: false,
placeholder: 'button[type="submit"]',
},
],
execute: async (params, context) => {
const { browser, onProgress, onLog } = context;
const url = params.url as string;
const fields = params.fields as Array<{ selector: string; value: string }>;
const submitSelector = params.submitSelector as string | undefined;
onProgress('导航到页面', 20);
await browser.goto(url);
onProgress('填写表单', 40);
for (const field of fields) {
await browser.type(field.selector, field.value, true);
onLog('action', `填写字段: ${field.selector}`);
}
if (submitSelector) {
onProgress('提交表单', 70);
await browser.click(submitSelector);
onLog('action', `点击提交: ${submitSelector}`);
await new Promise(r => setTimeout(r, 1000));
}
onProgress('完成', 100);
return { success: true, fieldsFilled: fields.length };
},
},
{
id: 'basic_click_navigate',
name: '点击导航',
description: '访问页面并点击指定元素',
category: 'basic',
icon: '🖱️',
params: [
{
key: 'url',
label: '网页地址',
type: 'url',
required: true,
},
{
key: 'selector',
label: '元素选择器',
type: 'text',
required: true,
placeholder: 'a.link, button.submit',
},
{
key: 'waitFor',
label: '等待元素',
type: 'text',
required: false,
description: '点击后等待此元素出现',
},
],
execute: async (params, context) => {
const { browser, onProgress, onLog } = context;
const url = params.url as string;
const selector = params.selector as string;
const waitFor = params.waitFor as string | undefined;
onProgress('导航到页面', 20);
await browser.goto(url);
onProgress('等待元素', 40);
await browser.wait(selector, 10000);
onProgress('点击元素', 60);
await browser.click(selector);
onLog('action', `点击: ${selector}`);
if (waitFor) {
onProgress('等待页面更新', 80);
await browser.wait(waitFor, 10000);
}
const finalUrl = await browser.url();
onProgress('完成', 100);
return { clicked: selector, finalUrl };
},
},
];
```
- [ ] **Step 2: Commit**
```bash
git add desktop/src/components/BrowserHand/templates/basic.ts
git commit -m "feat(browser-hand): add basic operation templates"
```
---
### Task 1.3: 数据采集模板
**Files:**
- Create: `desktop/src/components/BrowserHand/templates/scraping.ts`
- [ ] **Step 1: 实现数据采集模板**
```typescript
// desktop/src/components/BrowserHand/templates/scraping.ts
import type { TaskTemplate, ExecutionContext } from './types';
export const scrapingTemplates: TaskTemplate[] = [
{
id: 'scrape_text',
name: '抓取页面文本',
description: '从多个选择器提取文本内容',
category: 'scraping',
icon: '📄',
params: [
{
key: 'url',
label: '网页地址',
type: 'url',
required: true,
},
{
key: 'selectors',
label: '选择器列表',
type: 'textarea',
required: true,
placeholder: 'h1.title\np.description\n.price',
description: '每行一个 CSS 选择器',
},
],
execute: async (params, context) => {
const { browser, onProgress, onLog } = context;
const url = params.url as string;
const selectorsText = params.selectors as string;
const selectors = selectorsText.split('\n').map(s => s.trim()).filter(Boolean);
onProgress('导航到页面', 20);
await browser.goto(url);
onProgress('提取内容', 50);
const results: Record<string, string[]> = {};
for (const selector of selectors) {
try {
const elements = await browser.$$(selector);
results[selector] = elements.map(el => el.text || '');
onLog('info', `提取 ${selector}: ${results[selector].length} 个元素`);
} catch {
results[selector] = [];
onLog('warn', `未找到: ${selector}`);
}
}
onProgress('完成', 100);
return { url, data: results };
},
},
{
id: 'scrape_list',
name: '提取列表数据',
description: '批量提取结构化列表数据',
category: 'scraping',
icon: '📊',
params: [
{
key: 'url',
label: '网页地址',
type: 'url',
required: true,
},
{
key: 'itemSelector',
label: '列表项选择器',
type: 'text',
required: true,
placeholder: '.product-item, .list-item',
},
{
key: 'fields',
label: '字段映射',
type: 'json',
required: true,
placeholder: '{"name": "h3", "price": ".price", "link": "a@href"}',
description: 'JSON 对象,键为字段名,值为选择器',
},
],
execute: async (params, context) => {
const { browser, onProgress, onLog } = context;
const url = params.url as string;
const itemSelector = params.itemSelector as string;
const fields = params.fields as Record<string, string>;
onProgress('导航到页面', 20);
await browser.goto(url);
onProgress('查找列表项', 40);
const items = await browser.$$(itemSelector);
onLog('info', `找到 ${items.length} 个列表项`);
onProgress('提取数据', 60);
const data = items.map((item, index) => {
const row: Record<string, unknown> = {};
for (const [field, selector] of Object.entries(fields)) {
row[field] = item.text; // Simplified - real impl would use relative selectors
}
return row;
});
onProgress('完成', 100);
return { url, count: data.length, data };
},
},
{
id: 'scrape_images',
name: '收集图片链接',
description: '提取页面中的图片 URL',
category: 'scraping',
icon: '🖼️',
params: [
{
key: 'url',
label: '网页地址',
type: 'url',
required: true,
},
{
key: 'imageSelector',
label: '图片选择器',
type: 'text',
required: false,
default: 'img',
placeholder: 'img, .gallery img',
},
],
execute: async (params, context) => {
const { browser, onProgress, onLog } = context;
const url = params.url as string;
const imageSelector = (params.imageSelector as string) || 'img';
onProgress('导航到页面', 20);
await browser.goto(url);
onProgress('查找图片', 50);
const images = await browser.$$(imageSelector);
onLog('info', `找到 ${images.length} 张图片`);
onProgress('完成', 100);
return { url, count: images.length };
},
},
];
```
- [ ] **Step 2: Commit**
```bash
git add desktop/src/components/BrowserHand/templates/scraping.ts
git commit -m "feat(browser-hand): add scraping templates"
```
---
### Task 1.4: 自动化流程模板
**Files:**
- Create: `desktop/src/components/BrowserHand/templates/automation.ts`
- [ ] **Step 1: 实现自动化流程模板**
```typescript
// desktop/src/components/BrowserHand/templates/automation.ts
import type { TaskTemplate, ExecutionContext } from './types';
export const automationTemplates: TaskTemplate[] = [
{
id: 'auto_login_action',
name: '登录并操作',
description: '执行登录后进行后续操作',
category: 'automation',
icon: '🔐',
params: [
{
key: 'loginUrl',
label: '登录页面',
type: 'url',
required: true,
},
{
key: 'username',
label: '用户名',
type: 'text',
required: true,
},
{
key: 'password',
label: '密码',
type: 'text',
required: true,
},
{
key: 'usernameSelector',
label: '用户名输入框',
type: 'text',
required: true,
default: 'input[name="username"], input[type="text"]',
},
{
key: 'passwordSelector',
label: '密码输入框',
type: 'text',
required: true,
default: 'input[name="password"], input[type="password"]',
},
{
key: 'submitSelector',
label: '登录按钮',
type: 'text',
required: true,
default: 'button[type="submit"], input[type="submit"]',
},
],
execute: async (params, context) => {
const { browser, onProgress, onLog } = context;
onProgress('打开登录页', 10);
await browser.goto(params.loginUrl as string);
onProgress('填写凭据', 30);
await browser.type(params.usernameSelector as string, params.username as string, true);
await browser.type(params.passwordSelector as string, params.password as string, true);
onLog('action', '已填写用户名和密码');
onProgress('提交登录', 50);
await browser.click(params.submitSelector as string);
onLog('action', '已点击登录按钮');
onProgress('等待登录完成', 70);
await new Promise(r => setTimeout(r, 2000));
const finalUrl = await browser.url();
onProgress('完成', 100);
return { success: true, finalUrl };
},
},
{
id: 'auto_multi_page',
name: '多页面导航',
description: '遍历多个 URL 执行操作',
category: 'automation',
icon: '📑',
params: [
{
key: 'urls',
label: 'URL 列表',
type: 'textarea',
required: true,
placeholder: 'https://example1.com\nhttps://example2.com',
description: '每行一个 URL',
},
{
key: 'action',
label: '每页操作',
type: 'select',
required: true,
default: 'screenshot',
options: [
{ value: 'screenshot', label: '截图' },
{ value: 'source', label: '获取源码' },
{ value: 'text', label: '提取文本' },
],
},
],
execute: async (params, context) => {
const { browser, onProgress, onLog } = context;
const urlsText = params.urls as string;
const urls = urlsText.split('\n').map(s => s.trim()).filter(Boolean);
const action = params.action as string;
const results: Array<{ url: string; success: boolean; data?: unknown }> = [];
for (let i = 0; i < urls.length; i++) {
const url = urls[i];
const progress = Math.round(((i + 1) / urls.length) * 100);
onProgress(`处理 ${i + 1}/${urls.length}`, progress);
try {
await browser.goto(url);
await new Promise(r => setTimeout(r, 1000));
let data: unknown;
if (action === 'screenshot') {
data = await browser.screenshot();
} else if (action === 'source') {
data = await browser.source();
}
results.push({ url, success: true, data });
onLog('info', `完成: ${url}`);
} catch (err) {
results.push({ url, success: false });
onLog('error', `失败: ${url}`);
}
}
return { total: urls.length, results };
},
},
{
id: 'auto_monitor',
name: '页面监控',
description: '检查页面是否存在指定内容',
category: 'automation',
icon: '👁️',
params: [
{
key: 'url',
label: '监控页面',
type: 'url',
required: true,
},
{
key: 'checkSelector',
label: '检查元素',
type: 'text',
required: true,
description: '检查此元素是否存在',
},
],
execute: async (params, context) => {
const { browser, onProgress, onLog } = context;
const url = params.url as string;
const checkSelector = params.checkSelector as string;
onProgress('访问页面', 30);
await browser.goto(url);
onProgress('检查元素', 60);
let found = false;
try {
await browser.wait(checkSelector, 5000);
found = true;
onLog('info', `元素存在: ${checkSelector}`);
} catch {
onLog('warn', `元素不存在: ${checkSelector}`);
}
onProgress('完成', 100);
return { url, selector: checkSelector, found, checkedAt: new Date().toISOString() };
},
},
];
```
- [ ] **Step 2: Commit**
```bash
git add desktop/src/components/BrowserHand/templates/automation.ts
git commit -m "feat(browser-hand): add automation templates"
```
---
### Task 1.5: 模板注册
**Files:**
- Create: `desktop/src/components/BrowserHand/templates/index.ts`
- [ ] **Step 1: 创建模板注册文件**
```typescript
// desktop/src/components/BrowserHand/templates/index.ts
import type { TaskTemplate, TemplateCategory } from './types';
import { basicTemplates } from './basic';
import { scrapingTemplates } from './scraping';
import { automationTemplates } from './automation';
export * from './types';
export const TEMPLATE_CATEGORIES: TemplateCategory[] = [
{
id: 'basic',
name: '基础操作',
description: '导航、点击、表单等基础操作',
},
{
id: 'scraping',
name: '数据采集',
description: '文本、列表、图片等数据提取',
},
{
id: 'automation',
name: '自动化流程',
description: '登录、多页面、监控等自动化任务',
},
];
export const BUILTIN_TEMPLATES: TaskTemplate[] = [
...basicTemplates,
...scrapingTemplates,
...automationTemplates,
];
export function getTemplatesByCategory(category: TaskTemplate['category']): TaskTemplate[] {
return BUILTIN_TEMPLATES.filter(t => t.category === category);
}
export function getTemplateById(id: string): TaskTemplate | undefined {
return BUILTIN_TEMPLATES.find(t => t.id === id);
}
```
- [ ] **Step 2: Commit**
```bash
git add desktop/src/components/BrowserHand/templates/index.ts
git commit -m "feat(browser-hand): add template registry"
```
---
### Task 1.6: 状态管理 Store
**Files:**
- Create: `desktop/src/store/browserHandStore.ts`
- [ ] **Step 1: 创建 browserHandStore**
```typescript
// desktop/src/store/browserHandStore.ts
import { create } from 'zustand';
import Browser from '../lib/browser-client';
import type { TaskTemplate } from '../components/BrowserHand/templates/types';
import { BUILTIN_TEMPLATES } from '../components/BrowserHand/templates';
// === Types ===
export interface BrowserLog {
id: string;
timestamp: string;
level: 'info' | 'warn' | 'error' | 'action';
message: string;
details?: Record<string, unknown>;
}
export interface ExecutionState {
isRunning: boolean;
currentAction: string | null;
currentUrl: string | null;
lastScreenshot: string | null;
progress: number;
startTime: string | null;
templateName: string | null;
}
export interface RecentTask {
id: string;
templateId: string;
templateName: string;
params: Record<string, unknown>;
status: 'success' | 'failed' | 'cancelled';
executedAt: string;
duration: number;
result?: unknown;
error?: string;
}
// === Store State ===
interface BrowserHandState {
execution: ExecutionState;
logs: BrowserLog[];
templates: TaskTemplate[];
recentTasks: RecentTask[];
isTemplateModalOpen: boolean;
error: string | null;
}
// === Store Actions ===
interface BrowserHandActions {
executeTemplate: (templateId: string, params: Record<string, unknown>) => Promise<unknown>;
stopExecution: () => void;
updateExecutionState: (state: Partial<ExecutionState>) => void;
addLog: (log: Omit<BrowserLog, 'id' | 'timestamp'>) => void;
clearLogs: () => void;
openTemplateModal: () => void;
closeTemplateModal: () => void;
clearError: () => void;
getTemplate: (id: string) => TaskTemplate | undefined;
}
// === Helpers ===
const genId = () => `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
const ts = () => new Date().toISOString();
// === Store ===
export const useBrowserHandStore = create<BrowserHandState & BrowserHandActions>((set, get) => ({
// Initial State
execution: {
isRunning: false,
currentAction: null,
currentUrl: null,
lastScreenshot: null,
progress: 0,
startTime: null,
templateName: null,
},
logs: [],
templates: BUILTIN_TEMPLATES,
recentTasks: [],
isTemplateModalOpen: false,
error: null,
// Actions
executeTemplate: async (templateId, params) => {
const template = get().templates.find(t => t.id === templateId);
if (!template) throw new Error(`Template not found: ${templateId}`);
set({
execution: {
isRunning: true,
currentAction: '初始化',
currentUrl: null,
lastScreenshot: null,
progress: 0,
startTime: ts(),
templateName: template.name,
},
error: null,
});
get().addLog({ level: 'info', message: `开始执行: ${template.name}` });
const browser = new Browser();
const startTime = Date.now();
try {
await browser.start({ headless: true });
const context = {
browser,
onProgress: (action: string, progress: number) => {
get().updateExecutionState({ currentAction: action, progress });
get().addLog({ level: 'action', message: action });
},
onLog: (level, message, details) => {
get().addLog({ level, message, details });
},
};
const result = await template.execute(params, context);
try {
const screenshot = await browser.screenshot();
get().updateExecutionState({ lastScreenshot: screenshot.base64 });
} catch { /* ignore */ }
const duration = Date.now() - startTime;
get().addLog({ level: 'info', message: `执行完成,耗时 ${duration}ms` });
const task: RecentTask = {
id: genId(),
templateId,
templateName: template.name,
params,
status: 'success',
executedAt: ts(),
duration,
result,
};
set(state => ({
recentTasks: [task, ...state.recentTasks].slice(0, 20),
execution: { ...state.execution, isRunning: false, progress: 100, currentAction: '完成' },
}));
return result;
} catch (err) {
const errorMsg = err instanceof Error ? err.message : String(err);
const duration = Date.now() - startTime;
get().addLog({ level: 'error', message: `执行失败: ${errorMsg}` });
const task: RecentTask = {
id: genId(),
templateId,
templateName: template.name,
params,
status: 'failed',
executedAt: ts(),
duration,
error: errorMsg,
};
set(state => ({
recentTasks: [task, ...state.recentTasks].slice(0, 20),
execution: { ...state.execution, isRunning: false, currentAction: '失败' },
error: errorMsg,
}));
throw err;
} finally {
await browser.close();
}
},
stopExecution: () => {
set(state => ({
execution: { ...state.execution, isRunning: false, currentAction: '已停止' },
}));
get().addLog({ level: 'warn', message: '任务已停止' });
},
updateExecutionState: (state) => {
set(current => ({ execution: { ...current.execution, ...state } }));
},
addLog: (log) => {
set(state => ({
logs: [...state.logs.slice(-99), { ...log, id: genId(), timestamp: ts() }],
}));
},
clearLogs: () => set({ logs: [] }),
openTemplateModal: () => set({ isTemplateModalOpen: true }),
closeTemplateModal: () => set({ isTemplateModalOpen: false }),
clearError: () => set({ error: null }),
getTemplate: (id) => get().templates.find(t => t.id === id),
}));
export default useBrowserHandStore;
```
- [ ] **Step 2: Commit**
```bash
git add desktop/src/store/browserHandStore.ts
git commit -m "feat(browser-hand): add browserHandStore for state management"
```
---
## Chunk 2: UI 组件
### Task 2.1: 模块导出
**Files:**
- Create: `desktop/src/components/BrowserHand/index.ts`
- [ ] **Step 1: 创建模块导出**
```typescript
// desktop/src/components/BrowserHand/index.ts
export { BrowserHandCard } from './BrowserHandCard';
export { TaskTemplateModal } from './TaskTemplateModal';
export { ScreenshotPreview } from './ScreenshotPreview';
export * from './templates';
```
- [ ] **Step 2: Commit**
```bash
git add desktop/src/components/BrowserHand/index.ts
git commit -m "feat(browser-hand): add module exports"
```
---
### Task 2.2: 截图预览组件
**Files:**
- Create: `desktop/src/components/BrowserHand/ScreenshotPreview.tsx`
- [ ] **Step 1: 创建 ScreenshotPreview 组件**
```typescript
// desktop/src/components/BrowserHand/ScreenshotPreview.tsx
import { useState } from 'react';
import { ImageOff, Maximize2, RefreshCw, Loader2 } from 'lucide-react';
interface ScreenshotPreviewProps {
base64: string | null;
isLoading?: boolean;
onRefresh?: () => void;
}
export function ScreenshotPreview({ base64, isLoading, onRefresh }: ScreenshotPreviewProps) {
const [isFullscreen, setIsFullscreen] = useState(false);
if (isLoading) {
return (
<div className="w-full h-32 bg-gray-100 dark:bg-gray-800 rounded-lg flex items-center justify-center">
<Loader2 className="w-6 h-6 text-gray-400 animate-spin" />
</div>
);
}
if (!base64) {
return (
<div className="w-full h-32 bg-gray-100 dark:bg-gray-800 rounded-lg flex flex-col items-center justify-center text-gray-400">
<ImageOff className="w-8 h-8 mb-2" />
<span className="text-xs">暂无截图</span>
</div>
);
}
return (
<>
<div className="relative w-full h-32 bg-gray-100 dark:bg-gray-800 rounded-lg overflow-hidden group">
<img
src={`data:image/png;base64,${base64}`}
alt="Screenshot"
className="w-full h-full object-cover cursor-pointer"
onClick={() => setIsFullscreen(true)}
/>
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-2">
<button
onClick={() => setIsFullscreen(true)}
className="p-2 bg-white/20 rounded-lg hover:bg-white/30 text-white"
title="全屏查看"
>
<Maximize2 className="w-4 h-4" />
</button>
{onRefresh && (
<button
onClick={onRefresh}
className="p-2 bg-white/20 rounded-lg hover:bg-white/30 text-white"
title="刷新截图"
>
<RefreshCw className="w-4 h-4" />
</button>
)}
</div>
</div>
{isFullscreen && (
<div
className="fixed inset-0 z-50 bg-black/90 flex items-center justify-center p-4"
onClick={() => setIsFullscreen(false)}
>
<img
src={`data:image/png;base64,${base64}`}
alt="Screenshot Full"
className="max-w-full max-h-full object-contain"
/>
<button
className="absolute top-4 right-4 text-white text-2xl"
onClick={() => setIsFullscreen(false)}
>
</button>
</div>
)}
</>
);
}
```
- [ ] **Step 2: Commit**
```bash
git add desktop/src/components/BrowserHand/ScreenshotPreview.tsx
git commit -m "feat(browser-hand): add ScreenshotPreview component"
```
---
### Task 2.3: 任务模板选择模态框
**Files:**
- Create: `desktop/src/components/BrowserHand/TaskTemplateModal.tsx`
- [ ] **Step 1: 创建 TaskTemplateModal 组件**
```typescript
// desktop/src/components/BrowserHand/TaskTemplateModal.tsx
import { useState, useMemo } from 'react';
import { X, Play, AlertCircle } from 'lucide-react';
import { useBrowserHandStore } from '../../store/browserHandStore';
import { TEMPLATE_CATEGORIES, type TaskTemplate, type TaskTemplateParam } from './templates';
interface TaskTemplateModalProps {
isOpen: boolean;
onClose: () => void;
}
export function TaskTemplateModal({ isOpen, onClose }: TaskTemplateModalProps) {
const { templates, executeTemplate, isTemplateModalOpen } = useBrowserHandStore();
const [selectedCategory, setSelectedCategory] = useState<'basic' | 'scraping' | 'automation'>('basic');
const [selectedTemplate, setSelectedTemplate] = useState<TaskTemplate | null>(null);
const [params, setParams] = useState<Record<string, unknown>>({});
const [isExecuting, setIsExecuting] = useState(false);
const [errors, setErrors] = useState<Record<string, string>>({});
const filteredTemplates = useMemo(
() => templates.filter(t => t.category === selectedCategory),
[templates, selectedCategory]
);
const handleSelectTemplate = (template: TaskTemplate) => {
setSelectedTemplate(template);
// Initialize params with defaults
const defaultParams: Record<string, unknown> = {};
template.params.forEach(p => {
if (p.default !== undefined) defaultParams[p.key] = p.default;
});
setParams(defaultParams);
setErrors({});
};
const handleParamChange = (key: string, value: unknown) => {
setParams(prev => ({ ...prev, [key]: value }));
setErrors(prev => ({ ...prev, [key]: '' }));
};
const validateParams = (): boolean => {
if (!selectedTemplate) return false;
const newErrors: Record<string, string> = {};
selectedTemplate.params.forEach(param => {
if (param.required) {
const value = params[param.key];
if (value === undefined || value === '' || value === null) {
newErrors[param.key] = `${param.label}为必填项`;
}
}
// Type-specific validation
if (param.type === 'url' && params[param.key]) {
try {
new URL(params[param.key] as string);
} catch {
newErrors[param.key] = '请输入有效的 URL';
}
}
if (param.type === 'json' && params[param.key]) {
try {
JSON.parse(params[param.key] as string);
} catch {
newErrors[param.key] = '请输入有效的 JSON';
}
}
});
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleExecute = async () => {
if (!selectedTemplate || !validateParams()) return;
// Parse JSON params
const parsedParams: Record<string, unknown> = {};
selectedTemplate.params.forEach(p => {
if (p.type === 'json' && typeof params[p.key] === 'string') {
try {
parsedParams[p.key] = JSON.parse(params[p.key] as string);
} catch {
parsedParams[p.key] = params[p.key];
}
} else if (p.type === 'textarea' && typeof params[p.key] === 'string') {
// Handle multiline text (split by newline for certain fields)
if (p.key === 'selectors' || p.key === 'urls') {
parsedParams[p.key] = params[p.key];
} else {
parsedParams[p.key] = params[p.key];
}
} else {
parsedParams[p.key] = params[p.key];
}
});
setIsExecuting(true);
try {
await executeTemplate(selectedTemplate.id, parsedParams);
onClose();
} catch {
// Error is handled in store
} finally {
setIsExecuting(false);
}
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" onClick={onClose} />
<div className="relative bg-white dark:bg-gray-800 rounded-xl shadow-2xl w-full max-w-2xl mx-4 max-h-[90vh] overflow-hidden flex flex-col">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">选择任务模板</h2>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 p-1">
<X className="w-5 h-5" />
</button>
</div>
{/* Category Tabs */}
<div className="flex gap-2 p-4 border-b border-gray-200 dark:border-gray-700">
{TEMPLATE_CATEGORIES.map(cat => (
<button
key={cat.id}
onClick={() => setSelectedCategory(cat.id)}
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
selectedCategory === cat.id
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-400'
}`}
>
{cat.name}
</button>
))}
</div>
{/* Template Grid */}
<div className="flex-1 overflow-y-auto p-4">
<div className="grid grid-cols-3 gap-3 mb-4">
{filteredTemplates.map(template => (
<button
key={template.id}
onClick={() => handleSelectTemplate(template)}
className={`p-3 rounded-lg border text-left transition-all ${
selectedTemplate?.id === template.id
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
}`}
>
<span className="text-2xl mb-1 block">{template.icon}</span>
<span className="text-sm font-medium text-gray-900 dark:text-white block">{template.name}</span>
<span className="text-xs text-gray-500 dark:text-gray-400 line-clamp-2">{template.description}</span>
</button>
))}
</div>
{/* Selected Template Params */}
{selectedTemplate && (
<div className="border-t border-gray-200 dark:border-gray-700 pt-4">
<h3 className="text-sm font-medium text-gray-900 dark:text-white mb-3">
{selectedTemplate.icon} {selectedTemplate.name}
</h3>
<p className="text-xs text-gray-500 dark:text-gray-400 mb-4">{selectedTemplate.description}</p>
<div className="space-y-3">
{selectedTemplate.params.map(param => (
<ParamInput
key={param.key}
param={param}
value={params[param.key]}
error={errors[param.key]}
onChange={(value) => handleParamChange(param.key, value)}
/>
))}
</div>
</div>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-2 p-4 border-t border-gray-200 dark:border-gray-700">
<button
onClick={onClose}
className="px-4 py-2 text-sm border border-gray-200 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300"
>
取消
</button>
<button
onClick={handleExecute}
disabled={!selectedTemplate || isExecuting}
className="px-4 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
{isExecuting ? (
<>
<span className="animate-spin"></span>
执行中...
</>
) : (
<>
<Play className="w-4 h-4" />
执行任务
</>
)}
</button>
</div>
</div>
</div>
);
}
// Param Input Component
function ParamInput({
param,
value,
error,
onChange,
}: {
param: TaskTemplateParam;
value: unknown;
error?: string;
onChange: (value: unknown) => void;
}) {
const inputClass = `w-full px-3 py-2 border rounded-lg text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-white ${
error ? 'border-red-500' : 'border-gray-200 dark:border-gray-600'
}`;
return (
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{param.label}
{param.required && <span className="text-red-500 ml-1">*</span>}
</label>
{param.type === 'select' ? (
<select
value={(value as string) || ''}
onChange={(e) => onChange(e.target.value)}
className={inputClass}
>
{param.options?.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
) : param.type === 'textarea' || param.type === 'json' ? (
<textarea
value={(value as string) || ''}
onChange={(e) => onChange(e.target.value)}
placeholder={param.placeholder}
rows={3}
className={inputClass}
/>
) : (
<input
type={param.type === 'url' ? 'url' : param.type === 'number' ? 'number' : 'text'}
value={(value as string) ?? ''}
onChange={(e) => onChange(e.target.value)}
placeholder={param.placeholder}
className={inputClass}
/>
)}
{param.description && !error && (
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">{param.description}</p>
)}
{error && (
<p className="text-xs text-red-500 mt-1 flex items-center gap-1">
<AlertCircle className="w-3 h-3" />
{error}
</p>
)}
</div>
);
}
```
- [ ] **Step 2: Commit**
```bash
git add desktop/src/components/BrowserHand/TaskTemplateModal.tsx
git commit -m "feat(browser-hand): add TaskTemplateModal component"
```
---
### Task 2.4: BrowserHandCard 主组件
**Files:**
- Create: `desktop/src/components/BrowserHand/BrowserHandCard.tsx`
- [ ] **Step 1: 创建 BrowserHandCard 组件**
```typescript
// desktop/src/components/BrowserHand/BrowserHandCard.tsx
import { useState, useEffect } from 'react';
import { Globe, Play, Camera, RefreshCw, Settings, Loader2, AlertTriangle, X } from 'lucide-react';
import { useBrowserHandStore } from '../../store/browserHandStore';
import { ScreenshotPreview } from './ScreenshotPreview';
import type { Hand } from '../../store/handStore';
interface BrowserHandCardProps {
hand: Hand;
onOpenSettings?: () => void;
}
export function BrowserHandCard({ hand, onOpenSettings }: BrowserHandCardProps) {
const {
execution,
logs,
error,
openTemplateModal,
clearError,
stopExecution,
} = useBrowserHandStore();
const [showLogs, setShowLogs] = useState(false);
const isRunning = execution.isRunning;
const hasError = !!error;
return (
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 shadow-sm overflow-hidden">
{/* Header */}
<div className="flex items-start justify-between gap-3 p-4 border-b border-gray-100 dark:border-gray-700">
<div className="flex items-center gap-2 min-w-0">
<span className="text-xl flex-shrink-0">🌐</span>
<div className="min-w-0">
<h3 className="font-medium text-gray-900 dark:text-white truncate">Browser Hand</h3>
<p className="text-xs text-gray-500 dark:text-gray-400">浏览器自动化能力</p>
</div>
</div>
<StatusBadge isRunning={isRunning} hasError={hasError} />
</div>
{/* Screenshot Preview */}
<div className="p-4 border-b border-gray-100 dark:border-gray-700">
<ScreenshotPreview
base64={execution.lastScreenshot}
isLoading={isRunning && !execution.lastScreenshot}
/>
</div>
{/* Status Info */}
{(isRunning || execution.currentUrl) && (
<div className="px-4 py-2 bg-gray-50 dark:bg-gray-900 text-xs space-y-1">
{execution.currentUrl && (
<div className="flex items-center gap-2 text-gray-600 dark:text-gray-400">
<Globe className="w-3 h-3 flex-shrink-0" />
<span className="truncate">{execution.currentUrl}</span>
</div>
)}
{isRunning && (
<div className="flex items-center gap-2">
<span className="text-gray-600 dark:text-gray-400">{execution.currentAction}</span>
<div className="flex-1 h-1.5 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
<div
className="h-full bg-blue-500 transition-all duration-300"
style={{ width: `${execution.progress}%` }}
/>
</div>
<span className="text-gray-500 dark:text-gray-500 w-8 text-right">{execution.progress}%</span>
</div>
)}
</div>
)}
{/* Error Display */}
{hasError && (
<div className="px-4 py-2 bg-red-50 dark:bg-red-900/20 border-b border-red-100 dark:border-red-900/30">
<div className="flex items-start gap-2">
<AlertTriangle className="w-4 h-4 text-red-500 flex-shrink-0 mt-0.5" />
<p className="text-xs text-red-600 dark:text-red-400 flex-1">{error}</p>
<button onClick={clearError} className="text-red-400 hover:text-red-600">
<X className="w-3 h-3" />
</button>
</div>
</div>
)}
{/* Actions */}
<div className="flex items-center gap-2 p-4">
{isRunning ? (
<button
onClick={stopExecution}
className="flex-1 px-3 py-2 text-sm bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400 rounded-lg hover:bg-red-200 dark:hover:bg-red-900/50 flex items-center justify-center gap-2"
>
<X className="w-4 h-4" />
停止任务
</button>
) : (
<>
<button
onClick={openTemplateModal}
className="flex-1 px-3 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 flex items-center justify-center gap-2"
>
<Play className="w-4 h-4" />
执行任务
</button>
<button
onClick={() => setShowLogs(!showLogs)}
className="px-3 py-2 text-sm border border-gray-200 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300"
title="查看日志"
>
📋
</button>
{onOpenSettings && (
<button
onClick={onOpenSettings}
className="px-3 py-2 text-sm border border-gray-200 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300"
title="设置"
>
<Settings className="w-4 h-4" />
</button>
)}
</>
)}
</div>
{/* Logs Panel */}
{showLogs && (
<div className="border-t border-gray-200 dark:border-gray-700 max-h-48 overflow-y-auto">
<div className="p-2 text-xs font-medium text-gray-500 dark:text-gray-400 bg-gray-50 dark:bg-gray-900">
操作日志 ({logs.length})
</div>
<div className="p-2 space-y-1 text-xs font-mono">
{logs.length === 0 ? (
<p className="text-gray-400 dark:text-gray-500">暂无日志</p>
) : (
logs.map(log => (
<LogEntry key={log.id} log={log} />
))
)}
</div>
</div>
)}
</div>
);
}
// Status Badge
function StatusBadge({ isRunning, hasError }: { isRunning: boolean; hasError: boolean }) {
if (hasError) {
return (
<span className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400">
<span className="w-1.5 h-1.5 rounded-full bg-red-500" />
错误
</span>
);
}
if (isRunning) {
return (
<span className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400">
<Loader2 className="w-3 h-3 animate-spin" />
运行中
</span>
);
}
return (
<span className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400">
<span className="w-1.5 h-1.5 rounded-full bg-green-500" />
就绪
</span>
);
}
// Log Entry
function LogEntry({ log }: { log: { level: string; timestamp: string; message: string } }) {
const levelColors: Record<string, string> = {
info: 'text-blue-600 dark:text-blue-400',
warn: 'text-yellow-600 dark:text-yellow-400',
error: 'text-red-600 dark:text-red-400',
action: 'text-green-600 dark:text-green-400',
};
const time = new Date(log.timestamp).toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
return (
<div className="flex items-start gap-2">
<span className="text-gray-400 dark:text-gray-500 flex-shrink-0">{time}</span>
<span className={levelColors[log.level] || 'text-gray-600 dark:text-gray-400'}>[{log.level}]</span>
<span className="text-gray-700 dark:text-gray-300">{log.message}</span>
</div>
);
}
```
- [ ] **Step 2: Commit**
```bash
git add desktop/src/components/BrowserHand/BrowserHandCard.tsx
git commit -m "feat(browser-hand): add BrowserHandCard main component"
```
---
## Chunk 3: 集成
### Task 3.1: 集成到 HandsPanel
**Files:**
- Modify: `desktop/src/components/HandsPanel.tsx`
- [ ] **Step 1: 修改 HandsPanel 导入 BrowserHandCard**
在文件顶部添加导入:
```typescript
import { BrowserHandCard, TaskTemplateModal } from './BrowserHand';
```
- [ ] **Step 2: 修改 HandsPanel 检测 Browser Hand**
`HandsPanel` 组件内添加检测函数:
```typescript
// 在 HandsPanel 组件内
const isBrowserHand = (hand: Hand) =>
hand.id === 'browser' ||
hand.name?.toLowerCase().includes('browser') ||
hand.name === 'Browser Hand';
```
- [ ] **Step 3: 修改渲染逻辑**
找到渲染 HandCard 的位置,修改为条件渲染:
```typescript
// 找到 <div className="grid gap-3"> 部分
<div className="grid gap-3">
{hands.map((hand) =>
isBrowserHand(hand) ? (
<BrowserHandCard key={hand.id} hand={hand} />
) : (
<HandCard
key={hand.id}
hand={hand}
onDetails={handleDetails}
onActivate={handleActivate}
isActivating={activatingHandId === hand.id}
/>
)
)}
</div>
```
- [ ] **Step 4: 添加 TaskTemplateModal**
在组件末尾HandDetailsModal 之后)添加:
```typescript
// 在 return 的最后
<TaskTemplateModal
isOpen={useBrowserHandStore.getState().isTemplateModalOpen}
onClose={() => useBrowserHandStore.getState().closeTemplateModal()}
/>
```
- [ ] **Step 5: 添加 store 导入**
```typescript
import { useBrowserHandStore } from '../store/browserHandStore';
```
- [ ] **Step 6: Commit**
```bash
git add desktop/src/components/HandsPanel.tsx
git commit -m "feat(browser-hand): integrate BrowserHandCard into HandsPanel"
```
---
### Task 3.2: Store 注册
**Files:**
- Modify: `desktop/src/store/index.ts`
- [ ] **Step 1: 导出 browserHandStore**
```typescript
export { useBrowserHandStore } from './browserHandStore';
```
- [ ] **Step 2: Commit**
```bash
git add desktop/src/store/index.ts
git commit -m "feat(browser-hand): export browserHandStore from index"
```
---
## Chunk 4: 测试
### Task 4.1: Store 单元测试
**Files:**
- Create: `tests/desktop/browserHandStore.test.ts`
- [ ] **Step 1: 创建测试文件**
```typescript
// tests/desktop/browserHandStore.test.ts
import { describe, it, expect, beforeEach, vi } from 'vitest';
// Mock browser-client
vi.mock('../../desktop/src/lib/browser-client', () => ({
default: class MockBrowser {
start = vi.fn().mockResolvedValue(undefined);
close = vi.fn().mockResolvedValue(undefined);
goto = vi.fn().mockResolvedValue(undefined);
screenshot = vi.fn().mockResolvedValue({ base64: 'mock-base64' });
url = vi.fn().mockResolvedValue('https://example.com');
type = vi.fn().mockResolvedValue(undefined);
click = vi.fn().mockResolvedValue(undefined);
wait = vi.fn().mockResolvedValue(undefined);
$$ = vi.fn().mockResolvedValue([]);
source = vi.fn().mockResolvedValue('<html></html>');
},
createSession: vi.fn().mockResolvedValue({ session_id: 'test-session' }),
closeSession: vi.fn().mockResolvedValue(undefined),
listSessions: vi.fn().mockResolvedValue([]),
navigate: vi.fn().mockResolvedValue({ url: null, title: null }),
screenshot: vi.fn().mockResolvedValue({ base64: 'test', format: 'png' }),
}));
describe('browserHandStore', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should have initial state', async () => {
const { useBrowserHandStore } = await import('../../desktop/src/store/browserHandStore');
const state = useBrowserHandStore.getState();
expect(state.execution.isRunning).toBe(false);
expect(state.logs).toEqual([]);
expect(state.templates.length).toBeGreaterThan(0);
expect(state.isTemplateModalOpen).toBe(false);
expect(state.error).toBeNull();
});
it('should open and close template modal', async () => {
const { useBrowserHandStore } = await import('../../desktop/src/store/browserHandStore');
const store = useBrowserHandStore.getState();
store.openTemplateModal();
expect(useBrowserHandStore.getState().isTemplateModalOpen).toBe(true);
store.closeTemplateModal();
expect(useBrowserHandStore.getState().isTemplateModalOpen).toBe(false);
});
it('should add logs', async () => {
const { useBrowserHandStore } = await import('../../desktop/src/store/browserHandStore');
const store = useBrowserHandStore.getState();
store.addLog({ level: 'info', message: 'Test log' });
const logs = useBrowserHandStore.getState().logs;
expect(logs.length).toBe(1);
expect(logs[0].message).toBe('Test log');
expect(logs[0].level).toBe('info');
});
it('should clear logs', async () => {
const { useBrowserHandStore } = await import('../../desktop/src/store/browserHandStore');
const store = useBrowserHandStore.getState();
store.addLog({ level: 'info', message: 'Test' });
store.clearLogs();
expect(useBrowserHandStore.getState().logs).toEqual([]);
});
it('should update execution state', async () => {
const { useBrowserHandStore } = await import('../../desktop/src/store/browserHandStore');
const store = useBrowserHandStore.getState();
store.updateExecutionState({ currentAction: 'Testing', progress: 50 });
const execution = useBrowserHandStore.getState().execution;
expect(execution.currentAction).toBe('Testing');
expect(execution.progress).toBe(50);
});
it('should get template by id', async () => {
const { useBrowserHandStore } = await import('../../desktop/src/store/browserHandStore');
const store = useBrowserHandStore.getState();
const template = store.getTemplate('basic_navigate_screenshot');
expect(template).toBeDefined();
expect(template?.name).toBe('打开网页并截图');
});
it('should return undefined for unknown template', async () => {
const { useBrowserHandStore } = await import('../../desktop/src/store/browserHandStore');
const store = useBrowserHandStore.getState();
const template = store.getTemplate('unknown_template');
expect(template).toBeUndefined();
});
});
```
- [ ] **Step 2: Run tests**
```bash
cd desktop && pnpm vitest run tests/desktop/browserHandStore.test.ts
```
Expected: All tests pass
- [ ] **Step 3: Commit**
```bash
git add tests/desktop/browserHandStore.test.ts
git commit -m "test(browser-hand): add browserHandStore unit tests"
```
---
### Task 4.2: 最终验证
- [ ] **Step 1: TypeScript 检查**
```bash
cd desktop && pnpm tsc --noEmit
```
- [ ] **Step 2: 运行所有测试**
```bash
cd desktop && pnpm vitest run
```
- [ ] **Step 3: 构建检查**
```bash
cd desktop/src-tauri && cargo check
```
- [ ] **Step 4: Final commit**
```bash
git add -A
git commit -m "feat(browser-hand): complete Browser Hand UI integration
- Add browserHandStore for state management
- Add 9 task templates (basic, scraping, automation)
- Add BrowserHandCard with screenshot preview
- Add TaskTemplateModal for task selection
- Integrate into HandsPanel
- Add unit tests
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>"
```
---
## 完成检查清单
- [ ] 所有文件已创建
- [ ] TypeScript 编译通过
- [ ] 测试通过
- [ ] 集成到 HandsPanel
- [ ] 可以打开模板选择器
- [ ] 可以执行基础任务模板