69 KiB
A13课堂点名系统 - 插件开发完全指南
📋 目录
- 插件系统架构
- 快速开始
- 插件文件格式
- 插件API完整参考
- 常量与配置框架
- 事件系统
- 开发复杂抽取动画插件
- 执行外部程序与文件操作
- 网络广播与通信
- 自定义UI组件
- UI注入:在主程序任何位置添加按钮 ⭐
- 插件图标资源:使用和打包图标 ⭐ 新增
- 插件权限系统
- 调试与日志
- 最佳实践
- 常见问题
🏗️ 插件系统架构
核心概念
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
使用打包工具
- 运行
python arcx_packager.py - 填写元数据(名称、版本、作者)
- 在"代码编辑"标签页粘贴上面的代码
- 在"页面配置"标签页添加 settings 页面
- 点击"📦 打包成 .arcx"
- 将生成的 .arcx 文件放到 plugins 目录
- 重启软件
📄 插件文件格式
.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)
🎬 开发复杂抽取动画插件
核心思路
抽取动画插件的核心是:
- 监听
draw_start事件,在抽取开始时显示自定义动画 - 监听
draw_finish事件,在抽取完成时显示结果动画 - 可以完全替换默认的抽取动画,或者在默认动画基础上叠加效果
- 使用 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()
动画开发要点
- 使用 QTimer 实现帧动画:16ms 间隔 ≈ 60fps
- 使用 QPropertyAnimation 实现属性动画:透明度、位置、大小、颜色
- 使用 QPainter 实现自定义绘制:粒子、轨迹、特效
- 使用 QGraphicsEffect 实现视觉效果:模糊、阴影、颜色变化
- 监听事件实现触发:draw_start、draw_finish 等事件
- 注意性能:避免在 paintEvent 中做复杂计算,预计算动画帧
- 注意内存泄漏:动画结束后停止 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()
注意事项
- 及时清理:插件禁用时必须调用
injector.cleanup(),否则按钮会残留 - 按钮查找:
find_button_by_text()按文字精确匹配,如果按钮文字有变化会找不到 - 布局兼容:不是所有控件都有layout,添加前最好检查
- 样式覆盖:注入的按钮样式可能被主程序的QSS覆盖,必要时使用
setStyleSheet()强制设置 - 线程安全:UI操作必须在主线程执行,不要在子线程中直接操作UI
- 对象名称:如果要按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.icomy_button_icon.pngcustom_logo.svg
步骤2:在打包工具中添加图标
- 运行
python arcx_packager.py - 切换到"🎨 图标资源"标签页
- 点击"➕ 添加图标文件"
- 选择你的图标文件
- 继续打包流程
步骤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
图标使用最佳实践
- 优先使用系统内置图标:184个内置图标覆盖了绝大多数场景,无需额外打包
- SVG图标优先:SVG格式体积小、可缩放、支持主题色自适应
- 图标命名规范:使用小写字母、数字、下划线,避免中文和特殊字符
- 多尺寸图标:.ico 格式建议包含 16x16、32x32、48x48、256x256 多个尺寸
- 图标体积控制:单个图标建议不超过 100KB,避免插件包过大
常见问题
Q: 为什么我添加的图标在插件中找不到? A: 请检查:
- 图标是否在打包工具的"图标资源"标签页中添加
- 图标文件名是否正确(区分大小写)
- 是否使用了
context.has_icon()检查 - 重新安装插件后是否重启了主程序
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"}
调试技巧
- 使用 context.log() 输出调试信息
- 使用 try-except 捕获异常
- 使用 context.show_toast() 显示实时状态
- 使用 context.show_message() 显示详细信息
- 在设置页面添加调试按钮
- 查看 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:
- 使用
context.log()输出日志 - 查看
plugins/logs/插件名称.log - 使用
context.show_toast()显示实时状态 - 在设置页面添加调试按钮
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