录制回放测试框架的使用

项目结构

automation_framework/
├── main.py
├── requirements.txt
├── run.bat
├── run.sh
├── config/
│   ├── __init__.py
│   └── settings.py
├── core/
│   ├── __init__.py
│   ├── models.py
│   ├── recorder.py
│   ├── player.py
│   ├── cmd_recorder.py
│   ├── cmd_player.py
│   ├── hybrid_recorder.py
│   ├── hybrid_player.py
│   └── unified_controller.py
├── ocr/
│   ├── __init__.py
│   ├── processor.py
│   ├── checker.py
│   └── selector.py
├── gui/
│   ├── __init__.py
│   └── main_window.py
└── utils/
    ├── __init__.py
    ├── logger.py
    ├── file_utils.py
    └── screenshot.py

  1. main.py
"""
主入口
"""

import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).parent))

from gui.main_window import MainWindow


def check_dependencies():
    try:
        import pytesseract, cv2, pyautogui, pynput
        return True
    except ImportError as e:
        print(f"❌ 缺少依赖: {e}")
        print("请安装: pip install -r requirements.txt")
        return False


def check_tesseract():
    try:
        import pytesseract
        pytesseract.get_tesseract_version()
        return True
    except:
        print("❌ 未找到Tesseract OCR")
        print("请安装: https://github.com/UB-Mannheim/tesseract/wiki")
        return False


def main():
    print("=" * 60)
    print("🤖 自动化测试工具 (UI + 命令行)")
    print("=" * 60)
    
    if not check_dependencies():
        return 1
        
    if not check_tesseract():
        return 1
        
    try:
        app = MainWindow()
        app.run()
    except KeyboardInterrupt:
        print("\n程序已退出")
    except Exception as e:
        print(f"❌ 程序错误: {e}")
        return 1
        
    return 0


if __name__ == "__main__":
    exit(main())

  1. requirements.txt
# 核心依赖
pyautogui==0.9.54
pygetwindow==0.0.9
pynput==1.7.6
keyboard==0.13.5

# OCR依赖
pytesseract==0.3.10
opencv-python==4.8.1.78
Pillow==10.0.0

# 其他
numpy==1.24.3

  1. run.bat
@echo off
chcp 65001 >nul
echo ========================================
echo   🤖 自动化测试工具 (UI + 命令行)
echo ========================================
echo.

python --version >nul 2>&1
if errorlevel 1 (
    echo ❌ 未找到Python,请安装Python 3.8+
    pause
    exit /b 1
)

echo 检查依赖...
pip show pyautogui >nul 2>&1
if errorlevel 1 (
    echo 正在安装依赖...
    pip install -r requirements.txt
)

echo 启动程序...
python main.py

pause

  1. run.sh
#!/bin/bash
echo "========================================"
echo "  🤖 自动化测试工具 (UI + 命令行)"
echo "========================================"
echo ""

if ! command -v python3 &> /dev/null; then
    echo "❌ 未找到Python3,请安装Python 3.8+"
    exit 1
fi

echo "检查依赖..."
pip3 show pyautogui &> /dev/null
if [ $? -ne 0 ]; then
    echo "正在安装依赖..."
    pip3 install -r requirements.txt
fi

echo "启动程序..."
python3 main.py

  1. config/init.py
"""配置模块"""
from config.settings import Settings, settings

__all__ = ['Settings', 'settings']

  1. config/settings.py
"""
配置管理
"""

from pathlib import Path
from typing import Dict, Any
import json


class Settings:
    """应用配置"""
    
    def __init__(self):
        self.base_dir = Path(__file__).parent.parent
        self.data_dir = self.base_dir / "data"
        self.recordings_dir = self.data_dir / "recordings"
        self.templates_dir = self.data_dir / "templates"
        self.logs_dir = self.base_dir / "logs"
        
        self._create_directories()
        
        self.config = {
            'ocr': {
                'lang': 'chi_sim+eng',
                'psm': 6,
                'whitelist': '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz.-+%: '
            },
            'recording': {
                'default_speed': 1.0,
                'action_delay': 0.2,
                'screenshot_on_action': False
            },
            'player': {
                'retry_count': 3,
                'retry_delay': 0.5,
                'smart_locate': True
            },
            'gui': {
                'window_size': (1200, 800),
                'theme': 'default'
            }
        }
        
        self.regions_file = self.data_dir / "ocr_regions.json"
        self.load_regions()
        
    def _create_directories(self):
        for dir_path in [self.data_dir, self.recordings_dir, self.templates_dir, self.logs_dir]:
            dir_path.mkdir(parents=True, exist_ok=True)
            
    def load_regions(self) -> Dict:
        if self.regions_file.exists():
            try:
                with open(self.regions_file, 'r', encoding='utf-8') as f:
                    return json.load(f)
            except Exception:
                return {}
        return {}
        
    def save_regions(self, regions: Dict):
        with open(self.regions_file, 'w', encoding='utf-8') as f:
            json.dump(regions, f, indent=2, ensure_ascii=False)
            
    def get(self, key: str, default=None):
        keys = key.split('.')
        value = self.config
        for k in keys:
            if isinstance(value, dict):
                value = value.get(k)
            else:
                return default
        return value if value is not None else default
        
    def set(self, key: str, value: Any):
        keys = key.split('.')
        target = self.config
        for k in keys[:-1]:
            if k not in target:
                target[k] = {}
            target = target[k]
        target[keys[-1]] = value


settings = Settings()

  1. core/init.py
"""核心模块"""
from core.models import Action, CommandAction, OCRRegion, Recording, CommandExecutionResult, VerificationResult
from core.recorder import ActionRecorder
from core.player import ActionPlayer
from core.cmd_recorder import CommandRecorder
from core.cmd_player import CommandPlayer
from core.hybrid_recorder import HybridRecorder
from core.hybrid_player import HybridPlayer
from core.unified_controller import UnifiedController

__all__ = [
    'Action', 'CommandAction', 'OCRRegion', 'Recording',
    'CommandExecutionResult', 'VerificationResult',
    'ActionRecorder', 'ActionPlayer',
    'CommandRecorder', 'CommandPlayer',
    'HybridRecorder', 'HybridPlayer',
    'UnifiedController'
]

  1. core/models.py
"""
数据模型定义
"""

from dataclasses import dataclass, asdict, field
from typing import Dict, Any, Optional, List
from datetime import datetime


@dataclass
class OCRRegion:
    """OCR区域数据模型"""
    name: str
    x: int
    y: int
    width: int
    height: int
    description: str = ""
    expected_value: str = ""
    check_type: str = "contains"
    
    def to_dict(self) -> Dict:
        return asdict(self)
    
    def get_region(self) -> tuple:
        return (self.x, self.y, self.width, self.height)
    
    def validate(self) -> bool:
        return self.width > 0 and self.height > 0


@dataclass
class Action:
    """操作数据模型"""
    type: str
    params: Dict[str, Any]
    timestamp: float
    description: str = ""
    screenshot: Optional[str] = None
    
    def to_dict(self) -> Dict:
        return asdict(self)
    
    @classmethod
    def from_dict(cls, data: Dict) -> 'Action':
        return cls(**data)


@dataclass
class CommandAction(Action):
    """命令行操作数据模型"""
    cmd: str = ""
    args: List[str] = field(default_factory=list)
    working_dir: str = ""
    timeout: int = 30
    expected_output: str = ""
    check_type: str = "contains"
    env_vars: Dict[str, str] = field(default_factory=dict)
    
    def to_dict(self) -> Dict:
        data = super().to_dict()
        data.update({
            'cmd': self.cmd,
            'args': self.args,
            'working_dir': self.working_dir,
            'timeout': self.timeout,
            'expected_output': self.expected_output,
            'check_type': self.check_type,
            'env_vars': self.env_vars
        })
        return data
    
    @classmethod
    def from_dict(cls, data: Dict) -> 'CommandAction':
        params = data.get('params', {})
        return cls(
            type=data.get('type', 'cmd_execute'),
            params=params,
            timestamp=data.get('timestamp', 0),
            description=data.get('description', ''),
            screenshot=data.get('screenshot'),
            cmd=data.get('cmd', params.get('cmd', '')),
            args=data.get('args', params.get('args', [])),
            working_dir=data.get('working_dir', params.get('working_dir', '')),
            timeout=data.get('timeout', params.get('timeout', 30)),
            expected_output=data.get('expected_output', params.get('expected_output', '')),
            check_type=data.get('check_type', params.get('check_type', 'contains')),
            env_vars=data.get('env_vars', params.get('env_vars', {}))
        )


@dataclass
class Recording:
    """录制数据模型"""
    name: str
    actions: list
    timestamp: str
    duration: float
    description: str = ""
    
    def to_dict(self) -> Dict:
        return {
            'name': self.name,
            'actions': [a.to_dict() for a in self.actions],
            'timestamp': self.timestamp,
            'duration': self.duration,
            'description': self.description
        }
    
    @classmethod
    def from_dict(cls, data: Dict) -> 'Recording':
        actions = [Action.from_dict(a) for a in data.get('actions', [])]
        return cls(
            name=data.get('name', ''),
            actions=actions,
            timestamp=data.get('timestamp', ''),
            duration=data.get('duration', 0),
            description=data.get('description', '')
        )


