فهرست منبع

新增滚动和步骤提示

yichael 5 ماه پیش
والد
کامیت
c64666245c

+ 129 - 0
main-js/adb/scroll.js

@@ -0,0 +1,129 @@
+import { ipcMain } from 'electron';
+import { exec } from 'child_process';
+import { promisify } from 'util';
+import { getCachedAdbPath } from '../config.js';
+
+const execAsync = promisify(exec);
+
+// 默认每次滚动距离(像素)
+const DEFAULT_SCROLL_DISTANCE = 100;
+// 默认滚动持续时间(毫秒),使用较长的时间模拟真实手指滑动
+const DEFAULT_SCROLL_DURATION = 500;
+
+/**
+ * 计算滚动操作的坐标(小幅度滚动)
+ * @param {string} direction - 滚动方向: up-down, down-up, left-right, right-left
+ * @param {number} width - 设备宽度
+ * @param {number} height - 设备高度
+ * @param {number} scrollDistance - 每次滚动距离(像素),默认10px
+ * @returns {Object} 包含起始和结束坐标的对象 {x1, y1, x2, y2}
+ */
+function calculateScrollCoordinates(direction, width, height, scrollDistance = DEFAULT_SCROLL_DISTANCE) {
+  // 滚动从屏幕中心开始
+  const centerX = Math.round(width / 2);
+  const centerY = Math.round(height / 2);
+
+  let x1, y1, x2, y2;
+
+  switch (direction) {
+    case 'up-down':
+      // 从上往下滚动(向下滚动)
+      x1 = x2 = centerX;
+      y1 = centerY;
+      y2 = centerY + scrollDistance;
+      break;
+    
+    case 'down-up':
+      // 从下往上滚动(向上滚动)
+      x1 = x2 = centerX;
+      y1 = centerY;
+      y2 = centerY - scrollDistance;
+      break;
+    
+    case 'left-right':
+      // 从左往右滚动(向右滚动)
+      y1 = y2 = centerY;
+      x1 = centerX;
+      x2 = centerX + scrollDistance;
+      break;
+    
+    case 'right-left':
+      // 从右往左滚动(向左滚动)
+      y1 = y2 = centerY;
+      x1 = centerX;
+      x2 = centerX - scrollDistance;
+      break;
+    
+    default:
+      throw new Error(`未知的滚动方向: ${direction}`);
+  }
+
+  return { x1, y1, x2, y2 };
+}
+
+/**
+ * 发送滚动操作到设备
+ * 使用触摸屏事件模拟真实的手指滑动,避免触发 tap 事件
+ * @param {string} ipPort - 设备 ID
+ * @param {string} direction - 滚动方向: up-down, down-up, left-right, right-left
+ * @param {number} width - 设备宽度
+ * @param {number} height - 设备高度
+ * @param {number} scrollDistance - 每次滚动距离(像素),默认10px
+ * @param {number} duration - 滚动持续时间(毫秒),默认500ms
+ * @returns {Promise<Object>} 执行结果 {success, error?}
+ */
+export async function sendScroll(ipPort, direction, width, height, scrollDistance = DEFAULT_SCROLL_DISTANCE, duration = DEFAULT_SCROLL_DURATION) {
+  if (!ipPort) {
+    return { success: false, error: '缺少设备 ID' };
+  }
+  
+  if (!direction) {
+    return { success: false, error: '缺少滚动方向' };
+  }
+  
+  const validDirections = ['up-down', 'down-up', 'left-right', 'right-left'];
+  if (!validDirections.includes(direction)) {
+    return { success: false, error: `无效的滚动方向: ${direction},应为: ${validDirections.join(', ')}` };
+  }
+  
+  if (typeof width !== 'number' || typeof height !== 'number') {
+    return { success: false, error: '设备宽度和高度必须是数字' };
+  }
+  
+  if (width <= 0 || height <= 0) {
+    return { success: false, error: '设备宽度和高度必须大于0' };
+  }
+  
+  try {
+    const adbPath = getCachedAdbPath();
+    
+    // 计算滚动坐标
+    const { x1, y1, x2, y2 } = calculateScrollCoordinates(direction, width, height, scrollDistance);
+    
+    // 使用 input touchscreen swipe 命令来模拟真实的触摸滑动
+    // 这个命令会模拟手指在屏幕上慢慢滑动,不会触发 tap 事件
+    // 使用较长的持续时间(至少 300ms)确保被识别为滑动而不是点击
+    const actualDuration = Math.max(duration, 300); // 确保至少 300ms
+    
+    const command = `${adbPath} -s ${ipPort} shell input touchscreen swipe ${x1} ${y1} ${x2} ${y2} ${actualDuration}`;
+    
+    await execAsync(command, {
+      timeout: 10000,
+      maxBuffer: 1024 * 1024
+    });
+    
+    console.log(`成功滚动: ${direction} (${x1},${y1}) -> (${x2},${y2}), 持续时间: ${actualDuration}ms`);
+    return { success: true };
+  } catch (error) {
+    console.error('滚动失败:', error.message);
+    return { success: false, error: error.message };
+  }
+}
+
+// 注册 IPC 处理器
+export function registerIpcHandlers() {
+  // IPC 处理程序:发送滚动操作到设备
+  ipcMain.handle('send-scroll', async (event, ipPort, direction, width, height, scrollDistance, duration) => {
+    return await sendScroll(ipPort, direction, width, height, scrollDistance, duration);
+  });
+}

