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

53 KiB
Raw Blame History

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


文件结构

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: 创建模板类型文件

// 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
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: 实现基础操作模板

// 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
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: 实现数据采集模板

// 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
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: 实现自动化流程模板

// 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
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: 创建模板注册文件

// 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
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

// 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
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: 创建模块导出

// desktop/src/components/BrowserHand/index.ts

export { BrowserHandCard } from './BrowserHandCard';
export { TaskTemplateModal } from './TaskTemplateModal';
export { ScreenshotPreview } from './ScreenshotPreview';
export * from './templates';
  • Step 2: Commit
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 组件

// 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
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 组件

// 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
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 组件

// 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
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

在文件顶部添加导入:

import { BrowserHandCard, TaskTemplateModal } from './BrowserHand';
  • Step 2: 修改 HandsPanel 检测 Browser Hand

HandsPanel 组件内添加检测函数:

// 在 HandsPanel 组件内
const isBrowserHand = (hand: Hand) =>
  hand.id === 'browser' ||
  hand.name?.toLowerCase().includes('browser') ||
  hand.name === 'Browser Hand';
  • Step 3: 修改渲染逻辑

找到渲染 HandCard 的位置,修改为条件渲染:

// 找到 <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 之后)添加:

// 在 return 的最后
<TaskTemplateModal
  isOpen={useBrowserHandStore.getState().isTemplateModalOpen}
  onClose={() => useBrowserHandStore.getState().closeTemplateModal()}
/>
  • Step 5: 添加 store 导入
import { useBrowserHandStore } from '../store/browserHandStore';
  • Step 6: Commit
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

export { useBrowserHandStore } from './browserHandStore';
  • Step 2: Commit
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: 创建测试文件

// 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
cd desktop && pnpm vitest run tests/desktop/browserHandStore.test.ts

Expected: All tests pass

  • Step 3: Commit
git add tests/desktop/browserHandStore.test.ts
git commit -m "test(browser-hand): add browserHandStore unit tests"

Task 4.2: 最终验证

  • Step 1: TypeScript 检查
cd desktop && pnpm tsc --noEmit
  • Step 2: 运行所有测试
cd desktop && pnpm vitest run
  • Step 3: 构建检查
cd desktop/src-tauri && cargo check
  • Step 4: Final commit
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
  • 可以打开模板选择器
  • 可以执行基础任务模板