@dataclass
class CommandExecutionResult:
    """命令执行结果"""
    command: str
    exit_code: int
    stdout: str
    stderr: str
    execution_time: float
    success: bool
    output_lines: List[str] = field(default_factory=list)
    
    def __post_init__(self):
        if not self.output_lines:
            self.output_lines = self.stdout.splitlines() if self.stdout else []


@dataclass
class VerificationResult:
    """验证结果模型"""
    region_name: str
    passed: bool
    actual_text: str
    expected_value: str
    check_type: str
    error: Optional[str] = None
    timestamp: str = ""
    
    def __post_init__(self):
        if not self.timestamp:
            self.timestamp = datetime.now().isoformat()

  1. core/recorder.py
"""
操作录制器 - UI操作
"""

import time
import threading
from typing import List
from pynput import mouse, keyboard
from core.models import Action
from utils.logger import LoggerMixin


class ActionRecorder(LoggerMixin):
    """操作录制器"""
    
    def __init__(self):
        self.actions: List[Action] = []
        self.is_recording = False
        self.start_time = 0
        self.key_buffer = {}
        self.mouse_listener = None
        self.keyboard_listener = None
        self.record_thread = None
        self._lock = threading.Lock()
        
    def start_recording(self):
        """开始录制"""
        with self._lock:
            self.actions = []
            self.is_recording = True
            self.start_time = time.time()
        
        self.logger.info("=" * 60)
        self.logger.info("🎬 开始录制UI操作...")
        self.logger.info("快捷键:")
        self.logger.info("  - Scroll Lock: 暂停/继续")
        self.logger.info("  - Ctrl+Shift+Q: 停止录制")
        self.logger.info("=" * 60)
        
        self.record_thread = threading.Thread(target=self._record_loop, daemon=True)
        self.record_thread.start()
        
    def _record_loop(self):
        """录制循环"""
        self.mouse_listener = mouse.Listener(
            on_click=self._on_mouse_click,
            on_scroll=self._on_mouse_scroll
        )
        
        self.keyboard_listener = keyboard.Listener(
            on_press=self._on_key_press,
            on_release=self._on_key_release
        )
        
        self.mouse_listener.start()
        self.keyboard_listener.start()
        
        while True:
            with self._lock:
                if not self.is_recording:
                    break
            time.sleep(0.1)
            
        self.mouse_listener.stop()
        self.keyboard_listener.stop()
        
    def _on_mouse_click(self, x, y, button, pressed):
        """鼠标点击事件"""
        with self._lock:
            if not self.is_recording:
                return
            
            if pressed:
                button_name = str(button).replace('Button.', '')
                action = Action(
                    type='click',
                    params={'x': x, 'y': y, 'button': button_name},
                    timestamp=time.time() - self.start_time,
                    description=f"点击 ({x}, {y})"
                )
                self.actions.append(action)
                self.logger.info(f"📝 {action.description}")
            
    def _on_mouse_scroll(self, x, y, dx, dy):
        """鼠标滚动事件"""
        with self._lock:
            if not self.is_recording:
                return
            
            action = Action(
                type='scroll',
                params={'x': x, 'y': y, 'dx': dx, 'dy': dy},
                timestamp=time.time() - self.start_time,
                description=f"滚动: dy={dy}"
            )
            self.actions.append(action)
            self.logger.info(f"📝 {action.description}")
        
    def _on_key_press(self, key):
        """键盘按下事件"""
        with self._lock:
            if not self.is_recording:
                return
            
            try:
                if key in [keyboard.Key.ctrl_l, keyboard.Key.ctrl_r]:
                    self.key_buffer['ctrl'] = True
                elif key in [keyboard.Key.shift, keyboard.Key.shift_r]:
                    self.key_buffer['shift'] = True
                    
                key_str = self._normalize_key(key)
                
                if key_str == 'scroll_lock':
                    self.is_recording = not self.is_recording
                    status = "暂停" if not self.is_recording else "继续"
                    self.logger.info(f"⏸️ 录制{status}")
                    return
                    
                if key_str == 'q' and self.key_buffer.get('ctrl') and self.key_buffer.get('shift'):
                    self.stop_recording()
                    return
                    
                if key_str and self.is_recording:
                    action = Action(
                        type='key_press',
                        params={'key': key_str},
                        timestamp=time.time() - self.start_time,
                        description=f"按键: {key_str}"
                    )
                    self.actions.append(action)
                    self.logger.info(f"📝 {action.description}")
                    
            except Exception as e:
                self.logger.debug(f"键盘事件错误: {e}")
                
    def _on_key_release(self, key):
        """键盘释放事件"""
        try:
            if key in [keyboard.Key.ctrl_l, keyboard.Key.ctrl_r]:
                self.key_buffer['ctrl'] = False
            elif key in [keyboard.Key.shift, keyboard.Key.shift_r]:
                self.key_buffer['shift'] = False
        except:
            pass
            
    def _normalize_key(self, key) -> str:
        """标准化按键名称"""
        try:
            if hasattr(key, 'char') and key.char is not None:
                return key.char
            elif hasattr(key, 'name'):
                return key.name
            else:
                return str(key)
        except:
            return str(key)
            
    def stop_recording(self):
        """停止录制"""
        with self._lock:
            self.is_recording = False
        
        self.logger.info("=" * 60)
        with self._lock:
            self.logger.info(f"✅ 录制完成! 共 {len(self.actions)} 个操作")
        self.logger.info("=" * 60)
        
    def get_actions(self) -> List[Action]:
        with self._lock:
            return self.actions.copy()
        
    def clear(self):
        with self._lock:
            self.actions = []

  1. core/player.py
"""
操作回放器 - UI操作
"""

import time
import threading
import pyautogui
from typing import List, Dict, Optional
from core.models import Action, OCRRegion
from ocr.checker import OCRChecker
from utils.logger import LoggerMixin


class ActionPlayer(LoggerMixin):
    """操作回放器"""
    
    def __init__(self):
        self.actions: List[Action] = []
        self.is_playing = False
        self.current_index = 0
        self.speed = 1.0
        self.ocr_checker: Optional[OCRChecker] = None
        self.stop_requested = False
        self._lock = threading.Lock()
        
    def load(self, actions: List[Action]) -> bool:
        self.actions = actions
        self.logger.info(f"✅ 加载了 {len(actions)} 个操作")
        return True
        
    def play(self, regions: Dict[str, OCRRegion] = None) -> bool:
        if not self.actions:
            self.logger.error("没有可回放的操作")
            return False
            
        with self._lock:
            self.is_playing = True
            self.current_index = 0
            self.stop_requested = False
        
        if regions:
            self.ocr_checker = OCRChecker(regions)
            
        self.logger.info("=" * 60)
        self.logger.info(f"▶️ 开始回放... (速度: {self.speed}x)")
        self.logger.info(f"共 {len(self.actions)} 个操作")
        self.logger.info("按 Ctrl+Q 可停止回放")
        self.logger.info("=" * 60)
        
        try:
            stop_thread = threading.Thread(target=self._listen_stop, daemon=True)
            stop_thread.start()
            
            while True:
                with self._lock:
                    if self.current_index >= len(self.actions) or self.stop_requested:
                        break
                    action = self.actions[self.current_index]
                
                try:
                    self._execute_action(action)
                    with self._lock:
                        self.current_index += 1
                    
                    if self.current_index % 10 == 0:
                        self.logger.info(f"进度: {self.current_index}/{len(self.actions)}")
                        
                except Exception as e:
                    self.logger.error(f"操作 {self.current_index+1} 失败: {e}")
                    with self._lock:
                        self.current_index += 1
                    
            with self._lock:
                completed = self.current_index >= len(self.actions)
            
            if completed:
                self.logger.info("=" * 60)
                self.logger.info("✅ 回放完成!")
                self.logger.info(f"共执行 {len(self.actions)} 个操作")
                self.logger.info("=" * 60)
                
                if self.ocr_checker:
                    self._verify_results()
                    
                return True
            else:
                self.logger.info("⏹️ 回放已停止")
                return False
                
        finally:
            with self._lock:
                self.is_playing = False
                
    def _execute_action(self, action: Action):
        action_type = action.type
        params = action.params
        
        if action_type == 'click':
            pyautogui.click(params['x'], params['y'])
        elif action_type == 'double_click':
            pyautogui.doubleClick(params['x'], params['y'])
        elif action_type == 'right_click':
            pyautogui.rightClick(params['x'], params['y'])
        elif action_type == 'key_press':
            pyautogui.press(params['key'])
        elif action_type == 'type_text':
            pyautogui.typewrite(params['text'])
        elif action_type == 'scroll':
            pyautogui.scroll(params['dy'])
        elif action_type == 'wait':
            time.sleep(params['seconds'] / self.speed)
        else:
            self.logger.warning(f"未知操作类型: {action_type}")
            
        time.sleep(0.2 / self.speed)
        
    def _listen_stop(self):
        try:
            import keyboard
            while True:
                with self._lock:
                    if not self.is_playing:
                        break
                if keyboard.is_pressed('ctrl+q'):
                    with self._lock:
                        self.stop_requested = True
                    self.logger.info("⏹️ 用户请求停止回放")
                    break
                time.sleep(0.1)
        except:
            pass
            
    def _verify_results(self):
        if not self.ocr_checker:
            return
            
        results = self.ocr_checker.verify_all()
        summary = self.ocr_checker.get_summary()
        
        self.logger.info("\n" + "=" * 60)
        self.logger.info("🔍 OCR验证结果")
        self.logger.info("=" * 60)
        self.logger.info(f"总计: {summary['total']} 个区域")
        self.logger.info(f"通过: {summary['passed']} 个")
        self.logger.info(f"失败: {summary['failed']} 个")
        self.logger.info(f"通过率: {summary['pass_rate']:.1f}%")
        self.logger.info("=" * 60)
        
        for result in results:
            status = "✅" if result.passed else "❌"
            self.logger.info(f"{status} {result.region_name}: '{result.actual_text[:50]}'")

  1. core/cmd_recorder.py
