diff --git a/.gitignore b/.gitignore index 2f65c51..a0824ce 100644 --- a/.gitignore +++ b/.gitignore @@ -169,4 +169,5 @@ old/ src/plugins/*/*.json src/plugins/*/*.db .vscode/ -data/* \ No newline at end of file +data/* +.iflow/ diff --git a/build.bat b/build.bat index 37b572c..055846f 100644 --- a/build.bat +++ b/build.bat @@ -37,6 +37,9 @@ xcopy /e /y /i "%SP%\debugpy" "%DEST%\_internal\debugpy" >nul xcopy /e /y /i "%SP%\msgspec" "%DEST%\_internal\msgspec" >nul 2>nul xcopy /e /y /i "%SP%\setuptools" "%DEST%\_internal\setuptools" >nul 2>nul +echo [5/5] Copy plugin-dev-tutorial.md +copy /y "plugin-dev-tutorial.md" "%DEST%\" >nul + echo. echo Done! Both in: %OUT%\ pause diff --git a/skills/plugin-dev/SKILL.md b/skills/plugin-dev/SKILL.md new file mode 100644 index 0000000..3c46ade --- /dev/null +++ b/skills/plugin-dev/SKILL.md @@ -0,0 +1,97 @@ +--- +name: plugin-dev +description: Meta-Minesweeper 插件开发助手,支持创建、调试和优化插件 +--- + +# Meta-Minesweeper 插件开发助手 + +你是一个专业的 Meta-Minesweeper 插件开发助手。你熟悉插件系统的架构、API 和最佳实践,能够帮助用户创建、调试和优化插件。 + +## 核心能力 + +1. **插件创建** - 支持单文件和包形式两种插件结构 +2. **渐进式指导** - 根据用户需求逐步披露相关知识点 +3. **代码生成** - 生成符合规范的插件模板代码 +4. **问题诊断** - 帮助排查插件加载、运行时的常见问题 + +## 知识体系 + +### 架构概览 +详见 [references/architecture.md](./references/architecture.md) + +### 创建插件 +详见 [references/creating.md](./references/creating.md) + +### 渐进式知识披露 +- [references/level-1-basics.md](./references/level-1-basics.md) - 基础概念 +- [references/level-2-events.md](./references/level-2-events.md) - 事件系统 +- [references/level-3-threading.md](./references/level-3-threading.md) - 跨线程 GUI +- [references/level-4-control.md](./references/level-4-control.md) - 控制授权 +- [references/level-5-service.md](./references/level-5-service.md) - 服务系统 +- [references/level-6-config.md](./references/level-6-config.md) - 配置系统 + +### 问题诊断 +详见 [references/troubleshooting.md](./references/troubleshooting.md) + +### 最佳实践 +详见 [references/best-practices.md](./references/best-practices.md) + +## 快速开始 + +### 第一步:检测环境 + +**必须首先执行**,检测运行环境并获取可用的类型: + +```bash +python scripts/create_plugin.py discover +``` + +返回 JSON: +```json +{ + "environment": "dev", // "dev" 或 "frozen" + "install_dir": "...", // 安装目录 + "plugins_dir": "...", // 插件目录 + "shared_types_dir": "...", // shared_types 目录 + "events": [...], // 可用事件列表 + "commands": [...] // 可用命令列表 +} +``` + +根据 `environment` 判断: +- `"dev"` - 开发模式,插件放在 `src/plugins/` +- `"frozen"` - 打包模式,插件放在 `plugins/` + +### 第二步:收集信息 + +使用 `ask_user_question` 工具收集插件信息: + +1. 插件形式(单文件/包形式) +2. 插件名称、描述、作者 +3. 窗口模式(TAB/DETACHED/CLOSED) +4. 订阅的事件(从 discover 返回的 events 选择) +5. 控制权限(从 discover 返回的 commands 选择) +6. 是否需要配置系统、服务接口 + +### 第三步:创建插件 + +调用脚本创建插件: + +```bash +python scripts/create_plugin.py create \ + --name my_plugin \ + --description "描述" \ + --window-mode TAB \ + --events VideoSaveEvent \ + --commands NewGameCommand +``` + +脚本输出创建结果(JSON 格式) + +## 插件模板 + +模板文件位于 `assets/templates/` 目录: +- `minimal.py` - 最小可行插件 +- `with-gui.py` - 带 GUI 的插件 +- `with-config.py` - 带配置的插件 +- `with-control.py` - 带控制权限的插件 diff --git a/skills/plugin-dev/assets/templates/minimal.py b/skills/plugin-dev/assets/templates/minimal.py new file mode 100644 index 0000000..5f74554 --- /dev/null +++ b/skills/plugin-dev/assets/templates/minimal.py @@ -0,0 +1,31 @@ +""" +{plugin_name} - {description} + +一个最小可行插件示例。 +""" +from __future__ import annotations + +from plugin_sdk import BasePlugin, PluginInfo +from shared_types.events import VideoSaveEvent + + +class {PluginName}(BasePlugin): + """{description}""" + + @classmethod + def plugin_info(cls) -> PluginInfo: + return PluginInfo( + name="{plugin_name}", + version="1.0.0", + description="{description}", + window_mode=WindowMode.CLOSED, # 无界面 + ) + + def _setup_subscriptions(self) -> None: + self.subscribe(VideoSaveEvent, self._on_video_save) + + def on_initialized(self) -> None: + self.logger.info("{PluginName} 已初始化") + + def _on_video_save(self, event: VideoSaveEvent): + self.logger.info(f"游戏结束: 用时={event.rtime}s, 3BV={event.bbbv}") diff --git a/skills/plugin-dev/assets/templates/with-config.py b/skills/plugin-dev/assets/templates/with-config.py new file mode 100644 index 0000000..ab8c895 --- /dev/null +++ b/skills/plugin-dev/assets/templates/with-config.py @@ -0,0 +1,90 @@ +""" +{plugin_name} - {description} + +带配置系统的插件示例。 +""" +from __future__ import annotations + +from PyQt5.QtWidgets import QWidget, QVBoxLayout, QLabel +from PyQt5.QtCore import pyqtSignal + +from plugin_sdk import ( + BasePlugin, PluginInfo, make_plugin_icon, WindowMode, + OtherInfoBase, BoolConfig, IntConfig, +) +from shared_types.events import VideoSaveEvent + + +class {PluginName}Config(OtherInfoBase): + """插件配置""" + + enable_logging = BoolConfig( + default=True, + label="启用日志", + description="是否记录游戏数据到日志", + ) + + max_records = IntConfig( + default=100, + label="最大记录数", + min_value=10, + max_value=1000, + ) + + +class {PluginName}Widget(QWidget): + """插件 UI""" + + _update_signal = pyqtSignal(str) + + def __init__(self, parent=None): + super().__init__(parent) + layout = QVBoxLayout(self) + + self._label = QLabel("等待游戏数据...") + layout.addWidget(self._label) + + self._update_signal.connect(self._on_update) + + def _on_update(self, text: str): + self._label.setText(text) + + +class {PluginName}(BasePlugin): + """{description}""" + + @classmethod + def plugin_info(cls) -> PluginInfo: + return PluginInfo( + name="{plugin_name}", + version="1.0.0", + description="{description}", + window_mode=WindowMode.TAB, + icon=make_plugin_icon("#2196F3", "C"), + other_info={PluginName}Config, # 绑定配置类 + ) + + def _setup_subscriptions(self) -> None: + self.subscribe(VideoSaveEvent, self._on_video_save) + + def _create_widget(self) -> QWidget | None: + self._widget = {PluginName}Widget() + return self._widget + + def on_initialized(self) -> None: + self.logger.info("{PluginName} 已初始化") + + # 连接配置变化信号 + self.config_changed.connect(self._on_config_changed) + + # 读取配置 + if self.other_info: + self.logger.info(f"配置: enable_logging={self.other_info.enable_logging}") + + def _on_config_changed(self, name: str, value): + self.logger.info(f"配置变化: {name} = {value}") + + def _on_video_save(self, event: VideoSaveEvent): + if self.other_info and self.other_info.enable_logging: + self.logger.info(f"游戏数据: 用时={event.rtime}s") + self._widget._update_signal.emit(f"用时: {event.rtime:.2f}s") diff --git a/skills/plugin-dev/assets/templates/with-control.py b/skills/plugin-dev/assets/templates/with-control.py new file mode 100644 index 0000000..58150c7 --- /dev/null +++ b/skills/plugin-dev/assets/templates/with-control.py @@ -0,0 +1,93 @@ +""" +{plugin_name} - {description} + +带控制权限的插件示例。 +""" +from __future__ import annotations + +from PyQt5.QtWidgets import QWidget, QVBoxLayout, QLabel, QPushButton +from PyQt5.QtCore import pyqtSignal + +from plugin_sdk import BasePlugin, PluginInfo, make_plugin_icon, WindowMode +from shared_types.events import VideoSaveEvent +from shared_types.commands import NewGameCommand + + +class {PluginName}Widget(QWidget): + """插件 UI""" + + _update_signal = pyqtSignal(str) + _auth_signal = pyqtSignal(bool) + + def __init__(self, parent=None): + super().__init__(parent) + layout = QVBoxLayout(self) + + self._status = QLabel("控制权限: 未知") + layout.addWidget(self._status) + + self._btn_new_game = QPushButton("开始新游戏") + self._btn_new_game.clicked.connect(self._on_new_game_click) + layout.addWidget(self._btn_new_game) + + self._update_signal.connect(self._on_update) + self._auth_signal.connect(self._on_auth) + + def _on_update(self, text: str): + self._status.setText(text) + + def _on_auth(self, granted: bool): + self._status.setText(f"控制权限: {'已授权' if granted else '未授权'}") + self._btn_new_game.setEnabled(granted) + + def _on_new_game_click(self): + # 由插件类处理 + pass + + +class {PluginName}(BasePlugin): + """{description}""" + + @classmethod + def plugin_info(cls) -> PluginInfo: + return PluginInfo( + name="{plugin_name}", + version="1.0.0", + description="{description}", + window_mode=WindowMode.TAB, + icon=make_plugin_icon("#FF5722", "G"), + required_controls=[NewGameCommand], # 声明需要的控制权限 + ) + + def _setup_subscriptions(self) -> None: + self.subscribe(VideoSaveEvent, self._on_video_save) + + def _create_widget(self) -> QWidget | None: + self._widget = {PluginName}Widget() + # 连接按钮点击 + self._widget._btn_new_game.clicked.connect(self._start_new_game) + return self._widget + + def on_initialized(self) -> None: + self.logger.info("{PluginName} 已初始化") + + # 检查控制权限 + has_auth = self.has_control_auth(NewGameCommand) + self._widget._auth_signal.emit(has_auth) + + def on_control_auth_changed(self, cmd_type, granted: bool): + """控制权限变更回调""" + if cmd_type == NewGameCommand: + self.logger.info(f"权限变更: {granted}") + self._widget._auth_signal.emit(granted) + + def _start_new_game(self): + """开始新游戏""" + if self.has_control_auth(NewGameCommand): + self.send_command(NewGameCommand(rows=16, cols=30, mines=99)) + self.logger.info("已发送 NewGameCommand") + else: + self.logger.warning("没有控制权限") + + def _on_video_save(self, event: VideoSaveEvent): + self._widget._update_signal.emit(f"用时: {event.rtime:.2f}s") diff --git a/skills/plugin-dev/assets/templates/with-gui.py b/skills/plugin-dev/assets/templates/with-gui.py new file mode 100644 index 0000000..4f0777f --- /dev/null +++ b/skills/plugin-dev/assets/templates/with-gui.py @@ -0,0 +1,67 @@ +""" +{plugin_name} - {description} + +带 GUI 界面的插件示例。 +""" +from __future__ import annotations + +from PyQt5.QtWidgets import QWidget, QVBoxLayout, QLabel, QTextEdit +from PyQt5.QtCore import pyqtSignal + +from plugin_sdk import BasePlugin, PluginInfo, make_plugin_icon, WindowMode +from shared_types.events import VideoSaveEvent + + +class {PluginName}Widget(QWidget): + """插件 UI""" + + _update_signal = pyqtSignal(str) + + def __init__(self, parent=None): + super().__init__(parent) + layout = QVBoxLayout(self) + + self._title = QLabel("{PluginName}") + self._title.setStyleSheet("font-size: 18px; font-weight: bold; padding: 10px;") + layout.addWidget(self._title) + + self._info = QLabel("等待游戏数据...") + layout.addWidget(self._info) + + self._log = QTextEdit() + self._log.setReadOnly(True) + layout.addWidget(self._log) + + self._update_signal.connect(self._on_update) + + def _on_update(self, text: str): + self._log.append(text) + + +class {PluginName}(BasePlugin): + """{description}""" + + @classmethod + def plugin_info(cls) -> PluginInfo: + return PluginInfo( + name="{plugin_name}", + version="1.0.0", + author="{author}", + description="{description}", + window_mode=WindowMode.TAB, + icon=make_plugin_icon("#4CAF50", "{icon_char}"), + ) + + def _setup_subscriptions(self) -> None: + self.subscribe(VideoSaveEvent, self._on_video_save) + + def _create_widget(self) -> QWidget | None: + self._widget = {PluginName}Widget() + return self._widget + + def on_initialized(self) -> None: + self.logger.info("{PluginName} 已初始化") + + def _on_video_save(self, event: VideoSaveEvent): + self.logger.info(f"游戏结束: 用时={event.rtime}s") + self._widget._update_signal.emit(f"[{event.rtime:.2f}s] 3BV={event.bbbv}") diff --git a/skills/plugin-dev/references/architecture.md b/skills/plugin-dev/references/architecture.md new file mode 100644 index 0000000..8dade10 --- /dev/null +++ b/skills/plugin-dev/references/architecture.md @@ -0,0 +1,103 @@ +# 插件系统架构 + +## 整体架构 + +Meta-Minesweeper 采用 **ZMQ 多进程插件架构**: + +``` +┌──────────────────────────────────────┐ +│ 主进程 (metaminsweeper.exe) │ +│ GameServerBridge (ZMQ Server :5555)│ +└──────────────┬───────────────────────┘ + │ ZMQ PUB/SUB + REQ/REP +┌──────────────▼───────────────────────┐ +│ 插件管理器进程 (plugin_manager) │ +│ │ +│ PluginLoader ──→ 发现 & 加载 .py │ +│ │ │ +│ EventDispatcher ──→ 事件分发 │ +│ │ │ +│ BasePlugin(QThread) × N │ +│ ├─ HistoryPlugin (内置) │ +│ ├─ 你的插件A (用户) │ +│ └─ 你的插件B (用户) │ +│ │ +│ PluginManagerWindow (Qt GUI) │ +└──────────────────────────────────────┘ +``` + +## 关键点 + +- 每个插件运行在**独立的 QThread** 中,互不阻塞 +- 主进程和插件管理器通过 **ZeroMQ** 通信 +- 插件通过**事件订阅**接收游戏数据,通过**指令发送**控制主进程 + +## 目录结构 + +``` +<安装目录>/ +├── metaminsweeper.exe # 主程序 +├── plugin_manager.exe # 插件管理器 +├── plugins/ # 👈 用户插件放这里! +│ ├── my_hello.py # 你的插件(单文件) +│ ├── my_complex/ # 或包形式插件 +│ │ ├── __init__.py +│ │ └── utils.py +│ └── services/ # 👈 服务接口定义 +│ └── history.py # HistoryService 接口 +├── plugin_sdk/ # 插件开发 SDK +│ ├── plugin_base.py # 👈 BasePlugin 基类 +│ ├── service_registry.py # 服务注册表 +│ └── config_types/ # 配置类型 +├── shared_types/ # 共享类型定义 +│ ├── events.py # 事件类型 +│ ├── commands.py # 指令类型 +│ └── enums.py # 枚举类型 +├── plugin_manager/ # 插件管理器内部模块 +├── user_plugins/ # 备用用户插件目录 +├── data/ +│ ├── logs/ # 日志输出(自动创建) +│ │ └── <插件名>.log # 各插件独立日志 +│ └── plugin_data/ # 各插件的独立数据目录(自动创建) +│ ├── HistoryPlugin/ +│ └── MyHelloPlugin/ # 你的插件数据会在这里自动创建 +└── _internal/ # PyInstaller 解压的内部文件(只读) +``` + +## 插件发现机制 + +``` +plugin_manager 启动 + → PluginLoader 初始化 + → 扫描以下目录: + ① /plugins/ (内置插件,打包时包含) + ② /plugins/ (👈 用户插件主目录) + ③ /user_plugins/ (备用用户插件目录) + → 对每个 .py 文件(不含 _ 开头)动态导入 + → 查找继承了 BasePlugin 的类 + → 实例化并注册到 PluginManager +``` + +## 支持两种形式 + +**单文件插件**(推荐新手使用): +``` +plugins/ +└── my_plugin.py # 一个 .py 文件 = 一个插件 +``` + +**包形式插件**(适合复杂插件): +``` +plugins/ +└── my_plugin/ + ├── __init__.py # 插件类定义在此处 + ├── models.py # 数据模型 + └── widgets.py # UI 组件 +``` + +## 自动发现规则 + +- 文件/目录名以 `_` 开头的会被跳过(如 `_template.py`) +- `services` 目录会被跳过(它是服务接口定义,不是插件) +- 单个 `.py` 文件中可以定义多个继承 `BasePlugin` 的类,都会被加载 +- 包形式插件中,只有 `__init__.py` 中导出的 `BasePlugin` 子类会被发现 diff --git a/skills/plugin-dev/references/best-practices.md b/skills/plugin-dev/references/best-practices.md new file mode 100644 index 0000000..bb79ca5 --- /dev/null +++ b/skills/plugin-dev/references/best-practices.md @@ -0,0 +1,154 @@ +# 最佳实践 + +## 核心原则 + +| 实践 | 原因 | +|------|------| +| IO 操作放在 `on_initialized()` 或事件处理器 | 在工作线程执行,不阻塞 UI | +| GUI 操作用信号槽或 `run_on_gui()` | 跨线程安全,避免崩溃 | +| 使用 `self.logger` 而非 `print()` | 自动分文件、支持过滤、自动轮转 | +| 使用 `self.data_dir` 存储数据 | 自动处理打包/开发模式路径差异 | +| 在 `on_shutdown()` 释放资源 | 避免资源泄漏 | +| 给插件起唯一的 name | 用于日志、数据目录、UI 标识 | + +## 日志记录 + +```python +# ✅ 正确:使用 self.logger +def _handle_event(self, event): + self.logger.info(f"处理事件: {event.rtime}s") + self.logger.debug("详细调试信息") + self.logger.warning("警告信息") + self.logger.error("错误信息") + +# ❌ 错误:使用 print +def _handle_event(self, event): + print("处理事件") # 不会记录到日志文件 +``` + +日志会自动输出到: +- `data/logs/.log` - 插件专属日志 +- `data/logs/main.log` - 主日志 + +## 数据存储 + +```python +def on_initialized(self): + # ✅ 正确:使用 self.data_dir + db_path = self.data_dir / "my_data.db" + config_path = self.data_dir / "settings.json" + + # 目录会自动创建 + # 打包后和开发模式下路径不同,但 self.data_dir 会自动处理 + +# ❌ 错误:硬编码路径 +def on_initialized(self): + db_path = Path("data/my_plugin/my_data.db") # 打包后路径可能不对 +``` + +## 线程安全 + +```python +class MyWidget(QWidget): + # ✅ 正确:定义信号 + _update_signal = pyqtSignal(str) + + def __init__(self): + super().__init__() + self._update_signal.connect(self._do_update) + + def _do_update(self, text): + self.label.setText(text) + + +class MyPlugin(BasePlugin): + def _handle_event(self, event): + # ✅ 正确:通过信号更新 GUI + self._widget._update_signal.emit(f"数据: {event.rtime}") + + # 或使用 run_on_gui(一次性调用) + self.run_on_gui(self._widget.update_data, event.rtime) +``` + +## 资源管理 + +```python +class MyPlugin(BasePlugin): + def on_initialized(self): + # 初始化资源 + self._db = sqlite3.connect(self.data_dir / "data.db") + self._network = NetworkClient() + + def on_shutdown(self): + # ✅ 正确:释放资源 + self._db.close() + self._network.disconnect() + self.logger.info("资源已释放") +``` + +## 配置管理 + +```python +class MyConfig(OtherInfoBase): + # ✅ 正确:合理的默认值 + max_records = IntConfig( + default=100, + label="最大记录数", + min_value=10, + max_value=10000, + ) + +class MyPlugin(BasePlugin): + def on_initialized(self): + # ✅ 正确:检查配置是否存在 + if self.other_info: + max_records = self.other_info.max_records + + # ✅ 正确:监听配置变化 + self.config_changed.connect(self._on_config_changed) +``` + +## 错误处理 + +```python +def _handle_event(self, event): + try: + result = self._process_data(event) + self._widget._signal.emit(result) + except Exception as e: + # ✅ 正确:记录错误,不影响其他插件 + self.logger.error(f"处理失败: {e}", exc_info=True) +``` + +## 性能优化 + +```python +# ✅ 正确:耗时操作在工作线程 +def _handle_event(self, event): + # 这些都在工作线程执行,不阻塞 UI + data = self._heavy_computation(event) + self._db.insert(data) + self._widget._signal.emit(data) + +# ❌ 错误:耗时操作在主线程 +def _create_widget(self): + time.sleep(5) # 卡住 UI 加载 + return MyWidget() +``` + +## 调试技巧 + +```python +def _handle_event(self, event): + # 临时添加调试日志 + self.logger.debug(f"event type: {type(event)}") + self.logger.debug(f"event data: {msgspec.structs.asdict(event)}") + + # 正常处理 + ... +``` + +VS Code 调试: +1. 点击界面上的 🐛 Debug 按钮 +2. VS Code → 运行和调试 → 附加到进程 → 选 plugin_manager.exe +3. 在插件代码打断点调试 diff --git a/skills/plugin-dev/references/creating.md b/skills/plugin-dev/references/creating.md new file mode 100644 index 0000000..94f9f98 --- /dev/null +++ b/skills/plugin-dev/references/creating.md @@ -0,0 +1,101 @@ +# 创建新插件流程 + +当用户请求创建新插件时,按以下步骤操作: + +## 步骤 1:询问插件形式 + +使用 `ask_user_question` 工具询问: + +```json +{ + "question": "请选择插件的形式?", + "header": "插件形式", + "options": [ + {"label": "单文件 (.py)", "description": "简单插件,一个文件搞定,适合快速开发"}, + {"label": "包形式 (目录)", "description": "复杂插件,支持多模块分离,适合大型插件"} + ], + "multiSelect": false +} +``` + +## 步骤 2:收集插件信息 + +依次询问以下信息: + +### 基本信息 +```json +{ + "question": "请输入插件名称(英文,下划线分隔,如 my_plugin):", + "header": "插件名称" +} +``` + +### 描述信息 +```json +{ + "question": "请输入插件描述:", + "header": "描述" +} +``` + +### 作者信息 +```json +{ + "question": "请输入作者名称:", + "header": "作者" +} +``` + +### 功能选项 +```json +{ + "question": "插件需要哪些功能?", + "header": "功能选项", + "options": [ + {"label": "GUI 界面", "description": "插件需要一个可视化界面"}, + {"label": "订阅事件", "description": "监听游戏事件(如游戏结束)"}, + {"label": "发送指令", "description": "控制主进程(如开始新游戏)"}, + {"label": "配置系统", "description": "用户可配置的设置项"} + ], + "multiSelect": true +} +``` + +## 步骤 3:生成插件代码 + +根据收集的信息,使用 `write_file` 工具创建插件文件: + +- **单文件插件**: 创建 `plugins/{plugin_name}.py` +- **包形式插件**: 创建 `plugins/{plugin_name}/__init__.py` + +## 步骤 4:验证创建结果 + +创建完成后: +1. 使用 `python -m py_compile` 检查语法 +2. 告知用户插件文件位置 +3. 提醒用户重启插件管理器以加载新插件 + +## 示例交互流程 + +``` +用户: 帮我创建一个新插件 + +助手: [使用 ask_user_question 询问插件形式] +用户选择: 单文件 (.py) + +助手: [询问插件名称] +用户输入: auto_replay + +助手: [询问描述] +用户输入: 自动回放功能 + +助手: [询问作者] +用户输入: developer + +助手: [询问功能选项] +用户选择: GUI 界面, 订阅事件 + +助手: [使用 write_file 创建 plugins/auto_replay.py] +助手: [使用 py_compile 验证语法] +助手: 插件已创建,请重启插件管理器加载 +``` diff --git a/skills/plugin-dev/references/level-1-basics.md b/skills/plugin-dev/references/level-1-basics.md new file mode 100644 index 0000000..44bb831 --- /dev/null +++ b/skills/plugin-dev/references/level-1-basics.md @@ -0,0 +1,114 @@ +# Level 1: 基础概念 + +## BasePlugin 基类 + +所有插件都必须继承 `BasePlugin` 类: + +```python +from plugin_sdk import BasePlugin, PluginInfo + + +class MyPlugin(BasePlugin): + ... +``` + +## 必须实现的方法 + +### 1. `plugin_info()` - 插件元信息 + +```python +@classmethod +def plugin_info(cls) -> PluginInfo: + return PluginInfo( + name="my_plugin", # 唯一标识(必填) + version="1.0.0", # 版本号 + description="插件描述", # 描述 + author="author", # 作者 + enabled=True, # 默认是否启用 + priority=100, # 优先级(越小越先处理事件) + show_window=True, # 初始化时显示窗口 + window_mode=WindowMode.TAB, # 窗口模式 + ) +``` + +### 2. `_setup_subscriptions()` - 事件订阅 + +```python +def _setup_subscriptions(self) -> None: + from shared_types.events import VideoSaveEvent + self.subscribe(VideoSaveEvent, self._on_video_save) +``` + +## 可选实现的方法 + +### `_create_widget()` - 创建 GUI + +```python +def _create_widget(self) -> QWidget | None: + """返回插件界面,None 表示无界面""" + self._widget = MyWidget() + return self._widget +``` + +### `on_initialized()` - 初始化回调 + +```python +def on_initialized(self) -> None: + """在插件线程中执行,可做耗时操作""" + self.logger.info("插件已初始化") + # 数据库初始化、网络连接等 +``` + +### `on_shutdown()` - 关闭清理 + +```python +def on_shutdown(self) -> None: + """插件关闭前执行清理""" + self.logger.info("插件正在关闭...") + # 释放资源、保存数据等 +``` + +## 插件生命周期 + +``` +plugin_info() # 返回元信息 + ↓ +实例化 BasePlugin + ↓ +initialize() # 启动 QThread + ↓ +_setup_subscriptions() # 注册事件订阅 + ↓ +_create_widget() # 创建 UI(主线程) + ↓ +start() # QThread 开始运行 + ↓ +on_initialized() # 【工作线程】初始化回调 + ↓ +═══ 进入事件循环 ═══ + ↓ +shutdown() # 请求停止 + ↓ +on_shutdown() # 【工作线程】清理回调 + ↓ +STOPPED +``` + +## BasePlugin 核心属性 + +| 属性 | 类型 | 说明 | +|------|------|------| +| `self.info` | `PluginInfo` | 插件元信息 | +| `self.name` | `str` | 插件名称 | +| `self.data_dir` | `Path` | **插件专属数据目录** | +| `self.logger` | `Logger` | **已绑定的日志器** | +| `self.widget` | `QWidget` | UI 组件(如果有) | +| `self.other_info` | `OtherInfoBase` | 配置对象(如果有) | + +## WindowMode 窗口模式 + +| 模式 | 行为 | +|------|------| +| `WindowMode.TAB` | 嵌入主窗口标签页 | +| `WindowMode.DETACHED` | 独立窗口(可拖回标签页) | +| `WindowMode.CLOSED` | 不自动创建窗口 | diff --git a/skills/plugin-dev/references/level-2-events.md b/skills/plugin-dev/references/level-2-events.md new file mode 100644 index 0000000..3f5cc13 --- /dev/null +++ b/skills/plugin-dev/references/level-2-events.md @@ -0,0 +1,89 @@ +# Level 2: 事件系统 + +## 订阅事件 + +在 `_setup_subscriptions()` 中订阅感兴趣的事件: + +```python +def _setup_subscriptions(self) -> None: + from shared_types.events import VideoSaveEvent, BoardUpdateEvent + + self.subscribe(VideoSaveEvent, self._on_video_save) + self.subscribe(BoardUpdateEvent, self._on_board_update) +``` + +## 可用事件类型 + +### VideoSaveEvent - 游戏结束 + +一局游戏结束时触发,包含完整的统计数据: + +```python +def _on_video_save(self, event: VideoSaveEvent): + # 基本信息 + event.rtime # 用时(秒) + event.level # 难度 + event.game_board_state # 游戏状态(胜利/失败) + + # 操作统计 + event.left # 左键次数 + event.right # 右键次数 + event.double # 双击次数 + event.cl # 总点击数 + event.ce # 有效点击数 + + # 3BV 相关 + event.bbbv # 总 3BV + event.bbbv_solved # 已解决 3BV + + # 效率指标 + event.stnb # STNB 分数 + event.corr # Corr 分数 + event.ioe # IOE 效率 + + # 录像数据 + event.raw_data # base64 编码的录像 + event.mode # 游戏模式 +``` + +### BoardUpdateEvent - 棋盘更新 + +每步操作都会触发: + +```python +def _on_board_update(self, event: BoardUpdateEvent): + event.board # 当前棋盘状态 + event.mouse_state # 鼠标状态 +``` + +## 取消订阅 + +```python +def _some_method(self): + self.unsubscribe(VideoSaveEvent) +``` + +## 事件处理器注意事项 + +**重要**: 事件处理器在**插件工作线程**中执行: + +- ✅ 可以做 IO 操作(数据库、文件) +- ✅ 可以做耗时计算 +- ❌ 不能直接操作 GUI(会导致崩溃) +- ✅ 更新 GUI 必须使用信号槽或 `run_on_gui()` + +## 事件处理流程 + +``` +主进程触发事件 + ↓ +ZMQ 发送到插件管理器 + ↓ +EventDispatcher 分发 + ↓ +各插件的 handler 被调用(在工作线程) + ↓ +handler 处理数据 + ↓ +(如需更新 GUI)通过信号槽投递到主线程 +``` diff --git a/skills/plugin-dev/references/level-3-threading.md b/skills/plugin-dev/references/level-3-threading.md new file mode 100644 index 0000000..b8a247d --- /dev/null +++ b/skills/plugin-dev/references/level-3-threading.md @@ -0,0 +1,99 @@ +# Level 3: 跨线程 GUI 更新 + +## 为什么需要跨线程机制? + +`BasePlugin` 继承自 `QThread`,事件处理器运行在**插件工作线程**中,但 PyQt 的 GUI 操作只能在**主线程**执行。直接跨线程操作 GUI 会导致未定义行为或崩溃。 + +## 推荐方式:pyqtSignal + 槽函数 + +因为插件类本身就是 `QObject`(QThread 的父类),所以可以直接定义信号: + +```python +from PyQt5.QtCore import pyqtSignal + +class MyWidget(QWidget): + # 定义信号 + _update_signal = pyqtSignal(str) + _data_signal = pyqtSignal(dict) + + def __init__(self, parent=None): + super().__init__(parent) + # 连接信号到槽函数 + self._update_signal.connect(self._on_update) + self._data_signal.connect(self._on_data) + + def _on_update(self, text: str): + """槽函数在主线程执行""" + self.label.setText(text) + + def _on_data(self, data: dict): + self.table.update_data(data) + + +class MyPlugin(BasePlugin): + def _on_video_save(self, event): + # 工作线程 emit → 自动跨线程到主线程 + self._widget._update_signal.emit(f"用时: {event.rtime:.2f}s") + self._widget._data_signal.emit({"time": event.rtime}) +``` + +## 备选方式:run_on_gui() + +适用于一次性调用、不需要重复连接的场景: + +```python +def _on_video_save(self, event): + # 一次性更新 GUI + self.run_on_gui(self._widget.update_label, f"用时: {event.rtime}s") + + # 带多个参数 + self.run_on_gui(self._widget.update_stats, event.rtime, event.bbbv) +``` + +## 两种方式对比 + +| 方式 | 适用场景 | 特点 | +|------|----------|------| +| **pyqtSignal + 槽** | 有固定 UI 需反复更新 | **推荐**。声明式,类型签名清晰 | +| **run_on_gui()** | 临时/一次性 UI 调用 | 灵活但可读性略差 | + +## 错误示例 + +```python +# ❌ 错误:直接在事件处理器中操作 GUI +def _on_video_save(self, event): + self._widget.label.setText(f"用时: {event.rtime}s") # 崩溃! + +# ❌ 错误:在 _create_widget 中做耗时操作 +def _create_widget(self): + time.sleep(5) # 卡住 UI 加载 + return MyWidget() +``` + +## 正确示例 + +```python +class MyWidget(QWidget): + _update = pyqtSignal(str) + + def __init__(self): + super().__init__() + self._update.connect(self._do_update) + + def _do_update(self, text): + self.label.setText(text) + + +class MyPlugin(BasePlugin): + def _create_widget(self): + self._widget = MyWidget() + return self._widget + + def _on_video_save(self, event): + # ✅ 正确:通过信号更新 GUI + self._widget._update.emit(f"用时: {event.rtime:.2f}s") +``` + +## 底层原理 + +两种方式底层原理相同 —— 都是通过 Qt 的 `QueuedConnection` 将调用投递到主线程的事件循环中,确保 GUI 操作在主线程执行。 diff --git a/skills/plugin-dev/references/level-4-control.md b/skills/plugin-dev/references/level-4-control.md new file mode 100644 index 0000000..ffde209 --- /dev/null +++ b/skills/plugin-dev/references/level-4-control.md @@ -0,0 +1,107 @@ +# Level 4: 控制授权系统 + +## 为什么需要控制授权? + +为了防止多个插件同时发送冲突的控制指令,系统实现了**控制授权机制**: + +- 每个**控制命令类型**只能授权给**一个插件** +- 未获得授权的插件发送该命令会被拒绝 +- 授权变更时会通知相关插件 + +## 声明需要的控制权限 + +在 `PluginInfo` 中通过 `required_controls` 字段声明: + +```python +from shared_types.commands import NewGameCommand, MouseClickCommand + +class MyPlugin(BasePlugin): + + @classmethod + def plugin_info(cls) -> PluginInfo: + return PluginInfo( + name="my_plugin", + description="需要控制权限的插件", + required_controls=[NewGameCommand], # 👈 声明需要的控制权限 + ) +``` + +## 可用的控制命令 + +| 命令类 | 说明 | 参数 | +|--------|------|------| +| `NewGameCommand` | 开始新游戏 | `rows`, `cols`, `mines` | +| `MouseClickCommand` | 模拟鼠标点击 | `row`, `col`, `button` | + +## 检查授权状态 + +```python +def on_initialized(self) -> None: + # 检查当前是否有权限 + has_auth = self.has_control_auth(NewGameCommand) + if has_auth: + self.logger.info("已获得 NewGameCommand 权限") + else: + self.logger.warning("未获得 NewGameCommand 权限") +``` + +## 响应授权变更 + +覆写 `on_control_auth_changed` 方法: + +```python +def on_control_auth_changed( + self, + command_type: type, + granted: bool, +) -> None: + """ + 控制权限变更回调 + + Args: + command_type: 命令类型 + granted: True 表示获得权限,False 表示失去权限 + """ + if command_type == NewGameCommand: + if granted: + self.logger.info("获得了控制权限") + self.run_on_gui(self._widget.enable_controls, True) + else: + self.logger.warning("失去了控制权限") + self.run_on_gui(self._widget.enable_controls, False) +``` + +## 发送控制命令 + +```python +def _start_new_game(self) -> None: + # 方式一:直接发送(无权限时会被拒绝) + self.send_command(NewGameCommand(rows=16, cols=30, mines=99)) + + # 方式二:检查权限后再发送 + if self.has_control_auth(NewGameCommand): + self.send_command(NewGameCommand(rows=16, cols=30, mines=99)) + else: + self.logger.warning("没有控制权限") +``` + +## 用户授权操作 + +用户通过插件管理器管理授权: + +1. 打开插件管理器 +2. 点击工具栏的 **"🔐 控制授权"** 按钮 +3. 选择要授权的控制类型 +4. 从下拉列表中选择插件 +5. 确认后生效 + +授权配置会持久化到 `data/control_authorization.json`。 + +## 相关 API + +| 方法/属性 | 说明 | +|-----------|------| +| `has_control_auth(command_type)` | 检查是否有该控制类型的权限 | +| `on_control_auth_changed(cmd_type, granted)` | 权限变更回调(覆写) | +| `PluginInfo.required_controls` | 声明需要的控制权限 | +| `send_command(command)` | 发送控制命令 | diff --git a/skills/plugin-dev/references/level-5-service.md b/skills/plugin-dev/references/level-5-service.md new file mode 100644 index 0000000..3381a7d --- /dev/null +++ b/skills/plugin-dev/references/level-5-service.md @@ -0,0 +1,111 @@ +# Level 5: 服务系统 + +## 什么是服务系统? + +服务系统允许插件之间进行类型安全的方法调用。服务方法在**服务提供者线程**执行,调用方无需关心线程安全。 + +## 定义服务接口 + +在 `plugins/services/` 目录下创建接口定义文件: + +```python +# plugins/services/my_service.py +from typing import Protocol, runtime_checkable +from dataclasses import dataclass + +@dataclass(frozen=True, slots=True) +class MyData: + """数据类型(frozen 保证不可变,线程安全)""" + id: int + name: str + +@runtime_checkable +class MyService(Protocol): + """服务接口定义""" + def get_data(self, id: int) -> MyData | None: ... + def list_data(self, limit: int = 100) -> list[MyData]: ... +``` + +## 注册服务(服务提供者) + +```python +from plugins.services.my_service import MyService + +class ProviderPlugin(BasePlugin): + + def on_initialized(self): + # 注册服务,显式指定 Protocol 类型 + self.register_service(self, protocol=MyService) + self.logger.info("MyService 已注册") + + # 实现服务接口方法 + def get_data(self, id: int) -> MyData | None: + return self._db.query(id) + + def list_data(self, limit: int = 100) -> list[MyData]: + return self._db.query_all(limit) +``` + +## 使用服务(服务消费者) + +### 方式一:等待服务就绪(推荐) + +```python +from plugins.services.my_service import MyService + +class ConsumerPlugin(BasePlugin): + + def on_initialized(self): + # 等待服务就绪,最多 10 秒 + self._service = self.wait_for_service(MyService, timeout=10.0) + if self._service is None: + self.logger.warning("MyService 未就绪") + + def _do_something(self): + if self._service: + # 调用服务方法(在提供者线程执行) + data = self._service.get_data(123) + all_data = self._service.list_data(100) +``` + +### 方式二:检查服务可用 + +```python +def on_initialized(self): + if self.has_service(MyService): + self._service = self.get_service_proxy(MyService) + +def _do_something(self): + if self._service: + data = self._service.get_data(123) +``` + +## 异步调用 + +```python +def _async_call(self): + # 异步调用,返回 Future + future = self.call_service_async(MyService, "get_data", 123) + + # 做其他事情... + + # 阻塞等待结果 + result = future.result(timeout=5.0) +``` + +## 服务相关 API + +| 方法 | 说明 | +|------|------| +| `register_service(self, protocol=ServiceClass)` | 注册服务 | +| `has_service(ServiceClass)` | 检查服务是否可用 | +| `wait_for_service(ServiceClass, timeout)` | 等待服务就绪并获取代理(推荐) | +| `get_service_proxy(ServiceClass)` | 获取服务代理对象 | +| `call_service_async(ServiceClass, "method", *args)` | 异步调用,返回 Future | + +## 注意事项 + +1. **死锁风险**:不要让两个插件互相调用对方的服务 +2. **线程安全**:服务方法在提供者线程执行,调用方无需关心 +3. **删除接口**:不要在服务接口中暴露删除等敏感操作 +4. **超时处理**:服务调用默认超时 10 秒,可配置 diff --git a/skills/plugin-dev/references/level-6-config.md b/skills/plugin-dev/references/level-6-config.md new file mode 100644 index 0000000..19ec368 --- /dev/null +++ b/skills/plugin-dev/references/level-6-config.md @@ -0,0 +1,173 @@ +# Level 6: 配置系统 + +## 配置系统概述 + +插件可以定义自己的配置项,这些配置会: +- 自动生成 UI 控件(在设置对话框中) +- 自动持久化到 `data/plugin_data//config.json` +- 支持配置变化事件通知 + +## 配置类型一览 + +| 类型 | UI 控件 | 用途示例 | +|------|---------|----------| +| `BoolConfig` | QCheckBox | 开关选项 | +| `IntConfig` | QSpinBox | 整数设置 | +| `FloatConfig` | QDoubleSpinBox | 浮点数设置 | +| `ChoiceConfig` | QComboBox | 下拉选择 | +| `TextConfig` | QLineEdit | 文本输入 | +| `ColorConfig` | 颜色按钮 | 颜色选择 | +| `FileConfig` | 文件对话框 | 文件路径 | +| `PathConfig` | 目录对话框 | 目录路径 | +| `LongTextConfig` | QTextEdit | 多行文本 | +| `RangeConfig` | 两个 QSpinBox | 数值范围 | + +## 定义配置类 + +```python +from plugin_sdk import ( + OtherInfoBase, BoolConfig, IntConfig, FloatConfig, + ChoiceConfig, TextConfig, ColorConfig, +) + +class MyConfig(OtherInfoBase): + """我的插件配置""" + + # 开关选项 + enable_feature = BoolConfig( + default=True, + label="启用功能", + description="是否启用某功能", + ) + + # 整数设置 + max_count = IntConfig( + default=100, + label="最大数量", + min_value=1, + max_value=1000, + step=10, + ) + + # 浮点数设置 + threshold = FloatConfig( + default=0.5, + label="阈值", + min_value=0.0, + max_value=1.0, + decimals=2, + ) + + # 下拉选择 + theme = ChoiceConfig( + default="dark", + label="主题", + choices=[ + ("light", "明亮"), + ("dark", "暗黑"), + ("auto", "跟随系统"), + ], + ) + + # 文本输入 + player_name = TextConfig( + default="", + label="玩家名称", + placeholder="输入名称...", + ) + + # 密码输入 + api_token = TextConfig( + default="", + label="API Token", + password=True, # 密码模式 + ) + + # 颜色选择 + theme_color = ColorConfig( + default="#1976d2", + label="主题颜色", + ) +``` + +## 绑定配置到插件 + +```python +class MyPlugin(BasePlugin): + + @classmethod + def plugin_info(cls) -> PluginInfo: + return PluginInfo( + name="my_plugin", + description="我的插件", + other_info=MyConfig, # 👈 绑定配置类 + ) +``` + +## 访问配置值 + +```python +def on_initialized(self): + if self.other_info: + enable = self.other_info.enable_feature + max_count = self.other_info.max_count + theme = self.other_info.theme + self.logger.info(f"配置: enable={enable}, max_count={max_count}") + +def _handle_event(self, event): + if self.other_info and self.other_info.enable_feature: + self._do_something(event) +``` + +## 监听配置变化 + +```python +def on_initialized(self): + # 连接配置变化信号 + self.config_changed.connect(self._on_config_changed) + +def _on_config_changed(self, name: str, value: Any): + """配置变化时调用(在主线程执行)""" + self.logger.info(f"配置变化: {name} = {value}") + + if name == "theme": + self._apply_theme(value) + elif name == "max_count": + self._resize_buffer(value) +``` + +## 手动保存配置 + +```python +def on_shutdown(self): + # 配置在设置对话框确认时自动保存 + # 也可以手动保存 + self.save_config() +``` + +## 配置存储位置 + +``` +data/plugin_data//config.json +``` + +示例: +```json +{ + "enable_feature": true, + "max_count": 100, + "theme": "dark", + "player_name": "Player1" +} +``` + +## 配置相关 API + +| 属性/方法 | 说明 | +|-----------|------| +| `self.other_info` | 配置对象实例 | +| `self.config_changed` | 配置变化信号 | +| `self.save_config()` | 手动保存配置 | +| `self.other_info.to_dict()` | 导出为字典 | +| `self.other_info.from_dict(data)` | 从字典加载 | +| `self.other_info.reset_to_defaults()` | 重置为默认值 | diff --git a/skills/plugin-dev/references/template.md b/skills/plugin-dev/references/template.md new file mode 100644 index 0000000..d47f0dd --- /dev/null +++ b/skills/plugin-dev/references/template.md @@ -0,0 +1,225 @@ +# 插件模板代码 + +## 最小可行插件(无 GUI) + +```python +"""插件描述""" +from __future__ import annotations + +from plugin_sdk import BasePlugin, PluginInfo +from shared_types.events import VideoSaveEvent + + +class MyPlugin(BasePlugin): + """插件描述""" + + @classmethod + def plugin_info(cls) -> PluginInfo: + return PluginInfo( + name="my_plugin", + version="1.0.0", + description="插件描述", + window_mode=WindowMode.CLOSED, # 无界面 + ) + + def _setup_subscriptions(self) -> None: + self.subscribe(VideoSaveEvent, self._on_video_save) + + def on_initialized(self) -> None: + self.logger.info("MyPlugin 已初始化") + + def _on_video_save(self, event: VideoSaveEvent): + self.logger.info(f"游戏结束: 用时={event.rtime}s") +``` + +## 带 GUI 的插件模板 + +```python +"""插件描述""" +from __future__ import annotations + +from PyQt5.QtWidgets import QWidget, QVBoxLayout, QLabel +from PyQt5.QtCore import pyqtSignal + +from plugin_sdk import BasePlugin, PluginInfo, make_plugin_icon, WindowMode +from shared_types.events import VideoSaveEvent + + +class MyPluginWidget(QWidget): + """插件 UI""" + + _update_signal = pyqtSignal(str) + + def __init__(self, parent=None): + super().__init__(parent) + layout = QVBoxLayout(self) + + self._title = QLabel("插件标题") + self._title.setStyleSheet("font-size: 18px; font-weight: bold;") + layout.addWidget(self._title) + + self._info = QLabel("等待数据...") + layout.addWidget(self._info) + + self._update_signal.connect(self._on_update) + + def _on_update(self, text: str): + self._info.setText(text) + + +class MyPlugin(BasePlugin): + """插件描述""" + + @classmethod + def plugin_info(cls) -> PluginInfo: + return PluginInfo( + name="my_plugin", + version="1.0.0", + author="author", + description="插件描述", + window_mode=WindowMode.TAB, + icon=make_plugin_icon("#4CAF50", "M"), + ) + + def _setup_subscriptions(self) -> None: + self.subscribe(VideoSaveEvent, self._on_video_save) + + def _create_widget(self) -> QWidget | None: + self._widget = MyPluginWidget() + return self._widget + + def on_initialized(self) -> None: + self.logger.info("MyPlugin 已初始化") + + def _on_video_save(self, event: VideoSaveEvent): + self.logger.info(f"收到游戏数据: 用时={event.rtime}s") + self._widget._update_signal.emit(f"用时: {event.rtime:.2f}s") +``` + +## 包形式插件结构 + +``` +plugins/my_plugin/ +├── __init__.py # 插件类定义 +├── models.py # 数据模型 +├── widgets.py # UI 组件 +└── utils.py # 工具函数 +``` + +### `__init__.py` 示例 + +```python +"""插件包入口""" +from __future__ import annotations + +from plugin_sdk import BasePlugin, PluginInfo +from .widgets import MyPluginWidget +from .models import MyDataModel + + +class MyPlugin(BasePlugin): + """插件主类""" + + @classmethod + def plugin_info(cls) -> PluginInfo: + return PluginInfo( + name="my_plugin", + version="1.0.0", + description="复杂插件示例", + ) + + def _setup_subscriptions(self) -> None: + from shared_types.events import VideoSaveEvent + self.subscribe(VideoSaveEvent, self._on_video_save) + + def _create_widget(self): + self._widget = MyPluginWidget() + return self._widget + + def on_initialized(self) -> None: + self._model = MyDataModel(self.data_dir) + self.logger.info("MyPlugin 已初始化") + + def _on_video_save(self, event): + data = self._model.process(event) + self._widget.update_data(data) +``` + +## 带配置的插件模板 + +```python +from plugin_sdk import BasePlugin, PluginInfo, OtherInfoBase, BoolConfig, IntConfig + + +class MyConfig(OtherInfoBase): + """插件配置""" + + enable_feature = BoolConfig( + default=True, + label="启用功能", + description="是否启用某功能", + ) + + max_count = IntConfig( + default=100, + label="最大数量", + min_value=1, + max_value=1000, + ) + + +class MyPlugin(BasePlugin): + + @classmethod + def plugin_info(cls) -> PluginInfo: + return PluginInfo( + name="my_plugin", + description="带配置的插件", + other_info=MyConfig, # 👈 绑定配置类 + ) + + def on_initialized(self) -> None: + if self.other_info: + max_count = self.other_info.max_count + self.logger.info(f"配置: max_count={max_count}") + + def _on_config_changed(self, name: str, value): + """配置变化时调用""" + self.logger.info(f"配置变化: {name} = {value}") +``` + +## 带控制权限的插件模板 + +```python +from shared_types.commands import NewGameCommand + + +class MyPlugin(BasePlugin): + + @classmethod + def plugin_info(cls) -> PluginInfo: + return PluginInfo( + name="my_plugin", + description="需要控制权限的插件", + required_controls=[NewGameCommand], # 👈 声明需要的权限 + ) + + def on_initialized(self) -> None: + # 检查是否有权限 + if self.has_control_auth(NewGameCommand): + self.logger.info("已获得 NewGameCommand 权限") + else: + self.logger.warning("未获得 NewGameCommand 权限") + + def on_control_auth_changed(self, cmd_type, granted: bool): + """权限变更回调""" + if cmd_type == NewGameCommand: + if granted: + self.logger.info("获得了控制权限") + else: + self.logger.warning("失去了控制权限") + + def _start_new_game(self): + if self.has_control_auth(NewGameCommand): + self.send_command(NewGameCommand(rows=16, cols=30, mines=99)) +``` diff --git a/skills/plugin-dev/references/troubleshooting.md b/skills/plugin-dev/references/troubleshooting.md new file mode 100644 index 0000000..2402c12 --- /dev/null +++ b/skills/plugin-dev/references/troubleshooting.md @@ -0,0 +1,173 @@ +# 问题诊断 + +## 插件未被加载 + +按顺序排查: + +### 1. 检查文件位置 +确认 `.py` 文件在正确的目录: +- `plugins/` - 用户插件主目录 +- `user_plugins/` - 备用用户插件目录 + +### 2. 检查命名规则 +- 文件名不能以 `_` 开头(如 `_test.py` 会被跳过) +- `services` 目录会被跳过(它是服务接口定义,不是插件) + +### 3. 检查基类继承 +确认类继承了 `BasePlugin`: +```python +from plugin_sdk import BasePlugin + +class MyPlugin(BasePlugin): # 必须继承 BasePlugin + ... +``` + +### 4. 检查必须方法 +确认实现了 `plugin_info()` 和 `_setup_subscriptions()`: +```python +@classmethod +def plugin_info(cls) -> PluginInfo: + return PluginInfo(name="my_plugin", ...) + +def _setup_subscriptions(self) -> None: + ... +``` + +### 5. 查看错误日志 +检查 `data/logs/plugin_manager.log` 中的错误: +``` +Failed to load module: xxx +SyntaxError: xxx +ImportError: xxx +``` + +## GUI 更新导致崩溃 + +### 症状 +- 界面卡死 +- 随机崩溃 +- "QObject: Cannot create children for a parent that is in a different thread" 错误 + +### 原因 +在事件处理器(工作线程)中直接操作 GUI。 + +### 解决方案 +使用信号槽或 `run_on_gui()`: +```python +# ❌ 错误 +def _on_event(self, event): + self._widget.label.setText("text") # 崩溃! + +# ✅ 正确 +def _on_event(self, event): + self._widget._signal.emit("text") # 通过信号 + # 或 + self.run_on_gui(self._widget.label.setText, "text") +``` + +## 事件未触发 + +### 排查步骤 + +1. 确认已订阅事件: +```python +def _setup_subscriptions(self) -> None: + self.subscribe(VideoSaveEvent, self._on_video_save) +``` + +2. 确认事件类型正确: +```python +from shared_types.events import VideoSaveEvent # 不是其他名字 +``` + +3. 检查插件是否启用: +- 在插件管理器中查看插件状态 +- 确认 `PluginInfo(enabled=True)` + +4. 查看日志: +```python +def _on_video_save(self, event): + self.logger.info("收到事件") # 确认是否被调用 +``` + +## 导入错误 + +### 症状 +``` +ImportError: No module named 'xxx' +ModuleNotFoundError: No module named 'xxx' +``` + +### 解决方案 + +**方案 A:使用已安装的库** +只使用 `requirements.txt` 中的依赖: +- PyQt5, msgspec, loguru, pyzmq 等 + +**方案 B:重新打包** +1. 在 `requirements.txt` 中添加依赖 +2. 在 `plugin_manager.spec` 的 `hiddenimports` 中添加模块名 +3. 重新执行 PyInstaller 打包 + +**方案 C:放在插件目录** +某些纯 Python 库可以直接放入插件目录中。 + +## 服务调用失败 + +### 症状 +``` +Service not found: MyService +Timeout waiting for service: MyService +``` + +### 解决方案 + +1. 确认服务提供者已加载: +```python +def on_initialized(self): + # 使用 wait_for_service 等待 + self._service = self.wait_for_service(MyService, timeout=10.0) +``` + +2. 确认服务已注册: +```python +# 服务提供者 +def on_initialized(self): + self.register_service(self, protocol=MyService) +``` + +3. 检查 Protocol 定义正确: +```python +@runtime_checkable +class MyService(Protocol): + def method(self) -> ReturnType: ... +``` + +## 配置不生效 + +### 症状 +- `self.other_info` 为 None +- 配置值没有更新 + +### 解决方案 + +1. 确认绑定了配置类: +```python +@classmethod +def plugin_info(cls) -> PluginInfo: + return PluginInfo( + name="my_plugin", + other_info=MyConfig, # 👈 必须绑定 + ) +``` + +2. 确认访问方式正确: +```python +if self.other_info: # 先检查是否为 None + value = self.other_info.some_config +``` + +3. 检查配置文件: +``` +data/plugin_data//config.json +``` diff --git a/skills/plugin-dev/scripts/create_plugin.py b/skills/plugin-dev/scripts/create_plugin.py new file mode 100644 index 0000000..3e6bf73 --- /dev/null +++ b/skills/plugin-dev/scripts/create_plugin.py @@ -0,0 +1,741 @@ +#!/usr/bin/env python +""" +插件创建脚本 + +用法: + python create_plugin.py discover + python create_plugin.py create --name my_plugin --description "描述" [options] + +功能: + - 根据参数生成插件代码 + - 动态读取可用的控制命令和订阅事件 + - 支持单文件和模块化包结构 + - 自动生成订阅代码、控制申请、服务接口 +""" +from __future__ import annotations + +import argparse +import json +import os +import sys +from pathlib import Path +from dataclasses import dataclass, asdict +from typing import Any + + +# ═══════════════════════════════════════════════════════════════════ +# 环境检测 +# ═══════════════════════════════════════════════════════════════════ + +def get_base_dir() -> Path: + """获取基础目录(开发模式)""" + script_dir = Path(__file__).resolve().parent + return script_dir.parent.parent.parent.parent + + +def is_frozen() -> bool: + """检测是否在 PyInstaller 打包环境中""" + base = get_base_dir() + return (base / "metaminsweeper.exe").exists() + + +def get_install_dir() -> Path: + """获取安装目录""" + return get_base_dir() + + +def get_plugins_dir() -> Path: + """获取插件目录""" + base = get_install_dir() + if is_frozen(): + return base / "plugins" + return base / "src" / "plugins" + + +def get_shared_types_dir() -> Path: + """获取 shared_types 目录""" + base = get_install_dir() + if is_frozen(): + return base / "shared_types" + return base / "src" / "shared_types" + + +def get_plugin_sdk_dir() -> Path: + """获取 plugin_sdk 目录""" + base = get_install_dir() + if is_frozen(): + return base / "plugin_sdk" + return base / "src" / "plugin_sdk" + + +# ═══════════════════════════════════════════════════════════════════ +# 动态读取类型 +# ═══════════════════════════════════════════════════════════════════ + +def discover_events() -> list[dict[str, Any]]: + """发现可用的事件类型""" + events = [] + events_file = get_shared_types_dir() / "events.py" + + if not events_file.exists(): + return events + + content = events_file.read_text(encoding="utf-8") + import re + pattern = r'class\s+(\w+)\s*\(.*?\):' + for match in re.finditer(pattern, content): + name = match.group(1) + if name.endswith("Event"): + doc_pattern = rf'class\s+{name}\s*\(.*?\):\s*"""(.*?)"""' + doc_match = re.search(doc_pattern, content, re.DOTALL) + doc = doc_match.group(1).strip() if doc_match else "" + events.append({"name": name, "description": doc}) + + return events + + +def discover_commands() -> list[dict[str, Any]]: + """发现可用的控制命令类型""" + commands = [] + commands_file = get_shared_types_dir() / "commands.py" + + if not commands_file.exists(): + return commands + + content = commands_file.read_text(encoding="utf-8") + import re + pattern = r'class\s+(\w+)\s*\(.*?\):' + for match in re.finditer(pattern, content): + name = match.group(1) + if name.endswith("Command"): + doc_pattern = rf'class\s+{name}\s*\(.*?\):\s*"""(.*?)"""' + doc_match = re.search(doc_pattern, content, re.DOTALL) + doc = doc_match.group(1).strip() if doc_match else "" + commands.append({"name": name, "description": doc}) + + return commands + + +# ═══════════════════════════════════════════════════════════════════ +# 代码生成 - 单文件模式 +# ═══════════════════════════════════════════════════════════════════ + +def _to_class_name(name: str) -> str: + """将插件名转换为类名前缀 + + Examples: + test_plugin -> TestPlugin + history -> History + my_awesome_plugin -> MyAwesomePlugin + """ + return "".join(word.capitalize() for word in name.split("_")) + + +def generate_single_file_plugin( + name: str, + description: str = "", + version: str = "1.0.0", + author: str = "", + window_mode: str = "TAB", + icon_color: str = "#4CAF50", + icon_char: str = "", + events: list[str] = None, + commands: list[str] = None, + needs_config: bool = False, + needs_service: bool = False, + service_name: str = "", +) -> str: + """生成单文件插件代码""" + if events is None: + events = [] + if commands is None: + commands = [] + + if not icon_char: + icon_char = name[0].upper() + + needs_gui = window_mode != "CLOSED" + class_prefix = _to_class_name(name) + lines = [] + + # 文件头 + lines.append(f'"""') + lines.append(f'{name} - {description}') + lines.append(f'"""') + lines.append('from __future__ import annotations') + lines.append('') + + # 导入 + imports = [] + if needs_gui: + imports.append('from PyQt5.QtWidgets import QWidget, QVBoxLayout, QLabel') + imports.append('from PyQt5.QtCore import pyqtSignal') + + sdk_imports = ['BasePlugin', 'PluginInfo'] + if needs_gui: + sdk_imports.extend(['make_plugin_icon', 'WindowMode']) + if needs_config: + sdk_imports.extend(['OtherInfoBase', 'BoolConfig']) + imports.append(f'from plugin_sdk import {", ".join(sdk_imports)}') + + if events: + imports.append(f'from shared_types.events import {", ".join(events)}') + if commands: + imports.append(f'from shared_types.commands import {", ".join(commands)}') + if needs_service and service_name: + imports.append(f'from plugins.services.{name} import {service_name}') + + lines.extend(imports) + lines.append('') + + # 配置类 + if needs_config: + lines.append(f'class {class_prefix}Config(OtherInfoBase):') + lines.append(f' """插件配置"""') + lines.append(' ') + lines.append(' enable_feature = BoolConfig(') + lines.append(' default=True,') + lines.append(' label="启用功能",') + lines.append(' )') + lines.append('') + lines.append('') + + # Widget 类 + widget_name = f'{class_prefix}Widget' + if needs_gui: + lines.append(f'class {widget_name}(QWidget):') + lines.append(f' """插件 UI"""') + lines.append(' ') + lines.append(' _update_signal = pyqtSignal(str)') + lines.append('') + lines.append(' def __init__(self, parent=None):') + lines.append(' super().__init__(parent)') + lines.append(' layout = QVBoxLayout(self)') + lines.append(' ') + lines.append(' self._label = QLabel("就绪")') + lines.append(' layout.addWidget(self._label)') + lines.append(' ') + lines.append(' self._update_signal.connect(self._on_update)') + lines.append('') + lines.append(' def _on_update(self, text: str) -> None:') + lines.append(' """更新显示文本"""') + lines.append(' self._label.setText(text)') + lines.append('') + lines.append('') + + # 主插件类 + plugin_name = f'{class_prefix}Plugin' + lines.append(f'class {plugin_name}(BasePlugin):') + lines.append(f' """{description}"""') + lines.append('') + + # plugin_info + lines.append(' @classmethod') + lines.append(' def plugin_info(cls) -> PluginInfo:') + lines.append(' return PluginInfo(') + lines.append(f' name="{name}",') + lines.append(f' version="{version}",') + if author: + lines.append(f' author="{author}",') + lines.append(f' description="{description}",') + + if needs_gui: + lines.append(f' window_mode=WindowMode.{window_mode},') + lines.append(f' icon=make_plugin_icon("{icon_color}", "{icon_char}"),') + else: + lines.append(f' window_mode=WindowMode.CLOSED,') + + if needs_config: + lines.append(f' other_info={class_prefix}Config,') + if commands: + lines.append(f' required_controls=[{", ".join(commands)}],') + + lines.append(' )') + lines.append('') + + # _setup_subscriptions + lines.append(' def _setup_subscriptions(self) -> None:') + if events: + for event in events: + handler_name = f'_on_{event.lower().replace("event", "")}' + lines.append(f' self.subscribe({event}, self.{handler_name})') + else: + lines.append(' pass') + lines.append('') + + # _create_widget + if needs_gui: + lines.append(' def _create_widget(self) -> QWidget | None:') + lines.append(f' self._widget = {widget_name}()') + lines.append(' return self._widget') + lines.append('') + + # on_initialized + lines.append(' def on_initialized(self) -> None:') + lines.append(f' self.logger.info("{plugin_name} 已初始化")') + + if needs_service and service_name: + lines.append(' ') + lines.append(' # 注册服务接口') + lines.append(f' self.register_service(self, protocol={service_name})') + lines.append(f' self.logger.info("{service_name} 已注册")') + + if commands: + lines.append(' ') + lines.append(' # 检查控制权限') + for cmd in commands: + lines.append(f' has_auth = self.has_control_auth({cmd})') + lines.append(f' self.logger.info(f"{cmd} 权限: {{has_auth}}")') + + if needs_config: + lines.append(' ') + lines.append(' self.config_changed.connect(self._on_config_changed)') + + lines.append('') + + # on_control_auth_changed + if commands: + lines.append(' def on_control_auth_changed(self, cmd_type, granted: bool) -> None:') + lines.append(' """控制权限变更回调"""') + for cmd in commands: + lines.append(f' if cmd_type == {cmd}:') + lines.append(' self.logger.info(f"权限变更: {granted}")') + lines.append('') + + # _on_config_changed + if needs_config: + lines.append(' def _on_config_changed(self, name: str, value) -> None:') + lines.append(' """配置变化回调"""') + lines.append(' self.logger.info(f"配置变化: {name} = {value}")') + lines.append('') + + # 事件处理器 - 带类型提示 + for event in events: + handler_name = f'_on_{event.lower().replace("event", "")}' + lines.append(f' def {handler_name}(self, event: {event}) -> None:') + lines.append(f' """处理 {event}"""') + lines.append(f' self.logger.info(f"收到 {event}")') + if needs_gui: + lines.append(' # self._widget._update_signal.emit("...")') + lines.append('') + + return '\n'.join(lines) + + +# ═══════════════════════════════════════════════════════════════════ +# 代码生成 - 模块化包结构 +# ═══════════════════════════════════════════════════════════════════ + +def generate_package_files( + name: str, + description: str = "", + version: str = "1.0.0", + author: str = "", + window_mode: str = "TAB", + icon_color: str = "#4CAF50", + icon_char: str = "", + events: list[str] = None, + commands: list[str] = None, + needs_config: bool = False, + needs_service: bool = False, + service_name: str = "", +) -> dict[str, str]: + """生成模块化包结构的多个文件""" + if events is None: + events = [] + if commands is None: + commands = [] + + if not icon_char: + icon_char = name[0].upper() + + needs_gui = window_mode != "CLOSED" + class_prefix = _to_class_name(name) + files = {} + + # ═══════════════════════════════════════════════════════════════════ + # config.py + # ═══════════════════════════════════════════════════════════════════ + if needs_config: + config_lines = [ + '"""', + f'{name} - 配置定义', + '"""', + 'from __future__ import annotations', + '', + 'from plugin_sdk import OtherInfoBase, BoolConfig', + '', + '', + f'class {class_prefix}Config(OtherInfoBase):', + ' """插件配置"""', + ' ', + ' enable_feature = BoolConfig(', + ' default=True,', + ' label="启用功能",', + ' )', + ] + files['config.py'] = '\n'.join(config_lines) + + + # ═══════════════════════════════════════════════════════════════════ + # widgets.py + # ═══════════════════════════════════════════════════════════════════ + if needs_gui: + widget_lines = [ + '"""', + f'{name} - UI 组件', + '"""', + 'from __future__ import annotations', + '', + 'from PyQt5.QtWidgets import QWidget, QVBoxLayout, QLabel', + 'from PyQt5.QtCore import pyqtSignal', + '', + '', + f'class {class_prefix}Widget(QWidget):', + ' """插件 UI"""', + ' ', + ' _update_signal = pyqtSignal(str)', + '', + ' def __init__(self, parent=None):', + ' super().__init__(parent)', + ' layout = QVBoxLayout(self)', + ' ', + ' self._label = QLabel("就绪")', + ' layout.addWidget(self._label)', + ' ', + ' self._update_signal.connect(self._on_update)', + '', + ' def _on_update(self, text: str) -> None:', + ' """更新显示文本"""', + ' self._label.setText(text)', + ] + files['widgets.py'] = '\n'.join(widget_lines) + + + # 注意:服务接口不放在插件包内,而是放在 plugins/services/{name}.py + # 由 cmd_create 函数单独处理 + + + # ═══════════════════════════════════════════════════════════════════ + # plugin.py + # ═══════════════════════════════════════════════════════════════════ + plugin_lines = [ + '"""', + f'{name} - 插件主类', + '"""', + 'from __future__ import annotations', + '', + ] + + # 导入 + if needs_gui: + plugin_lines.append('from PyQt5.QtWidgets import QWidget') + + sdk_imports = ['BasePlugin', 'PluginInfo'] + if needs_gui: + sdk_imports.extend(['make_plugin_icon', 'WindowMode']) + plugin_lines.append(f'from plugin_sdk import {", ".join(sdk_imports)}') + + if events: + plugin_lines.append(f'from shared_types.events import {", ".join(events)}') + if commands: + plugin_lines.append(f'from shared_types.commands import {", ".join(commands)}') + + if needs_gui: + plugin_lines.append(f'from .widgets import {class_prefix}Widget') + if needs_config: + plugin_lines.append(f'from .config import {class_prefix}Config') + if needs_service and service_name: + plugin_lines.append(f'from plugins.services.{name} import {service_name}') + + plugin_lines.append('') + plugin_lines.append('') + + # 主插件类 + plugin_name = f'{class_prefix}Plugin' + plugin_lines.append(f'class {plugin_name}(BasePlugin):') + plugin_lines.append(f' """{description}"""') + plugin_lines.append('') + + # plugin_info + plugin_lines.append(' @classmethod') + plugin_lines.append(' def plugin_info(cls) -> PluginInfo:') + plugin_lines.append(' return PluginInfo(') + plugin_lines.append(f' name="{name}",') + plugin_lines.append(f' version="{version}",') + if author: + plugin_lines.append(f' author="{author}",') + plugin_lines.append(f' description="{description}",') + + if needs_gui: + plugin_lines.append(f' window_mode=WindowMode.{window_mode},') + plugin_lines.append(f' icon=make_plugin_icon("{icon_color}", "{icon_char}"),') + else: + plugin_lines.append(f' window_mode=WindowMode.CLOSED,') + + if needs_config: + plugin_lines.append(f' other_info={class_prefix}Config,') + if commands: + plugin_lines.append(f' required_controls=[{", ".join(commands)}],') + + plugin_lines.append(' )') + plugin_lines.append('') + + # _setup_subscriptions + plugin_lines.append(' def _setup_subscriptions(self) -> None:') + if events: + for event in events: + handler_name = f'_on_{event.lower().replace("event", "")}' + plugin_lines.append(f' self.subscribe({event}, self.{handler_name})') + else: + plugin_lines.append(' pass') + plugin_lines.append('') + + # _create_widget + if needs_gui: + plugin_lines.append(' def _create_widget(self) -> QWidget | None:') + plugin_lines.append(f' self._widget = {class_prefix}Widget()') + plugin_lines.append(' return self._widget') + plugin_lines.append('') + + # on_initialized + plugin_lines.append(' def on_initialized(self) -> None:') + plugin_lines.append(f' self.logger.info("{plugin_name} 已初始化")') + + if needs_service and service_name: + plugin_lines.append(' ') + plugin_lines.append(' # 注册服务接口') + plugin_lines.append(f' self.register_service(self, protocol={service_name})') + plugin_lines.append(f' self.logger.info("{service_name} 已注册")') + + if commands: + plugin_lines.append(' ') + plugin_lines.append(' # 检查控制权限') + for cmd in commands: + plugin_lines.append(f' has_auth = self.has_control_auth({cmd})') + plugin_lines.append(f' self.logger.info(f"{cmd} 权限: {{has_auth}}")') + + if needs_config: + plugin_lines.append(' ') + plugin_lines.append(' self.config_changed.connect(self._on_config_changed)') + + plugin_lines.append('') + + # on_control_auth_changed + if commands: + plugin_lines.append(' def on_control_auth_changed(self, cmd_type, granted: bool) -> None:') + plugin_lines.append(' """控制权限变更回调"""') + for cmd in commands: + plugin_lines.append(f' if cmd_type == {cmd}:') + plugin_lines.append(' self.logger.info(f"权限变更: {granted}")') + plugin_lines.append('') + + # _on_config_changed + if needs_config: + plugin_lines.append(' def _on_config_changed(self, name: str, value) -> None:') + plugin_lines.append(' """配置变化回调"""') + plugin_lines.append(' self.logger.info(f"配置变化: {name} = {value}")') + plugin_lines.append('') + + # 事件处理器 - 带类型提示 + for event in events: + handler_name = f'_on_{event.lower().replace("event", "")}' + plugin_lines.append(f' def {handler_name}(self, event: {event}) -> None:') + plugin_lines.append(f' """处理 {event}"""') + plugin_lines.append(f' self.logger.info(f"收到 {event}")') + if needs_gui: + plugin_lines.append(' # self._widget._update_signal.emit("...")') + plugin_lines.append('') + + files['plugin.py'] = '\n'.join(plugin_lines) + + # ═══════════════════════════════════════════════════════════════════ + # __init__.py + # ═══════════════════════════════════════════════════════════════════ + init_lines = [ + '"""', + f'{name} - {description}', + '"""', + 'from __future__ import annotations', + '', + f'from .plugin import {plugin_name}', + '', + f'__all__ = ["{plugin_name}"]', + ] + files['__init__.py'] = '\n'.join(init_lines) + + return files + + +def generate_service_interface(service_name: str, description: str = "") -> str: + """生成服务接口文件""" + # 从 ServiceName 提取基础名(去掉 Service 后缀) + base_name = service_name.replace("Service", "") + + lines = [ + '"""', + f'{service_name} 服务接口', + '"""', + 'from __future__ import annotations', + '', + 'from typing import Protocol, runtime_checkable', + 'from dataclasses import dataclass', + '', + '', + '# @dataclass(frozen=True, slots=True)', + f'# class {base_name}Data:', + '# """数据类型"""', + '# id: int', + '# name: str', + '', + '', + '@runtime_checkable', + f'class {service_name}(Protocol):', + ' """服务接口定义"""', + ' pass', + '', + '# 示例方法(取消注释后使用):', + f'# def get_data(self, id: int) -> {base_name}Data | None: ...', + f'# def list_data(self, limit: int = 100) -> list[{base_name}Data]: ...', + '', + ] + return '\n'.join(lines) + + +# ═══════════════════════════════════════════════════════════════════ +# 命令行接口 +# ═══════════════════════════════════════════════════════════════════ + +def cmd_discover(args): + """发现可用的类型""" + result = { + "environment": "frozen" if is_frozen() else "dev", + "install_dir": str(get_install_dir()), + "plugins_dir": str(get_plugins_dir()), + "shared_types_dir": str(get_shared_types_dir()), + "events": discover_events(), + "commands": discover_commands(), + } + print(json.dumps(result, ensure_ascii=False, indent=2)) + + +def cmd_create(args): + """创建插件""" + plugins_dir = get_plugins_dir() + plugins_dir.mkdir(parents=True, exist_ok=True) + + created_files = [] + + # 生成服务接口文件(放在 plugins/services/{name}.py) + if args.service: + services_dir = plugins_dir / "services" + services_dir.mkdir(parents=True, exist_ok=True) + + class_prefix = _to_class_name(args.name) + service_name = f"{class_prefix}Service" + + service_content = generate_service_interface( + service_name=service_name, + description=args.description or "", + ) + + service_file = services_dir / f"{args.name}.py" + service_file.write_text(service_content, encoding="utf-8") + created_files.append(str(service_file)) + + if args.package: + # 包形式 - 生成多个文件 + files = generate_package_files( + name=args.name, + description=args.description or "", + version=args.version, + author=args.author or "", + window_mode=args.window_mode, + icon_color=args.icon_color, + icon_char=args.icon_char or "", + events=args.events.split(",") if args.events else [], + commands=args.commands.split(",") if args.commands else [], + needs_config=args.config, + needs_service=args.service, + service_name=_to_class_name(args.name) + "Service" if args.service else "", + ) + + pkg_dir = plugins_dir / args.name + pkg_dir.mkdir(parents=True, exist_ok=True) + + for filename, content in files.items(): + file_path = pkg_dir / filename + file_path.write_text(content, encoding="utf-8") + created_files.append(str(file_path)) + + result = { + "success": True, + "is_package": True, + "files": created_files, + } + else: + # 单文件形式 + code = generate_single_file_plugin( + name=args.name, + description=args.description or "", + version=args.version, + author=args.author or "", + window_mode=args.window_mode, + icon_color=args.icon_color, + icon_char=args.icon_char or "", + events=args.events.split(",") if args.events else [], + commands=args.commands.split(",") if args.commands else [], + needs_config=args.config, + needs_service=args.service, + service_name=_to_class_name(args.name) + "Service" if args.service else "", + ) + + plugin_file = plugins_dir / f"{args.name}.py" + plugin_file.write_text(code, encoding="utf-8") + created_files.append(str(plugin_file)) + + result = { + "success": True, + "is_package": False, + "files": created_files, + } + + print(json.dumps(result, ensure_ascii=False, indent=2)) + + +def main(): + parser = argparse.ArgumentParser(description="插件创建工具") + subparsers = parser.add_subparsers(dest="command", help="命令") + + # discover 命令 + p_discover = subparsers.add_parser("discover", help="发现可用的类型") + p_discover.set_defaults(func=cmd_discover) + + # create 命令 + p_create = subparsers.add_parser("create", help="创建插件") + p_create.add_argument("--name", required=True, help="插件名称") + p_create.add_argument("--description", default="", help="插件描述") + p_create.add_argument("--version", default="1.0.0", help="版本号") + p_create.add_argument("--author", default="", help="作者") + p_create.add_argument("--window-mode", default="TAB", choices=["TAB", "DETACHED", "CLOSED"], help="窗口模式") + p_create.add_argument("--icon-color", default="#4CAF50", help="图标颜色") + p_create.add_argument("--icon-char", default="", help="图标字符") + p_create.add_argument("--package", action="store_true", help="创建包形式插件") + p_create.add_argument("--events", default="", help="订阅的事件,逗号分隔") + p_create.add_argument("--commands", default="", help="需要的控制权限,逗号分隔") + p_create.add_argument("--config", action="store_true", help="需要配置系统") + p_create.add_argument("--service", action="store_true", help="需要服务接口") + p_create.add_argument("--service-name", default="", help="服务接口名称") + p_create.set_defaults(func=cmd_create) + + args = parser.parse_args() + + if args.command is None: + parser.print_help() + return + + args.func(args) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/src/history_gui.py b/src/history_gui.py deleted file mode 100644 index 11f3d25..0000000 --- a/src/history_gui.py +++ /dev/null @@ -1,741 +0,0 @@ -import math -import json -from pathlib import Path -import sqlite3 -import subprocess -import sys -from turtle import right -from typing import Any -from PyQt5.QtGui import QCloseEvent -from PyQt5.QtWidgets import QApplication, QWidget, QVBoxLayout, QTableWidget, QMenu, \ - QAction, QTableWidgetItem, QHeaderView, QTableView, QMessageBox, QFileDialog, \ - QComboBox, QLineEdit, QSpinBox, QDoubleSpinBox, QDateTimeEdit, QHBoxLayout, QPushButton, \ - QSpacerItem, QSizePolicy, QLabel -from PyQt5.QtCore import Qt, QCoreApplication, QAbstractTableModel, QModelIndex -from datetime import datetime -import inspect -from utils import GameBoardState, BaseDiaPlayEnum, get_paths, patch_env, GameMode, GameLevel -_translate = QCoreApplication.translate -# 逻辑符 - - -class LogicSymbol(BaseDiaPlayEnum): - And = 0 - Or = 1 - - @property - def display_name(self): - match self: - case LogicSymbol.And: - return _translate("Form", "与") - case LogicSymbol.Or: - return _translate("Form", "或") - - @property - def to_sql(self): - match self: - case LogicSymbol.And: - return "and" - case LogicSymbol.Or: - return "or" - - -class CompareSymbol(BaseDiaPlayEnum): - Equal = 0 - NotEqual = 1 - GreaterThan = 2 - LessThan = 3 - GreaterThanOrEqual = 4 - LessThanOrEqual = 5 - Contains = 6 - NotContains = 7 - - @property - def display_name(self): - match self: - case CompareSymbol.Equal: - return _translate("Form", "等于") - case CompareSymbol.NotEqual: - return _translate("Form", "不等于") - case CompareSymbol.GreaterThan: - return _translate("Form", "大于") - case CompareSymbol.LessThan: - return _translate("Form", "小于") - case CompareSymbol.GreaterThanOrEqual: - return _translate("Form", "大于等于") - case CompareSymbol.LessThanOrEqual: - return _translate("Form", "小于等于") - case CompareSymbol.Contains: - return _translate("Form", "包含") - case CompareSymbol.NotContains: - return _translate("Form", "不包含") - - @property - def to_sql(self): - match self: - case CompareSymbol.Equal: - return "=" - case CompareSymbol.NotEqual: - return "!=" - case CompareSymbol.GreaterThan: - return ">" - case CompareSymbol.LessThan: - return "<" - case CompareSymbol.GreaterThanOrEqual: - return ">=" - case CompareSymbol.LessThanOrEqual: - return "<=" - case CompareSymbol.Contains: - return "in" - case CompareSymbol.NotContains: - return "not in" - - -class HistoryData: - replay_id: int = 0 - game_board_state: GameBoardState = GameBoardState.Win - rtime: float = 0 - left: int = 0 - right: int = 0 - double: int = 0 - left_s: float = 0.0 - right_s: float = 0.0 - double_s: float = 0.0 - level: GameLevel = GameLevel.BEGINNER - cl: int = 0 - cl_s: float = 0.0 - ce: int = 0 - ce_s: float = 0.0 - rce: int = 0 - lce: int = 0 - dce: int = 0 - bbbv: int = 0 - bbbv_solved: int = 0 - bbbv_s: float = 0.0 - flag: int = 0 - path: float = 0.0 - etime: float = datetime.now() - start_time: datetime = datetime.now() - end_time: datetime = datetime.now() - mode: GameMode = GameMode.Standard - software: str = "" - player_identifier: str = "" - race_identifier: str = "" - uniqueness_identifier: str = "" - stnb: float = 0.0 - corr: float = 0.0 - thrp: float = 0.0 - ioe: float = 0.0 - is_official: int = 0 - is_fair: int = 0 - op: int = 0 - isl: int = 0 - pluck: float = 0.0 - - @classmethod - def get_field_value(cls, field_name: str): - for name, value in inspect.getmembers(cls): - if not name.startswith("__") and not callable(value) and not name.startswith("_") and name == field_name: - return value - - @classmethod - def fields(cls): - return [name for name, value in inspect.getmembers(cls) if not name.startswith("__") and not callable(value) and not name.startswith("_")] - - @classmethod - def query_all(cls): - return f"select {','.join(cls.fields())} from history" - - @classmethod - def from_dict(cls, data: dict): - instance = cls() - for name, value in inspect.getmembers(cls): - if not name.startswith("__") and not callable(value) and not name.startswith("_"): - new_value = data.get(name) - if isinstance(value, datetime): - value = datetime.fromtimestamp(new_value / 1_000_000) - elif isinstance(value, float): - value = round(new_value, 4) - elif isinstance(value, BaseDiaPlayEnum): - value = value.__class__(new_value) - else: - value = new_value - setattr(instance, name, value) - return instance - - -class HistoryTableModel(QAbstractTableModel): - def __init__(self, data: list[HistoryData], headers: list[str], show_fields: set[str], parent=None): - super().__init__(parent) - self._data = data - self._headers = headers - self._show_fields = show_fields - # 只显示在show_fields中的列 - self._visible_headers = [h for h in headers if h in show_fields] - - def rowCount(self, parent=None): - return len(self._data) - - def columnCount(self, parent=None): - return len(self._visible_headers) - - def data(self, index: QModelIndex, role=Qt.DisplayRole): - if not index.isValid(): - return None - - row = index.row() - col = index.column() - - if row >= len(self._data) or col >= len(self._visible_headers): - return None - - if role == Qt.DisplayRole: - field_name = self._visible_headers[col] - value = getattr(self._data[row], field_name) - - # 格式化显示值 - if isinstance(value, datetime): - return value.strftime("%Y-%m-%d %H:%M:%S.%f") - elif isinstance(value, BaseDiaPlayEnum): - return value.display_name - else: - return str(value) - - elif role == Qt.UserRole: - # 返回原始值 - field_name = self._visible_headers[col] - return getattr(self._data[row], field_name) - - elif role == Qt.TextAlignmentRole: - return Qt.AlignCenter | Qt.AlignVCenter - - return None - - def headerData(self, section: int, orientation: Qt.Orientation, role=Qt.DisplayRole): - if role == Qt.DisplayRole and orientation == Qt.Horizontal: - if section < len(self._visible_headers): - return self._visible_headers[section] - return None - - def update_data(self, data: list[HistoryData]): - self.beginResetModel() - self._data = data - self.endResetModel() - - def update_show_fields(self, show_fields: set[str]): - self.beginResetModel() - self._show_fields = show_fields - self._visible_headers = [h for h in self._headers if h in show_fields] - self.endResetModel() - - -class FliterWidget(QWidget): - def __init__(self, parent: QWidget | None = ...) -> None: - super().__init__(parent) - self.vbox = QVBoxLayout(self) - self.table = QTableWidget(self) - self.table.setColumnCount(6) - self.table.setHorizontalHeaderLabels( - ["左括号", "字段", "比较符", "值", "右括号", "逻辑符"]) - self.table.horizontalHeader().setDefaultAlignment(Qt.AlignCenter) - # 自定义右键菜单 - self.table.setContextMenuPolicy(Qt.CustomContextMenu) - self.table.customContextMenuRequested.connect(self.show_context_menu) - self.table.setSelectionBehavior(QTableView.SelectRows) - self.table.setSelectionMode(QTableView.SingleSelection) - self.vbox.addWidget(self.table) - self.setLayout(self.vbox) - - def show_context_menu(self, pos): - menu = QMenu(self) - menu.addAction(_translate("Form", "添加"), self.add_row) - menu.addAction(_translate("Form", "删除"), self.del_row) - menu.addAction(_translate("Form", "插入"), - lambda: self.insert_row(self.table.currentRow())) - menu.exec_(self.table.mapToGlobal(pos)) - - def build_left_bracket_Widget(self): - widget = QComboBox(self) - widget.addItems(["", "(", "(("]) - return widget - - def build_field_Widget(self): - widget = QComboBox(self) - widget.addItems(HistoryData.fields()) - widget.currentIndexChanged.connect(self.on_field_changed) - return widget - - def build_compare_Widget(self): - widget = QComboBox(self) - widget.addItems(CompareSymbol.display_names()) - widget.currentIndexChanged.connect(self.on_compare_changed) - return widget - - def build_right_bracket_Widget(self): - widget = QComboBox(self) - widget.addItems(["", ")", "))"]) - return widget - - def build_logic_Widget(self): - widget = QComboBox(self) - widget.addItems(LogicSymbol.display_names()) - - return widget - - def on_field_changed(self, index): - combo: QComboBox = self.sender() - item_index = self.table.indexAt(combo.pos()) - filed_name = combo.currentText() - if item_index.isValid(): - row = item_index.row() - compareSymbol_widget = self.table.cellWidget(row, 2) - compare_name = compareSymbol_widget.currentText() - compare = CompareSymbol.from_display_name(compare_name) - field_cls = HistoryData.get_field_value(filed_name) - widget = self.build_value_widget(compare, field_cls) - self.table.setCellWidget(row, 3, widget) - - def on_compare_changed(self, index): - combo: QComboBox = self.sender() - item_index = self.table.indexAt(combo.pos()) - if item_index.isValid(): - row = item_index.row() - field_widget = self.table.cellWidget(row, 1) - filed_name = field_widget.currentText() - compare_name = combo.currentText() - compare = CompareSymbol.from_display_name(compare_name) - field_cls = HistoryData.get_field_value(filed_name) - widget = self.build_value_widget(compare, field_cls) - self.table.setCellWidget(row, 3, widget) - - def build_value_widget(self, compare: CompareSymbol, field_value: Any): - if compare not in (CompareSymbol.Contains, CompareSymbol.NotContains): - if isinstance(field_value, int): - widget = QSpinBox(self) - elif isinstance(field_value, float): - widget = QDoubleSpinBox(self) - elif isinstance(field_value, str): - widget = QLineEdit(self) - elif isinstance(field_value, datetime): - widget = QDateTimeEdit(self) - elif isinstance(field_value, BaseDiaPlayEnum): - widget = QComboBox(self) - widget.addItems(field_value.display_names()) - else: - widget = QLineEdit(self) - return widget - - def add_row(self): - self.insert_row(self.table.rowCount()) - - def del_row(self): - self.table.removeRow(self.table.currentRow()) - - def insert_row(self, row: int): - self.table.insertRow(row) - field_widget = self.build_field_Widget() - compare_widget = self.build_compare_Widget() - compare = CompareSymbol.from_display_name(compare_widget.currentText()) - field_value = HistoryData.get_field_value(field_widget.currentText()) - self.table.setCellWidget(row, 0, self.build_left_bracket_Widget()) - self.table.setCellWidget(row, 1, field_widget) - self.table.setCellWidget(row, 2, compare_widget) - self.table.setCellWidget( - row, 3, self.build_value_widget(compare, field_value)) - self.table.setCellWidget(row, 4, self.build_right_bracket_Widget()) - self.table.setCellWidget(row, 5, self.build_logic_Widget()) - - def gen_fliter_str(self): - fliter_str = "" - left_count = 0 - right_count = 0 - for row in range(self.table.rowCount()): - - left_bracket_widget = self.table.cellWidget(row, 0) - field_widget = self.table.cellWidget(row, 1) - compare_widget = self.table.cellWidget(row, 2) - value_widget = self.table.cellWidget(row, 3) - right_bracket_widget = self.table.cellWidget(row, 4) - logic_widget = self.table.cellWidget(row, 5) - left_bracket = left_bracket_widget.currentText() - field = field_widget.currentText() - field_init_value = HistoryData.get_field_value(field) - compare = CompareSymbol.from_display_name( - compare_widget.currentText()) - right_bracket = right_bracket_widget.currentText() - logic = LogicSymbol.from_display_name( - logic_widget.currentText()).to_sql - if left_bracket == "(": - left_count += 1 - elif left_bracket == "((": - left_count += 2 - - if right_bracket == ")": - right_count += 1 - elif right_bracket == "))": - right_count += 2 - - if right_count > left_count: - QMessageBox.warning(self, "错误", f"第{row}行 右括号数量大于左括号数量,请检查") - return - - if isinstance(value_widget, QComboBox): - filed_cls = type(field_init_value) - value = filed_cls.from_display_name( - value_widget.currentText()).value - elif isinstance(value_widget, QDateTimeEdit): - value = int(value_widget.dateTime( - ).toPyDateTime().timestamp() * 1_000_000) - elif isinstance(value_widget, QSpinBox): - value = str(value_widget.value()) - elif isinstance(value_widget, QDoubleSpinBox): - value = str(value_widget.value()) - elif isinstance(value_widget, QLineEdit): - if compare in (CompareSymbol.Contains, CompareSymbol.NotContains): - if isinstance(field_init_value, (int, float)): - values = value_widget.text().split(",") - for v in values: - if not v.isdigit(): - QMessageBox.warning( - self, "错误", f"第{row}行 {v} 不是数字,请输入数字") - return None - value = ",".join(str(v) for v in values) - elif isinstance(field_init_value, BaseDiaPlayEnum): - values = value_widget.text().split(",") - filed_cls = type(field_init_value) - for v in values: - if v not in field_init_value.display_names(): - QMessageBox.warning( - self, "错误", f"第{row}行 {v} 不是合法的枚举值,请输入合法的枚举值") - return None - values = [filed_cls.from_display_name( - v).value for v in values] - value = ",".join(str(v) for v in values) - elif isinstance(field_init_value, datetime): - values = value_widget.text().split(",") - for v in values: - try: - d = datetime.strptime(v, "%Y-%m-%d %H:%M:%S") - except ValueError: - QMessageBox.warning( - self, "错误", f"第{row}行 {v} 不是合法的日期时间戳,请输入合法的日期时间戳,格式为: %Y-%m-%d %H:%M:%S") - return None - values = [int(datetime.strptime( - v, "%Y-%m-%d %H:%M:%S").timestamp() * 1_000_000) for d in values] - value = ",".join(str(v) for v in values) - else: - value = ",".join( - f"'{v}'" for v in value_widget.text().split(",")) - value = f"({value})" - else: - value = f"'{value_widget.text()}'" - if row == self.table.rowCount() - 1: - fliter_str += f" {left_bracket} {field} {compare.to_sql} {value} {right_bracket} " - else: - fliter_str += f" {left_bracket} {field} {compare.to_sql} {value} {right_bracket} {logic}" - if left_count != right_count: - QMessageBox.warning(self, "错误", f"左括号数量和右括号数量不匹配,请检查") - return None - return fliter_str - - -class HistoryTable(QWidget): - def __init__(self, showFields: set[str], parent: QWidget | None = ...) -> None: - super().__init__(parent) - self.layout: QVBoxLayout = QVBoxLayout(self) - self.table = QTableView(self) - self.layout.addWidget(self.table) - self.setLayout(self.layout) - # 设置不可编辑 - self.table.setEditTriggers(QTableView.NoEditTriggers) - # 添加右键菜单 - self.table.setContextMenuPolicy(Qt.CustomContextMenu) - self.table.customContextMenuRequested.connect(self.show_context_menu) - self.showFields: set[str] = showFields - self.headers = [ - "replay_id", - "game_board_state", - "rtime", - "left", - "right", - "double", - "left_s", - "right_s", - "double_s", - "level", - "cl", - "cl_s", - "ce", - "ce_s", - "rce", - "lce", - "dce", - "bbbv", - "bbbv_solved", - "bbbv_s", - "flag", - "path", - "etime", - "start_time", - "end_time", - "mode", - "software", - "player_identifier", - "race_identifier", - "uniqueness_identifier", - "stnb", - "corr", - "thrp", - "ioe", - "is_official", - "is_fair", - "op", - "isl", - "pluck" - ] - - # 创建模型 - self.model = HistoryTableModel([], self.headers, self.showFields, self) - self.table.setModel(self.model) - - # 居中显示文字 - self.table.horizontalHeader().setDefaultAlignment(Qt.AlignCenter) - # 选中整行 - self.table.setSelectionBehavior(QTableView.SelectRows) - # 自适应列宽 - self.table.horizontalHeader().setSectionResizeMode( - QHeaderView.ResizeToContents) - - def load(self, data: list[HistoryData]): - # 使用模型更新数据 - self.model.update_data(data) - - def refresh(self): - parent: 'HistoryGUI' = self.parent() - parent.load_data() - - def show_context_menu(self, pos): - menu = QMenu(self) - action1 = menu.addAction(_translate("Form", "播放"), self.play_row) - action2 = menu.addAction(_translate("Form", "导出"), self.export_row) - action3 = menu.addAction(_translate("Form", "刷新"), self.refresh) - # 给action3添加子菜单 - submenu = QMenu(_translate("Form", "显示字段"), self) - # 遍历所有字段,添加一个action - for field in self.headers: - action = QAction(field, self) - action.setCheckable(True) - action.setChecked(field in self.showFields) - action.triggered.connect( - lambda checked: self.on_action_triggered(checked)) - submenu.addAction(action) - menu.addMenu(submenu) - menu.exec_(self.table.mapToGlobal(pos)) - - def on_action_triggered(self, checked: bool): - action: QAction = self.sender() - name = action.text() - if checked: - self.showFields.add(name) - else: - self.showFields.remove(name) - - # 更新模型的显示字段 - self.model.update_show_fields(self.showFields) - - def save_evf(self, evf_path: str): - row_index = self.table.currentIndex().row() - if row_index < 0: - return - - # 从模型获取数据 - replay_id_index = self.model._visible_headers.index( - "replay_id") if "replay_id" in self.model._visible_headers else -1 - if replay_id_index >= 0: - replay_id = self.model.data(self.model.index( - row_index, replay_id_index), Qt.UserRole) - else: - # 如果replay_id不在显示字段中,从原始数据获取 - replay_id = getattr(self.model._data[row_index], "replay_id") - conn = sqlite3.connect(Path(get_paths()) / "history.db") - conn.row_factory = sqlite3.Row # 设置行工厂 - cursor = conn.cursor() - cursor.execute( - "select raw_data from history where replay_id = ?", (replay_id,)) - - raw_data = cursor.fetchone()[0] - with open(evf_path, "wb") as f: - f.write(raw_data) - conn.close() - - def play_row(self): - temp_filename = Path(get_paths())/f"tmp.evf" - self.save_evf(temp_filename) - # 检查当前目录是否存在main.py - if (Path(get_paths()) / "main.py").exists(): - subprocess.Popen( - [ - sys.executable, - str(Path(get_paths()) / "main.py"), - temp_filename - ], - env=patch_env(), - ) - elif (Path(get_paths()) / "metaminesweeper.exe").exists(): - subprocess.Popen( - [ - Path(get_paths()) / "metaminesweeper.exe", - temp_filename - ] - ) - else: - QMessageBox.warning( - self, "错误", "当前目录下不存在main.py或metaminesweeper.exe") - return - - def export_row(self): - file_path, _ = QFileDialog.getSaveFileName(self, _translate( - "Form", "导出evf文件"), get_paths(), "evf文件 (*.evf)") - - if not file_path: - return - self.save_evf(file_path) - - -class HistoryGUI(QWidget): - def __init__(self, parent=None): - super().__init__(parent) - self.setWindowTitle(_translate("Form", "历史记录")) - self.resize(800, 600) - self.layout = QVBoxLayout(self) - self.button_layout = QHBoxLayout() - self.query_button = QPushButton(_translate("Form", "查询")) - self.button_layout.addWidget(self.query_button) - self.button_layout.addItem(QSpacerItem( - 10, 10, QSizePolicy.Expanding, QSizePolicy.Minimum)) - - self.table = HistoryTable(self.get_show_fields(), self) - self.fliterWidget = FliterWidget(self) - - self.limit_layout = QHBoxLayout() - self.previous_button = QPushButton(_translate("Form", "上一页")) - self.page_spin = QSpinBox() - self.page_spin.setMinimum(1) - self.page_spin.setValue(1) - self.next_button = QPushButton(_translate("Form", "下一页")) - self.one_page_combo = QComboBox() - self.one_page_combo.addItems( - ["10", "20", "50", "100", "200", "500", "1000"]) - self.limit_label = QLabel("") - self.limit_layout.addItem(QSpacerItem( - 10, 10, QSizePolicy.Expanding, QSizePolicy.Minimum)) - self.limit_layout.addWidget(self.limit_label) - self.limit_layout.addWidget(self.previous_button) - self.limit_layout.addWidget(self.page_spin) - self.limit_layout.addWidget(self.next_button) - self.limit_layout.addWidget(self.one_page_combo) - - self.layout.addLayout(self.button_layout) - self.layout.addWidget(self.fliterWidget) - self.layout.addWidget(self.table) - self.layout.addLayout(self.limit_layout) - self.setLayout(self.layout) - - self.init_connect() - self.load_data() - - def init_connect(self): - self.query_button.clicked.connect(self.on_query_button_clicked) - self.previous_button.clicked.connect(self.previous_page) - self.next_button.clicked.connect(self.next_page) - self.one_page_combo.currentTextChanged.connect(self.one_page_changed) - self.page_spin.valueChanged.connect(self.page_changed) - - def on_query_button_clicked(self): - if self.page_spin.value() > 1: - self.page_spin.setValue(1) - else: - self.load_data() - - def previous_page(self): - self.page_spin.setValue(self.page_spin.value() - 1) - - def next_page(self): - self.page_spin.setValue(self.page_spin.value() + 1) - - def one_page_changed(self, text): - self.limit_changed() - - def page_changed(self, value): - self.limit_changed() - - def limit_changed(self): - self.load_data() - - def get_limit_str(self): - return f" limit {self.one_page_combo.currentText()} offset {(self.page_spin.value() - 1) * int(self.one_page_combo.currentText())}" - - def load_data(self): - # 判断是否存在历史记录数据库 - if not (Path(get_paths()) / "history.db").exists(): - QMessageBox.warning(self, "错误", "历史记录数据库不存在") - return - try: - conn = sqlite3.connect(Path(get_paths()) / "history.db") - conn.row_factory = sqlite3.Row # 设置行工厂 - cursor = conn.cursor() - filter_str = self.fliterWidget.gen_fliter_str() - sql = f"select *,COUNT(*) OVER() AS total_count from history" - if filter_str: - sql += " where " + filter_str - elif filter_str is None: - return - sql += self.get_limit_str() - cursor.execute(sql) - datas = cursor.fetchall() - - if not datas: - self.page_spin.setMaximum(1) - self.limit_label.setText(f'共0行,0页') - else: - self.page_spin.setMaximum( - math.ceil(datas[0]['total_count'] / int(self.one_page_combo.currentText()))) - self.limit_label.setText( - f'共{datas[0]["total_count"]}行,{self.page_spin.maximum()}页') - - history_data = [HistoryData.from_dict( - dict(data)) for data in datas] - conn.close() - except sqlite3.Error as e: - QMessageBox.warning( - self, "错误", f"加载历史记录数据失败: {e}") - return - - self.table.load(history_data) - - @property - def config_path(self): - return Path(get_paths()) / "history_show_fields.json" - - def get_show_fields(self): - # 先判断是否存在展示列的json文件 - if not (self.config_path).exists(): - return set(HistoryData.fields()) - with open(self.config_path, "r") as f: - return set(json.load(f)) - - def closeEvent(self, a0: QCloseEvent | None) -> None: - with open(self.config_path, "w") as f: - json.dump(list(self.table.showFields), f) - return super().closeEvent(a0) - - -if __name__ == "__main__": - - app = QApplication(sys.argv) - - gui = HistoryGUI() - - gui.show() - - sys.exit(app.exec_()) diff --git a/src/plugin_manager/logging_setup.py b/src/plugin_manager/logging_setup.py index e8b20ba..300b325 100644 --- a/src/plugin_manager/logging_setup.py +++ b/src/plugin_manager/logging_setup.py @@ -156,3 +156,24 @@ def set_plugin_log_level(sink_id: int, level: str = "DEBUG") -> None: if level_info is not None: handler._levelno = level_info.no # type: ignore[union-attr] handler._levelname = level.upper() # type: ignore[union-attr] + + +def set_console_log_level(level: str = "DEBUG") -> None: + """ + 动态修改控制台日志输出的级别 + + Args: + level: 新的日志级别 ("TRACE", "DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL") + """ + global _console_sink_id + if _console_sink_id is None: + return + + config = loguru.logger._core + handler = config.handlers.get(_console_sink_id) + if handler is not None: + levels = config.levels + level_info = levels.get(level.upper()) + if level_info is not None: + handler._levelno = level_info.no # type: ignore[union-attr] + handler._levelname = level.upper() # type: ignore[union-attr] diff --git a/src/plugin_manager/main_window.py b/src/plugin_manager/main_window.py index 2ecf88f..8e0f8a8 100644 --- a/src/plugin_manager/main_window.py +++ b/src/plugin_manager/main_window.py @@ -11,7 +11,7 @@ from pathlib import Path from typing import TYPE_CHECKING -from PyQt5.QtCore import Qt, pyqtSignal, QPoint, QTimer +from PyQt5.QtCore import Qt, pyqtSignal, QPoint, QTimer, QEvent from PyQt5.QtGui import QColor, QMouseEvent, QIcon, QPixmap from PyQt5.QtWidgets import ( QApplication, @@ -30,6 +30,7 @@ QMainWindow, QMessageBox, QMenu, + QPlainTextEdit, QPushButton, QScrollArea, QStatusBar, @@ -45,6 +46,7 @@ ) from .plugin_state import PluginStateManager, PluginState +from .settings_manager import SettingsManager from plugin_sdk.plugin_base import PluginLifecycle, WindowMode, LogLevel from plugin_sdk.control_auth import ControlAuthorizationManager from .app_paths import get_data_dir @@ -497,6 +499,347 @@ def apply_config(self) -> None: self._config_widget.apply_to_config() +# ═══════════════════════════════════════════════════════════════════ +# 基础设置对话框 +# ═══════════════════════════════════════════════════════════════════ + +class BasicSettingsDialog(QDialog): + """ + 插件管理器基础设置对话框 + + 包含日志等级等基础配置。 + """ + + # 设置变更信号 + settings_changed = pyqtSignal() + + def __init__(self, settings_manager: "SettingsManager", parent=None) -> None: + super().__init__(parent) + self._settings_manager = settings_manager + self.setWindowTitle("基础设置") + self.setMinimumWidth(400) + self._setup_ui() + + def _setup_ui(self) -> None: + layout = QVBoxLayout(self) + + # ── 主进程日志设置组 ── + main_log_group = QGroupBox("主进程文件日志") + main_log_layout = QFormLayout(main_log_group) + + self._file_log_level_combo = QComboBox() + self._file_log_level_combo.addItems(SettingsManager.LOG_LEVELS) + index = self._file_log_level_combo.findText(self._settings_manager.file_log_level) + if index >= 0: + self._file_log_level_combo.setCurrentIndex(index) + + file_log_label = QLabel("日志等级") + file_log_label.setToolTip("主进程日志文件的记录等级") + main_log_layout.addRow(file_log_label, self._file_log_level_combo) + + layout.addWidget(main_log_group) + + # ── 日志查看器设置组 ── + viewer_group = QGroupBox("日志查看器") + viewer_layout = QFormLayout(viewer_group) + + self._viewer_log_level_combo = QComboBox() + self._viewer_log_level_combo.addItems(SettingsManager.LOG_LEVELS) + index = self._viewer_log_level_combo.findText(self._settings_manager.viewer_log_level) + if index >= 0: + self._viewer_log_level_combo.setCurrentIndex(index) + + viewer_log_label = QLabel("日志等级") + viewer_log_label.setToolTip("日志查看器显示的日志等级") + viewer_layout.addRow(viewer_log_label, self._viewer_log_level_combo) + + self._auto_scroll_cb = QCheckBox() + self._auto_scroll_cb.setChecked(self._settings_manager.viewer_auto_scroll) + viewer_layout.addRow("自动滚动", self._auto_scroll_cb) + + self._show_source_cb = QCheckBox() + self._show_source_cb.setChecked(self._settings_manager.viewer_show_source) + viewer_layout.addRow("显示来源", self._show_source_cb) + + layout.addWidget(viewer_group) + + # 按钮盒 + button_box = QDialogButtonBox( + QDialogButtonBox.Ok | QDialogButtonBox.Cancel + ) + button_box.accepted.connect(self._on_accept) + button_box.rejected.connect(self.reject) + layout.addWidget(button_box) + + def _on_accept(self) -> None: + """保存设置""" + changed = False + + # 主进程文件日志等级 + new_file_level = self._file_log_level_combo.currentText() + if new_file_level != self._settings_manager.file_log_level: + self._settings_manager.set_file_log_level(new_file_level) # type: ignore + changed = True + + # 日志查看器等级 + new_viewer_level = self._viewer_log_level_combo.currentText() + if new_viewer_level != self._settings_manager.viewer_log_level: + self._settings_manager.set_viewer_log_level(new_viewer_level) # type: ignore + changed = True + + # 自动滚动 + new_auto_scroll = self._auto_scroll_cb.isChecked() + if new_auto_scroll != self._settings_manager.viewer_auto_scroll: + self._settings_manager.set_viewer_auto_scroll(new_auto_scroll) + changed = True + + # 显示来源 + new_show_source = self._show_source_cb.isChecked() + if new_show_source != self._settings_manager.viewer_show_source: + self._settings_manager.set_viewer_show_source(new_show_source) + changed = True + + if changed: + self.settings_changed.emit() + self.accept() + + +# ═══════════════════════════════════════════════════════════════════ +# 日志查看对话框 +# ═══════════════════════════════════════════════════════════════════ + +class LogViewerDialog(QDialog): + """ + 日志查看对话框(非模态) + + 通过 loguru sink 实时显示日志。 + """ + + # 日志信号: time_str, level, source, message + _log_signal = pyqtSignal(str, str, str, str) + + # 支持的日志等级 + LOG_LEVELS = ["TRACE", "DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] + + def __init__( + self, + plugin_names: list[str], + initial_log: str = "main", + initial_level: str = "DEBUG", + auto_scroll: bool = True, + show_source: bool = False, + parent=None + ) -> None: + """ + Args: + plugin_names: 插件名称列表 + initial_log: 初始显示的日志("main" 或插件名) + initial_level: 初始日志等级 + auto_scroll: 自动滚动默认值 + show_source: 显示来源信息默认值 + parent: 父窗口 + """ + super().__init__(parent) + self.setWindowTitle("日志查看") + self.setMinimumSize(900, 600) + self.setWindowFlags(self.windowFlags() | Qt.WindowMaximizeButtonHint) + + self._plugin_names = plugin_names + self._current_log = initial_log + self._current_level = initial_level + self._auto_scroll_default = auto_scroll + self._show_source_default = show_source + self._sink_id: int | None = None + + self._setup_ui() + self._log_signal.connect(self._append_log_line) + self._attach_sink(initial_log) + + def _setup_ui(self) -> None: + layout = QVBoxLayout(self) + + # 顶部:日志选择 + top_layout = QHBoxLayout() + + top_layout.addWidget(QLabel("日志源:")) + + self._log_combo = QComboBox() + self._log_combo.addItem("主进程", "main") + for name in self._plugin_names: + self._log_combo.addItem(f"插件: {name}", name) + self._log_combo.currentIndexChanged.connect(self._on_log_changed) + top_layout.addWidget(self._log_combo) + + top_layout.addSpacing(20) + + # 日志等级 + top_layout.addWidget(QLabel("等级:")) + + self._level_combo = QComboBox() + self._level_combo.addItems(self.LOG_LEVELS) + self._level_combo.setCurrentText(self._current_level) + self._level_combo.currentIndexChanged.connect(self._on_level_changed) + top_layout.addWidget(self._level_combo) + + top_layout.addSpacing(20) + + # 自动滚动 + self._auto_scroll_cb = QCheckBox("自动滚动") + self._auto_scroll_cb.setChecked(self._auto_scroll_default) + top_layout.addWidget(self._auto_scroll_cb) + + # 显示来源 + self._show_source_cb = QCheckBox("显示来源") + self._show_source_cb.setChecked(self._show_source_default) + top_layout.addWidget(self._show_source_cb) + + # 清空按钮 + clear_btn = QPushButton("清空") + clear_btn.clicked.connect(self._clear_log) + top_layout.addWidget(clear_btn) + + top_layout.addStretch() + layout.addLayout(top_layout) + + # 中部:日志内容 + self._log_view = QPlainTextEdit() + self._log_view.setReadOnly(True) + self._log_view.setMaximumBlockCount(5000) # 限制行数 + self._log_view.setStyleSheet(""" + QPlainTextEdit { + font-family: 'Consolas', 'Courier New', monospace; + font-size: 12px; + background-color: #1e1e1e; + color: #d4d4d4; + } + """) + layout.addWidget(self._log_view) + + # 底部:按钮 + button_box = QDialogButtonBox(QDialogButtonBox.Close) + button_box.rejected.connect(self.close) + layout.addWidget(button_box) + + def _attach_sink(self, log_name: str) -> None: + """添加 loguru sink""" + # 移除旧 sink + if self._sink_id is not None: + try: + loguru.logger.remove(self._sink_id) + except ValueError: + pass + self._sink_id = None + + self._current_log = log_name + + # 清空显示 + self._log_view.clear() + + # 保存信号引用(闭包需要) + log_signal = self._log_signal + + # 根据日志源设置过滤器 + if log_name == "main": + # 主进程日志:排除插件日志 + def filter_func(record): + return "plugin" not in record["extra"] + else: + # 插件日志:只显示该插件 + def filter_func(record, pn=log_name): + return record["extra"].get("plugin") == pn + + # sink 函数 + def sink_write(message): + record = message.record + time_obj = record["time"] + time_str = time_obj.strftime("%Y-%m-%d %H:%M:%S.%f")[:-3] # 去掉最后3位微秒 + level = record["level"].name + # 来源信息: name:function:line + source = f"{record['name']}:{record['function']}:{record['line']}" + text = str(message) + log_signal.emit(time_str, level, source, text) + + # 添加 sink + self._sink_id = loguru.logger.add( + sink_write, + level=self._current_level, + filter=filter_func, + format="{message}", + ) + logger.debug(f"Log viewer sink attached: {log_name}, level={self._current_level}, sink_id={self._sink_id}") + + def _append_log_line(self, time_str: str, level: str, source: str, message: str) -> None: + """追加一行日志""" + if self._show_source_cb.isChecked(): + line = f"{time_str} | {level:<7} | {source} | {message}" + else: + line = f"{time_str} | {level:<7} | {message}" + + # 简单的颜色标记 + if "ERROR" in level or "CRITICAL" in level: + self._log_view.appendHtml(f'{line}') + elif "WARNING" in level: + self._log_view.appendHtml(f'{line}') + elif "DEBUG" in level or "TRACE" in level: + self._log_view.appendHtml(f'{line}') + elif "INFO" in level: + self._log_view.appendHtml(f'{line}') + else: + self._log_view.appendPlainText(line) + + if self._auto_scroll_cb.isChecked(): + self._log_view.ensureCursorVisible() + + def _on_log_changed(self, index: int) -> None: + """日志源切换""" + log_name = self._log_combo.itemData(index) + self._attach_sink(log_name) + + def _on_level_changed(self, index: int) -> None: + """日志等级切换""" + self._current_level = self._level_combo.currentText() + self._attach_sink(self._current_log) + + def _clear_log(self) -> None: + """清空日志显示""" + self._log_view.clear() + + def closeEvent(self, event) -> None: + """关闭时移除 sink""" + if self._sink_id is not None: + try: + loguru.logger.remove(self._sink_id) + except ValueError: + pass + self._sink_id = None + super().closeEvent(event) + + def show_log(self, log_name: str) -> None: + """切换到指定日志""" + # 如果日志源不在列表中,添加它 + index = self._log_combo.findData(log_name) + if index < 0 and log_name != "main": + self._log_combo.addItem(f"插件: {log_name}", log_name) + index = self._log_combo.count() - 1 + + if index >= 0: + self._log_combo.setCurrentIndex(index) + + def update_settings(self, level: str, auto_scroll: bool, show_source: bool) -> None: + """更新设置""" + # 更新日志等级 + if level != self._current_level: + self._current_level = level + self._level_combo.setCurrentText(level) + self._attach_sink(self._current_log) + + # 更新自动滚动 + self._auto_scroll_cb.setChecked(auto_scroll) + + # 更新显示来源 + self._show_source_cb.setChecked(show_source) + + # ═══════════════════════════════════════════════════════════════════ # 控制授权配置对话框 # ═══════════════════════════════════════════════════════════════════ @@ -690,6 +1033,9 @@ def __init__(self, plugin_manager: PluginManager, parent=None): # 状态持久化 self._state_mgr = PluginStateManager(get_data_dir() / "plugin_states.json") self._state_mgr.load() + + # 设置管理 + self._settings_mgr = SettingsManager(get_data_dir()) self.setWindowTitle(self.tr("插件管理器")) self.setMinimumSize(800, 600) @@ -716,20 +1062,47 @@ def __init__(self, plugin_manager: PluginManager, parent=None): def _setup_ui(self) -> None: """构建界面""" + # ── 菜单栏 ── + menubar = self.menuBar() + + # 选项菜单 + options_menu = menubar.addMenu(self.tr("选项")) + + # 设置子菜单 + settings_menu = options_menu.addMenu(self.tr("设置")) + + # 基础设置动作 + act_basic_settings = settings_menu.addAction(self.tr("基础设置...")) + act_basic_settings.triggered.connect(self._open_basic_settings_dialog) + + # 控制授权动作 + act_control_auth = settings_menu.addAction(self.tr("控制授权...")) + act_control_auth.triggered.connect(self._open_control_auth_dialog) + + settings_menu.addSeparator() + + # 调试动作 + self._debug_act = settings_menu.addAction(self.tr("启动调试")) + self._debug_act.triggered.connect(self._start_debug) + + options_menu.addSeparator() + + # 插件开发指南动作 + act_dev_guide = options_menu.addAction(self.tr("插件开发指南")) + act_dev_guide.triggered.connect(self._open_dev_guide) + + # ── 查看菜单 ── + view_menu = menubar.addMenu(self.tr("查看")) + + # 日志查看动作 + act_log_viewer = view_menu.addAction(self.tr("日志查看")) + act_log_viewer.triggered.connect(lambda: self._open_log_viewer()) + # ── 工具栏 ── toolbar = QToolBar(self.tr("工具栏")) toolbar.setMovable(False) self.addToolBar(toolbar) - # 连接按钮 - btn = QPushButton(self.tr("连接")) - btn.setCheckable(True) - btn.setToolTip(self.tr("连接/断开主进程")) - self._conn_btn = btn - toolbar.addWidget(btn) - - toolbar.addSeparator() - # 刷新按钮 self._refresh_btn = QPushButton(self.tr("刷新")) self._refresh_btn.setToolTip(self.tr("刷新插件列表")) @@ -737,14 +1110,6 @@ def _setup_ui(self) -> None: toolbar.addSeparator() - # 调试按钮 - self._debug_btn = QPushButton("🐛 Debug") - self._debug_btn.setCheckable(True) - self._debug_btn.setToolTip(self.tr("开启/关闭远程调试 (debugpy)")) - toolbar.addWidget(self._debug_btn) - - toolbar.addSeparator() - # 控制授权按钮 self._control_auth_btn = QPushButton("🔐 " + self.tr("控制授权")) self._control_auth_btn.setToolTip(self.tr("配置插件控制命令权限")) @@ -862,18 +1227,127 @@ def _really_quit(self) -> None: self._manager.stop() QApplication.instance().quit() + def _open_global_settings(self) -> None: + """打开全局设置(目前显示控制授权对话框)""" + self._open_control_auth_dialog() + + def _open_dev_guide(self) -> None: + """用 Qt 控件打开插件开发指南文档""" + from PyQt5.QtWidgets import QTextBrowser + from PyQt5.QtGui import QFont, QPalette, QColor + from .app_paths import get_executable_dir + + # 获取 plugin-dev-tutorial.md 的路径 + guide_path = get_executable_dir() / "plugin-dev-tutorial.md" + + if not guide_path.exists(): + QMessageBox.warning( + self, + self.tr("插件开发指南"), + self.tr("未找到插件开发指南文档:\n{path}").format(path=str(guide_path)), + ) + return + + # 读取文档内容 + try: + content = guide_path.read_text(encoding="utf-8") + except Exception as e: + QMessageBox.warning( + self, + self.tr("插件开发指南"), + self.tr("无法读取文档:\n{error}").format(error=str(e)), + ) + return + + # 创建显示对话框 + dlg = QDialog(self) + dlg.setWindowTitle(self.tr("插件开发指南")) + dlg.setMinimumSize(900, 700) + + layout = QVBoxLayout(dlg) + + # 使用 QTextBrowser 显示 Markdown 内容 + text_browser = QTextBrowser() + text_browser.setOpenExternalLinks(True) + text_browser.setMarkdown(content) + + # 设置字体 + font = QFont("Microsoft YaHei", 11) + font.setStyleHint(QFont.SansSerif) + text_browser.setFont(font) + + # 设置调色板(颜色主题) + palette = text_browser.palette() + palette.setColor(QPalette.Text, QColor("#24292f")) # 文字颜色 + palette.setColor(QPalette.Link, QColor("#0969da")) # 链接颜色 + palette.setColor(QPalette.LinkVisited, QColor("#8250df")) # 已访问链接 + text_browser.setPalette(palette) + + # 设置文档边距 + doc = text_browser.document() + doc.setDocumentMargin(16) + + layout.addWidget(text_browser) + + # 关闭按钮 + btn_box = QDialogButtonBox(QDialogButtonBox.Close) + btn_box.rejected.connect(dlg.close) + layout.addWidget(btn_box) + + dlg.exec_() + def _connect_signals(self) -> None: self._refresh_btn.clicked.connect(self._refresh_plugin_list) self._list.itemDoubleClicked.connect(self._on_list_double_clicked) self._list.customContextMenuRequested.connect(self._on_list_context_menu) self.connection_changed.connect(self._on_conn_changed) - # 调试开关 - self._debug_btn.toggled.connect(self._toggle_debug) - # 控制授权按钮 self._control_auth_btn.clicked.connect(self._open_control_auth_dialog) + def _open_basic_settings_dialog(self) -> None: + """打开基础设置对话框""" + dialog = BasicSettingsDialog(self._settings_mgr, self) + dialog.settings_changed.connect(self._on_settings_changed) + dialog.exec_() + + def _on_settings_changed(self) -> None: + """设置变更后的回调""" + # 应用日志等级 + self._apply_log_level() + self.statusBar().showMessage(self.tr("设置已保存"), 2000) + + def _apply_log_level(self) -> None: + """应用日志等级到日志系统""" + from .logging_setup import set_console_log_level + level = self._settings_mgr.file_log_level + set_console_log_level(level) + logger.info(f"控制台日志等级已设置为: {level}") + + def _open_log_viewer(self, initial_log: str = "main") -> None: + """打开日志查看对话框(非模态)""" + # 获取所有插件名称 + plugin_names = list(self._manager.plugins.keys()) + + # 获取日志查看器设置 + viewer_level = self._settings_mgr.viewer_log_level + auto_scroll = self._settings_mgr.viewer_auto_scroll + show_source = self._settings_mgr.viewer_show_source + + # 创建或复用对话框 + if not hasattr(self, "_log_viewer_dlg") or self._log_viewer_dlg is None: + self._log_viewer_dlg = LogViewerDialog( + plugin_names, initial_log, viewer_level, auto_scroll, show_source, self + ) + else: + # 更新设置并切换到指定日志 + self._log_viewer_dlg.update_settings(viewer_level, auto_scroll, show_source) + self._log_viewer_dlg.show_log(initial_log) + + self._log_viewer_dlg.show() + self._log_viewer_dlg.raise_() + self._log_viewer_dlg.activateWindow() + def _open_control_auth_dialog(self) -> None: """打开控制授权配置对话框""" # 获取插件声明需要的控制权限 @@ -896,10 +1370,6 @@ def set_connected(self, ok: bool) -> None: def _on_conn_changed(self, ok: bool) -> None: rc = self._manager.reconnect_count self._conn_status.set_status(ok, rc) - self._conn_btn.setChecked(ok) - self._conn_btn.setText( - self.tr("已连接") if ok else self.tr("连接") - ) msg = ( self.tr("已连接到主进程") if ok @@ -920,13 +1390,6 @@ def _poll_connection_status(self) -> None: _debug_active: bool = False - def _toggle_debug(self, enabled: bool) -> None: - """开启/关闭 debugpy 远程调试""" - if enabled: - self._start_debug() - else: - self._stop_debug() - def _start_debug(self) -> None: """启动 debugpy 监听""" try: @@ -935,33 +1398,21 @@ def _start_debug(self) -> None: # 解决 PyInstaller 打包后子进程找不到 Python/debugpy 的问题 debugpy.listen(("0.0.0.0", 5678), in_process_debug_adapter=True) PluginManagerWindow._debug_active = True - self._debug_btn.setText("🐛 Listening...") - self._debug_btn.setStyleSheet("background: #4caf50; color: white; font-weight: bold;") - self.statusBar().showMessage(self.tr("Debug server listening on port 5678, waiting for VS Code attach...")) + + # 启动后禁用菜单项,不可重复启动 + self._debug_act.setText(self.tr("调试已启动")) + self._debug_act.setEnabled(False) + + self.statusBar().showMessage(self.tr("调试服务已在端口 5678 启动,等待 VS Code 连接。重启插件管理器可关闭调试。")) logger.info("Debug server started on port 5678") except ImportError as e: - self._debug_btn.setChecked(False) QMessageBox.warning( self, "Debug", f"debugpy import failed:\n{e}", ) except Exception as e: - self._debug_btn.setChecked(False) QMessageBox.warning(self, "Debug", f"Failed to start debugger:\n{e}") - def _stop_debug(self) -> None: - """停止 debugpy""" - try: - import debugpy - debugpy.stop_listen() - except Exception: - pass - PluginManagerWindow._debug_active = False - self._debug_btn.setText("🐛 Debug") - self._debug_btn.setStyleSheet("") - self.statusBar().showMessage(self.tr("Debug stopped")) - logger.info("Debug server stopped") - # ── 插件列表 ──────────────────────────────────────── # ── 插件列表 ──────────────────────────────────────── @@ -1182,22 +1633,8 @@ def _close_plugin_window(self, name: str) -> None: self._sync_state(name, window_mode=WindowMode.CLOSED) def _open_plugin_log(self, name: str) -> None: - """用系统默认程序打开插件日志文件""" - from .app_paths import get_log_dir - log_file = get_log_dir() / "plugins" / f"{name}.log" - if not log_file.exists(): - # 文件不存在时创建一个空文件,避免打开报错 - log_file.parent.mkdir(parents=True, exist_ok=True) - log_file.touch() - import subprocess - import os - try: - if os.name == "nt": - os.startfile(str(log_file)) # type: ignore[attr-defined] - else: - subprocess.Popen(["xdg-open", str(log_file)]) - except Exception as e: - logger.warning(f"Failed to open log file {log_file}: {e}") + """打开插件日志查看对话框""" + self._open_log_viewer(initial_log=name) def _open_plugin_settings(self, name: str) -> None: """打开插件设置对话框""" diff --git a/src/plugin_manager/settings_manager.py b/src/plugin_manager/settings_manager.py new file mode 100644 index 0000000..f6a7bc8 --- /dev/null +++ b/src/plugin_manager/settings_manager.py @@ -0,0 +1,120 @@ +""" +插件管理器基础设置管理 + +管理插件管理器自身的设置,如日志等级等。 +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass, field, asdict +from pathlib import Path +from typing import Literal + +LogLevel = Literal["TRACE", "DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] + + +@dataclass +class BasicSettings: + """插件管理器基础设置""" + # 主进程文件日志等级 + file_log_level: LogLevel = "DEBUG" + # 日志查看器设置 + viewer_log_level: LogLevel = "INFO" + viewer_auto_scroll: bool = True + viewer_show_source: bool = False + + def to_dict(self) -> dict: + return asdict(self) + + @classmethod + def from_dict(cls, data: dict) -> "BasicSettings": + return cls( + file_log_level=data.get("file_log_level", "DEBUG"), + viewer_log_level=data.get("viewer_log_level", "INFO"), + viewer_auto_scroll=data.get("viewer_auto_scroll", True), + viewer_show_source=data.get("viewer_show_source", False), + ) + + +class SettingsManager: + """ + 插件管理器设置持久化管理 + + 设置文件路径: /plugin_manager_settings.json + """ + + SETTINGS_FILENAME = "plugin_manager_settings.json" + + # 支持的日志等级 + LOG_LEVELS: list[LogLevel] = ["TRACE", "DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] + + def __init__(self, data_dir: Path) -> None: + self._data_dir = Path(data_dir) + self._data_dir.mkdir(parents=True, exist_ok=True) + self._settings = BasicSettings() + self._load() + + def _settings_path(self) -> Path: + return self._data_dir / self.SETTINGS_FILENAME + + def _load(self) -> None: + """从文件加载设置""" + path = self._settings_path() + if path.exists(): + try: + data = json.loads(path.read_text(encoding="utf-8")) + if isinstance(data, dict): + self._settings = BasicSettings.from_dict(data) + except (json.JSONDecodeError, KeyError, TypeError): + pass + + def save(self) -> None: + """保存设置到文件""" + path = self._settings_path() + path.write_text( + json.dumps(self._settings.to_dict(), indent=2, ensure_ascii=False), + encoding="utf-8", + ) + + @property + def settings(self) -> BasicSettings: + return self._settings + + @property + def file_log_level(self) -> LogLevel: + return self._settings.file_log_level + + @property + def viewer_log_level(self) -> LogLevel: + return self._settings.viewer_log_level + + @property + def viewer_auto_scroll(self) -> bool: + return self._settings.viewer_auto_scroll + + @property + def viewer_show_source(self) -> bool: + return self._settings.viewer_show_source + + def set_file_log_level(self, level: LogLevel) -> None: + """设置主进程文件日志等级""" + if level in self.LOG_LEVELS: + self._settings.file_log_level = level + self.save() + + def set_viewer_log_level(self, level: LogLevel) -> None: + """设置日志查看器日志等级""" + if level in self.LOG_LEVELS: + self._settings.viewer_log_level = level + self.save() + + def set_viewer_auto_scroll(self, enabled: bool) -> None: + """设置日志查看器自动滚动""" + self._settings.viewer_auto_scroll = enabled + self.save() + + def set_viewer_show_source(self, enabled: bool) -> None: + """设置日志查看器显示来源""" + self._settings.viewer_show_source = enabled + self.save() diff --git a/src/plugin_sdk/plugin_base.py b/src/plugin_sdk/plugin_base.py index 905b72d..ab2c89e 100644 --- a/src/plugin_sdk/plugin_base.py +++ b/src/plugin_sdk/plugin_base.py @@ -553,7 +553,7 @@ def shutdown(self) -> None: # 等待线程结束(最多 2 秒) # on_shutdown 已在 run() 末尾的插件线程中执行 if not self.wait(2000): - self.logger.warning( + self.logger.debug( f"Plugin thread did not stop in time: {self.name}") self.terminate() # 强制终止 # 注意:强制终止可能导致未完成的 Future 永久阻塞 @@ -569,7 +569,7 @@ def shutdown(self) -> None: self.logger.debug( f"Unregistered service: {protocol.__name__}") except Exception as e: - self.logger.warning( + self.logger.debug( f"Failed to unregister service {protocol.__name__}: {e}") self._registered_protocols.clear() @@ -602,7 +602,7 @@ def _enqueue_event(self, handler: Callable[[Any], None], event: Any) -> bool: """ with self._queue_lock: if len(self._event_queue) >= self.MAX_QUEUE_SIZE: - self.logger.warning( + self.logger.debug( f"Event queue full ({self.MAX_QUEUE_SIZE}), dropping event" ) return False @@ -722,7 +722,7 @@ def _check_control_auth(self, command: Any) -> bool: ) if not auth_manager.is_authorized(type(command), self.name): - self.logger.warning( + self.logger.debug( f"控制权限被拒绝: {tag} 未授权给 {self.name} (当前授权给: {authorized_plugin})" ) return False @@ -734,13 +734,13 @@ def send_command(self, command: Any) -> None: return if self._client: try: - self.logger.info(f"发送命令到 ZMQ: {type(command).__name__}") + self.logger.debug(f"发送命令到 ZMQ: {type(command).__name__}") self._client.send_command(command) - self.logger.info(f"命令已发送: {type(command).__name__}") + self.logger.debug(f"命令已发送: {type(command).__name__}") except Exception as e: self.logger.error(f"发送命令失败: {e}", exc_info=True) else: - self.logger.warning(f"无法发送命令: client 未初始化") + self.logger.debug(f"无法发送命令: client 未初始化") def request(self, command: Any, timeout: float = 5.0) -> CommandResponse | None: """发送请求并等待响应(同步,带权限检查)""" @@ -778,7 +778,7 @@ def on_initialized(self): self.register_service(self, protocol=MyService) """ if self._event_dispatcher is None: - self.logger.warning("Cannot register service: no dispatcher") + self.logger.debug("Cannot register service: no dispatcher") return # 自动推断 protocol @@ -794,7 +794,7 @@ def on_initialized(self): break if protocol is None: - self.logger.warning( + self.logger.debug( "Cannot register service: no protocol found. " "Specify protocol= explicitly or inherit from a Protocol." ) @@ -907,7 +907,7 @@ def on_initialized(self): # 等待 HistoryService 就绪 history = self.wait_for_service(HistoryService, timeout=10.0) if history is None: - self.logger.warning("HistoryService 未就绪") + self.logger.debug("HistoryService 未就绪") else: records = history.query_records(100) """ diff --git a/src/plugins/history/models.py b/src/plugins/history/models.py index 0f44e2e..60bc552 100644 --- a/src/plugins/history/models.py +++ b/src/plugins/history/models.py @@ -10,6 +10,8 @@ from PyQt5.QtCore import QCoreApplication +from shared_types.enums import GameBoardState, GameMode, GameLevel, BaseDiaPlayEnum + _translate = QCoreApplication.translate @@ -101,11 +103,13 @@ def to_sql(self): return self._SQL[self.value] +# ── 历史记录数据 ────────────────────────────────────────── + class HistoryData: """历史记录数据行(纯数据类,用类属性定义字段)""" replay_id: int = 0 - game_board_state: int = 0 + game_board_state: GameBoardState = GameBoardState.Win rtime: float = 0 left: int = 0 right: int = 0 @@ -113,7 +117,7 @@ class HistoryData: left_s: float = 0.0 right_s: float = 0.0 double_s: float = 0.0 - level: int = 0 + level: GameLevel = GameLevel.BEGINNER cl: int = 0 cl_s: float = 0.0 ce: int = 0 @@ -127,9 +131,9 @@ class HistoryData: flag: int = 0 path: float = 0.0 etime: float = 0 - start_time: int = 0 - end_time: int = 0 - mode: int = 0 + start_time: datetime = datetime.now() + end_time: datetime = datetime.now() + mode: GameMode = GameMode.Standard software: str = "" player_identifier: str = "" race_identifier: str = "" @@ -179,19 +183,15 @@ def from_dict(cls, data: dict): and not name.startswith("_") ): new_value = data.get(name) + if new_value is None: + continue + # Enum 类型 - 使用 try_create 处理值不在枚举中的情况 + if isinstance(value, BaseDiaPlayEnum): + value = value.__class__.try_create(new_value) # 时间戳字段转换 - if ( - name in ("etime",) - and isinstance(new_value, (int, float)) - and new_value - ): - value = new_value - elif ( - name in ("start_time", "end_time") - and isinstance(new_value, (int, float)) - and new_value - ): - value = datetime.fromtimestamp(new_value / 1_000_000) + elif isinstance(value, datetime): + if new_value: + value = datetime.fromtimestamp(new_value / 1_000_000) elif isinstance(value, float): value = round(new_value, 4) else: diff --git a/src/plugins/history/plugin.py b/src/plugins/history/plugin.py index 867f1da..0445704 100644 --- a/src/plugins/history/plugin.py +++ b/src/plugins/history/plugin.py @@ -13,13 +13,28 @@ from PyQt5.QtCore import pyqtSignal from PyQt5.QtWidgets import QWidget -from plugin_sdk import BasePlugin, PluginInfo, make_plugin_icon, WindowMode +from plugin_sdk import ( + BasePlugin, PluginInfo, make_plugin_icon, WindowMode, + OtherInfoBase, IntConfig, +) from shared_types.events import VideoSaveEvent from plugins.services.history import HistoryService, GameRecord from .widgets import HistoryMainWidget +class HistoryConfig(OtherInfoBase): + """历史记录插件配置""" + + float_decimals = IntConfig( + default=2, + label="小数位数", + description="查询窗口中浮点数显示的小数位数", + min_value=0, + max_value=10, + ) + + class HistoryPlugin(BasePlugin): """ 历史记录插件 @@ -39,6 +54,7 @@ def plugin_info(cls) -> PluginInfo: version="1.0.0", icon=make_plugin_icon("#7b1fa2", "\N{SCROLL}"), window_mode=WindowMode.TAB, + other_info=HistoryConfig, ) def __init__(self, info): @@ -50,7 +66,13 @@ def _setup_subscriptions(self) -> None: def _create_widget(self) -> QWidget: db_path = self.data_dir / "history.db" config_path = self.data_dir / "history_show_fields.json" - self._widget = HistoryMainWidget(db_path, config_path) + + # 获取配置中的小数位数 + float_decimals = 2 + if self.other_info: + float_decimals = self.other_info.float_decimals + + self._widget = HistoryMainWidget(db_path, config_path, float_decimals) self.video_save_over.connect(self._widget.query_button.click) return self._widget @@ -246,4 +268,5 @@ def delete_record(self, record_id: int) -> bool: conn.close() def _on_config_changed(self, name: str, value: Any) -> None: - return super()._on_config_changed(name, value) + if name == "float_decimals" and hasattr(self, '_widget'): + self._widget.set_float_decimals(value) diff --git a/src/plugins/history/table_model.py b/src/plugins/history/table_model.py index 07b3e2a..2ac2190 100644 --- a/src/plugins/history/table_model.py +++ b/src/plugins/history/table_model.py @@ -8,6 +8,7 @@ from PyQt5.QtCore import Qt, QAbstractTableModel, QModelIndex +from shared_types.enums import BaseDiaPlayEnum from .models import HistoryData @@ -44,7 +45,10 @@ def data(self, index: QModelIndex, role=Qt.DisplayRole): value = getattr(self._data[row], field_name) if isinstance(value, datetime): return value.strftime("%Y-%m-%d %H:%M:%S.%f") + elif isinstance(value, BaseDiaPlayEnum): + return value.display_name else: + # 对于可能是枚举但值不在定义中的情况,直接显示数值 return str(value) elif role == Qt.UserRole: diff --git a/src/plugins/history/widgets.py b/src/plugins/history/widgets.py index 79720fa..c5e4693 100644 --- a/src/plugins/history/widgets.py +++ b/src/plugins/history/widgets.py @@ -48,8 +48,9 @@ class FilterWidget(QWidget): """筛选条件控件""" - def __init__(self, parent=None): + def __init__(self, float_decimals: int = 2, parent=None): super().__init__(parent) + self._float_decimals = float_decimals vbox = QVBoxLayout(self) self.table = QTableWidget(self) self.table.setColumnCount(6) @@ -63,6 +64,10 @@ def __init__(self, parent=None): self.table.setSelectionMode(QTableView.SingleSelection) vbox.addWidget(self.table) self.setLayout(vbox) + + def set_float_decimals(self, decimals: int) -> None: + """动态设置小数位数""" + self._float_decimals = decimals def show_context_menu(self, pos): menu = QMenu(self) @@ -128,15 +133,27 @@ def on_compare_changed(self, index): row, 3, self._build_value_widget(compare, field_cls)) def _build_value_widget(self, compare: CompareSymbol, field_value: Any): + from shared_types.enums import BaseDiaPlayEnum + if compare not in (CompareSymbol.Contains, CompareSymbol.NotContains): - if isinstance(field_value, int): + if isinstance(field_value, BaseDiaPlayEnum): + w = QComboBox(self) + # 获取该枚举类的所有成员的 display_name + enum_cls = field_value.__class__ + w.addItems([e.display_name for e in enum_cls]) + return w + elif isinstance(field_value, int): return QSpinBox(self) elif isinstance(field_value, float): - return QDoubleSpinBox(self) + w = QDoubleSpinBox(self) + w.setDecimals(self._float_decimals) + return w elif isinstance(field_value, str): return QLineEdit(self) elif isinstance(field_value, datetime): - return QDateTimeEdit(self) + w = QDateTimeEdit(self) + w.setDateTime(datetime.now()) # 默认当前时间 + return w return QLineEdit(self) def add_row(self): @@ -194,8 +211,21 @@ def gen_filter_str(self): return None # 获取值 + from shared_types.enums import BaseDiaPlayEnum if isinstance(value_w, QComboBox): - value = value_w.currentText() + # 如果字段是 Enum 类型,需要获取对应的枚举值 + if isinstance(field_init_value, BaseDiaPlayEnum): + enum_cls = field_init_value.__class__ + display_name = value_w.currentText() + # 找到对应的枚举成员 + for e in enum_cls: + if e.display_name == display_name: + value = str(e.value) + break + else: + value = value_w.currentText() + else: + value = value_w.currentText() elif isinstance(value_w, QDateTimeEdit): value = int( value_w.dateTime().toPyDateTime().timestamp() * 1_000_000) @@ -426,6 +456,7 @@ def __init__( self, db_path: Path, config_path: Path, + float_decimals: int = 2, parent=None, ): super().__init__(parent) @@ -446,7 +477,7 @@ def __init__( ) # 筛选 + 表格 - self.filter_widget = FilterWidget(self) + self.filter_widget = FilterWidget(float_decimals, self) self.table = HistoryTable(self._get_show_fields(), db_path, self) # 分页 @@ -549,3 +580,7 @@ def closeEvent(self, event: _QCloseEvent): with open(self._config_path, "w") as f: json.dump(list(self.table.showFields), f) super().closeEvent(event) + + def set_float_decimals(self, decimals: int) -> None: + """动态设置小数位数""" + self.filter_widget.set_float_decimals(decimals) diff --git a/src/run_plugin_manager.py b/src/run_plugin_manager.py deleted file mode 100644 index 9927f1e..0000000 --- a/src/run_plugin_manager.py +++ /dev/null @@ -1,84 +0,0 @@ -""" -启动插件管理器进程 - -独立进程运行,连接到扫雷主进程 -""" -import sys -import os -import argparse - -# 确保 src 目录在路径中 -src_dir = os.path.dirname(os.path.abspath(__file__)) -if src_dir not in sys.path: - sys.path.insert(0, src_dir) - - -def main(): - parser = argparse.ArgumentParser(description="插件管理器") - parser.add_argument( - "--endpoint", - "-e", - default=None, - help="ZMQ 端点地址(默认 tcp://127.0.0.1:5555)", - ) - parser.add_argument( - "--no-gui", - action="store_true", - help="不显示界面(后台运行)", - ) - parser.add_argument( - "--plugin-dir", - "-p", - action="append", - help="插件目录(可多次指定)", - ) - args = parser.parse_args() - - # 初始化 loguru 日志系统 - from plugin_manager.app_paths import get_log_dir - from plugin_manager.logging_setup import init_logging - init_logging(get_log_dir(), console=True) - - # 确定端点(Windows 不支持 ipc,使用 tcp) - if args.endpoint is None: - endpoint = "tcp://127.0.0.1:5555" - else: - endpoint = args.endpoint - - # 确定插件目录 - plugin_dirs = args.plugin_dir - if plugin_dirs is None: - # 默认插件目录 - plugin_dirs = [os.path.join(src_dir, "plugins")] - - from plugin_manager import PluginManager - - # 创建并启动 - manager = PluginManager( - endpoint=endpoint, - plugin_dirs=plugin_dirs, - ) - - if args.no_gui: - # 后台模式 - try: - manager.start() - print(f"插件管理器已启动: {endpoint}") - print("按 Ctrl+C 停止") - while True: - import time - time.sleep(1) - except KeyboardInterrupt: - pass - finally: - manager.stop() - else: - # GUI 模式 - from PyQt5.QtWidgets import QApplication - app = QApplication(sys.argv) - manager.start_with_gui(app) - sys.exit(app.exec_()) - - -if __name__ == "__main__": - main() diff --git a/src/shared_types/__init__.py b/src/shared_types/__init__.py index 4016ed4..15163ed 100644 --- a/src/shared_types/__init__.py +++ b/src/shared_types/__init__.py @@ -13,6 +13,14 @@ COMMAND_TYPES, ) +from .enums import ( + BaseDiaPlayEnum, + GameBoardState, + MouseState, + GameMode, + GameLevel, +) + __all__ = [ # 事件 "BoardUpdateEvent", @@ -20,4 +28,10 @@ # 指令 "NewGameCommand", "COMMAND_TYPES", + # 枚举 + "BaseDiaPlayEnum", + "GameBoardState", + "MouseState", + "GameMode", + "GameLevel", ] diff --git a/src/shared_types/enums.py b/src/shared_types/enums.py new file mode 100644 index 0000000..a7080c4 --- /dev/null +++ b/src/shared_types/enums.py @@ -0,0 +1,180 @@ +""" +共享枚举类型 + +定义游戏状态、鼠标状态、游戏模式、游戏难度等枚举。 +这些枚举遵循 evf 标准(ms_toollib 也遵循此标准)。 + +参考:https://github.com/eee555/ms-toollib/blob/main/evf%E6%A0%87%E5%87%86.md +""" +from __future__ import annotations + +from enum import Enum + +from PyQt5.QtCore import QCoreApplication + +_translate = QCoreApplication.translate + + +class BaseDiaPlayEnum(Enum): + """带显示名称的枚举基类""" + + @property + def display_name(self) -> str: + return self.name + + @classmethod + def from_display_name(cls, display_name: str) -> "BaseDiaPlayEnum": + for member in cls: + if member.display_name == display_name: + return member + raise ValueError(f"Invalid display name: {display_name}") + + @classmethod + def display_names(cls) -> list[str]: + return [member.display_name for member in cls] + + @classmethod + def try_create(cls, value: int) -> "BaseDiaPlayEnum | int": + """尝试创建枚举,如果值不在定义中则返回原始值""" + try: + return cls(value) + except ValueError: + return value + + +class GameBoardState(BaseDiaPlayEnum): + """ + 游戏板状态枚举 + + 这些魔数遵循 ms_toollib 标准。 + """ + Ready = 1 + Playing = 2 + Win = 3 + Loss = 4 + PreFlaging = 5 + Display = 6 + + @property + def display_name(self) -> str: + match self: + case GameBoardState.Win: + return _translate("Form", "胜利") + case GameBoardState.Loss: + return _translate("Form", "失败") + case GameBoardState.Ready: + return _translate("Form", "准备") + case GameBoardState.Playing: + return _translate("Form", "进行中") + case GameBoardState.PreFlaging: + return _translate("Form", "预标记") + case GameBoardState.Display: + return _translate("Form", "回放") + case _: + return str(self.value) + + +class MouseState(BaseDiaPlayEnum): + """ + 鼠标状态枚举 + + 游戏过程中,鼠标的动作会触发鼠标事件,并在 evf 录像中记录为 + 诸如 "mv", "lc", "lr", "rc", "rr", "mc", "mr", "pf", "cc", "l", "r", "m" + 动作导致鼠标转移至不同的状态,用于计算左键、右键、双击等次数,显示局面高亮等。 + + 这些魔数遵循 ms_toollib 标准。 + """ + UpUp = 1 + UpDown = 2 + UpDownNotFlag = 3 + DownUp = 4 + Chording = 5 + ChordingNotFlag = 6 + DownUpAfterChording = 7 + Undefined = 8 + + @property + def display_name(self) -> str: + match self: + case MouseState.UpUp: + return _translate("Form", "双键抬起") + case MouseState.UpDown: + return _translate("Form", "右键按下且标过雷") + case MouseState.UpDownNotFlag: + return _translate("Form", "右键按下且没有标过雷") + case MouseState.DownUp: + return _translate("Form", "左键按下") + case MouseState.Chording: + return _translate("Form", "双键按下") + case MouseState.ChordingNotFlag: + return _translate("Form", "双键按下且先按下右键且没有标雷") + case MouseState.DownUpAfterChording: + return _translate("Form", "双击后先弹起右键左键还没有弹起") + case MouseState.Undefined: + return _translate("Form", "未初始化") + case _: + return str(self.value) + + +class GameMode(BaseDiaPlayEnum): + """ + 游戏模式枚举 + + 这些魔数遵循 evf 标准(ms_toollib 也是遵循 evf 标准)。 + """ + Standard = 0 + Win7 = 4 + ClassicNoGuess = 5 + StrictNoGuess = 6 + WeakNoGuess = 7 + BlessingMode = 8 + GuessableNoGuess = 9 + LuckyMode = 10 + + @property + def display_name(self) -> str: + match self: + case GameMode.Standard: + return _translate("Form", "标准") + case GameMode.Win7: + return _translate("Form", "win7") + case GameMode.ClassicNoGuess: + return _translate("Form", "经典无猜") + case GameMode.StrictNoGuess: + return _translate("Form", "强无猜") + case GameMode.WeakNoGuess: + return _translate("Form", "弱无猜") + case GameMode.BlessingMode: + return _translate("Form", "准无猜") + case GameMode.GuessableNoGuess: + return _translate("Form", "强可猜") + case GameMode.LuckyMode: + return _translate("Form", "弱可猜") + case _: + return str(self.value) + + +class GameLevel(BaseDiaPlayEnum): + """ + 游戏难度枚举 + + 这些魔数遵循 evf 标准(ms_toollib 也是遵循 evf 标准)。 + """ + BEGINNER = 3 + INTERMEDIATE = 4 + EXPERT = 5 + CUSTOM = 6 + + @property + def display_name(self) -> str: + match self: + case GameLevel.BEGINNER: + return _translate("Form", "初级") + case GameLevel.INTERMEDIATE: + return _translate("Form", "中级") + case GameLevel.EXPERT: + return _translate("Form", "高级") + case GameLevel.CUSTOM: + return _translate("Form", "自定义") + case _: + return str(self.value) diff --git a/src/utils.py b/src/utils.py index 5dcdd04..27fd447 100644 --- a/src/utils.py +++ b/src/utils.py @@ -13,153 +13,18 @@ import ms_toollib as ms import math -from enum import Enum from PyQt5.QtCore import QCoreApplication -_translate = QCoreApplication.translate - - -class BaseDiaPlayEnum(Enum): - - @property - def display_name(self): - return self.name - - @classmethod - def from_display_name(cls, display_name: str): - for member in cls: - if member.display_name == display_name: - return member - raise ValueError(f"Invalid display name: {display_name}") - - @classmethod - def display_names(cls): - return [member.display_name for member in cls] +# 从 shared_types 导入共享枚举 +from shared_types.enums import ( + BaseDiaPlayEnum, + GameBoardState, + MouseState, + GameMode, + GameLevel, +) - -class GameBoardState(BaseDiaPlayEnum): - ''' - 关于鼠标状态的枚举体,这些魔数遵循ms_toollib标准 - ''' - Ready = 1 - Playing = 2 - Win = 3 - Loss = 4 - PreFlaging = 5 - Display = 6 - - @property - def display_name(self): - match self: - case GameBoardState.Win: - return _translate("Form", "胜利") - case GameBoardState.Loss: - return _translate("Form", "失败") - case GameBoardState.Ready: - return _translate("Form", "准备") - case GameBoardState.Playing: - return _translate("Form", "进行中") - case GameBoardState.PreFlaging: - return _translate("Form", "预标记") - case GameBoardState.Display: - return _translate("Form", "回放") - - -class MouseState(BaseDiaPlayEnum): - ''' - 关于鼠标状态的枚举体。游戏过程中,鼠标的动作会触发鼠标事件,并在evf录像中记录为 - 诸如"mv", "lc", "lr", "rc", "rr", "mc", "mr", "pf", "cc", "l", "r", "m" - 动作导致鼠标转移至不同的状态,用于计算左键、右键、双击等次数,显示局面高亮等 - 这些魔数遵循ms_toollib标准 - ''' - UpUp = 1 - UpDown = 2 - UpDownNotFlag = 3 - DownUp = 4 - Chording = 5 - ChordingNotFlag = 6 - DownUpAfterChording = 7 - Undefined = 8 - - @property - def display_name(self): - match self: - case MouseState.UpUp: - return _translate("Form", "双键抬起") - case MouseState.UpDown: - return _translate("Form", "右键按下且标过雷") - case MouseState.UpDownNotFlag: - return _translate("Form", "右键按下且没有标过雷") - case MouseState.DownUp: - return _translate("Form", "左键按下") - case MouseState.Chording: - return _translate("Form", "双键按下") - case MouseState.ChordingNotFlag: - return _translate("Form", "双键按下且先按下右键且没有标雷") - case MouseState.DownUpAfterChording: - return _translate("Form", "双击后先弹起右键左键还没有弹起") - case MouseState.Undefined: - return _translate("Form", "未初始化") - - -class GameMode(BaseDiaPlayEnum): - ''' - 关于游戏模式的枚举体,这些魔数遵循evf标准(ms_toollib也是遵循evf标准) - 参考: - https://github.com/eee555/ms-toollib/blob/main/evf%E6%A0%87%E5%87%86.md - ''' - Standard = 0 - Win7 = 4 - ClassicNoGuess = 5 - StrictNoGuess = 6 - WeakNoGuess = 7 - BlessingMode = 8 - GuessableNoGuess = 9 - LuckyMode = 10 - - @property - def display_name(self): - match self: - case GameMode.Standard: - return _translate("Form", "标准") - case GameMode.Win7: - return _translate("Form", "win7") - case GameMode.ClassicNoGuess: - return _translate("Form", "经典无猜") - case GameMode.StrictNoGuess: - return _translate("Form", "强无猜") - case GameMode.WeakNoGuess: - return _translate("Form", "弱无猜") - case GameMode.BlessingMode: - return _translate("Form", "准无猜") - case GameMode.GuessableNoGuess: - return _translate("Form", "强可猜") - case GameMode.LuckyMode: - return _translate("Form", "弱可猜") - - -class GameLevel(BaseDiaPlayEnum): - ''' - 关于游戏难度的枚举体,这些魔数遵循evf标准(ms_toollib也是遵循evf标准) - 参考: - https://github.com/eee555/ms-toollib/blob/main/evf%E6%A0%87%E5%87%86.md - ''' - BEGINNER = 3 - INTERMEDIATE = 4 - EXPERT = 5 - CUSTOM = 6 - - @property - def display_name(self): - match self: - case GameLevel.BEGINNER: - return _translate("Form", "初级") - case GameLevel.INTERMEDIATE: - return _translate("Form", "中级") - case GameLevel.EXPERT: - return _translate("Form", "高级") - case GameLevel.CUSTOM: - return _translate("Form", "自定义") +_translate = QCoreApplication.translate def get_paths():