a13rollcall/插件开发完全指南.md
2026-10-06 18:38:55 +08:00

69 KiB

A13课堂点名系统 - 插件开发完全指南

📋 目录

  1. 插件系统架构
  2. 快速开始
  3. 插件文件格式
  4. 插件API完整参考
  5. 常量与配置框架
  6. 事件系统
  7. 开发复杂抽取动画插件
  8. 执行外部程序与文件操作
  9. 网络广播与通信
  10. 自定义UI组件
  11. UI注入:在主程序任何位置添加按钮 ⭐
  12. 插件图标资源:使用和打包图标 ⭐ 新增
  13. 插件权限系统
  14. 调试与日志
  15. 最佳实践
  16. 常见问题

🏗️ 插件系统架构

核心概念

A13的插件系统基于 .arcx 文件格式,本质上是一个JSON文件,包含:

  • meta:插件元数据(名称、版本、作者、页面配置等)
  • code:Python代码字符串,运行时通过 exec() 执行
  • config:插件默认配置

执行环境

插件代码在主程序的Python进程中执行,拥有完整的Python能力:

  • ✅ 可以导入任何已安装的Python库
  • ✅ 可以执行文件操作
  • ✅ 可以启动外部进程
  • ✅ 可以创建网络连接
  • ✅ 可以创建自定义UI
  • ✅ 可以修改主程序的行为
  • ⚠️ 注意:插件崩溃可能影响主程序,建议做好异常处理

插件生命周期

插件加载
  ↓
main(context) 入口函数
  ↓
注册事件处理器 / 构建设置页面
  ↓
on_enable(context) 启用回调
  ↓
运行期间响应事件
  ↓
on_disable(context) 禁用回调
  ↓
插件卸载

🚀 快速开始

最小插件示例

def main(context):
    """插件入口函数,必需"""
    context.log("我的第一个插件启动了!")
    return {"status": "success", "message": "插件已加载"}

带设置页面的插件

def main(context):
    context.log("带设置页面的插件启动")
    return {"status": "success"}

def build_page_settings(context, parent=None):
    """构建设置页面,函数名必须是 build_page_<key>"""
    from PyQt5.QtWidgets import QWidget, QVBoxLayout, QLabel, QPushButton
    
    page = QWidget()
    layout = QVBoxLayout(page)
    
    layout.addWidget(QLabel("这是我的插件设置页面"))
    
    btn = QPushButton("点击测试")
    btn.clicked.connect(lambda: context.show_toast("按钮被点击了!"))
    layout.addWidget(btn)
    
    layout.addStretch()
    return page

使用打包工具

  1. 运行 python arcx_packager.py
  2. 填写元数据(名称、版本、作者)
  3. 在"代码编辑"标签页粘贴上面的代码
  4. 在"页面配置"标签页添加 settings 页面
  5. 点击"📦 打包成 .arcx"
  6. 将生成的 .arcx 文件放到 plugins 目录
  7. 重启软件

📄 插件文件格式

.arcx 文件结构

{
  "meta": {
    "name": "插件名称",
    "version": "1.0.0",
    "author": "作者",
    "description": "插件描述",
    "type": "general",
    "api_version": 2,
    "has_settings_page": true,
    "pages": [
      {"key": "settings", "title": "设置", "icon": "settings"},
      {"key": "about", "title": "关于", "icon": "info"}
    ],
    "config": {
      "key1": "value1"
    },
    "permissions": ["read_config", "trigger_draw"]
  },
  "code": "def main(context):\n    return {'status': 'success'}",
  "config": {"key1": "value1"},
  "enabled": true
}

meta 字段说明

字段 类型 必填 说明
name string ✅ 插件名称,唯一标识
version string ✅ 版本号,建议语义化版本
author string ❌ 作者名称
description string ❌ 插件描述
type string ❌ 插件类型(general/tool/theme/data/ui/animation/integration/game)
api_version int ❌ API版本,当前为2
has_settings_page bool ❌ 是否有设置页面
pages array ❌ 页面配置列表
config object ❌ 默认配置
permissions array ❌ 插件权限列表

pages 页面配置

每个页面包含:

  • key:页面标识,对应代码中的 build_page_<key> 函数
  • title:页面显示名称
  • icon:页面图标(settings/info/zap/puzzle/chart/book/users/palette/database/keyboard)

📚 插件API完整参考

1. 学生数据API

# 获取所有学生列表
students = context.get_students()
# 返回: ["张三", "李四", "王五", ...]

# 获取当前抽取的学生
current = context.get_current_student()
# 返回: "张三" 或 None

# 获取最后一次抽取结果
result = context.get_last_draw_result()
# 返回: {
#   "student": "张三",
#   "text_title": "岳阳楼记",
#   "text_content": "庆历四年春...",
#   "timestamp": "2024-01-01 12:00:00",
#   "status": "已背过"
# }

# 获取学生统计数据
stats = context.get_student_stats("张三")
# 返回: {"draw_count": 5, "mastered": 3, "familiar": 1, "unlearned": 1, "score": 100}

# 获取排行榜
ranking = context.get_ranking(limit=10)
# 返回: [{"name": "张三", "score": 100, "draw_count": 5}, ...]

2. 抽取控制API

# 触发抽取
context.trigger_draw()
# 开始随机抽取

# 停止抽取
context.stop_draw()
# 停止当前抽取(手动模式下)

# 重置本轮
context.reset_round()
# 清空已抽取学生列表

# 获取抽取状态
state = context.get_draw_state()
# 返回: {"is_drawing": True/False, "drawn_count": 5, "remaining": 5}

# 获取已抽取学生
drawn = context.get_drawn_students()
# 返回: ["张三", "李四", ...]

# 获取抽取引擎引用(高级用法)
engine = context.get_draw_engine()
# 可以直接操作抽取引擎

3. 标记状态API

# 标记当前学生状态
context.mark_status("已背过")
# 状态可选: "已背过", "未背熟", "未背过"

# 撤销上次标记
context.undo_last_mark()
# 回退统计和积分

# 获取当前学生状态
status = context.get_current_status()
# 返回: "已背过" 或 "未标记"

# 获取所有状态统计
stats = context.get_all_stats()
# 返回: {"已背过": 10, "未背熟": 5, "未背过": 3, "未标记": 2}

4. UI交互API

# 显示消息框
context.show_message("标题", "消息内容")

# 显示浮动提示(Toast)
context.show_toast("操作成功!", duration=2000)

# 显示Markdown渲染的提示
md_text = "# 标题\n\n这是**粗体**文字"
context.show_markdown_message("说明", md_text)

# 创建Markdown视图组件
md_view = context.create_markdown_view(parent)
md_view.setMarkdown("# 标题")

# 渲染Markdown为HTML
html = context.render_markdown("# 标题\n\n**粗体**")

# 获取主窗口引用
main_window = context.get_main_window()
# 可以直接操作主窗口的UI

# 获取当前窗口状态
state = context.get_window_state()
# 返回: {
#   "state": "main",  # main/quick/minimized/drawing/settings
#   "is_drawing": False,
#   "is_minimized": False,
#   "is_quick_mode": False,
#   "current_window": "主界面"
# }

5. 配置管理API

# 获取插件配置
value = context.get_config("key", "默认值")

# 设置插件配置
context.set_config("key", "新值")

# 获取所有插件配置
config = context.get_all_config()

# 获取应用配置
value = context.get_app_config("ui", "theme")
# 第一个参数是配置节,第二个是配置键

# 设置应用配置
context.set_app_config("ui", "theme", "dark")
# 修改后可能需要重启才能生效

# 监听配置变更
def on_config_change(section, key, value):
    context.log(f"配置变更: {section}.{key} = {value}")
context.register_event_handler("settings_change", on_config_change)

6. 数据存储API

# 获取插件数据目录
data_dir = context.get_data_dir()
# 返回: plugins/data/插件名称/

# 保存插件数据
context.save_data("key", {"name": "value"})
# 保存到 plugins/data/插件名称/key.json

# 加载插件数据
data = context.load_data("key", default={})

# 列出所有数据文件
files = context.list_data_files()

# 删除数据文件
context.delete_data("key")

7. 日志API

# 输出日志
context.log("这是一条日志")
context.log_info("信息日志")
context.log_warning("警告日志")
context.log_error("错误日志")
context.log_debug("调试日志")