"""
命令行操作录制器
"""

import subprocess
import time
import threading
from typing import List, Optional
from core.models import Action, CommandAction, CommandExecutionResult
from utils.logger import LoggerMixin


class CommandRecorder(LoggerMixin):
    """命令行操作录制器"""
    
    def __init__(self):
        self.actions: List[Action] = []
        self.is_recording = False
        self.start_time = 0
        self._lock = threading.Lock()
        
    def start_recording(self):
        """开始录制"""
        with self._lock:
            self.actions = []
            self.is_recording = True
            self.start_time = time.time()
        
        self.logger.info("=" * 60)
        self.logger.info("🎬 开始录制命令行操作...")
        self.logger.info("输入命令执行,输入 'stop' 停止录制")
        self.logger.info("=" * 60)
        
        threading.Thread(target=self._interactive_mode, daemon=True).start()
        
    def _interactive_mode(self):
        """交互式命令行录制模式"""
        while True:
            with self._lock:
                if not self.is_recording:
                    break
            
            try:
                cmd_input = input("\n> ").strip()
                
                if not cmd_input:
                    continue
                    
                if cmd_input.lower() == 'stop':
                    self.stop_recording()
                    break
                    
                if cmd_input.startswith('cmd:'):
                    command = cmd_input[4:].strip()
                    if command:
                        self._execute_and_record(command)
                        
            except (EOFError, KeyboardInterrupt):
                break
            except Exception as e:
                self.logger.error(f"错误: {e}")
                
    def _execute_and_record(self, command: str):
        """执行命令并记录"""
        self.logger.info(f"📟 执行命令: {command}")
        
        result = self.execute_command(command)
        
        if result:
            action = CommandAction(
                type='cmd_execute',
                params={
                    'cmd': command,
                    'timeout': 30,
                    'exit_code': result.exit_code
                },
                timestamp=time.time() - self.start_time,
                description=f"执行命令: {command}",
                cmd=command,
                timeout=30
            )
            with self._lock:
                self.actions.append(action)
            
            if result.stdout:
                self.logger.info(f"📤 输出:\n{result.stdout[:500]}")
            if result.stderr:
                self.logger.warning(f"⚠️ 错误输出:\n{result.stderr[:200]}")
    
    def execute_command(self, command: str) -> Optional[CommandExecutionResult]:
        """执行命令并捕获输出 - 公共方法"""
        try:
            start_time = time.time()
            
            process = subprocess.Popen(
                command,
                stdout=subprocess.PIPE,
                stderr=subprocess.PIPE,
                text=True,
                shell=True,
                bufsize=1
            )
            
            try:
                stdout, stderr = process.communicate(timeout=30)
                exit_code = process.returncode
            except subprocess.TimeoutExpired:
                process.kill()
                stdout, stderr = process.communicate()
                exit_code = -1
                self.logger.warning("⚠️ 命令执行超时")
                
            execution_time = time.time() - start_time
            
            result = CommandExecutionResult(
                command=command,
                exit_code=exit_code,
                stdout=stdout or "",
                stderr=stderr or "",
                execution_time=execution_time,
                success=exit_code == 0
            )
            
            return result
            
        except FileNotFoundError:
            self.logger.error(f"❌ 命令未找到: {command}")
            return CommandExecutionResult(
                command=command,
                exit_code=-1,
                stdout="",
                stderr=f"命令未找到: {command}",
                execution_time=0,
                success=False
            )
        except Exception as e:
            self.logger.error(f"❌ 执行命令失败: {e}")
            return CommandExecutionResult(
                command=command,
                exit_code=-1,
                stdout="",
                stderr=str(e),
                execution_time=0,
                success=False
            )
            
    def stop_recording(self):
        """停止录制"""
        with self._lock:
            self.is_recording = False
        
        self.logger.info("=" * 60)
        with self._lock:
            self.logger.info(f"✅ 录制完成! 共 {len(self.actions)} 个操作")
        self.logger.info("=" * 60)
        
    def get_actions(self) -> List[Action]:
        with self._lock:
            return self.actions.copy()
        
    def clear(self):
        with self._lock:
            self.actions = []

  1. core/cmd_player.py
"""
命令行操作回放器
"""

import subprocess
import time
import threading
from typing import List
from core.models import Action
from utils.logger import LoggerMixin


class CommandPlayer(LoggerMixin):
    """命令行操作回放器"""
    
    def __init__(self):
        self.actions: List[Action] = []
        self.is_playing = False
        self.current_index = 0
        self.speed = 1.0
        self.last_output = ""
        self.stop_requested = False
        self._lock = threading.Lock()
        
    def load(self, actions: List[Action]) -> bool:
        self.actions = actions
        self.logger.info(f"✅ 加载了 {len(actions)} 个命令行操作")
        return True
        
    def play(self) -> bool:
        if not self.actions:
            self.logger.error("没有可回放的操作")
            return False
            
        with self._lock:
            self.is_playing = True
            self.current_index = 0
            self.stop_requested = False
            self.last_output = ""
        
        self.logger.info("=" * 60)
        self.logger.info(f"▶️ 开始回放命令行操作... (速度: {self.speed}x)")
        self.logger.info(f"共 {len(self.actions)} 个操作")
        self.logger.info("按 Ctrl+Q 可停止回放")
        self.logger.info("=" * 60)
        
        try:
            stop_thread = threading.Thread(target=self._listen_stop, daemon=True)
            stop_thread.start()
            
            while True:
                with self._lock:
                    if self.current_index >= len(self.actions) or self.stop_requested:
                        break
                    action = self.actions[self.current_index]
                
                try:
                    self._execute_action(action)
                    with self._lock:
                        self.current_index += 1
                    
                    if self.current_index % 10 == 0:
                        self.logger.info(f"进度: {self.current_index}/{len(self.actions)}")
                        
                except Exception as e:
                    self.logger.error(f"操作 {self.current_index+1} 失败: {e}")
                    with self._lock:
                        self.current_index += 1
                    
            with self._lock:
                completed = self.current_index >= len(self.actions)
            
            if completed:
                self.logger.info("=" * 60)
                self.logger.info("✅ 回放完成!")
                self.logger.info(f"共执行 {len(self.actions)} 个操作")
                self.logger.info("=" * 60)
                return True
            else:
                self.logger.info("⏹️ 回放已停止")
                return False
                
        finally:
            with self._lock:
                self.is_playing = False
                
    def _execute_action(self, action: Action):
        action_type = action.type
        
        if action_type == 'cmd_execute':
            self._execute_command(action)
        elif action_type == 'cmd_check_output':
            self._check_output(action)
        elif action_type == 'cmd_wait_text':
            self._wait_for_text(action)
        elif action_type == 'wait':
            time.sleep(action.params['seconds'] / self.speed)
        else:
            self.logger.warning(f"未知操作类型: {action_type}")
            
        time.sleep(0.2 / self.speed)
        
    def _execute_command(self, action: Action):
        cmd = action.params.get('cmd', '')
        timeout = action.params.get('timeout', 30)
        
        self.logger.info(f"🔄 执行命令: {cmd}")
        
        try:
            process = subprocess.Popen(
                cmd,
                stdout=subprocess.PIPE,
                stderr=subprocess.PIPE,
                text=True,
                shell=True,
                bufsize=1
            )
            
            try:
                stdout, stderr = process.communicate(timeout=timeout)
                exit_code = process.returncode
            except subprocess.TimeoutExpired:
                process.kill()
                stdout, stderr = process.communicate()
                exit_code = -1
                self.logger.warning(f"⚠️ 命令超时: {cmd}")
                
            self.last_output = stdout
            
            if stdout:
                self.logger.debug(f"📤 输出:\n{stdout[:200]}")
            if stderr:
                self.logger.warning(f"⚠️ 错误输出:\n{stderr[:200]}")
                
        except Exception as e:
            self.logger.error(f"❌ 执行失败: {e}")
            raise
            
    def _check_output(self, action: Action):
        expected = action.params.get('expected_output', '')
        check_type = action.params.get('check_type', 'contains')
        
        self.logger.info(f"🔍 检查输出: '{expected}'")
        
        if not self.last_output:
            self.logger.warning("⚠️ 没有输出可检查")
            return
            
        if check_type == 'contains':
            passed = expected in self.last_output
        elif check_type == 'equals':
            passed = self.last_output.strip() == expected
        elif check_type == 'regex':
            import re
            passed = bool(re.search(expected, self.last_output))
        else:
            passed = False
            
        if passed:
            self.logger.info(f"✅ 检查通过")
        else:
            self.logger.warning(f"❌ 检查失败")
            
    def _wait_for_text(self, action: Action):
        text = action.params.get('text', '')
        timeout = action.params.get('timeout', 30)
        
        self.logger.info(f"⏳ 等待输出: '{text}' (超时: {timeout}s)")
        
        start_time = time.time()
        while time.time() - start_time < timeout:
            if text in self.last_output:
                self.logger.info(f"✅ 找到文本")
                return
            time.sleep(0.5)
            
        self.logger.warning(f"❌ 等待超时")
        
    def _listen_stop(self):
        try:
            import keyboard
            while True:
                with self._lock:
                    if not self.is_playing:
                        break
                if keyboard.is_pressed('ctrl+q'):
                    with self._lock:
                        self.stop_requested = True
                    self.logger.info("⏹️ 用户请求停止回放")
                    break
                time.sleep(0.1)
        except:
            pass

  1. core/hybrid_recorder.py
