镜像自地址
https://github.com/a13zhy/a13rollcall.git
已同步 2026-10-20 19:37:47 +08:00
2399 行
69 KiB
Markdown
2399 行
69 KiB
Markdown
# 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*
|