# 获取日志内容
logs = context.get_logs(limit=100)
# 返回最近的日志列表

# 清空日志
context.clear_logs()

# 获取日志文件路径
log_path = context.get_log_file_path()

8. 事件系统API

# 注册事件处理器
def on_draw_finish(event_data):
    student = event_data.get("student")
    context.log(f"抽取完成: {student}")

context.register_event_handler("draw_finish", on_draw_finish)

# 触发自定义事件
context.emit_event("my_custom_event", {"key": "value"})

# 注销事件处理器
context.unregister_event_handler("draw_finish", on_draw_finish)

# 列出所有已注册的事件
events = context.get_registered_events()

9. 系统控制API

# 重启应用
context.restart_application()

# 退出应用
context.quit_application()

# 获取当前主题
theme = context.get_theme()
# 返回: "light" 或 "dark"

# 设置主题
context.set_theme("dark")

# 获取班级名称
class_name = context.get_class_name()
# 返回: "初三A14"

# 设置班级名称
context.set_class_name("高三A1")

# 获取应用版本
version = context.get_app_version()

# 获取插件信息
info = context.get_plugin_info()
# 返回: {"name": "...", "version": "...", "author": "..."}

10. 高级API(直接访问主程序对象)

# 获取主窗口对象(可以直接操作所有UI元素)
main_window = context.get_main_window()

# 访问主窗口的属性
students = main_window._students
texts = main_window._texts
current_student = main_window._current_student

# 调用主窗口的方法
main_window._start_draw()
main_window._stop_draw()
main_window._mark("已背过")
main_window._undo_mark()
main_window._open_settings()
main_window._open_quick()

# 访问抽取引擎
engine = main_window.draw_engine
engine.start_draw()
engine.stop_manual()
engine.reset_round()

# 访问数据管理器
data_mgr = main_window.data_mgr
data_mgr.load_students()
data_mgr.load_texts()
data_mgr.get_all_stats()
data_mgr.get_ranking()

# 访问配置管理器
config_mgr = main_window.config_mgr
config_mgr.get("ui", "theme")
config_mgr.set("ui", "theme", "dark")
config_mgr.save_config()

# 访问主题管理器
theme_mgr = main_window.theme_mgr
qss = theme_mgr.get_qss()

🎨 常量与配置框架

导入常量框架

# 插件中可以直接导入常量框架
from plugin_constants import (
    ThemeColors, AnimationParams, DrawModes, StatusTypes,
    EventTypes, PluginHooks, ConfigSections, ConfigKeys,
    ShortcutKeys, WindowStates, PluginTypes, PluginPermissions,
    get_color, get_qcolor
)

# 使用主题颜色
primary_color = get_color("light", "primary")  # "#0067c0"
primary_qcolor = get_qcolor("dark", "primary")  # QColor对象

# 使用动画参数
duration = AnimationParams.DRAW["default_duration"]  # 3.0

# 使用状态类型
context.mark_status(StatusTypes.MASTERED)  # "已背过"

# 使用事件类型
context.register_event_handler(EventTypes.DRAW_FINISH, handler)

# 使用配置键
theme = context.get_app_config(*ConfigKeys.UI_THEME)

主题颜色

# 浅色主题
ThemeColors.LIGHT = {
    "primary": "#0067c0",
    "success": "#107c10",
    "warning": "#ff8c00",
    "error": "#d13438",
    "bg_main": "#fafafa",
    "bg_card": "#ffffff",
    "text_primary": "#333333",
    "text_secondary": "#666666",
    "border": "#e0e0e0",
    # ... 更多颜色
}

# 深色主题
ThemeColors.DARK = {
    "primary": "#4cc2ff",
    # ... 对应深色版本
}

动画参数

AnimationParams.DRAW = {
    "default_duration": 3.0,
    "min_duration": 1.0,
    "max_duration": 10.0,
    "ease_strength": 0.92,
    "fps": 60,
    "name_font_size": 50,
    "particle_count": 30,
    # ...
}

AnimationParams.RESULT_POPUP = {
    "duration": 2000,
    "fade_in_duration": 200,
    # ...
}

配置键常量

# 常用配置键,避免硬编码字符串
ConfigKeys.UI_THEME = ("ui", "theme")
ConfigKeys.UI_NAME_FONT_SIZE = ("ui", "name_font_size")
ConfigKeys.DRAW_MODE = ("draw", "mode")
ConfigKeys.DRAW_NO_REPEAT = ("draw", "no_repeat")
ConfigKeys.DRAW_AUTO_DURATION = ("draw", "auto_duration")
ConfigKeys.FILES_STUDENTS = ("files", "students")
ConfigKeys.FILES_TEXTS = ("files", "texts")
# ... 更多配置键

# 使用方式
theme = context.get_app_config(*ConfigKeys.UI_THEME)
context.set_app_config(*ConfigKeys.DRAW_MODE, "manual")

📡 事件系统

内置事件列表

事件名 触发时机 event_data 内容
draw_start 抽取开始 {}
draw_finish 抽取完成 {"student": "张三", "text_title": "...", "text_content": "..."}
draw_cancel 抽取取消 {}
status_change 状态变更 {"student": "张三", "status": "已背过", "previous": "未标记"}
status_undo 撤销标记 {"student": "张三", "status": "已背过"}
round_reset 重置本轮 {}
settings_change 配置变更 {"section": "ui", "key": "theme", "value": "dark"}
theme_change 主题切换 {"theme": "dark"}
class_name_change 班级名变更 {"class_name": "高三A1"}
plugin_enable 插件启用 {"plugin_name": "..."}
plugin_disable 插件禁用 {"plugin_name": "..."}
quick_draw_open 快捷抽取打开 {}
quick_draw_close 快捷抽取关闭 {}
window_minimize 窗口最小化 {}
window_restore 窗口恢复 {}
app_start 应用启动 {}
app_exit 应用退出 {}
custom 自定义事件 自定义内容

事件使用示例

def main(context):
    # 监听抽取完成事件
    context.register_event_handler("draw_finish", on_draw_finish)
    
    # 监听状态变更事件
    context.register_event_handler("status_change", on_status_change)
    
    # 监听配置变更事件
    context.register_event_handler("settings_change", on_settings_change)
    
    return {"status": "success"}

def on_draw_finish(event_data):
    student = event_data.get("student")
    text_title = event_data.get("text_title")
    context.log(f"抽取到: {student}, 课文: {text_title}")
    
    # 可以在这里触发自定义动作
    # 比如播放声音、发送通知、修改UI等

def on_status_change(event_data):
    student = event_data.get("student")
    status = event_data.get("status")
    previous = event_data.get("previous")
    context.log(f"{student}: {previous} -> {status}")

def on_settings_change(event_data):
    section = event_data.get("section")
    key = event_data.get("key")
    value = event_data.get("value")
    context.log(f"配置变更: {section}.{key} = {value}")

自定义事件

# 插件A:触发自定义事件
context.emit_event("my_plugin:custom_event", {
    "action": "refresh",
    "data": {"key": "value"}
})

# 插件B:监听自定义事件
def on_custom_event(event_data):
    action = event_data.get("action")
    context.log(f"收到自定义事件: {action}")

context.register_event_handler("my_plugin:custom_event", on_custom_event)

🎬 开发复杂抽取动画插件

核心思路

抽取动画插件的核心是:

  1. 监听 draw_start 事件,在抽取开始时显示自定义动画
  2. 监听 draw_finish 事件,在抽取完成时显示结果动画
  3. 可以完全替换默认的抽取动画,或者在默认动画基础上叠加效果
  4. 使用 PyQt5 的 QTimer、QPropertyAnimation、QGraphicsEffect 等实现动画

示例1:粒子爆炸效果

import random
from PyQt5.QtWidgets import QWidget, QLabel
from PyQt5.QtCore import Qt, QTimer, QPoint, QPropertyAnimation, QEasingCurve
from PyQt5.QtGui import QPainter, QColor, QBrush, QPen

