Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
478 changes: 466 additions & 12 deletions plugin-dev-tutorial.md

Large diffs are not rendered by default.

5 changes: 3 additions & 2 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,6 @@ ms-toollib==1.5.3
setuptools==80.9.0
msgspec>=0.20.0
zmq>=0.0.0
pywin32
loguru
pywin32>=311
loguru>=0.7.3
debugpy>=1.8.20
35 changes: 35 additions & 0 deletions src/plugin_manager/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
- 事件分发机制
- 动态加载插件
- 独立的主界面窗口
- 插件自定义配置系统
"""

from .plugin_base import BasePlugin, PluginInfo, make_plugin_icon, WindowMode, LogLevel
Expand All @@ -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",
]
140 changes: 140 additions & 0 deletions src/plugin_manager/config_manager.py
Original file line number Diff line number Diff line change
@@ -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()
51 changes: 51 additions & 0 deletions src/plugin_manager/config_types/__init__.py
Original file line number Diff line number Diff line change
@@ -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",
]
101 changes: 101 additions & 0 deletions src/plugin_manager/config_types/base_config.py
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading