diff --git a/plugin-dev-tutorial.md b/plugin-dev-tutorial.md index 9087a2f..9d22788 100644 --- a/plugin-dev-tutorial.md +++ b/plugin-dev-tutorial.md @@ -11,9 +11,10 @@ - [三、理解插件发现机制](#三理解插件发现机制) - [四、编写第一个插件(Hello World)](#四编写第一个插件hello-world) - [五、核心 API 详解](#五核心-api-详解) -- [六、实战:带 GUI 的完整插件示例](#六实战带-gui-的完整插件示例) -- [七、VS Code 调试指南](#七vs-code-调试指南) -- [八、常见问题与最佳实践](#八常见问题与最佳实践) +- [六、插件自定义配置系统](#六插件自定义配置系统) +- [七、实战:带 GUI 的完整插件示例](#七实战带-gui-的完整插件示例) +- [八、VS Code 调试指南](#八vs-code-调试指南) +- [九、常见问题与最佳实践](#九常见问题与最佳实践) --- @@ -65,6 +66,14 @@ Meta-Minesweeper 采用 **ZMQ 多进程插件架构**: │ └── my_complex/ # 或包形式插件 │ ├── __init__.py │ └── utils.py +├── shared_types/ # 共享类型定义 +│ ├── events.py # 事件类型 +│ ├── commands.py # 指令类型 +│ └── services/ # 👈 服务接口定义 +│ └── history.py # HistoryService 接口 +├── plugin_manager/ # 插件管理器模块 +│ ├── plugin_base.py # 👈 BasePlugin 基类 +│ └── service_registry.py # 服务注册表 ├── user_plugins/ # 备用用户插件目录 ├── data/ │ ├── logs/ # 日志输出(自动创建) @@ -336,6 +345,8 @@ class HelloPlugin(BasePlugin): | `self.log_level` | `LogLevel` | 当前的日志级别 | | `self.plugin_icon` | `QIcon` | 插件图标 | | `self.logger` | `loguru.Logger` | **已绑定插件名称的日志器**(直接用!) | +| `self.other_info` | `OtherInfoBase \| None` | 插件自定义配置对象 | +| `self.config_changed` | `pyqtSignal` | 配置变化信号,参数 `(name, value)` | ### 5.2 事件订阅 API @@ -435,7 +446,57 @@ self.run_on_gui(some_function, arg1, arg2, keyword_arg=value) 两种方式的底层原理相同 —— 都是通过 QueuedConnection 将调用投递到 Qt 主线程的事件循环。 -### 5.5 日志记录 +### 5.5 服务通讯 API(插件间调用) + +插件间通过**服务接口**进行类型安全的调用,服务方法会在**服务提供者线程**执行,线程安全。 + +```python +# ════════════════════════════════════════ +# 1. 注册服务(服务提供者) +# ════════════════════════════════════════ +def on_initialized(self): + # 注册服务,显式指定 Protocol 类型 + self.register_service(self, protocol=MyService) + +# ════════════════════════════════════════ +# 2. 检查服务是否存在 +# ════════════════════════════════════════ +if self.has_service(MyService): + # 服务可用 + pass + +# ════════════════════════════════════════ +# 3. 获取服务代理(推荐) +# ════════════════════════════════════════ +service = self.get_service_proxy(MyService) + +# 调用服务方法(IDE 完整补全,在服务提供者线程执行) +data = service.get_data(123) # 同步调用,阻塞等待结果 +all_data = service.list_data(100) # 超时默认 10 秒 + +# ════════════════════════════════════════ +# 4. 异步调用(非阻塞) +# ════════════════════════════════════════ +future = self.call_service_async(MyService, "get_data", 123) +# 做其他事情... +result = future.result(timeout=5.0) # 阻塞等待结果 +``` + +**服务相关方法:** + +| 方法 | 说明 | +|------|------| +| `register_service(self, protocol=MyService)` | 注册服务(在 `on_initialized` 中调用) | +| `has_service(MyService)` | 检查服务是否可用 | +| `get_service_proxy(MyService)` | 获取服务代理对象(推荐) | +| `call_service_async(MyService, "method", *args)` | 异步调用,返回 Future | + +**注意事项:** +- 服务方法在**服务提供者线程**执行,调用方无需关心线程安全 +- **死锁风险**:不要让两个插件互相调用对方的服务 +- 服务接口中不要暴露删除等敏感操作 + +### 5.6 日志记录 ```python # 每个 BasePlugin 实例都有绑定好的 logger,直接使用即可 @@ -449,7 +510,7 @@ self.logger.error("错误信息") # /logs/plugin_manager.log (主日志) ``` -### 5.6 PluginInfo 配置项 +### 5.7 PluginInfo 配置项 ```python @dataclass @@ -465,6 +526,7 @@ class PluginInfo: log_level: LogLevel = "DEBUG" # 默认日志级别 icon: QIcon | None = None # 图标 log_config: LogConfig | None = None # 高级日志配置 + other_info: type[OtherInfoBase] | None = None # 👈 自定义配置类 ``` **WindowMode 含义:** @@ -477,7 +539,287 @@ class PluginInfo: --- -## 六、实战:带 GUI 的完整插件示例 +## 六、插件自定义配置系统 + +插件可以定义自己的配置项,这些配置会: +- 自动生成 UI 控件(在设置对话框中) +- 自动持久化到 `data/plugin_data//config.json` +- 支持配置变化事件通知 + +### 6.1 配置类型一览 + +| 类型 | UI 控件 | 用途示例 | +|------|---------|----------| +| `BoolConfig` | QCheckBox | 开关选项(启用/禁用功能) | +| `IntConfig` | QSpinBox / QSlider | 整数设置(数量、超时时间) | +| `FloatConfig` | QDoubleSpinBox | 浮点数设置(阈值、系数) | +| `ChoiceConfig` | QComboBox | 下拉选择(主题、模式) | +| `TextConfig` | QLineEdit | 文本输入(名称、路径、密码) | +| `ColorConfig` | 颜色按钮 + QColorDialog | 颜色选择(主题颜色) | +| `FileConfig` | QLineEdit + 文件对话框 | 文件路径选择 | +| `PathConfig` | QLineEdit + 目录对话框 | 目录路径选择 | +| `LongTextConfig` | QTextEdit | 多行文本(脚本、描述) | +| `RangeConfig` | 两个 QSpinBox | 数值范围(最小/最大值) | + +### 6.2 定义配置类 + +继承 `OtherInfoBase` 并声明配置字段: + +```python +from plugin_manager.config_types import ( + OtherInfoBase, BoolConfig, IntConfig, FloatConfig, + ChoiceConfig, TextConfig, ColorConfig, FileConfig, + PathConfig, LongTextConfig, RangeConfig, +) + +class MyPluginConfig(OtherInfoBase): + """我的插件配置""" + + # ── 基础类型 ───────────────────────── + enable_auto_save = BoolConfig( + default=True, + label="自动保存", + description="游戏结束后自动保存录像", + ) + + max_records = IntConfig( + default=100, + label="最大记录数", + min_value=10, + max_value=10000, + step=10, + ) + + min_rtime = FloatConfig( + default=0.0, + label="最小用时筛选", + min_value=0.0, + max_value=999.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, # 密码模式 + placeholder="输入密钥...", + ) + + # ── 高级类型 ───────────────────────── + theme_color = ColorConfig( + default="#1976d2", + label="主题颜色", + ) + + export_file = FileConfig( + default="", + label="导出文件", + filter="JSON (*.json)", # 文件过滤器 + save_mode=True, # 保存文件模式 + ) + + log_directory = PathConfig( + default="", + label="日志目录", + ) + + description = LongTextConfig( + default="", + label="描述", + placeholder="输入描述...", + max_height=100, + ) + + time_range = RangeConfig( + default=(0, 300), + label="时间范围(秒)", + min_value=0, + max_value=999, + ) +``` + +### 6.3 绑定配置到插件 + +在 `PluginInfo` 中通过 `other_info` 属性绑定: + +```python +class MyPlugin(BasePlugin): + + @classmethod + def plugin_info(cls) -> PluginInfo: + return PluginInfo( + name="my_plugin", + version="1.0.0", + description="我的插件", + other_info=MyPluginConfig, # 👈 绑定配置类 + ) +``` + +### 6.4 访问配置值 + +```python +class MyPlugin(BasePlugin): + + def on_initialized(self): + # 访问配置值 + if self.other_info: + max_records = self.other_info.max_records + theme = self.other_info.theme + self.logger.info(f"配置: max_records={max_records}, theme={theme}") + + def _handle_event(self, event): + # 使用配置 + if self.other_info and self.other_info.enable_auto_save: + self._save_record(event) +``` + +### 6.5 监听配置变化 + +```python +class MyPlugin(BasePlugin): + + 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_records": + self._resize_buffer(value) +``` + +### 6.6 配置相关属性和方法 + +| 属性/方法 | 说明 | +|-----------|------| +| `self.other_info` | 配置对象实例(可能为 None) | +| `self.config_changed` | 配置变化信号,参数 `(name, value)` | +| `self.save_config()` | 手动保存配置到文件 | +| `self.other_info.to_dict()` | 导出配置为字典 | +| `self.other_info.from_dict(data)` | 从字典加载配置 | +| `self.other_info.reset_to_defaults()` | 重置为默认值 | + +### 6.7 配置存储位置 + +配置自动保存到: +``` +data/plugin_data//config.json +``` + +示例: +```json +{ + "enable_auto_save": true, + "max_records": 100, + "theme": "dark", + "player_name": "Player1", + "theme_color": "#1976d2" +} +``` + +### 6.8 自定义配置类型 + +如果预定义的配置类型不满足需求,可以继承 `BaseConfig` 创建自定义类型: + +```python +from plugin_manager.config_types.base_config import BaseConfig +from PyQt5.QtWidgets import QWidget, QVBoxLayout, QLabel, QDial +from PyQt5.QtCore import Qt + +class DialConfig(BaseConfig[int]): + """旋钮配置 → QDial 控件""" + widget_type = "dial" + + def __init__( + self, + default: int = 0, + label: str = "", + min_value: int = 0, + max_value: int = 100, + **kwargs, + ): + super().__init__(default, label, **kwargs) + self.min_value = min_value + self.max_value = max_value + + def create_widget(self): + """创建自定义 UI 控件,返回 (控件, getter, setter, 信号)""" + widget = QDial() + widget.setRange(self.min_value, self.max_value) + widget.setValue(int(self.default)) + widget.setNotchesVisible(True) + + if self.description: + widget.setToolTip(self.description) + + # 返回控件、getter、setter、以及 valueChanged 信号 + return widget, widget.value, widget.setValue, widget.valueChanged + + def to_storage(self, value: int) -> int: + return int(value) + + def from_storage(self, data) -> int: + return int(data) + +# 使用自定义配置类型 +class MyConfig(OtherInfoBase): + volume = DialConfig(50, "音量", min_value=0, max_value=100) + sensitivity = DialConfig(5, "灵敏度", min_value=1, max_value=10) +``` + +**自定义配置类型要点:** + +| 方法/属性 | 说明 | +|-----------|------| +| `widget_type` | 控件类型标识 | +| `create_widget()` | 返回 `(控件, getter, setter, 信号)` 四元组 | +| `to_storage(value)` | 将值转换为 JSON 可序列化格式 | +| `from_storage(data)` | 从 JSON 数据恢复值 | + +**信号对象说明:** + +信号对象可以是: +- Qt 控件的内置信号(如 `widget.valueChanged`、`widget.textChanged`) +- 自定义 `pyqtSignal`(需要通过 QObject 子类定义) + +```python +# 方式一:使用控件的内置信号 +return widget, widget.value, widget.setValue, widget.valueChanged + +# 方式二:自定义信号(复杂控件) +from PyQt5.QtCore import QObject, pyqtSignal + +class MySignal(QObject): + changed = pyqtSignal() + +signal_emitter = MySignal(parent=container) # parent 防止垃圾回收 +# ... 控件变化时调用 signal_emitter.changed.emit() +return container, get_value, set_value, signal_emitter.changed +``` + +--- + +## 七、实战:带 GUI 的完整插件示例 下面是一个更完整的示例——**实时统计面板插件**,展示计数器、表格等常见 UI 元素的用法: @@ -654,7 +996,7 @@ class StatsPlugin(BasePlugin): ``` --- -## 七、VS Code 调试指南 +## 八、VS Code 调试指南 ### 最简开发方式(推荐) @@ -691,7 +1033,7 @@ code <安装目录> --- -## 八、常见问题与最佳实践 +## 九、常见问题与最佳实践 ### Q1: 我的插件为什么没有被加载? @@ -717,6 +1059,33 @@ code <安装目录> ### Q3: 如何存储插件的持久化数据? +**方式一:使用配置系统(推荐)** + +定义配置类并绑定到插件,配置会自动保存和加载: + +```python +class MyConfig(OtherInfoBase): + setting1 = BoolConfig(True, "设置1") + setting2 = IntConfig(100, "设置2") + +class MyPlugin(BasePlugin): + @classmethod + def plugin_info(cls) -> PluginInfo: + return PluginInfo(name="my_plugin", other_info=MyConfig) + + def on_initialized(self): + # 访问配置 + if self.other_info: + value = self.other_info.setting1 + + def on_shutdown(self): + # 配置在设置对话框确认时自动保存 + # 也可以手动保存 + self.save_config() +``` + +**方式二:使用 self.data_dir** + 使用 `self.data_dir` —— 它指向 `/data/plugin_data//`: ```python @@ -730,11 +1099,74 @@ def on_initialized(self): ### Q4: 插件之间如何通信? -目前插件间没有直接的通信 API。间接方式: +插件间通过**服务接口(Protocol)**进行类型安全的通讯。 + +#### 1. 定义服务接口 + +在 `shared_types/services/` 目录下创建接口定义文件: + +```python +# shared_types/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]: ... +``` + +#### 2. 服务提供者 + +```python +class ProviderPlugin(BasePlugin): + def on_initialized(self): + # 注册服务(显式指定 protocol) + self.register_service(self, protocol=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) +``` + +#### 3. 服务使用者 + +```python +class ConsumerPlugin(BasePlugin): + def on_initialized(self): + if self.has_service(MyService): + # 获取服务代理(推荐) + self._service = self.get_service_proxy(MyService) + + def _do_something(self): + # 调用服务方法(IDE 完整补全,在服务提供者线程执行) + data = self._service.get_data(123) + all_data = self._service.list_data(100) +``` + +#### 服务调用方式 -- **通过主进程中转**:插件 A 发送 Command → 主进程处理 → 触发 Event → 插件 B 收到 -- **通过文件系统**:插件 A 写文件到公共目录 → 插件 B 定时轮询(不推荐) -- **共享 ZMQ Client**:未来可能会支持插件间自定义频道 +| 方法 | 说明 | 推荐 | +|------|------|------| +| `get_service_proxy(MyService)` | 获取代理对象,方法调用在提供者线程执行 | ✅ | +| `call_service_async(MyService, "method", *args)` | 异步调用,返回 Future | 高级用法 | +| `has_service(MyService)` | 检查服务是否可用 | - | + +#### 注意事项 + +- **死锁风险**:不要让两个插件互相调用对方的服务 +- **线程安全**:服务方法在提供者线程执行,调用方无需关心 +- **删除接口**:不要在服务接口中暴露删除等敏感操作 ### Q5: 最佳实践清单 @@ -790,7 +1222,14 @@ def on_initialized(self): # ═══ 最小可行插件模板 ═══ from plugin_manager import BasePlugin, PluginInfo, make_plugin_icon, WindowMode +from plugin_manager.config_types import OtherInfoBase, BoolConfig, IntConfig # 可选 from shared_types.events import VideoSaveEvent # 按需导入 +from shared_types.services.my_service import MyService # 服务接口(可选) + +# ═══ 配置类定义(可选) ═══ +class MyConfig(OtherInfoBase): + enable_feature = BoolConfig(True, "启用功能") + max_count = IntConfig(100, "最大数量", min_value=1, max_value=1000) class MyPlugin(BasePlugin): @@ -801,6 +1240,7 @@ class MyPlugin(BasePlugin): description="插件描述", window_mode=WindowMode.TAB, # TAB / DETACHED / CLOSED icon=make_plugin_icon("#1976D2", "M"), + other_info=MyConfig, # 👈 绑定配置类(可选) ) def _setup_subscriptions(self) -> None: @@ -811,6 +1251,20 @@ class MyPlugin(BasePlugin): def on_initialized(self): # 可选:耗时初始化 pass # self.data_dir 可存放数据 + + # 注册服务(如果是服务提供者) + # self.register_service(self, protocol=MyService) + + # 获取服务代理(如果是服务使用者) + # if self.has_service(MyService): + # self._service = self.get_service_proxy(MyService) + + # 连接配置变化信号(可选) + # self.config_changed.connect(self._on_config_changed) + + # 访问配置值(可选) + # if self.other_info: + # max_count = self.other_info.max_count def on_shutdown(self): # 可选:资源清理 pass diff --git a/requirements.txt b/requirements.txt index 74532e3..f61b9f8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,5 +3,6 @@ ms-toollib==1.5.3 setuptools==80.9.0 msgspec>=0.20.0 zmq>=0.0.0 -pywin32 -loguru \ No newline at end of file +pywin32>=311 +loguru>=0.7.3 +debugpy>=1.8.20 diff --git a/src/plugin_manager/__init__.py b/src/plugin_manager/__init__.py index 39c3f4b..461f706 100644 --- a/src/plugin_manager/__init__.py +++ b/src/plugin_manager/__init__.py @@ -6,6 +6,7 @@ - 事件分发机制 - 动态加载插件 - 独立的主界面窗口 +- 插件自定义配置系统 """ from .plugin_base import BasePlugin, PluginInfo, make_plugin_icon, WindowMode, LogLevel @@ -15,18 +16,52 @@ from .plugin_loader import PluginLoader from .server_bridge import GameServerBridge from .main_window import PluginManagerWindow +from .config_types import ( + BaseConfig, + BoolConfig, + IntConfig, + FloatConfig, + ChoiceConfig, + TextConfig, + ColorConfig, + FileConfig, + PathConfig, + LongTextConfig, + RangeConfig, + OtherInfoBase, +) +from .config_widget import OtherInfoWidget, OtherInfoScrollArea +from .config_manager import PluginConfigManager __all__ = [ + # 核心类 "BasePlugin", "PluginInfo", "WindowMode", "LogLevel", "LogConfig", "make_plugin_icon", + # 管理器 "PluginManager", "PluginManagerWindow", "EventDispatcher", "PluginLoader", "GameServerBridge", "run_plugin_manager_process", + # 配置系统 + "BaseConfig", + "BoolConfig", + "IntConfig", + "FloatConfig", + "ChoiceConfig", + "TextConfig", + "ColorConfig", + "FileConfig", + "PathConfig", + "LongTextConfig", + "RangeConfig", + "OtherInfoBase", + "OtherInfoWidget", + "OtherInfoScrollArea", + "PluginConfigManager", ] \ No newline at end of file diff --git a/src/plugin_manager/config_manager.py b/src/plugin_manager/config_manager.py new file mode 100644 index 0000000..6f5bbf4 --- /dev/null +++ b/src/plugin_manager/config_manager.py @@ -0,0 +1,140 @@ +""" +插件配置持久化管理 + +负责插件配置的加载和保存到 JSON 文件。 +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import TYPE_CHECKING + +from .config_types.other_info import OtherInfoBase + +if TYPE_CHECKING: + pass + + +class PluginConfigManager: + """ + 插件配置持久化管理 + + 目录结构:: + + data/plugin_data/ + ├── hello_world/ + │ └── config.json + └── stats_plugin/ + └── config.json + + 用法:: + + from plugin_manager.config_manager import PluginConfigManager + from plugin_manager.app_paths import get_plugin_data_dir + + manager = PluginConfigManager(Path("data/plugin_data")) + config = MyPluginOtherInfo() + manager.load("my_plugin", config) + # ... 使用 config ... + manager.save("my_plugin", config) + """ + + CONFIG_FILENAME = "config.json" + + def __init__(self, base_dir: Path) -> None: + """ + 初始化配置管理器 + + Args: + base_dir: 配置文件基础目录,通常为 data/plugin_data + """ + self._base_dir = Path(base_dir) + self._base_dir.mkdir(parents=True, exist_ok=True) + + def _config_path(self, plugin_name: str) -> Path: + """ + 获取插件配置文件路径 + + Args: + plugin_name: 插件名称 + + Returns: + 配置文件完整路径 + """ + return self._base_dir / plugin_name / self.CONFIG_FILENAME + + def load(self, plugin_name: str, config: OtherInfoBase) -> OtherInfoBase: + """ + 加载插件配置 + + 如果配置文件不存在或加载失败,则使用默认值。 + + Args: + plugin_name: 插件名称 + config: 配置容器实例 + + Returns: + 加载后的配置容器(同一实例) + """ + path = self._config_path(plugin_name) + + if not path.exists(): + # 配置文件不存在,使用默认值 + return config + + try: + data = json.loads(path.read_text(encoding="utf-8")) + if isinstance(data, dict): + config.from_dict(data) + except (json.JSONDecodeError, KeyError, TypeError) as e: + # 加载失败,使用默认值 + # 可选:记录日志 + pass + + return config + + def save(self, plugin_name: str, config: OtherInfoBase) -> None: + """ + 保存插件配置 + + Args: + plugin_name: 插件名称 + config: 配置容器实例 + """ + path = self._config_path(plugin_name) + path.parent.mkdir(parents=True, exist_ok=True) + + data = config.to_dict() + path.write_text( + json.dumps(data, indent=2, ensure_ascii=False), + encoding="utf-8", + ) + + def delete(self, plugin_name: str) -> None: + """ + 删除插件配置文件 + + Args: + plugin_name: 插件名称 + """ + path = self._config_path(plugin_name) + if path.exists(): + path.unlink() + + # 如果目录为空,也删除目录 + dir_path = path.parent + if dir_path.exists() and not any(dir_path.iterdir()): + dir_path.rmdir() + + def exists(self, plugin_name: str) -> bool: + """ + 检查插件配置文件是否存在 + + Args: + plugin_name: 插件名称 + + Returns: + True 表示存在 + """ + return self._config_path(plugin_name).exists() diff --git a/src/plugin_manager/config_types/__init__.py b/src/plugin_manager/config_types/__init__.py new file mode 100644 index 0000000..2983dd9 --- /dev/null +++ b/src/plugin_manager/config_types/__init__.py @@ -0,0 +1,51 @@ +""" +插件配置类型系统 + +提供插件自定义配置的定义、UI 反射和持久化支持。 + +用法:: + + from plugin_manager.config_types import ( + OtherInfoBase, BoolConfig, IntConfig, ChoiceConfig, TextConfig, + ColorConfig, FileConfig, PathConfig, LongTextConfig, RangeConfig, + ) + + class MyPluginOtherInfo(OtherInfoBase): + auto_save = BoolConfig(True, "自动保存") + interval = IntConfig(30, "间隔(秒)", min_value=1, max_value=300) + theme = ChoiceConfig("dark", "主题", + choices=[("light", "明亮"), ("dark", "暗黑")]) + theme_color = ColorConfig("#1976d2", "主题颜色") + export_path = FileConfig("", "导出文件", filter="JSON (*.json)", save_mode=True) + log_dir = PathConfig("", "日志目录") + description = LongTextConfig("", "描述", placeholder="输入描述...") + time_range = RangeConfig((0, 300), "时间范围(秒)") +""" + +from .base_config import BaseConfig +from .bool_config import BoolConfig +from .int_config import IntConfig +from .float_config import FloatConfig +from .choice_config import ChoiceConfig +from .text_config import TextConfig +from .color_config import ColorConfig +from .file_config import FileConfig +from .path_config import PathConfig +from .long_text_config import LongTextConfig +from .range_config import RangeConfig +from .other_info import OtherInfoBase + +__all__ = [ + "BaseConfig", + "BoolConfig", + "IntConfig", + "FloatConfig", + "ChoiceConfig", + "TextConfig", + "ColorConfig", + "FileConfig", + "PathConfig", + "LongTextConfig", + "RangeConfig", + "OtherInfoBase", +] diff --git a/src/plugin_manager/config_types/base_config.py b/src/plugin_manager/config_types/base_config.py new file mode 100644 index 0000000..1e82f4e --- /dev/null +++ b/src/plugin_manager/config_types/base_config.py @@ -0,0 +1,101 @@ +""" +配置字段基类 + +所有配置类型继承此类,通过类名决定 UI 控件类型。 +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from dataclasses import dataclass +from typing import Any, Callable, ClassVar, Generic, TypeVar + +from PyQt5.QtWidgets import QWidget +from PyQt5.QtCore import pyqtSignal, QObject + +T = TypeVar("T") + + +@dataclass +class BaseConfig(ABC, Generic[T]): + """ + 配置字段基类 + + 子类通过类名决定 UI 类型,变量名作为配置 key。 + + Attributes: + default: 默认值 + label: 显示标签 + description: tooltip 提示 + validator: 自定义验证函数 + + 类属性: + widget_type: UI 控件类型标识,由工厂使用 + """ + + default: T + label: str = "" + description: str = "" + validator: Callable[[T], bool] | None = None + + # 类变量:用于 UI 工厂识别 + widget_type: ClassVar[str] = "base" + + def __post_init__(self) -> None: + """初始化后处理""" + # 确保 label 不为空 + if not self.label: + self.label = "" + + @abstractmethod + def create_widget(self) -> tuple[QWidget, Callable[[], T], Callable[[T], None], QObject]: + """ + 创建 PyQt 控件 + + Returns: + (控件, 获取值函数, 设置值函数, 值变化信号对象) + + 信号对象应该是一个有 connect 方法的 QObject(如 pyqtSignal)。 + 当值变化时,配置系统会自动连接这个信号来同步值。 + """ + pass + + @abstractmethod + def to_storage(self, value: T) -> Any: + """ + 转换为存储格式(JSON 可序列化) + + Args: + value: 配置值 + + Returns: + 可 JSON 序列化的值 + """ + pass + + @abstractmethod + def from_storage(self, data: Any) -> T: + """ + 从存储格式恢复 + + Args: + data: JSON 反序列化的数据 + + Returns: + 配置值 + """ + pass + + def validate(self, value: T) -> bool: + """ + 验证值是否有效 + + Args: + value: 待验证的值 + + Returns: + True 表示有效 + """ + if self.validator is not None: + return self.validator(value) + return True diff --git a/src/plugin_manager/config_types/bool_config.py b/src/plugin_manager/config_types/bool_config.py new file mode 100644 index 0000000..c1fe18a --- /dev/null +++ b/src/plugin_manager/config_types/bool_config.py @@ -0,0 +1,44 @@ +""" +布尔配置类型 → QCheckBox +""" + +from __future__ import annotations + +from typing import Any, Callable + +from PyQt5.QtWidgets import QCheckBox +from PyQt5.QtCore import QObject + +from .base_config import BaseConfig + + +class BoolConfig(BaseConfig[bool]): + """ + 布尔配置 → QCheckBox + + 用法:: + + auto_save = BoolConfig(True, "自动保存", description="录制完成自动保存") + """ + + widget_type = "checkbox" + + def __post_init__(self) -> None: + """确保默认值是布尔类型""" + self.default = bool(self.default) + + def create_widget(self) -> tuple[QCheckBox, Callable[[], bool], Callable[[bool], None], QObject]: + """创建 QCheckBox 控件,返回 (控件, getter, setter, 信号)""" + widget = QCheckBox() + widget.setChecked(self.default) + if self.description: + widget.setToolTip(self.description) + return widget, widget.isChecked, widget.setChecked, widget.stateChanged + + def to_storage(self, value: bool) -> bool: + """转换为存储格式""" + return bool(value) + + def from_storage(self, data: Any) -> bool: + """从存储格式恢复""" + return bool(data) diff --git a/src/plugin_manager/config_types/choice_config.py b/src/plugin_manager/config_types/choice_config.py new file mode 100644 index 0000000..3e76df1 --- /dev/null +++ b/src/plugin_manager/config_types/choice_config.py @@ -0,0 +1,75 @@ +""" +选择配置类型 → QComboBox +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Callable + +from PyQt5.QtCore import QObject +from PyQt5.QtWidgets import QComboBox + +from .base_config import BaseConfig + + +@dataclass +class ChoiceConfig(BaseConfig[str]): + """ + 选择配置 → QComboBox + + Args: + default: 默认值(选项的 value) + label: 显示标签 + choices: 选项列表,格式为 [(value, display_text), ...] + description: tooltip 提示 + + 用法:: + + theme = ChoiceConfig( + "dark", "主题", + choices=[("light", "明亮"), ("dark", "暗黑"), ("auto", "跟随系统")] + ) + """ + + choices: list[tuple[str, str]] = field(default_factory=list) + + widget_type = "combobox" + + def __post_init__(self) -> None: + """确保默认值是字符串类型""" + self.default = str(self.default) + + def create_widget(self) -> tuple[QComboBox, Callable[[], str], Callable[[str], None], QObject]: + """创建 QComboBox 控件,返回 (控件, getter, setter, 信号)""" + + widget = QComboBox() + for value, text in self.choices: + widget.addItem(text, value) + + # 设置默认值 + idx = widget.findData(self.default) + if idx >= 0: + widget.setCurrentIndex(idx) + + if self.description: + widget.setToolTip(self.description) + + def get_value() -> str: + data = widget.currentData() + return str(data) if data is not None else self.default + + def set_value(value: str) -> None: + idx = widget.findData(value) + if idx >= 0: + widget.setCurrentIndex(idx) + + return widget, get_value, set_value, widget.currentIndexChanged + + def to_storage(self, value: str) -> str: + """转换为存储格式""" + return str(value) + + def from_storage(self, data: Any) -> str: + """从存储格式恢复""" + return str(data) diff --git a/src/plugin_manager/config_types/color_config.py b/src/plugin_manager/config_types/color_config.py new file mode 100644 index 0000000..009d4fe --- /dev/null +++ b/src/plugin_manager/config_types/color_config.py @@ -0,0 +1,105 @@ +""" +颜色配置类型 → 颜色选择按钮 +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Callable + +from PyQt5.QtCore import QObject, pyqtSignal +from PyQt5.QtGui import QColor +from PyQt5.QtWidgets import QPushButton, QColorDialog, QHBoxLayout, QWidget + +from .base_config import BaseConfig + + +class ColorChangeSignal(QObject): + """颜色变化信号发射器""" + changed = pyqtSignal() + + +@dataclass +class ColorConfig(BaseConfig[str]): + """ + 颜色配置 → QPushButton + QColorDialog + + Args: + default: 默认颜色(格式 "#RRGGBB" 或 "#AARRGGBB") + label: 显示标签 + description: tooltip 提示 + + 用法:: + + theme_color = ColorConfig("#1976d2", "主题颜色") + highlight_color = ColorConfig("#ff5722", "高亮颜色") + """ + + widget_type = "color" + + def __post_init__(self) -> None: + """确保默认值是有效的颜色格式""" + if not self.default.startswith("#"): + self.default = "#" + self.default + + def create_widget(self) -> tuple[QWidget, Callable[[], str], Callable[[str], None], QObject]: + """创建颜色选择按钮,返回 (控件, getter, setter, 信号)""" + + container = QWidget() + layout = QHBoxLayout(container) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(4) + + # 创建信号发射器,并保存为容器的属性(防止垃圾回收) + signal_emitter = ColorChangeSignal(parent=container) + changed_signal = signal_emitter.changed + + # 颜色预览按钮 + btn = QPushButton() + btn.setFixedSize(40, 24) + btn.setStyleSheet(f"background-color: {self.default}; border: 1px solid #999;") + if self.description: + btn.setToolTip(self.description) + + # 文本显示 + text_btn = QPushButton(self.default) + text_btn.setFixedHeight(24) + text_btn.setStyleSheet("text-align: left; padding-left: 4px;") + + layout.addWidget(btn) + layout.addWidget(text_btn, 1) + + current_color = [self.default] # 使用列表保存可变状态 + + def on_click(): + color = QColorDialog.getColor(QColor(current_color[0])) + if color.isValid(): + color_str = color.name() # #RRGGBB + current_color[0] = color_str + btn.setStyleSheet(f"background-color: {color_str}; border: 1px solid #999;") + text_btn.setText(color_str) + changed_signal.emit() + + btn.clicked.connect(on_click) + text_btn.clicked.connect(on_click) + + def get_value() -> str: + return current_color[0] + + def set_value(value: str) -> None: + if value and value.startswith("#"): + current_color[0] = value + btn.setStyleSheet(f"background-color: {value}; border: 1px solid #999;") + text_btn.setText(value) + + return container, get_value, set_value, changed_signal + + def to_storage(self, value: str) -> str: + """转换为存储格式""" + return str(value) + + def from_storage(self, data: Any) -> str: + """从存储格式恢复""" + if isinstance(data, str) and data.startswith("#"): + return data + return self.default diff --git a/src/plugin_manager/config_types/file_config.py b/src/plugin_manager/config_types/file_config.py new file mode 100644 index 0000000..d82fe6f --- /dev/null +++ b/src/plugin_manager/config_types/file_config.py @@ -0,0 +1,83 @@ +""" +文件配置类型 → 文件选择器 +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Callable + +from PyQt5.QtCore import QObject +from PyQt5.QtWidgets import QWidget, QHBoxLayout, QLineEdit, QPushButton, QFileDialog + +from .base_config import BaseConfig + + +@dataclass +class FileConfig(BaseConfig[str]): + """ + 文件配置 → QLineEdit + QPushButton (浏览) + + Args: + default: 默认文件路径 + label: 显示标签 + description: tooltip 提示 + filter: 文件过滤器(如 "JSON Files (*.json)") + save_mode: True 表示保存文件对话框,False 表示打开文件对话框 + + 用法:: + + db_file = FileConfig("", "数据库文件", filter="SQLite (*.db)") + export_file = FileConfig("", "导出文件", filter="JSON (*.json)", save_mode=True) + """ + + filter: str = "" + save_mode: bool = False + + widget_type = "file" + + def __post_init__(self) -> None: + """确保默认值是字符串""" + self.default = str(self.default) if self.default else "" + + def create_widget(self) -> tuple[QWidget, Callable[[], str], Callable[[str], None], QObject]: + """创建文件选择器,返回 (控件, getter, setter, 信号)""" + + container = QWidget() + layout = QHBoxLayout(container) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(4) + + line_edit = QLineEdit(str(self.default)) + if self.description: + line_edit.setToolTip(self.description) + + btn = QPushButton("浏览") + btn.setFixedWidth(50) + + def on_browse(): + if self.save_mode: + path, _ = QFileDialog.getSaveFileName( + container, "选择文件", line_edit.text(), self.filter + ) + else: + path, _ = QFileDialog.getOpenFileName( + container, "选择文件", line_edit.text(), self.filter + ) + if path: + line_edit.setText(path) + + btn.clicked.connect(on_browse) + + layout.addWidget(line_edit, 1) + layout.addWidget(btn) + + return container, line_edit.text, line_edit.setText, line_edit.textChanged + + def to_storage(self, value: str) -> str: + """转换为存储格式""" + return str(value) + + def from_storage(self, data: Any) -> str: + """从存储格式恢复""" + return str(data) if data else self.default diff --git a/src/plugin_manager/config_types/float_config.py b/src/plugin_manager/config_types/float_config.py new file mode 100644 index 0000000..402f122 --- /dev/null +++ b/src/plugin_manager/config_types/float_config.py @@ -0,0 +1,68 @@ +""" +浮点数配置类型 → QDoubleSpinBox +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Callable + +from PyQt5.QtCore import QObject +from PyQt5.QtWidgets import QDoubleSpinBox + +from .base_config import BaseConfig + + +@dataclass +class FloatConfig(BaseConfig[float]): + """ + 浮点数配置 → QDoubleSpinBox + + Args: + default: 默认值 + label: 显示标签 + min_value: 最小值 + max_value: 最大值 + step: 步进值 + decimals: 小数位数 + description: tooltip 提示 + + 用法:: + + ratio = FloatConfig(0.8, "缩放比例", min_value=0.1, max_value=2.0, decimals=2) + """ + + min_value: float = 0.0 + max_value: float = 9999.0 + step: float = 0.1 + decimals: int = 2 + + widget_type = "doublespinbox" + + def __post_init__(self) -> None: + """确保默认值是浮点类型""" + self.default = float(self.default) + + def create_widget( + self, + ) -> tuple[QDoubleSpinBox, Callable[[], float], Callable[[float], None], QObject]: + """创建 QDoubleSpinBox 控件,返回 (控件, getter, setter, 信号)""" + widget = QDoubleSpinBox() + widget.setRange(self.min_value, self.max_value) + widget.setValue(self.default) + widget.setSingleStep(self.step) + widget.setDecimals(self.decimals) + if self.description: + widget.setToolTip(self.description) + return widget, widget.value, widget.setValue, widget.valueChanged + + def to_storage(self, value: float) -> float: + """转换为存储格式""" + return float(value) + + def from_storage(self, data: Any) -> float: + """从存储格式恢复""" + try: + return float(data) + except (ValueError, TypeError): + return self.default diff --git a/src/plugin_manager/config_types/int_config.py b/src/plugin_manager/config_types/int_config.py new file mode 100644 index 0000000..abb2a1b --- /dev/null +++ b/src/plugin_manager/config_types/int_config.py @@ -0,0 +1,79 @@ +""" +整数配置类型 → QSpinBox 或 QSlider +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Callable + +from PyQt5.QtCore import Qt +from PyQt5.QtWidgets import QSpinBox, QSlider + +from .base_config import BaseConfig + + +@dataclass +class IntConfig(BaseConfig[int]): + """ + 整数配置 → QSpinBox 或 QSlider + + Args: + default: 默认值 + label: 显示标签 + min_value: 最小值 + max_value: 最大值 + step: 步进值 + use_slider: 是否使用滑块控件 + description: tooltip 提示 + + 用法:: + + interval = IntConfig(30, "间隔(秒)", min_value=1, max_value=300, step=10) + quality = IntConfig(80, "质量", min_value=1, max_value=100, use_slider=True) + """ + + min_value: int = 0 + max_value: int = 9999 + step: int = 1 + use_slider: bool = False + + widget_type = "spinbox" + + def __post_init__(self) -> None: + """确保默认值是整数类型""" + self.default = int(self.default) + + def create_widget( + self, + ) -> tuple[QSpinBox | QSlider, Callable[[], int], Callable[[int], None], QObject]: + """创建 QSpinBox 或 QSlider 控件,返回 (控件, getter, setter, 信号)""" + from PyQt5.QtCore import QObject + + if self.use_slider: + widget = QSlider(Qt.Horizontal) + widget.setRange(self.min_value, self.max_value) + widget.setValue(self.default) + widget.setSingleStep(self.step) + if self.description: + widget.setToolTip(self.description) + return widget, widget.value, widget.setValue, widget.valueChanged + else: + widget = QSpinBox() + widget.setRange(self.min_value, self.max_value) + widget.setValue(self.default) + widget.setSingleStep(self.step) + if self.description: + widget.setToolTip(self.description) + return widget, widget.value, widget.setValue, widget.valueChanged + + def to_storage(self, value: int) -> int: + """转换为存储格式""" + return int(value) + + def from_storage(self, data: Any) -> int: + """从存储格式恢复""" + try: + return int(data) + except (ValueError, TypeError): + return self.default diff --git a/src/plugin_manager/config_types/long_text_config.py b/src/plugin_manager/config_types/long_text_config.py new file mode 100644 index 0000000..3fe10db --- /dev/null +++ b/src/plugin_manager/config_types/long_text_config.py @@ -0,0 +1,63 @@ +""" +多行文本配置类型 → QTextEdit +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Callable + +from PyQt5.QtCore import QObject +from PyQt5.QtWidgets import QTextEdit + +from .base_config import BaseConfig + + +@dataclass +class LongTextConfig(BaseConfig[str]): + """ + 多行文本配置 → QTextEdit + + Args: + default: 默认文本内容 + label: 显示标签 + description: tooltip 提示 + placeholder: 占位符文本 + max_height: 最大高度(像素) + + 用法:: + + description = LongTextConfig("", "描述", placeholder="输入描述...") + script = LongTextConfig("", "脚本内容", max_height=150) + """ + + placeholder: str = "" + max_height: int = 100 + + widget_type = "longtext" + + def __post_init__(self) -> None: + """确保默认值是字符串""" + self.default = str(self.default) if self.default else "" + + def create_widget(self) -> tuple[QTextEdit, Callable[[], str], Callable[[str], None], QObject]: + """创建多行文本编辑器,返回 (控件, getter, setter, 信号)""" + widget = QTextEdit() + widget.setPlainText(str(self.default)) + widget.setMaximumHeight(self.max_height) + + if self.placeholder: + widget.setPlaceholderText(self.placeholder) + + if self.description: + widget.setToolTip(self.description) + + return widget, widget.toPlainText, widget.setPlainText, widget.textChanged + + def to_storage(self, value: str) -> str: + """转换为存储格式""" + return str(value) + + def from_storage(self, data: Any) -> str: + """从存储格式恢复""" + return str(data) if data is not None else self.default diff --git a/src/plugin_manager/config_types/other_info.py b/src/plugin_manager/config_types/other_info.py new file mode 100644 index 0000000..c70bf76 --- /dev/null +++ b/src/plugin_manager/config_types/other_info.py @@ -0,0 +1,199 @@ +""" +插件自定义配置容器基类 + +插件继承此类定义自己的配置字段。 +""" + +from __future__ import annotations + +from typing import Any, Callable, ClassVar + +from .base_config import BaseConfig + + +class OtherInfoBase: + """ + 插件自定义配置容器基类 + + 插件继承此类定义配置字段,变量名作为配置 key: + + 用法:: + + from plugin_manager.config_types import ( + OtherInfoBase, BoolConfig, IntConfig, ChoiceConfig, TextConfig + ) + + class MyPluginOtherInfo(OtherInfoBase): + # 变量名 = ConfigType(默认值, 标签, 其他参数...) + auto_save = BoolConfig(True, "自动保存", description="录制完成自动保存") + save_interval = IntConfig(30, "保存间隔(秒)", min_value=10, max_value=300, step=10) + output_format = ChoiceConfig( + "evf", "输出格式", + choices=[("evf", "EVF"), ("avi", "AVI"), ("mp4", "MP4")] + ) + api_key = TextConfig("", "API密钥", password=True, placeholder="输入密钥...") + + 然后在 PluginInfo 中绑定:: + + @classmethod + def plugin_info(cls) -> PluginInfo: + return PluginInfo( + name="my_plugin", + other_info=MyPluginOtherInfo # 绑定配置类 + ) + """ + + # 子类定义的配置字段(类属性) + _fields: ClassVar[dict[str, BaseConfig]] = {} + + def __init__(self) -> None: + """ + 初始化配置容器 + + 收集所有 BaseConfig 类属性,并初始化运行时值存储。 + """ + # 收集所有 BaseConfig 类属性 + fields: dict[str, BaseConfig] = {} + for name in dir(type(self)): + attr = getattr(type(self), name, None) + if isinstance(attr, BaseConfig): + fields[name] = attr + + # 使用 object.__setattr__ 设置实例属性(避免触发 __setattr__) + object.__setattr__(self, "_fields", fields) + object.__setattr__(self, "_values", {}) + object.__setattr__(self, "_on_change", None) # 变化回调 + + # 初始化运行时值存储(初始为默认值) + # 注意:这里必须使用 object.__setattr__ 因为我们要直接操作 _values 字典 + values: dict[str, Any] = {name: field.default for name, field in fields.items()} + object.__setattr__(self, "_values", values) + + def set_on_change(self, callback: Callable[[str, Any], None] | None) -> None: + """ + 设置配置值变化回调 + + Args: + callback: 回调函数,签名为 (字段名, 新值) -> None + """ + object.__setattr__(self, "_on_change", callback) + + def __getattribute__(self, name: str) -> Any: + """获取属性 - 拦截配置字段访问""" + # 先获取 _fields 和 _values(避免无限递归) + if name.startswith("_") or name in ("to_dict", "from_dict", "reset_to_defaults", "get_fields", "set_on_change"): + return object.__getattribute__(self, name) + + try: + fields = object.__getattribute__(self, "_fields") + values = object.__getattribute__(self, "_values") + except AttributeError: + return object.__getattribute__(self, name) + + if name in fields: + return values.get(name, fields[name].default) + + return object.__getattribute__(self, name) + + def __setattr__(self, name: str, value: Any) -> None: + """设置配置值(带验证和回调)""" + if name.startswith("_"): + object.__setattr__(self, name, value) + return + + fields = object.__getattribute__(self, "_fields") + values = object.__getattribute__(self, "_values") + + if name in fields: + field = fields[name] + if not field.validate(value): + raise ValueError(f"Invalid value for '{name}': {value}") + + old_value = values.get(name, field.default) + values[name] = value + + # 触发变化回调 + on_change = object.__getattribute__(self, "_on_change") + if on_change is not None and old_value != value: + on_change(name, value) + else: + object.__setattr__(self, name, value) + + @classmethod + def get_fields(cls) -> dict[str, BaseConfig]: + """ + 获取所有配置字段定义 + + Returns: + 字段名到 BaseConfig 实例的映射 + """ + return { + name: attr + for name in dir(cls) + if isinstance(attr := getattr(cls, name, None), BaseConfig) + } + + def to_dict(self) -> dict[str, Any]: + """ + 导出为字典(用于保存) + + Returns: + 配置键值对字典,值已转换为存储格式 + """ + fields = object.__getattribute__(self, "_fields") + values = object.__getattribute__(self, "_values") + return { + name: field.to_storage(values.get(name, field.default)) + for name, field in fields.items() + } + + def from_dict(self, data: dict[str, Any], silent: bool = True) -> None: + """ + 从字典加载配置 + + Args: + data: 配置键值对字典 + silent: 是否静默加载(不触发变化回调),默认 True + """ + fields = object.__getattribute__(self, "_fields") + values = object.__getattribute__(self, "_values") + on_change = object.__getattribute__(self, "_on_change") + + for name, field in fields.items(): + if name in data: + try: + old_value = values.get(name, field.default) + new_value = field.from_storage(data[name]) + values[name] = new_value + + # 只有非静默模式且值真正变化时才触发回调 + if not silent and on_change is not None and old_value != new_value: + on_change(name, new_value) + except (ValueError, TypeError): + # 加载失败则使用默认值 + values[name] = field.default + + def reset_to_defaults(self, silent: bool = True) -> None: + """ + 重置所有配置为默认值 + + Args: + silent: 是否静默重置(不触发变化回调),默认 True + """ + fields = object.__getattribute__(self, "_fields") + values = object.__getattribute__(self, "_values") + on_change = object.__getattribute__(self, "_on_change") + + for name, field in fields.items(): + old_value = values.get(name, field.default) + values[name] = field.default + + # 只有非静默模式且值真正变化时才触发回调 + if not silent and on_change is not None and old_value != field.default: + on_change(name, field.default) + + def __repr__(self) -> str: + fields = object.__getattribute__(self, "_fields") + values = object.__getattribute__(self, "_values") + values_str = ", ".join(f"{k}={v!r}" for k, v in values.items()) + return f"{type(self).__name__}({values_str})" diff --git a/src/plugin_manager/config_types/path_config.py b/src/plugin_manager/config_types/path_config.py new file mode 100644 index 0000000..7f683e4 --- /dev/null +++ b/src/plugin_manager/config_types/path_config.py @@ -0,0 +1,73 @@ +""" +目录配置类型 → 目录选择器 +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Callable + +from PyQt5.QtCore import QObject +from PyQt5.QtWidgets import QWidget, QHBoxLayout, QLineEdit, QPushButton, QFileDialog + +from .base_config import BaseConfig + + +@dataclass +class PathConfig(BaseConfig[str]): + """ + 目录配置 → QLineEdit + QPushButton (浏览) + + Args: + default: 默认目录路径 + label: 显示标签 + description: tooltip 提示 + + 用法:: + + log_dir = PathConfig("", "日志目录") + cache_dir = PathConfig("", "缓存目录") + """ + + widget_type = "path" + + def __post_init__(self) -> None: + """确保默认值是字符串""" + self.default = str(self.default) if self.default else "" + + def create_widget(self) -> tuple[QWidget, Callable[[], str], Callable[[str], None], QObject]: + """创建目录选择器,返回 (控件, getter, setter, 信号)""" + + container = QWidget() + layout = QHBoxLayout(container) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(4) + + line_edit = QLineEdit(str(self.default)) + if self.description: + line_edit.setToolTip(self.description) + + btn = QPushButton("浏览") + btn.setFixedWidth(50) + + def on_browse(): + path = QFileDialog.getExistingDirectory( + container, "选择目录", line_edit.text() + ) + if path: + line_edit.setText(path) + + btn.clicked.connect(on_browse) + + layout.addWidget(line_edit, 1) + layout.addWidget(btn) + + return container, line_edit.text, line_edit.setText, line_edit.textChanged + + def to_storage(self, value: str) -> str: + """转换为存储格式""" + return str(value) + + def from_storage(self, data: Any) -> str: + """从存储格式恢复""" + return str(data) if data else self.default diff --git a/src/plugin_manager/config_types/range_config.py b/src/plugin_manager/config_types/range_config.py new file mode 100644 index 0000000..968d5fe --- /dev/null +++ b/src/plugin_manager/config_types/range_config.py @@ -0,0 +1,111 @@ +""" +数值范围配置类型 → 两个 QSpinBox +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Callable + +from PyQt5.QtCore import QObject, pyqtSignal +from PyQt5.QtWidgets import QWidget, QHBoxLayout, QSpinBox, QLabel + +from .base_config import BaseConfig + + +class RangeChangeSignal(QObject): + """范围变化信号发射器""" + changed = pyqtSignal() + + +@dataclass +class RangeConfig(BaseConfig[tuple[int, int]]): + """ + 数值范围配置 → 两个 QSpinBox + + Args: + default: 默认范围值 (min, max) + label: 显示标签 + description: tooltip 提示 + min_value: 最小允许值 + max_value: 最大允许值 + step: 步进值 + + 用法:: + + time_range = RangeConfig((0, 300), "时间范围(秒)", min_value=0, max_value=999) + bbbv_range = RangeConfig((0, 999), "3BV范围", min_value=0, max_value=9999) + """ + + min_value: int = 0 + max_value: int = 9999 + step: int = 1 + + widget_type = "range" + + def __post_init__(self) -> None: + """确保默认值是元组""" + if not isinstance(self.default, tuple): + self.default = (self.min_value, self.max_value) + self.default = (int(self.default[0]), int(self.default[1])) + + def create_widget( + self, + ) -> tuple[QWidget, Callable[[], tuple[int, int]], Callable[[tuple[int, int]], None], QObject]: + """创建范围选择器,返回 (控件, getter, setter, 信号)""" + + container = QWidget() + layout = QHBoxLayout(container) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(4) + + # 创建信号发射器,并保存为容器的属性(防止垃圾回收) + signal_emitter = RangeChangeSignal(parent=container) + changed_signal = signal_emitter.changed + + # 最小值 + min_spin = QSpinBox() + min_spin.setRange(self.min_value, self.max_value) + min_spin.setValue(self.default[0]) + min_spin.setSingleStep(self.step) + + # 分隔符 + sep = QLabel("-") + + # 最大值 + max_spin = QSpinBox() + max_spin.setRange(self.min_value, self.max_value) + max_spin.setValue(self.default[1]) + max_spin.setSingleStep(self.step) + + if self.description: + min_spin.setToolTip(self.description) + max_spin.setToolTip(self.description) + + layout.addWidget(min_spin) + layout.addWidget(sep) + layout.addWidget(max_spin) + + # 连接两个 spinbox 的值变化信号到统一的 changed 信号 + min_spin.valueChanged.connect(lambda: changed_signal.emit()) + max_spin.valueChanged.connect(lambda: changed_signal.emit()) + + def get_value() -> tuple[int, int]: + return (min_spin.value(), max_spin.value()) + + def set_value(value: tuple[int, int]) -> None: + if isinstance(value, tuple) and len(value) == 2: + min_spin.setValue(int(value[0])) + max_spin.setValue(int(value[1])) + + return container, get_value, set_value, changed_signal + + def to_storage(self, value: tuple[int, int]) -> list[int]: + """转换为存储格式(JSON 不支持元组)""" + return [int(value[0]), int(value[1])] + + def from_storage(self, data: Any) -> tuple[int, int]: + """从存储格式恢复""" + if isinstance(data, (list, tuple)) and len(data) == 2: + return (int(data[0]), int(data[1])) + return self.default diff --git a/src/plugin_manager/config_types/text_config.py b/src/plugin_manager/config_types/text_config.py new file mode 100644 index 0000000..2edf781 --- /dev/null +++ b/src/plugin_manager/config_types/text_config.py @@ -0,0 +1,65 @@ +""" +文本配置类型 → QLineEdit +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Callable + +from PyQt5.QtCore import QObject +from PyQt5.QtWidgets import QLineEdit + +from .base_config import BaseConfig + + +@dataclass +class TextConfig(BaseConfig[str]): + """ + 文本配置 → QLineEdit + + Args: + default: 默认值 + label: 显示标签 + placeholder: 占位符文本 + password: 是否为密码输入(显示为 ***) + description: tooltip 提示 + + 用法:: + + api_key = TextConfig("", "API密钥", password=True, placeholder="输入密钥...") + name = TextConfig("", "名称", placeholder="请输入名称") + """ + + placeholder: str = "" + password: bool = False + + widget_type = "textedit" + + def __post_init__(self) -> None: + """确保默认值是字符串类型""" + self.default = str(self.default) + + def create_widget(self) -> tuple[QLineEdit, Callable[[], str], Callable[[str], None], QObject]: + """创建 QLineEdit 控件,返回 (控件, getter, setter, 信号)""" + widget = QLineEdit() + widget.setText(str(self.default)) + + if self.placeholder: + widget.setPlaceholderText(self.placeholder) + + if self.password: + widget.setEchoMode(QLineEdit.Password) + + if self.description: + widget.setToolTip(self.description) + + return widget, widget.text, widget.setText, widget.textChanged + + def to_storage(self, value: str) -> str: + """转换为存储格式""" + return str(value) + + def from_storage(self, data: Any) -> str: + """从存储格式恢复""" + return str(data) if data is not None else self.default diff --git a/src/plugin_manager/config_widget.py b/src/plugin_manager/config_widget.py new file mode 100644 index 0000000..98e70dc --- /dev/null +++ b/src/plugin_manager/config_widget.py @@ -0,0 +1,175 @@ +""" +插件配置 UI 组件 + +根据 OtherInfoBase 自动生成配置界面。 +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Callable + +from PyQt5.QtCore import Qt, QObject, pyqtSignal +from PyQt5.QtWidgets import ( + QFormLayout, + QLabel, + QScrollArea, + QVBoxLayout, + QWidget, +) + +from .config_types.base_config import BaseConfig +from .config_types.other_info import OtherInfoBase + +if TYPE_CHECKING: + pass + + +class OtherInfoWidget(QWidget): + """ + 根据 OtherInfoBase 自动生成配置 UI + + 自动绑定配置字段 → UI 控件 → 值同步 + + Signals: + config_changed: 配置值变化信号,参数为 (字段名, 新值) + """ + + config_changed = pyqtSignal(str, object) # (field_name, new_value) + + def __init__(self, other_info: OtherInfoBase, parent: QWidget | None = None) -> None: + """ + 初始化配置 UI + + Args: + other_info: 配置容器实例 + parent: 父控件 + """ + super().__init__(parent) + self._other_info = other_info + self._widgets: dict[str, QWidget] = {} + self._getters: dict[str, Callable[[], Any]] = {} + self._setters: dict[str, Callable[[Any], None]] = {} + self._signals: dict[str, QObject] = {} + + self._setup_ui() + + def _setup_ui(self) -> None: + """构建 UI""" + # 使用 FormLayout 布局 + layout = QFormLayout(self) + layout.setSpacing(10) + layout.setContentsMargins(10, 10, 10, 10) + layout.setLabelAlignment(Qt.AlignmentFlag.AlignLeft) + + fields = self._other_info._fields + + if not fields: + # 无配置项时显示提示 + label = QLabel("此插件无自定义配置") + label.setStyleSheet("color: gray; font-style: italic;") + layout.addRow(label) + return + + for name, config_field in fields.items(): + # 使用 config_field 自己的 create_widget 方法 + widget, getter, setter, signal = config_field.create_widget() + + # 设置当前值 + current = getattr(self._other_info, name) + setter(current) + + # 创建标签 + label = QLabel(config_field.label) + + # 添加到布局 + layout.addRow(label, widget) + + # 保存引用 + self._widgets[name] = widget + self._getters[name] = getter + self._setters[name] = setter + self._signals[name] = signal + + # 连接变化信号 + self._connect_change_signal(signal, name) + + def _connect_change_signal(self, signal: QObject, name: str) -> None: + """ + 连接控件变化信号 + + Args: + signal: 值变化信号对象(QObject 或 pyqtSignal) + name: 字段名 + """ + def on_change(*args) -> None: + self._on_changed(name) + + # 信号可能是 QObject(有 connect 方法)或信号的 bound signal + try: + signal.connect(on_change) + except (TypeError, AttributeError): + pass # 如果信号连接失败,忽略 + + def _on_changed(self, name: str) -> None: + """ + 控件值变化时只发射信号,不立即应用到配置 + + Args: + name: 字段名 + """ + value = self._getters[name]() + # 只发射 UI 信号,不修改配置对象 + # 配置将在 apply_to_config() 时统一应用 + self.config_changed.emit(name, value) + + def apply_to_config(self) -> None: + """将所有 UI 值同步到 OtherInfo 配置对象(此时才触发变化回调)""" + for name, getter in self._getters.items(): + # 设置配置值,此时会触发 OtherInfoBase 的变化回调 + setattr(self._other_info, name, getter()) + + def refresh_from_config(self) -> None: + """从 OtherInfo 配置对象刷新 UI 值""" + for name, setter in self._setters.items(): + value = getattr(self._other_info, name) + setter(value) + + @property + def other_info(self) -> OtherInfoBase: + """获取配置对象""" + return self._other_info + + +class OtherInfoScrollArea(QScrollArea): + """ + 带滚动条的配置 UI 容器 + + 用于配置项较多时提供滚动支持。 + """ + + config_changed = pyqtSignal(str, object) + + def __init__(self, other_info: OtherInfoBase, parent: QWidget | None = None) -> None: + super().__init__(parent) + self.setWidgetResizable(True) + self.setFrameShape(QScrollArea.Shape.NoFrame) + + # 创建内部 widget + self._inner_widget = OtherInfoWidget(other_info, self) + self.setWidget(self._inner_widget) + + # 转发信号 + self._inner_widget.config_changed.connect(self.config_changed.emit) + + def apply_to_config(self) -> None: + """将所有 UI 值同步到 OtherInfo 配置对象""" + self._inner_widget.apply_to_config() + + def refresh_from_config(self) -> None: + """从 OtherInfo 配置对象刷新 UI 值""" + self._inner_widget.refresh_from_config() + + @property + def other_info(self) -> OtherInfoBase: + """获取配置对象""" + return self._inner_widget.other_info diff --git a/src/plugin_manager/event_dispatcher.py b/src/plugin_manager/event_dispatcher.py index b5a0bb2..cc6f8e4 100644 --- a/src/plugin_manager/event_dispatcher.py +++ b/src/plugin_manager/event_dispatcher.py @@ -15,6 +15,8 @@ import loguru +from .service_registry import ServiceRegistry + if TYPE_CHECKING: from .plugin_base import BasePlugin @@ -39,11 +41,29 @@ class EventDispatcher: - dispatch() 不阻塞:将事件投递到各插件的队列,立即返回 - 异常隔离通过各插件线程自行处理 - 背压控制:队列满时丢弃事件并记录警告 + - 服务注册:管理插件间服务通讯 """ def __init__(self): self._handlers: dict[str, list[HandlerEntry]] = defaultdict(list) self._lock = threading.RLock() + # 服务注册表(用于插件间通讯) + self._service_registry = ServiceRegistry() + + @property + def services(self) -> ServiceRegistry: + """ + 获取服务注册表 + + 用法:: + + # 注册服务 + dispatcher.services.register(HistoryService, provider, "history") + + # 获取服务 + history = dispatcher.services.get(HistoryService) + """ + return self._service_registry def subscribe( self, diff --git a/src/plugin_manager/logging_setup.py b/src/plugin_manager/logging_setup.py index 10f2b04..e8b20ba 100644 --- a/src/plugin_manager/logging_setup.py +++ b/src/plugin_manager/logging_setup.py @@ -150,6 +150,9 @@ def set_plugin_log_level(sink_id: int, level: str = "DEBUG") -> None: config = loguru.logger._core handler = config.handlers.get(sink_id) if handler is not None: - from loguru._logger import Level - handler._levelno = Level(level).no # type: ignore[union-attr] - handler._levelname = level # type: ignore[union-attr] + # 从 loguru 的 levels 字典获取级别号 + 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 78842f6..81ddfd9 100644 --- a/src/plugin_manager/main_window.py +++ b/src/plugin_manager/main_window.py @@ -17,6 +17,7 @@ QApplication, QCheckBox, QComboBox, + QDialog, QDialogButtonBox, QFormLayout, QGroupBox, @@ -29,6 +30,7 @@ QMessageBox, QMenu, QPushButton, + QScrollArea, QStatusBar, QSystemTrayIcon, QTabBar, @@ -348,12 +350,20 @@ def set_status( class PluginSettingsDialog(QDialog): """编辑单个插件的持久化状态""" - def __init__(self, plugin_name: str, state: PluginState, parent=None): + def __init__( + self, + plugin_name: str, + state: PluginState, + other_info: "OtherInfoBase | None" = None, + parent=None, + ): super().__init__(parent) self._name = plugin_name + self._other_info = other_info self.setWindowTitle(self.tr("插件设置 - {n}").format(n=plugin_name)) - self.setMinimumWidth(380) + self.setMinimumWidth(400) + self.setMinimumHeight(300) self.setModal(True) layout = QVBoxLayout(self) @@ -379,7 +389,8 @@ def __init__(self, plugin_name: str, state: PluginState, parent=None): for mode in WindowMode._values(): label = WindowMode.LABELS.get(mode, mode) self._combo_mode.addItem(label, mode) - idx = self._combo_mode.findData(state.window_mode.value if isinstance(state.window_mode, WindowMode) else str(state.window_mode)) + # WindowMode 继承自 str,直接 str() 转换 + idx = self._combo_mode.findData(str(state.window_mode)) if idx >= 0: self._combo_mode.setCurrentIndex(idx) else: @@ -395,9 +406,8 @@ def __init__(self, plugin_name: str, state: PluginState, parent=None): for level in LogLevel._values(): label = LogLevel.LABELS.get(level, level) self._combo_loglevel.addItem(label, level) - _lvl_idx = self._combo_loglevel.findData( - state.log_level.value if isinstance(state.log_level, LogLevel) else str(state.log_level).upper() - ) + # LogLevel 继承自 str,直接 str() 转换 + _lvl_idx = self._combo_loglevel.findData(str(state.log_level).upper()) if _lvl_idx >= 0: self._combo_loglevel.setCurrentIndex(_lvl_idx) else: @@ -405,6 +415,21 @@ def __init__(self, plugin_name: str, state: PluginState, parent=None): form3.addRow(self.tr("日志级别:"), self._combo_loglevel) layout.addWidget(grp3) + # ── 插件自定义配置 ── + self._config_widget = None + if other_info is not None: + from .config_widget import OtherInfoScrollArea + + grp4 = QGroupBox(self.tr("插件配置")) + grp4_layout = QVBoxLayout(grp4) + + scroll_area = OtherInfoScrollArea(other_info, grp4) + scroll_area.setMinimumHeight(150) + grp4_layout.addWidget(scroll_area) + + layout.addWidget(grp4) + self._config_widget = scroll_area + layout.addStretch() # 按钮 @@ -424,6 +449,11 @@ def result_state(self) -> PluginState: log_level=LogLevel(str(self._combo_loglevel.currentData())), ) + def apply_config(self) -> None: + """应用配置到 other_info 对象""" + if self._config_widget: + self._config_widget.apply_to_config() + # ═══════════════════════════════════════════════════════════════════ # 主窗口 @@ -921,11 +951,21 @@ def _open_plugin_log(self, name: str) -> None: def _open_plugin_settings(self, name: str) -> None: """打开插件设置对话框""" current = self._effective_state(name) - dlg = PluginSettingsDialog(name, current, parent=self) + # 获取插件的 other_info + plugin = self._manager.plugins.get(name) + other_info = plugin.other_info if plugin else None + + dlg = PluginSettingsDialog(name, current, other_info, parent=self) if dlg.exec_() == QDialog.Accepted: new_state = dlg.result_state self._state_mgr.set(name, new_state) self._state_mgr.save() + + # 应用插件自定义配置 + if other_info: + dlg.apply_config() + plugin.save_config() + # 立即应用启用/禁用 if current.enabled != new_state.enabled: if new_state.enabled: @@ -933,8 +973,33 @@ def _open_plugin_settings(self, name: str) -> None: else: self._close_plugin_window(name) self._manager.disable_plugin(name) + + # 立即应用窗口模式变化 + if current.window_mode != new_state.window_mode: + t = self._tab_widget + if new_state.window_mode == WindowMode.CLOSED: + # 关闭窗口 + self._closed_plugins.add(name) + if name in t._detached_windows: + # 如果是独立窗口,嵌回标签页然后关闭 + t._attach_tab(name) + self._close_plugin_window(name) + elif new_state.window_mode == WindowMode.DETACHED: + # 弹出为独立窗口 + self._closed_plugins.discard(name) + if name not in t._detached_windows: + # 当前是标签页,弹出为独立窗口 + for i in range(t.count()): + if t.tabText(i) == name: + t._detach_tab(i, name) + break + else: # TAB + # 嵌回标签页 + self._closed_plugins.discard(name) + if name in t._detached_windows: + t._attach_tab(name) + # 立即应用日志级别 - plugin = self._manager.plugins.get(name) if plugin and current.log_level != new_state.log_level: plugin.set_log_level(new_state.log_level) self._refresh_plugin_list() diff --git a/src/plugin_manager/plugin_base.py b/src/plugin_manager/plugin_base.py index 742fed2..226acc0 100644 --- a/src/plugin_manager/plugin_base.py +++ b/src/plugin_manager/plugin_base.py @@ -12,14 +12,19 @@ from __future__ import annotations from collections import deque +from concurrent.futures import Future import threading from abc import abstractmethod from contextlib import contextmanager from dataclasses import dataclass from enum import Enum -from typing import TYPE_CHECKING, Any, Callable, TypeVar, cast +from typing import TYPE_CHECKING, Any, Callable, ClassVar, TypeVar, cast _E = TypeVar("_E", bound="BaseEvent") +_T = TypeVar("_T") # 用于服务获取方法的泛型 + +if TYPE_CHECKING: + from .config_types import OtherInfoBase if TYPE_CHECKING: from PyQt5.QtGui import QIcon @@ -29,6 +34,8 @@ from lib_zmq_plugins.shared.base import BaseEvent, get_event_tag +from .service_registry import ServiceNotFoundError + if TYPE_CHECKING: from PyQt5.QtWidgets import QWidget from lib_zmq_plugins.client.zmq_client import ZMQClient @@ -96,6 +103,36 @@ def _values(cls) -> list[str]: } +class _ServiceProxy: + """ + 服务代理对象(内部使用) + + 拦截属性访问,将方法调用转换为 call_service 调用。 + 让插件开发者可以直接通过属性访问方式调用服务方法。 + """ + + def __init__(self, plugin: "BasePlugin", protocol: type): + object.__setattr__(self, "_plugin", plugin) + object.__setattr__(self, "_protocol", protocol) + + def __getattr__(self, name: str) -> Any: + # 返回一个可调用对象,调用时转发到 _call_service + def _method_call(*args, timeout: float = 10.0, **kwargs) -> Any: + if kwargs: + # 如果有 kwargs,不支持(服务方法通常只有位置参数) + raise TypeError( + f"Service method call with keyword arguments is not supported. " + f"Use positional arguments: {self._protocol.__name__}.{name}(*args)" + ) + return self._plugin._call_service( + self._protocol, name, *args, timeout=timeout + ) + return _method_call + + def __repr__(self) -> str: + return f"" + + class PluginLifecycle(str, Enum): """插件生命周期状态""" NEW = "NEW" # 刚创建,未初始化 @@ -130,6 +167,7 @@ def _values(cls) -> list[str]: @dataclass class PluginInfo: """插件元信息""" + name: str # 插件名称 version: str = "1.0.0" # 版本号 author: str = "" # 作者 @@ -141,6 +179,8 @@ class PluginInfo: log_level: LogLevel = cast(LogLevel, "DEBUG") # 默认日志级别 icon: QIcon | None = None # 插件图标,None 使用默认蓝色问号 log_config: "LogConfig | None" = None # 日志轮转配置,None 使用全局默认值 + # 插件自定义配置类(继承自 OtherInfoBase) + other_info: type["OtherInfoBase"] | None = None class BasePlugin(QThread): @@ -169,6 +209,7 @@ def plugin_info(cls) -> PluginInfo: # ── GUI 跨线程信号(类级别,所有实例共享连接到各自 slot)── gui_call = pyqtSignal(object, object, object) ready = pyqtSignal(object) # 插件就绪信号(参数:插件实例) + config_changed = pyqtSignal(str, object) # 配置变化信号(参数:字段名, 新值) # 队列最大容量(背压控制) MAX_QUEUE_SIZE = 4096 @@ -215,6 +256,35 @@ def __init__(self, info: PluginInfo): log_config=info.log_config, ) self._log_level: LogLevel = info.log_level + + # 记录本插件注册的服务(用于 shutdown 时自动注销) + self._registered_protocols: list[type] = [] + + # ── 插件自定义配置 ── + self._other_info: OtherInfoBase | None = None + self._config_manager: PluginConfigManager | None = None + if info.other_info is not None: + from .config_manager import PluginConfigManager + from .app_paths import get_plugin_data_dir + + # 实例化配置对象 + self._other_info = info.other_info() + # 设置配置变化回调 + self._other_info.set_on_change(self._on_config_changed) + # 创建配置管理器 + data_dir = get_plugin_data_dir(type(self)) + self._config_manager = PluginConfigManager(data_dir) + # 加载配置 + self._config_manager.load(info.name, self._other_info) + + def _on_config_changed(self, name: str, value: Any) -> None: + """配置变化回调(在配置对象中触发,需转发到主线程发射信号)""" + # 使用 run_on_gui 确保信号在主线程发射 + self.run_on_gui(self._emit_config_changed, name, value) + + def _emit_config_changed(self, name: str, value: Any) -> None: + """在主线程发射 config_changed 信号""" + self.config_changed.emit(name, value) # ═══════════════════════════════════════════════════════════════════ # 属性 @@ -265,6 +335,17 @@ def log_level(self) -> LogLevel: """当前日志级别""" return self._log_level + @property + def other_info(self) -> OtherInfoBase | None: + """插件自定义配置对象""" + return self._other_info + + def save_config(self) -> None: + """保存插件配置到文件""" + if self._config_manager and self._other_info: + self._config_manager.save(self._info.name, self._other_info) + self.logger.debug(f"Config saved: {self._other_info.to_dict()}") + def set_log_level(self, level: LogLevel | str) -> None: """动态设置插件的日志级别""" from .logging_setup import set_plugin_log_level @@ -327,7 +408,10 @@ def _on_gui_call( kwargs: dict, ) -> None: """GUI 主线程执行的槽:接收来自工作线程的回调请求""" - func(*args, **kwargs) + try: + func(*args, **kwargs) + except Exception as e: + self.logger.error(f"GUI callback error: {e}", exc_info=True) # ═══════════════════════════════════════════════════════════════════ # 线程入口(子类不应覆写) @@ -428,7 +512,8 @@ def shutdown(self) -> None: if self._lifecycle == PluginLifecycle.STOPPED: return - self._lifecycle = PluginLifecycle.SHUTTING_DOWN + with self._resource_lock: + self._lifecycle = PluginLifecycle.SHUTTING_DOWN # 通知线程退出 self._stop_requested.set() @@ -439,9 +524,20 @@ def shutdown(self) -> None: if not self.wait(2000): self.logger.warning(f"Plugin thread did not stop in time: {self.name}") self.terminate() # 强制终止 + # 注意:强制终止可能导致未完成的 Future 永久阻塞 + # 调用方应设置超时并处理 TimeoutError 异常 if self._event_dispatcher: self._event_dispatcher.unsubscribe_all(self) + + # 注销本插件注册的所有服务 + for protocol in self._registered_protocols: + try: + self._event_dispatcher.services.unregister(protocol) + self.logger.debug(f"Unregistered service: {protocol.__name__}") + except Exception as e: + self.logger.warning(f"Failed to unregister service {protocol.__name__}: {e}") + self._registered_protocols.clear() if self._widget: self._widget.deleteLater() @@ -451,7 +547,11 @@ def shutdown(self) -> None: with self._queue_lock: self._event_queue.clear() - self._lifecycle = PluginLifecycle.STOPPED + # 保存插件配置 + self.save_config() + + with self._resource_lock: + self._lifecycle = PluginLifecycle.STOPPED # ═══════════════════════════════════════════════════════════════════ # 内部事件投递(由 EventDispatcher 调用) @@ -543,6 +643,270 @@ def request(self, command: Any, timeout: float = 5.0) -> Any: return self._client.request(command, timeout) return None + # ═══════════════════════════════════════════════════════════════════ + # 服务注册(插件间通讯) + # ═══════════════════════════════════════════════════════════════════ + + def register_service( + self, + provider: object, + *, + protocol: type | None = None, + ) -> None: + """ + 注册服务(供其他插件调用) + + Args: + provider: 服务提供者实例(通常是 self) + protocol: 服务接口类型(可选,自动推断) + + Raises: + TypeError: 未实现 Protocol 的所有方法 + + 用法:: + + class MyPlugin(BasePlugin): + def on_initialized(self): + # 显式指定 protocol + self.register_service(self, protocol=MyService) + """ + if self._event_dispatcher is None: + self.logger.warning("Cannot register service: no dispatcher") + return + + # 自动推断 protocol + if protocol is None: + # 从 provider 的基类中找到 Protocol 子类 + for base in type(provider).__mro__: + if ( + base is not object + and hasattr(base, "_is_protocol") + and base._is_protocol + ): + protocol = base + break + + if protocol is None: + self.logger.warning( + "Cannot register service: no protocol found. " + "Specify protocol= explicitly or inherit from a Protocol." + ) + return + + # 验证 provider 实现了 Protocol 的所有方法 + missing_methods = self._check_protocol_implementation(provider, protocol) + if missing_methods: + raise TypeError( + f"Cannot register service: {type(provider).__name__} does not implement " + f"{protocol.__name__}. Missing methods: {', '.join(missing_methods)}" + ) + + # 注册服务 + self._event_dispatcher.services.register(protocol, provider, self.name) + + # 记录已注册的 protocol(用于 shutdown 时自动注销) + if protocol not in self._registered_protocols: + self._registered_protocols.append(protocol) + + def _check_protocol_implementation( + self, + provider: object, + protocol: type, + ) -> list[str]: + """ + 检查 provider 是否实现了 Protocol 的所有方法 + + Returns: + 缺失的方法名列表(空列表表示全部实现) + """ + missing = [] + + # 获取 Protocol 中定义的所有方法(不包括继承自 object 的) + for name in dir(protocol): + if name.startswith('_'): + continue + + attr = getattr(protocol, name, None) + if attr is None: + continue + + # 检查是否是方法(Callable) + if callable(attr) or isinstance(attr, property): + # 检查 provider 是否有该方法 + provider_attr = getattr(type(provider), name, None) + if provider_attr is None: + missing.append(name) + + return missing + + def _get_service(self, protocol: type[_T]) -> _T: + """ + 获取服务实例(内部使用) + + 注意:直接调用服务方法会在调用方线程执行,可能有线程安全问题。 + 推荐使用 get_service_proxy() 获取代理对象。 + """ + if self._event_dispatcher is None: + raise ServiceNotFoundError(protocol) + + return self._event_dispatcher.services.get(protocol) + + def _try_get_service(self, protocol: type[_T]) -> _T | None: + """ + 尝试获取服务实例(内部使用) + + 注意:直接调用服务方法会在调用方线程执行,可能有线程安全问题。 + 推荐使用 get_service_proxy() 获取代理对象。 + """ + if self._event_dispatcher is None: + return None + + return self._event_dispatcher.services.try_get(protocol) + + def has_service(self, protocol: type) -> bool: + """ + 检查服务是否可用 + + Args: + protocol: 服务接口类型 + + Returns: + True 表示服务已注册 + """ + if self._event_dispatcher is None: + return False + + return self._event_dispatcher.services.has(protocol) + + def _call_service( + self, + protocol: type, + method: str, + *args, + timeout: float = 10.0, + ) -> Any: + """ + 调用服务方法(内部实现) + + 由 _ServiceProxy 调用,插件开发者应使用 get_service_proxy()。 + + WARNING - 死锁风险: + 如果两个插件互相调用对方的服务(A 调 B 的同时 B 调 A), + 会产生死锁,因为双方队列都在等待对方响应。 + + 避免方法: + - 使用 call_service_async() 异步调用 + - 设计单向依赖关系,避免循环调用 + - 不要在服务方法实现中调用其他插件的服务 + """ + if self._event_dispatcher is None: + raise ServiceNotFoundError(protocol) + + provider = self._event_dispatcher.services.get(protocol) + + # 创建 Future 用于接收结果 + future: Future[Any] = Future() + + # 定义在服务提供者线程执行的函数 + def _execute_in_provider_thread(_: Any) -> None: + try: + result = getattr(provider, method)(*args) + future.set_result(result) + except Exception as e: + future.set_exception(e) + + # 投递到服务提供者的队列 + success = provider._enqueue_event(_execute_in_provider_thread, None) + if not success: + raise RuntimeError( + f"Failed to enqueue service call: {protocol.__name__}.{method} " + "(provider queue full)" + ) + + # 等待结果 + return future.result(timeout=timeout) + + def call_service_async( + self, + protocol: type, + method: str, + *args, + ) -> Future[Any]: + """ + 异步调用服务方法(非阻塞,返回 Future) + + Args: + protocol: 服务接口类型 + method: 方法名 + *args: 方法参数 + + Returns: + Future 对象,可调用 result() 获取结果 + + 用法:: + + future = self.call_service_async(MyService, "some_method", arg1) + # 做其他事情... + result = future.result(timeout=5.0) # 阻塞等待结果 + """ + if self._event_dispatcher is None: + future: Future[Any] = Future() + future.set_exception(ServiceNotFoundError(protocol)) + return future + + try: + provider = self._event_dispatcher.services.get(protocol) + except ServiceNotFoundError as e: + future = Future() + future.set_exception(e) + return future + + future: Future[Any] = Future() + + def _execute_in_provider_thread(_: Any) -> None: + try: + result = getattr(provider, method)(*args) + future.set_result(result) + except Exception as e: + future.set_exception(e) + + success = provider._enqueue_event(_execute_in_provider_thread, None) + if not success: + future.set_exception(RuntimeError( + f"Failed to enqueue service call: {protocol.__name__}.{method} " + "(provider queue full)" + )) + + return future + + def get_service_proxy(self, protocol: type[_T]) -> _T: + """ + 获取服务代理对象(类型安全,IDE 友好) + + 返回一个代理对象,通过属性访问方式调用服务方法。 + 所有方法调用都会在服务提供者线程执行,线程安全。 + + Args: + protocol: 服务接口类型 + + Returns: + 服务代理对象(IDE 可推断类型,支持方法补全) + + 用法:: + + # 获取代理对象 + service = self.get_service_proxy(MyService) + + # 直接调用方法(IDE 完整补全) + result = service.some_method(arg1, arg2) + count = service.get_count() + + # 以上调用等同于: + # result = self.call_service(MyService, "some_method", arg1, arg2) + # count = self.call_service(MyService, "get_count") + """ + return _ServiceProxy(self, protocol) # type: ignore[return-value] + # ═══════════════════════════════════════════════════════════════════ # 辅助 # ═══════════════════════════════════════════════════════════════════ diff --git a/src/plugin_manager/plugin_state.py b/src/plugin_manager/plugin_state.py index 498bd2a..a748275 100644 --- a/src/plugin_manager/plugin_state.py +++ b/src/plugin_manager/plugin_state.py @@ -86,14 +86,13 @@ def get_effective(self, name: str, plugin_default: PluginState | None = None) -> plugin_default: 插件自身声明的默认值(来自 PluginInfo),为 None 时使用系统默认 """ if name in self._states: - # JSON 中有记录 → 以 JSON 为准,缺失字段回退到插件/系统默认 + # JSON 中有记录 → 直接使用 JSON 的值(不再与默认值比较) saved = self._states[name] - fallback = plugin_default or _DEFAULT return PluginState( - enabled=saved.enabled if saved.enabled != _DEFAULT.enabled else fallback.enabled, - show_window=saved.show_window if saved.show_window != _DEFAULT.show_window else fallback.show_window, - window_mode=saved.window_mode if saved.window_mode != _DEFAULT.window_mode else fallback.window_mode, - log_level=saved.log_level if saved.log_level != _DEFAULT.log_level else fallback.log_level, + enabled=saved.enabled, + show_window=saved.show_window, + window_mode=saved.window_mode, + log_level=saved.log_level, ) # 无 JSON 记录 → 使用插件声明或系统默认 return (plugin_default or _DEFAULT) diff --git a/src/plugin_manager/service_registry.py b/src/plugin_manager/service_registry.py new file mode 100644 index 0000000..f9ddb70 --- /dev/null +++ b/src/plugin_manager/service_registry.py @@ -0,0 +1,217 @@ +""" +插件间服务通讯机制 + +提供类型安全的服务注册和获取,无需字符串反射,IDE 可完整推断类型。 + +使用方式: + +1. 定义服务接口 (在 shared_types/services_xxx.py): + @runtime_checkable + class HistoryService(Protocol): + def query_records(self, limit: int) -> list[GameRecord]: ... + +2. 服务提供者: + class HistoryPlugin(BasePlugin, HistoryService): + def query_records(self, limit: int) -> list[GameRecord]: + return self._db.query(limit) + + def on_initialized(self): + self.register_service(self) # 注册 + +3. 服务使用者: + class StatsPlugin(BasePlugin): + def _update(self): + history = self.get_service(HistoryService) + records = history.query_records(100) # IDE 完整补全 +""" +from __future__ import annotations + +import threading +from typing import TypeVar, runtime_checkable, Protocol +from dataclasses import dataclass + +import loguru + +logger = loguru.logger.bind(name="ServiceRegistry") + +_T = TypeVar("_T") + + +class ServiceNotFoundError(Exception): + """服务未找到异常""" + def __init__(self, protocol: type): + self.protocol = protocol + super().__init__(f"Service not found: {protocol.__name__}") + + +class ServiceAlreadyRegisteredError(Exception): + """服务已注册异常""" + def __init__(self, protocol: type): + self.protocol = protocol + super().__init__(f"Service already registered: {protocol.__name__}") + + +@dataclass +class ServiceEntry: + """服务注册条目""" + protocol: type # Protocol 类型 + provider: object # 服务提供者实例 + plugin_name: str # 提供者插件名 + + +class ServiceRegistry: + """ + 服务注册表(线程安全) + + 管理插件间服务的注册和获取,支持: + - 类型安全:通过 Protocol 类型获取服务 + - IDE 友好:返回类型可推断 + - 线程安全:使用 RLock 保护 + + Usage:: + + registry = ServiceRegistry() + + # 注册服务 + registry.register(HistoryService, history_plugin, "history") + + # 获取服务(类型安全) + history = registry.get(HistoryService) + records = history.query_records(100) # IDE 完整补全 + """ + + def __init__(self): + self._providers: dict[type, ServiceEntry] = {} + self._lock = threading.RLock() + + def register( + self, + protocol: type[_T], + provider: _T, + plugin_name: str = "", + ) -> None: + """ + 注册服务 + + Args: + protocol: 服务接口类型(Protocol) + provider: 服务提供者实例 + plugin_name: 提供者插件名(用于日志) + + Raises: + ServiceAlreadyRegisteredError: 服务已注册 + """ + with self._lock: + if protocol in self._providers: + raise ServiceAlreadyRegisteredError(protocol) + + self._providers[protocol] = ServiceEntry( + protocol=protocol, + provider=provider, + plugin_name=plugin_name, + ) + logger.debug( + f"Service registered: {protocol.__name__} " + f"(provider: {plugin_name or 'unknown'})" + ) + + def unregister(self, protocol: type) -> bool: + """ + 注销服务 + + Args: + protocol: 服务接口类型 + + Returns: + True 表示成功注销,False 表示服务不存在 + """ + with self._lock: + if protocol in self._providers: + entry = self._providers.pop(protocol) + logger.debug( + f"Service unregistered: {protocol.__name__} " + f"(provider: {entry.plugin_name})" + ) + return True + return False + + def get(self, protocol: type[_T]) -> _T: + """ + 获取服务实例(类型安全) + + Args: + protocol: 服务接口类型 + + Returns: + 服务提供者实例(IDE 可推断类型) + + Raises: + ServiceNotFoundError: 服务未注册 + + WARNING - 生命周期风险: + 返回的服务实例引用在锁释放后可能被其他线程注销。 + 调用方应确保在使用期间服务不会被注销, + 或使用 BasePlugin.get_service_proxy() 获取代理对象。 + + Usage:: + + history = registry.get(HistoryService) + records = history.query_records(100) # IDE 完整补全 + """ + with self._lock: + entry = self._providers.get(protocol) + if entry is None: + raise ServiceNotFoundError(protocol) + return entry.provider # type: ignore[return-value] + + def try_get(self, protocol: type[_T]) -> _T | None: + """ + 尝试获取服务实例(不抛异常) + + Args: + protocol: 服务接口类型 + + Returns: + 服务实例或 None + """ + with self._lock: + entry = self._providers.get(protocol) + if entry is None: + return None + return entry.provider # type: ignore[return-value] + + def has(self, protocol: type) -> bool: + """ + 检查服务是否已注册 + + Args: + protocol: 服务接口类型 + + Returns: + True 表示已注册 + """ + with self._lock: + return protocol in self._providers + + def list_services(self) -> list[tuple[type, str]]: + """ + 列出所有已注册的服务 + + Returns: + [(protocol, plugin_name), ...] + """ + with self._lock: + return [ + (entry.protocol, entry.plugin_name) + for entry in self._providers.values() + ] + + def clear(self) -> None: + """清除所有服务注册""" + with self._lock: + self._providers.clear() + logger.debug("All services unregistered") + + def __repr__(self) -> str: + with self._lock: + return f"" diff --git a/src/plugins/hello_world.py b/src/plugins/hello_world.py index 7901be4..0ff7cea 100644 --- a/src/plugins/hello_world.py +++ b/src/plugins/hello_world.py @@ -5,13 +5,275 @@ """ from __future__ import annotations -from PyQt5.QtWidgets import QWidget, QVBoxLayout, QLabel, QTextEdit -from PyQt5.QtCore import pyqtSignal +from typing import Any + +from PyQt5.QtWidgets import QWidget, QVBoxLayout, QLabel, QTextEdit, QDial +from PyQt5.QtCore import pyqtSignal, Qt from plugin_manager import BasePlugin, PluginInfo, make_plugin_icon, WindowMode +from plugin_manager.config_types import ( + OtherInfoBase, + BoolConfig, + IntConfig, + FloatConfig, + ChoiceConfig, + TextConfig, + ColorConfig, + FileConfig, + PathConfig, + LongTextConfig, + RangeConfig, + BaseConfig, +) from shared_types.events import VideoSaveEvent +# ═══════════════════════════════════════════════════════════════════ +# 自定义配置类型示例 +# ═══════════════════════════════════════════════════════════════════ + + +class DialConfig(BaseConfig[int]): + """ + 自定义配置类型: 旋钮控件 → QDial + + 演示如何继承 BaseConfig 创建自定义配置类型 + """ + widget_type = "dial" + + def __init__( + self, + default: int = 50, + label: str = "", + min_value: int = 0, + max_value: int = 100, + notch_step: int = 10, + **kwargs, + ): + super().__init__(default, label, **kwargs) + self.min_value = min_value + self.max_value = max_value + self.notch_step = notch_step + + def create_widget(self): + """创建 QDial 控件,返回 (控件, getter, setter, 信号)""" + from PyQt5.QtCore import QObject + + widget = QDial() + widget.setRange(self.min_value, self.max_value) + widget.setValue(int(self.default)) + widget.setNotchesVisible(True) + widget.setSingleStep(self.notch_step) + widget.setPageStep(self.notch_step * 2) + widget.setMinimumSize(60, 60) + widget.setMaximumSize(100, 100) + + if self.description: + widget.setToolTip(self.description) + + # 返回控件、getter、setter、以及 valueChanged 信号 + return widget, widget.value, widget.setValue, widget.valueChanged + + def to_storage(self, value: int) -> int: + return int(value) + + def from_storage(self, data: Any) -> int: + try: + return int(data) + except (ValueError, TypeError): + return int(self.default) + + +# ═══════════════════════════════════════════════════════════════════ +# 插件配置 - 包含所有配置类型示例 +# ═══════════════════════════════════════════════════════════════════ + + +class HelloPluginConfig(OtherInfoBase): + """ + Hello World 插件配置 + + 包含所有支持的配置类型示例 + """ + + # ── BoolConfig: 布尔值 → QCheckBox ──────────────── + enable_auto_log = BoolConfig( + default=True, + label="自动记录日志", + description="收到事件时自动记录到日志", + ) + show_timestamp = BoolConfig( + default=True, + label="显示时间戳", + description="在日志中显示时间戳", + ) + + # ── IntConfig: 整数 → QSpinBox ──────────────────── + max_log_lines = IntConfig( + default=100, + label="最大日志行数", + description="保留的最大日志行数", + min_value=10, + max_value=1000, + step=10, + ) + refresh_interval = IntConfig( + default=5, + label="刷新间隔(秒)", + description="自动刷新的间隔时间", + min_value=1, + max_value=60, + ) + + # ── FloatConfig: 浮点数 → QDoubleSpinBox ────────── + min_rtime_filter = FloatConfig( + default=0.0, + label="最小时间筛选(秒)", + description="只记录大于此时间的游戏", + min_value=0.0, + max_value=999.0, + decimals=2, + ) + zoom_factor = FloatConfig( + default=1.0, + label="缩放因子", + description="UI 缩放比例", + min_value=0.5, + max_value=2.0, + step=0.1, + decimals=1, + ) + + # ── ChoiceConfig: 选择 → QComboBox ──────────────── + log_level = ChoiceConfig( + default="INFO", + label="日志级别", + description="日志显示级别", + choices=[ + ("DEBUG", "DEBUG"), + ("INFO", "INFO"), + ("WARNING", "WARNING"), + ("ERROR", "ERROR"), + ], + ) + display_mode = ChoiceConfig( + default="compact", + label="显示模式", + choices=[ + ("compact", "紧凑"), + ("detailed", "详细"), + ("minimal", "极简"), + ], + ) + + # ── TextConfig: 文本 → QLineEdit ────────────────── + player_name = TextConfig( + default="", + label="玩家名称", + placeholder="输入玩家名称...", + ) + api_token = TextConfig( + default="", + label="API Token", + description="用于远程同步的认证令牌", + password=True, + placeholder="输入密钥...", + ) + + # ── ColorConfig: 颜色 → 颜色选择按钮 ────────────── + theme_color = ColorConfig( + default="#4CAF50", + label="主题颜色", + description="插件的主题颜色", + ) + highlight_color = ColorConfig( + default="#FF5722", + label="高亮颜色", + description="重要信息的高亮颜色", + ) + + # ── FileConfig: 文件 → 文件选择器 ──────────────── + export_file = FileConfig( + default="", + label="导出文件", + description="日志导出文件路径", + filter="Text Files (*.txt);;JSON Files (*.json)", + save_mode=True, + ) + import_file = FileConfig( + default="", + label="导入文件", + description="导入配置文件", + filter="JSON Files (*.json)", + ) + + # ── PathConfig: 目录 → 目录选择器 ──────────────── + log_directory = PathConfig( + default="", + label="日志目录", + description="日志文件保存目录", + ) + cache_directory = PathConfig( + default="", + label="缓存目录", + description="临时缓存文件目录", + ) + + # ── LongTextConfig: 多行文本 → QTextEdit ──────── + welcome_message = LongTextConfig( + default="欢迎使用 Hello World 插件!", + label="欢迎消息", + placeholder="输入欢迎消息...", + max_height=80, + ) + custom_script = LongTextConfig( + default="", + label="自定义脚本", + description="自定义处理脚本(Python 代码)", + placeholder="# 在此输入 Python 代码...", + max_height=120, + ) + + # ── RangeConfig: 数值范围 → 两个 QSpinBox ────── + rtime_range = RangeConfig( + default=(0, 300), + label="时间范围(秒)", + description="只记录此时间范围内的游戏", + min_value=0, + max_value=999, + ) + bbbv_range = RangeConfig( + default=(0, 999), + label="3BV 范围", + description="只记录此 3BV 范围内的游戏", + min_value=0, + max_value=9999, + ) + + # ── 自定义配置类型: DialConfig → QDial ───────────── + volume = DialConfig( + default=50, + label="音量", + description="自定义旋钮控件示例", + min_value=0, + max_value=100, + notch_step=10, + ) + sensitivity = DialConfig( + default=5, + label="灵敏度", + description="响应灵敏度设置", + min_value=1, + max_value=10, + notch_step=1, + ) + + +# ═══════════════════════════════════════════════════════════════════ +# UI 组件 +# ═══════════════════════════════════════════════════════════════════ + + class HelloWidget(QWidget): """简单的 UI 界面""" @@ -43,6 +305,11 @@ def _append_log(self, text: str): self._info.setText(f"Received {self._count} record(s)") +# ═══════════════════════════════════════════════════════════════════ +# 插件主体 +# ═══════════════════════════════════════════════════════════════════ + + class HelloPlugin(BasePlugin): @classmethod @@ -54,6 +321,7 @@ def plugin_info(cls) -> PluginInfo: description="Hello World - demonstrates event subscription and pyqtSignal GUI update", icon=make_plugin_icon("#4CAF50", "H", 64), window_mode=WindowMode.TAB, + other_info=HelloPluginConfig, ) def _setup_subscriptions(self) -> None: @@ -65,6 +333,14 @@ def _create_widget(self) -> QWidget: def on_initialized(self) -> None: self.logger.info("HelloPlugin initialized") + if self.other_info: + self.logger.info(f"配置: {self.other_info.to_dict()}") + # 连接配置变化信号 + self.config_changed.connect(self._on_config_changed) + + def _on_config_changed(self, name: str, value) -> None: + """配置变化时的回调""" + self.logger.info(f"配置变化: {name} = {value}") def on_shutdown(self) -> None: self.logger.info("HelloPlugin shutting down") @@ -79,4 +355,4 @@ def _on_video_save(self, event: VideoSaveEvent): f"3BV={event.bbbv} | L={event.left} R={event.right}" ) # pyqtSignal emit -> auto QueuedConnection cross-thread to main thread - self._widget._update_signal.emit(info_text) + self._widget._update_signal.emit(info_text) \ No newline at end of file diff --git a/src/plugins/history.py b/src/plugins/history.py index 2173ef0..6251ae4 100644 --- a/src/plugins/history.py +++ b/src/plugins/history.py @@ -26,6 +26,7 @@ from plugin_manager import BasePlugin, PluginInfo, make_plugin_icon, WindowMode from plugin_manager.app_paths import get_executable_dir from shared_types.events import VideoSaveEvent +from shared_types.services.history import HistoryService, GameRecord from PyQt5.QtCore import ( QObject, @@ -344,7 +345,8 @@ def show_context_menu(self, pos): 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()) + _translate("Form", "插入"), lambda: self.insert_row( + self.table.currentRow()) ) menu.exec_(self.table.mapToGlobal(pos)) @@ -385,7 +387,8 @@ def on_field_changed(self, index): compare_w: QComboBox = self.table.cellWidget(row, 2) compare = CompareSymbol.from_display_name(compare_w.currentText()) field_cls = HistoryData.get_field_value(field_name) - self.table.setCellWidget(row, 3, self._build_value_widget(compare, field_cls)) + self.table.setCellWidget( + row, 3, self._build_value_widget(compare, field_cls)) def on_compare_changed(self, index): combo: QComboBox = self.sender() @@ -397,7 +400,8 @@ def on_compare_changed(self, index): field_name = field_w.currentText() compare = CompareSymbol.from_display_name(combo.currentText()) field_cls = HistoryData.get_field_value(field_name) - self.table.setCellWidget(row, 3, self._build_value_widget(compare, field_cls)) + self.table.setCellWidget( + row, 3, self._build_value_widget(compare, field_cls)) def _build_value_widget(self, compare: CompareSymbol, field_value: Any): if compare not in (CompareSymbol.Contains, CompareSymbol.NotContains): @@ -426,7 +430,8 @@ def insert_row(self, row: int): self.table.setCellWidget(row, 0, self._build_left_bracket()) self.table.setCellWidget(row, 1, field_w) self.table.setCellWidget(row, 2, compare_w) - self.table.setCellWidget(row, 3, self._build_value_widget(compare, field_value)) + self.table.setCellWidget( + row, 3, self._build_value_widget(compare, field_value)) self.table.setCellWidget(row, 4, self._build_right_bracket()) self.table.setCellWidget(row, 5, self._build_logic()) @@ -468,7 +473,8 @@ def gen_filter_str(self): if isinstance(value_w, QComboBox): value = value_w.currentText() elif isinstance(value_w, QDateTimeEdit): - value = int(value_w.dateTime().toPyDateTime().timestamp() * 1_000_000) + value = int( + value_w.dateTime().toPyDateTime().timestamp() * 1_000_000) elif isinstance(value_w, QSpinBox): value = str(value_w.value()) elif isinstance(value_w, QDoubleSpinBox): @@ -496,14 +502,16 @@ def gen_filter_str(self): return None values = [ int( - datetime.strptime(v, "%Y-%m-%d %H:%M:%S").timestamp() + datetime.strptime( + v, "%Y-%m-%d %H:%M:%S").timestamp() * 1_000_000 ) for v in values ] value = ",".join(str(v) for v in values) else: - value = ",".join(f"'{v}'" for v in value_w.text().split(",")) + value = ",".join( + f"'{v}'" for v in value_w.text().split(",")) value = f"({value})" else: value = f"'{value_w.text()}'" @@ -611,7 +619,8 @@ def show_context_menu(self, pos): action = QAction(field, self) action.setCheckable(True) action.setChecked(field in self.showFields) - action.triggered.connect(lambda checked, a=action: self._on_toggle_field(a)) + action.triggered.connect( + lambda checked, a=action: self._on_toggle_field(a)) submenu.addAction(action) menu.addMenu(submenu) menu.exec_(self.table.mapToGlobal(pos)) @@ -640,7 +649,8 @@ def _read_raw_data(self, replay_id: int) -> bytes | None: try: cursor = conn.cursor() cursor.execute( - "SELECT raw_data FROM history WHERE replay_id = ?", (replay_id,) + "SELECT raw_data FROM history WHERE replay_id = ?", ( + replay_id,) ) row = cursor.fetchone() return row[0] if row else None @@ -666,7 +676,8 @@ def play_row(self): main_py = exec_dir / "main.py" if main_py.exists(): - subprocess.Popen([sys.executable, str(main_py), str(temp_filename)]) + subprocess.Popen( + [sys.executable, str(main_py), str(temp_filename)]) elif exe.exists(): subprocess.Popen([str(exe), str(temp_filename)]) else: @@ -691,7 +702,12 @@ def export_row(self): class HistoryMainWidget(QWidget): """历史记录插件的主界面(作为插件的 widget 返回)""" - def __init__(self, db_path: Path, config_path: Path, parent=None): + def __init__( + self, + db_path: Path, + config_path: Path, + parent=None, + ): super().__init__(parent) self._db_path = db_path self._config_path = config_path @@ -721,7 +737,9 @@ def __init__(self, db_path: Path, config_path: Path, parent=None): 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.one_page_combo.addItems( + ["10", "20", "50", "100", "200", "500", "1000"]) + self.limit_label = QLabel("") limit_layout.addItem( QSpacerItem(10, 10, QSizePolicy.Expanding, QSizePolicy.Minimum) @@ -824,8 +842,10 @@ class HistoryPlugin(BasePlugin): - 后台:监听 VideoSaveEvent,写入 SQLite - 界面:提供筛选、分页、播放/导出功能 + - 服务:提供 HistoryService 接口供其他插件查询历史记录 """ video_save_over = pyqtSignal() + @classmethod def plugin_info(cls) -> PluginInfo: return PluginInfo( @@ -834,7 +854,7 @@ def plugin_info(cls) -> PluginInfo: author="ljzloser", version="1.0.0", icon=make_plugin_icon("#7b1fa2", "\N{SCROLL}"), - window_mode=WindowMode.DETACHED, + window_mode=WindowMode.TAB, ) def __init__(self, info): @@ -852,8 +872,8 @@ def _create_widget(self) -> QWidget: def on_initialized(self) -> None: self._init_db() - time.sleep(10) - self.logger.info("历史记录插件已初始化") + self.register_service(self, protocol=HistoryService) + self.logger.info("历史记录插件已初始化,HistoryService 已注册") # ── 数据库 ────────────────────────────────────────────── @@ -942,3 +962,104 @@ def _on_video_save(self, event: VideoSaveEvent) -> None: finally: conn.close() self.video_save_over.emit() + + # ═══════════════════════════════════════════════════════════════════ + # HistoryService 接口实现 + # ═══════════════════════════════════════════════════════════════════ + + def query_records( + self, + limit: int = 100, + offset: int = 0, + level: int | None = None, + ) -> list[GameRecord]: + """查询游戏记录""" + db_path = self.data_dir / "history.db" + conn = sqlite3.connect(db_path) + conn.row_factory = sqlite3.Row + cursor = conn.cursor() + + try: + if level is not None: + cursor.execute( + """ + SELECT * FROM history + WHERE level = ? + ORDER BY replay_id DESC + LIMIT ? OFFSET ? + """, + (level, limit, offset), + ) + else: + cursor.execute( + """ + SELECT * FROM history + ORDER BY replay_id DESC + LIMIT ? OFFSET ? + """, + (limit, offset), + ) + rows = cursor.fetchall() + return [GameRecord( + replay_id=row["replay_id"], + rtime=row["rtime"], + level=row["level"], + bbbv=row["bbbv"], + bbbv_solved=row["bbbv_solved"], + left=row["left"], + right=row["right"], + double=row["double"], + cl=row["cl"], + ce=row["ce"], + flag=row["flag"], + game_board_state=row["game_board_state"], + mode=row["mode"], + software=row["software"] or "", + start_time=row["start_time"], + end_time=row["end_time"], + ) for row in rows] + finally: + conn.close() + + def get_record_count(self, level: int | None = None) -> int: + """获取记录总数""" + db_path = self.data_dir / "history.db" + conn = sqlite3.connect(db_path) + cursor = conn.cursor() + + try: + if level is not None: + cursor.execute( + "SELECT COUNT(*) FROM history WHERE level = ?", (level,) + ) + else: + cursor.execute("SELECT COUNT(*) FROM history") + return cursor.fetchone()[0] + finally: + conn.close() + + def get_last_record(self) -> GameRecord | None: + """获取最近一条记录""" + records = self.query_records(limit=1) + return records[0] if records else None + + def delete_record(self, record_id: int) -> bool: + """删除指定记录""" + db_path = self.data_dir / "history.db" + conn = sqlite3.connect(db_path) + cursor = conn.cursor() + + try: + cursor.execute( + "DELETE FROM history WHERE replay_id = ?", (record_id,) + ) + conn.commit() + deleted = cursor.rowcount > 0 + if deleted: + self.logger.info(f"Deleted record: {record_id}") + return deleted + finally: + conn.close() + + def _on_config_changed(self, name: str, value: Any) -> None: + return super()._on_config_changed(name, value) diff --git a/src/plugins/stats_panel.py b/src/plugins/stats_panel.py index bb0aa7b..5404313 100644 --- a/src/plugins/stats_panel.py +++ b/src/plugins/stats_panel.py @@ -1,11 +1,11 @@ """ 实时游戏统计面板 -展示计数器、表格等常见 UI 元素的用法。 +通过 HistoryService 获取历史记录进行统计分析。 +收到 VideoSaveEvent 时触发刷新,不直接使用 event 数据。 """ from __future__ import annotations -import json from collections import defaultdict from PyQt5.QtWidgets import ( @@ -16,22 +16,22 @@ from plugin_manager import BasePlugin, PluginInfo, make_plugin_icon, WindowMode from shared_types.events import VideoSaveEvent +from shared_types.services.history import HistoryService, GameRecord class StatsPanel(QWidget): """统计面板 UI""" - _signal_update_stats = pyqtSignal(dict) - _signal_add_record = pyqtSignal(dict) + _signal_refresh = pyqtSignal() def __init__(self, parent=None): super().__init__(parent) self._total_games = 0 + self._best_time = float('inf') self._stats_by_level = defaultdict(lambda: {"count": 0, "best_time": float('inf')}) self._setup_ui() - self._signal_update_stats.connect(self._do_update_stats) - self._signal_add_record.connect(self._do_add_record) + self._signal_refresh.connect(self._do_refresh) def _setup_ui(self): main_layout = QVBoxLayout(self) @@ -75,31 +75,42 @@ def _make_stat_card(title: str, value: str, color: str) -> QWidget: layout.addWidget(lbl_value) return card - def _do_update_stats(self, data: dict): - level = data.get("level", "?") - rtime = data.get("rtime", 0) - - self._total_games += 1 + def _do_refresh(self): + """刷新显示(由插件调用)""" + # 更新总数显示 self._lbl_total.findChild(QLabel).setText(str(self._total_games)) - stats = self._stats_by_level[level] - stats["count"] += 1 - if rtime > 0 and rtime < stats["best_time"]: - stats["best_time"] = rtime - self._lbl_best.findChild(QLabel).setText(f"{rtime:.2f}") + # 更新最佳时间 + if self._best_time < float('inf'): + self._lbl_best.findChild(QLabel).setText(f"{self._best_time:.2f}") + + def clear_table(self): + """清空表格""" + self._table.setRowCount(0) - def _do_add_record(self, data: dict): + def add_record(self, level: int, rtime: float, bbbv: int, left: int, right: int): + """添加一条记录到表格""" row = self._table.rowCount() self._table.insertRow(row) - self._table.setItem(row, 0, QTableWidgetItem(str(data.get("level", "?")))) - self._table.setItem(row, 1, QTableWidgetItem(f"{data.get('rtime', 0):.2f}")) - self._table.setItem(row, 2, QTableWidgetItem(str(data.get("bbbv", 0)))) - ops = int(data.get("left", 0)) + int(data.get("right", 0)) + self._table.setItem(row, 0, QTableWidgetItem(str(level))) + self._table.setItem(row, 1, QTableWidgetItem(f"{rtime:.2f}")) + self._table.setItem(row, 2, QTableWidgetItem(str(bbbv))) + ops = left + right self._table.setItem(row, 3, QTableWidgetItem(str(ops))) + def update_stats(self, total: int, best_time: float): + """更新统计数据""" + self._total_games = total + self._best_time = best_time + class StatsPlugin(BasePlugin): - """实时游戏统计插件""" + """实时游戏统计插件 + + 数据来源:仅依赖 HistoryService + - 初始化时加载历史统计 + - 收到 VideoSaveEvent 时触发刷新(重新查询历史) + """ @classmethod def plugin_info(cls) -> PluginInfo: @@ -107,7 +118,7 @@ def plugin_info(cls) -> PluginInfo: name="stats_panel", version="1.0.0", author="Example", - description="Real-time game statistics panel with table and counters", + description="Real-time game statistics panel (via HistoryService)", icon=make_plugin_icon("#E91E63", "S", 64), window_mode=WindowMode.TAB, ) @@ -120,26 +131,60 @@ def _create_widget(self) -> QWidget: return self._panel def on_initialized(self) -> None: - saved = self.data_dir / "saved_stats.json" - if saved.exists(): - try: - data = json.loads(saved.read_text(encoding='utf-8')) - self.logger.info(f"Restored {len(data)} records from disk") - except Exception as e: - self.logger.warning(f"Failed to load saved stats: {e}") + # 检查 HistoryService 是否可用 + if self.has_service(HistoryService): + self.logger.info("HistoryService 已连接") + self._load_history_stats() + else: + self.logger.warning("HistoryService 不可用,统计面板将无法工作") + + def _load_history_stats(self) -> None: + """从 HistoryService 加载历史统计(在服务提供者线程执行)""" + try: + # 获取服务代理对象(IDE 友好) + history = self.get_service_proxy(HistoryService) + + # 直接调用方法(IDE 完整补全) + total = history.get_record_count() + self.logger.info(f"历史记录总数: {total}") + + # 清空表格 + self._panel.clear_table() + + # 获取最近记录 + records = history.query_records(100, 0, None) + + # 计算最佳时间 + best_time = float('inf') + for r in records: + if r.rtime > 0 and r.rtime < best_time: + best_time = r.rtime + + # 更新统计 + self._panel.update_stats(total, best_time) + + # 添加最近记录到表格(显示最近 20 条) + for r in records[:20]: + self._panel.add_record( + level=r.level, + rtime=r.rtime, + bbbv=r.bbbv, + left=r.left, + right=r.right, + ) + + # 触发 UI 刷新 + self._panel._signal_refresh.emit() + + except Exception as e: + self.logger.warning(f"加载历史统计失败: {e}") def on_shutdown(self) -> None: self.logger.info("StatsPlugin shutting down") def _on_video_save(self, event: VideoSaveEvent): - self.logger.info(f"[{event.level}] {event.rtime:.2f}s | 3BV={event.bbbv}") - - event_dict = { - "level": event.level, - "rtime": event.rtime, - "bbbv": event.bbbv, - "left": event.left, - "right": event.right, - } - self._panel._signal_update_stats.emit(event_dict) - self._panel._signal_add_record.emit(event_dict) + """收到录像保存事件,触发重新加载历史统计""" + self.logger.info(f"Video saved, refreshing stats...") + + # 不直接使用 event 数据,而是重新查询 HistoryService + self._load_history_stats() diff --git a/src/shared_types/services/history.py b/src/shared_types/services/history.py new file mode 100644 index 0000000..7aca270 --- /dev/null +++ b/src/shared_types/services/history.py @@ -0,0 +1,96 @@ +""" +历史记录服务接口 + +定义历史记录查询的标准接口,供插件间通讯使用。 + +Usage:: + + # 服务提供者 (HistoryPlugin) + class HistoryPlugin(BasePlugin): + def query_records(self, limit: int = 100, ...) -> list[GameRecord]: + return [GameRecord(...) for row in rows] + + def on_initialized(self): + self.register_service(self, protocol=HistoryService) + + # 服务使用者 + class StatsPlugin(BasePlugin): + def on_initialized(self): + self._history = self.get_service(HistoryService) + + def _update(self): + records = self._history.query_records(limit=100) + for r in records: + print(r.rtime, r.level, r.bbbv) # IDE 完整补全 +""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import Protocol, runtime_checkable + + +@dataclass(frozen=True, slots=True) +class GameRecord: + """ + 游戏记录数据类型 + + 使用 frozen=True 保证不可变(线程安全) + 使用 slots=True 减少内存占用 + """ + replay_id: int + rtime: float + level: int + bbbv: int + bbbv_solved: int = 0 + left: int = 0 + right: int = 0 + double: int = 0 + cl: int = 0 + ce: int = 0 + flag: int = 0 + game_board_state: int = 0 + mode: int = 0 + software: str = "" + start_time: int = 0 + end_time: int = 0 + + +@runtime_checkable +class HistoryService(Protocol): + """ + 历史记录服务接口(只读) + + 提供游戏历史记录的查询功能。 + 实现此接口的插件可被其他插件通过 call_service() 调用。 + + 注意:此接口仅提供只读操作,删除等敏感操作不对外暴露。 + + 类型安全:IDE 可完整推断返回类型和方法签名。 + """ + + def query_records( + self, + limit: int = 100, + offset: int = 0, + level: int | None = None, + ) -> list[GameRecord]: + """ + 查询游戏记录 + + Args: + limit: 返回记录数量上限 + offset: 偏移量(用于分页) + level: 游戏难度筛选(None 表示全部) + + Returns: + GameRecord 列表 + """ + ... + + def get_record_count(self, level: int | None = None) -> int: + """获取记录总数""" + ... + + def get_last_record(self) -> GameRecord | None: + """获取最近一条记录""" + ...