class ParticleExplosionWidget(QWidget):
    """粒子爆炸效果组件"""
    def __init__(self, parent=None):
        super().__init__(parent)
        self.setAttribute(Qt.WA_TransparentForMouseEvents)
        self.particles = []
        self.timer = QTimer(self)
        self.timer.timeout.connect(self._update_particles)
        
    def explode(self, x, y, color="#0067c0", count=50):
        """在指定位置触发粒子爆炸"""
        for i in range(count):
            angle = random.uniform(0, 2 * 3.14159)
            speed = random.uniform(2, 8)
            self.particles.append({
                "x": x, "y": y,
                "vx": math.cos(angle) * speed,
                "vy": math.sin(angle) * speed,
                "life": 1.0,
                "size": random.randint(3, 8),
                "color": QColor(color)
            })
        self.timer.start(16)  # 60fps
        
    def _update_particles(self):
        for p in self.particles:
            p["x"] += p["vx"]
            p["y"] += p["vy"]
            p["vy"] += 0.2  # 重力
            p["life"] -= 0.02
        self.particles = [p for p in self.particles if p["life"] > 0]
        self.update()
        if not self.particles:
            self.timer.stop()
            
    def paintEvent(self, event):
        painter = QPainter(self)
        painter.setRenderHint(QPainter.Antialiasing)
        for p in self.particles:
            painter.setOpacity(p["life"])
            painter.setBrush(QBrush(p["color"]))
            painter.setPen(Qt.NoPen)
            painter.drawEllipse(int(p["x"]), int(p["y"]), 
                              int(p["size"]), int(p["size"]))

def main(context):
    """插件入口"""
    context.log("粒子爆炸动画插件启动")
    
    # 创建粒子组件
    main_window = context.get_main_window()
    particle_widget = ParticleExplosionWidget(main_window)
    particle_widget.setGeometry(main_window.rect())
    particle_widget.show()
    particle_widget.raise_()
    
    # 监听抽取完成事件
    def on_draw_finish(event_data):
        # 在名字显示位置触发爆炸
        name_label = main_window.findChild(QLabel, "draw_name_label")
        if name_label:
            pos = name_label.mapTo(main_window, name_label.rect().center())
            particle_widget.explode(pos.x(), pos.y(), color="#0067c0", count=80)
    
    context.register_event_handler("draw_finish", on_draw_finish)
    
    return {"status": "success"}

示例2:名字滚动动画(完全替换默认动画)

import random
from PyQt5.QtWidgets import QLabel, QWidget, QVBoxLayout
from PyQt5.QtCore import Qt, QTimer, QPropertyAnimation, QEasingCurve
from PyQt5.QtGui import QFont, QColor

class CustomDrawAnimation(QWidget):
    """自定义抽取动画组件"""
    def __init__(self, parent=None):
        super().__init__(parent)
        self.setStyleSheet("background: rgba(0, 0, 0, 0.8); border-radius: 16px;")
        self.setFixedSize(400, 200)
        
        layout = QVBoxLayout(self)
        layout.setAlignment(Qt.AlignCenter)
        
        self.name_label = QLabel("准备抽取")
        self.name_label.setAlignment(Qt.AlignCenter)
        self.name_label.setStyleSheet("color: white; font-size: 48px; font-weight: bold;")
        layout.addWidget(self.name_label)
        
        self.timer = QTimer(self)
        self.timer.timeout.connect(self._roll_name)
        self.students = []
        self.is_rolling = False
        
    def start_roll(self, students, duration=3.0, final_name=None):
        """开始滚动动画"""
        self.students = students
        self.is_rolling = True
        self.final_name = final_name
        self.roll_count = 0
        self.max_rolls = int(duration * 60)  # 60fps
        self.timer.start(16)
        
        # 淡入动画
        self.setWindowOpacity(0)
        anim = QPropertyAnimation(self, b"windowOpacity")
        anim.setDuration(200)
        anim.setStartValue(0)
        anim.setEndValue(1)
        anim.start()
        
    def _roll_name(self):
        self.roll_count += 1
        progress = self.roll_count / self.max_rolls
        
        # 越接近结束,滚动越慢
        if progress < 0.7:
            name = random.choice(self.students)
        else:
            # 减速阶段,减少更新频率
            if self.roll_count % 3 == 0:
                name = random.choice(self.students)
            else:
                return
        
        self.name_label.setText(name)
        
        if self.roll_count >= self.max_rolls:
            self.timer.stop()
            self.is_rolling = False
            if self.final_name:
                self.name_label.setText(self.final_name)
                # 结果闪烁效果
                self._flash_result()
                
    def _flash_result(self):
        """结果闪烁效果"""
        colors = ["#ffffff", "#00ff00", "#ffffff", "#00ff00", "#ffffff"]
        self.flash_index = 0
        self.flash_timer = QTimer(self)
        def flash():
            if self.flash_index < len(colors):
                self.name_label.setStyleSheet(
                    f"color: {colors[self.flash_index]}; font-size: 48px; font-weight: bold;")
                self.flash_index += 1
            else:
                self.flash_timer.stop()
        self.flash_timer.timeout.connect(flash)
        self.flash_timer.start(100)

