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

2399 行
69 KiB
Markdown

此文件含有模棱两可的 Unicode 字符

此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。

# A13课堂点名系统 - 插件开发完全指南
## 📋 目录
1. [插件系统架构](#插件系统架构)
2. [快速开始](#快速开始)
3. [插件文件格式](#插件文件格式)
4. [插件API完整参考](#插件api完整参考)
5. [常量与配置框架](#常量与配置框架)
6. [事件系统](#事件系统)
7. [开发复杂抽取动画插件](#开发复杂抽取动画插件)
8. [执行外部程序与文件操作](#执行外部程序与文件操作)
9. [网络广播与通信](#网络广播与通信)
10. [自定义UI组件](#自定义ui组件)
11. **[UI注入:在主程序任何位置添加按钮](#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) 禁用回调
↓
插件卸载
```
---
## 🚀 快速开始
### 最小插件示例
```python
def main(context):
"""插件入口函数,必需"""
context.log("我的第一个插件启动了!")
return {"status": "success", "message": "插件已加载"}
```
### 带设置页面的插件
```python
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 文件结构
```json
{
"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
```python
# 获取所有学生列表
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
```python
# 触发抽取
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
```python
# 标记当前学生状态
context.mark_status("已背过")
# 状态可选: "已背过", "未背熟", "未背过"
# 撤销上次标记
context.undo_last_mark()
# 回退统计和积分
# 获取当前学生状态
status = context.get_current_status()
# 返回: "已背过" 或 "未标记"
# 获取所有状态统计
stats = context.get_all_stats()
# 返回: {"已背过": 10, "未背熟": 5, "未背过": 3, "未标记": 2}
```
### 4. UI交互API
```python
# 显示消息框
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
```python
# 获取插件配置
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
```python
# 获取插件数据目录
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
```python
# 输出日志
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
```python
# 注册事件处理器
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
```python
# 重启应用
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(直接访问主程序对象)
```python
# 获取主窗口对象(可以直接操作所有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()
```
---
## 🎨 常量与配置框架
### 导入常量框架
```python
# 插件中可以直接导入常量框架
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)
```
### 主题颜色
```python
# 浅色主题
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",
# ... 对应深色版本
}
```
### 动画参数
```python
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,
# ...
}
```
### 配置键常量
```python
# 常用配置键,避免硬编码字符串
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` | 自定义事件 | 自定义内容 |
### 事件使用示例
```python
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}")
```
### 自定义事件
```python
# 插件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:粒子爆炸效果
```python
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:名字滚动动画(完全替换默认动画)
```python
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翻转效果
```python
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,删除不需要的对象
---
## 💻 执行外部程序与文件操作
### 执行外部程序
```python
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"}
```
### 文件操作
```python
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服务器(接受广播)
```python
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广播
```python
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通信
```python
# 需要安装 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组件
### 自定义悬浮窗
```python
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"}
```
### 自定义仪表盘
```python
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。
```python
# 导入UI注入工具
from plugin_ui_injector import UIInjector
# 创建注入器
main_window = context.get_main_window()
injector = UIInjector(main_window)
```
### 快速开始:5种常用位置添加按钮
#### 1. 在顶部栏添加按钮
```python
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. 在底部状态栏添加按钮
```python
btn = injector.add_button_to_bottom_bar(
text="📊 统计",
callback=show_stats,
style="ghost",
width=90
)
```
#### 3. 在指定按钮旁边添加按钮
```python
# 找到"开始"按钮,在它右边添加
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. 创建可拖动的悬浮按钮
```python
floating_btn = injector.create_floating_button(
text="🎨 特效",
callback=toggle_effect,
x=20, y=100, # 初始位置
size=(70, 36), # 按钮大小
style="warning"
)
# 用户可以拖动这个按钮到任意位置
```
#### 5. 在任意布局中添加按钮
```python
# 先找到目标布局
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元素
```python
# 按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 |
#### 修改现有按钮
```python
# 修改按钮文字、回调、样式
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元素
```python
# 添加分隔线
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"
)
```
#### 清理资源
```python
# 插件禁用时清理所有注入的UI元素
def on_disable(context):
if hasattr(context, 'injector'):
context.injector.cleanup()
```
### 按钮样式
| 样式 | 效果 | 适用场景 |
|------|------|---------|
| `primary` | 蓝色背景,白色文字 | 主要操作 |
| `success` | 绿色背景,白色文字 | 成功/确认 |
| `warning` | 橙色背景,白色文字 | 警告/注意 |
| `danger` | 红色背景,白色文字 | 危险/删除 |
| `ghost` | 透明背景,边框 | 次要操作 |
| `small` | 小尺寸,灰色背景 | 工具栏小按钮 |
### 完整示例:多功能UI注入插件
```python
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()` 查看
### 调试技巧
```python
# 查看所有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文件路径
```python
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加载
```python
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:在插件代码中使用图标
```python
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:在插件中动态预览
```python
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中声明权限
```json
{
"meta": {
"name": "我的插件",
"permissions": ["read_config", "trigger_draw", "network"]
}
}
```
### 权限检查(插件内部)
```python
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"}
```
---
## 🐛 调试与日志
### 日志级别
```python
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`
```python
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. 代码结构
```python
# 好的结构
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. 异常处理
```python
# 好的异常处理
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. 配置管理
```python
# 使用配置而不是硬编码
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. 性能优化
```python
# 避免在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. 内存管理
```python
# 及时清理不需要的对象
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` 方法:
```python
main_window = context.get_main_window()
original_start = main_window._start_draw
def custom_start():
# 你的自定义动画逻辑
pass
main_window._start_draw = custom_start
```
### Q3: 插件之间如何通信?
A: 使用事件系统:
```python
# 插件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*