- 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
1840 lines
53 KiB
Markdown
1840 lines
53 KiB
Markdown
# 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
|
||
- [ ] 可以打开模板选择器
|
||
- [ ] 可以执行基础任务模板
|