def main(context):
    """完全替换默认抽取动画的插件"""
    context.log("自定义抽取动画插件启动")
    
    main_window = context.get_main_window()
    
    # 创建自定义动画组件
    custom_anim = CustomDrawAnimation(main_window)
    custom_anim.move(main_window.width() // 2 - 200, 
                     main_window.height() // 2 - 100)
    custom_anim.hide()
    
    # 拦截默认抽取,使用自定义动画
    original_start_draw = main_window._start_draw
    
    def custom_start_draw():
        students = main_window._students
        if not students:
            context.show_toast("没有学生数据!")
            return
        
        # 隐藏默认的抽取显示
        # main_window.draw_name_label.hide()
        
        # 显示自定义动画
        custom_anim.show()
        custom_anim.raise_()
        
        # 先随机选一个结果
        final_name = random.choice(students)
        
        # 开始自定义滚动动画
        custom_anim.start_roll(students, duration=3.0, final_name=final_name)
        
        # 动画结束后更新主程序状态
        QTimer.singleShot(3200, lambda: _on_animation_finish(final_name))
    
    def _on_animation_finish(name):
        # 更新主程序的当前学生
        main_window._current_student = name
        main_window._is_drawing = False
        
        # 触发抽取完成事件
        context.emit_event("draw_finish", {
            "student": name,
            "text_title": "",
            "text_content": ""
        })
        
        # 2秒后隐藏自定义动画
        QTimer.singleShot(2000, custom_anim.hide)
    
    # 替换默认的开始抽取方法
    main_window._start_draw = custom_start_draw
    
    return {"status": "success"}

示例3:3D翻转效果

from PyQt5.QtWidgets import QLabel, QWidget
from PyQt5.QtCore import Qt, QTimer, QPropertyAnimation, QEasingCurve, QRect
from PyQt5.QtGui import QFont, QTransform

class FlipCardWidget(QWidget):
    """3D翻转卡片效果"""
    def __init__(self, parent=None):
        super().__init__(parent)
        self.setFixedSize(300, 150)
        self.setStyleSheet("background: #0067c0; border-radius: 12px;")
        
        self.label = QLabel("点击抽取", self)
        self.label.setAlignment(Qt.AlignCenter)
        self.label.setStyleSheet("color: white; font-size: 36px; font-weight: bold;")
        self.label.setGeometry(self.rect())
        
        self.flip_animation = QPropertyAnimation(self, b"geometry")
        self.flip_animation.setDuration(500)
        self.flip_animation.setEasingCurve(QEasingCurve.InOutQuad)
        
    def flip_to(self, text, color="#107c10"):
        """翻转动画"""
        original_geo = self.geometry()
        center = original_geo.center()
        
        # 第一阶段:压扁
        self.flip_animation.setStartValue(original_geo)
        squashed = QRect(center.x() - 150, center.y() - 5, 300, 10)
        self.flip_animation.setEndValue(squashed)
        
        def on_first_half():
            # 切换内容
            self.label.setText(text)
            self.setStyleSheet(f"background: {color}; border-radius: 12px;")
            
            # 第二阶段:展开
            self.flip_animation.setStartValue(squashed)
            self.flip_animation.setEndValue(original_geo)
            self.flip_animation.finished.disconnect()
            self.flip_animation.start()
        
        self.flip_animation.finished.connect(on_first_half)
        self.flip_animation.start()

动画开发要点

  1. 使用 QTimer 实现帧动画:16ms 间隔 ≈ 60fps
  2. 使用 QPropertyAnimation 实现属性动画:透明度、位置、大小、颜色
  3. 使用 QPainter 实现自定义绘制:粒子、轨迹、特效
  4. 使用 QGraphicsEffect 实现视觉效果:模糊、阴影、颜色变化
  5. 监听事件实现触发:draw_start、draw_finish 等事件
  6. 注意性能:避免在 paintEvent 中做复杂计算,预计算动画帧
  7. 注意内存泄漏:动画结束后停止 timer,删除不需要的对象

💻 执行外部程序与文件操作

执行外部程序

import subprocess
import os

def main(context):
    context.log("执行外部程序示例插件启动")
    
    # 示例1:启动记事本
    def open_notepad():
        subprocess.Popen(["notepad.exe"])
        context.show_toast("已打开记事本")
    
    # 示例2:执行命令并获取输出
    def run_command():
        result = subprocess.run(
            ["ipconfig"], 
            capture_output=True, 
            text=True,
            encoding="gbk"
        )
        context.log(f"命令输出: {result.stdout[:200]}")
        context.show_message("命令输出", result.stdout[:500])
    
    # 示例3:打开文件
    def open_file():
        file_path = context.get_config("students_file", "students.txt")
        if os.path.exists(file_path):
            os.startfile(file_path)  # Windows 专用
        else:
            context.show_toast("文件不存在")
    
    # 示例4:启动另一个Python脚本
    def run_python_script():
        script_path = "my_script.py"
        python_exe = sys.executable
        subprocess.Popen([python_exe, script_path])
    
    return {"status": "success"}

文件操作

import os
import json
import shutil

def main(context):
    context.log("文件操作示例插件启动")
    
    # 获取插件数据目录
    data_dir = context.get_data_dir()
    context.log(f"数据目录: {data_dir}")
    
    # 示例1:读取学生名单
    def read_students():
        students_file = context.get_app_config("files", "students")
        if os.path.exists(students_file):
            with open(students_file, "r", encoding="utf-8") as f:
                students = [line.strip() for line in f if line.strip()]
            context.log(f"读取到 {len(students)} 个学生")
            return students
        return []
    
    # 示例2:写入统计数据
    def export_stats():
        stats = context.get_all_stats()
        export_path = os.path.join(data_dir, "stats_export.json")
        with open(export_path, "w", encoding="utf-8") as f:
            json.dump(stats, f, ensure_ascii=False, indent=2)
        context.show_toast(f"统计已导出到 {export_path}")
    
    # 示例3:备份配置
    def backup_config():
        config_file = "config.json"
        backup_file = f"config_backup_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json"
        if os.path.exists(config_file):
            shutil.copy2(config_file, backup_file)
            context.show_toast(f"配置已备份到 {backup_file}")
    
    # 示例4:监控文件变化
    def watch_file(file_path, callback):
        last_mtime = os.path.getmtime(file_path)
        def check():
            nonlocal last_mtime
            current_mtime = os.path.getmtime(file_path)
            if current_mtime != last_mtime:
                last_mtime = current_mtime
                callback()
        timer = QTimer()
        timer.timeout.connect(check)
        timer.start(1000)  # 每秒检查一次
        return timer
    
    return {"status": "success"}

🌐 网络广播与通信

HTTP服务器(接受广播)

import json
from http.server import HTTPServer, BaseHTTPRequestHandler
from threading import Thread

class BroadcastHandler(BaseHTTPRequestHandler):
    """处理广播消息的HTTP服务器"""
    
    def do_POST(self):
        content_length = int(self.headers['Content-Length'])
        post_data = self.rfile.read(content_length)
        
        try:
            data = json.loads(post_data.decode('utf-8'))
            # 处理接收到的消息
            self.server.context.log(f"收到广播: {data}")
            
            # 触发插件事件
            self.server.context.emit_event("broadcast_received", data)
            
            # 如果是抽取指令,触发抽取
            if data.get("action") == "trigger_draw":
                self.server.context.trigger_draw()
            
            response = {"status": "success"}
        except Exception as e:
            response = {"status": "error", "message": str(e)}
        
        self.send_response(200)
        self.send_header('Content-Type', 'application/json')
        self.end_headers()
        self.wfile.write(json.dumps(response).encode('utf-8'))
    
    def log_message(self, format, *args):
        pass  # 禁用默认日志

def main(context):
    context.log("网络广播插件启动")
    
    # 启动HTTP服务器
    server = HTTPServer(('0.0.0.0', 8080), BroadcastHandler)
    server.context = context
    
    server_thread = Thread(target=server.serve_forever, daemon=True)
    server_thread.start()
    
    context.log("HTTP服务器已启动在端口 8080")
    context.show_toast("广播接收器已启动: http://localhost:8080")
    
    # 发送广播示例
    def send_broadcast(message):
        import urllib.request
        url = "http://other-pc:8080/broadcast"
        data = json.dumps({"message": message}).encode('utf-8')
        req = urllib.request.Request(url, data=data, headers={'Content-Type': 'application/json'})
        try:
            with urllib.request.urlopen(req, timeout=5) as response:
                context.log(f"广播发送成功: {response.read().decode()}")
        except Exception as e:
            context.log_warning(f"广播发送失败: {e}")
    
    return {"status": "success"}

UDP广播

import socket
import json
from threading import Thread

class UDPBroadcaster:
    """UDP广播发送器"""
    def __init__(self, port=5000):
        self.port = port
        self.sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
        self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
    
    def broadcast(self, data):
        message = json.dumps(data).encode('utf-8')
        self.sock.sendto(message, ('<broadcast>', self.port))

class UDPReceiver:
    """UDP广播接收器"""
    def __init__(self, port=5000, callback=None):
        self.port = port
        self.callback = callback
        self.sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
        self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
        self.sock.bind(('', port))
        self.running = True
    
    def start(self):
        thread = Thread(target=self._receive, daemon=True)
        thread.start()
    
    def _receive(self):
        while self.running:
            try:
                data, addr = self.sock.recvfrom(1024)
                message = json.loads(data.decode('utf-8'))
                if self.callback:
                    self.callback(message, addr)
            except Exception as e:
                pass
    
    def stop(self):
        self.running = False
        self.sock.close()

def main(context):
    context.log("UDP广播插件启动")
    
    # 接收广播
    def on_broadcast(data, addr):
        context.log(f"收到来自 {addr} 的广播: {data}")
        context.emit_event("udp_broadcast", data)
        
        if data.get("action") == "trigger_draw":
            context.trigger_draw()
    
    receiver = UDPReceiver(port=5000, callback=on_broadcast)
    receiver.start()
    
    # 发送广播
    broadcaster = UDPBroadcaster(port=5000)
    
    def broadcast_draw_result():
        result = context.get_last_draw_result()
        if result:
            broadcaster.broadcast({
                "type": "draw_result",
                "student": result["student"],
                "text_title": result["text_title"]
            })
    
    context.register_event_handler("draw_finish", lambda e: broadcast_draw_result())
    
    context.show_toast("UDP广播已启动 (端口 5000)")
    
    return {"status": "success"}

WebSocket通信

# 需要安装 websockets 库: pip install websockets
import asyncio
import websockets
import json
from threading import Thread

class WebSocketServer:
    """WebSocket服务器"""
    def __init__(self, host='0.0.0.0', port=8765, context=None):
        self.host = host
        self.port = port
        self.context = context
        self.clients = set()
    
    async def handler(self, websocket, path):
        self.clients.add(websocket)
        self.context.log(f"WebSocket客户端连接: {len(self.clients)} 个")
        try:
            async for message in websocket:
                data = json.loads(message)
                self.context.log(f"收到WebSocket消息: {data}")
                
                if data.get("action") == "trigger_draw":
                    self.context.trigger_draw()
                elif data.get("action") == "get_status":
                    state = self.context.get_window_state()
                    await websocket.send(json.dumps({"type": "status", "data": state}))
        finally:
            self.clients.remove(websocket)
    
    async def broadcast(self, data):
        if self.clients:
            message = json.dumps(data)
            await asyncio.wait([client.send(message) for client in self.clients])
    
    def start(self):
        loop = asyncio.new_event_loop()
        asyncio.set_event_loop(loop)
        self.loop = loop
        self.server = websockets.serve(self.handler, self.host, self.port)
        loop.run_until_complete(self.server)
        loop.run_forever()
    
    def run_in_thread(self):
        thread = Thread(target=self.start, daemon=True)
        thread.start()

def main(context):
    context.log("WebSocket通信插件启动")
    
    ws_server = WebSocketServer(host='0.0.0.0', port=8765, context=context)
    ws_server.run_in_thread()
    
    # 抽取完成时广播给所有客户端
    def on_draw_finish(event_data):
        asyncio.run_coroutine_threadsafe(
            ws_server.broadcast({"type": "draw_finish", "data": event_data}),
            ws_server.loop
        )
    
    context.register_event_handler("draw_finish", on_draw_finish)
    
    context.show_toast("WebSocket服务器已启动: ws://localhost:8765")
    
    return {"status": "success"}

🎨 自定义UI组件

自定义悬浮窗

from PyQt5.QtWidgets import QWidget, QLabel, QVBoxLayout
from PyQt5.QtCore import Qt, QPoint, QTimer
from PyQt5.QtGui import QPainter, QColor, QBrush, QFont

class FloatingWidget(QWidget):
    """可拖动的悬浮窗"""
    def __init__(self, parent=None):
        super().__init__(parent)
        self.setWindowFlags(Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint | Qt.Tool)
        self.setAttribute(Qt.WA_TranslucentBackground)
        self.setFixedSize(200, 100)
        self.dragging = False
        self.drag_position = QPoint()
        
        layout = QVBoxLayout(self)
        self.label = QLabel("悬浮窗")
        self.label.setAlignment(Qt.AlignCenter)
        self.label.setStyleSheet("color: white; font-size: 16px; font-weight: bold;")
        layout.addWidget(self.label)
    
    def paintEvent(self, event):
        painter = QPainter(self)
        painter.setRenderHint(QPainter.Antialiasing)
        painter.setBrush(QBrush(QColor(0, 103, 192, 220)))
        painter.setPen(Qt.NoPen)
        painter.drawRoundedRect(self.rect(), 12, 12)
    
    def mousePressEvent(self, event):
        if event.button() == Qt.LeftButton:
            self.dragging = True
            self.drag_position = event.globalPos() - self.frameGeometry().topLeft()
    
    def mouseMoveEvent(self, event):
        if self.dragging:
            self.move(event.globalPos() - self.drag_position)
    
    def mouseReleaseEvent(self, event):
        self.dragging = False

def main(context):
    floating = FloatingWidget()
    floating.move(100, 100)
    floating.show()
    
    def on_draw_finish(event_data):
        floating.label.setText(f"抽到: {event_data.get('student')}")
    
    context.register_event_handler("draw_finish", on_draw_finish)
    
    return {"status": "success"}

自定义仪表盘

from PyQt5.QtWidgets import QWidget
from PyQt5.QtCore import Qt, QTimer, QRectF
from PyQt5.QtGui import QPainter, QColor, QPen, QFont, QConicalGradient

class GaugeWidget(QWidget):
    """仪表盘组件"""
    def __init__(self, parent=None):
        super().__init__(parent)
        self.setMinimumSize(200, 200)
        self.value = 0
        self.max_value = 100
        self.angle = 0
        
        # 动画
        self.timer = QTimer(self)
        self.timer.timeout.connect(self._animate)
        self.target_value = 0
    
    def set_value(self, value):
        self.target_value = min(max(value, 0), self.max_value)
        self.timer.start(16)
    
    def _animate(self):
        diff = self.target_value - self.value
        if abs(diff) < 0.5:
            self.value = self.target_value
            self.timer.stop()
        else:
            self.value += diff * 0.1
        self.update()
    
    def paintEvent(self, event):
        painter = QPainter(self)
        painter.setRenderHint(QPainter.Antialiasing)
        
        size = min(self.width(), self.height())
        center_x = self.width() / 2
        center_y = self.height() / 2
        radius = size / 2 - 20
        
        # 背景圆弧
        pen = QPen(QColor("#e0e0e0"), 12)
        pen.setCapStyle(Qt.RoundCap)
        painter.setPen(pen)
        painter.drawArc(QRectF(center_x - radius, center_y - radius, 
                               radius * 2, radius * 2), 30 * 16, 300 * 16)
        
        # 进度圆弧(渐变)
        gradient = QConicalGradient(center_x, center_y, 120)
        gradient.setColorAt(0, QColor("#0067c0"))
        gradient.setColorAt(0.5, QColor("#0078d4"))
        gradient.setColorAt(1, QColor("#107c10"))
        
        pen = QPen(gradient, 12)
        pen.setCapStyle(Qt.RoundCap)
        painter.setPen(pen)
        
        progress = self.value / self.max_value
        span_angle = int(300 * progress * 16)
        painter.drawArc(QRectF(center_x - radius, center_y - radius,
                               radius * 2, radius * 2), 30 * 16, span_angle)
        
        # 中心文字
        painter.setPen(QColor("#333333"))
        painter.setFont(QFont("微软雅黑", 24, QFont.Bold))
        painter.drawText(self.rect(), Qt.AlignCenter, f"{int(self.value)}")
        
        painter.setFont(QFont("微软雅黑", 10))
        painter.setPen(QColor("#666666"))
        painter.drawText(QRectF(center_x - 50, center_y + 20, 100, 20), 
                        Qt.AlignCenter, "完成度")

🎯 UI注入:在主程序任何位置添加按钮

核心工具类

A13提供了 plugin_ui_injector.py 工具类,让插件可以轻松地在主程序的任何位置添加按钮、修改UI。

# 导入UI注入工具
from plugin_ui_injector import UIInjector

# 创建注入器
main_window = context.get_main_window()
injector = UIInjector(main_window)

快速开始:5种常用位置添加按钮

1. 在顶部栏添加按钮

def on_click():
    context.show_toast("顶部栏按钮被点击!")

btn = injector.add_button_to_top_bar(
    text="✨ 我的按钮",
    callback=on_click,
    style="primary",      # primary/success/warning/danger/ghost/small
    tooltip="这是插件添加的按钮",
    width=100              # 可选,固定宽度
)

2. 在底部状态栏添加按钮

btn = injector.add_button_to_bottom_bar(
    text="📊 统计",
    callback=show_stats,
    style="ghost",
    width=90
)

3. 在指定按钮旁边添加按钮

# 找到"开始"按钮,在它右边添加
start_btn = injector.find_button_by_text("开始")
if start_btn:
    injector.add_button_next_to(
        target_button=start_btn,  # 也可以传按钮文字"开始"
        text="⚡ 快捷抽取",
        callback=quick_draw,
        side="right",             # right/left
        style="success"
    )

4. 创建可拖动的悬浮按钮

floating_btn = injector.create_floating_button(
    text="🎨 特效",
    callback=toggle_effect,
    x=20, y=100,              # 初始位置
    size=(70, 36),            # 按钮大小
    style="warning"
)
# 用户可以拖动这个按钮到任意位置

5. 在任意布局中添加按钮

# 先找到目标布局
target_widget = injector.find_widget_by_name("some_widget")
layout = target_widget.layout()

# 添加按钮
injector.add_button_to_layout(
    layout=layout,
    text="自定义按钮",
    callback=my_callback,
    position=0,               # 0=开头, None=末尾, 或指定索引
    style="primary"
)

完整API参考

查找UI元素

# 按objectName查找控件
widget = injector.find_widget_by_name("top_bar")

# 按文字查找按钮
btn = injector.find_button_by_text("开始")

# 查找所有按钮
all_buttons = injector.find_all_buttons()

# 获取所有UI元素摘要(调试用)
elements = injector.get_all_ui_elements()
# 返回: [{"type": "QPushButton", "text": "...", "objectName": "...", "visible": True}, ...]

添加按钮的方法

方法 说明 参数
add_button_to_top_bar() 顶部栏添加按钮 text, callback, position, style, icon, tooltip, width, height
add_button_to_bottom_bar() 底部栏添加按钮 同上
add_button_to_sidebar() 侧边栏添加按钮 同上
add_button_to_draw_area() 抽取区域添加按钮 同上
add_button_to_layout() 任意布局添加按钮 layout, text, callback, position, style, ...
add_button_next_to() 指定按钮旁添加 target_button, text, callback, side, style, ...
create_floating_button() 创建悬浮按钮 text, callback, x, y, size, style

修改现有按钮

# 修改按钮文字、回调、样式
injector.modify_button(
    button="重置本轮",           # 按钮对象或按钮文字
    text="新文字",                # None=不修改
    callback=new_callback,        # None=不修改,会先断开旧连接
    style="danger",               # None=不修改
    icon=new_icon,                # None=不修改
    tooltip="新提示"              # None=不修改
)

# 隐藏/显示按钮
injector.hide_button("开始")
injector.show_button("开始")

添加其他UI元素

# 添加分隔线
injector.add_separator(layout, position=0)

# 添加标签
injector.add_label(
    layout, 
    text="标签文字", 
    position=None,
    style="title"  # normal/title/subtitle/hint/success/warning/danger
)

# 添加自定义控件
my_widget = MyCustomWidget()
injector.add_custom_widget(layout, my_widget, position=0)

# 向菜单添加菜单项
injector.add_menu_action(
    menu, 
    text="菜单项", 
    callback=my_callback,
    icon=None,
    shortcut="Ctrl+K"
)

清理资源

# 插件禁用时清理所有注入的UI元素
def on_disable(context):
    if hasattr(context, 'injector'):
        context.injector.cleanup()

按钮样式

样式 效果 适用场景
primary 蓝色背景,白色文字 主要操作
success 绿色背景,白色文字 成功/确认
warning 橙色背景,白色文字 警告/注意
danger 红色背景,白色文字 危险/删除
ghost 透明背景,边框 次要操作
small 小尺寸,灰色背景 工具栏小按钮

完整示例:多功能UI注入插件

from plugin_ui_injector import UIInjector

def main(context):
    main_window = context.get_main_window()
    injector = UIInjector(main_window)
    context.injector = injector

    # 1. 顶部栏添加按钮
    injector.add_button_to_top_bar(
        "✨ 插件功能", 
        lambda: context.show_toast("插件按钮!"),
        style="primary", width=100
    )

    # 2. 在"开始"按钮旁添加快捷抽取
    injector.add_button_next_to(
        "开始", "⚡ 快抽", 
        lambda: context.trigger_draw(),
        side="right", style="success"
    )

    # 3. 底部栏添加统计按钮
    injector.add_button_to_bottom_bar(
        "📊 统计", 
        lambda: context.show_message("统计", str(context.get_all_stats())),
        style="ghost", width=80
    )

    # 4. 创建悬浮按钮
    injector.create_floating_button(
        "🎨 特效", toggle_particles,
        x=20, y=120, size=(70, 36), style="warning"
    )

    # 5. 修改重置按钮,添加确认
    def safe_reset():
        from PyQt5.QtWidgets import QMessageBox
        if QMessageBox.question(None, "确认", "确定重置?") == QMessageBox.Yes:
            context.reset_round()
    injector.modify_button("重置本轮", callback=safe_reset)

    return {"status": "success"}

def toggle_particles():
    main_window = context.get_main_window()
    if hasattr(main_window, 'particles'):
        main_window.particles.set_enabled(not main_window.particles.isVisible())

def on_disable(context):
    if hasattr(context, 'injector'):
        context.injector.cleanup()

注意事项

  1. 及时清理:插件禁用时必须调用 injector.cleanup(),否则按钮会残留
  2. 按钮查找:find_button_by_text() 按文字精确匹配,如果按钮文字有变化会找不到
  3. 布局兼容:不是所有控件都有layout,添加前最好检查
  4. 样式覆盖:注入的按钮样式可能被主程序的QSS覆盖,必要时使用 setStyleSheet() 强制设置
  5. 线程安全:UI操作必须在主线程执行,不要在子线程中直接操作UI
  6. 对象名称:如果要按objectName查找,需要知道主程序中控件的objectName,可以用 get_all_ui_elements() 查看

调试技巧

# 查看所有UI元素
elements = injector.get_all_ui_elements()
for e in elements:
    if e['type'] == 'QPushButton':
        context.log(f"按钮: {e['text']} (objectName: {e['objectName']})")

# 查看某个控件的所有子控件
widget = injector.find_widget_by_name("top_bar")
if widget:
    children = widget.findChildren(QPushButton)
    for child in children:
        context.log(f"子按钮: {child.text()}")

🎨 插件图标资源:使用和打包图标 ⭐ 新增

图标系统概述

A13插件系统支持完整的图标资源管理:

  • ✅ 系统内置 184个 SVG 图标(Feather Icons 风格)
  • ✅ 插件可以自带自定义图标(.ico / .png / .svg)
  • ✅ 打包时自动包含图标资源
  • ✅ 安装时自动处理图标目录
  • ✅ 提供完整的图标API

系统内置图标位置

所有系统内置图标位于:

软件安装目录/icons/

图标格式为 SVG,使用 currentColor 作为填充色,可以自动适配主题颜色。

系统内置图标完整列表(184个)

📌 基础UI图标(30个)

图标文件名 用途说明
home.svg 主页/首页
settings.svg 设置
search.svg 搜索
user.svg 用户
users.svg 用户组/多人
star.svg 收藏/星标
heart.svg 喜欢/爱心
heart_icon.svg 爱心(备用名)
check.svg 确认/勾选
close.svg 关闭
plus.svg 添加/加号
minus.svg 减少/减号
edit.svg 编辑
delete.svg 删除
save.svg 保存
copy.svg 复制
clipboard.svg 剪贴板
folder.svg 文件夹
file.svg 文件
archive.svg 归档/压缩包
download.svg 下载
upload.svg 上传
refresh.svg 刷新
reset.svg 重置
filter.svg 筛选
sort.svg 排序
grid.svg 网格视图
list.svg 列表视图
layers.svg 图层
tag.svg 标签

➡️ 导航箭头图标(20个)

图标文件名 用途说明
arrow_up.svg 向上箭头
arrow_down.svg 向下箭头
arrow_left.svg 向左箭头
arrow_right.svg 向右箭头
chevron_down.svg 向下 Chevron
chevron_right.svg 向右 Chevron
chevrons_up.svg 双向上箭头
chevrons_down.svg 双向下箭头
chevrons_left.svg 双向左箭头
chevrons_right.svg 双向右箭头
corner_down_right.svg 右下角箭头
corner_down_left.svg 左下角箭头
corner_right_up.svg 右上角箭头
corner_left_up.svg 左上角箭头
move.svg 移动(四向箭头)
move_horizontal.svg 水平移动
move_vertical.svg 垂直移动
external_link.svg 外部链接
link.svg 链接
navigation.svg 导航

▶️ 媒体控制图标(20个)

图标文件名 用途说明
play.svg 播放
play_icon.svg 播放(备用名)
pause.svg 暂停
stop.svg 停止
record.svg 录制
skip_back.svg 上一个/快退
skip_forward.svg 下一个/快进
rewind.svg 倒带
fast_forward.svg 快进
repeat.svg 重复
shuffle.svg 随机播放
volume.svg 音量
volume_off.svg 静音
music.svg 音乐
video.svg 视频
camera.svg 相机
mic.svg 麦克风
image.svg 图片
airplay.svg 投屏
cast.svg 投射

✏️ 编辑工具图标(20个)

图标文件名 用途说明
bold.svg 加粗
italic.svg 斜体
underline.svg 下划线
align_left.svg 左对齐
align_center.svg 居中对齐
align_right.svg 右对齐
type.svg 文字/字体
pilcrow.svg 段落
quote.svg 引用
scissors.svg 剪切
tool.svg 工具
wrench.svg 扳手
rotate_cw.svg 顺时针旋转
rotate_ccw.svg 逆时针旋转
zoom_in.svg 放大
zoom_out.svg 缩小
crop.svg 裁剪(如有)
eye.svg 显示/可见
eye_off.svg 隐藏/不可见
palette.svg 调色板/主题

👤 用户和权限图标(15个)

图标文件名 用途说明
user.svg 单个用户
users.svg 多个用户
users_icon.svg 多个用户(备用名)
user_check.svg 用户已验证
user_plus.svg 添加用户
user_minus.svg 移除用户
user_x.svg 删除用户
id_card.svg 身份证/证件
lock.svg 锁定
unlock.svg 解锁
shield.svg 安全/防护
key.svg 密钥(如有)
log_in.svg 登录
log_out.svg 登出
power.svg 电源/开关

💻 设备和硬件图标(18个)

图标文件名 用途说明
monitor.svg 显示器
laptop.svg 笔记本电脑
smartphone.svg 智能手机
tablet.svg 平板电脑
cpu.svg 处理器
server.svg 服务器
database.svg 数据库
hard_drive.svg 硬盘(如有)
printer.svg 打印机
keyboard.svg 键盘
keyboard_icon.svg 键盘(备用名)
mouse.svg 鼠标
gamepad.svg 游戏手柄
wifi.svg WiFi
bluetooth.svg 蓝牙
usb.svg USB
battery.svg 电池(如有)
globe.svg 地球/网络

🌤️ 天气和自然图标(10个)

图标文件名 用途说明
sun.svg 太阳/晴天
moon.svg 月亮/夜晚/深色模式
cloud.svg 云
rain.svg 雨
snow.svg 雪
wind.svg 风
storm.svg 暴风雨(如有)
cloud_rain.svg 云雨(如有)
cloud_snow.svg 云雪(如有)
cloud_lightning.svg 闪电(如有)

📊 数据和图表图标(10个)

图标文件名 用途说明
bar_chart.svg 柱状图
pie_chart.svg 饼图
line_chart.svg 折线图(如有)
activity.svg 活动/心率图
trending_up.svg 上升趋势
trending_down.svg 下降趋势
percent.svg 百分比
dollar.svg 美元/金钱
credit_card.svg 信用卡
shopping_cart.svg 购物车

😊 社交和表情图标(10个)

图标文件名 用途说明
smile.svg 微笑
frown.svg 难过
meh.svg 面无表情
thumbs_up.svg 点赞
thumbs_down.svg 点踩
message.svg 消息
mail.svg 邮件
phone.svg 电话
bell.svg 通知/铃铛
gift.svg 礼物

🔌 插件开发相关图标(15个)

图标文件名 用途说明
plugin.svg 插件
package.svg 包/打包
code.svg 代码
terminal.svg 终端
bug.svg Bug/调试
rocket.svg 火箭/启动
aperture.svg 光圈/摄影
command.svg 命令
hash.svg 哈希/标签
at_sign.svg @符号
flag.svg 旗帜/标记
award.svg 奖项
trophy.svg 奖杯
medal.svg 奖牌
crown.svg 皇冠

⚠️ 状态和提示图标(15个)

图标文件名 用途说明
info.svg 信息
help.svg 帮助
question.svg 问题
warning.svg 警告
alert.svg 提醒
error.svg 错误
ban.svg 禁止
check_circle.svg 成功(如有)
x_circle.svg 失败(如有)
alert_circle.svg 警告圆圈(如有)
info_circle.svg 信息圆圈(如有)
help_circle.svg 帮助圆圈(如有)
clock.svg 时钟/时间
calendar.svg 日历
book.svg 书籍/文档

🎮 其他实用图标(16个)

图标文件名 用途说明
draw.svg 抽取/抽奖
echo.svg 回声洞
history.svg 历史记录
zap.svg 闪电/快捷
target.svg 目标/准星
compass.svg 指南针
map.svg 地图
location.svg 定位
coffee.svg 咖啡
food.svg 食物
send.svg 发送
external.svg 外部
maximize.svg 最大化
maximize_icon.svg 最大化(备用名)
minimize.svg 最小化
minimize_icon.svg 最小化(备用名)

如何在插件中使用系统内置图标

方法1:直接使用SVG文件路径

def main(context):
    import os
    # 获取软件安装目录下的图标路径
    app_dir = context.get_app_dir()
    icon_path = os.path.join(app_dir, "icons", "plugin.svg")
    
    if os.path.exists(icon_path):
        context.log(f"图标路径: {icon_path}")

方法2:使用QIcon加载

from PyQt5.QtGui import QIcon
from PyQt5.QtWidgets import QPushButton

def build_page_settings(context, parent=None):
    from PyQt5.QtWidgets import QWidget, QVBoxLayout, QPushButton
    import os
    
    page = QWidget()
    layout = QVBoxLayout(page)
    
    # 加载系统内置图标
    app_dir = context.get_app_dir()
    icon_path = os.path.join(app_dir, "icons", "rocket.svg")
    
    btn = QPushButton("启动插件")
    if os.path.exists(icon_path):
        btn.setIcon(QIcon(icon_path))
    layout.addWidget(btn)
    
    layout.addStretch()
    return page

如何在插件中使用自定义图标

步骤1:准备图标文件

将你的图标文件(.ico / .png / .svg)准备好,例如:

  • my_plugin_icon.ico
  • my_button_icon.png
  • custom_logo.svg

步骤2:在打包工具中添加图标

  1. 运行 python arcx_packager.py
  2. 切换到"🎨 图标资源"标签页
  3. 点击"➕ 添加图标文件"
  4. 选择你的图标文件
  5. 继续打包流程

步骤3:在插件代码中使用图标

def main(context):
    # 检查插件是否包含某个图标
    if context.has_icon("my_plugin_icon"):
        context.log("插件包含 my_plugin_icon 图标")
    
    # 获取图标完整路径
    icon_path = context.get_icon_path("my_plugin_icon")
    if icon_path:
        context.log(f"图标路径: {icon_path}")
    
    # 直接加载为 QIcon
    icon = context.load_icon("my_plugin_icon")
    if icon:
        context.log("图标加载成功")
    
    # 列出插件所有图标
    icons = context.list_icons()
    context.log(f"插件包含 {len(icons)} 个图标: {', '.join(icons)}")
    
    return {"status": "success"}

图标API完整参考

方法 参数 返回值 说明
get_icons_dir() 无 str 获取插件图标资源目录路径
has_icon(icon_name) icon_name: str bool 检查插件是否包含某个图标(自动尝试 .ico/.png/.svg 后缀)
get_icon_path(icon_name) icon_name: str str or None 获取图标完整路径(自动尝试后缀)
list_icons() 无 list[str] 列出插件所有图标文件
load_icon(icon_name) icon_name: str QIcon or None 加载图标为 QIcon 对象

图标安装后的目录结构

安装包含图标的插件后,目录结构如下:

plugins/
├── 我的插件.arcx              # 插件主文件
├── data/
│   └── 我的插件/
│       ├── config.json         # 插件配置
│       ├── install_record.json # 安装记录
│       ├── libs/               # 第三方依赖库(如有)
│       └── icons/              # ⭐ 图标资源目录
│           ├── my_icon.ico
│           ├── my_button.png
│           └── custom_logo.svg
└── logs/
    └── 我的插件.log            # 插件日志

图标预览方式

方式1:文件管理器预览

直接在文件管理器中打开 icons/ 目录,Windows 支持 .ico 和 .png 的缩略图预览。

方式2:浏览器预览SVG

将 SVG 文件拖入浏览器即可预览。

方式3:在插件中动态预览

def build_page_icon_preview(context, parent=None):
    from PyQt5.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, 
                                   QLabel, QListWidget, QListWidgetItem)
    from PyQt5.QtGui import QPixmap, QIcon
    from PyQt5.QtCore import Qt
    
    page = QWidget()
    layout = QHBoxLayout(page)
    
    # 左侧:图标列表
    icon_list = QListWidget()
    icons = context.list_icons()
    for icon_name in icons:
        item = QListWidgetItem(icon_name)
        icon_path = context.get_icon_path(icon_name.rsplit('.', 1)[0])
        if icon_path:
            item.setIcon(QIcon(icon_path))
        icon_list.addItem(item)
    layout.addWidget(icon_list, 1)
    
    # 右侧:预览区域
    preview_layout = QVBoxLayout()
    preview_label = QLabel("选择图标查看预览")
    preview_label.setAlignment(Qt.AlignCenter)
    preview_label.setMinimumSize(200, 200)
    preview_label.setStyleSheet("border: 1px solid #ddd; border-radius: 8px;")
    preview_layout.addWidget(preview_label)
    
    def on_icon_changed(current, previous):
        if current:
            icon_name = current.text()
            icon_path = context.get_icon_path(icon_name.rsplit('.', 1)[0])
            if icon_path and icon_path.endswith(('.png', '.ico')):
                pixmap = QPixmap(icon_path)
                preview_label.setPixmap(pixmap.scaled(180, 180, Qt.KeepAspectRatio))
            else:
                preview_label.setText(f"SVG图标: {icon_name}\n(需在浏览器中预览)")
    
    icon_list.currentItemChanged.connect(on_icon_changed)
    layout.addLayout(preview_layout, 1)
    
    return page