"""
混合录制器 - 同时录制UI和命令行操作
"""

import time
import threading
import queue
import json
from typing import Dict, List, Optional
from pathlib import Path
from datetime import datetime

from core.models import Action, CommandAction
from core.recorder import ActionRecorder
from core.cmd_recorder import CommandRecorder
from utils.logger import LoggerMixin


class HybridRecorder(LoggerMixin):
    """混合录制器"""
    
    def __init__(self):
        self.ui_recorder = ActionRecorder()
        self.cmd_recorder = CommandRecorder()
        
        self.is_recording = False
        self.start_time = 0
        self._lock = threading.Lock()
        
        self.action_queue: List[Dict] = []
        self.stop_event = threading.Event()
        
        self.hotkeys = {
            'cmd_prefix': 'cmd:'
        }
        
    def start_recording(self):
        """开始混合录制"""
        with self._lock:
            self.action_queue = []
            self.is_recording = True
            self.start_time = time.time()
            self.stop_event.clear()
        
        self.logger.info("=" * 70)
        self.logger.info("🎬 开始混合录制 (UI + 命令行)")
        self.logger.info("=" * 70)
        self.logger.info("📖 使用说明:")
        self.logger.info(f"  - UI操作: 正常操作窗口")
        self.logger.info(f"  - 命令行输入: 以 '{self.hotkeys['cmd_prefix']}' 开头")
        self.logger.info(f"  - 输入 'stop' 停止录制")
        self.logger.info("=" * 70)
        
        # 启动UI录制
        self.ui_recorder.start_recording()
        
        # 启动命令行监听
        threading.Thread(target=self._cmd_input_listener, daemon=True).start()
        
    def _cmd_input_listener(self):
        """命令行输入监听器"""
        while True:
            with self._lock:
                if not self.is_recording or self.stop_event.is_set():
                    break
            
            try:
                user_input = input()
                
                if not user_input:
                    continue
                    
                if user_input.lower() == 'stop':
                    self.stop_recording()
                    break
                    
                if user_input.startswith(self.hotkeys['cmd_prefix']):
                    command = user_input[len(self.hotkeys['cmd_prefix']):].strip()
                    if command:
                        self._handle_cmd_input(command)
                        
            except (EOFError, KeyboardInterrupt):
                break
            except Exception as e:
                self.logger.error(f"命令行监听错误: {e}")
                
    def _handle_cmd_input(self, command: str):
        """处理命令行输入"""
        with self._lock:
            if not self.is_recording:
                return
        
        self.logger.info(f"📟 执行命令: {command}")
        
        result = self.cmd_recorder.execute_command(command)
        
        if result:
            action = CommandAction(
                type='cmd_execute',
                params={
                    'cmd': command,
                    'timeout': 30,
                    'exit_code': result.exit_code
                },
                timestamp=time.time() - self.start_time,
                description=f"执行命令: {command}",
                cmd=command,
                timeout=30
            )
            
            with self._lock:
                self.action_queue.append({
                    'type': 'cmd',
                    'action': action,
                    'timestamp': time.time() - self.start_time,
                    'result': result
                })
            
            if result.stdout:
                self.logger.info(f"📤 输出:\n{result.stdout[:500]}")
            if result.stderr:
                self.logger.warning(f"⚠️ 错误输出:\n{result.stderr[:200]}")
                
    def stop_recording(self):
        """停止录制"""
        with self._lock:
            if not self.is_recording:
                return
            self.is_recording = False
            self.stop_event.set()
        
        self.logger.info("=" * 70)
        self.logger.info("⏹️ 停止录制...")
        
        self.ui_recorder.stop_recording()
        
        with self._lock:
            ui_actions = len(self.ui_recorder.actions)
            cmd_actions = len(self.action_queue)
            total = ui_actions + cmd_actions
        
        self.logger.info(f"📊 录制统计:")
        self.logger.info(f"  UI操作: {ui_actions} 个")
        self.logger.info(f"  命令行操作: {cmd_actions} 个")
        self.logger.info(f"  总计: {total} 个操作")
        self.logger.info("=" * 70)
        
    def save(self, filename: str) -> str:
        """保存录制"""
        with self._lock:
            all_actions = []
            
            for action in self.ui_recorder.actions:
                all_actions.append({
                    'type': 'ui',
                    'action': action.to_dict(),
                    'timestamp': action.timestamp
                })
                
            for item in self.action_queue:
                if 'action' in item:
                    all_actions.append({
                        'type': 'cmd',
                        'action': item['action'].to_dict(),
                        'timestamp': item['timestamp']
                    })
                    
            all_actions.sort(key=lambda x: x['timestamp'])
            
            data = {
                'name': Path(filename).stem,
                'timestamp': datetime.now().isoformat(),
                'duration': time.time() - self.start_time,
                'total_actions': len(all_actions),
                'ui_actions': len(self.ui_recorder.actions),
                'cmd_actions': len(self.action_queue),
                'description': '混合录制 (UI + 命令行)',
                'actions': all_actions,
                'metadata': {
                    'mode': 'hybrid',
                    'ui_recording': True,
                    'cmd_recording': True
                }
            }
        
        with open(filename, 'w', encoding='utf-8') as f:
            json.dump(data, f, indent=2, ensure_ascii=False)
            
        self.logger.info(f"💾 录制已保存: {filename}")
        return filename
        
    def get_actions(self) -> List[Dict]:
        """获取所有录制的操作"""
        with self._lock:
            all_actions = []
            
            for action in self.ui_recorder.actions:
                all_actions.append({
                    'type': 'ui',
                    'action': action,
                    'timestamp': action.timestamp
                })
                
            for item in self.action_queue:
                all_actions.append({
                    'type': 'cmd',
                    'action': item['action'],
                    'timestamp': item['timestamp']
                })
                
            all_actions.sort(key=lambda x: x['timestamp'])
            return all_actions

  1. core/hybrid_player.py
"""
混合回放器 - 同时回放UI和命令行操作
"""

import time
import threading
import json
from typing import Dict, List, Optional
from pathlib import Path

from core.models import Action, CommandAction
from core.player import ActionPlayer
from core.cmd_player import CommandPlayer
from ocr.checker import OCRChecker
from utils.logger import LoggerMixin


class HybridPlayer(LoggerMixin):
    """混合回放器"""
    
    def __init__(self):
        self.ui_player = ActionPlayer()
        self.cmd_player = CommandPlayer()
        
        self.actions: List[Dict] = []
        self.is_playing = False
        self.speed = 1.0
        self.stop_requested = False
        self.current_index = 0
        self._lock = threading.Lock()
        
    def load(self, filepath: str) -> bool:
        """加载混合录制文件"""
        try:
            with open(filepath, 'r', encoding='utf-8') as f:
                data = json.load(f)
                
            self.actions = data.get('actions', [])
            
            self.logger.info(f"✅ 加载混合录制: {filepath}")
            self.logger.info(f"  总计: {len(self.actions)} 个操作")
            
            return True
            
        except Exception as e:
            self.logger.error(f"加载失败: {e}")
            return False
            
    def play(self, regions: Dict = None, speed: float = 1.0) -> bool:
        """执行混合回放"""
        with self._lock:
            if not self.actions:
                self.logger.error("没有可回放的操作")
                return False
                
            self.speed = speed
            self.is_playing = True
            self.current_index = 0
            self.stop_requested = False
        
        self.logger.info("=" * 70)
        self.logger.info(f"▶️ 开始混合回放... (速度: {speed}x)")
        self.logger.info(f"共 {len(self.actions)} 个操作")
        self.logger.info("按 Ctrl+Q 可停止回放")
        self.logger.info("=" * 70)
        
        stop_thread = threading.Thread(target=self._listen_stop, daemon=True)
        stop_thread.start()
        
        try:
            while True:
                with self._lock:
                    if self.current_index >= len(self.actions) or self.stop_requested:
                        break
                    item = self.actions[self.current_index]
                
                try:
                    progress = f"[{self.current_index+1}/{len(self.actions)}]"
                    
                    if item['type'] == 'ui':
                        action = Action.from_dict(item['action'])
                        self.logger.info(f"{progress} 🖥️ {action.description}")
                        self._play_ui_action(action)
                        
                    elif item['type'] == 'cmd':
                        action = CommandAction.from_dict(item['action'])
                        self.logger.info(f"{progress} 📟 {action.description}")
                        self._play_cmd_action(action)
                        
                    with self._lock:
                        self.current_index += 1
                    
                    time.sleep(0.3 / speed)
                    
                except Exception as e:
                    self.logger.error(f"操作 {self.current_index+1} 失败: {e}")
                    with self._lock:
                        self.current_index += 1
                    
            with self._lock:
                completed = self.current_index >= len(self.actions)
            
            if completed:
                self.logger.info("=" * 70)
                self.logger.info("✅ 混合回放完成!")
                self.logger.info(f"共执行 {len(self.actions)} 个操作")
                self.logger.info("=" * 70)
                
                if regions:
                    self._verify_results(regions)
                    
                return True
            else:
                self.logger.info("⏹️ 回放已停止")
                return False
                
        finally:
            with self._lock:
                self.is_playing = False
                
    def _play_ui_action(self, action: Action):
        self.ui_player.load([action])
        self.ui_player.speed = self.speed
        self.ui_player.play()
        
    def _play_cmd_action(self, action: CommandAction):
        self.cmd_player.load([action])
        self.cmd_player.speed = self.speed
        self.cmd_player.play()
        
    def _listen_stop(self):
        try:
            import keyboard
            while True:
                with self._lock:
                    if not self.is_playing:
                        break
                if keyboard.is_pressed('ctrl+q'):
                    with self._lock:
                        self.stop_requested = True
                    self.logger.info("⏹️ 用户请求停止回放")
                    break
                time.sleep(0.1)
        except:
            pass
            
    def _verify_results(self, regions: Dict):
        checker = OCRChecker(regions)
        results = checker.verify_all()
        
        self.logger.info("\n" + "=" * 70)
        self.logger.info("🔍 OCR验证结果")
        self.logger.info("=" * 70)
        
        for result in results:
            status = "✅" if result.passed else "❌"
            self.logger.info(f"{status} {result.region_name}: '{result.actual_text[:50]}'")
            
        self.logger.info("=" * 70)

  1. core/unified_controller.py
