# 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_""" 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_` 函数 - `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, ('', 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*