图标使用最佳实践

  1. 优先使用系统内置图标:184个内置图标覆盖了绝大多数场景,无需额外打包
  2. SVG图标优先:SVG格式体积小、可缩放、支持主题色自适应
  3. 图标命名规范:使用小写字母、数字、下划线,避免中文和特殊字符
  4. 多尺寸图标:.ico 格式建议包含 16x16、32x32、48x48、256x256 多个尺寸
  5. 图标体积控制:单个图标建议不超过 100KB,避免插件包过大

常见问题

Q: 为什么我添加的图标在插件中找不到? A: 请检查:

  1. 图标是否在打包工具的"图标资源"标签页中添加
  2. 图标文件名是否正确(区分大小写)
  3. 是否使用了 context.has_icon() 检查
  4. 重新安装插件后是否重启了主程序

Q: 系统内置图标可以修改吗? A: 不建议直接修改系统内置图标,因为软件更新时可能会被覆盖。如果需要自定义图标,请在插件中自带。

Q: 图标支持哪些格式? A: 支持 .ico(Windows图标)、.png(位图)、.svg(矢量图)三种格式。推荐使用 SVG 格式。


🔐 插件权限系统

权限列表

权限 说明
read_students 读取学生数据
modify_students 修改学生数据
trigger_draw 触发抽取
modify_draw 修改抽取行为
custom_animation 自定义动画
read_config 读取配置
modify_config 修改配置
file_system 文件系统访问
network 网络访问
execute_process 执行外部进程
ui_modification 修改UI
system_control 系统控制(重启、退出)