+ 2 - 0
main.js

@@ -8,6 +8,7 @@ import { registerIpcHandlers as registerDeviceInfoHandlers } from './main-js/adb
 import { registerIpcHandlers as registerScreenshotHandlers } from './main-js/adb/screenshot.js';
 import { registerIpcHandlers as registerTouchEventHandlers } from './main-js/adb/touch-event.js';
 import { registerIpcHandlers as registerInputHandlers } from './main-js/adb/input.js';
+import { registerIpcHandlers as registerScrollHandlers } from './main-js/adb/scroll.js';
 import { registerIpcHandlers as registerHistoryHandlers } from './main-js/history.js';
 
 const __filename = fileURLToPath(import.meta.url);
@@ -27,6 +28,7 @@ app.whenReady().then(() => {
   registerScreenshotHandlers();
   registerTouchEventHandlers();
   registerInputHandlers();
+  registerScrollHandlers();
   registerHistoryHandlers();
 
   app.on('activate', () => {

+ 1 - 0
preload.cjs

@@ -10,6 +10,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
   sendTap: (ipPort, x, y) => ipcRenderer.invoke('send-tap', ipPort, x, y),
   sendSwipe: (ipPort, x1, y1, x2, y2, duration) => ipcRenderer.invoke('send-swipe', ipPort, x1, y1, x2, y2, duration),
   sendText: (ipPort, text) => ipcRenderer.invoke('send-text', ipPort, text),
+  sendScroll: (ipPort, direction, width, height, scrollDistance, duration) => ipcRenderer.invoke('send-scroll', ipPort, direction, width, height, scrollDistance, duration),
   sendKeyEvent: (ipPort, keyCode) => ipcRenderer.invoke('send-key-event', ipPort, keyCode),
   // 监听设备发现事件
   onDeviceFound: (callback) => {

BIN
py/__pycache__/test-to-img.cpython-312.pyc


+ 124 - 0
py/test-to-img.py

@@ -0,0 +1,124 @@
+"""
+文字转图片模块
+功能:将文字转换为图片,用于图像匹配
+"""
+
+import sys
+import os
+from PIL import Image, ImageDraw, ImageFont
+from pathlib import Path
+import tempfile
+
+
+def text_to_image(
+    text: str,
+    output_path: str = None,
+    font_size: int = 50,
+    font_path: str = None,
+    text_color: tuple = (0, 0, 0),  # 黑色
+    bg_color: tuple = (255, 255, 255),  # 白色背景
+    padding: int = 10
+) -> str:
+    """
+    将文字转换为图片
+    
+    Args:
+        text: 要转换的文字
+        output_path: 输出图片路径,如果为 None 则使用临时文件
+        font_size: 字体大小(像素)
+        font_path: 字体文件路径,如果为 None 则使用系统默认字体
+        text_color: 文字颜色 RGB 元组,默认黑色
+        bg_color: 背景颜色 RGB 元组,默认白色
+        padding: 图片内边距(像素)
+    
+    Returns:
+        生成的图片文件路径
+    """
+    if not text or not text.strip():
+        raise ValueError("文字内容不能为空")
+    
+    # 尝试加载字体
+    try:
+        if font_path and os.path.exists(font_path):
+            font = ImageFont.truetype(font_path, font_size)
+        else:
+            # Windows 系统默认字体
+            if sys.platform == 'win32':
+                # 尝试使用微软雅黑
+                font_paths = [
+                    'C:/Windows/Fonts/msyh.ttc',  # 微软雅黑
+                    'C:/Windows/Fonts/simhei.ttf',  # 黑体
+                    'C:/Windows/Fonts/simsun.ttc',  # 宋体
+                ]
+                font = None
+                for fp in font_paths:
+                    if os.path.exists(fp):
+                        try:
+                            font = ImageFont.truetype(fp, font_size)
+                            break
+                        except:
+                            continue
+                if font is None:
+                    font = ImageFont.load_default()
+            else:
+                font = ImageFont.load_default()
+    except Exception as e:
+        print(f"警告: 无法加载指定字体,使用默认字体: {e}")
+        font = ImageFont.load_default()
+    
+    # 创建临时画布来测量文字尺寸
+    temp_img = Image.new('RGB', (1, 1))
+    temp_draw = ImageDraw.Draw(temp_img)
+    
+    # 获取文字边界框
+    bbox = temp_draw.textbbox((0, 0), text, font=font)
+    text_width = bbox[2] - bbox[0]
+    text_height = bbox[3] - bbox[1]
+    
+    # 计算图片尺寸(添加 padding)
+    img_width = text_width + padding * 2
+    img_height = text_height + padding * 2
+    
+    # 创建图片
+    img = Image.new('RGB', (img_width, img_height), bg_color)
+    draw = ImageDraw.Draw(img)
+    
+    # 绘制文字(居中)
+    x = padding
+    y = padding
+    draw.text((x, y), text, font=font, fill=text_color)
+    
+    # 保存图片
+    if output_path is None:
+        # 使用临时文件
+        temp_dir = tempfile.gettempdir()
+        output_path = os.path.join(temp_dir, f'temp_text_{os.getpid()}_{hash(text)}.png')
+    
+    # 确保输出目录存在
+    output_dir = os.path.dirname(output_path)
+    if output_dir and not os.path.exists(output_dir):
+        os.makedirs(output_dir, exist_ok=True)
+    
+    img.save(output_path, 'PNG')
+    return output_path
+
+
+if __name__ == "__main__":
+    # 测试示例
+    if len(sys.argv) < 2:
+        print("用法: python test-to-img.py <文字内容> [输出路径] [字体大小] [字体路径]")
+        print("示例: python test-to-img.py \"测试文字\" output.png 50")
+        sys.exit(1)
+    
+    text = sys.argv[1]
+    output_path = sys.argv[2] if len(sys.argv) > 2 else None
+    font_size = int(sys.argv[3]) if len(sys.argv) > 3 else 50
+    font_path = sys.argv[4] if len(sys.argv) > 4 else None
+    
+    try:
+        result_path = text_to_image(text, output_path, font_size, font_path)
+        print(f"成功生成图片: {result_path}")
+        print(f"JSON格式: {{\"path\": \"{result_path}\"}}")
+    except Exception as e:
+        print(f"错误: {e}")
+        sys.exit(1)

+ 296 - 10
src/pages/Chat/History/ActionParser.js

@@ -1,6 +1,125 @@
 // 动作解析和执行器
 // 配置参数
 const DEFAULT_STEP_INTERVAL = 1000; // 默认步骤间隔1秒
+const DEFAULT_SCROLL_DISTANCE = 100; // 默认每次滚动距离(像素)
+
+/**
+ * 解析时间字符串(格式:2026/1/13 02:09)
+ * @param {string} timeStr - 时间字符串
+ * @returns {Date|null} 解析后的日期对象,失败返回null
+ */
+function parseTimeString(timeStr) {
+  if (!timeStr || timeStr.trim() === '') {
+    return null;
+  }
+  
+  try {
+    // 支持格式:2026/1/13 02:09 或 2026/01/13 02:09
+    const parts = timeStr.trim().split(' ');
+    if (parts.length !== 2) {
+      return null;
+    }
+    
+    const datePart = parts[0].split('/');
+    const timePart = parts[1].split(':');
+    
+    if (datePart.length !== 3 || timePart.length !== 2) {
+      return null;
+    }
+    
+    const year = parseInt(datePart[0], 10);
+    const month = parseInt(datePart[1], 10) - 1; // 月份从0开始
+    const day = parseInt(datePart[2], 10);
+    const hour = parseInt(timePart[0], 10);
+    const minute = parseInt(timePart[1], 10);
+    
+    const date = new Date(year, month, day, hour, minute, 0, 0);
+    
+    // 验证日期是否有效
+    if (isNaN(date.getTime())) {
+      return null;
+    }
+    
+    return date;
+  } catch (error) {
+    console.error('解析时间字符串失败:', error);
+    return null;
+  }
+}
+
+/**
+ * 解析延迟字符串(格式:10s, 5m, 2h)
+ * @param {string} delayStr - 延迟字符串
+ * @returns {number|null} 延迟的毫秒数,失败返回null
+ */
+function parseDelayString(delayStr) {
+  if (!delayStr || delayStr.trim() === '') {
+    return 0; // 空字符串表示不延迟
+  }
+  
+  try {
+    const trimmed = delayStr.trim();
+    const unit = trimmed.slice(-1).toLowerCase();
+    const value = parseInt(trimmed.slice(0, -1), 10);
+    
+    if (isNaN(value) || value < 0) {
+      return null;
+    }
+    
+    switch (unit) {
+      case 's':
+        return value * 1000; // 秒转毫秒
+      case 'm':
+        return value * 60 * 1000; // 分钟转毫秒
+      case 'h':
+        return value * 60 * 60 * 1000; // 小时转毫秒
+      default:
+        console.warn(`未知的延迟单位: ${unit},应为 s/m/h`);
+        return null;
+    }
+  } catch (error) {
+    console.error('解析延迟字符串失败:', error);
+    return null;
+  }
+}
+
+/**
+ * 计算需要等待的时间(毫秒)
+ * @param {string} data - 执行时间字符串(格式:2026/1/13 02:09)
+ * @param {string} delay - 延迟字符串(格式:10s, 5m, 2h)
+ * @returns {number} 需要等待的毫秒数
+ */
+function calculateWaitTime(data, delay) {
+  let targetTime = null;
+  
+  // 如果有 data 时间,解析它
+  if (data && data.trim() !== '') {
+    targetTime = parseTimeString(data);
+    if (!targetTime) {
+      console.warn(`无法解析时间字符串: ${data},将立即执行`);
+      targetTime = new Date(); // 解析失败则立即执行
+    }
+  } else {
+    // 没有 data 时间,使用当前时间
+    targetTime = new Date();
+  }
+  
+  // 解析 delay
+  const delayMs = parseDelayString(delay);
+  if (delayMs === null) {
+    console.warn(`无法解析延迟字符串: ${delay},将不延迟`);
+  } else {
+    // 在目标时间基础上加上延迟
+    targetTime = new Date(targetTime.getTime() + delayMs);
+  }
+  
+  // 计算需要等待的时间
+  const now = new Date();
+  const waitTime = targetTime.getTime() - now.getTime();
+  
+  // 如果目标时间已过,返回0(立即执行)
+  return Math.max(0, waitTime);
+}
 
 /**
  * 解析 processing.json 中的操作数组
@@ -21,11 +140,19 @@ export function parseActions(actions) {
       continue;
     }
 
+    // 解析公共字段
+    const times = action.times && action.times > 0 ? parseInt(action.times, 10) : 1;
+    const data = action.data || '';
+    const delay = action.delay || '';
+
     // 检查 press 操作
     if (action.press) {
       parsedActions.push({
         type: 'press',
         value: action.press, // 图片文件名
+        times: times,
+        data: data,
+        delay: delay,
       });
     }
     // 检查 input 操作
@@ -33,6 +160,9 @@ export function parseActions(actions) {
       parsedActions.push({
         type: 'input',
         value: action.input, // 要输入的文本
+        times: times,
+        data: data,
+        delay: delay,
       });
     }
     // 检查 swipe 操作
@@ -48,6 +178,9 @@ export function parseActions(actions) {
       parsedActions.push({
         type: 'swipe',
         value: swipeValue, // 滑动方向
+        times: times,
+        data: data,
+        delay: delay,
       });
     }
     // 检查 string-press 操作
@@ -55,6 +188,27 @@ export function parseActions(actions) {
       parsedActions.push({
         type: 'string-press',
         value: action['string-press'], // 要查找的文字
+        times: times,
+        data: data,
+        delay: delay,
+      });
+    }
+    // 检查 scroll 操作
+    else if (action.scroll) {
+      const scrollValue = action.scroll;
+      const validScrollDirections = ['up-down', 'down-up', 'left-right', 'right-left'];
+      
+      if (!validScrollDirections.includes(scrollValue)) {
+        console.warn(`无效的滚动方向: ${scrollValue},应为: ${validScrollDirections.join(', ')}`);
+        continue;
+      }
+      
+      parsedActions.push({
+        type: 'scroll',
+        value: scrollValue, // 滚动方向
+        times: times,
+        data: data,
+        delay: delay,
       });
     }
     else {
@@ -65,6 +219,34 @@ export function parseActions(actions) {
   return parsedActions;
 }
 
+/**
+ * 获取操作名称(用于显示)
+ * @param {Object} action - 操作对象
+ * @returns {string} 操作名称
+ */
+function getActionName(action) {
+  const typeNames = {
+    'press': '点击图片',
+    'input': '输入文本',
+    'swipe': '滑动',
+    'string-press': '点击文字',
+    'scroll': '滚动'
+  };
+  
+  const typeName = typeNames[action.type] || action.type;
+  const value = action.value || '';
+  
+  if (action.type === 'input') {
+    return `${typeName}: ${value.length > 20 ? value.substring(0, 20) + '...' : value}`;
+  } else if (action.type === 'string-press') {
+    return `${typeName}: ${value.length > 20 ? value.substring(0, 20) + '...' : value}`;
+  } else if (action.type === 'press') {
+    return `${typeName}: ${value}`;
+  } else {
+    return `${typeName}: ${value}`;
+  }
+}
+
 /**
  * 计算滑动操作的坐标
  * @param {string} direction - 滑动方向: up-down, down-up, left-right, right-left
@@ -115,6 +297,7 @@ export function calculateSwipeCoordinates(direction, width, height) {
   return { x1, y1, x2, y2 };
 }
 
+
 /**
  * 执行单个操作
  * @param {Object} action - 操作对象 {type, value}
@@ -224,6 +407,29 @@ export async function executeAction(action, device, folderPath, resolution) {
         return { success: true };
       }
 
+      case 'scroll': {
+        // 滚动操作(小幅度滚动)
+        if (!window.electronAPI || !window.electronAPI.sendScroll) {
+          return { success: false, error: '滚动 API 不可用' };
+        }
+
+        const scrollResult = await window.electronAPI.sendScroll(
+          device,
+          action.value,
+          resolution.width,
+          resolution.height,
+          DEFAULT_SCROLL_DISTANCE,
+          500 // 滚动持续时间500ms,模拟真实手指滑动,避免触发tap事件
+        );
+        
+        if (!scrollResult.success) {
+          return { success: false, error: `滚动失败: ${scrollResult.error}` };
+        }
+
+        console.log(`成功滚动: ${action.value}`);
+        return { success: true };
+      }
+
       default:
         return { success: false, error: `未知的操作类型: ${action.type}` };
     }
@@ -263,26 +469,106 @@ export async function executeActionSequence(
     }
 
     const action = actions[i];
-    console.log(`执行步骤 ${i + 1}/${actions.length}: ${action.type} - ${action.value}`);
+    const times = action.times || 1;
+    
+    // 发送步骤开始执行事件
+    if (onStepComplete) {
+      const stepName = getActionName(action);
+      onStepComplete(i + 1, actions.length, stepName, 0, times, 0);
+    }
+    
+    // 计算等待时间(根据 data 和 delay)
+    const waitTime = calculateWaitTime(action.data, action.delay);
+    
+    if (waitTime > 0) {
+      const waitSeconds = Math.round(waitTime / 1000);
+      console.log(`步骤 ${i + 1}/${actions.length} 等待 ${waitSeconds} 秒后执行...`);
+      
+      // 在等待期间也更新倒计时
+      let remainingTime = waitTime;
+      const countdownInterval = 100;
+      const stepName = getActionName(action);
+      
+      while (remainingTime > 0) {
+        if (shouldStop && shouldStop()) {
+          return { success: false, error: '执行被停止', completedSteps };
+        }
+        
+        if (onStepComplete) {
+          onStepComplete(i + 1, actions.length, stepName, remainingTime, times, 0);
+        }
+        
+        const waitTimeChunk = Math.min(countdownInterval, remainingTime);
+        await new Promise(resolve => setTimeout(resolve, waitTimeChunk));
+        remainingTime -= waitTimeChunk;
+      }
+    }
+
+    // 根据 times 重复执行操作
+    for (let t = 0; t < times; t++) {
+      // 检查是否应该停止
+      if (shouldStop && shouldStop()) {
+        console.log('执行被停止');
+        return { success: false, error: '执行被停止', completedSteps };
+      }
+
+      // 发送步骤执行中事件(包含当前执行次数)
+      if (onStepComplete) {
+        const stepName = getActionName(action);
+        onStepComplete(i + 1, actions.length, stepName, 0, times, t + 1);
+      }
+
+      if (times > 1) {
+        console.log(`执行步骤 ${i + 1}/${actions.length} (第 ${t + 1}/${times} 次): ${action.type} - ${action.value}`);
+      } else {
+        console.log(`执行步骤 ${i + 1}/${actions.length}: ${action.type} - ${action.value}`);
+      }
 
-    // 执行操作
-    const result = await executeAction(action, device, folderPath, resolution);
+      // 执行操作
+      const result = await executeAction(action, device, folderPath, resolution);
 
-    if (!result.success) {
-      console.error(`步骤 ${i + 1} 执行失败:`, result.error);
-      return { success: false, error: result.error, completedSteps: i };
+      if (!result.success) {
+        console.error(`步骤 ${i + 1} 执行失败:`, result.error);
+        return { success: false, error: result.error, completedSteps: i };
+      }
+
+      // 如果不是最后一次重复,等待一小段时间
+      if (t < times - 1) {
+        await new Promise(resolve => setTimeout(resolve, 500)); // 重复执行间隔500ms
+      }
     }
 
     completedSteps++;
 
-    // 调用完成回调
+    // 调用完成回调,传递步骤信息(当前步骤已完成)
     if (onStepComplete) {
-      onStepComplete(i + 1, actions.length);
+      const stepName = getActionName(action);
+      onStepComplete(i + 1, actions.length, stepName, 0, times, times);
     }
 
-    // 如果不是最后一步,等待间隔时间
+    // 如果不是最后一步,等待间隔时间并显示倒计时
     if (i < actions.length - 1) {
-      await new Promise(resolve => setTimeout(resolve, stepInterval));
+      // 在等待期间,更新倒计时
+      let remainingTime = stepInterval;
+      const countdownInterval = 100; // 每100ms更新一次倒计时
+      const nextStepName = getActionName(actions[i + 1]);
+      const nextTimes = actions[i + 1].times || 1;
+      
+      while (remainingTime > 0) {
+        // 检查是否应该停止
+        if (shouldStop && shouldStop()) {
+          return { success: false, error: '执行被停止', completedSteps };
+        }
+        
+        // 更新倒计时回调(显示下一个步骤的倒计时)
+        if (onStepComplete) {
+          onStepComplete(i + 1, actions.length, nextStepName, remainingTime, nextTimes, 0);
+        }
+        
+        const waitTime = Math.min(countdownInterval, remainingTime);
+        await new Promise(resolve => setTimeout(resolve, waitTime));
+        remainingTime -= waitTime;
+      }
     }
   }
 

+ 30 - 1
src/pages/Chat/History/History.js

@@ -160,16 +160,45 @@ export function useHistory() {
           return !stillPlaying || stillCurrentDevice !== currentDevice;
         };
 
+        // 步骤完成回调,通过自定义事件发送步骤信息
+        const onStepComplete = (currentStep, totalSteps, stepName, countdown, times, currentTime) => {
+          // 发送步骤更新事件
+          const event = new CustomEvent('action-step-update', {
+            detail: {
+              currentStep,
+              totalSteps,
+              stepName,
+              countdown, // 倒计时(毫秒)
+              times: times || 1, // 总执行次数
+              currentTime: currentTime || 0, // 当前执行到第几次
+              isRunning: true
+            }
+          });
+          window.dispatchEvent(event);
+        };
+
         const result = await executeActionSequence(
           actions,
           currentDevice,
           folderPath,
           resolution,
           stepInterval,
-          null, // onStepComplete 回调
+          onStepComplete, // 传递回调函数
           shouldStop
         );
 
+        // 执行完成或失败,发送停止事件
+        const stopEvent = new CustomEvent('action-step-update', {
+          detail: {
+            currentStep: 0,
+            totalSteps: 0,
+            stepName: '',
+            countdown: 0,
+            isRunning: false
+          }
+        });
+        window.dispatchEvent(stopEvent);
+
         if (!result.success) {
           console.error('操作序列执行失败:', result.error);
           if (playingIndexRef.current === index) {

+ 52 - 2
src/pages/ScreenShot/ScreenShot.css

@@ -15,7 +15,7 @@
 .ScreenShot-container.no-padding {
   padding: 0;
   align-items: stretch;
-  justify-content: flex-start;
+  justify-content: center;
   position: absolute;
   top: 0;
   left: 0;
@@ -47,13 +47,17 @@
   border: none;
   background: transparent;
   width: 100%;
-  height: 100%;
   max-width: 100%;
   max-height: 100%;
   aspect-ratio: 1280 / 2400;
   margin: 0;
+  padding: 0;
   align-self: stretch;
   flex-shrink: 0;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  box-sizing: border-box;
 }
 
 .screenshot-img {
@@ -67,3 +71,49 @@
   color: #666;
   font-size: 14px;
 }
+
+.action-processing {
+  position: absolute;
+  bottom: 20px;
+  left: 20px;
+  right: 20px;
+  background: rgba(0, 0, 0, 0.9);
+  border: none;
+  padding: 12px 16px;
+  z-index: 1000;
+  font-family: 'Courier New', 'Consolas', monospace;
+  box-shadow: 0 0 10px rgba(0, 255, 65, 0.3);
+}
+
+.action-step-info {
+  display: flex;
+  flex-direction: column;
+  gap: 4px;
+  color: #00ff41;
+}
+
+.action-step-title {
+  font-size: 12px;
+  font-weight: 400;
+  color: #00ff41;
+  text-transform: none;
+  letter-spacing: 0;
+  opacity: 0.8;
+}
+
+.action-step-name {
+  font-size: 14px;
+  font-weight: 400;
+  color: #00ff41;
+  line-height: 1.4;
+  font-family: 'Courier New', 'Consolas', monospace;
+}
+
+.action-countdown {
+  font-size: 12px;
+  color: #00ff41;
+  font-weight: 400;
+  margin-top: 2px;
+  opacity: 0.7;
+  font-family: 'Courier New', 'Consolas', monospace;
+}

+ 91 - 0
src/pages/ScreenShot/ScreenShot.js

@@ -133,6 +133,97 @@ export function ScreenShotLogic() {
   };
 }
 
+// 操作步骤状态管理
+export function useActionStepStatus() {
+  const [stepStatus, setStepStatus] = useState({
+    currentStep: 0,
+    totalSteps: 0,
+    stepName: '',
+    countdown: 0, // 倒计时(毫秒)
+    times: 1, // 总执行次数
+    currentTime: 0, // 当前执行到第几次
+    isRunning: false
+  });
+
+  useEffect(() => {
+    const handleStepUpdate = (e) => {
+      const detail = e.detail || {};
+      setStepStatus({
+        currentStep: detail.currentStep || 0,
+        totalSteps: detail.totalSteps || 0,
+        stepName: detail.stepName || '',
+        countdown: detail.countdown || 0,
+        times: detail.times || 1,
+        currentTime: detail.currentTime || 0,
+        isRunning: detail.isRunning || false
+      });
+    };
+
+    window.addEventListener('action-step-update', handleStepUpdate);
+
+    return () => {
+      window.removeEventListener('action-step-update', handleStepUpdate);
+    };
+  }, []);
+
+  return stepStatus;
+}
+
+// 操作步骤显示逻辑(包含倒计时)
+export function useActionStepDisplay() {
+  const stepStatus = useActionStepStatus();
+  const [countdownDisplay, setCountdownDisplay] = useState(0);
+
+  // 更新倒计时显示
+  useEffect(() => {
+    if (stepStatus.isRunning && stepStatus.countdown > 0) {
+      // 初始设置倒计时
+      const initialSeconds = Math.ceil(stepStatus.countdown / 1000);
+      setCountdownDisplay(initialSeconds);
+      
+      // 每100ms更新一次倒计时
+      const interval = setInterval(() => {
+        setCountdownDisplay(prev => {
+          if (prev <= 0.1) {
+            return 0;
+          }
+          return Math.max(0, Math.ceil(prev - 0.1));
+        });
+      }, 100);
+      
+      return () => clearInterval(interval);
+    } else {
+      setCountdownDisplay(0);
+    }
+  }, [stepStatus.countdown, stepStatus.isRunning]);
+
+  // 格式化步骤名称显示
+  const formatStepName = () => {
+    let stepText = '';
+    if (stepStatus.currentStep > 0 && stepStatus.totalSteps > 0) {
+      stepText = `步骤 ${stepStatus.currentStep}/${stepStatus.totalSteps}`;
+    }
+    
+    let timesText = '';
+    if (stepStatus.times > 1 && stepStatus.currentTime > 0) {
+      timesText = ` (${stepStatus.currentTime}/${stepStatus.times})`;
+    }
+    
+    const nameText = stepStatus.stepName || '准备中...';
+    
+    if (stepText) {
+      return `${stepText}${timesText}: ${nameText}`;
+    }
+    return nameText;
+  };
+
+  return {
+    isRunning: stepStatus.isRunning,
+    stepName: formatStepName(),
+    countdown: countdownDisplay
+  };
+}
+
 function delay(ms) {
   return new Promise((resolve) => setTimeout(resolve, ms));
 }

+ 19 - 1
src/pages/ScreenShot/ScreenShot.jsx

@@ -1,5 +1,5 @@
 import './ScreenShot.css';
-import { ScreenShotLogic } from './ScreenShot.js';
+import { ScreenShotLogic, useActionStepDisplay } from './ScreenShot.js';
 import { useTouchEvents } from './TouchEvent.js';
 import { useInputEvents } from './InputEvent.js';
 import { useRef } from 'react';
@@ -12,6 +12,9 @@ function ScreenShot() {
   // 启用键盘输入(当设备连接时自动启用)
   useInputEvents(currentDevice, !!currentDevice);
 
+  // 获取操作步骤显示信息(包含倒计时)
+  const stepDisplay = useActionStepDisplay();
+
   return (
     <div className={`ScreenShot-container ${currentDevice ? 'no-padding' : ''}`}>
       {!currentDevice && (
@@ -41,6 +44,21 @@ function ScreenShot() {
           )}
         </div>
       )}
+      {stepDisplay.isRunning && (
+        <div className="action-processing">
+          <div className="action-step-info">
+            <div className="action-step-title">执行中</div>
+            <div className="action-step-name">
+              {stepDisplay.stepName}
+            </div>
+            {stepDisplay.countdown > 0 && (
+              <div className="action-countdown">
+                下一个步骤: {stepDisplay.countdown} 秒
+              </div>
+            )}
+          </div>
+        </div>
+      )}
     </div>
   );
 }

+ 16 - 0
static/processing/上下滑动/processing.json

@@ -0,0 +1,16 @@
+{
+	"actions": [
+		{
+			"swipe": "down-up",
+			"times":1,
+			"data":"",
+			"delay":""
+		},
+		{
+			"swipe": "up-down",
+			"times":1,
+			"data":"",
+			"delay":""
+		}
+	]
+}

+ 0 - 0
static/processing/测试复杂流程/2.png → static/processing/复杂流程/2.png


+ 0 - 0
static/processing/测试复杂流程/3.png → static/processing/复杂流程/3.png


+ 28 - 0
static/processing/复杂流程/processing.json

@@ -0,0 +1,28 @@
+{
+	"actions": [
+		{
+			"string-press": "qq聊天测试群",
+			"times":1,
+			"data":"",
+			"delay":"5s"
+		},
+		{
+			"press": "2.png",
+			"times":1,
+			"data":"",
+			"delay":""
+		},
+		{
+			"input": "机器人测试",
+			"times":2,
+			"data":"",
+			"delay":""
+		},
+		{
+			"press": "3.png",
+			"times":1,
+			"data":"2026/1/13 2:46",
+			"delay":"1m"
+		}
+	]
+}

+ 10 - 0
static/processing/文字坐标定位/processing.json

@@ -0,0 +1,10 @@
+{
+	"actions": [
+		{
+			"string-press": "qq聊天测试群",
+			"times":1,
+			"data":"",
+			"delay":""
+		}
+	]
+}

+ 0 - 6
static/processing/测试上下滑动/processing.json

@@ -1,6 +0,0 @@
-{
-	"actions": [
-		{"swipe": "down-up"},
-		{"swipe": "up-down"}
-	]
-}

+ 0 - 8
static/processing/测试复杂流程/processing.json

@@ -1,8 +0,0 @@
-{
-	"actions": [
-		{"string-press": "qq聊天测试群"},
-		{"press": "2.png"},
-		{"input": "测试信息"},
-		{"press": "3.png"}
-	]
-}

BIN
static/processing/测试按/1.png


+ 0 - 5
static/processing/测试按/processing.json

@@ -1,5 +0,0 @@
-{
-	"actions": [
-		{"press": "1.png"}
-	]
-}

+ 0 - 5
static/processing/测试文字定位识别/processing.json

@@ -1,5 +0,0 @@
-{
-	"actions": [
-		{"string-press": "qq聊天测试群"}
-	]
-}

+ 10 - 0
static/processing/滚动/processing.json

@@ -0,0 +1,10 @@
+{
+	"actions": [
+		{
+			"scroll": "down-up",
+			"times":10,
+			"data":"",
+			"delay":""
+		}
+	]
+}

+ 0 - 0
static/processing/测试复杂流程/1.png → static/processing/点击目标/1.png


+ 10 - 0
static/processing/点击目标/processing.json

@@ -0,0 +1,10 @@
+{
+	"actions": [
+		{
+			"press": "1.png",
+			"times":1,
+			"data":"2026/1/13 03:34",
+			"delay":""
+		}
+	]
+}

BIN
static/processing/综合/2.png


BIN
static/processing/综合/3.png


+ 28 - 0
static/processing/综合/processing.json

@@ -0,0 +1,28 @@
+{
+	"actions": [
+		{
+			"string-press": "qq聊天测试群",
+			"times":1,
+			"data":"",
+			"delay":""
+		},
+		{
+			"press": "2.png",
+			"times":1,
+			"data":"",
+			"delay":""
+		},
+		{
+			"input": "测试信息",
+			"times":1,
+			"data":"2026/1/13 02:09",
+			"delay":"10s"
+		},
+		{
+			"press": "3.png",
+			"times":1,
+			"data":"2026/1/13 02:09",
+			"delay":""
+		}
+	]
+}

BIN
temp_screenshot.png


BIN
temp_text_1768236525061_mmtnk.png


BIN
temp_text_1768236914318_6ydum7.png