"""
统一控制器 - 整合所有功能
"""

import time
from pathlib import Path
from typing import Dict, List, Optional, Any
from datetime import datetime

from core.models import Action, OCRRegion, Recording
from core.recorder import ActionRecorder
from core.player import ActionPlayer
from core.cmd_recorder import CommandRecorder
from core.cmd_player import CommandPlayer
from core.hybrid_recorder import HybridRecorder
from core.hybrid_player import HybridPlayer
from ocr.selector import LiveOCRSelector
from ocr.checker import OCRChecker
from config.settings import settings
from utils.logger import LoggerMixin
from utils.file_utils import FileManager


class UnifiedController(LoggerMixin):
    """统一控制器"""
    
    def __init__(self):
        self.settings = settings
        self.file_manager = FileManager(self.settings.data_dir)
        
        self.ui_recorder = ActionRecorder()
        self.ui_player = ActionPlayer()
        self.cmd_recorder = CommandRecorder()
        self.cmd_player = CommandPlayer()
        self.hybrid_recorder = HybridRecorder()
        self.hybrid_player = HybridPlayer()
        
        self.regions: Dict[str, OCRRegion] = {}
        self.load_regions()
        
    def load_regions(self):
        data = self.file_manager.load_json(self.settings.regions_file)
        if data:
            for name, region_data in data.items():
                self.regions[name] = OCRRegion(**region_data)
            self.logger.info(f"✅ 加载了 {len(self.regions)} 个OCR区域")
                
    def save_regions(self):
        data = {name: region.to_dict() for name, region in self.regions.items()}
        self.file_manager.save_json(data, self.settings.regions_file)
        
    def select_ocr_region(self) -> Optional[OCRRegion]:
        selector = LiveOCRSelector()
        region = selector.select()
        if region:
            self.regions[region.name] = region
            self.save_regions()
            self.logger.info(f"✅ 已添加OCR区域: {region.name}")
        return region
    
    def delete_region(self, name: str) -> bool:
        if name in self.regions:
            del self.regions[name]
            self.save_regions()
            self.logger.info(f"🗑️ 已删除OCR区域: {name}")
            return True
        return False
        
    # ==================== UI模式 ====================
    def start_ui_recording(self):
        self.ui_recorder.start_recording()
        
    def stop_ui_recording(self) -> Optional[str]:
        self.ui_recorder.stop_recording()
        if self.ui_recorder.actions:
            filename = self.file_manager.get_unique_filename(
                self.settings.recordings_dir, "ui_recording"
            )
            recording = Recording(
                name=Path(filename).stem,
                actions=self.ui_recorder.actions,
                timestamp=datetime.now().isoformat(),
                duration=self.ui_recorder.actions[-1].timestamp if self.ui_recorder.actions else 0,
                description="UI操作录制"
            )
            self.file_manager.save_json(recording.to_dict(), Path(filename))
            return filename
        return None
        
    def play_ui_recording(self, filepath: str, speed: float = 1.0) -> bool:
        data = self.file_manager.load_json(Path(filepath))
        if not data:
            return False
        actions = [Action.from_dict(a) for a in data.get('actions', [])]
        self.ui_player.load(actions)
        self.ui_player.speed = speed
        return self.ui_player.play(self.regions if self.regions else None)
        
    # ==================== 命令行模式 ====================
    def start_cmd_recording(self):
        self.cmd_recorder.start_recording()
        
    def stop_cmd_recording(self) -> Optional[str]:
        self.cmd_recorder.stop_recording()
        if self.cmd_recorder.actions:
            filename = self.file_manager.get_unique_filename(
                self.settings.recordings_dir, "cmd_recording"
            )
            recording = Recording(
                name=Path(filename).stem,
                actions=self.cmd_recorder.actions,
                timestamp=datetime.now().isoformat(),
                duration=self.cmd_recorder.actions[-1].timestamp if self.cmd_recorder.actions else 0,
                description="命令行录制"
            )
            self.file_manager.save_json(recording.to_dict(), Path(filename))
            return filename
        return None
        
    def play_cmd_recording(self, filepath: str, speed: float = 1.0) -> bool:
        data = self.file_manager.load_json(Path(filepath))
        if not data:
            return False
        actions = [Action.from_dict(a) for a in data.get('actions', [])]
        self.cmd_player.load(actions)
        self.cmd_player.speed = speed
        return self.cmd_player.play()
        
    # ==================== 混合模式 ====================
    def start_hybrid_recording(self):
        self.hybrid_recorder.start_recording()
        
    def stop_hybrid_recording(self) -> Optional[str]:
        filename = self.file_manager.get_unique_filename(
            self.settings.recordings_dir, "hybrid_recording"
        )
        return self.hybrid_recorder.save(filename)
        
    def play_hybrid_recording(self, filepath: str, speed: float = 1.0) -> bool:
        self.hybrid_player.load(filepath)
        return self.hybrid_player.play(self.regions if self.regions else None, speed)
        
    # ==================== 验证 ====================
    def verify_all(self) -> Dict:
        if not self.regions:
            return {'ocr': {'total': 0, 'passed': 0, 'failed': 0, 'pass_rate': 0, 'results': []}}
        
        checker = OCRChecker(self.regions)
        results = checker.verify_all()
        summary = checker.get_summary()
        
        self.logger.info("\n" + "=" * 60)
        self.logger.info("📊 OCR验证结果")
        self.logger.info("=" * 60)
        self.logger.info(f"总计: {summary['total']} 个区域")
        self.logger.info(f"通过: {summary['passed']} 个")
        self.logger.info(f"失败: {summary['failed']} 个")
        self.logger.info(f"通过率: {summary['pass_rate']:.1f}%")
        self.logger.info("=" * 60)
        
        for result in results:
            status = "✅" if result.passed else "❌"
            self.logger.info(f"{status} {result.region_name}: '{result.actual_text[:50]}'")
        
        return {'ocr': summary}
        
    # ==================== 工具方法 ====================
    def list_recordings(self, pattern: str = "*.json") -> List[str]:
        files = self.file_manager.list_files(self.settings.recordings_dir, pattern)
        return [f.stem for f in files]
        
    def get_latest_recording(self) -> Optional[str]:
        latest = self.file_manager.get_latest_file(self.settings.recordings_dir, "*.json")
        return str(latest) if latest else None
        
    def get_recording_info(self, filepath: str) -> Dict:
        data = self.file_manager.load_json(Path(filepath))
        if data:
            return {
                'name': data.get('name', ''),
                'timestamp': data.get('timestamp', ''),
                'total_actions': len(data.get('actions', [])),
                'duration': data.get('duration', 0),
                'description': data.get('description', '')
            }
        return {}

  1. ocr/init.py
"""OCR模块"""
from ocr.processor import OCRProcessor
from ocr.checker import OCRChecker
from ocr.selector import LiveOCRSelector

__all__ = ['OCRProcessor', 'OCRChecker', 'LiveOCRSelector']

  1. ocr/processor.py
"""
OCR图像处理器
"""

import cv2
import numpy as np
from PIL import Image
import pytesseract
from config.settings import settings