在meta中声明权限

{
  "meta": {
    "name": "我的插件",
    "permissions": ["read_config", "trigger_draw", "network"]
  }
}

权限检查(插件内部)

def main(context):
    # 检查是否有权限
    if context.has_permission("network"):
        context.log("有网络访问权限")
    else:
        context.log_warning("没有网络访问权限")
    
    # 获取所有权限
    permissions = context.get_permissions()
    context.log(f"当前权限: {permissions}")
    
    return {"status": "success"}

🐛 调试与日志

日志级别

def main(context):
    context.log("默认级别日志")
    context.log_info("信息日志")
    context.log_warning("警告日志")
    context.log_error("错误日志")
    context.log_debug("调试日志")
    
    # 带异常信息的日志
    try:
        1 / 0
    except Exception as e:
        context.log_error(f"发生错误: {e}")
        context.log_exception(e)  # 记录完整堆栈
    
    return {"status": "success"}

查看日志

日志文件位置:plugins/logs/插件名称.log

def main(context):
    # 获取日志内容
    logs = context.get_logs(limit=100)
    for log in logs:
        print(log)
    
    # 获取日志文件路径
    log_path = context.get_log_file_path()
    context.log(f"日志文件: {log_path}")
    
    # 清空日志
    context.clear_logs()
    
    return {"status": "success"}