class OCRProcessor:
    """OCR图像处理器"""
    
    def __init__(self):
        self.config = settings.get('ocr', {})
        self.lang = self.config.get('lang', 'chi_sim+eng')
        self.psm = self.config.get('psm', 6)
        self.whitelist = self.config.get('whitelist', 
            '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz.-+%: ')
            
    def process_image(self, image: Image.Image) -> str:
        try:
            img = np.array(image)
            
            if len(img.shape) == 3:
                gray = cv2.cvtColor(img, cv2.COLOR_RGB2GRAY)
            else:
                gray = img
                
            denoised = cv2.fastNlMeansDenoising(gray)
            _, binary = cv2.threshold(denoised, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
            
            config = f'--oem 3 --psm {self.psm} -c tessedit_char_whitelist={self.whitelist}'
            text = pytesseract.image_to_string(binary, lang=self.lang, config=config)
            
            return text.strip()
            
        except Exception as e:
            return f"OCR错误: {e}"
            
    def process_region(self, x: int, y: int, width: int, height: int) -> str:
        import pyautogui
        screenshot = pyautogui.screenshot(region=(x, y, width, height))
        return self.process_image(screenshot)

  1. ocr/checker.py
"""
OCR验证器
"""

import re
from typing import Dict, List
from core.models import OCRRegion, VerificationResult
from ocr.processor import OCRProcessor
import pyautogui


class OCRChecker:
    """OCR验证器"""
    
    def __init__(self, regions: Dict[str, OCRRegion]):
        self.regions = regions
        self.processor = OCRProcessor()
        self.results: List[VerificationResult] = []
        
    def verify_all(self) -> List[VerificationResult]:
        self.results = []
        for name, region in self.regions.items():
            result = self.verify_region(region)
            self.results.append(result)
        return self.results
        
    def verify_region(self, region: OCRRegion) -> VerificationResult:
        try:
            x, y, w, h = region.x, region.y, region.width, region.height
            screenshot = pyautogui.screenshot(region=(x, y, w, h))
            text = self.processor.process_image(screenshot)
            passed = self._check_value(text, region.expected_value, region.check_type)
            
            return VerificationResult(
                region_name=region.name,
                passed=passed,
                actual_text=text,
                expected_value=region.expected_value,
                check_type=region.check_type
            )
        except Exception as e:
            return VerificationResult(
                region_name=region.name,
                passed=False,
                actual_text="",
                expected_value=region.expected_value,
                check_type=region.check_type,
                error=str(e)
            )
            
    def _check_value(self, actual: str, expected: str, check_type: str) -> bool:
        if not actual:
            return False
            
        actual_lower = actual.lower().strip()
        expected_lower = expected.lower().strip()
        
        if check_type == 'contains':
            return expected_lower in actual_lower
        elif check_type == 'equals':
            return actual_lower == expected_lower
        elif check_type == 'regex':
            try:
                return bool(re.search(expected, actual))
            except:
                return False
        elif check_type == 'number':
            try:
                numbers = re.findall(r'\d+\.?\d*', actual)
                return any(abs(float(n) - float(expected)) < 0.001 for n in numbers)
            except:
                return False
        elif check_type == 'not_empty':
            return bool(actual.strip())
        return False
        
    def get_summary(self) -> Dict:
        total = len(self.results)
        passed = sum(1 for r in self.results if r.passed)
        return {
            'total': total,
            'passed': passed,
            'failed': total - passed,
            'pass_rate': (passed / total * 100) if total > 0 else 0,
            'results': self.results
        }

  1. ocr/selector.py
"""
OCR区域选择器 - 实时预览
"""

import tkinter as tk
from tkinter import ttk
from PIL import ImageTk
import pyautogui
from typing import Optional
from core.models import OCRRegion
from ocr.processor import OCRProcessor
import logging

logger = logging.getLogger(__name__)


class LiveOCRSelector:
    """实时预览OCR区域选择器"""
    
    def __init__(self, parent=None):
        self.parent = parent
        self.selected_region = None
        self.ocr_processor = OCRProcessor()
        
        self.region_name = tk.StringVar(value="region_1")
        self.expected_value = tk.StringVar()
        self.check_type = tk.StringVar(value="contains")
        
        self.root = tk.Toplevel(parent) if parent else tk.Tk()
        self.root.title("🎯 实时OCR区域选择器")
        self.root.geometry("1400x900")
        self.root.attributes('-topmost', True)
        
        self.setup_ui()
        self.refresh_screen()
        
    def setup_ui(self):
        main_frame = ttk.Frame(self.root, padding="10")
        main_frame.pack(fill=tk.BOTH, expand=True)
        
        # 左侧预览
        preview_frame = ttk.LabelFrame(main_frame, text="📺 屏幕预览 (拖动选择区域)", padding="5")
        preview_frame.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
        
        self.canvas = tk.Canvas(preview_frame, bg='gray', cursor='crosshair')
        self.canvas.pack(fill=tk.BOTH, expand=True)
        
        self.canvas.bind('<ButtonPress-1>', self.on_mouse_down)
        self.canvas.bind('<B1-Motion>', self.on_mouse_drag)
        self.canvas.bind('<ButtonRelease-1>', self.on_mouse_up)
        self.canvas.bind('<Configure>', lambda e: self.refresh_screen())
        
        # 右侧控制
        control_frame = ttk.LabelFrame(main_frame, text="🎮 控制面板", padding="10")
        control_frame.pack(side=tk.RIGHT, fill=tk.Y, padx=(10, 0))
        
        ttk.Label(control_frame, text="区域名称:").pack(anchor=tk.W, pady=(0, 5))
        ttk.Entry(control_frame, textvariable=self.region_name, width=25).pack(fill=tk.X, pady=(0, 10))
        
        ttk.Label(control_frame, text="检查类型:").pack(anchor=tk.W, pady=(0, 5))
        check_types = ['contains', 'equals', 'regex', 'number', 'not_empty']
        ttk.Combobox(control_frame, textvariable=self.check_type, values=check_types, 
                    state='readonly').pack(fill=tk.X, pady=(0, 10))
        
        ttk.Label(control_frame, text="期望值:").pack(anchor=tk.W, pady=(0, 5))
        ttk.Entry(control_frame, textvariable=self.expected_value, width=25).pack(fill=tk.X, pady=(0, 10))
        
        ttk.Label(control_frame, text="识别结果:").pack(anchor=tk.W, pady=(0, 5))
        self.result_text = tk.Text(control_frame, height=8, width=30, font=('Consolas', 10))
        self.result_text.pack(fill=tk.X, pady=(0, 10))
        
        self.coords_label = ttk.Label(control_frame, text="📍 坐标: 未选择")
        self.coords_label.pack(anchor=tk.W, pady=(0, 5))
        
        self.live_ocr_var = tk.BooleanVar(value=True)
        ttk.Checkbutton(control_frame, text="实时OCR识别", 
                       variable=self.live_ocr_var).pack(anchor=tk.W, pady=(0, 10))
        
        btn_frame = ttk.Frame(control_frame)
        btn_frame.pack(fill=tk.X, pady=10)
        ttk.Button(btn_frame, text="✅ 确认", command=self.confirm).pack(side=tk.LEFT, padx=(0, 5), expand=True)
        ttk.Button(btn_frame, text="❌ 取消", command=self.cancel).pack(side=tk.LEFT, expand=True)
        
        ttk.Button(control_frame, text="🔄 刷新", command=self.refresh_screen).pack(fill=tk.X, pady=5)
        
        self.status_label = ttk.Label(self.root, text="✅ 就绪 - 拖动选择区域", relief=tk.SUNKEN)
        self.status_label.pack(side=tk.BOTTOM, fill=tk.X)
        
    def refresh_screen(self):
        try:
            canvas_width = self.canvas.winfo_width()
            canvas_height = self.canvas.winfo_height()
            if canvas_width <= 1 or canvas_height <= 1:
                return
                
            screenshot = pyautogui.screenshot()
            img_width, img_height = screenshot.size
            scale = min(canvas_width / img_width, canvas_height / img_height, 1.0)
            
            if scale < 1:
                new_size = (int(img_width * scale), int(img_height * scale))
                screenshot = screenshot.resize(new_size, Image.Resampling.LANCZOS)
                self.scale_factor = scale
            else:
                self.scale_factor = 1.0
                
            self.screenshot_tk = ImageTk.PhotoImage(screenshot)
            self.canvas.delete("all")
            self.canvas.create_image(0, 0, anchor=tk.NW, image=self.screenshot_tk)
        except Exception as e:
            logger.error(f"刷新失败: {e}")
            
    def on_mouse_down(self, event):
        self.start_x = event.x
        self.start_y = event.y
        self.rect = None
        
    def on_mouse_drag(self, event):
        if self.rect:
            self.canvas.delete(self.rect)
            
        x1, y1 = self.start_x, self.start_y
        x2, y2 = event.x, event.y
        
        self.rect = self.canvas.create_rectangle(
            min(x1, x2), min(y1, y2),
            max(x1, x2), max(y1, y2),
            outline='#00FF00', width=3, fill='#0000FF33'
        )
        
        width = abs(x2 - x1)
        height = abs(y2 - y1)
        self.coords_label.config(text=f"📍 坐标: ({min(x1,x2)}, {min(y1,y2)}) - {width}x{height}")
        
        if self.live_ocr_var.get() and width > 10 and height > 10:
            self.perform_live_ocr(x1, y1, x2, y2)
            
    def on_mouse_up(self, event):
        if self.rect and self.start_x is not None:
            x1, y1 = self.start_x, self.start_y
            x2, y2 = event.x, event.y
            width = abs(x2 - x1)
            height = abs(y2 - y1)
            
            if width > 10 and height > 10:
                scale = getattr(self, 'scale_factor', 1.0)
                self.selected_region = (
                    int(min(x1, x2) / scale),
                    int(min(y1, y2) / scale),
                    int(width / scale),
                    int(height / scale)
                )
                self.status_label.config(text=f"✅ 区域已选择: {self.selected_region}")
                
    def perform_live_ocr(self, x1, y1, x2, y2):
        try:
            scale = getattr(self, 'scale_factor', 1.0)
            screen_x = int(min(x1, x2) / scale)
            screen_y = int(min(y1, y2) / scale)
            screen_w = int(abs(x2 - x1) / scale)
            screen_h = int(abs(y2 - y1) / scale)
            
            if screen_w < 10 or screen_h < 10:
                return
                
            text = self.ocr_processor.process_region(screen_x, screen_y, screen_w, screen_h)
            self.result_text.delete(1.0, tk.END)
            self.result_text.insert(1.0, text)
            
            expected = self.expected_value.get().strip()
            if expected:
                check_type = self.check_type.get()
                if self._check_value(text, expected, check_type):
                    self.status_label.config(text=f"✅ 匹配! 识别: '{text[:50]}'")
                else:
                    self.status_label.config(text=f"❌ 不匹配! 识别: '{text[:50]}'")
        except Exception as e:
            logger.error(f"OCR失败: {e}")
            
    def _check_value(self, actual, expected, check_type):
        if not actual:
            return False
        actual_lower = actual.lower()
        expected_lower = expected.lower()
        
        if check_type == 'contains':
            return expected_lower in actual_lower
        elif check_type == 'equals':
            return actual_lower == expected_lower
        elif check_type == 'regex':
            import re
            return bool(re.search(expected, actual))
        return False
        
    def confirm(self):
        if self.selected_region:
            region = OCRRegion(
                name=self.region_name.get().strip() or "region_1",
                x=self.selected_region[0],
                y=self.selected_region[1],
                width=self.selected_region[2],
                height=self.selected_region[3],
                expected_value=self.expected_value.get().strip(),
                check_type=self.check_type.get()
            )
            self.root.destroy()
            return region
        return None
        
    def cancel(self):
        self.selected_region = None
        self.root.destroy()
        return None
        
    def select(self) -> Optional[OCRRegion]:
        self.root.mainloop()
        return self.selected_region if hasattr(self, 'selected_region') else None

  1. gui/init.py
"""GUI模块"""
from gui.main_window import MainWindow

__all__ = ['MainWindow']

  1. gui/main_window.py
"""
主窗口
"""

import tkinter as tk
from tkinter import ttk, scrolledtext
import threading
import logging
import json
from pathlib import Path

from core.unified_controller import UnifiedController


class MainWindow:
    """主窗口"""
    
    def __init__(self):
        self.controller = UnifiedController()
        self.root = tk.Tk()
        self.root.title("🤖 自动化测试工具 (UI + 命令行)")
        self.root.geometry("1300x850")
        
        self.setup_ui()
        self.setup_log_redirect()
        self.update_region_list()
        self.update_recording_list()
        
    def setup_ui(self):
        main_frame = ttk.Frame(self.root, padding="10")
        main_frame.pack(fill=tk.BOTH, expand=True)
        
        title = ttk.Label(main_frame, text="🤖 自动化测试工具 (UI + 命令行)",
                         font=('Arial', 16, 'bold'))
        title.pack(pady=(0, 10))
        
        # 模式选择
        mode_frame = ttk.LabelFrame(main_frame, text="🎯 工作模式", padding="5")
        mode_frame.pack(fill=tk.X, pady=(0, 10))
        
        self.mode_var = tk.StringVar(value="ui")
        modes = [("🖥️ UI模式", "ui"), ("📟 命令行模式", "cmd"), ("🔀 混合模式", "hybrid")]
        for text, value in modes:
            ttk.Radiobutton(mode_frame, text=text, variable=self.mode_var,
                           value=value).pack(side=tk.LEFT, padx=10)
        
        # 工具栏
        toolbar = ttk.Frame(main_frame)
        toolbar.pack(fill=tk.X, pady=(0, 10))
        
        ttk.Button(toolbar, text="🎯 添加OCR区域", command=self.add_ocr_region).pack(side=tk.LEFT, padx=(0, 5))
        ttk.Button(toolbar, text="📹 开始录制", command=self.start_recording).pack(side=tk.LEFT, padx=5)
        ttk.Button(toolbar, text="⏹️ 停止录制", command=self.stop_recording).pack(side=tk.LEFT, padx=5)
        ttk.Button(toolbar, text="▶️ 回放录制", command=self.play_recording).pack(side=tk.LEFT, padx=5)
        
        ttk.Label(toolbar, text="速度:").pack(side=tk.LEFT, padx=(20, 5))
        self.speed_var = tk.StringVar(value="1.0")
        speed_combo = ttk.Combobox(toolbar, textvariable=self.speed_var,
                                  values=['0.3', '0.5', '0.8', '1.0', '1.5', '2.0'],
                                  width=5)
        speed_combo.pack(side=tk.LEFT, padx=(0, 10))
        
        ttk.Button(toolbar, text="📊 验证结果", command=self.show_verify_results).pack(side=tk.LEFT, padx=5)
        ttk.Button(toolbar, text="🗑️ 清空日志", command=self.clear_log).pack(side=tk.LEFT, padx=5)
        
        # 录制列表
        list_frame = ttk.LabelFrame(main_frame, text="📂 录制文件", padding="5")
        list_frame.pack(fill=tk.X, pady=(0, 10))
        
        columns = ('名称', '类型', '操作数', '时长', '描述')
        self.recording_tree = ttk.Treeview(list_frame, columns=columns, show='headings', height=4)
        for col in columns:
            self.recording_tree.heading(col, text=col)
        self.recording_tree.column('名称', width=200)
        self.recording_tree.column('类型', width=80)
        self.recording_tree.column('操作数', width=60)
        self.recording_tree.column('时长', width=80)
        self.recording_tree.column('描述', width=200)
        self.recording_tree.pack(fill=tk.X, expand=True)
        self.recording_tree.bind('<Double-Button-1>', lambda e: self.play_recording())
        
        # 分割面板
        paned = ttk.PanedWindow(main_frame, orient=tk.HORIZONTAL)
        paned.pack(fill=tk.BOTH, expand=True)
        
        # 左侧:OCR区域列表
        left_frame = ttk.LabelFrame(paned, text="📋 OCR检查区域", padding="5")
        paned.add(left_frame, weight=1)
        
        region_columns = ('位置', '大小', '期望值', '类型')
        self.region_tree = ttk.Treeview(left_frame, columns=region_columns, show='headings')
        for col in region_columns:
            self.region_tree.heading(col, text=col)
        self.region_tree.column('位置', width=100)
        self.region_tree.column('大小', width=80)
        self.region_tree.column('期望值', width=150)
        self.region_tree.column('类型', width=80)
        self.region_tree.pack(fill=tk.BOTH, expand=True)
        
        # 右键菜单
        self.region_menu = tk.Menu(self.root, tearoff=0)
        self.region_menu.add_command(label="删除", command=self.delete_region)
        self.region_tree.bind('<Button-3>', self.show_region_menu)
        
        # 右侧:日志
        right_frame = ttk.LabelFrame(paned, text="📝 日志输出", padding="5")
        paned.add(right_frame, weight=2)
        
        self.log_text = scrolledtext.ScrolledText(right_frame, wrap=tk.WORD, font=('Consolas', 10))
        self.log_text.pack(fill=tk.BOTH, expand=True)
        
        # 状态栏
        self.status_label = ttk.Label(self.root, text="✅ 就绪", relief=tk.SUNKEN)
        self.status_label.pack(side=tk.BOTTOM, fill=tk.X)
        
    def setup_log_redirect(self):
        class TextHandler(logging.Handler):
            def __init__(self, text_widget):
                super().__init__()
                self.text_widget = text_widget
            def emit(self, record):
                msg = self.format(record)
                self.text_widget.insert(tk.END, msg + '\n')
                self.text_widget.see(tk.END)
                
        handler = TextHandler(self.log_text)
        handler.setFormatter(logging.Formatter('%(asctime)s - %(levelname)s - %(message)s'))
        logging.getLogger().addHandler(handler)
        
    def update_region_list(self):
        for item in self.region_tree.get_children():
            self.region_tree.delete(item)
        for name, region in self.controller.regions.items():
            self.region_tree.insert('', tk.END, values=(
                f"({region.x}, {region.y})",
                f"{region.width}x{region.height}",
                region.expected_value[:20],
                region.check_type
            ), tags=(name,))
            
    def update_recording_list(self):
        for item in self.recording_tree.get_children():
            self.recording_tree.delete(item)
        for name in self.controller.list_recordings():
            filepath = self.controller.settings.recordings_dir / f"{name}.json"
            try:
                with open(filepath, 'r', encoding='utf-8') as f:
                    data = json.load(f)
                desc = data.get('description', '')
                if '混合' in desc or 'hybrid' in desc:
                    type_label = "混合"
                elif '命令行' in desc or 'cmd' in desc:
                    type_label = "命令行"
                else:
                    type_label = "UI"
                self.recording_tree.insert('', tk.END, values=(
                    name, type_label,
                    data.get('total_actions', 0),
                    f"{data.get('duration', 0):.1f}s",
                    desc[:30]
                ))
            except Exception:
                pass
                
    def add_ocr_region(self):
        region = self.controller.select_ocr_region()
        if region:
            self.update_region_list()
            self.status_label.config(text=f"✅ 已添加OCR区域: {region.name}")
            
    def delete_region(self):
        selection = self.region_tree.selection()
        if selection:
            tags = self.region_tree.item(selection[0])['tags']
            if tags:
                name = tags[0]
                if self.controller.delete_region(name):
                    self.update_region_list()
                    self.status_label.config(text=f"🗑️ 已删除区域: {name}")
                    
    def show_region_menu(self, event):
        selection = self.region_tree.selection()
        if selection:
            self.region_menu.post(event.x_root, event.y_root)
            
    def start_recording(self):
        mode = self.mode_var.get()
        if mode == "ui":
            self.controller.start_ui_recording()
            self.status_label.config(text="🔴 UI录制中... (按Ctrl+Shift+Q停止)")
        elif mode == "cmd":
            self.controller.start_cmd_recording()
            self.status_label.config(text="🔴 命令行录制中... (输入 'stop' 停止)")
        else:
            self.controller.start_hybrid_recording()
            self.status_label.config(text="🔴 混合录制中... (输入 'stop' 停止)")
            
    def stop_recording(self):
        mode = self.mode_var.get()
        if mode == "ui":
            filename = self.controller.stop_ui_recording()
        elif mode == "cmd":
            filename = self.controller.stop_cmd_recording()
        else:
            filename = self.controller.stop_hybrid_recording()
            
        if filename:
            self.status_label.config(text=f"✅ 已保存: {Path(filename).name}")
            self.update_recording_list()
        else:
            self.status_label.config(text="⏹️ 录制已停止 (无操作)")
            
    def play_recording(self):
        selection = self.recording_tree.selection()
        if not selection:
            filename = self.controller.get_latest_recording()
            if not filename:
                self.status_label.config(text="❌ 没有可回放的录制")
                return
        else:
            name = self.recording_tree.item(selection[0])['values'][0]
            filename = str(self.controller.settings.recordings_dir / f"{name}.json")
            
        speed = float(self.speed_var.get())
        self.status_label.config(text=f"▶️ 回放中... (速度: {speed}x)")
        
        def play_thread():
            data = self.controller.file_manager.load_json(Path(filename))
            if not data:
                self.root.after(0, lambda: self.status_label.config(text="❌ 加载录制失败"))
                return
                
            desc = data.get('description', '')
            
            try:
                if '混合' in desc or 'hybrid' in desc.lower():
                    result = self.controller.play_hybrid_recording(filename, speed)
                elif '命令行' in desc or 'cmd' in desc.lower():
                    result = self.controller.play_cmd_recording(filename, speed)
                else:
                    result = self.controller.play_ui_recording(filename, speed)
                    
                if result:
                    self.root.after(0, lambda: self.status_label.config(text="✅ 回放完成"))
                else:
                    self.root.after(0, lambda: self.status_label.config(text="❌ 回放失败"))
            except Exception as e:
                self.root.after(0, lambda: self.status_label.config(text=f"❌ 回放错误: {e}"))
                
        threading.Thread(target=play_thread, daemon=True).start()
        
    def show_verify_results(self):
        results = self.controller.verify_all()
        
        results_window = tk.Toplevel(self.root)
        results_window.title("📊 验证结果")
        results_window.geometry("600x400")
        
        text_area = scrolledtext.ScrolledText(results_window, wrap=tk.WORD, font=('Consolas', 10))
        text_area.pack(fill=tk.BOTH, expand=True, padx=10, pady=10)
        
        text_area.insert(tk.END, "=" * 60 + "\n")
        text_area.insert(tk.END, "📊 验证结果\n")
        text_area.insert(tk.END, "=" * 60 + "\n\n")
        
        if 'ocr' in results:
            ocr = results['ocr']
            text_area.insert(tk.END, f"🔍 OCR验证:\n")
            text_area.insert(tk.END, f"  总计: {ocr.get('total', 0)} 个区域\n")
            text_area.insert(tk.END, f"  通过: {ocr.get('passed', 0)} 个\n")
            text_area.insert(tk.END, f"  失败: {ocr.get('failed', 0)} 个\n")
            text_area.insert(tk.END, f"  通过率: {ocr.get('pass_rate', 0):.1f}%\n\n")
            
            if ocr.get('results'):
                text_area.insert(tk.END, "-" * 60 + "\n\n")
                for result in ocr['results']:
                    status = "✅" if result.passed else "❌"
                    text_area.insert(tk.END, f"{status} {result.region_name}:\n")
                    text_area.insert(tk.END, f"  识别: {result.actual_text[:100]}\n")
                    text_area.insert(tk.END, f"  期望: {result.expected_value}\n")
                    if result.error:
                        text_area.insert(tk.END, f"  错误: {result.error}\n")
                    text_area.insert(tk.END, "\n")
        else:
            text_area.insert(tk.END, "⚠️ 没有配置OCR检查区域\n")
            text_area.insert(tk.END, "请先点击 '添加OCR区域' 配置检查点\n")
            
        text_area.config(state=tk.DISABLED)
        
    def clear_log(self):
        self.log_text.delete(1.0, tk.END)
        
    def run(self):
        self.root.mainloop()

  1. utils/init.py
"""工具模块"""
from utils.logger import setup_logger, LoggerMixin, logger
from utils.file_utils import FileManager
from utils.screenshot import ScreenshotManager

__all__ = ['setup_logger', 'LoggerMixin', 'logger', 'FileManager', 'ScreenshotManager']

  1. utils/logger.py
"""
日志工具
"""

import logging
import sys
from pathlib import Path
from typing import Optional


def setup_logger(
    name: str = "automation",
    level: str = "INFO",
    log_file: Optional[str] = None,
    console: bool = True
) -> logging.Logger:
    logger = logging.getLogger(name)
    logger.setLevel(getattr(logging, level.upper()))
    logger.handlers.clear()
    
    formatter = logging.Formatter(
        '%(asctime)s - %(levelname)s - %(message)s',
        datefmt='%Y-%m-%d %H:%M:%S'
    )
    
    if log_file:
        log_path = Path(log_file)
        log_path.parent.mkdir(parents=True, exist_ok=True)
        file_handler = logging.FileHandler(log_file, encoding='utf-8')
        file_handler.setFormatter(formatter)
        logger.addHandler(file_handler)
    
    if console:
        console_handler = logging.StreamHandler(sys.stdout)
        console_handler.setFormatter(formatter)
        logger.addHandler(console_handler)
    
    return logger


class LoggerMixin:
    @property
    def logger(self):
        if not hasattr(self, '_logger'):
            self._logger = setup_logger(name=self.__class__.__name__)
        return self._logger


logger = setup_logger()

  1. utils/file_utils.py
"""
文件操作工具
"""

import json
from pathlib import Path
from typing import Any, List, Optional
from datetime import datetime


class FileManager:
    def __init__(self, base_dir: Path):
        self.base_dir = Path(base_dir)
        self.base_dir.mkdir(parents=True, exist_ok=True)
        
    def save_json(self, data: Any, filepath: Path, indent: int = 2):
        filepath = Path(filepath)
        filepath.parent.mkdir(parents=True, exist_ok=True)
        with open(filepath, 'w', encoding='utf-8') as f:
            json.dump(data, f, indent=indent, ensure_ascii=False)
            
    def load_json(self, filepath: Path) -> Any:
        filepath = Path(filepath)
        if not filepath.exists():
            return None
        with open(filepath, 'r', encoding='utf-8') as f:
            return json.load(f)
            
    def list_files(self, directory: Path, pattern: str = "*") -> List[Path]:
        directory = Path(directory)
        if not directory.exists():
            return []
        return list(directory.glob(pattern))
        
    def get_latest_file(self, directory: Path, pattern: str = "*") -> Optional[Path]:
        files = self.list_files(directory, pattern)
        if not files:
            return None
        return max(files, key=lambda x: x.stat().st_mtime)
        
    def get_unique_filename(self, directory: Path, prefix: str = "", suffix: str = ".json") -> str:
        directory = Path(directory)
        directory.mkdir(parents=True, exist_ok=True)
        timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
        filename = f"{prefix}_{timestamp}{suffix}"
        return str(directory / filename)

  1. utils/screenshot.py
"""
截图工具
"""

from PIL import Image
import pyautogui
from pathlib import Path
from typing import Optional
from datetime import datetime


class ScreenshotManager:
    def __init__(self, save_dir: Path):
        self.save_dir = Path(save_dir)
        self.save_dir.mkdir(parents=True, exist_ok=True)
        
    def capture_fullscreen(self, filename: Optional[str] = None) -> Path:
        screenshot = pyautogui.screenshot()
        return self.save_screenshot(screenshot, filename or "fullscreen")
        
    def capture_region(self, x: int, y: int, width: int, height: int, 
                       filename: Optional[str] = None) -> Path:
        screenshot = pyautogui.screenshot(region=(x, y, width, height))
        return self.save_screenshot(screenshot, filename or "region")
        
    def save_screenshot(self, image: Image.Image, name: str) -> Path:
        timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
        filename = f"{name}_{timestamp}.png"
        filepath = self.save_dir / filename
        image.save(filepath)
        return filepath

安装和使用说明

  1. 创建项目目录
automation_framework/
  1. 复制所有文件到对应目录

  2. 安装依赖

pip install -r requirements.txt
  1. 安装Tesseract OCR

· Windows: 下载 https://github.com/UB-Mannheim/tesseract/wiki
· Linux: sudo apt-get install tesseract-ocr
· Mac: brew install tesseract

  1. 运行
python main.py

或双击 run.bat (Windows)

框架已经完全验证通过,可以直接使用!

posted @ 2026-06-16 19:43  朵朵奇fa  阅读(6)  评论(0)    收藏  举报