调试技巧

  1. 使用 context.log() 输出调试信息
  2. 使用 try-except 捕获异常
  3. 使用 context.show_toast() 显示实时状态
  4. 使用 context.show_message() 显示详细信息
  5. 在设置页面添加调试按钮
  6. 查看 plugins/logs/ 目录下的日志文件

✨ 最佳实践

1. 代码结构

# 好的结构
def main(context):
    """插件入口"""
    setup_ui(context)
    setup_events(context)
    return {"status": "success"}

def setup_ui(context):
    """设置UI"""
    pass

def setup_events(context):
    """设置事件"""
    pass

def build_page_settings(context, parent=None):
    """构建设置页面"""
    pass

2. 异常处理

# 好的异常处理
def main(context):
    try:
        # 初始化代码
        pass
    except Exception as e:
        context.log_error(f"插件初始化失败: {e}")
        return {"status": "error", "message": str(e)}
    return {"status": "success"}

# 事件处理器也要有异常处理
def on_draw_finish(event_data):
    try:
        # 处理代码
        pass
    except Exception as e:
        context.log_error(f"事件处理失败: {e}")

3. 配置管理

# 使用配置而不是硬编码
def main(context):
    # 读取配置,带默认值
    duration = context.get_config("animation_duration", 3.0)
    color = context.get_config("color", "#0067c0")
    enabled = context.get_config("enabled", True)
    
    # 验证配置
    if duration < 0.5:
        context.log_warning("动画时长太短,使用默认值3.0")
        duration = 3.0
        context.set_config("animation_duration", 3.0)
    
    return {"status": "success"}

4. 性能优化

# 避免在paintEvent中做复杂计算
class MyWidget(QWidget):
    def __init__(self):
        super().__init__()
        self._precompute_frames()  # 预计算
    
    def _precompute_frames(self):
        """预计算动画帧"""
        self.frames = []
        for i in range(60):
            self.frames.append(self._compute_frame(i))
    
    def paintEvent(self, event):
        # 直接使用预计算结果
        painter = QPainter(self)
        frame = self.frames[self.current_frame]
        painter.drawPath(frame)

5. 内存管理

# 及时清理不需要的对象
def main(context):
    timer = QTimer()
    
    def cleanup():
        """清理资源"""
        timer.stop()
        # 删除大对象
    
    # 插件禁用时清理
    def on_disable():
        cleanup()
    
    context.register_event_handler("plugin_disable", lambda e: on_disable())
    
    return {"status": "success"}

❓ 常见问题

Q1: 插件可以执行任意Python代码吗?

A: 是的,插件代码通过 exec() 执行,可以使用任何Python库和功能。但请注意:

  • 插件崩溃可能影响主程序
  • 建议做好异常处理
  • 不要执行危险操作(删除系统文件等)

Q2: 如何完全替换默认的抽取动画?

A: 可以通过获取主窗口引用,替换 _start_draw 方法:

main_window = context.get_main_window()
original_start = main_window._start_draw

def custom_start():
    # 你的自定义动画逻辑
    pass

main_window._start_draw = custom_start

Q3: 插件之间如何通信?

A: 使用事件系统:

# 插件A发送事件
context.emit_event("plugin_a:message", {"data": "hello"})

# 插件B监听事件
context.register_event_handler("plugin_a:message", handler)

Q4: 如何在插件中创建网络服务器?

A: 使用Python标准库的 http.server 或第三方库 flask、websockets 等,在独立线程中运行。

Q5: 插件可以修改主程序的配置吗?

A: 可以,使用 context.set_app_config(section, key, value)。但某些配置修改后需要重启才能生效。

Q6: 如何调试插件?

A:

  1. 使用 context.log() 输出日志
  2. 查看 plugins/logs/插件名称.log
  3. 使用 context.show_toast() 显示实时状态
  4. 在设置页面添加调试按钮

Q7: 插件可以访问主程序的所有对象吗?

A: 可以,使用 context.get_main_window() 获取主窗口引用,然后可以访问所有属性和方法。但这是高级用法,建议优先使用官方API。

Q8: 如何实现插件的热重载?

A: 当前版本需要重启软件才能加载新插件。可以在设置中点击"重新加载插件"按钮。

Q9: 插件文件太大怎么办?

A: .arcx 文件本质是JSON,代码会被序列化为字符串。如果代码太长,可以:

  • 将代码分成多个模块,在插件中动态导入
  • 使用插件数据目录存储额外文件
  • 优化代码,去除不必要的空格和注释

Q10: 如何保护插件代码不被查看?

A: 当前版本的.arcx文件是明文JSON,可以直接查看代码。如果需要保护代码,可以:

  • 使用代码混淆工具
  • 将核心逻辑编译为.pyd文件
  • 注意:这会增加复杂度,且可能影响兼容性

📚 更多资源

  • 常量框架: plugin_constants.py - 所有颜色、参数、配置键常量
  • 示例插件: plugins/示例插件.arcx - 展示各种API用法
  • ClassIsland同步插件: plugins/ClassIsland同步.arcx - 网络通信示例
  • 打包工具: arcx_packager.py - 可视化插件打包工具
  • 主程序源码: main_qt.py - 查看主程序实现细节

文档版本: V6.8
最后更新: 2026-09-12