1
1
mirror of https://github.com/KenanZhu/AutoLibrary.git synced 2026-08-02 22:19:37 +08:00

Compare commits

...

61 Commits

Author SHA1 Message Date
github-actions[bot] e4d838a9d0 chore(release): v1.4.0 [auto release commit] 2026-07-14 02:19:40 +00:00
Kenan Zhu 70f78c2a6f chore: 从 KenanZhu/develop 合并到主分支 (#14)
feat:    公告栏系统、定时任务重构、检查更新与多项缺陷修复
2026-07-14 08:42:35 +08:00
KenanZhu e40e7767c7 style(settings): 移除导入主题后冗余的状态刷新调用 2026-07-10 11:41:43 +08:00
KenanZhu 1b6c170d6c style(ReserveFlow): 提取容器类的UI设置到setupUi方法 2026-07-10 10:07:38 +08:00
KenanZhu 30a7a8e996 fix(settings): 修复测试公告栏连接时 QThread 未等待导致闪退 2026-07-09 09:29:45 +08:00
KenanZhu d59ecc3090 feat(update): 新增检查更新功能与菜单栏公告入口 2026-07-09 09:06:58 +08:00
KenanZhu 7685f6a616 style(ReserveFlow): 为 TimeSelectDialog 添加类型注释 2026-07-09 08:16:41 +08:00
KenanZhu ed8dec70a6 fix: 恢复 deleteLater() 替换错误的 delete() 调用 2026-06-26 11:16:09 +08:00
KenanZhu dfa9ae1a0c fix(config): ConfigManager.get() 返回深拷贝,消除跨模块字典原地修改 2026-06-26 11:16:09 +08:00
KenanZhu 06740776a9 fix(timer,bulletin): 修复定时任务与公告栏 5 项交叉审查问题 2026-06-26 11:16:08 +08:00
KenanZhu c8f9ae752f fix(bulletin): 修复托盘消息点击冲突,增加公告入口,移除托盘公告选项 2026-06-26 10:18:15 +08:00
KenanZhu 89c1e33257 chore: 更新官网手册链接 2026-06-26 10:00:02 +08:00
KenanZhu 862f86827a refactor(timer): ALMainWindow 集成 ALTimerTaskPoller 消除内联轮询 2026-06-26 09:58:39 +08:00
KenanZhu ed98623cda feat(timer): 新增 ALTimerTaskPoller 定时任务轮询器 2026-06-26 09:56:24 +08:00
KenanZhu ab51c72a11 style: 重命名 ALMainWorkers 为 ALMainWorker 遵循单数命名 2026-06-26 09:18:48 +08:00
KenanZhu 2fe4584129 refactor(bulletin): 提取 ALBulletinFetchWorker 到独立文件 2026-06-26 09:16:45 +08:00
KenanZhu 92c5fa0dfb style: 统一代码格式与导入优化 2026-06-26 09:16:23 +08:00
KenanZhu 05ad9ba0b3 chore: 合并 feature/bulletin-system 到 develop 2026-06-25 13:26:12 +08:00
KenanZhu 0069d5274b style(gui): 统一非Qt控件变量为蛇形命名 2026-06-25 13:17:36 +08:00
KenanZhu 6a9c662287 fix(bulletin): 修复状态标签圆圈不可见和定时器悬空引用问题 2026-06-25 11:32:58 +08:00
KenanZhu d7fff16991 fix(driver): 驱动下载完成后更新下拉条目安装状态后缀 2026-06-25 10:49:31 +08:00
KenanZhu db345f9efc chore: 合并 refactor/webdriver-naming 到 develop 2026-06-25 10:27:33 +08:00
KenanZhu 133433f2d8 refactor(driver): WebDriver 子系统命名规范化与代码整理 2026-06-25 10:19:32 +08:00
KenanZhu 6f32923865 fix(bulletin): 修复公告栏系统多项逻辑缺陷与线程安全隐患
Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-24 12:17:26 +08:00
KenanZhu a716e5b945 feat(bulletin): 公告栏系统完整实现
Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-24 12:15:28 +08:00
KenanZhu 588718f9c5 docs(readme): 更新 README.md 内容 2026-06-22 10:36:53 +08:00
KenanZhu 0ba76babc6 docs(readme): 优化自定义主题描述,补充主题市场链接与注意事项表述修正 2026-06-21 10:19:12 +08:00
KenanZhu dc7e5d8cd8 docs(readme): 重新分类功能列表,补充自定义主题与 macOS 平台支持说明 2026-06-21 09:54:41 +08:00
Kenan Zhu 818acd4efc feat(theme): 全局设置窗口、.altheme 主题系统与 GUI 重构 (#13)
feat(theme): 全局设置窗口、.altheme 主题系统与 GUI 重构
2026-06-21 09:32:36 +08:00
Kenan Zhu 308a1dfcf3 Merge branch 'main' into feature/global-settings 2026-06-21 09:31:34 +08:00
KenanZhu c250fa4a6e refactor(theme): 将重复的主题逻辑下沉至 ThemeUtils,消除 validateTheme 职责过重 2026-06-19 11:21:50 +08:00
KenanZhu 8f8e3e4ba7 refactor(gui): 自定义主题控件及函数重命名,统一 CustomTheme 前缀 2026-06-19 10:22:36 +08:00
KenanZhu 88a74a7a47 refactor(gui): 提取窗口居中逻辑至 CenterOnParentMixin,消除5处重复 showEvent 2026-06-19 10:20:35 +08:00
KenanZhu 5552af1345 refactor(gui): 消除确认按钮重复逻辑,重置按钮不再提前应用主题 2026-06-19 09:36:18 +08:00
KenanZhu f9175371dc feat(gui): +/- 按钮文本替换为 QtAwesome 图标,fa5s 统一升级为 fa6s
- ALSettingsWidget: BrowseQssButton/RemoveThemeButton 的 + / - 文本改为 fa6s.plus/fa6s.minus 图标
- ALAutoScriptEditDialog: ZoomInBtn/ZoomOutBtn 的全角 +/- 改为 fa6s.plus/fa6s.minus 图标
- 其余图标同步从 fa5s 升级至 fa6s (Font Awesome 6)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-17 08:15:03 +08:00
KenanZhu 8e1b28f3fe fix: requirements.txt 编码从 UTF-16 LE 转为 UTF-8,移除 8 个多余依赖包
移除的包: altgraph, mpmath, pefile, pyinstaller-hooks-contrib, pywin32-ctypes, setuptools, sympy, websocket-client
(这些均为传递依赖,pip 会根据直接依赖自动解析安装)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-16 22:19:05 +08:00
KenanZhu 57f1cfb3f2 fix(theme): 修复死锁、冗余读取、空作者字符串等交叉审查问题
- ThemeManager 拆分 _removeThemeFile 无锁版本, 消除 importTheme 持锁
  时调用 removeTheme 导致的死锁
- validateTheme 增加 check_qss 参数, listThemes 跳过 QSS 读取
- validateTheme 拒绝空/空白作者字符串, 避免 info.json 与文件名不一致
- 统一默认作者为 "未知作者"
- ALSettingsWidget.ui 增加删除按钮 [-], 浏览按钮改为 [+]
- ALSettingsWidget 实现 onRemoveThemeButtonClicked 删除逻辑
2026-06-16 19:37:09 +08:00
KenanZhu 007b4dc2ef fix(theme): 修复同名主题无法区分作者及导入链路边界问题
- 新增 ThemeUtils.validateTheme 和 readThemeQss 集中校验与读取逻辑
- ThemeManager.importTheme 通过 _resolveDestPath 处理同名主题:
  不同作者自动命名为 {主题名}_{作者名}.altheme, 首次导入保持原名
- ThemeManager.listThemes 返回 file 字段以便 UI 层定位文件
- ALSettingsWidget 全线改用 file 标识符, 组合框按作者消歧义显示
- 移除 applyTheme 中的临时目录解压, 改用 readThemeQss 直接读取
2026-06-16 18:37:47 +08:00
KenanZhu 67f297b434 revert(ALConfigWidget.ui): 撤回上次提交中的 ui 文件的启动默认页面 2026-06-07 12:53:02 +08:00
KenanZhu 86f0761eed refactor(theme): 优化 LightLake 与 BlueForest 主题显示样式 2026-06-07 12:50:32 +08:00
KenanZhu 79e5b43498 fix(theme): 修复主题管理系统逻辑缺陷
- removeTheme() 删除当前活动主题后从 ConfigManager 读取已保存的主题偏好作为回退方案,不再硬编码 'system'
- saveAndApply() 在调用 _applyCustomTheme 前先 setActiveStyle(style),确保主题应用时使用最新选择的内置样式
- _applyCustomTheme() 返回 bool 表示成败,失败时调用方清除配置中的 custom_theme 避免下次启动循环失败
- importTheme() 增加 self.__lock 保护,消除 TOCTOU 竞态条件
- ThemeManager 新增 CfgKey 导入以支持 removeTheme 读取配置

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 18:58:07 +08:00
KenanZhu b56d2c203e fix(theme): 修复 QSS 主题样式缺陷
- 新增 QSpinBox/QDateEdit/QTimeEdit 的 ::up-button/::down-button/::up-arrow/::down-arrow 子控件样式,修复 spin button 箭头在 QSS 部分覆盖后退化渲染的问题
- 新增 QTreeWidget::indicator / QListWidget::indicator / QTableWidget::indicator 全状态样式,修复树控件中 CheckState 复选框因缺失 ::indicator 子控件而无法区分勾选状态的视觉 bug
- 指示器勾选态颜色与主题色保持一致(BlueForest: #2dd4bf, LightLake: #0ea58a)
- 同步深浅主题差异:移除 :hover:!selected 规则、统一 HeaderView padding、spin button 宽度及属性顺序
- up-arrow 注释 image:none 以还原原生箭头渲染

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 18:57:56 +08:00
KenanZhu 44dbde3355 fix(theme): 主题系统交叉审查缺陷修复
启动恢复:
- _initializeAppearance 自定义主题加载失败时调用 clearTheme 回退配色方案

列表校验:
- listThemes 同时校验 info.json 和 theme.qss 完整性
- 损坏的主题文件记录 LogManager 警告并跳过
- 按 (名称, 作者) 去重,同一作者同名主题仅保留一个

导入保护:
- importTheme 新增 (名称, 作者) 冲突检查
- applyTheme 缺少 theme.qss 时抛出明确 ValueError

状态一致性:
- saveAndApply 在 syncRadioFromNeedTheme 后重新采集 THEME 再保存
- __original_theme / __original_custom_theme 随每次 Apply 同步更新
- Reset 按钮恢复组合框到原始位置并刷新状态标签

代码质量:
- 提取 _colorSchemeFor 静态方法消除 applyTheme/clearTheme 中的重复映射
- 移除未使用的 _applyTheme 死代码
- _active_style_name 默认值从 '' 改为 'Fusion'
- 日志调用统一使用 LogManager
- _applyCustomTheme 异常时通过 LogManager 记录详细错误

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 00:46:05 +08:00
KenanZhu 62f8ec3d91 refactor(theme): 将重复的主题逻辑下沉至 ThemeManager
ThemeManager 新增:
- clearTheme(theme) — 清除 QSS 并应用指定色调
- applyThemeOrClear(name, fallback) — 应用或回退的封装
- _applyColorScheme(theme) — Qt ColorScheme 设置的统一入口
- themeToReadable(need_theme) — 静态工具方法

ALSettingsWidget 移除:
- _clearCustomTheme → 改用 themeInstance().clearTheme()
- _applyCustomTheme → 改用 themeInstance().applyThemeOrClear()
- _themeToReadable → 改用 ThemeManager.themeToReadable()

ALSettingsWidget 仅保留 _applyTheme(含 setStyle 逻辑,供 AppInitializer 使用)
2026-05-30 22:51:05 +08:00
KenanZhu 2d77cbec79 fix(gui): 修复保存主题时色调模式与主题 need_theme 不一致的问题
- 将 THEME 配置写入移至 syncRadioFromNeedTheme 之后
- 确保保存的色调模式值与主题实际兼容值一致,避免重启后错配
2026-05-30 22:30:31 +08:00
KenanZhu 10d731518a fix(gui): ThemeInfoLabel 添加细边框
- 使用 palette(mid) 自适应当前主题色调
2026-05-30 22:20:29 +08:00
KenanZhu 9fdb6f7652 fix(ui): 修复 .ui XML 标签嵌套错误
- AppearancePageSpacer 从 CustomQssGroupBoxLayout 内移出,恢复为 AppearancePageLayout 的直接子项
- 补全缺失的 AppearancePageLayout 与 QScrollArea 闭合标签
- 修复 alignment 属性的 <set> 标签未正确闭合
2026-05-30 22:19:04 +08:00
KenanZhu ef903ee817 fix(gui): ThemeInfoLabel 作者与简介缩小字号
- 用 <span style='font-size:smaller;'> 包裹作者和简介行
- 主题名称保持原有字号
2026-05-30 22:07:48 +08:00
KenanZhu d6e8eef8c8 fix(gui): 恢复固定高度 420,滚动区域内添加弹性 Spacer 维持控件间距 2026-05-30 22:05:21 +08:00
KenanZhu e893752c25 feat(gui): 为右侧配置面板添加滚动区域,放宽宽度限制
- AppearancePageLayout 包裹在 QScrollArea 内,内容溢出时可滚动
- 移除垂直 Spacer(滚动区域内不需要)
- 最小宽度 400→480,最大宽度 500→580,最大高度 420→不限
- .ui 与 Ui_ALSettingsWidget.py 同步更新
2026-05-30 22:02:43 +08:00
KenanZhu 1cfd7382be fix(gui): 修复 ThemeInfoLabel 富文本换行与布局
- 设置 textFormat=RichText,\n 替换为 <br> 实现正确换行
- .ui 添加 minimumHeight=60、alignment=AlignTop 防止多行文本被裁剪
2026-05-30 21:58:40 +08:00
KenanZhu 1d9e41ab86 fix(gui): 重置按钮触发默认主题切换
- 重置按钮始终切到"默认"(index 0),清除自定义 QSS
- 调用 _clearCustomTheme 实际清除样式并应用原始色调模式
2026-05-30 21:56:19 +08:00
KenanZhu 645f07b4d2 refactor(gui): currentTextChanged → currentIndexChanged,ResetQssButton → ResetThemeButton
- ThemeComboBox 改用 currentIndexChanged(int) 信号
- ResetQssButton 重命名为 ResetThemeButton(.ui/.py 同步)
- 重置按钮行为改为恢复至原始主题并立即应用(saveAndApply)
2026-05-30 21:42:18 +08:00
KenanZhu 732f104c5c refactor(gui): 重命名 _applyThemeByName → _applyCustomTheme,_clearQss → _clearCustomTheme
- _clearCustomTheme(theme) 清除 QSS 后切换到指定默认主题
- _applyCustomTheme(name, fallback_theme) 应用自定义主题,失败时回退到 fallback_theme
- saveAndApply 调用处传入当前 radio 主题作为 fallback
2026-05-30 21:37:15 +08:00
KenanZhu a2bc1881bc feat(gui): 新增主题信息标签,移除 custom_qss 兼容,优化重置按钮
- .ui 新增 ThemeInfoLabel 用于展示主题作者和简介
- ALSettingsWidget 新增 _updateThemeInfo 方法,ComboBox 切换时更新信息
- 移除 _loadQss/_applyQss 模块函数及所有 CUSTOM_QSS 引用
- AppInitializer 移除 _applyQss 导入和回退逻辑
- ConfigProvider/ConfigManager 移除 custom_qss 键
- 纯 QSS 导入通过 ThemeManager 打包为 .altheme 统一管理
2026-05-30 21:33:59 +08:00
KenanZhu c1004ed2bc refactor(gui): 主题切换改为显式确认,移除 ComboBox 即时响应
- 移除 ThemeComboBox.currentTextChanged 信号连接
- 主题仅通过"应用"/"确认"按钮显式应用
- 导入主题不再自动应用,仅选中并更新列表
- 取消时恢复原始主题与原 ComboBox 选中状态
- collectSettings 将"默认"统一转为空字符串
- saveAndApply 新增 _syncRadioFromNeedTheme 同步色调单选
2026-05-30 21:23:13 +08:00
KenanZhu 38489191f5 refactor(gui): 将主题控件样式修改迁移至 .ui 文件
- ThemeComboBox 移入 ALEttingsWidget.ui,由 setupUi 创建
- QssPathEdit/ApplyQssButton 的隐藏改为 .ui 的 visible=false
- BrowseQssButton/ResetQssButton/CustomQssHintLabel 文本直接在 .ui 中定义
- ALSettingsWidget.modifyUi 移除冗余的程序化 UI 修改
- 移除 ThemeStatusLabel 别名,直接使用 QssStatusLabel
2026-05-30 21:11:01 +08:00
KenanZhu 35253dadbb feat(theme): 引入 .altheme 主题文件格式与主题管理系统
- 新增 .altheme 文件格式(zip 压缩包包含 info.json 与 theme.qss)
- 新增 utils/ThemeUtils.py:主题文件打包/解包/读取工具函数
- 新增 managers/theme/ThemeManager:主题目录管理器,支持导入/列举/删除/应用
- 新增 LightLake 浅色主题 QSS 文件
- 新增 CfgKey.GLOBAL.APPEARANCE.CUSTOM_THEME 配置键
- 配置模板新增 custom_theme 字段
- ALSettingsWidget 接入 ThemeManager,替换裸 QSS 路径模式
- AppInitializer 启动时恢复自定义主题状态
- Zip Slip 防护与线程安全保护
2026-05-30 21:01:18 +08:00
KenanZhu c0b6e0899c fix(theme): 优化 BlueForest 按钮样式
- QPushButton 添加 min-width: 80px, min-height: 25px 统一按钮默认大小
- 移除无效的 QDialogButtonBox 选择器,对话框按钮直接继承 QPushButton
- QPushButton padding 调整为 4px 12px,兼顾各场景按钮尺寸

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 19:54:25 +08:00
KenanZhu 9c1772b186 feat(theme): 新增 BlueForest 官方深色主题样式
- 新增 BlueForest.qss,基于 Fusion 控件规格的纯配色深色主题
- 深蓝底色 + 亮青绿强调色,控件尺寸与 Fusion 风格保持一致
- 全局统一 selection-background-color 为 #2dd4bf
- 背景色分层:页面 > 头部栏 > 交互控件 > 弹出层 > 输入区
- Border 属性统一拆分为 style/color/width 三段式
- AppInitializer / ALSettingsWidget 配合主题加载

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 19:27:10 +08:00
KenanZhu 05b93799d4 feat(gui): 引入全局设置窗口 ALSettingsWidget
- 新增 ALSettingsWidget,左侧导航+右侧内容的设置面板
- 合并外观主题、界面风格、自定义QSS为单页布局
- 深浅色主题通过 Qt.ColorScheme 官方 API 实现
- 界面风格变更检测基于当前运行的 QStyle 比对
- 使用 qtawesome 提供矢量导航图标
- 风格变更时弹出重启确认对话框
- ALAutoScriptEditDialog 中重置/复制按钮改用 qtawesome 图标
- 外观初始化迁移至 boot.AppInitializer
- 菜单栏新增工具→全局设置入口
- GLOBAL 配置扩展 appearance 段(theme/style/custom_qss)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 17:56:21 +08:00
49 changed files with 5923 additions and 1017 deletions
+18 -9
View File
@@ -17,13 +17,22 @@
### 功能 ### 功能
#### 核心特性
1. 自动预约 - 支持自动预约 1. 自动预约 - 支持自动预约
2. 自动续约 - 支持自动续约 2. 自动续约 - 支持自动续约
3. 自动签到 - 支持自动签到 3. 自动签到 - 支持自动签到
4. 远程签到 - 支持远程签到,无需在图书馆网络环境下即可签到 4. 远程签到 - 支持远程签到,无需在图书馆网络环境下即可签到
5. 批量操作 - 支持同时预约多个用户,可以指定当前需要跳过的用户,并将用户分成多个组
6. 定时任务 - 使用内置定时任务管理,添加定时任务,指定时间后按当前预约信息自动运行,支持设置重复任务 #### 辅助特性
7. 驱动管理 - 内置浏览器驱动自动管理,支持自动检测浏览器版本并下载对应驱动,无需手动下载
1. 批量操作 - 支持同时预约多个用户,可以指定当前需要跳过的用户,并将用户分成多个组
2. 定时任务 - 使用内置定时任务管理,添加定时任务,指定时间后按当前预约信息自动运行,支持设置重复任务
3. 驱动管理 - 内置浏览器驱动自动管理,支持自动检测浏览器版本并下载对应驱动,无需手动下载
4. 自定义主题 - 支持深浅色主题切换,多款应用样式切换,可导入 .altheme 自定义主题一键换肤
> [!TIP]
> 前往 [主题市场](https://www.autolibrary.kenanzhu.com/marketplace) 获取和分享更多自定义主题。
*具体操作方法和注意事项请访问我们的 [帮助手册](https://manuals.autolibrary.kenanzhu.com)* *具体操作方法和注意事项请访问我们的 [帮助手册](https://manuals.autolibrary.kenanzhu.com)*
@@ -39,7 +48,7 @@
#### 平台支持 & 编译步骤 #### 平台支持 & 编译步骤
本工具目前支持 Windows 平台,由于使用 PySide6 库开发,理论上是可以自行编译并在 Linux 和 macOS 上运行,这里提供简单的编译步骤: 本工具目前支持 Windows 和 macOSApple Silicon平台,由于使用 PySide6 库开发,理论上是可以自行编译并在 Linux 和旧架构 macOS 上运行,这里提供简单的编译步骤:
1. 确保系统安装了 Python 3.13 版本 (推荐,过低或高版本会导致兼容问题)。 1. 确保系统安装了 Python 3.13 版本 (推荐,过低或高版本会导致兼容问题)。
2. 安装所有依赖库,命令为 `pip install -r requirements.txt` (建议在虚拟环境下操作)。 2. 安装所有依赖库,命令为 `pip install -r requirements.txt` (建议在虚拟环境下操作)。
@@ -71,7 +80,7 @@ def classification(self, img: bytes):
只要确保处于校园网网络环境内,工具都是可以正常运行的。操作处理速度基本上取决于校园网的网络环境,一般情况下在 3-4 秒(不考虑硬件差异)左右即可完成一个用户的操作,完全满足正常使用需求。 只要确保处于校园网网络环境内,工具都是可以正常运行的。操作处理速度基本上取决于校园网的网络环境,一般情况下在 3-4 秒(不考虑硬件差异)左右即可完成一个用户的操作,完全满足正常使用需求。
> [!NOTE] > [!NOTE]
> 工具仅作为正常的预约,签到和续约的图书馆辅助工具,请勿干扰图书馆的正常运行(如故意预约多个座位,或同时预约大量的用户等,对此影响图书馆正常运行本工具概不负责,请在善用工具方便自己的情况下尽量不用影响其他同学的使用 > 工具仅作为正常的预约,签到和续约的图书馆辅助工具,请勿干扰图书馆的正常运行(如故意预约多个座位,或同时预约大量的用户等)由此影响图书馆正常运行的情况本工具概不负责,请在善用工具方便自己的情况下尽量不用影响其他同学的使用。
#### 关于批量操作 #### 关于批量操作
@@ -97,13 +106,13 @@ def classification(self, img: bytes):
#### 会有手机端的版本吗? #### 会有手机端的版本吗?
暂时没有考虑,而且也没有足够的时间和能力开发多平台的版本并测试维护,所以暂时只提供 Windows 版本。 暂时没有考虑,而且也没有足够的时间和能力开发多平台的版本并测试维护,所以暂时只提供 Windows 和 macOSApple Silicon版本。
#### 后续会有哪些功能? #### 后续会有哪些功能?
当前版本的功能对于正常使用已经足够,不过后续会着重完善预约时的使用体验,暂时有以下构想: 当前版本的功能对于正常使用已经足够,不过后续会着重完善预约时的使用体验,暂时有以下构想:
- 引入交互预约面板功能,预约时直接在座位分布图中选择可用座位,并按用户分配,无需事先配置预约信息。 - ~~引入交互预约面板功能,预约时直接在座位分布图中选择可用座位,并按用户分配,无需事先配置预约信息。~~ (不计划)
- ~~优化定时任务管理功能,用户可以在定时任务管理界面设置重复运行的定时任务,如每日预约、每周预约等。~~ (已完成) - ~~优化定时任务管理功能,用户可以在定时任务管理界面设置重复运行的定时任务,如每日预约、每周预约等。~~ (已完成)
- 软件的自动更新以及公告栏功能,用户可以自动更新最新版本并获取最新公告事项。 - 软件的自动更新以及公告栏功能,用户可以自动更新最新版本并获取最新公告事项。
@@ -111,8 +120,8 @@ def classification(self, img: bytes):
#### 其他功能建议? #### 其他功能建议?
如果你有其他功能建议,或者遇到了什么功能性,操作上的问题,欢迎提交 Issue 到本项目。 如果你有其他功能建议,或者遇到了什么功能性,操作上的问题,欢迎提交 [Issue](https://github.com/KenanZhu/AutoLibrary/issues) 到本项目。
如果你有足够的开发能力,欢迎为本项目提交 PR,也可以 Fork 本项目,根据自己的需求进行修改和完善。 如果你有足够的开发能力,欢迎为本项目提交 [PR](https://github.com/KenanZhu/AutoLibrary/pulls) ,也可以 Fork 本项目,根据自己的需求进行修改和完善。
### 联系我 ### 联系我
+2 -7
View File
@@ -7,33 +7,28 @@ flatbuffers==25.12.19
h11==0.16.0 h11==0.16.0
idna==3.11 idna==3.11
lupa==2.8 lupa==2.8
mpmath==1.3.0
numpy==2.4.3 numpy==2.4.3
onnxruntime==1.24.4 onnxruntime==1.24.4
outcome==1.3.0.post0 outcome==1.3.0.post0
packaging==26.0 packaging==26.0
pefile==2024.8.26
pillow==12.1.1 pillow==12.1.1
protobuf==7.34.0 protobuf==7.34.0
pybrowsers==1.3.2 pybrowsers==1.3.2
pycparser==3.0 pycparser==3.0
pyinstaller==6.19.0 pyinstaller==6.19.0
pyinstaller-hooks-contrib==2026.3
PySide6==6.10.2 PySide6==6.10.2
PySide6_Addons==6.10.2 PySide6_Addons==6.10.2
PySide6_Essentials==6.10.2 PySide6_Essentials==6.10.2
PySocks==1.7.1 PySocks==1.7.1
pywin32-ctypes==0.2.3 QtAwesome==1.4.2
QtPy==2.4.3
requests==2.32.5 requests==2.32.5
selenium==4.38.0 selenium==4.38.0
setuptools==82.0.1
shiboken6==6.10.2 shiboken6==6.10.2
sniffio==1.3.1 sniffio==1.3.1
sortedcontainers==2.4.0 sortedcontainers==2.4.0
sympy==1.14.0
trio==0.33.0 trio==0.33.0
trio-websocket==0.12.2 trio-websocket==0.12.2
typing_extensions==4.15.0 typing_extensions==4.15.0
urllib3==2.6.3 urllib3==2.6.3
websocket-client==1.9.0
wsproto==1.3.2 wsproto==1.3.2
-1
View File
@@ -24,7 +24,6 @@ def main():
translator = QTranslator() translator = QTranslator()
if translator.load(":/res/translators/qtbase_zh_CN.ts"): if translator.load(":/res/translators/qtbase_zh_CN.ts"):
app.installTranslator(translator) app.installTranslator(translator)
app.setStyle("Fusion")
app.setApplicationName("AutoLibrary") app.setApplicationName("AutoLibrary")
if not initializeApp(): if not initializeApp():
sys.exit(-1) sys.exit(-1)
+2 -2
View File
@@ -11,7 +11,7 @@ import logging
import queue import queue
import datetime import datetime
import managers.log.LogManager as LogManager from managers.log.LogManager import getLogger
class MsgBase: class MsgBase:
@@ -54,7 +54,7 @@ class MsgBase:
self._input_queue = input_queue self._input_queue = input_queue
self._output_queue = output_queue self._output_queue = output_queue
try: try:
self._logger = LogManager.getLogger(self._class_name) self._logger = getLogger(self._class_name)
except RuntimeError: except RuntimeError:
self._logger = None self._logger = None
+32 -2
View File
@@ -10,10 +10,16 @@ See the LICENSE file for details.
import os import os
from PySide6.QtCore import QStandardPaths, QDir from PySide6.QtCore import QStandardPaths, QDir
from PySide6.QtWidgets import QApplication
from managers.log.LogManager import instance as logInstance from interfaces.ConfigProvider import CfgKey
from managers.config.ConfigManager import instance as configInstance from managers.config.ConfigManager import instance as configInstance
from managers.driver.WebDriverManager import instance as webdriverInstance from managers.driver.WebDriverManager import instance as webdriverInstance
from managers.log.LogManager import instance as logInstance
from managers.theme.ThemeManager import(
setActiveStyle,
instance as themeInstance
)
def _initializeLogManager( def _initializeLogManager(
@@ -64,13 +70,36 @@ def _initializeWebDriverManager(
webdriverInstance(driver_dir) webdriverInstance(driver_dir)
return True return True
def _initializeAppearance(
):
logger = logInstance().getLogger("AppInitializer")
app = QApplication.instance()
if not app:
return
cfg = configInstance()
saved_style = cfg.get(CfgKey.GLOBAL.APPEARANCE.STYLE, "Fusion")
saved_theme = cfg.get(CfgKey.GLOBAL.APPEARANCE.THEME, "system")
saved_custom_theme = cfg.get(CfgKey.GLOBAL.APPEARANCE.CUSTOM_THEME, "")
app.setStyle(saved_style)
setActiveStyle(saved_style)
if saved_custom_theme:
try:
themeInstance().applyTheme(saved_custom_theme)
except Exception:
logger.warning("无法应用自定义主题 '%s',回退到默认外观", saved_custom_theme)
themeInstance().clearTheme(saved_theme)
return
themeInstance().clearTheme(saved_theme)
def initializeApp( def initializeApp(
) -> bool: ) -> bool:
""" """
Initialize the application components Initialize the application components
Order: Order:
LogManager -> ConfigManager -> WebDriverManager LogManager -> ConfigManager -> WebDriverManager -> Appearance
""" """
if not _initializeLogManager(): if not _initializeLogManager():
@@ -79,4 +108,5 @@ def initializeApp(
return False return False
if not _initializeWebDriverManager(): if not _initializeWebDriverManager():
return False return False
_initializeAppearance()
return True return True
+3 -5
View File
@@ -55,9 +55,9 @@ class ALAboutDialog(QDialog, Ui_ALAboutDialog):
AboutBrowser.setOpenExternalLinks(True) AboutBrowser.setOpenExternalLinks(True)
AboutBrowser.setLineWrapMode(QTextBrowser.LineWrapMode.NoWrap) AboutBrowser.setLineWrapMode(QTextBrowser.LineWrapMode.NoWrap)
AboutBrowser.setTextInteractionFlags(Qt.TextBrowserInteraction) AboutBrowser.setTextInteractionFlags(Qt.TextBrowserInteraction)
BrowserFont = AboutBrowser.font() browser_font = AboutBrowser.font()
BrowserFont.setFamilies(["Courier New", "Consolas", "Menlo", "DejaVu Sans Mono", "monospace"]) browser_font.setFamilies(["Courier New", "Consolas", "Menlo", "DejaVu Sans Mono", "monospace"])
AboutBrowser.setFont(BrowserFont) AboutBrowser.setFont(browser_font)
self.TabWidget.addTab(AboutBrowser, "关于") self.TabWidget.addTab(AboutBrowser, "关于")
LicenseBrowser = QTextBrowser() LicenseBrowser = QTextBrowser()
LicenseBrowser.setHtml(self.generateLicenseText()) LicenseBrowser.setHtml(self.generateLicenseText())
@@ -135,7 +135,6 @@ THE SOFTWARE.</p>"""
system = platform.system() system = platform.system()
version = platform.version() version = platform.version()
architecture = platform.architecture()[0] architecture = platform.architecture()[0]
if system == "Windows": if system == "Windows":
try: try:
version = platform.win32_ver()[1] version = platform.win32_ver()[1]
@@ -152,7 +151,6 @@ THE SOFTWARE.</p>"""
version = f"{distro.name()} {distro.version()}" version = f"{distro.name()} {distro.version()}"
except ImportError: except ImportError:
pass pass
return { return {
'system': system, 'system': system,
'version': version, 'version': version,
+133 -118
View File
@@ -9,6 +9,8 @@ See the LICENSE file for details.
""" """
from copy import deepcopy from copy import deepcopy
import qtawesome as qta
from PySide6.QtCore import ( from PySide6.QtCore import (
QDate, QDate,
QSize, QSize,
@@ -20,7 +22,6 @@ from PySide6.QtCore import (
from PySide6.QtGui import ( from PySide6.QtGui import (
QColor, QColor,
QFont, QFont,
QIcon,
QSyntaxHighlighter, QSyntaxHighlighter,
QTextCharFormat, QTextCharFormat,
) )
@@ -74,54 +75,54 @@ class ALScriptHighlighter(QSyntaxHighlighter):
super().__init__(parent) super().__init__(parent)
self._rules = [] self._rules = []
KeywordFmt = QTextCharFormat() keyword_fmt = QTextCharFormat()
KeywordFmt.setForeground(QColor("#569CD6")) keyword_fmt.setForeground(QColor("#569CD6"))
KeywordFmt.setFontWeight(QFont.Weight.Bold) keyword_fmt.setFontWeight(QFont.Weight.Bold)
for kw in [ for kw in [
"if", "elseif", "else", "end", "then", "if", "elseif", "else", "end", "then",
"and", "or", "not", "and", "or", "not",
"local", "function", "return", "nil", "local", "function", "return", "nil",
]: ]:
self._rules.append((r"\b" + kw + r"\b", KeywordFmt)) self._rules.append((r"\b" + kw + r"\b", keyword_fmt))
BoolFmt = QTextCharFormat() bool_fmt = QTextCharFormat()
BoolFmt.setForeground(QColor("#4FC1FF")) bool_fmt.setForeground(QColor("#4FC1FF"))
BoolFmt.setFontWeight(QFont.Weight.Bold) bool_fmt.setFontWeight(QFont.Weight.Bold)
self._rules.append((r"\btrue\b", BoolFmt)) self._rules.append((r"\btrue\b", bool_fmt))
self._rules.append((r"\bfalse\b", BoolFmt)) self._rules.append((r"\bfalse\b", bool_fmt))
CmpFmt = QTextCharFormat() cmp_fmt = QTextCharFormat()
CmpFmt.setForeground(QColor("#C586C0")) cmp_fmt.setForeground(QColor("#C586C0"))
CmpFmt.setFontWeight(QFont.Weight.Normal) cmp_fmt.setFontWeight(QFont.Weight.Normal)
for op in [r"==", r"~=", r">=", r"<=", r">", r"<"]: for op in [r"==", r"~=", r">=", r"<=", r">", r"<"]:
self._rules.append((op, CmpFmt)) self._rules.append((op, cmp_fmt))
ArithFmt = QTextCharFormat() arith_fmt = QTextCharFormat()
ArithFmt.setForeground(QColor("#C586C0")) arith_fmt.setForeground(QColor("#C586C0"))
ArithFmt.setFontWeight(QFont.Weight.Normal) arith_fmt.setFontWeight(QFont.Weight.Normal)
for op in [r"\+", r"-", r"\*", r"/", r"\.\."]: for op in [r"\+", r"-", r"\*", r"/", r"\.\."]:
self._rules.append((op, ArithFmt)) self._rules.append((op, arith_fmt))
FuncFmt = QTextCharFormat() func_fmt = QTextCharFormat()
FuncFmt.setForeground(QColor("#DCDCAA")) func_fmt.setForeground(QColor("#DCDCAA"))
FuncFmt.setFontWeight(QFont.Weight.Normal) func_fmt.setFontWeight(QFont.Weight.Normal)
for fn in [ "time", "date", "datenow", "timenow", "dateadd", "timeadd"]: for fn in [ "time", "date", "datenow", "timenow", "dateadd", "timeadd"]:
self._rules.append((r"\b" + fn + r"\b", FuncFmt)) self._rules.append((r"\b" + fn + r"\b", func_fmt))
VarFmt = QTextCharFormat() var_fmt = QTextCharFormat()
VarFmt.setForeground(QColor("#9CDCFE")) var_fmt.setForeground(QColor("#9CDCFE"))
VarFmt.setFontWeight(QFont.Weight.Normal) var_fmt.setFontWeight(QFont.Weight.Normal)
var_names = [name for _, (name, _) in createAllVariablesTable().items()] var_names = [name for _, (name, _) in createAllVariablesTable().items()]
for var in var_names: for var in var_names:
self._rules.append((r"\b" + var + r"\b", VarFmt)) self._rules.append((r"\b" + var + r"\b", var_fmt))
StrFmt = QTextCharFormat() st_fmt = QTextCharFormat()
StrFmt.setForeground(QColor("#CE9178")) st_fmt.setForeground(QColor("#CE9178"))
StrFmt.setFontWeight(QFont.Weight.Normal) st_fmt.setFontWeight(QFont.Weight.Normal)
self._rules.append((r'"[^"]*"', StrFmt)) self._rules.append((r'"[^"]*"', st_fmt))
self._rules.append((r"'[^']*'", StrFmt)) self._rules.append((r"'[^']*'", st_fmt))
NumFmt = QTextCharFormat() num_fmt = QTextCharFormat()
NumFmt.setForeground(QColor("#B5CEA8")) num_fmt.setForeground(QColor("#B5CEA8"))
NumFmt.setFontWeight(QFont.Weight.Normal) num_fmt.setFontWeight(QFont.Weight.Normal)
self._rules.append((r"\b\d+(?:\.\d+)?\b", NumFmt)) self._rules.append((r"\b\d+(?:\.\d+)?\b", num_fmt))
CommentFmt = QTextCharFormat() comment_fmt = QTextCharFormat()
CommentFmt.setForeground(QColor("#6A9955")) comment_fmt.setForeground(QColor("#6A9955"))
CommentFmt.setFontItalic(True) comment_fmt.setFontItalic(True)
self._rules.append((r"--[^\n]*", CommentFmt)) self._rules.append((r"--[^\n]*", comment_fmt))
def highlightBlock( def highlightBlock(
self, self,
@@ -188,13 +189,13 @@ class ALAutoScriptEditDialog(QDialog):
): ):
super().__init__(parent) super().__init__(parent)
self._fontSize = 21 self._font_size = 21
self._mockWidgets = {} self._mock_widgets = {}
self.setupUi() self.setupUi()
self.connectSignals() self.connectSignals()
self.TextEdit.setPlainText(script) self.TextEdit.setPlainText(script)
self._Highlighter = ALScriptHighlighter( self._high_lighter = ALScriptHighlighter(
self.TextEdit.document() self.TextEdit.document()
) )
if mockData: if mockData:
@@ -210,23 +211,27 @@ class ALAutoScriptEditDialog(QDialog):
Layout.setSpacing(3) Layout.setSpacing(3)
Layout.setContentsMargins(3, 3, 3, 3) Layout.setContentsMargins(3, 3, 3, 3)
ToolbarLayout = QHBoxLayout() ToolbarLayout = QHBoxLayout()
self.ZoomInBtn = QPushButton("") self.ZoomInBtn = QPushButton("")
self.ZoomInBtn.setIcon(qta.icon("fa6s.plus", color=self._iconColor()))
self.ZoomInBtn.setIconSize(QSize(14, 14))
self.ZoomInBtn.setFixedSize(25, 25) self.ZoomInBtn.setFixedSize(25, 25)
self.ZoomOutBtn = QPushButton("") self.ZoomOutBtn = QPushButton("")
self.ZoomOutBtn.setIcon(qta.icon("fa6s.minus", color=self._iconColor()))
self.ZoomOutBtn.setIconSize(QSize(14, 14))
self.ZoomOutBtn.setFixedSize(25, 25) self.ZoomOutBtn.setFixedSize(25, 25)
self.ZoomResetBtn = QPushButton("") self.ZoomResetBtn = QPushButton("")
self.ZoomResetBtn.setIcon(QIcon(":/res/icons/Reset.svg")) self.ZoomResetBtn.setIcon(qta.icon("fa6s.rotate-left", color=self._iconColor()))
self.ZoomResetBtn.setIconSize(QSize(20, 20)) self.ZoomResetBtn.setIconSize(QSize(14, 14))
self.ZoomResetBtn.setFixedSize(25, 25) self.ZoomResetBtn.setFixedSize(25, 25)
self.ZoomResetBtn.setToolTip("重置缩放") self.ZoomResetBtn.setToolTip("重置缩放")
self.ZoomLabel = QLabel(f"{self._fontSize}px") self.ZoomLabel = QLabel(f"{self._font_size}px")
self.ZoomLabel.setFixedHeight(25) self.ZoomLabel.setFixedHeight(25)
self.OrchBtn = QPushButton("编排") self.OrchBtn = QPushButton("编排")
self.OrchBtn.setFixedHeight(25) self.OrchBtn.setFixedSize(80, 25)
self.OrchBtn.setToolTip("可视化生成 AutoScript 代码并插入到光标位置") self.OrchBtn.setToolTip("可视化生成 AutoScript 代码并插入到光标位置")
ToolbarLayout.addWidget(self.OrchBtn) ToolbarLayout.addWidget(self.OrchBtn)
self.DebugBtn = QPushButton("▶ 调试运行") self.DebugBtn = QPushButton("▶ 调试运行")
self.DebugBtn.setFixedHeight(25) self.DebugBtn.setFixedSize(80, 25)
self.DebugBtn.setToolTip("使用右侧模拟数据执行脚本,查看目标变量变化") self.DebugBtn.setToolTip("使用右侧模拟数据执行脚本,查看目标变量变化")
ToolbarLayout.addWidget(self.DebugBtn) ToolbarLayout.addWidget(self.DebugBtn)
Sep = QFrame() Sep = QFrame()
@@ -240,8 +245,8 @@ class ALAutoScriptEditDialog(QDialog):
ToolbarLayout.addWidget(self.ZoomLabel) ToolbarLayout.addWidget(self.ZoomLabel)
ToolbarLayout.addStretch() ToolbarLayout.addStretch()
self.CopyBtn = QPushButton("") self.CopyBtn = QPushButton("")
self.CopyBtn.setIcon(QIcon(":/res/icons/Copy.svg")) self.CopyBtn.setIcon(qta.icon("fa6s.copy", color=self._iconColor()))
self.CopyBtn.setIconSize(QSize(20, 20)) self.CopyBtn.setIconSize(QSize(14, 14))
self.CopyBtn.setFixedSize(25, 25) self.CopyBtn.setFixedSize(25, 25)
self.CopyBtn.setToolTip("复制脚本") self.CopyBtn.setToolTip("复制脚本")
ToolbarLayout.addWidget(self.CopyBtn) ToolbarLayout.addWidget(self.CopyBtn)
@@ -254,7 +259,7 @@ class ALAutoScriptEditDialog(QDialog):
self.TextEdit.setStyleSheet( self.TextEdit.setStyleSheet(
"QPlainTextEdit {" "QPlainTextEdit {"
" font-family: 'Courier New', 'Consolas', monospace;" " font-family: 'Courier New', 'Consolas', monospace;"
f" font-size: {self._fontSize}px;" f" font-size: {self._font_size}px;"
"}" "}"
) )
Layout.addWidget(self.TextEdit) Layout.addWidget(self.TextEdit)
@@ -264,7 +269,9 @@ class ALAutoScriptEditDialog(QDialog):
QDialogButtonBox.StandardButton.Cancel QDialogButtonBox.StandardButton.Cancel
) )
self.BtnBox.button(QDialogButtonBox.StandardButton.Ok).setText("确定") self.BtnBox.button(QDialogButtonBox.StandardButton.Ok).setText("确定")
self.BtnBox.button(QDialogButtonBox.StandardButton.Ok).setFixedSize(80, 25)
self.BtnBox.button(QDialogButtonBox.StandardButton.Cancel).setText("取消") self.BtnBox.button(QDialogButtonBox.StandardButton.Cancel).setText("取消")
self.BtnBox.button(QDialogButtonBox.StandardButton.Cancel).setFixedSize(80, 25)
Layout.addWidget(self.BtnBox) Layout.addWidget(self.BtnBox)
def createButtonPanel( def createButtonPanel(
@@ -280,30 +287,30 @@ class ALAutoScriptEditDialog(QDialog):
BasicLayout.setSpacing(4) BasicLayout.setSpacing(4)
BasicLayout.setContentsMargins(4, 4, 4, 4) BasicLayout.setContentsMargins(4, 4, 4, 4)
BasicLayout.setAlignment(Qt.AlignLeft | Qt.AlignTop) BasicLayout.setAlignment(Qt.AlignLeft | Qt.AlignTop)
controlButtons = [ control_buttons = [
("如果 (if...)", "if then\n \nend"), ("如果 (if...)", "if then\n \nend"),
("再如果 (elseif...)", "elseif then\n "), ("再如果 (elseif...)", "elseif then\n "),
("否则 (else)", "else"), ("否则 (else)", "else"),
("结束 (end)", "end"), ("结束 (end)", "end"),
("跳过 (pass)", "-- pass"), ("跳过 (pass)", "-- pass"),
] ]
self.addButtonsToGrid(BasicLayout, controlButtons, 0, 0, 3) self.addButtonsToGrid(BasicLayout, control_buttons, 0, 0, 3)
assignButtons = [ assign_buttons = [
("赋值 (=)", " = "), ("赋值 (=)", " = "),
] ]
self.addButtonsToGrid(BasicLayout, assignButtons, 1, 2, 3) self.addButtonsToGrid(BasicLayout, assign_buttons, 1, 2, 3)
TabWidget.addTab(BasicWidget, "基本语法") TabWidget.addTab(BasicWidget, "基本语法")
OperatorWidget = QWidget() OperatorWidget = QWidget()
OperatorLayout = QGridLayout(OperatorWidget) OperatorLayout = QGridLayout(OperatorWidget)
OperatorLayout.setSpacing(4) OperatorLayout.setSpacing(4)
OperatorLayout.setContentsMargins(4, 4, 4, 4) OperatorLayout.setContentsMargins(4, 4, 4, 4)
OperatorLayout.setAlignment(Qt.AlignLeft | Qt.AlignTop) OperatorLayout.setAlignment(Qt.AlignLeft | Qt.AlignTop)
arithmeticButtons = [ arithmetic_buttons = [
("加 (+)", " + "), ("加 (+)", " + "),
("减 (-)", " - "), ("减 (-)", " - "),
] ]
self.addButtonsToGrid(OperatorLayout, arithmeticButtons, 0, 0, 3) self.addButtonsToGrid(OperatorLayout, arithmetic_buttons, 0, 0, 3)
compareButtons = [ compare_buttons = [
("等于 (==)", " == "), ("等于 (==)", " == "),
("不等于 (~=)", " ~= "), ("不等于 (~=)", " ~= "),
("大于 (>)", " > "), ("大于 (>)", " > "),
@@ -311,7 +318,7 @@ class ALAutoScriptEditDialog(QDialog):
("大于等于 (>=)", " >= "), ("大于等于 (>=)", " >= "),
("小于等于 (<=)", " <= "), ("小于等于 (<=)", " <= "),
] ]
self.addButtonsToGrid(OperatorLayout, compareButtons, 1, 0, 3) self.addButtonsToGrid(OperatorLayout, compare_buttons, 1, 0, 3)
logic_buttons = [ logic_buttons = [
("且 (and)", " and "), ("且 (and)", " and "),
("或 (or)", " or "), ("或 (or)", " or "),
@@ -328,40 +335,40 @@ class ALAutoScriptEditDialog(QDialog):
("假 (false)", "false"), ("假 (false)", "false"),
] ]
self.addButtonsToGrid(LiteralLayout, bool_buttons, 0, 0, 3) self.addButtonsToGrid(LiteralLayout, bool_buttons, 0, 0, 3)
dateTimeButtons = [ date_time_buttons = [
("日期", 'date(2026, 1, 1)'), ("日期", 'date(2026, 1, 1)'),
("时间", 'time(0, 0)'), ("时间", 'time(0, 0)'),
] ]
self.addButtonsToGrid(LiteralLayout, dateTimeButtons, 1, 0, 3) self.addButtonsToGrid(LiteralLayout, date_time_buttons, 1, 0, 3)
hintButtons = [ hint_buttons = [
("字符串", '"请输入文本"'), ("字符串", '"请输入文本"'),
("数字", "123"), ("数字", "123"),
("注释", "-- 请输入注释"), ("注释", "-- 请输入注释"),
] ]
self.addButtonsToGrid(LiteralLayout, hintButtons, 2, 0, 3) self.addButtonsToGrid(LiteralLayout, hint_buttons, 2, 0, 3)
TabWidget.addTab(LiteralWidget, "字面量") TabWidget.addTab(LiteralWidget, "字面量")
VarWidget = QWidget() VarWidget = QWidget()
VarLayout = QGridLayout(VarWidget) VarLayout = QGridLayout(VarWidget)
VarLayout.setSpacing(4) VarLayout.setSpacing(4)
VarLayout.setContentsMargins(4, 4, 4, 4) VarLayout.setContentsMargins(4, 4, 4, 4)
VarLayout.setAlignment(Qt.AlignLeft | Qt.AlignTop) VarLayout.setAlignment(Qt.AlignLeft | Qt.AlignTop)
varButtons = [ var_buttons = [
(display_name, name) for display_name, (name, _) in createAllVariablesTable().items() (display_name, name) for display_name, (name, _) in createAllVariablesTable().items()
] ]
self.addButtonsToGrid(VarLayout, varButtons, 0, 0, 3) self.addButtonsToGrid(VarLayout, var_buttons, 0, 0, 3)
TabWidget.addTab(VarWidget, "变量") TabWidget.addTab(VarWidget, "变量")
FuncWidget = QWidget() FuncWidget = QWidget()
FuncLayout = QGridLayout(FuncWidget) FuncLayout = QGridLayout(FuncWidget)
FuncLayout.setSpacing(4) FuncLayout.setSpacing(4)
FuncLayout.setContentsMargins(4, 4, 4, 4) FuncLayout.setContentsMargins(4, 4, 4, 4)
FuncLayout.setAlignment(Qt.AlignLeft | Qt.AlignTop) FuncLayout.setAlignment(Qt.AlignLeft | Qt.AlignTop)
funcButtons = [ func_buttons = [
("datenow()", "datenow()", "返回当前日期的 Unix 时间戳"), ("datenow()", "datenow()", "返回当前日期的 Unix 时间戳"),
("timenow()", "timenow()", "返回当前时间在一天中的分钟数"), ("timenow()", "timenow()", "返回当前时间在一天中的分钟数"),
("dateadd(day, n)", "dateadd(, )", "日期偏移: dateadd(日期时间戳, 天数)"), ("dateadd(day, n)", "dateadd(, )", "日期偏移: dateadd(日期时间戳, 天数)"),
("timeadd(time, n)", "timeadd(, )", "时间偏移: timeadd(分钟数, 分钟数)"), ("timeadd(time, n)", "timeadd(, )", "时间偏移: timeadd(分钟数, 分钟数)"),
] ]
for i, (text, template, tooltip) in enumerate(funcButtons): for i, (text, template, tooltip) in enumerate(func_buttons):
Btn = QPushButton(text) Btn = QPushButton(text)
Btn.setProperty("template", template) Btn.setProperty("template", template)
Btn.clicked.connect(self.insertTemplate) Btn.clicked.connect(self.insertTemplate)
@@ -412,17 +419,17 @@ class ALAutoScriptEditDialog(QDialog):
Form = QFormLayout(Group) Form = QFormLayout(Group)
Form.setSpacing(4) Form.setSpacing(4)
Form.setContentsMargins(5, 10, 5, 5) Form.setContentsMargins(5, 10, 5, 5)
self._mockWidgets = {} self._mock_widgets = {}
mockData = createMockTargetData() mock_data = createMockTargetData()
for name, var_type, key_path, display_name in createTargetVarDefs(): for name, var_type, key_path, display_name in createTargetVarDefs():
d = mockData d = mock_data
for key in key_path: for key in key_path:
d = d[key] d = d[key]
default = d default = d
Widget = self.makeMockInput(var_type, default) Widget = self.makeMockInput(var_type, default)
Label = QLabel(f"{display_name}: {name}({var_type})") Label = QLabel(f"{display_name}: {name}({var_type})")
Form.addRow(Label, Widget) Form.addRow(Label, Widget)
self._mockWidgets[name] = (Widget, var_type, key_path) self._mock_widgets[name] = (Widget, var_type, key_path)
return Group return Group
def makeMockInput( def makeMockInput(
@@ -432,49 +439,49 @@ class ALAutoScriptEditDialog(QDialog):
) -> QWidget: ) -> QWidget:
if var_type == "String": if var_type == "String":
W = QLineEdit() widget = QLineEdit()
W.setText(str(default)) widget.setText(str(default))
return W return widget
if var_type == "Boolean": if var_type == "Boolean":
W = QComboBox() widget = QComboBox()
W.addItems(["", ""]) widget.addItems(["", ""])
W.setCurrentIndex(0 if default else 1) widget.setCurrentIndex(0 if default else 1)
return W return widget
if var_type == "Date": if var_type == "Date":
W = QDateEdit() widget = QDateEdit()
W.setCalendarPopup(True) widget.setCalendarPopup(True)
W.setDisplayFormat("yyyy-MM-dd") widget.setDisplayFormat("yyyy-MM-dd")
W.setDate(QDate.fromString(str(default), "yyyy-MM-dd")) widget.setDate(QDate.fromString(str(default), "yyyy-MM-dd"))
return W return widget
if var_type == "Time": if var_type == "Time":
W = QTimeEdit() widget = QTimeEdit()
W.setDisplayFormat("HH:mm") widget.setDisplayFormat("HH:mm")
W.setTime(QTime.fromString(str(default), "HH:mm")) widget.setTime(QTime.fromString(str(default), "HH:mm"))
return W return widget
if var_type == "Int": if var_type == "Int":
W = QSpinBox() widget = QSpinBox()
W.setMinimum(-999999) widget.setMinimum(-999999)
W.setMaximum(999999) widget.setMaximum(999999)
W.setValue(int(default) if default else 0) widget.setValue(int(default) if default else 0)
return W return widget
if var_type == "Float": if var_type == "Float":
W = QDoubleSpinBox() widget = QDoubleSpinBox()
W.setMinimum(-999999.0) widget.setMinimum(-999999.0)
W.setMaximum(999999.0) widget.setMaximum(999999.0)
W.setDecimals(2) widget.setDecimals(2)
W.setValue(float(default) if default else 0.0) widget.setValue(float(default) if default else 0.0)
return W return widget
W = QLineEdit() widget = QLineEdit()
W.setText(str(default)) widget.setText(str(default))
return W return widget
def getMockData( def getMockData(
self self
) -> dict: ) -> dict:
data = {} data = {}
for name, var_type, key_path, display_name in createTargetVarDefs(): for name, var_type, key_path, _ in createTargetVarDefs():
widget, _, _ = self._mockWidgets[name] widget, _, _ = self._mock_widgets[name]
value = self.getMockValue(widget, var_type) value = self.getMockValue(widget, var_type)
d = data d = data
for key in key_path[:-1]: for key in key_path[:-1]:
@@ -489,14 +496,14 @@ class ALAutoScriptEditDialog(QDialog):
if not data: if not data:
return return
for name, var_type, key_path, display_name in createTargetVarDefs(): for name, var_type, key_path, _ in createTargetVarDefs():
d = data d = data
try: try:
for key in key_path: for key in key_path:
d = d[key] d = d[key]
except (KeyError, TypeError): except (KeyError, TypeError):
continue continue
widget, _, _ = self._mockWidgets[name] widget, _, _ = self._mock_widgets[name]
self.setMockValue(widget, var_type, d) self.setMockValue(widget, var_type, d)
def getMockValue( def getMockValue(
@@ -537,6 +544,14 @@ class ALAutoScriptEditDialog(QDialog):
else: else:
widget.setText(str(value)) widget.setText(str(value))
def _iconColor(
self
) -> str:
return QApplication.instance().palette().color(
QApplication.instance().palette().ColorRole.WindowText
).name()
def connectSignals( def connectSignals(
self self
): ):
@@ -563,10 +578,10 @@ class ALAutoScriptEditDialog(QDialog):
self.TextEdit.setStyleSheet( self.TextEdit.setStyleSheet(
"QPlainTextEdit {" "QPlainTextEdit {"
" font-family: 'Courier New', 'Consolas', monospace;" " font-family: 'Courier New', 'Consolas', monospace;"
f" font-size: {self._fontSize}px;" f" font-size: {self._font_size}px;"
"}" "}"
) )
self.ZoomLabel.setText(f"{self._fontSize}px") self.ZoomLabel.setText(f"{self._font_size}px")
@Slot() @Slot()
def insertTemplate( def insertTemplate(
@@ -587,7 +602,7 @@ class ALAutoScriptEditDialog(QDialog):
self self
): ):
self._fontSize = min(self._fontSize + 2, 40) self._font_size = min(self._font_size + 2, 40)
self.updateFontSize() self.updateFontSize()
@Slot() @Slot()
@@ -595,7 +610,7 @@ class ALAutoScriptEditDialog(QDialog):
self self
): ):
self._fontSize = max(self._fontSize - 2, 8) self._font_size = max(self._font_size - 2, 8)
self.updateFontSize() self.updateFontSize()
@Slot() @Slot()
@@ -603,7 +618,7 @@ class ALAutoScriptEditDialog(QDialog):
self self
): ):
self._fontSize = 21 self._font_size = 21
self.updateFontSize() self.updateFontSize()
@Slot() @Slot()
@@ -624,13 +639,13 @@ class ALAutoScriptEditDialog(QDialog):
): ):
from gui.ALAutoScriptOrchDialog import ALAutoScriptOrchDialog from gui.ALAutoScriptOrchDialog import ALAutoScriptOrchDialog
Dlg = ALAutoScriptOrchDialog(self) Dialpg = ALAutoScriptOrchDialog(self)
if Dlg.exec() == QDialog.DialogCode.Accepted: if Dialpg.exec() == QDialog.DialogCode.Accepted:
script = Dlg.getScript() script = Dialpg.getScript()
if script: if script:
cursor = self.TextEdit.textCursor() cursor = self.TextEdit.textCursor()
cursor.insertText(script) cursor.insertText(script)
Dlg.deleteLater() Dialpg.deleteLater()
@Slot() @Slot()
def onDebugRun( def onDebugRun(
@@ -664,6 +679,6 @@ class ALAutoScriptEditDialog(QDialog):
if not changes: if not changes:
QMessageBox.information(self, "调试运行", "目标变量未发生变化。") QMessageBox.information(self, "调试运行", "目标变量未发生变化。")
return return
Dlg = _DebugResultDialog(changes, self) Dialpg = _DebugResultDialog(changes, self)
Dlg.exec() Dialpg.exec()
Dlg.deleteLater() Dialpg.deleteLater()
+53 -49
View File
@@ -32,16 +32,16 @@ class ConditionalBlock(QGroupBox):
def __init__( def __init__(
self, self,
blockIndex: int, block_index: int,
varMgr = None, var_mgr = None,
parent = None parent = None
): ):
super().__init__(parent) super().__init__(parent)
self.blockIndex = blockIndex self._block_index = block_index
self._varMgr = varMgr self._var_mgr = var_mgr
self._actionWidgets = [] self._action_widgets = []
self._conditionRows = [] self._condition_rows = []
self.setupUi() self.setupUi()
self.connectSignals() self.connectSignals()
@@ -53,8 +53,12 @@ class ConditionalBlock(QGroupBox):
self.setUpdatesEnabled(False) self.setUpdatesEnabled(False)
self.setStyleSheet( self.setStyleSheet(
"QGroupBox { font-weight: bold; border: 1px solid #ccc; " "QGroupBox { "\
"margin-top: 5px; padding-top: 5px; }" "font-weight: bold;"\
"border: 1px solid #ccc;"\
"margin-top: 5px;"\
"padding-top: 5px; "\
"}"
) )
self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed) self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
MainLayout = QVBoxLayout(self) MainLayout = QVBoxLayout(self)
@@ -67,7 +71,7 @@ class ConditionalBlock(QGroupBox):
self.TypeCombo.addItem("ELSE IF", "ELSE IF") self.TypeCombo.addItem("ELSE IF", "ELSE IF")
self.TypeCombo.addItem("ELSE", "ELSE") self.TypeCombo.addItem("ELSE", "ELSE")
self.TypeCombo.setFixedHeight(25) self.TypeCombo.setFixedHeight(25)
if self.blockIndex == 0: if self._block_index == 0:
self.TypeCombo.setEnabled(False) self.TypeCombo.setEnabled(False)
HeaderLayout.addWidget(QLabel("类型:", self)) HeaderLayout.addWidget(QLabel("类型:", self))
HeaderLayout.addWidget(self.TypeCombo) HeaderLayout.addWidget(self.TypeCombo)
@@ -115,10 +119,10 @@ class ConditionalBlock(QGroupBox):
): ):
Row = ConditionRowFrame( Row = ConditionRowFrame(
self._varMgr, self.blockIndex, self._var_mgr, self._block_index,
isFirst=True, parent=self is_first=True, parent=self
) )
self._conditionRows.append(Row) self._condition_rows.append(Row)
self.CondRowsLayout.addWidget(Row) self.CondRowsLayout.addWidget(Row)
def addConditionRow( def addConditionRow(
@@ -126,11 +130,11 @@ class ConditionalBlock(QGroupBox):
): ):
Row = ConditionRowFrame( Row = ConditionRowFrame(
self._varMgr, self.blockIndex, self._var_mgr, self._block_index,
isFirst=False, parent=self is_first=False, parent=self
) )
Row.DeleteBtn.clicked.connect(lambda: self.removeConditionRow(Row)) Row.DeleteBtn.clicked.connect(lambda: self.removeConditionRow(Row))
self._conditionRows.append(Row) self._condition_rows.append(Row)
self.CondRowsLayout.addWidget(Row) self.CondRowsLayout.addWidget(Row)
def removeConditionRow( def removeConditionRow(
@@ -138,8 +142,8 @@ class ConditionalBlock(QGroupBox):
row: ConditionRowFrame row: ConditionRowFrame
): ):
if row in self._conditionRows and len(self._conditionRows) > 1: if row in self._condition_rows and len(self._condition_rows) > 1:
self._conditionRows.remove(row) self._condition_rows.remove(row)
self.CondRowsLayout.removeWidget(row) self.CondRowsLayout.removeWidget(row)
row.hide() row.hide()
row.deleteLater() row.deleteLater()
@@ -148,9 +152,9 @@ class ConditionalBlock(QGroupBox):
self self
): ):
Step = ActionStepFrame(self._varMgr, self.blockIndex, parent=self) Step = ActionStepFrame(self._var_mgr, self._block_index, parent=self)
Step.DeleteBtn.clicked.connect(lambda: self.removeActionStep(Step)) Step.DeleteBtn.clicked.connect(lambda: self.removeActionStep(Step))
self._actionWidgets.append(Step) self._action_widgets.append(Step)
self.ActionsLayout.addWidget(Step) self.ActionsLayout.addWidget(Step)
def removeActionStep( def removeActionStep(
@@ -158,8 +162,8 @@ class ConditionalBlock(QGroupBox):
step: ActionStepFrame step: ActionStepFrame
): ):
if step in self._actionWidgets: if step in self._action_widgets:
self._actionWidgets.remove(step) self._action_widgets.remove(step)
self.ActionsLayout.removeWidget(step) self.ActionsLayout.removeWidget(step)
step.hide() step.hide()
step.deleteLater() step.deleteLater()
@@ -174,19 +178,19 @@ class ConditionalBlock(QGroupBox):
self self
): ):
return list(self._conditionRows) return list(self._condition_rows)
def getActionSteps( def getActionSteps(
self self
): ):
return list(self._actionWidgets) return list(self._action_widgets)
def countActionSteps( def countActionSteps(
self self
) -> int: ) -> int:
return len(self._actionWidgets) return len(self._action_widgets)
def toScript( def toScript(
self self
@@ -195,48 +199,48 @@ class ConditionalBlock(QGroupBox):
Generate Lua script lines for this conditional block. Generate Lua script lines for this conditional block.
""" """
blockType = self.getBlockType() block_type = self.getBlockType()
lines = [] lines = []
if blockType in ("IF", "ELSE IF"): if block_type in ("IF", "ELSE IF"):
condTexts = [ cond_texts = [
r.toScript() for r in self._conditionRows if r.toScript() r.toScript() for r in self._condition_rows if r.toScript()
] ]
if not condTexts: if not cond_texts:
condTexts = ["true"] cond_texts = ["true"]
if len(condTexts) == 1: if len(cond_texts) == 1:
combined = condTexts[0] combined = cond_texts[0]
else: else:
parts = [] parts = []
for i, ct in enumerate(condTexts): for i, ct in enumerate(cond_texts):
if i > 0: if i > 0:
logic = self._conditionRows[i].getLogic() or "and" logic = self._condition_rows[i].getLogic() or "and"
parts.append(f" {logic} ") parts.append(f" {logic} ")
parts.append(f"({ct})") parts.append(f"({ct})")
combined = "".join(parts) combined = "".join(parts)
if blockType == "IF": if block_type == "IF":
lines.append(f"if {combined} then") lines.append(f"if {combined} then")
else: else:
lines.append(f"elseif {combined} then") lines.append(f"elseif {combined} then")
else: else:
lines.append("else") lines.append("else")
for step in self._actionWidgets: for step in self._action_widgets:
scriptLine = step.toScript() script_line = step.toScript()
if scriptLine: if script_line:
lines.append(scriptLine) lines.append(script_line)
return lines return lines
def refreshVarCombos( def refreshVarCombos(
self self
): ):
for row in self._conditionRows: for row in self._condition_rows:
row.refreshVarCombos() row.refreshVarCombos()
for step in self._actionWidgets: for step in self._action_widgets:
step.refreshVarCombos() step.refreshVarCombos()
def setPrevBlockType( def setPrevBlockType(
self, self,
prevType: str | None prev_type: str | None
): ):
model = self.TypeCombo.model() model = self.TypeCombo.model()
@@ -247,19 +251,19 @@ class ConditionalBlock(QGroupBox):
if idx < 0: if idx < 0:
continue continue
item = model.item(idx) item = model.item(idx)
shouldEnable = prevType != "ELSE" should_enable = prev_type != "ELSE"
item.setEnabled(shouldEnable) item.setEnabled(should_enable)
if prevType == "ELSE" and self.TypeCombo.currentData() in ("ELSE IF", "ELSE"): if prev_type == "ELSE" and self.TypeCombo.currentData() in ("ELSE IF", "ELSE"):
self.TypeCombo.setCurrentIndex(0) self.TypeCombo.setCurrentIndex(0)
@Slot(int) @Slot(int)
def onTypeChanged( def onTypeChanged(
self, self,
_idx idx: int
): ):
isCond = self.TypeCombo.currentData() in ("IF", "ELSE IF") is_cond = self.TypeCombo.currentData() in ("IF", "ELSE IF")
self.ConditionWidget.setVisible(isCond) self.ConditionWidget.setVisible(is_cond)
self.ActionLabel.setText( self.ActionLabel.setText(
"执行步骤:" if isCond else "ELSE 执行步骤:" "执行步骤:" if is_cond else "ELSE 执行步骤:"
) )
+2 -2
View File
@@ -35,7 +35,7 @@ class ALAutoScriptOrchDialog(QDialog):
super().__init__(parent) super().__init__(parent)
self._blocks = [] self._blocks = []
self._varMgr = VariableManager(self) self._var_mgr = VariableManager(self)
self.setupUi() self.setupUi()
self.connectSignals() self.connectSignals()
@@ -91,7 +91,7 @@ class ALAutoScriptOrchDialog(QDialog):
): ):
Block = ConditionalBlock( Block = ConditionalBlock(
len(self._blocks), self._varMgr, parent=self len(self._blocks), self._var_mgr, parent=self
) )
Block.DeleteBlockBtn.clicked.connect(lambda: self.removeBlock(Block)) Block.DeleteBlockBtn.clicked.connect(lambda: self.removeBlock(Block))
Block.TypeCombo.currentIndexChanged.connect(self.updateBlockTypeRestrictions) Block.TypeCombo.currentIndexChanged.connect(self.updateBlockTypeRestrictions)
+140 -121
View File
@@ -46,11 +46,11 @@ def getTypeOrder(
return [t for t, _ in VARTYPE_INFOS] return [t for t, _ in VARTYPE_INFOS]
def getArithType( def getArithType(
varType: str var_type: str
) -> bool: ) -> bool:
for t, a in VARTYPE_INFOS: for t, a in VARTYPE_INFOS:
if t == varType: if t == var_type:
return a return a
def getPresetVars( def getPresetVars(
@@ -104,6 +104,7 @@ class _DateInputContainer(QWidget):
): ):
super().__init__(parent) super().__init__(parent)
self.setupUi() self.setupUi()
def setupUi( def setupUi(
@@ -113,36 +114,36 @@ class _DateInputContainer(QWidget):
Layout = QHBoxLayout(self) Layout = QHBoxLayout(self)
Layout.setContentsMargins(0, 0, 0, 0) Layout.setContentsMargins(0, 0, 0, 0)
Layout.setSpacing(4) Layout.setSpacing(4)
self._ModeCombo = QComboBox(self) self.ModeCombo = QComboBox(self)
self._ModeCombo.addItem("相对日期", "relative") self.ModeCombo.addItem("相对日期", "relative")
self._ModeCombo.addItem("绝对日期", "absolute") self.ModeCombo.addItem("绝对日期", "absolute")
self._ModeCombo.setFixedHeight(25) self.ModeCombo.setFixedHeight(25)
self._Stack = QStackedWidget(self) self.Stack = QStackedWidget(self)
self._RelCombo = QComboBox(self) self.RelCombo = QComboBox(self)
for display, data in DATE_OPTIONS: for display, data in DATE_OPTIONS:
self._RelCombo.addItem(display, data) self.RelCombo.addItem(display, data)
self._RelCombo.setFixedHeight(25) self.RelCombo.setFixedHeight(25)
self._Stack.addWidget(self._RelCombo) self.Stack.addWidget(self.RelCombo)
self._DateEdit = QDateEdit(self) self.DateEdit = QDateEdit(self)
self._DateEdit.setDisplayFormat("yyyy-MM-dd") self.DateEdit.setDisplayFormat("yyyy-MM-dd")
self._DateEdit.setCalendarPopup(True) self.DateEdit.setCalendarPopup(True)
self._DateEdit.setFixedHeight(25) self.DateEdit.setFixedHeight(25)
self._Stack.addWidget(self._DateEdit) self.Stack.addWidget(self.DateEdit)
self._ModeCombo.currentIndexChanged.connect( self.ModeCombo.currentIndexChanged.connect(
lambda i: self._Stack.setCurrentIndex(i) lambda i: self.Stack.setCurrentIndex(i)
) )
Layout.addWidget(self._ModeCombo) Layout.addWidget(self.ModeCombo)
Layout.addWidget(self._Stack) Layout.addWidget(self.Stack)
Layout.addStretch() Layout.addStretch()
def getValue( def getValue(
self self
) -> str: ) -> str:
mode = self._ModeCombo.currentData() mode = self.ModeCombo.currentData()
if mode == "relative": if mode == "relative":
return self._RelCombo.currentText() return self.RelCombo.currentText()
return self._DateEdit.date().toString("yyyy-MM-dd") return self.DateEdit.date().toString("yyyy-MM-dd")
class _TimeInputContainer(QWidget): class _TimeInputContainer(QWidget):
@@ -153,19 +154,25 @@ class _TimeInputContainer(QWidget):
): ):
super().__init__(parent) super().__init__(parent)
self._TimeEdit = QTimeEdit(self)
self._TimeEdit.setDisplayFormat("HH:mm")
self._TimeEdit.setFixedHeight(25)
self.setupUi()
def setupUi(
self
):
self.TimeEdit = QTimeEdit(self)
self.TimeEdit.setDisplayFormat("HH:mm")
self.TimeEdit.setFixedHeight(25)
Layout = QHBoxLayout(self) Layout = QHBoxLayout(self)
Layout.setContentsMargins(0, 0, 0, 0) Layout.setContentsMargins(0, 0, 0, 0)
Layout.addWidget(self._TimeEdit) Layout.addWidget(self.TimeEdit)
def getValue( def getValue(
self self
) -> str: ) -> str:
return self._TimeEdit.time().toString("HH:mm") return self.TimeEdit.time().toString("HH:mm")
class _DateOffsetContainer(QWidget): class _DateOffsetContainer(QWidget):
@@ -176,19 +183,25 @@ class _DateOffsetContainer(QWidget):
): ):
super().__init__(parent) super().__init__(parent)
self._SpinBox = QSpinBox(self)
self._SpinBox.setRange(0, 99999)
self._SpinBox.setFixedHeight(25)
self._UnitCombo = QComboBox(self)
for display, data in DATE_OFFSET_OPTIONS:
self._UnitCombo.addItem(display, data)
self._UnitCombo.setFixedHeight(25)
self.setupUi()
def setupUi(
self
):
self.SpinBox = QSpinBox(self)
self.SpinBox.setRange(0, 99999)
self.SpinBox.setFixedHeight(25)
self.UnitCombo = QComboBox(self)
for display, data in DATE_OFFSET_OPTIONS:
self.UnitCombo.addItem(display, data)
self.UnitCombo.setFixedHeight(25)
Layout = QHBoxLayout(self) Layout = QHBoxLayout(self)
Layout.setContentsMargins(0, 0, 0, 0) Layout.setContentsMargins(0, 0, 0, 0)
Layout.setSpacing(4) Layout.setSpacing(4)
Layout.addWidget(self._SpinBox) Layout.addWidget(self.SpinBox)
Layout.addWidget(self._UnitCombo) Layout.addWidget(self.UnitCombo)
Layout.addStretch() Layout.addStretch()
def getValue( def getValue(
@@ -201,8 +214,8 @@ class _DateOffsetContainer(QWidget):
self self
) -> int: ) -> int:
val = self._SpinBox.value() val = self.SpinBox.value()
unit = self._UnitCombo.currentData() unit = self.UnitCombo.currentData()
if unit == "weeks": if unit == "weeks":
return val*7 return val*7
if unit == "months": if unit == "months":
@@ -220,14 +233,20 @@ class _TimeOffsetContainer(QWidget):
): ):
super().__init__(parent) super().__init__(parent)
self._SpinBox = QSpinBox(self)
self._SpinBox.setRange(0, 99999)
self._SpinBox.setSuffix(" 小时")
self._SpinBox.setFixedHeight(25)
self.setupUi()
def setupUi(
self
):
self.SpinBox = QSpinBox(self)
self.SpinBox.setRange(0, 99999)
self.SpinBox.setSuffix(" 小时")
self.SpinBox.setFixedHeight(25)
Layout = QHBoxLayout(self) Layout = QHBoxLayout(self)
Layout.setContentsMargins(0, 0, 0, 0) Layout.setContentsMargins(0, 0, 0, 0)
Layout.addWidget(self._SpinBox) Layout.addWidget(self.SpinBox)
def getValue( def getValue(
self self
@@ -239,7 +258,7 @@ class _TimeOffsetContainer(QWidget):
self self
) -> int: ) -> int:
return self._SpinBox.value() return self.SpinBox.value()
class VariableManager(QObject): class VariableManager(QObject):
@@ -251,7 +270,7 @@ class VariableManager(QObject):
super().__init__(parent) super().__init__(parent)
self._vars = [] self._vars = []
self._nameMap = {} self._name_map = {}
self.initPresetVars() self.initPresetVars()
@@ -262,21 +281,21 @@ class VariableManager(QObject):
for p in getPresetVars(): for p in getPresetVars():
entry = {"name": p["name"], "type": p["type"], "display": p["display"]} entry = {"name": p["name"], "type": p["type"], "display": p["display"]}
self._vars.append(entry) self._vars.append(entry)
self._nameMap[p["name"]] = entry self._name_map[p["name"]] = entry
def getInfoByName( def getInfoByName(
self, self,
name: str name: str
): ):
return self._nameMap.get(name.upper().strip()) return self._name_map.get(name.upper().strip())
def populateCombo( def populateCombo(
self, self,
combo: QComboBox combo: QComboBox
): ):
currentData = combo.currentData() current_data = combo.currentData()
combo.blockSignals(True) combo.blockSignals(True)
combo.clear() combo.clear()
for entry in self._vars: for entry in self._vars:
@@ -284,10 +303,10 @@ class VariableManager(QObject):
entry["display"], entry["display"],
(entry["name"], entry["type"]) (entry["name"], entry["type"])
) )
if currentData: if current_data:
for i in range(combo.count()): for i in range(combo.count()):
d = combo.itemData(i) d = combo.itemData(i)
if d and d[0] == currentData[0]: if d and d[0] == current_data[0]:
combo.setCurrentIndex(i) combo.setCurrentIndex(i)
break break
combo.blockSignals(False) combo.blockSignals(False)
@@ -299,40 +318,40 @@ def makeValueWidget(
) -> QWidget: ) -> QWidget:
if var_type == "Int": if var_type == "Int":
w = QSpinBox(parent) Widget = QSpinBox(parent)
w.setRange(-999999, 999999) Widget.setRange(-999999, 999999)
w.setFixedHeight(25) Widget.setFixedHeight(25)
w.setMinimumWidth(100) Widget.setMinimumWidth(100)
return w return Widget
if var_type == "Float": if var_type == "Float":
w = QDoubleSpinBox(parent) Widget = QDoubleSpinBox(parent)
w.setRange(-999999.0, 999999.0) Widget.setRange(-999999.0, 999999.0)
w.setDecimals(2) Widget.setDecimals(2)
w.setFixedHeight(25) Widget.setFixedHeight(25)
w.setMinimumWidth(100) Widget.setMinimumWidth(100)
return w return Widget
if var_type == "String": if var_type == "String":
w = QLineEdit(parent) Widget = QLineEdit(parent)
w.setPlaceholderText("输入值") Widget.setPlaceholderText("输入值")
w.setFixedHeight(25) Widget.setFixedHeight(25)
w.setMinimumWidth(120) Widget.setMinimumWidth(120)
return w return Widget
if var_type == "Boolean": if var_type == "Boolean":
w = QComboBox(parent) Widget = QComboBox(parent)
w.addItem("是 (true)", "true") Widget.addItem("是 (true)", "true")
w.addItem("否 (false)", "false") Widget.addItem("否 (false)", "false")
w.setFixedHeight(25) Widget.setFixedHeight(25)
w.setMinimumWidth(100) Widget.setMinimumWidth(100)
return w return Widget
if var_type == "Date": if var_type == "Date":
return _DateInputContainer(parent) return _DateInputContainer(parent)
if var_type == "Time": if var_type == "Time":
return _TimeInputContainer(parent) return _TimeInputContainer(parent)
w = QLineEdit(parent) Widget = QLineEdit(parent)
w.setPlaceholderText("输入值") Widget.setPlaceholderText("输入值")
w.setFixedHeight(25) Widget.setFixedHeight(25)
w.setMinimumWidth(120) Widget.setMinimumWidth(120)
return w return Widget
def makeOffsetWidget( def makeOffsetWidget(
var_type: str, var_type: str,
@@ -340,35 +359,35 @@ def makeOffsetWidget(
) -> QWidget: ) -> QWidget:
if var_type == "Int": if var_type == "Int":
w = QSpinBox(parent) Widget = QSpinBox(parent)
w.setRange(-999999, 999999) Widget.setRange(-999999, 999999)
w.setFixedHeight(25) Widget.setFixedHeight(25)
w.setMinimumWidth(100) Widget.setMinimumWidth(100)
return w return Widget
if var_type == "Float": if var_type == "Float":
w = QDoubleSpinBox(parent) Widget = QDoubleSpinBox(parent)
w.setRange(-999999.0, 999999.0) Widget.setRange(-999999.0, 999999.0)
w.setDecimals(2) Widget.setDecimals(2)
w.setFixedHeight(25) Widget.setFixedHeight(25)
w.setMinimumWidth(100) Widget.setMinimumWidth(100)
return w return Widget
if var_type == "Date": if var_type == "Date":
return _DateOffsetContainer(parent) return _DateOffsetContainer(parent)
if var_type == "Time": if var_type == "Time":
return _TimeOffsetContainer(parent) return _TimeOffsetContainer(parent)
w = QLabel("(不支持该操作)", parent) Widget = QLabel("(不支持该操作)", parent)
w.setFixedHeight(25) Widget.setFixedHeight(25)
return w return Widget
def makeVarRefCombo( def makeVarRefCombo(
parent: QWidget = None parent: QWidget = None
) -> QComboBox: ) -> QComboBox:
Cb = QComboBox(parent) ComboBox = QComboBox(parent)
Cb.setFixedHeight(25) ComboBox.setFixedHeight(25)
Cb.setMinimumWidth(120) ComboBox.setMinimumWidth(120)
Cb.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) ComboBox.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
return Cb return ComboBox
def makeComboWidget( def makeComboWidget(
items, items,
@@ -376,12 +395,12 @@ def makeComboWidget(
parent: QWidget = None parent: QWidget = None
) -> QComboBox: ) -> QComboBox:
Cb = QComboBox(parent) ComboBox = QComboBox(parent)
for display, data in items: for display, data in items:
Cb.addItem(display, data) ComboBox.addItem(display, data)
Cb.setFixedHeight(25) ComboBox.setFixedHeight(25)
Cb.setMinimumWidth(min_width) ComboBox.setMinimumWidth(min_width)
return Cb return ComboBox
def makeLabel( def makeLabel(
text: str, text: str,
@@ -389,30 +408,30 @@ def makeLabel(
width: int = None width: int = None
) -> QLabel: ) -> QLabel:
Lbl = QLabel(text, parent) Label = QLabel(text, parent)
Lbl.setFixedHeight(25) Label.setFixedHeight(25)
if width: if width:
Lbl.setFixedWidth(width) Label.setFixedWidth(width)
return Lbl return Label
def getValueFromWidget( def getValueFromWidget(
w: QWidget widget: QWidget
) -> str: ) -> str:
if hasattr(w, "getValue"): if hasattr(widget, "getValue"):
return w.getValue() return widget.getValue()
if isinstance(w, QTimeEdit): if isinstance(widget, QTimeEdit):
return w.time().toString("HH:mm") return widget.time().toString("HH:mm")
if isinstance(w, QDateEdit): if isinstance(widget, QDateEdit):
return w.date().toString("yyyy-MM-dd") return widget.date().toString("yyyy-MM-dd")
if isinstance(w, QComboBox): if isinstance(widget, QComboBox):
return w.currentData() or w.currentText() return widget.currentData() or widget.currentText()
if isinstance(w, QSpinBox): if isinstance(widget, QSpinBox):
return str(w.value()) return str(widget.value())
if isinstance(w, QDoubleSpinBox): if isinstance(widget, QDoubleSpinBox):
return str(w.value()) return str(widget.value())
if isinstance(w, QLineEdit): if isinstance(widget, QLineEdit):
return w.text() return widget.text()
return "" return ""
def encodeValueStr( def encodeValueStr(
+89 -89
View File
@@ -42,18 +42,18 @@ class ConditionRowFrame(QFrame):
def __init__( def __init__(
self, self,
varMgr, var_mgr,
parentBlockIndex: int = 0, parent_block_index: int = 0,
isFirst: bool = False, is_first: bool = False,
parent = None parent = None
): ):
super().__init__(parent) super().__init__(parent)
self._varMgr = varMgr self._var_mgr = var_mgr
self._blockIndex = parentBlockIndex self._block_index = parent_block_index
self._isFirst = isFirst self._is_first = is_first
self._isBoolMode = False self._is_bool_mode = False
self._rawRhsExpr = "" self._raw_rhs_expr = ""
self.setupUi() self.setupUi()
self.connectSignals() self.connectSignals()
@@ -69,7 +69,7 @@ class ConditionRowFrame(QFrame):
Layout = QHBoxLayout(self) Layout = QHBoxLayout(self)
Layout.setContentsMargins(2, 2, 2, 2) Layout.setContentsMargins(2, 2, 2, 2)
Layout.setSpacing(4) Layout.setSpacing(4)
if self._isFirst: if self._is_first:
self.LogicCombo = None self.LogicCombo = None
else: else:
self.LogicCombo = makeComboWidget(LOGIC_OPTIONS, min_width=110, parent=self) self.LogicCombo = makeComboWidget(LOGIC_OPTIONS, min_width=110, parent=self)
@@ -82,11 +82,11 @@ class ConditionRowFrame(QFrame):
Layout.addWidget(self.LeftVarCombo) Layout.addWidget(self.LeftVarCombo)
self.OpCombo = makeComboWidget(COMPARE_OPTIONS, min_width=80, parent=self) self.OpCombo = makeComboWidget(COMPARE_OPTIONS, min_width=80, parent=self)
Layout.addWidget(self.OpCombo) Layout.addWidget(self.OpCombo)
self._CompTypeCombo = makeComboWidget([ self.CompTypeCombo = makeComboWidget([
("特定值", "literal"), ("特定值", "literal"),
("变量", "variable"), ("变量", "variable"),
], min_width=70, parent=self) ], min_width=70, parent=self)
Layout.addWidget(self._CompTypeCombo) Layout.addWidget(self.CompTypeCombo)
self.RhsStack = QStackedWidget(self) self.RhsStack = QStackedWidget(self)
self.RhsStack.setFixedHeight(25) self.RhsStack.setFixedHeight(25)
self.initLiteralStack() self.initLiteralStack()
@@ -94,7 +94,7 @@ class ConditionRowFrame(QFrame):
self.RhsStack.addWidget(self.RhsVarCombo) self.RhsStack.addWidget(self.RhsVarCombo)
self.RhsStack.setCurrentIndex(0) self.RhsStack.setCurrentIndex(0)
Layout.addWidget(self.RhsStack) Layout.addWidget(self.RhsStack)
if not self._isFirst: if not self._is_first:
self.DeleteBtn = QPushButton("×", self) self.DeleteBtn = QPushButton("×", self)
self.DeleteBtn.setFixedSize(25, 25) self.DeleteBtn.setFixedSize(25, 25)
self.DeleteBtn.setStyleSheet("color: red; font-weight: bold;") self.DeleteBtn.setStyleSheet("color: red; font-weight: bold;")
@@ -108,21 +108,21 @@ class ConditionRowFrame(QFrame):
self self
): ):
wasBool = self._isBoolMode was_bool = self._is_bool_mode
boolName = None bool_name = None
if wasBool: if was_bool:
data = self.LeftVarCombo.currentData() data = self.LeftVarCombo.currentData()
if data: if data:
boolName = data[0] bool_name = data[0]
self._varMgr.populateCombo(self.LeftVarCombo) self._var_mgr.populateCombo(self.LeftVarCombo)
# Append boolean literal sentinels at the end # Append boolean literal sentinels at the end
self.LeftVarCombo.insertSeparator(self.LeftVarCombo.count()) self.LeftVarCombo.insertSeparator(self.LeftVarCombo.count())
self.LeftVarCombo.addItem("true", ("true", "Boolean")) self.LeftVarCombo.addItem("true", ("true", "Boolean"))
self.LeftVarCombo.addItem("false", ("false", "Boolean")) self.LeftVarCombo.addItem("false", ("false", "Boolean"))
if wasBool and boolName: if was_bool and bool_name:
for ci in range(self.LeftVarCombo.count()): for ci in range(self.LeftVarCombo.count()):
d = self.LeftVarCombo.itemData(ci) d = self.LeftVarCombo.itemData(ci)
if d and d[0] == boolName: if d and d[0] == bool_name:
self.LeftVarCombo.setCurrentIndex(ci) self.LeftVarCombo.setCurrentIndex(ci)
break break
@@ -130,7 +130,7 @@ class ConditionRowFrame(QFrame):
self self
): ):
self._varMgr.populateCombo(self.RhsVarCombo) self._var_mgr.populateCombo(self.RhsVarCombo)
def initLiteralStack( def initLiteralStack(
self self
@@ -138,12 +138,12 @@ class ConditionRowFrame(QFrame):
self.LiteralStack = QStackedWidget(self) self.LiteralStack = QStackedWidget(self)
self.LiteralStack.setFixedHeight(25) self.LiteralStack.setFixedHeight(25)
self._literalWidgets = {} self.literal_widgets = {}
for vt in getTypeOrder(): for vt in getTypeOrder():
W = makeValueWidget(vt, self.LiteralStack) W = makeValueWidget(vt, self.LiteralStack)
self._literalWidgets[vt] = W self.literal_widgets[vt] = W
self.LiteralStack.addWidget(W) self.LiteralStack.addWidget(W)
self.LiteralStack.setCurrentWidget(self._literalWidgets.get("String")) self.LiteralStack.setCurrentWidget(self.literal_widgets.get("String"))
self.RhsStack.addWidget(self.LiteralStack) self.RhsStack.addWidget(self.LiteralStack)
def connectSignals( def connectSignals(
@@ -151,7 +151,7 @@ class ConditionRowFrame(QFrame):
): ):
self.LeftVarCombo.currentIndexChanged.connect(self.onLeftVarChanged) self.LeftVarCombo.currentIndexChanged.connect(self.onLeftVarChanged)
self._CompTypeCombo.currentIndexChanged.connect(self.onCompTypeChanged) self.CompTypeCombo.currentIndexChanged.connect(self.onCompTypeChanged)
def getLogic( def getLogic(
self self
@@ -164,16 +164,16 @@ class ConditionRowFrame(QFrame):
vartype: str vartype: str
): ):
if vartype not in self._literalWidgets: if vartype not in self.literal_widgets:
vartype = "String" vartype = "String"
self.LiteralStack.setCurrentWidget(self._literalWidgets[vartype]) self.LiteralStack.setCurrentWidget(self.literal_widgets[vartype])
def toScript( def toScript(
self self
) -> str: ) -> str:
data = self.LeftVarCombo.currentData() data = self.LeftVarCombo.currentData()
if self._isBoolMode and data: if self._is_bool_mode and data:
return data[0] return data[0]
if not data: if not data:
return "" return ""
@@ -183,28 +183,28 @@ class ConditionRowFrame(QFrame):
name = "datenow()" name = "datenow()"
elif name == "CURRENT_TIME": elif name == "CURRENT_TIME":
name = "timenow()" name = "timenow()"
opSym = self.OpCombo.currentData() op_sym = self.OpCombo.currentData()
if self._rawRhsExpr: if self._raw_rhs_expr:
return f"{name} {opSym} {self._rawRhsExpr}" return f"{name} {op_sym} {self._raw_rhs_expr}"
isVarRef = (self._CompTypeCombo.currentData() == "variable") is_var_ref = (self.CompTypeCombo.currentData() == "variable")
if isVarRef: if is_var_ref:
rd = self.RhsVarCombo.currentData() rd = self.RhsVarCombo.currentData()
if rd: if rd:
rhsName = rd[0] rhs_name = rd[0]
if rhsName == "CURRENT_DATE": if rhs_name == "CURRENT_DATE":
rhsName = "datenow()" rhs_name = "datenow()"
elif rhsName == "CURRENT_TIME": elif rhs_name == "CURRENT_TIME":
rhsName = "timenow()" rhs_name = "timenow()"
return f"{name} {opSym} {rhsName}" return f"{name} {op_sym} {rhs_name}"
rhsText = self.RhsVarCombo.currentText().strip() rhs_text = self.RhsVarCombo.currentText().strip()
if rhsText: if rhs_text:
return f"{name} {opSym} {rhsText}" return f"{name} {op_sym} {rhs_text}"
return "" return ""
w = self._literalWidgets.get(vartype) w = self.literal_widgets.get(vartype)
if w: if w:
rawVal = getValueFromWidget(w) raw_val = getValueFromWidget(w)
encoded = encodeValueStr(rawVal, vartype) encoded = encodeValueStr(raw_val, vartype)
return f"{name} {opSym} {encoded}" return f"{name} {op_sym} {encoded}"
return "" return ""
def refreshVarCombos( def refreshVarCombos(
@@ -220,19 +220,19 @@ class ConditionRowFrame(QFrame):
idx idx
): ):
self._rawRhsExpr = "" self._raw_rhs_expr = ""
if idx < 0: if idx < 0:
return return
data = self.LeftVarCombo.itemData(idx) data = self.LeftVarCombo.itemData(idx)
if not data: if not data:
return return
name, vartype = data name, vartype = data
isBool = name in ("true", "false") is_bool = name in ("true", "false")
self._isBoolMode = isBool self._is_bool_mode = is_bool
self.OpCombo.setVisible(not isBool) self.OpCombo.setVisible(not is_bool)
self._CompTypeCombo.setVisible(not isBool) self.CompTypeCombo.setVisible(not is_bool)
self.RhsStack.setVisible(not isBool) self.RhsStack.setVisible(not is_bool)
if not isBool: if not is_bool:
self.updateRHSLiteralWidget(vartype) self.updateRHSLiteralWidget(vartype)
@Slot(int) @Slot(int)
@@ -241,8 +241,8 @@ class ConditionRowFrame(QFrame):
idx idx
): ):
self._rawRhsExpr = "" self._raw_rhs_expr = ""
isVar = (self._CompTypeCombo.currentData() == "variable") isVar = (self.CompTypeCombo.currentData() == "variable")
self.RhsStack.setCurrentIndex(1 if isVar else 0) self.RhsStack.setCurrentIndex(1 if isVar else 0)
if isVar: if isVar:
self.populateRHSVarCombo() self.populateRHSVarCombo()
@@ -252,15 +252,15 @@ class ActionStepFrame(QFrame):
def __init__( def __init__(
self, self,
varMgr, var_mgr,
parentBlockIndex: int = 0, parent_block_index: int = 0,
parent = None parent = None
): ):
super().__init__(parent) super().__init__(parent)
self._varMgr = varMgr self._var_mgr = var_mgr
self._blockIndex = parentBlockIndex self._block_index = parent_block_index
self._currentTargetType = "String" self._current_target_type = "String"
self.setupUi() self.setupUi()
self.connectSignals() self.connectSignals()
@@ -312,7 +312,7 @@ class ActionStepFrame(QFrame):
for p in getPresetVars(): for p in getPresetVars():
if p["name"] in ("CURRENT_TIME", "CURRENT_DATE"): if p["name"] in ("CURRENT_TIME", "CURRENT_DATE"):
continue continue
info = self._varMgr.getInfoByName(p["name"]) info = self._var_mgr.getInfoByName(p["name"])
if info: if info:
self.TargetCombo.addItem( self.TargetCombo.addItem(
info["display"], info["display"],
@@ -324,18 +324,18 @@ class ActionStepFrame(QFrame):
self self
): ):
self._literalWidgets = {} self.literal_widgets = {}
self._offsetWidgets = {} self.offset_widgets = {}
for vt in getTypeOrder(): for vt in getTypeOrder():
self._literalWidgets[vt] = makeValueWidget(vt, self.ValueStack) self.literal_widgets[vt] = makeValueWidget(vt, self.ValueStack)
self.ValueStack.addWidget(self._literalWidgets[vt]) self.ValueStack.addWidget(self.literal_widgets[vt])
if getArithType(vt): if getArithType(vt):
self._offsetWidgets[vt] = makeOffsetWidget(vt, self.ValueStack) self.offset_widgets[vt] = makeOffsetWidget(vt, self.ValueStack)
self.ValueStack.addWidget(self._offsetWidgets[vt]) self.ValueStack.addWidget(self.offset_widgets[vt])
else: else:
Lbl = QLabel("(不支持该操作)", self.ValueStack) Lbl = QLabel("(不支持该操作)", self.ValueStack)
Lbl.setFixedHeight(25) Lbl.setFixedHeight(25)
self._offsetWidgets[vt] = Lbl self.offset_widgets[vt] = Lbl
self.ValueStack.addWidget(Lbl) self.ValueStack.addWidget(Lbl)
def connectSignals( def connectSignals(
@@ -358,14 +358,14 @@ class ActionStepFrame(QFrame):
): ):
op = self.OpTypeCombo.currentData() op = self.OpTypeCombo.currentData()
isArith = (op in ("add", "sub")) is_arith = (op in ("add", "sub"))
actualType = self._currentTargetType actual_type = self._current_target_type
if isArith and actualType in self._offsetWidgets: if is_arith and actual_type in self.offset_widgets:
self.ValueStack.setCurrentWidget(self._offsetWidgets[actualType]) self.ValueStack.setCurrentWidget(self.offset_widgets[actual_type])
elif actualType in self._literalWidgets: elif actual_type in self.literal_widgets:
self.ValueStack.setCurrentWidget(self._literalWidgets[actualType]) self.ValueStack.setCurrentWidget(self.literal_widgets[actual_type])
else: else:
self.ValueStack.setCurrentWidget(self._literalWidgets.get("String")) self.ValueStack.setCurrentWidget(self.literal_widgets.get("String"))
def toScript( def toScript(
self self
@@ -380,10 +380,10 @@ class ActionStepFrame(QFrame):
return " -- pass" return " -- pass"
if not target: if not target:
return "" return ""
rawVal = self.getValueRaw() raw_val = self.getValueRaw()
vartype = self._currentTargetType vartype = self._current_target_type
if op == "set": if op == "set":
encoded = encodeValueStr(rawVal, vartype) encoded = encodeValueStr(raw_val, vartype)
return f" {target} = {encoded}" return f" {target} = {encoded}"
elif op == "add": elif op == "add":
if vartype == "Date" and hasattr(self.ValueStack.currentWidget(), "getOffsetDays"): if vartype == "Date" and hasattr(self.ValueStack.currentWidget(), "getOffsetDays"):
@@ -392,7 +392,7 @@ class ActionStepFrame(QFrame):
if vartype == "Time" and hasattr(self.ValueStack.currentWidget(), "getOffsetHours"): if vartype == "Time" and hasattr(self.ValueStack.currentWidget(), "getOffsetHours"):
hours = self.ValueStack.currentWidget().getOffsetHours() hours = self.ValueStack.currentWidget().getOffsetHours()
return f" {target} = timeadd({target}, {hours})" return f" {target} = timeadd({target}, {hours})"
return f" {target} = {target} + {rawVal}" return f" {target} = {target} + {raw_val}"
elif op == "sub": elif op == "sub":
if vartype == "Date" and hasattr(self.ValueStack.currentWidget(), "getOffsetDays"): if vartype == "Date" and hasattr(self.ValueStack.currentWidget(), "getOffsetDays"):
days = self.ValueStack.currentWidget().getOffsetDays() days = self.ValueStack.currentWidget().getOffsetDays()
@@ -400,7 +400,7 @@ class ActionStepFrame(QFrame):
if vartype == "Time" and hasattr(self.ValueStack.currentWidget(), "getOffsetHours"): if vartype == "Time" and hasattr(self.ValueStack.currentWidget(), "getOffsetHours"):
hours = self.ValueStack.currentWidget().getOffsetHours() hours = self.ValueStack.currentWidget().getOffsetHours()
return f" {target} = timeadd({target}, -{hours})" return f" {target} = timeadd({target}, -{hours})"
return f" {target} = {target} - {rawVal}" return f" {target} = {target} - {raw_val}"
return "" return ""
def getValueRaw( def getValueRaw(
@@ -419,15 +419,15 @@ class ActionStepFrame(QFrame):
self self
): ):
currentData = self.TargetCombo.currentData() current_data = self.TargetCombo.currentData()
self.populateTargetCombo() self.populateTargetCombo()
if currentData: if current_data:
for i in range(self.TargetCombo.count()): for i in range(self.TargetCombo.count()):
d = self.TargetCombo.itemData(i) d = self.TargetCombo.itemData(i)
if d and d[0] == currentData[0]: if d and d[0] == current_data[0]:
self.TargetCombo.setCurrentIndex(i) self.TargetCombo.setCurrentIndex(i)
break break
self._varMgr.populateCombo(self.ExistingVarCombo) self._var_mgr.populateCombo(self.ExistingVarCombo)
@Slot(int) @Slot(int)
def onTargetChanged( def onTargetChanged(
@@ -441,7 +441,7 @@ class ActionStepFrame(QFrame):
if not data: if not data:
return return
_, vartype = data _, vartype = data
self._currentTargetType = vartype self._current_target_type = vartype
self.updateValueWidget() self.updateValueWidget()
self.onValueSrcChanged(self.ValueSrcCombo.currentIndex()) self.onValueSrcChanged(self.ValueSrcCombo.currentIndex())
@@ -459,10 +459,10 @@ class ActionStepFrame(QFrame):
idx idx
): ):
isVar = (self.ValueSrcCombo.currentData() == "variable") is_var = (self.ValueSrcCombo.currentData() == "variable")
self.ValueStack.setVisible(not isVar) self.ValueStack.setVisible(not is_var)
self.ExistingVarCombo.setVisible(isVar) self.ExistingVarCombo.setVisible(is_var)
if isVar: if is_var:
self._varMgr.populateCombo(self.ExistingVarCombo) self._var_mgr.populateCombo(self.ExistingVarCombo)
else: else:
self.updateValueWidget() self.updateValueWidget()
+352
View File
@@ -0,0 +1,352 @@
# -*- coding: utf-8 -*-
"""
Copyright (c) 2026 KenanZhu.
All rights reserved.
This software is provided "as is", without any warranty of any kind.
You may use, modify, and distribute this file under the terms of the MIT License.
See the LICENSE file for details.
"""
from datetime import datetime, timedelta
from typing import Any
from PySide6.QtCore import (
Qt,
Signal,
Slot,
QTimer
)
from PySide6.QtGui import (
QFont
)
from PySide6.QtWidgets import (
QDialog,
QHBoxLayout,
QLabel,
QListWidgetItem,
QMessageBox,
QVBoxLayout,
QWidget
)
from gui.ALBulletinWorker import ALBulletinFetchWorker
from gui.ALStatusLabel import ALStatusLabel
from gui.resources.ui.Ui_ALBulletinDialog import Ui_ALBulletinDialog
from managers.bulletin.BulletinManager import instance as bulletinInstance
class ALBulletinItemWidget(QWidget):
"""
Single bulletin item widget for the bulletin list.
"""
def __init__(
self,
parent=None,
bulletin: dict[str, Any] = None
):
super().__init__(parent)
self.__bulletin = bulletin.copy() if bulletin else {}
self.modifyUi()
def modifyUi(
self
):
self.ItemWidgetLayout = QVBoxLayout()
self.ItemWidgetLayout.setSpacing(10)
self.ItemWidgetLayout.setContentsMargins(10, 5, 10, 5)
self.BulletinTitleLayout = QHBoxLayout()
self.BulletinTitleLabel = QLabel(self.__bulletin.get("title", "无标题"))
title_font = QFont()
title_font.setBold(True)
self.BulletinTitleLabel.setFont(title_font)
self.BulletinTitleLayout.addWidget(self.BulletinTitleLabel)
if self.__bulletin.get("isNew", False):
self.NewIndicatorLabel = QLabel("新 !")
self.NewIndicatorLabel.setStyleSheet(
"color: #DC0000;"\
"font-size: 10px;"\
"font-weight: bold;"\
"font-style: italic;"
)
self.NewIndicatorLabel.setFixedSize(25, 25)
self.BulletinTitleLayout.addWidget(self.NewIndicatorLabel)
else:
self.NewIndicatorLabel = None
if self.__bulletin.get("isEdited", False):
self.BulletinIsEditedLabel = QLabel("(已编辑)")
self.BulletinIsEditedLabel.setStyleSheet(
"color: #FF9800;"\
"font-size: 10px;"
)
self.BulletinTitleLayout.addWidget(self.BulletinIsEditedLabel)
self.BulletinTitleLayout.addStretch()
self.ItemWidgetLayout.addLayout(self.BulletinTitleLayout)
self.BulletinInfoLayout = QHBoxLayout()
self.BulletinDateLabel = QLabel()
try:
raw_dt = self.__bulletin.get("dateTime", "")
date_time = datetime.fromisoformat(raw_dt) if raw_dt else datetime.now()
except (ValueError, TypeError):
date_time = datetime.now()
self.BulletinDateLabel.setText(date_time.strftime("%Y-%m-%d %H:%M:%S"))
self.BulletinDateLabel.setStyleSheet("color: #969696; font-size: 11px;")
self.BulletinInfoLayout.addWidget(self.BulletinDateLabel)
self.BulletinAuthorLabel = QLabel(self.__bulletin.get("author", "未知"))
self.BulletinAuthorLabel.setStyleSheet("color: #969696; font-size: 11px;")
self.BulletinInfoLayout.addWidget(self.BulletinAuthorLabel)
self.BulletinInfoLayout.addStretch()
self.ItemWidgetLayout.addLayout(self.BulletinInfoLayout)
self.setLayout(self.ItemWidgetLayout)
self.__bulletin = None
def markAsRead(
self
):
if self.NewIndicatorLabel:
self.NewIndicatorLabel.hide()
class ALBulletinDialog(QDialog, Ui_ALBulletinDialog):
"""
Bulletin viewer dialog.
"""
def __init__(
self,
parent=None
):
super().__init__(parent)
self.__sync_timer: QTimer | None = None
self.__fetch_worker: ALBulletinFetchWorker | None = None
self.__bulletin_mgr = bulletinInstance()
self.setupUi(self)
self.modifyUi()
self.connectSignals()
self.setupTimer()
if self.shouldAutoSync():
self.syncBulletin()
def modifyUi(
self
):
self.ALSyncStatusLabel = ALStatusLabel(self)
self.ALSyncStatusLabel.setFixedSize(30, 30)
self.SyncLayout.replaceWidget(
self.SyncStatusPlaceholder, self.ALSyncStatusLabel
)
title_font = QFont()
title_font.setBold(True)
title_font.setPointSize(15)
self.BulletinTitleLabel.setFont(title_font)
self.BulletinDateLabel.setStyleSheet("color: #969696;")
self.BulletinAuthorLabel.setStyleSheet("color: #969696;")
self.BulletinIsEditedLabel.setStyleSheet("color: #FF9800;")
self.BulletinIsEditedLabel.hide()
last_time = self.__bulletin_mgr.lastSyncTime()
if last_time:
self.setLastSyncTime(last_time)
self.updateBulletinList(self.__bulletin_mgr.bulletins())
def shouldAutoSync(
self
) -> bool:
last = self.__bulletin_mgr.lastSyncTime()
if last is None:
return True
try:
last_dt = datetime.fromisoformat(last)
interval_min = max(self.__bulletin_mgr.syncInterval()//2, 1)
threshold = timedelta(minutes=interval_min)
return (datetime.now().astimezone() - last_dt) > threshold
except (ValueError, TypeError):
return True
def setLastSyncTime(
self,
iso_str: str
):
"""
Set last sync time to update the UI status.
"""
try:
dt = datetime.fromisoformat(iso_str)
self.LastSyncDateTimeEdit.setDateTime(dt)
except (ValueError, TypeError):
pass
def connectSignals(
self
):
self.BulletinListWidget.itemClicked.connect(self.onBulletinListWidgetItemClicked)
self.SyncButton.clicked.connect(self.syncBulletin)
def setupTimer(
self
):
self.__sync_timer = QTimer(self)
self.__sync_timer.timeout.connect(self.syncBulletin)
self.__sync_timer.start(self.__bulletin_mgr.syncInterval()*60*1000)
def updateBulletinList(
self,
bulletins: list[dict]
):
self.BulletinListWidget.clear()
sorted_list = sorted(
bulletins, key=lambda x: x.get("dateTime", ""), reverse=True
)
for bulletin in sorted_list:
item = QListWidgetItem()
item.setData(Qt.UserRole, bulletin)
widget = ALBulletinItemWidget(self, bulletin)
item.setSizeHint(widget.sizeHint())
self.BulletinListWidget.addItem(item)
self.BulletinListWidget.setItemWidget(item, widget)
def showBulletin(
self,
bulletin: dict
):
self.BulletinTitleLabel.setText(bulletin.get("title", "无标题"))
try:
raw_dt = bulletin.get("dateTime", "")
date_time = datetime.fromisoformat(raw_dt) if raw_dt else datetime.now()
except (ValueError, TypeError):
date_time = datetime.now()
self.BulletinDateLabel.setText(date_time.strftime("%Y-%m-%d %H:%M:%S"))
self.BulletinAuthorLabel.setText(bulletin.get("author", "未知"))
if bulletin.get("isEdited", False):
self.BulletinIsEditedLabel.show()
else:
self.BulletinIsEditedLabel.hide()
self.BulletinContentTextBrowser.setHtml(
"<div style='font-size: 14px;'>{content}</div>".format(
content=bulletin.get("content", "无内容")
)
)
def clearBulletin(
self
):
self.BulletinTitleLabel.setText("")
self.BulletinDateLabel.setText("")
self.BulletinAuthorLabel.setText("")
self.BulletinContentTextBrowser.setText("")
self.BulletinIsEditedLabel.hide()
def syncBulletin(
self
):
if self.__fetch_worker is not None:
return
self.SyncButton.setEnabled(False)
self.SyncButton.setText("同步中...")
self.ALSyncStatusLabel.status = ALStatusLabel.Status.RUNNING
self.SyncStatusDetailLabel.setText("")
self.SyncStatusDetailLabel.setStyleSheet("")
params = self.__bulletin_mgr.getSyncDateTimeAndRange()
self.__fetch_worker = ALBulletinFetchWorker(
self, self.__bulletin_mgr.apiUrl(), params
)
self.__fetch_worker.fetchWorkerIsFinished.connect(self.onBulletinsFetched)
self.__fetch_worker.fetchWorkerFinishedWithError.connect(self.onBulletinsFetchError)
self.__fetch_worker.start()
@Slot(dict)
def onBulletinsFetched(
self,
data: dict
):
worker = self.sender()
if worker is not self.__fetch_worker:
return
worker.fetchWorkerIsFinished.disconnect(self.onBulletinsFetched)
worker.fetchWorkerFinishedWithError.disconnect(self.onBulletinsFetchError)
worker.wait(2000)
worker.deleteLater()
self.__fetch_worker = None
bulletins = data.get("bulletins", [])
delete_ids = data.get("delete_ids", [])
merged = self.__bulletin_mgr.updateAndMergeBulletins(bulletins, delete_ids)
self.__bulletin_mgr.setLastSyncTime(datetime.now().astimezone().isoformat())
self.SyncButton.setEnabled(True)
self.SyncButton.setText("同步")
self.ALSyncStatusLabel.status = ALStatusLabel.Status.SUCCESS
self.SyncStatusDetailLabel.setText("同步成功")
self.SyncStatusDetailLabel.setStyleSheet("color: green;")
QTimer.singleShot(3000, self, self.clearSyncStatus)
self.setLastSyncTime(self.__bulletin_mgr.lastSyncTime())
self.updateBulletinList(merged)
self.clearBulletin()
interval_ms = self.__bulletin_mgr.syncInterval()*60*1000
if self.__sync_timer:
self.__sync_timer.start(interval_ms)
@Slot(str)
def onBulletinsFetchError(
self,
error_message: str
):
worker = self.sender()
if worker is not self.__fetch_worker:
return
worker.fetchWorkerIsFinished.disconnect(self.onBulletinsFetched)
worker.fetchWorkerFinishedWithError.disconnect(self.onBulletinsFetchError)
worker.wait(2000)
worker.deleteLater()
self.__fetch_worker = None
self.SyncButton.setEnabled(True)
self.SyncButton.setText("重试")
self.ALSyncStatusLabel.status = ALStatusLabel.Status.FAILURE
self.SyncStatusDetailLabel.setText("同步失败,请检查网络连接")
self.SyncStatusDetailLabel.setStyleSheet("color: red;")
QMessageBox.warning(
self,
"警告 - AutoLibrary",
f"同步失败:{error_message}"
)
interval_ms = self.__bulletin_mgr.syncInterval()*60*1000
if self.__sync_timer:
self.__sync_timer.start(interval_ms)
def clearSyncStatus(
self
):
self.ALSyncStatusLabel.status = ALStatusLabel.Status.WAITING
self.SyncStatusDetailLabel.setText("")
self.SyncStatusDetailLabel.setStyleSheet("")
@Slot(QListWidgetItem)
def onBulletinListWidgetItemClicked(
self,
item: QListWidgetItem
):
if item is None or item.data(Qt.UserRole) is None:
return
bulletin = item.data(Qt.UserRole)
if bulletin.get("isNew", False):
self.__bulletin_mgr.markBulletinAsRead(bulletin["id"])
widget = self.BulletinListWidget.itemWidget(item)
if widget:
widget.markAsRead()
item.setData(Qt.UserRole, bulletin)
self.showBulletin(bulletin)
+188
View File
@@ -0,0 +1,188 @@
# -*- coding: utf-8 -*-
"""
Copyright (c) 2026 KenanZhu.
All rights reserved.
This software is provided "as is", without any warranty of any kind.
You may use, modify, and distribute this file under the terms of the MIT License.
See the LICENSE file for details.
"""
from datetime import datetime
from PySide6.QtCore import (
QObject,
QTimer,
Signal,
Slot
)
from gui.ALBulletinWorker import ALBulletinFetchWorker
from managers.bulletin.BulletinManager import instance as bulletinInstance
class ALBulletinPoller(QObject):
"""
Background bulletin poller.
Owns the periodic poll timer and the ALBulletinFetchWorker
lifecycle. Emits signals when new bulletins are detected so
the owner can show tray notifications without knowing the
fetch / merge details.
"""
newBulletinsDetected = Signal(int)
def __init__(
self,
parent=None
):
super().__init__(parent)
self.__timer = QTimer(self)
self.__timer.timeout.connect(self.__poll)
self.__worker = None
self.__dialog_open = False
self.__stopped = False
self.__mgr = bulletinInstance()
def start(
self
):
self.__stopped = False
interval_ms = self.__mgr.syncInterval()*60*1000
self.__timer.start(interval_ms)
def stop(
self
):
self.__stopped = True
self.__timer.stop()
self.__cleanupWorker()
def restart(
self
):
self.stop()
self.start()
def fetchNow(
self
):
if self.__worker is not None:
return
if self.__dialog_open:
return
self.__doFetch()
def isPolling(
self
) -> bool:
return self.__timer.isActive()
def setDialogOpen(
self,
open: bool
):
self.__dialog_open = open
def __disconnectWorker(
self,
worker: ALBulletinFetchWorker
):
try:
worker.fetchWorkerIsFinished.disconnect(self.__onFetched)
except (TypeError, RuntimeError):
pass
try:
worker.fetchWorkerFinishedWithError.disconnect(self.__onError)
except (TypeError, RuntimeError):
pass
def __cleanupWorker(
self
):
if self.__worker is None:
return
self.__disconnectWorker(self.__worker)
self.__worker.wait(500)
self.__worker.deleteLater()
self.__worker = None
def __doFetch(
self
):
params = self.__mgr.getSyncDateTimeAndRange()
self.__worker = ALBulletinFetchWorker(
self, self.__mgr.apiUrl(), params
)
self.__worker.fetchWorkerIsFinished.connect(self.__onFetched)
self.__worker.fetchWorkerFinishedWithError.connect(self.__onError)
self.__worker.start()
@Slot()
def __poll(
self
):
if self.__worker is not None:
return
if self.__dialog_open:
return
self.__doFetch()
@Slot(dict)
def __onFetched(
self,
data: dict
):
worker = self.sender()
if worker is not self.__worker:
return
self.__disconnectWorker(worker)
worker.wait(500)
worker.deleteLater()
self.__worker = None
old_ids = {str(b.get("id", "")) for b in self.__mgr.bulletins()}
bulletins = data.get("bulletins", [])
delete_ids = data.get("delete_ids", [])
self.__mgr.updateAndMergeBulletins(bulletins, delete_ids)
self.__mgr.setLastSyncTime(datetime.now().astimezone().isoformat())
delete_id_set = {str(d) for d in delete_ids}
new_ids = {
str(b.get("id", ""))
for b in bulletins
if str(b.get("id", "")) and str(b.get("id", "")) not in old_ids
}
new_ids -= delete_id_set
if new_ids and not self.__dialog_open:
self.newBulletinsDetected.emit(len(new_ids))
if not self.__stopped:
interval_ms = self.__mgr.syncInterval()*60*1000
self.__timer.start(interval_ms)
@Slot(str)
def __onError(
self,
error_message: str
):
worker = self.sender()
if worker is not self.__worker:
return
self.__disconnectWorker(worker)
worker.wait(500)
worker.deleteLater()
self.__worker = None
if not self.__stopped:
interval_ms = self.__mgr.syncInterval()*60*1000
self.__timer.start(interval_ms)
+57
View File
@@ -0,0 +1,57 @@
# -*- coding: utf-8 -*-
"""
Copyright (c) 2026 KenanZhu.
All rights reserved.
This software is provided "as is", without any warranty of any kind.
You may use, modify, and distribute this file under the terms of the MIT License.
See the LICENSE file for details.
"""
import requests
from PySide6.QtCore import (
QThread,
Signal
)
class ALBulletinFetchWorker(QThread):
"""
Worker thread for fetching bulletins from the server.
"""
fetchWorkerIsFinished = Signal(dict)
fetchWorkerFinishedWithError = Signal(str)
def __init__(
self,
parent=None,
request_url: str = "",
params: dict = None
):
super().__init__(parent)
self.__request_url = request_url.rstrip("/") if request_url else ""
self.__params = params or {}
def run(
self
):
try:
response = requests.get(
self.__request_url, params=self.__params, timeout=5
)
response.raise_for_status()
r = response.json()
if r.get("code") == 200:
data = r.get("data", {})
self.fetchWorkerIsFinished.emit(data)
else:
raise Exception(
f"服务器返回错误: [{r.get('code', '未知代码')}] {r.get('msg', '未知错误')}"
)
except requests.RequestException as e:
self.fetchWorkerFinishedWithError.emit(f"获取公告数据时发生网络错误: \n{e}")
except Exception as e:
self.fetchWorkerFinishedWithError.emit(f"获取公告数据时发生未知错误: \n{e}")
+192
View File
@@ -0,0 +1,192 @@
# -*- coding: utf-8 -*-
"""
Copyright (c) 2026 KenanZhu.
All rights reserved.
This software is provided "as is", without any warranty of any kind.
You may use, modify, and distribute this file under the terms of the MIT License.
See the LICENSE file for details.
"""
from PySide6.QtCore import Qt
from PySide6.QtGui import QDesktopServices
from PySide6.QtCore import QUrl
from PySide6.QtWidgets import (
QDialog,
QDialogButtonBox,
QLabel,
QVBoxLayout
)
class ALCheckUpdateDialog(QDialog):
"""
Dialog for showing update check result.
Supports two layouts:
- has_update=True : version comparison with green text + action buttons
- has_update=False: "already up-to-date" message + close button
"""
def __init__(
self,
parent=None,
has_update: bool = False,
current_version: str = "",
latest_version: str = "",
tag_name: str = "",
html_url: str = ""
):
super().__init__(parent)
self.__has_update = has_update
self.__html_url = html_url
self.__current_version = current_version
self.__latest_version = latest_version
self.__tag_name = tag_name
self.modifyUi()
self.connectSignals()
def modifyUi(
self
):
self.setWindowTitle("检查更新 - AutoLibrary")
self.setMinimumWidth(360)
Layout = QVBoxLayout(self)
Layout.setSpacing(12)
Layout.setContentsMargins(20, 20, 20, 20)
if self.__has_update:
self.buildUpdateAvailableUi(Layout)
else:
self.buildUpToDateUi(Layout)
def buildUpdateAvailableUi(
self,
layout: QVBoxLayout
):
TitleLabel = QLabel()
TitleLabel.setStyleSheet(
"font-size: 14px;"
"font-weight: bold;"
)
TitleLabel.setTextFormat(Qt.TextFormat.RichText)
TitleLabel.setText(
f"检测到新版本: "
f"<span>{self.__current_version}</span> "
f"<span>&gt;</span> "
f"<span style='color: green; font-weight: bold;'>{self.__latest_version}</span>"
)
layout.addWidget(TitleLabel)
InfoLabel = QLabel()
InfoLabel.setTextFormat(Qt.TextFormat.RichText)
InfoLabel.setWordWrap(True)
InfoLabel.setText(
f"最新版本: <b>{self.__tag_name}</b><br>"
)
InfoLabel.setOpenExternalLinks(True)
layout.addWidget(InfoLabel)
layout.addSpacing(8)
self.__button_box = QDialogButtonBox()
self.__btn_github = self.__button_box.addButton(
"前往 GitHub",
QDialogButtonBox.ButtonRole.AcceptRole
)
self.__btn_github.setMinimumWidth(100)
self.__btn_github.setMaximumWidth(100)
self.__btn_github.setMinimumHeight(25)
self.__btn_github.setMaximumHeight(25)
self.__btn_download = self.__button_box.addButton(
"官网下载",
QDialogButtonBox.ButtonRole.ActionRole
)
self.__btn_download.setMinimumWidth(80)
self.__btn_download.setMaximumWidth(80)
self.__btn_download.setMinimumHeight(25)
self.__btn_download.setMaximumHeight(25)
self.__btn_cancel = self.__button_box.addButton(
"取消",
QDialogButtonBox.ButtonRole.RejectRole
)
self.__btn_cancel.setMinimumWidth(80)
self.__btn_cancel.setMaximumWidth(80)
self.__btn_cancel.setMinimumHeight(25)
self.__btn_cancel.setMaximumHeight(25)
layout.addWidget(self.__button_box)
def buildUpToDateUi(
self,
layout: QVBoxLayout
):
TitleLabel = QLabel()
TitleLabel.setStyleSheet(
"font-size: 14px;"
"font-weight: bold;"
)
TitleLabel.setText(f"已是最新版本 !")
layout.addWidget(TitleLabel)
InfoLabel = QLabel()
InfoLabel.setText(
f"当前版本: <b>{self.__current_version}</b><br>"
)
layout.addWidget(InfoLabel)
layout.addStretch()
self.__button_box = QDialogButtonBox()
self.__btn_close = self.__button_box.addButton(
"确定",
QDialogButtonBox.ButtonRole.AcceptRole
)
self.__btn_close.setMinimumWidth(80)
self.__btn_close.setMaximumWidth(80)
self.__btn_close.setMinimumHeight(25)
self.__btn_close.setMaximumHeight(25)
layout.addWidget(self.__button_box)
def connectSignals(
self
):
if self.__has_update:
self.__btn_github.clicked.connect(self.onGoToGitHub)
self.__btn_download.clicked.connect(self.onGoToDownload)
self.__btn_cancel.clicked.connect(self.reject)
else:
self.__btn_close.clicked.connect(self.accept)
def onGoToGitHub(
self
):
QDesktopServices.openUrl(QUrl(self.__html_url))
self.accept()
def onGoToDownload(
self
):
QDesktopServices.openUrl(
QUrl("https://www.autolibrary.kenanzhu.com/downloads")
)
self.accept()
@staticmethod
def showResult(
parent,
has_update: bool,
current_version: str,
latest_version: str = "",
tag_name: str = "",
html_url: str = ""
):
dialog = ALCheckUpdateDialog(
parent,
has_update=has_update,
current_version=current_version,
latest_version=latest_version,
tag_name=tag_name,
html_url=html_url
)
dialog.exec()
+51
View File
@@ -0,0 +1,51 @@
# -*- coding: utf-8 -*-
"""
Copyright (c) 2026 KenanZhu.
All rights reserved.
This software is provided "as is", without any warranty of any kind.
You may use, modify, and distribute this file under the terms of the MIT License.
See the LICENSE file for details.
"""
import requests
from PySide6.QtCore import (
QThread,
Signal
)
class ALCheckUpdateWorker(QThread):
"""
Worker thread for checking latest release from GitHub API.
"""
checkUpdateWorkerIsFinished = Signal(dict)
checkUpdateWorkerFinishedWithError = Signal(str)
def __init__(
self,
parent=None
):
super().__init__(parent)
self.__api_url = "https://api.github.com/repos/KenanZhu/AutoLibrary/releases/latest"
def run(
self
):
try:
response = requests.get(self.__api_url, timeout=10)
response.raise_for_status()
data = response.json()
self.checkUpdateWorkerIsFinished.emit({
"tag_name": data.get("tag_name", ""),
"name": data.get("name", ""),
"html_url": data.get("html_url", ""),
"body": data.get("body", ""),
})
except requests.RequestException as e:
self.checkUpdateWorkerFinishedWithError.emit(f"网络请求失败: \n{e}")
except Exception as e:
self.checkUpdateWorkerFinishedWithError.emit(f"检查更新时发生未知错误: \n{e}")
+76 -99
View File
@@ -42,6 +42,7 @@ from gui.ALUserTreeWidget import (
ALUserTreeWidget ALUserTreeWidget
) )
from gui.ALWebDriverDownloadDialog import ALWebDriverDownloadDialog from gui.ALWebDriverDownloadDialog import ALWebDriverDownloadDialog
from gui.ALWidgetMixin import CenterOnParentMixin
from gui.resources.ui.Ui_ALConfigWidget import Ui_ALConfigWidget from gui.resources.ui.Ui_ALConfigWidget import Ui_ALConfigWidget
from interfaces.ConfigProvider import ( from interfaces.ConfigProvider import (
CfgKey, CfgKey,
@@ -52,7 +53,7 @@ from utils.JSONReader import JSONReader
from utils.JSONWriter import JSONWriter from utils.JSONWriter import JSONWriter
class ALConfigWidget(QWidget, Ui_ALConfigWidget): class ALConfigWidget(CenterOnParentMixin, QWidget, Ui_ALConfigWidget):
configWidgetIsClosed = Signal() configWidgetIsClosed = Signal()
@@ -110,29 +111,6 @@ class ALConfigWidget(QWidget, Ui_ALConfigWidget):
self.ConfirmButton.clicked.connect(self.onConfirmButtonClicked) self.ConfirmButton.clicked.connect(self.onConfirmButtonClicked)
self.CancelButton.clicked.connect(self.onCancelButtonClicked) self.CancelButton.clicked.connect(self.onCancelButtonClicked)
def showEvent(
self,
event
):
result = super().showEvent(event)
screen_rect = self.screen().geometry()
target_pos = self.parent().geometry().center()
target_pos.setX(target_pos.x() - self.width()//2)
target_pos.setY(target_pos.y() - self.height()//2)
if target_pos.x() < 0:
target_pos.setX(0)
if target_pos.x() + self.width() > screen_rect.width():
target_pos.setX(screen_rect.width() - self.width())
if target_pos.y() < 0:
target_pos.setY(0)
if target_pos.y() + self.height() > screen_rect.height():
target_pos.setY(screen_rect.height() - self.height())
self.move(target_pos)
return result
def closeEvent( def closeEvent(
self, self,
event: QCloseEvent event: QCloseEvent
@@ -386,18 +364,18 @@ class ALConfigWidget(QWidget, Ui_ALConfigWidget):
user_config = self.defaultUserConfig() user_config = self.defaultUserConfig()
for i in range(self.UserTreeWidget.topLevelItemCount()): for i in range(self.UserTreeWidget.topLevelItemCount()):
GroupItem = self.UserTreeWidget.topLevelItem(i) group_item = self.UserTreeWidget.topLevelItem(i)
group_config = { group_config = {
"name": GroupItem.text(0), "name": group_item.text(0),
"enabled": GroupItem.checkState(1) == Qt.CheckState.Checked, "enabled": group_item.checkState(1) == Qt.CheckState.Checked,
"users": [] "users": []
} }
for j in range(GroupItem.childCount()): for j in range(group_item.childCount()):
UserItem = GroupItem.child(j) user_item = group_item.child(j)
user = UserItem.data(0, Qt.UserRole) user = user_item.data(0, Qt.UserRole)
if not user: if not user:
continue continue
user["enabled"] = UserItem.checkState(1) == Qt.CheckState.Checked user["enabled"] = user_item.checkState(1) == Qt.CheckState.Checked
group_config["users"].append(user) group_config["users"].append(user)
user_config["groups"].append(group_config) user_config["groups"].append(group_config)
return user_config return user_config
@@ -453,18 +431,18 @@ class ALConfigWidget(QWidget, Ui_ALConfigWidget):
try: try:
if "groups" in users: if "groups" in users:
for group_config in users["groups"]: for group_config in users["groups"]:
GroupItem = QTreeWidgetItem(self.UserTreeWidget, ALUserTreeItemType.GROUP.value) group_item = QTreeWidgetItem(self.UserTreeWidget, ALUserTreeItemType.GROUP.value)
GroupItem.setText(0, group_config["name"]) group_item.setText(0, group_config["name"])
GroupItem.setFlags(GroupItem.flags() | Qt.ItemIsEditable) group_item.setFlags(group_item.flags() | Qt.ItemIsEditable)
GroupItem.setCheckState(1, Qt.Checked if group_config.get("enabled", True) else Qt.Unchecked) group_item.setCheckState(1, Qt.Checked if group_config.get("enabled", True) else Qt.Unchecked)
for user_config in group_config["users"]: for user_config in group_config["users"]:
UserItem = QTreeWidgetItem(GroupItem, ALUserTreeItemType.USER.value) user_item = QTreeWidgetItem(group_item, ALUserTreeItemType.USER.value)
UserItem.setText(0, user_config["username"]) user_item.setText(0, user_config["username"])
UserItem.setText(1, "" if user_config.get("enabled", True) else "跳过") user_item.setText(1, "" if user_config.get("enabled", True) else "跳过")
UserItem.setData(0, Qt.UserRole, user_config) user_item.setData(0, Qt.UserRole, user_config)
UserItem.setCheckState(1, Qt.Checked if user_config.get("enabled", True) else Qt.Unchecked) user_item.setCheckState(1, Qt.Checked if user_config.get("enabled", True) else Qt.Unchecked)
UserItem.setDisabled(not group_config.get("enabled", True)) user_item.setDisabled(not group_config.get("enabled", True))
GroupItem.setExpanded(True) group_item.setExpanded(True)
except KeyError as e: except KeyError as e:
QMessageBox.warning( QMessageBox.warning(
self, self,
@@ -638,43 +616,43 @@ class ALConfigWidget(QWidget, Ui_ALConfigWidget):
) -> QTreeWidgetItem: ) -> QTreeWidgetItem:
self.UserTreeWidget.itemChanged.disconnect(self.onUserTreeWidgetItemChanged) self.UserTreeWidget.itemChanged.disconnect(self.onUserTreeWidgetItemChanged)
GroupItem = QTreeWidgetItem(self.UserTreeWidget, ALUserTreeItemType.GROUP.value) group_item = QTreeWidgetItem(self.UserTreeWidget, ALUserTreeItemType.GROUP.value)
if not group_name: if not group_name:
group_name = f"新分组-{self.UserTreeWidget.topLevelItemCount()}" group_name = f"新分组-{self.UserTreeWidget.topLevelItemCount()}"
GroupItem.setText(0, group_name) group_item.setText(0, group_name)
GroupItem.setFlags(GroupItem.flags() | Qt.ItemIsEditable) group_item.setFlags(group_item.flags() | Qt.ItemIsEditable)
GroupItem.setCheckState(1, Qt.Checked) group_item.setCheckState(1, Qt.Checked)
self.UserTreeWidget.setCurrentItem(GroupItem) self.UserTreeWidget.setCurrentItem(group_item)
self.UserTreeWidget.itemChanged.connect(self.onUserTreeWidgetItemChanged) self.UserTreeWidget.itemChanged.connect(self.onUserTreeWidgetItemChanged)
return GroupItem return group_item
def delGroup( def delGroup(
self, self,
GroupItem: QTreeWidgetItem = None group_item: QTreeWidgetItem = None
): ):
if GroupItem is None: if group_item is None:
return return
if GroupItem.type() != ALUserTreeItemType.GROUP.value: if group_item.type() != ALUserTreeItemType.GROUP.value:
return return
index = self.UserTreeWidget.indexOfTopLevelItem(GroupItem) index = self.UserTreeWidget.indexOfTopLevelItem(group_item)
self.UserTreeWidget.takeTopLevelItem(index) self.UserTreeWidget.takeTopLevelItem(index)
def addUser( def addUser(
self, self,
GroupItem: QTreeWidgetItem = None group_item: QTreeWidgetItem = None
) -> QTreeWidgetItem: ) -> QTreeWidgetItem:
if GroupItem is None: if group_item is None:
CurrentItem = self.UserTreeWidget.currentItem() current_item = self.UserTreeWidget.currentItem()
if CurrentItem is None: if current_item is None:
GroupItem = self.addGroup() group_item = self.addGroup()
if GroupItem.type() == ALUserTreeItemType.USER.value: if group_item.type() == ALUserTreeItemType.USER.value:
GroupItem = GroupItem.parent() group_item = group_item.parent()
if GroupItem.checkState(1) == Qt.CheckState.Unchecked: if group_item.checkState(1) == Qt.CheckState.Unchecked:
return None return None
new_user = { new_user = {
"username": f"新用户-{GroupItem.childCount()}", "username": f"新用户-{group_item.childCount()}",
"password": "000000", "password": "000000",
"enabled": True, "enabled": True,
"reserve_info": { "reserve_info": {
@@ -703,30 +681,30 @@ class ALConfigWidget(QWidget, Ui_ALConfigWidget):
} }
} }
self.UserTreeWidget.itemChanged.disconnect(self.onUserTreeWidgetItemChanged) self.UserTreeWidget.itemChanged.disconnect(self.onUserTreeWidgetItemChanged)
UserItem = QTreeWidgetItem(GroupItem, ALUserTreeItemType.USER.value) user_item = QTreeWidgetItem(group_item, ALUserTreeItemType.USER.value)
UserItem.setText(0, new_user["username"]) user_item.setText(0, new_user["username"])
UserItem.setText(1, "") user_item.setText(1, "")
UserItem.setData(0, Qt.UserRole, new_user) user_item.setData(0, Qt.UserRole, new_user)
UserItem.setCheckState(1, Qt.CheckState.Checked) user_item.setCheckState(1, Qt.CheckState.Checked)
GroupItem.setExpanded(True) group_item.setExpanded(True)
self.UserTreeWidget.setCurrentItem(UserItem) self.UserTreeWidget.setCurrentItem(user_item)
self.setUserToWidget(new_user) self.setUserToWidget(new_user)
self.UserTreeWidget.itemChanged.connect(self.onUserTreeWidgetItemChanged) self.UserTreeWidget.itemChanged.connect(self.onUserTreeWidgetItemChanged)
return UserItem return user_item
def delUser( def delUser(
self, self,
UserItem: QTreeWidgetItem = None user_item: QTreeWidgetItem = None
): ):
if UserItem is None: if user_item is None:
return return
if UserItem.type() != ALUserTreeItemType.USER.value: if user_item.type() != ALUserTreeItemType.USER.value:
return return
ParentItem = UserItem.parent() parent_item = user_item.parent()
index = ParentItem.indexOfChild(UserItem) index = parent_item.indexOfChild(user_item)
ParentItem.takeChild(index) parent_item.takeChild(index)
if ParentItem.childCount() == 0: if parent_item.childCount() == 0:
self.UserTreeWidget.setCurrentItem(None) self.UserTreeWidget.setCurrentItem(None)
def renameItem( def renameItem(
@@ -844,10 +822,10 @@ class ALConfigWidget(QWidget, Ui_ALConfigWidget):
if item.type() == ALUserTreeItemType.GROUP.value: if item.type() == ALUserTreeItemType.GROUP.value:
is_checked = item.checkState(1) == Qt.CheckState.Checked is_checked = item.checkState(1) == Qt.CheckState.Checked
for i in range(item.childCount()): for i in range(item.childCount()):
Child = item.child(i) child = item.child(i)
if self.UserTreeWidget.currentItem() == Child: if self.UserTreeWidget.currentItem() == child:
self.UserTreeWidget.setCurrentItem(item) self.UserTreeWidget.setCurrentItem(item)
Child.setDisabled(not is_checked) child.setDisabled(not is_checked)
else: else:
is_checked = item.checkState(1) == Qt.CheckState.Checked is_checked = item.checkState(1) == Qt.CheckState.Checked
item.setText(1, "" if is_checked else "跳过") item.setText(1, "" if is_checked else "跳过")
@@ -864,32 +842,32 @@ class ALConfigWidget(QWidget, Ui_ALConfigWidget):
def showGroupMenu( def showGroupMenu(
self, self,
menu: QMenu, menu: QMenu,
GroupItem: QTreeWidgetItem = None group_item: QTreeWidgetItem = None
): ):
AddUserAction = QAction("添加用户", menu) AddUserAction = QAction("添加用户", menu)
RenameGroupAction = QAction("重命名分组", menu) RenameGroupAction = QAction("重命名分组", menu)
DelGroupAction = QAction("删除分组", menu) DelGroupAction = QAction("删除分组", menu)
AddUserAction.triggered.connect(lambda: self.addUser(GroupItem)) AddUserAction.triggered.connect(lambda: self.addUser(group_item))
RenameGroupAction.triggered.connect(lambda: self.renameItem(GroupItem)) RenameGroupAction.triggered.connect(lambda: self.renameItem(group_item))
DelGroupAction.triggered.connect(lambda: self.delGroup(GroupItem)) DelGroupAction.triggered.connect(lambda: self.delGroup(group_item))
menu.addAction(AddUserAction) menu.addAction(AddUserAction)
menu.addSeparator() menu.addSeparator()
menu.addAction(RenameGroupAction) menu.addAction(RenameGroupAction)
menu.addAction(DelGroupAction) menu.addAction(DelGroupAction)
if GroupItem.checkState(1) == Qt.CheckState.Unchecked: if group_item.checkState(1) == Qt.CheckState.Unchecked:
AddUserAction.setEnabled(False) AddUserAction.setEnabled(False)
def showUserMenu( def showUserMenu(
self, self,
menu: QMenu, menu: QMenu,
UserItem: QTreeWidgetItem = None user_item: QTreeWidgetItem = None
): ):
RenameUserAction = QAction("重命名用户", menu) RenameUserAction = QAction("重命名用户", menu)
DelUserAction = QAction("删除用户", menu) DelUserAction = QAction("删除用户", menu)
RenameUserAction.triggered.connect(lambda: self.renameItem(UserItem)) RenameUserAction.triggered.connect(lambda: self.renameItem(user_item))
DelUserAction.triggered.connect(lambda: self.delUser(UserItem)) DelUserAction.triggered.connect(lambda: self.delUser(user_item))
menu.addAction(RenameUserAction) menu.addAction(RenameUserAction)
menu.addAction(DelUserAction) menu.addAction(DelUserAction)
@@ -899,14 +877,14 @@ class ALConfigWidget(QWidget, Ui_ALConfigWidget):
pos pos
): ):
CurrentItem = self.UserTreeWidget.itemAt(pos) current_item = self.UserTreeWidget.itemAt(pos)
Menu = QMenu(self.UserTreeWidget) Menu = QMenu(self.UserTreeWidget)
if CurrentItem is None: if current_item is None:
self.showTreeMenu(Menu) self.showTreeMenu(Menu)
elif CurrentItem.type() == ALUserTreeItemType.GROUP.value: elif current_item.type() == ALUserTreeItemType.GROUP.value:
self.showGroupMenu(Menu, CurrentItem) self.showGroupMenu(Menu, current_item)
else: else:
self.showUserMenu(Menu, CurrentItem) self.showUserMenu(Menu, current_item)
Menu.exec_(self.UserTreeWidget.mapToGlobal(pos)) Menu.exec_(self.UserTreeWidget.mapToGlobal(pos))
@Slot() @Slot()
@@ -914,16 +892,16 @@ class ALConfigWidget(QWidget, Ui_ALConfigWidget):
self self
): ):
CurrentItem = self.UserTreeWidget.currentItem() current_item = self.UserTreeWidget.currentItem()
self.addUser(CurrentItem) self.addUser(current_item)
@Slot() @Slot()
def onDelUserButtonClicked( def onDelUserButtonClicked(
self self
): ):
CurrentItem = self.UserTreeWidget.currentItem() current_item = self.UserTreeWidget.currentItem()
self.delUser(CurrentItem) self.delUser(current_item)
@Slot() @Slot()
def onBrowseBrowserDriverButtonClicked( def onBrowseBrowserDriverButtonClicked(
@@ -1056,7 +1034,6 @@ class ALConfigWidget(QWidget, Ui_ALConfigWidget):
): ):
msg = "" msg = ""
run_config_path = self.ExportRunConfigEdit.text() run_config_path = self.ExportRunConfigEdit.text()
user_config_path = self.ExportUserConfigEdit.text() user_config_path = self.ExportUserConfigEdit.text()
if run_config_path: if run_config_path:
@@ -1133,8 +1110,8 @@ class ALConfigWidget(QWidget, Ui_ALConfigWidget):
self self
): ):
CurrentItem = self.UserTreeWidget.currentItem() current_item = self.UserTreeWidget.currentItem()
if CurrentItem and CurrentItem.type() == ALUserTreeItemType.USER.value: if current_item and current_item.type() == ALUserTreeItemType.USER.value:
self.UserTreeWidget.setCurrentItem(None) self.UserTreeWidget.setCurrentItem(None)
if self.saveConfigs( if self.saveConfigs(
self.__config_paths["run"], self.__config_paths["run"],
+290 -144
View File
@@ -9,11 +9,12 @@ See the LICENSE file for details.
""" """
import queue import queue
import packaging.version as ver
from PySide6.QtCore import ( from PySide6.QtCore import (
QTimer, QTimer,
QUrl, QUrl,
Qt, Qt,
Signal,
Slot Slot
) )
from PySide6.QtGui import ( from PySide6.QtGui import (
@@ -32,44 +33,54 @@ from PySide6.QtWidgets import (
from base.MsgBase import MsgBase from base.MsgBase import MsgBase
from gui.ALAboutDialog import ALAboutDialog from gui.ALAboutDialog import ALAboutDialog
from gui.ALBulletinDialog import ALBulletinDialog
from gui.ALCheckUpdateWorker import ALCheckUpdateWorker
from gui.ALCheckUpdateDialog import ALCheckUpdateDialog
from gui.ALConfigWidget import ALConfigWidget from gui.ALConfigWidget import ALConfigWidget
from gui.ALMainWorkers import ( from gui.ALSettingsWidget import ALSettingsWidget
AutoLibWorker,
TimerTaskWorker
)
from gui.ALTimerTaskManageWidget import ALTimerTaskManageWidget from gui.ALTimerTaskManageWidget import ALTimerTaskManageWidget
from gui.ALMainWorker import AutoLibWorker
from gui.ALBulletinPoller import ALBulletinPoller
from gui.ALTimerTaskPoller import ALTimerTaskPoller
from gui.ALVersionInfo import AL_VERSION
from gui.resources import ALResource from gui.resources import ALResource
from gui.resources.ui.Ui_ALMainWindow import Ui_ALMainWindow from gui.resources.ui.Ui_ALMainWindow import Ui_ALMainWindow
from managers.bulletin.BulletinManager import instance as bulletinInstance
from managers.config.ConfigUtils import ConfigUtils from managers.config.ConfigUtils import ConfigUtils
class ALMainWindow(MsgBase, QMainWindow, Ui_ALMainWindow): class ALMainWindow(MsgBase, QMainWindow, Ui_ALMainWindow):
# signal : timer task
timerTaskIsRunning = Signal(dict)
timerTaskIsExecuted = Signal(dict)
timerTaskIsError = Signal(dict)
def __init__( def __init__(
self self
): ):
MsgBase.__init__(self, queue.Queue(), queue.Queue()) MsgBase.__init__(self, queue.Queue(), queue.Queue())
QMainWindow.__init__(self) QMainWindow.__init__(self)
self.__timer_task_queue = queue.Queue()
self.__config_paths = ConfigUtils.getAutomationConfigPaths() self.__config_paths = ConfigUtils.getAutomationConfigPaths()
self.__alTimerTaskManageWidget = None self.__ALTimerTaskManageWidget = None
self.__alConfigWidget = None self.__ALConfigWidget = None
self.__ALSettingsWidget = None
self.__ALBulletinDialog = None
self.__auto_lib_thread = None self.__auto_lib_thread = None
self.__current_timer_task_thread = None self.__bulletin_poller = ALBulletinPoller(self)
self.__is_running_timer_task = False self.__timer_task_poller = ALTimerTaskPoller(
self,
self._input_queue,
self._output_queue,
self.__config_paths
)
self.__notification_type = ""
self.setupUi(self) self.setupUi(self)
self.modifyUi() self.modifyUi()
self.setupTray() self.setupTray()
self.connectSignals() self.connectSignals()
self.startMsgPolling() self.startMsgPolling()
self.startTimerTaskPolling() self.__timer_task_poller.start()
self.__bulletin_poller.start()
if bulletinInstance().autoFetch():
QTimer.singleShot(1000, self.__bulletin_poller.fetchNow)
self._showLog("主窗口初始化完成") self._showLog("主窗口初始化完成")
def modifyUi( def modifyUi(
@@ -80,41 +91,31 @@ class ALMainWindow(MsgBase, QMainWindow, Ui_ALMainWindow):
self.setWindowIcon(self.Icon) self.setWindowIcon(self.Icon)
self.MessageIOTextEdit.setFont(QFont("Courier New", 10)) self.MessageIOTextEdit.setFont(QFont("Courier New", 10))
self.ManualAction.triggered.connect(self.onManualActionTriggered) self.ManualAction.triggered.connect(self.onManualActionTriggered)
self.CheckUpdateAction.triggered.connect(self.onCheckUpdateActionTriggered)
self.AboutAction.triggered.connect(self.onAboutActionTriggered) self.AboutAction.triggered.connect(self.onAboutActionTriggered)
self.SettingsAction.triggered.connect(self.onSettingsActionTriggered)
self.BulletinAction.triggered.connect(self.onBulletinActionTriggered)
# initialize timer task widget, but not show it # initialize timer task widget, but not show it
try: try:
self.__alTimerTaskManageWidget = ALTimerTaskManageWidget(self) self.__ALTimerTaskManageWidget = ALTimerTaskManageWidget(self)
except Exception as e: except Exception as e:
QMessageBox.critical( QMessageBox.critical(
self, self,
"错误 - AutoLibrary", "错误 - AutoLibrary",
f"初始化定时任务功能失败: \n{e}" f"初始化定时任务功能失败: \n{e}"
) )
self.__alTimerTaskManageWidget = None self.__ALTimerTaskManageWidget = None
self.TimerTaskManageWidgetButton.setEnabled(False) self.TimerTaskManageWidgetButton.setEnabled(False)
self.TimerTaskManageWidgetButton.setToolTip("定时任务功能初始化失败, 请检查配置文件。") self.TimerTaskManageWidgetButton.setToolTip("定时任务功能初始化失败, 请检查配置文件。")
return return
self.timerTaskIsRunning.connect(self.__alTimerTaskManageWidget.onTimerTaskIsRunning) self.__timer_task_poller.taskRunning.connect(self.onTimerTaskRunning)
self.timerTaskIsExecuted.connect(self.__alTimerTaskManageWidget.onTimerTaskIsExecuted) self.__timer_task_poller.taskFinished.connect(self.onTimerTaskFinished)
self.timerTaskIsError.connect(self.__alTimerTaskManageWidget.onTimerTaskIsError) self.__timer_task_poller.taskRunning.connect(self.__ALTimerTaskManageWidget.onTimerTaskIsRunning)
self.__alTimerTaskManageWidget.timerTaskIsReady.connect(self.onTimerTaskIsReady) self.__timer_task_poller.taskExecuted.connect(self.__ALTimerTaskManageWidget.onTimerTaskIsExecuted)
self.__alTimerTaskManageWidget.timerTaskManageWidgetIsClosed.connect(self.onTimerTaskManageWidgetClosed) self.__timer_task_poller.taskError.connect(self.__ALTimerTaskManageWidget.onTimerTaskIsError)
self.__alTimerTaskManageWidget.setWindowFlags(Qt.WindowType.Window|Qt.WindowType.WindowCloseButtonHint) self.__ALTimerTaskManageWidget.timerTaskIsReady.connect(self.__timer_task_poller.enqueue)
self.__ALTimerTaskManageWidget.timerTaskManageWidgetIsClosed.connect(self.onTimerTaskManageWidgetClosed)
def onAboutActionTriggered( self.__ALTimerTaskManageWidget.setWindowFlags(Qt.WindowType.Window|Qt.WindowType.WindowCloseButtonHint)
self
):
AboutDialog = ALAboutDialog(self)
AboutDialog.exec()
def onManualActionTriggered(
self
):
Url = QUrl("https://www.autolibrary.kenanzhu.com/manuals")
QDesktopServices.openUrl(Url)
def setupTray( def setupTray(
self self
@@ -125,7 +126,6 @@ class ALMainWindow(MsgBase, QMainWindow, Ui_ALMainWindow):
return return
self.TrayIcon = QSystemTrayIcon(self.Icon, self) self.TrayIcon = QSystemTrayIcon(self.Icon, self)
self.TrayIcon.setToolTip("AutoLibrary") self.TrayIcon.setToolTip("AutoLibrary")
self.TrayMenu = QMenu() self.TrayMenu = QMenu()
self.TrayMenu.addAction("显示主窗口", self.showNormal) self.TrayMenu.addAction("显示主窗口", self.showNormal)
self.TrayMenu.addAction("显示定时窗口", self.onTimerTaskManageWidgetButtonClicked) self.TrayMenu.addAction("显示定时窗口", self.onTimerTaskManageWidgetButtonClicked)
@@ -133,8 +133,8 @@ class ALMainWindow(MsgBase, QMainWindow, Ui_ALMainWindow):
self.TrayMenu.addSeparator() self.TrayMenu.addSeparator()
self.TrayMenu.addAction("退出", self.close) self.TrayMenu.addAction("退出", self.close)
self.TrayIcon.setContextMenu(self.TrayMenu) self.TrayIcon.setContextMenu(self.TrayMenu)
self.TrayIcon.activated.connect(self.onTrayIconActivated) self.TrayIcon.activated.connect(self.onTrayIconActivated)
self.TrayIcon.messageClicked.connect(self.onTrayMessageClicked)
self.TrayIcon.show() self.TrayIcon.show()
def hideToTray( def hideToTray(
@@ -142,6 +142,9 @@ class ALMainWindow(MsgBase, QMainWindow, Ui_ALMainWindow):
): ):
self.hide() self.hide()
self.__notification_type = ""
if not hasattr(self, "TrayIcon"):
return
self.TrayIcon.showMessage( self.TrayIcon.showMessage(
"AutoLibrary", "AutoLibrary",
"\n已最小化到托盘", "\n已最小化到托盘",
@@ -149,14 +152,6 @@ class ALMainWindow(MsgBase, QMainWindow, Ui_ALMainWindow):
2000 2000
) )
def onTrayIconActivated(
self,
reason: QSystemTrayIcon.ActivationReason
):
if reason == QSystemTrayIcon.DoubleClick:
self.showNormal()
def connectSignals( def connectSignals(
self self
): ):
@@ -167,6 +162,7 @@ class ALMainWindow(MsgBase, QMainWindow, Ui_ALMainWindow):
self.StopButton.clicked.connect(self.onStopButtonClicked) self.StopButton.clicked.connect(self.onStopButtonClicked)
self.SendButton.clicked.connect(self.onSendButtonClicked) self.SendButton.clicked.connect(self.onSendButtonClicked)
self.MessageEdit.returnPressed.connect(self.onSendButtonClicked) self.MessageEdit.returnPressed.connect(self.onSendButtonClicked)
self.__bulletin_poller.newBulletinsDetected.connect(self.onBulletinPollerNewBulletins)
def closeEvent( def closeEvent(
self, self,
@@ -179,17 +175,22 @@ class ALMainWindow(MsgBase, QMainWindow, Ui_ALMainWindow):
return return
if self.__msg_queue_timer and self.__msg_queue_timer.isActive(): if self.__msg_queue_timer and self.__msg_queue_timer.isActive():
self.__msg_queue_timer.stop() self.__msg_queue_timer.stop()
if self.__timer_task_timer and self.__timer_task_timer.isActive(): if self.__timer_task_poller:
self.__timer_task_timer.stop() self.__timer_task_poller.stop()
if self.__is_running_timer_task: if self.__ALTimerTaskManageWidget:
self.__current_timer_task_thread.wait(2000) self.__ALTimerTaskManageWidget.close()
self.__current_timer_task_thread.deleteLater() self.__ALTimerTaskManageWidget.deleteLater()
if self.__alTimerTaskManageWidget: if self.__ALConfigWidget:
self.__alTimerTaskManageWidget.close() self.__ALConfigWidget.close()
self.__alTimerTaskManageWidget.deleteLater()
if self.__alConfigWidget:
self.__alConfigWidget.close()
# the config widget is already deleted in the 'self.onConfigWidgetClosed' # the config widget is already deleted in the 'self.onConfigWidgetClosed'
if self.__ALSettingsWidget:
self.__ALSettingsWidget.close()
# the settings widget is already deleted in the 'self.onSettingsWidgetClosed'
if self.__ALBulletinDialog:
self.__ALBulletinDialog.close()
# the bulletin dialog is already deleted in the 'self.onBulletinDialogClosed'
if self.__bulletin_poller:
self.__bulletin_poller.stop()
self._showLog("主窗口关闭") self._showLog("主窗口关闭")
QMainWindow.closeEvent(self, event) QMainWindow.closeEvent(self, event)
@@ -214,47 +215,6 @@ class ALMainWindow(MsgBase, QMainWindow, Ui_ALMainWindow):
self.__msg_queue_timer.timeout.connect(self.pollMsgQueue) self.__msg_queue_timer.timeout.connect(self.pollMsgQueue)
self.__msg_queue_timer.start(100) self.__msg_queue_timer.start(100)
def startTimerTaskPolling(
self
):
self.__timer_task_timer = QTimer()
self.__timer_task_timer.timeout.connect(self.pollTimerTaskQueue)
self.__timer_task_timer.start(500)
def pollTimerTaskQueue(
self
):
if self.__is_running_timer_task:
return
try:
while not self.__is_running_timer_task:
timer_task = self.__timer_task_queue.get_nowait()
self.timerTaskIsRunning.emit(timer_task)
self.__timer_task_timer.stop()
self.__is_running_timer_task = True
self.setControlButtons(None, True, False)
if not timer_task["silent"]:
self.TrayIcon.showMessage(
"定时任务 - AutoLibrary",
f"\n已开始执行定时任务: \n{timer_task['name']}",
QSystemTrayIcon.MessageIcon.Information,
1000
)
self.showNormal()
self.__current_timer_task_thread = TimerTaskWorker(
timer_task,
self._input_queue,
self._output_queue,
self.__config_paths
)
self.__current_timer_task_thread.timerTaskWorkerIsFinished.connect(self.onTimerTaskFinished)
self.__current_timer_task_thread.start()
except queue.Empty:
self.__is_running_timer_task = False
pass
def setControlButtons( def setControlButtons(
self, self,
config_button_enabled: bool, config_button_enabled: bool,
@@ -282,6 +242,145 @@ class ALMainWindow(MsgBase, QMainWindow, Ui_ALMainWindow):
except queue.Empty: except queue.Empty:
pass pass
@Slot(int)
def onBulletinPollerNewBulletins(
self,
count: int
):
if not hasattr(self, "TrayIcon"):
return
self.__notification_type = "bulletin"
self.TrayIcon.showMessage(
"公告栏 - AutoLibrary",
f"{count} 条新公告,点击查看详情。",
QSystemTrayIcon.MessageIcon.Information,
3000
)
@Slot(dict)
def onTimerTaskRunning(
self,
timer_task: dict
):
self.setControlButtons(None, True, False)
self.__notification_type = ""
if not hasattr(self, "TrayIcon"):
return
if not timer_task.get("silent", False):
self.TrayIcon.showMessage(
"定时任务 - AutoLibrary",
f"\n已开始执行定时任务: \n{timer_task['name']}",
QSystemTrayIcon.MessageIcon.Information,
1000
)
self.showNormal()
@Slot(bool, dict)
def onTimerTaskFinished(
self,
is_error: bool,
timer_task: dict
):
self.setControlButtons(None, False, True)
self.__notification_type = ""
if not hasattr(self, "TrayIcon"):
return
self.TrayIcon.showMessage(
"定时任务 - AutoLibrary",
f"\n定时任务 '{timer_task['name']}' 执行{'失败' if is_error else '完成'}",
QSystemTrayIcon.MessageIcon.Warning if is_error else QSystemTrayIcon.MessageIcon.Information,
1000
)
self._showTrace(
f"定时任务 {timer_task['name']} 执行{'失败' if is_error else '完成'}, uuid: {timer_task['uuid']}"
)
@Slot(dict)
def onCheckUpdateIsFinished(
self,
data: dict
):
worker = self.sender()
if worker is not self.__check_update_worker:
return
worker.checkUpdateWorkerIsFinished.disconnect(self.onCheckUpdateIsFinished)
worker.checkUpdateWorkerFinishedWithError.disconnect(self.onCheckUpdateFinishedWithError)
worker.wait(3000)
worker.deleteLater()
self.__check_update_worker = None
tag_name = data.get("tag_name", "")
html_url = data.get("html_url", "")
latest_version = tag_name.lstrip("v")
try:
local_ver = ver.Version(AL_VERSION)
remote_ver = ver.Version(latest_version)
except ver.InvalidVersion:
self._showTrace("版本号解析失败, 无法比较版本", self.TraceLevel.WARNING)
return
if remote_ver > local_ver:
ALCheckUpdateDialog.showResult(
self,
has_update=True,
current_version=AL_VERSION,
latest_version=latest_version,
tag_name=tag_name,
html_url=html_url
)
else:
ALCheckUpdateDialog.showResult(
self,
has_update=False,
current_version=AL_VERSION
)
self._showLog("检查更新完成")
@Slot(str)
def onCheckUpdateFinishedWithError(
self,
error_message: str
):
worker = self.sender()
if worker is not self.__check_update_worker:
return
worker.checkUpdateWorkerIsFinished.disconnect(self.onCheckUpdateIsFinished)
worker.checkUpdateWorkerFinishedWithError.disconnect(self.onCheckUpdateFinishedWithError)
worker.wait(3000)
worker.deleteLater()
self.__check_update_worker = None
QMessageBox.warning(
self,
"检查更新 - AutoLibrary",
f"检查更新失败: \n{error_message}",
)
self._showLog("检查更新失败")
@Slot()
def onBulletinDialogClosed(
self
):
if self.__ALBulletinDialog:
self.__ALBulletinDialog.finished.disconnect(self.onBulletinDialogClosed)
self.__ALBulletinDialog.deleteLater()
self.__ALBulletinDialog = None
self.__bulletin_poller.setDialogOpen(False)
@Slot()
def onSettingsWidgetClosed(
self
):
if self.__ALSettingsWidget:
self.__ALSettingsWidget.settingsWidgetIsClosed.disconnect(self.onSettingsWidgetClosed)
self.__ALSettingsWidget.deleteLater()
self.__ALSettingsWidget = None
self.SettingsAction.setEnabled(True)
@Slot() @Slot()
def onTimerTaskManageWidgetClosed( def onTimerTaskManageWidgetClosed(
self self
@@ -294,59 +393,106 @@ class ALMainWindow(MsgBase, QMainWindow, Ui_ALMainWindow):
self self
): ):
if self.__alConfigWidget: if self.__ALConfigWidget:
self.__alConfigWidget.configWidgetIsClosed.disconnect(self.onConfigWidgetClosed) self.__ALConfigWidget.configWidgetIsClosed.disconnect(self.onConfigWidgetClosed)
self.__alConfigWidget.deleteLater() self.__ALConfigWidget.deleteLater()
self.__alConfigWidget = None self.__ALConfigWidget = None
self.__config_paths = ConfigUtils.getAutomationConfigPaths() self.__config_paths = ConfigUtils.getAutomationConfigPaths()
self.__timer_task_poller.updateConfigPaths(self.__config_paths)
self.setControlButtons(True, None, None) self.setControlButtons(True, None, None)
self._showLog("配置窗口已关闭,配置文件路径已更新") self._showLog("配置窗口已关闭,配置文件路径已更新")
@Slot(dict) @Slot()
def onTimerTaskIsReady( def onBulletinActionTriggered(
self, self
timer_task: dict
): ):
self.__timer_task_queue.put(timer_task) if self.__ALBulletinDialog is None:
self.__ALBulletinDialog = ALBulletinDialog(self)
self.__ALBulletinDialog.finished.connect(self.onBulletinDialogClosed)
self.__bulletin_poller.setDialogOpen(True)
self.__ALBulletinDialog.show()
self.__ALBulletinDialog.raise_()
self.__ALBulletinDialog.activateWindow()
self._showLog("打开公告栏窗口")
@Slot(dict) @Slot()
def onTimerTaskFinished( def onSettingsActionTriggered(
self, self
is_error: bool,
timer_task: dict
): ):
self.__current_timer_task_thread.wait(1000) if self.__ALSettingsWidget is None:
self.__current_timer_task_thread.timerTaskWorkerIsFinished.disconnect(self.onTimerTaskFinished) self.__ALSettingsWidget = ALSettingsWidget(self)
self.__current_timer_task_thread.deleteLater() self.__ALSettingsWidget.settingsWidgetIsClosed.connect(self.onSettingsWidgetClosed)
self.__current_timer_task_thread = None self.__ALSettingsWidget.openBulletinRequested.connect(self.onBulletinActionTriggered)
self.setControlButtons(None, False, True) self.__ALSettingsWidget.show()
self.__is_running_timer_task = False self.__ALSettingsWidget.raise_()
self.__timer_task_timer.start(500) self.__ALSettingsWidget.activateWindow()
timer_task["executed"] = True self.SettingsAction.setEnabled(False)
self.TrayIcon.showMessage( self._showLog("打开全局设置窗口")
"定时任务 - AutoLibrary",
f"\n定时任务 '{timer_task['name']}' 执行{'失败' if is_error else '完成'}", @Slot()
QSystemTrayIcon.MessageIcon.Warning if is_error else QSystemTrayIcon.MessageIcon.Information, def onAboutActionTriggered(
1000 self
) ):
self._showTrace(
f"定时任务 {timer_task['name']} 执行{'失败' if is_error else '完成'}, uuid: {timer_task['uuid']}" AboutDialog = ALAboutDialog(self)
) AboutDialog.exec()
if not is_error:
self.timerTaskIsExecuted.emit(timer_task) @Slot()
else: def onManualActionTriggered(
self.timerTaskIsError.emit(timer_task) self
):
url = QUrl("https://manuals.autolibrary.kenanzhu.com")
QDesktopServices.openUrl(url)
@Slot()
def onCheckUpdateActionTriggered(
self
):
if hasattr(self, '__check_update_worker') and self.__check_update_worker is not None:
return
self.__check_update_worker = ALCheckUpdateWorker(self)
self.__check_update_worker.checkUpdateWorkerIsFinished.connect(self.onCheckUpdateIsFinished)
self.__check_update_worker.checkUpdateWorkerFinishedWithError.connect(self.onCheckUpdateFinishedWithError)
self.__check_update_worker.start()
self._showLog("正在检查更新...")
@Slot()
def onTrayMessageClicked(
self
):
if self.__notification_type == "bulletin":
self.__notification_type = ""
self.onBulletinActionTriggered()
@Slot(QSystemTrayIcon.ActivationReason)
def onTrayIconActivated(
self,
reason: QSystemTrayIcon.ActivationReason
):
if reason == QSystemTrayIcon.DoubleClick:
self.showNormal()
@Slot() @Slot()
def onTimerTaskManageWidgetButtonClicked( def onTimerTaskManageWidgetButtonClicked(
self self
): ):
self.__alTimerTaskManageWidget.show() if self.__ALTimerTaskManageWidget is None:
self.__alTimerTaskManageWidget.raise_() QMessageBox.warning(
self.__alTimerTaskManageWidget.activateWindow() self,
"警告 - AutoLibrary",
"定时任务功能初始化失败, 请检查配置文件。"
)
return
self.__ALTimerTaskManageWidget.show()
self.__ALTimerTaskManageWidget.raise_()
self.__ALTimerTaskManageWidget.activateWindow()
self.TimerTaskManageWidgetButton.setEnabled(False) self.TimerTaskManageWidgetButton.setEnabled(False)
self._showLog("打开定时任务管理窗口") self._showLog("打开定时任务管理窗口")
@@ -355,12 +501,12 @@ class ALMainWindow(MsgBase, QMainWindow, Ui_ALMainWindow):
self self
): ):
if self.__alConfigWidget is None: if self.__ALConfigWidget is None:
self.__alConfigWidget = ALConfigWidget(self) self.__ALConfigWidget = ALConfigWidget(self)
self.__alConfigWidget.configWidgetIsClosed.connect(self.onConfigWidgetClosed) self.__ALConfigWidget.configWidgetIsClosed.connect(self.onConfigWidgetClosed)
self.__alConfigWidget.show() self.__ALConfigWidget.show()
self.__alConfigWidget.raise_() self.__ALConfigWidget.raise_()
self.__alConfigWidget.activateWindow() self.__ALConfigWidget.activateWindow()
self.ConfigButton.setEnabled(False) self.ConfigButton.setEnabled(False)
self._showLog("打开配置窗口") self._showLog("打开配置窗口")
+2 -29
View File
@@ -24,9 +24,9 @@ from PySide6.QtWidgets import (
) )
from gui.ALSeatMapView import ALSeatMapView from gui.ALSeatMapView import ALSeatMapView
from gui.ALWidgetMixin import CenterOnParentMixin
class ALSeatMapSelectDialog(CenterOnParentMixin, QDialog):
class ALSeatMapSelectDialog(QDialog):
seatMapSelectDialogIsClosed = Signal(list) seatMapSelectDialogIsClosed = Signal(list)
@@ -55,7 +55,6 @@ class ALSeatMapSelectDialog(QDialog):
self.setMinimumSize(800, 600) self.setMinimumSize(800, 600)
self.resize(800, 600) self.resize(800, 600)
self.setWindowTitle(f"选择楼层座位 - AutoLibrary") self.setWindowTitle(f"选择楼层座位 - AutoLibrary")
self.SeatMapWidgetMainLayout = QVBoxLayout(self) self.SeatMapWidgetMainLayout = QVBoxLayout(self)
self.SeatMapWidgetMainLayout.setContentsMargins(5, 5, 5, 5) self.SeatMapWidgetMainLayout.setContentsMargins(5, 5, 5, 5)
self.SeatMapWidgetMainLayout.setSpacing(5) self.SeatMapWidgetMainLayout.setSpacing(5)
@@ -63,10 +62,8 @@ class ALSeatMapSelectDialog(QDialog):
self.TitleLabel.setAlignment(Qt.AlignmentFlag.AlignCenter) self.TitleLabel.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.TitleLabel.setStyleSheet("font-size: 16px; font-weight: bold; margin: 10px;") self.TitleLabel.setStyleSheet("font-size: 16px; font-weight: bold; margin: 10px;")
self.SeatMapWidgetMainLayout.addWidget(self.TitleLabel) self.SeatMapWidgetMainLayout.addWidget(self.TitleLabel)
self.SeatMapGraphicsView = ALSeatMapView(None, self.__seats_data) self.SeatMapGraphicsView = ALSeatMapView(None, self.__seats_data)
self.SeatMapWidgetMainLayout.addWidget(self.SeatMapGraphicsView) self.SeatMapWidgetMainLayout.addWidget(self.SeatMapGraphicsView)
self.TipsLabel = QLabel( self.TipsLabel = QLabel(
" 点击座位进行选择/取消选择, 最多选择1个座位 \n" " 点击座位进行选择/取消选择, 最多选择1个座位 \n"
" [操作方法: Ctrl+鼠标滚轮缩放 | 滚轮/拖拽/方向键 移动]" " [操作方法: Ctrl+鼠标滚轮缩放 | 滚轮/拖拽/方向键 移动]"
@@ -74,7 +71,6 @@ class ALSeatMapSelectDialog(QDialog):
self.TipsLabel.setAlignment(Qt.AlignmentFlag.AlignLeft) self.TipsLabel.setAlignment(Qt.AlignmentFlag.AlignLeft)
self.TipsLabel.setStyleSheet("color: #666; margin: 5px;") self.TipsLabel.setStyleSheet("color: #666; margin: 5px;")
self.SeatMapWidgetMainLayout.addWidget(self.TipsLabel) self.SeatMapWidgetMainLayout.addWidget(self.TipsLabel)
self.ConfirmButton = QPushButton("确认") self.ConfirmButton = QPushButton("确认")
self.ConfirmButton.setFixedSize(80, 25) self.ConfirmButton.setFixedSize(80, 25)
self.ConfirmButton.setAutoDefault(True) self.ConfirmButton.setAutoDefault(True)
@@ -96,29 +92,6 @@ class ALSeatMapSelectDialog(QDialog):
self.ConfirmButton.clicked.connect(self.onConfirmButtonClicked) self.ConfirmButton.clicked.connect(self.onConfirmButtonClicked)
self.CancelButton.clicked.connect(self.onCancelButtonClicked) self.CancelButton.clicked.connect(self.onCancelButtonClicked)
def showEvent(
self,
event
):
result = super().showEvent(event)
screen_rect = self.screen().geometry()
target_pos = self.parent().geometry().center()
target_pos.setX(target_pos.x() - self.width()//2)
target_pos.setY(target_pos.y() - self.height()//2)
if target_pos.x() < 0:
target_pos.setX(0)
if target_pos.x() + self.width() > screen_rect.width():
target_pos.setX(screen_rect.width() - self.width())
if target_pos.y() < 0:
target_pos.setY(0)
if target_pos.y() + self.height() > screen_rect.height():
target_pos.setY(screen_rect.height() - self.height())
self.move(target_pos)
return result
def closeEvent( def closeEvent(
self, self,
event: QCloseEvent event: QCloseEvent
-2
View File
@@ -85,11 +85,9 @@ class ALSeatMapView(QGraphicsView):
self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
self.viewport().installEventFilter(self) self.viewport().installEventFilter(self)
self.SeatsContainerWidget = QWidget() self.SeatsContainerWidget = QWidget()
self.SeatsContainerLayout = QGridLayout(self.SeatsContainerWidget) self.SeatsContainerLayout = QGridLayout(self.SeatsContainerWidget)
self.setupSeatMap() self.setupSeatMap()
self.ContainerProxy = self.SeatMapGraphicsScene.addWidget(self.SeatsContainerWidget) self.ContainerProxy = self.SeatMapGraphicsScene.addWidget(self.SeatsContainerWidget)
self.ContainerProxy.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsSelectable, False) self.ContainerProxy.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsSelectable, False)
+562
View File
@@ -0,0 +1,562 @@
# -*- coding: utf-8 -*-
"""
Copyright (c) 2026 KenanZhu.
All rights reserved.
This software is provided "as is", without any warranty of any kind.
You may use, modify, and distribute this file under the terms of the MIT License.
See the LICENSE file for details.
"""
import os
import sys
import time
import requests
import qtawesome as qta
from PySide6.QtCore import (
QProcess,
Qt,
QTimer,
Signal,
Slot
)
from PySide6.QtGui import (
QCloseEvent
)
from PySide6.QtWidgets import (
QApplication,
QFileDialog,
QMessageBox,
QStyleFactory,
QWidget
)
import managers.config.ConfigManager as ConfigManager
from managers.bulletin.BulletinManager import instance as bulletinInstance
from managers.log.LogManager import instance as logInstance
from managers.theme.ThemeManager import(
getActiveStyle,
setActiveStyle,
instance as themeInstance
)
from gui.ALBulletinWorker import ALBulletinFetchWorker
from gui.ALWidgetMixin import CenterOnParentMixin
from gui.resources.ui.Ui_ALSettingsWidget import Ui_ALSettingsWidget
from interfaces.ConfigProvider import (
CfgKey,
ConfigProvider
)
def _applyCustomTheme(
name: str,
fallback_theme: str = "system"
) -> bool:
if not name:
themeInstance().clearTheme(fallback_theme)
return True
try:
themeInstance().applyTheme(name)
return True
except Exception as e:
logInstance().getLogger("ALSettingsWidget").warning(
f"无法应用自定义主题 '{name}',回退到 {fallback_theme} 外观: {e}"
)
themeInstance().clearTheme(fallback_theme)
return False
def _themeToReadable(
need_theme: str
) -> str:
if need_theme == "dark":
return "深色"
elif need_theme == "light":
return "浅色"
elif need_theme == "both":
return "所有"
else:
return "未知"
def _restartApp(
):
QApplication.instance().quit()
QProcess.startDetached(sys.executable, sys.argv)
class ALSettingsWidget(CenterOnParentMixin, QWidget, Ui_ALSettingsWidget):
settingsWidgetIsClosed = Signal()
openBulletinRequested = Signal()
def __init__(
self,
parent=None
):
super().__init__(parent)
self.__cfg_mgr: ConfigProvider = ConfigManager.instance()
self.__original_theme: str = ""
self.__original_custom_theme: str = ""
self.__original_style: str = ""
self.__original_bulletin_url: str = ""
self.__original_bulletin_auto_fetch: bool = False
self.__original_bulletin_sync_interval: int = 10
self.setupUi(self)
self.modifyUi()
self.connectSignals()
self.loadSettings()
def closeEvent(
self,
event: QCloseEvent
):
if hasattr(self, '__bulletin_test_worker') and self.__bulletin_test_worker is not None:
self.__bulletin_test_worker.wait(3000)
self.settingsWidgetIsClosed.emit()
super().closeEvent(event)
def modifyUi(
self
):
self.setWindowFlags(Qt.WindowType.Window)
self.setNavigationIcons()
color = QApplication.instance().palette().color(
QApplication.instance().palette().ColorRole.WindowText
).name()
self.ImportCustomThemeButton.setIcon(qta.icon("fa6s.plus", color=color))
self.ImportCustomThemeButton.setText("")
self.RemoveCustomThemeButton.setIcon(qta.icon("fa6s.minus", color=color))
self.RemoveCustomThemeButton.setText("")
self.CustomThemeInfoLabel.setTextFormat(Qt.TextFormat.RichText)
self.CustomThemeInfoLabel.setStyleSheet(
"border: 1px solid palette(mid);"\
"border-radius: 2px;"\
"padding: 5px;"
)
self.NavigationList.setCurrentRow(0)
self.NavigationList.currentRowChanged.connect(self.PageStack.setCurrentIndex)
self.populateStyles()
self.populateCustomThemes()
def setNavigationIcons(
self
):
app : QApplication | None = QApplication.instance()
color = app.palette().color(app.palette().ColorRole.WindowText).name()
item0 = self.NavigationList.item(0)
if item0:
item0.setIcon(qta.icon("fa6s.palette", color=color))
item1 = self.NavigationList.item(1)
if item1:
item1.setIcon(qta.icon("fa6s.bullhorn", color=color))
def populateStyles(
self
):
self.StyleComboBox.clear()
self.StyleComboBox.addItems(QStyleFactory.keys())
def populateCustomThemes(
self
):
self.CustomThemeComboBox.blockSignals(True)
self.CustomThemeComboBox.clear()
self.CustomThemeComboBox.addItem("默认", "")
self.__theme_cache = {}
themes = themeInstance().listThemes()
for t in themes:
name = t.get("name", "")
file = t.get("file", name)
author = t.get("author", "")
if name:
self.__theme_cache[file] = t
self.CustomThemeComboBox.addItem(name, file)
self.CustomThemeComboBox.blockSignals(False)
def connectSignals(
self
):
self.ImportCustomThemeButton.clicked.connect(self.onImportCustomThemeButtonClicked)
self.RemoveCustomThemeButton.clicked.connect(self.onRemoveCustomThemeButtonClicked)
self.CustomThemeComboBox.currentIndexChanged.connect(self.onCustomThemeComboBoxChanged)
self.ResetCustomThemeButton.clicked.connect(self.onResetCustomThemeButtonClicked)
self.BulletinTestButton.clicked.connect(self.onBulletinTestButtonClicked)
self.BulletinOpenButton.clicked.connect(self.openBulletinRequested.emit)
self.CancelButton.clicked.connect(self.onCancelButtonClicked)
self.ApplyButton.clicked.connect(self.onApplyButtonClicked)
self.ConfirmButton.clicked.connect(self.onConfirmButtonClicked)
def loadSettings(
self
):
# appearance settings
theme = self.__cfg_mgr.get(CfgKey.GLOBAL.APPEARANCE.THEME, "system")
style = self.__cfg_mgr.get(CfgKey.GLOBAL.APPEARANCE.STYLE, "Fusion")
custom_theme = self.__cfg_mgr.get(CfgKey.GLOBAL.APPEARANCE.CUSTOM_THEME, "")
self.__original_theme = theme
self.__original_custom_theme = custom_theme
self.__original_style = getActiveStyle()
if theme == "light":
self.LightThemeRadio.setChecked(True)
elif theme == "dark":
self.DarkThemeRadio.setChecked(True)
else:
self.SystemThemeRadio.setChecked(True)
index = self.StyleComboBox.findText(style)
if index < 0:
index = 0
self.StyleComboBox.setCurrentIndex(index)
if custom_theme:
idx = self.CustomThemeComboBox.findData(custom_theme)
if idx >= 0:
self.CustomThemeComboBox.setCurrentIndex(idx)
self.updateCustomThemeInfo()
self.updateCustomThemeStatus()
# bulletin settings
bulletin_mgr = bulletinInstance()
self.__original_bulletin_url = bulletin_mgr.serverUrl()
self.__original_bulletin_auto_fetch = bulletin_mgr.autoFetch()
self.__original_bulletin_sync_interval = bulletin_mgr.syncInterval()
self.BulletinServerUrlEdit.setText(self.__original_bulletin_url)
self.BulletinAutoFetchCheck.setChecked(self.__original_bulletin_auto_fetch)
self.BulletinSyncIntervalSpin.setValue(self.__original_bulletin_sync_interval)
def updateCustomThemeInfo(
self
):
file = self.CustomThemeComboBox.currentData()
if not file:
self.CustomThemeInfoLabel.setText("")
return
t = self.__theme_cache.get(file)
if t:
name = t.get("name", "未知")
author = t.get("author", "未知作者")
need_theme = t.get("need_theme", "both")
brief = t.get("brief", "没有相关简介")
self.CustomThemeInfoLabel.setText(
f"<b>{name}</b> - 适用于 <i>{_themeToReadable(need_theme)}</i> 主题<br>"
f"作者:{author}<br><br>"
f"{brief}"
)
else:
self.CustomThemeInfoLabel.setText("")
def updateCustomThemeStatus(
self
):
file = self.CustomThemeComboBox.currentData()
t = self.__theme_cache.get(file) if file else None
name = t.get("name", "") if t else ""
if name:
self.CustomThemeStatusLabel.setText(f"当前使用 {name} 主题。")
else:
self.CustomThemeStatusLabel.setText("当前使用 默认 主题。")
def syncRadioFromNeedTheme(
self,
name: str
):
t = self.__theme_cache.get(name)
if t:
need_theme = t.get("need_theme", "both")
if need_theme == "light":
self.LightThemeRadio.setChecked(True)
elif need_theme == "dark":
self.DarkThemeRadio.setChecked(True)
def clearBulletinTestStatus(
self
):
self.BulletinTestStatusLabel.setText("")
self.BulletinTestStatusLabel.setStyleSheet("")
def collectAppearanceSettings(
self
) -> tuple[str, str, str]:
if self.LightThemeRadio.isChecked():
theme = "light"
elif self.DarkThemeRadio.isChecked():
theme = "dark"
else:
theme = "system"
style = self.StyleComboBox.currentText()
custom_theme = self.CustomThemeComboBox.currentData() or ""
if not custom_theme:
custom_theme = ""
return theme, style, custom_theme
def collectBulletinSettings(
self
) -> tuple[str, bool, int]:
url = self.BulletinServerUrlEdit.text().strip()
auto_fetch = self.BulletinAutoFetchCheck.isChecked()
sync_interval = self.BulletinSyncIntervalSpin.value()
if sync_interval < 1:
sync_interval = 5
return url, auto_fetch, sync_interval
def saveAppearanceSettings(
self
):
theme, style, custom_theme = self.collectAppearanceSettings()
self.__cfg_mgr.set(CfgKey.GLOBAL.APPEARANCE.STYLE, style)
self.__cfg_mgr.set(CfgKey.GLOBAL.APPEARANCE.CUSTOM_THEME, custom_theme)
setActiveStyle(style)
if not _applyCustomTheme(custom_theme, theme):
self.__cfg_mgr.set(CfgKey.GLOBAL.APPEARANCE.CUSTOM_THEME, "")
self.syncRadioFromNeedTheme(custom_theme)
theme, _, _ = self.collectAppearanceSettings()
self.__cfg_mgr.set(CfgKey.GLOBAL.APPEARANCE.THEME, theme)
self.setNavigationIcons()
self.updateCustomThemeStatus()
self.updateCustomThemeInfo()
self.__original_theme = theme
self.__original_custom_theme = custom_theme if custom_theme else ""
self.__original_style = getActiveStyle()
def saveBulletinSettings(
self
):
url, auto_fetch, sync_interval = self.collectBulletinSettings()
self.__cfg_mgr.set(CfgKey.GLOBAL.BULLETIN.SERVER_URL, url)
self.__cfg_mgr.set(CfgKey.GLOBAL.BULLETIN.AUTO_FETCH, auto_fetch)
self.__cfg_mgr.set(CfgKey.GLOBAL.BULLETIN.SYNC_INTERVAL, sync_interval)
self.__original_bulletin_url = url
self.__original_bulletin_auto_fetch = auto_fetch
self.__original_bulletin_sync_interval = sync_interval
def saveSettings(
self
):
self.saveBulletinSettings()
self.saveAppearanceSettings()
def maybeRestart(
self
) -> bool:
reply = QMessageBox.question(
self,
"提示 - AutoLibrary",
"界面风格已修改,需要重启程序才能生效。是否立即重启?",
QMessageBox.Yes | QMessageBox.No,
QMessageBox.Yes
)
if reply == QMessageBox.Yes:
_restartApp()
return True
return False
@Slot()
def onImportCustomThemeButtonClicked(
self
):
file_path, _ = QFileDialog.getOpenFileName(
self,
"导入主题 - AutoLibrary",
"",
"主题文件 (*.altheme *.qss);;所有文件 (*)"
)
if not file_path:
return
try:
file_id = themeInstance().importTheme(file_path)
self.populateCustomThemes()
idx = self.CustomThemeComboBox.findData(file_id)
if idx >= 0:
self.CustomThemeComboBox.setCurrentIndex(idx)
self.updateCustomThemeInfo()
except Exception as e:
QMessageBox.warning(
self,
"导入失败 - AutoLibrary",
f"无法导入主题文件:{e}"
)
@Slot()
def onRemoveCustomThemeButtonClicked(
self
):
file = self.CustomThemeComboBox.currentData()
if not file:
QMessageBox.information(
self,
"提示 - AutoLibrary",
"请先选择一个主题。"
)
return
t = self.__theme_cache.get(file)
name = t.get("name", file) if t else file
reply = QMessageBox.question(
self,
"删除主题 - AutoLibrary",
f"确定要删除主题 \"{name}\" 吗?",
QMessageBox.Yes | QMessageBox.No,
QMessageBox.No
)
if reply != QMessageBox.Yes:
return
try:
themeInstance().removeTheme(file)
self.populateCustomThemes()
self.CustomThemeComboBox.setCurrentIndex(0)
self.updateCustomThemeStatus()
self.updateCustomThemeInfo()
except Exception as e:
QMessageBox.warning(
self,
"删除失败 - AutoLibrary",
f"无法删除主题:{e}"
)
@Slot()
def onCustomThemeComboBoxChanged(
self,
index: int
):
self.updateCustomThemeInfo()
@Slot()
def onResetCustomThemeButtonClicked(
self
):
self.CustomThemeComboBox.blockSignals(True)
if self.__original_custom_theme:
idx = self.CustomThemeComboBox.findData(self.__original_custom_theme)
if idx >= 0:
self.CustomThemeComboBox.setCurrentIndex(idx)
else:
self.CustomThemeComboBox.setCurrentIndex(0)
else:
self.CustomThemeComboBox.setCurrentIndex(0)
self.CustomThemeComboBox.blockSignals(False)
if self.__original_theme == "light":
self.LightThemeRadio.setChecked(True)
elif self.__original_theme == "dark":
self.DarkThemeRadio.setChecked(True)
else:
self.SystemThemeRadio.setChecked(True)
self.updateCustomThemeInfo()
self.updateCustomThemeStatus()
@Slot()
def onBulletinTestButtonClicked(
self
):
url = self.BulletinServerUrlEdit.text().strip()
if not url:
self.BulletinTestStatusLabel.setText("请先输入服务器地址。")
self.BulletinTestStatusLabel.setStyleSheet("color: red;")
return
if hasattr(self, '__bulletin_test_worker') and self.__bulletin_test_worker is not None:
return
self.BulletinTestButton.setEnabled(False)
self.BulletinTestStatusLabel.setText("正在测试连接...")
self.BulletinTestStatusLabel.setStyleSheet("")
self.__bulletin_test_t0 = time.monotonic()
api_url = url.rstrip("/") + "/bulletins"
self.__bulletin_test_worker = ALBulletinFetchWorker(
self, api_url, {"date": "", "time": "", "range_hour": "1"}
)
self.__bulletin_test_worker.fetchWorkerIsFinished.connect(
self.onBulletinTestIsFinished
)
self.__bulletin_test_worker.fetchWorkerFinishedWithError.connect(
self.onBulletinTestFinishedWithError
)
self.__bulletin_test_worker.start()
@Slot(dict)
def onBulletinTestIsFinished(
self,
data: dict
):
self.__bulletin_test_worker.fetchWorkerIsFinished.disconnect(
self.onBulletinTestIsFinished
)
self.__bulletin_test_worker.fetchWorkerFinishedWithError.disconnect(
self.onBulletinTestFinishedWithError
)
self.__bulletin_test_worker.wait(3000)
self.__bulletin_test_worker.deleteLater()
self.__bulletin_test_worker = None
elapsed_ms = (time.monotonic() - self.__bulletin_test_t0) * 1000
self.BulletinTestStatusLabel.setText(
f"连接成功!响应延迟 {elapsed_ms:.0f} ms"
)
self.BulletinTestStatusLabel.setStyleSheet("color: green;")
self.BulletinTestButton.setEnabled(True)
QTimer.singleShot(3000, self, self.clearBulletinTestStatus)
@Slot(str)
def onBulletinTestFinishedWithError(
self,
error_message: str
):
self.__bulletin_test_worker.fetchWorkerIsFinished.disconnect(
self.onBulletinTestIsFinished
)
self.__bulletin_test_worker.fetchWorkerFinishedWithError.disconnect(
self.onBulletinTestFinishedWithError
)
self.__bulletin_test_worker.wait(3000)
self.__bulletin_test_worker.deleteLater()
self.__bulletin_test_worker = None
self.BulletinTestStatusLabel.setText(f"连接失败:{error_message}")
self.BulletinTestStatusLabel.setStyleSheet("color: red;")
self.BulletinTestButton.setEnabled(True)
@Slot()
def onCancelButtonClicked(
self
):
self.close()
@Slot()
def onApplyButtonClicked(
self
):
_, style, _ = self.collectAppearanceSettings()
style_changed = self.__original_style != style
self.saveSettings()
if style_changed:
self.maybeRestart()
@Slot()
def onConfirmButtonClicked(
self
):
self.onApplyButtonClicked()
self.close()
+63 -67
View File
@@ -118,35 +118,34 @@ class ALStatusLabel(QLabel):
event event
): ):
Painter = QPainter(self) painter = QPainter(self)
Painter.setRenderHint(QPainter.RenderHint.Antialiasing) painter.setRenderHint(QPainter.RenderHint.Antialiasing)
center_x = self.width()/2 center_x = self.width()/2
center_y = self.height()/2 center_y = self.height()/2
radius = min(center_x, center_y) - 3 radius = min(center_x, center_y) - 3
match self.__status: match self.__status:
case self.Status.WAITING: case self.Status.WAITING:
Pen = Painter.pen() pen = painter.pen()
Pen.setWidth(2) pen.setWidth(2)
Pen.setBrush(Qt.BrushStyle.NoBrush) pen.setCapStyle(Qt.PenCapStyle.RoundCap)
Pen.setCapStyle(Qt.PenCapStyle.RoundCap) pen.setColor(QColor("#969696")) # grey
Pen.setColor(QColor("#969696")) # grey painter.setPen(pen)
Painter.setPen(Pen) painter.drawEllipse(
Painter.drawEllipse(
int(center_x - radius), int(center_x - radius),
int(center_y - radius), int(center_y - radius),
int(radius*2), int(radius*2),
int(radius*2) int(radius*2)
) )
case self.Status.RUNNING: case self.Status.RUNNING:
Gradient = QConicalGradient(center_x, center_y, self.__icon_angle) gradient = QConicalGradient(center_x, center_y, self.__icon_angle)
Gradient.setColorAt(0.0, QColor("#2294FF" if self.isDarkMode() else "#0094FF")) gradient.setColorAt(0.0, QColor("#2294FF" if self.isDarkMode() else "#0094FF"))
Gradient.setColorAt(1.0, QColor("#2294FF00")) gradient.setColorAt(1.0, QColor("#2294FF00"))
Pen = Painter.pen() pen = painter.pen()
Pen.setWidth(3) pen.setWidth(3)
Pen.setBrush(Gradient) pen.setBrush(gradient)
Pen.setCapStyle(Qt.PenCapStyle.RoundCap) pen.setCapStyle(Qt.PenCapStyle.RoundCap)
Painter.setPen(Pen) painter.setPen(pen)
Painter.drawEllipse( painter.drawEllipse(
int(center_x - radius), int(center_x - radius),
int(center_y - radius), int(center_y - radius),
int(radius*2), int(radius*2),
@@ -154,102 +153,99 @@ class ALStatusLabel(QLabel):
) )
case self.Status.SUCCESS: case self.Status.SUCCESS:
# draw the success green circle # draw the success green circle
Pen = Painter.pen() pen = painter.pen()
Pen.setWidth(2) pen.setWidth(2)
Pen.setBrush(Qt.BrushStyle.NoBrush) pen.setCapStyle(Qt.PenCapStyle.RoundCap)
Pen.setCapStyle(Qt.PenCapStyle.RoundCap) pen.setColor(QColor("#4CAF50" if self.isDarkMode() else "#00AF50")) # green
Pen.setColor(QColor("#4CAF50" if self.isDarkMode() else "#00AF50")) # green painter.setPen(pen)
Painter.setPen(Pen) painter.drawEllipse(
Painter.drawEllipse(
int(center_x - radius), int(center_x - radius),
int(center_y - radius), int(center_y - radius),
int(radius*2), int(radius*2),
int(radius*2) int(radius*2)
) )
# draw the success check mark '✓' # draw the success check mark '✓'
Painter.setPen(Qt.PenStyle.SolidLine) painter.setPen(Qt.PenStyle.SolidLine)
Pen = Painter.pen() pen = painter.pen()
Pen.setWidth(3) pen.setWidth(3)
Pen.setBrush(Qt.BrushStyle.NoBrush) pen.setBrush(Qt.BrushStyle.NoBrush)
Pen.setCapStyle(Qt.PenCapStyle.RoundCap) pen.setCapStyle(Qt.PenCapStyle.RoundCap)
# white when dark mode, black when light mode # white when dark mode, black when light mode
Pen.setColor(self.getMarkColor()) pen.setColor(self.getMarkColor())
Painter.setPen(Pen) painter.setPen(pen)
mark_size = radius/2 mark_size = radius/2
mark_path = [ mark_path = [
(center_x - mark_size, center_y), (center_x - mark_size, center_y),
(center_x - mark_size/3, center_y + mark_size/2), (center_x - mark_size/3, center_y + mark_size/2),
(center_x + mark_size, center_y - mark_size/2) (center_x + mark_size, center_y - mark_size/2)
] ]
Painter.drawLine( painter.drawLine(
int(mark_path[0][0]),int(mark_path[0][1]), int(mark_path[0][0]),int(mark_path[0][1]),
int(mark_path[1][0]),int(mark_path[1][1]) int(mark_path[1][0]),int(mark_path[1][1])
) )
Painter.drawLine( painter.drawLine(
int(mark_path[1][0]),int(mark_path[1][1]), int(mark_path[1][0]),int(mark_path[1][1]),
int(mark_path[2][0]),int(mark_path[2][1]) int(mark_path[2][0]),int(mark_path[2][1])
) )
case self.Status.WARNING: case self.Status.WARNING:
# draw the warning orange circle # draw the warning orange circle
Pen = Painter.pen() pen = painter.pen()
Pen.setWidth(2) pen.setWidth(2)
Pen.setBrush(Qt.BrushStyle.NoBrush) pen.setCapStyle(Qt.PenCapStyle.RoundCap)
Pen.setCapStyle(Qt.PenCapStyle.RoundCap) pen.setColor(QColor("#FF9800")) # orange
Pen.setColor(QColor("#FF9800")) # orange painter.setPen(pen)
Painter.setPen(Pen) painter.drawEllipse(
Painter.drawEllipse(
int(center_x - radius), int(center_x - radius),
int(center_y - radius), int(center_y - radius),
int(radius*2), int(radius*2),
int(radius*2) int(radius*2)
) )
# draw the warning exclamation mark '!' # draw the warning exclamation mark '!'
Painter.setPen(Qt.PenStyle.SolidLine) painter.setPen(Qt.PenStyle.SolidLine)
Pen = Painter.pen() pen = painter.pen()
Pen.setWidth(3) pen.setWidth(3)
Pen.setBrush(Qt.BrushStyle.NoBrush) pen.setBrush(Qt.BrushStyle.NoBrush)
Pen.setCapStyle(Qt.PenCapStyle.RoundCap) pen.setCapStyle(Qt.PenCapStyle.RoundCap)
# white when dark mode, black when light mode # white when dark mode, black when light mode
Pen.setColor(self.getMarkColor()) pen.setColor(self.getMarkColor())
Painter.setPen(Pen) painter.setPen(pen)
Painter.drawLine( painter.drawLine(
int(center_x), int(center_y - radius/2), int(center_x), int(center_y - radius/2),
int(center_x), int(center_y + radius/6) int(center_x), int(center_y + radius/6)
) )
Painter.drawPoint( painter.drawPoint(
int(center_x), int(center_y + radius/2) int(center_x), int(center_y + radius/2)
) )
case self.Status.FAILURE: case self.Status.FAILURE:
# draw the failure red circle # draw the failure red circle
Pen = Painter.pen() pen = painter.pen()
Pen.setWidth(2) pen.setWidth(2)
Pen.setBrush(Qt.BrushStyle.NoBrush) pen.setCapStyle(Qt.PenCapStyle.RoundCap)
Pen.setCapStyle(Qt.PenCapStyle.RoundCap) pen.setColor(QColor("#DC0000")) # red
Pen.setColor(QColor("#DC0000")) # red painter.setPen(pen)
Painter.setPen(Pen) painter.drawEllipse(
Painter.drawEllipse(
int(center_x - radius), int(center_x - radius),
int(center_y - radius), int(center_y - radius),
int(radius*2), int(radius*2),
int(radius*2) int(radius*2)
) )
# draw the failure cross mark '✗' # draw the failure cross mark '✗'
Painter.setPen(Qt.PenStyle.SolidLine) painter.setPen(Qt.PenStyle.SolidLine)
Pen = Painter.pen() pen = painter.pen()
Pen.setWidth(3) pen.setWidth(3)
Pen.setBrush(Qt.BrushStyle.NoBrush) pen.setBrush(Qt.BrushStyle.NoBrush)
Pen.setCapStyle(Qt.PenCapStyle.RoundCap) pen.setCapStyle(Qt.PenCapStyle.RoundCap)
# white when dark mode, black when light mode # white when dark mode, black when light mode
Pen.setColor(self.getMarkColor()) pen.setColor(self.getMarkColor())
Painter.setPen(Pen) painter.setPen(pen)
mark_size = radius/3 mark_size = radius/3
Painter.drawLine( painter.drawLine(
int(center_x - mark_size), int(center_y - mark_size), int(center_x - mark_size), int(center_y - mark_size),
int(center_x + mark_size), int(center_y + mark_size) int(center_x + mark_size), int(center_y + mark_size)
) )
Painter.drawLine( painter.drawLine(
int(center_x + mark_size), int(center_y - mark_size), int(center_x + mark_size), int(center_y - mark_size),
int(center_x - mark_size), int(center_y + mark_size) int(center_x - mark_size), int(center_y + mark_size)
) )
Painter.end() painter.end()
super().paintEvent(event) super().paintEvent(event)
+1 -1
View File
@@ -322,5 +322,5 @@ class ALTimerTaskAddDialog(QDialog, Ui_ALTimerTaskAddDialog):
): ):
QDesktopServices.openUrl( QDesktopServices.openUrl(
QUrl("https://www.autolibrary.kenanzhu.com/manuals/autoscript") QUrl("https://manuals.autolibrary.kenanzhu.com/zh/autoscript")
) )
+14 -14
View File
@@ -108,24 +108,24 @@ class ALTimerTaskHistoryDialog(QDialog):
execute_time = record.get("execute_time", "") execute_time = record.get("execute_time", "")
result = record.get("result", ALTimerTaskStatus.UNKNOWN) result = record.get("result", ALTimerTaskStatus.UNKNOWN)
duration = record.get("duration", 0) duration = record.get("duration", 0)
ExecuteTimeItem = QTableWidgetItem(execute_time) execute_time_item = QTableWidgetItem(execute_time)
ExecuteTimeItem.setTextAlignment(Qt.AlignmentFlag.AlignCenter) execute_time_item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
self.HistoryTableWidget.setItem(row, 0, ExecuteTimeItem) self.HistoryTableWidget.setItem(row, 0, execute_time_item)
ResultItem = QTableWidgetItem(result.value) result_item = QTableWidgetItem(result.value)
ResultItem.setTextAlignment(Qt.AlignmentFlag.AlignCenter) result_item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
match result: match result:
case ALTimerTaskStatus.EXECUTED: case ALTimerTaskStatus.EXECUTED:
ResultItem.setForeground(Qt.GlobalColor.green) result_item.setForeground(Qt.GlobalColor.green)
case ALTimerTaskStatus.ERROR: case ALTimerTaskStatus.ERROR:
ResultItem.setForeground(Qt.GlobalColor.red) result_item.setForeground(Qt.GlobalColor.red)
case ALTimerTaskStatus.OUTDATED: case ALTimerTaskStatus.OUTDATED:
ResultItem.setForeground(Qt.GlobalColor.red) result_item.setForeground(Qt.GlobalColor.red)
case _: case _:
ResultItem.setForeground(Qt.GlobalColor.black) result_item.setForeground(Qt.GlobalColor.black)
self.HistoryTableWidget.setItem(row, 1, ResultItem) self.HistoryTableWidget.setItem(row, 1, result_item)
DurationItem = QTableWidgetItem(f"{duration:.2f}") duration_item = QTableWidgetItem(f"{duration:.2f}")
DurationItem.setTextAlignment(Qt.AlignmentFlag.AlignCenter) duration_item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
self.HistoryTableWidget.setItem(row, 2, DurationItem) self.HistoryTableWidget.setItem(row, 2, duration_item)
self.HistoryTableWidget.setRowHeight(row, 25) self.HistoryTableWidget.setRowHeight(row, 25)
@Slot() @Slot()
@@ -135,4 +135,4 @@ class ALTimerTaskHistoryDialog(QDialog):
self.__history.clear() self.__history.clear()
self.HistoryTableWidget.setRowCount(0) self.HistoryTableWidget.setRowCount(0)
self.__task_data["repeat_history"] = self.__history self.__task_data["repeat_history"] = self.__history # = []
+14 -31
View File
@@ -43,6 +43,7 @@ from gui.ALTimerTaskAddDialog import (
ALTimerTaskStatus ALTimerTaskStatus
) )
from gui.ALTimerTaskHistoryDialog import ALTimerTaskHistoryDialog from gui.ALTimerTaskHistoryDialog import ALTimerTaskHistoryDialog
from gui.ALWidgetMixin import CenterOnParentMixin
from gui.resources.ui.Ui_ALTimerTaskManageWidget import Ui_ALTimerTaskManageWidget from gui.resources.ui.Ui_ALTimerTaskManageWidget import Ui_ALTimerTaskManageWidget
from interfaces.ConfigProvider import ( from interfaces.ConfigProvider import (
CfgKey, CfgKey,
@@ -80,12 +81,12 @@ class ALTimerTaskItemWidget(QWidget):
self.TaskInfoLayout = QVBoxLayout() self.TaskInfoLayout = QVBoxLayout()
self.TaskInfoLayout.setSpacing(5) self.TaskInfoLayout.setSpacing(5)
TaskNameLabel = QLabel(self.__timer_task["name"]) TaskNameLabel = QLabel(self.__timer_task["name"])
TaskNameLabelFont = TaskNameLabel.font() task_name_label_font = TaskNameLabel.font()
TaskNameLabelFont.setBold(True) task_name_label_font.setBold(True)
TaskNameLabel.setFont(TaskNameLabelFont) TaskNameLabel.setFont(task_name_label_font)
TaskNameLabel.setFixedHeight(25) TaskNameLabel.setFixedHeight(25)
self.TaskInfoLayout.addWidget(TaskNameLabel) self.TaskInfoLayout.addWidget(TaskNameLabel)
ExecuteTimeStr = self.__timer_task["execute_time"].strftime("%Y-%m-%d %H:%M:%S") execute_time_str = self.__timer_task["execute_time"].strftime("%Y-%m-%d %H:%M:%S")
if self.__timer_task.get("repeat", False): if self.__timer_task.get("repeat", False):
repeat_days = self.__timer_task.get("repeat_days", []) repeat_days = self.__timer_task.get("repeat_days", [])
repeat_hour = self.__timer_task.get("repeat_hour", 0) repeat_hour = self.__timer_task.get("repeat_hour", 0)
@@ -93,14 +94,14 @@ class ALTimerTaskItemWidget(QWidget):
repeat_second = self.__timer_task.get("repeat_second", 0) repeat_second = self.__timer_task.get("repeat_second", 0)
if len(repeat_days) == 7: if len(repeat_days) == 7:
time_str = f"{repeat_hour:02d}:{repeat_minute:02d}:{repeat_second:02d}" time_str = f"{repeat_hour:02d}:{repeat_minute:02d}:{repeat_second:02d}"
ExecuteTimeLabel = QLabel(f"下次执行时间: {ExecuteTimeStr} (每日 {time_str})") ExecuteTimeLabel = QLabel(f"下次执行时间: {execute_time_str} (每日 {time_str})")
else: else:
day_names = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"] day_names = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"]
selected_days = [day_names[d] for d in repeat_days] selected_days = [day_names[d] for d in repeat_days]
time_str = f"{repeat_hour:02d}:{repeat_minute:02d}:{repeat_second:02d}" time_str = f"{repeat_hour:02d}:{repeat_minute:02d}:{repeat_second:02d}"
ExecuteTimeLabel = QLabel(f"下次执行时间: {ExecuteTimeStr} (每{','.join(selected_days)} {time_str})") ExecuteTimeLabel = QLabel(f"下次执行时间: {execute_time_str} (每{','.join(selected_days)} {time_str})")
else: else:
ExecuteTimeLabel = QLabel(f"执行时间: {ExecuteTimeStr}") ExecuteTimeLabel = QLabel(f"执行时间: {execute_time_str}")
ExecuteTimeLabel.setStyleSheet("color: #969696;") ExecuteTimeLabel.setStyleSheet("color: #969696;")
ExecuteTimeLabel.setFixedHeight(20) ExecuteTimeLabel.setFixedHeight(20)
self.TaskInfoLayout.addWidget(ExecuteTimeLabel) self.TaskInfoLayout.addWidget(ExecuteTimeLabel)
@@ -189,7 +190,7 @@ class ALTimerTaskItemWidget(QWidget):
Menu.exec(self.mapToGlobal(pos)) Menu.exec(self.mapToGlobal(pos))
class ALTimerTaskManageWidget(QWidget, Ui_ALTimerTaskManageWidget): class ALTimerTaskManageWidget(CenterOnParentMixin, QWidget, Ui_ALTimerTaskManageWidget):
class SortPolicy(Enum): class SortPolicy(Enum):
@@ -299,29 +300,6 @@ class ALTimerTaskManageWidget(QWidget, Ui_ALTimerTaskManageWidget):
) )
return False return False
def showEvent(
self,
event
):
result = super().showEvent(event)
screen_rect = self.screen().geometry()
target_pos = self.parent().geometry().center()
target_pos.setX(target_pos.x() - self.width()//2)
target_pos.setY(target_pos.y() - self.height()//2)
if target_pos.x() < 0:
target_pos.setX(0)
if target_pos.x() + self.width() > screen_rect.width():
target_pos.setX(screen_rect.width() - self.width())
if target_pos.y() < 0:
target_pos.setY(0)
if target_pos.y() + self.height() > screen_rect.height():
target_pos.setY(screen_rect.height() - self.height())
self.move(target_pos)
return result
def closeEvent( def closeEvent(
self, self,
event: QCloseEvent event: QCloseEvent
@@ -537,6 +515,11 @@ class ALTimerTaskManageWidget(QWidget, Ui_ALTimerTaskManageWidget):
task: dict task: dict
): ):
# Here we use reference to task data, not copy.
# So any change of task data in history dialog will be
# reflected in the timer task list
# Thus we can emit timerTasksChanged signal to
# update config file
Dialog = ALTimerTaskHistoryDialog(self, task) Dialog = ALTimerTaskHistoryDialog(self, task)
if Dialog.exec() == QDialog.DialogCode.Accepted: if Dialog.exec() == QDialog.DialogCode.Accepted:
self.timerTasksChanged.emit() self.timerTasksChanged.emit()
+142
View File
@@ -0,0 +1,142 @@
# -*- coding: utf-8 -*-
"""
Copyright (c) 2026 KenanZhu.
All rights reserved.
This software is provided "as is", without any warranty of any kind.
You may use, modify, and distribute this file under the terms of the MIT License.
See the LICENSE file for details.
"""
import queue
from PySide6.QtCore import (
QObject,
QTimer,
Signal,
Slot
)
from gui.ALMainWorker import TimerTaskWorker
class ALTimerTaskPoller(QObject):
taskRunning = Signal(dict)
taskFinished = Signal(bool, dict)
taskExecuted = Signal(dict)
taskError = Signal(dict)
def __init__(
self,
parent=None,
input_queue: queue.Queue = None,
output_queue: queue.Queue = None,
config_paths: list = None
):
super().__init__(parent)
self.__input_queue = input_queue or queue.Queue()
self.__output_queue = output_queue or queue.Queue()
self.__config_paths = config_paths or []
self.__task_queue = queue.Queue()
self.__timer = QTimer(self)
self.__timer.timeout.connect(self.__poll)
self.__worker = None
self.__stopped = False
def start(
self
):
self.__stopped = False
self.__timer.start(500)
def stop(
self
):
self.__stopped = True
self.__timer.stop()
self.__cleanupWorker()
def enqueue(
self,
task: dict
):
self.__task_queue.put(task)
def isRunning(
self
) -> bool:
return self.__worker is not None
def updateConfigPaths(
self,
config_paths: list
):
self.__config_paths = config_paths
@Slot()
def __poll(
self
):
if self.__worker is not None:
return
try:
task = self.__task_queue.get_nowait()
except queue.Empty:
return
self.__timer.stop()
self.taskRunning.emit(task)
try:
self.__worker = TimerTaskWorker(
task,
self.__input_queue,
self.__output_queue,
self.__config_paths
)
self.__worker.timerTaskWorkerIsFinished.connect(self.__onFinished)
self.__worker.start()
except Exception:
self.__worker = None
if not self.__stopped:
self.__timer.start(500)
@Slot(bool, dict)
def __onFinished(
self,
is_error: bool,
task: dict
):
self.__worker.timerTaskWorkerIsFinished.disconnect(self.__onFinished)
self.__worker.wait(1000)
self.__worker.deleteLater()
self.__worker = None
try:
self.taskFinished.emit(is_error, task)
if not is_error:
self.taskExecuted.emit(task)
else:
self.taskError.emit(task)
finally:
if not self.__stopped:
self.__timer.start(500)
def __cleanupWorker(
self
):
if self.__worker is None:
return
try:
self.__worker.timerTaskWorkerIsFinished.disconnect(self.__onFinished)
except (TypeError, RuntimeError):
pass
self.__worker.wait(500)
self.__worker.deleteLater()
self.__worker = None
+10 -10
View File
@@ -109,27 +109,27 @@ class ALUserTreeWidget(QTreeWidget):
super().dragMoveEvent(event) super().dragMoveEvent(event)
SourceItem = self.currentItem() source_item = self.currentItem()
TargetItem = self.itemAt(event.position().toPoint()) target_item = self.itemAt(event.position().toPoint())
if SourceItem is None: if source_item is None:
event.ignore() event.ignore()
return return
if SourceItem.type() == ALUserTreeItemType.GROUP.value: if source_item.type() == ALUserTreeItemType.GROUP.value:
if TargetItem is not None: if target_item is not None:
event.ignore() event.ignore()
return return
elif SourceItem.type() == ALUserTreeItemType.USER.value: elif source_item.type() == ALUserTreeItemType.USER.value:
if TargetItem is None: if target_item is None:
event.ignore() event.ignore()
return return
if TargetItem.type() != ALUserTreeItemType.GROUP.value: if target_item.type() != ALUserTreeItemType.GROUP.value:
event.ignore() event.ignore()
return return
if TargetItem.checkState(1) == Qt.CheckState.Unchecked: if target_item.checkState(1) == Qt.CheckState.Unchecked:
event.ignore() event.ignore()
return return
if not self.isDragPositionValid( if not self.isDragPositionValid(
self.visualItemRect(TargetItem), self.visualItemRect(target_item),
event.position().toPoint() event.position().toPoint()
): ):
event.ignore() event.ignore()
+3 -3
View File
@@ -5,11 +5,11 @@
workflow process. Do not edit manually. workflow process. Do not edit manually.
This file is auto-generated during the workflow process. This file is auto-generated during the workflow process.
Last updated: 2026-05-09 06:05:13 UTC Last updated: 2026-07-14 02:19:31 UTC
""" """
AL_VERSION = "1.3.0" AL_VERSION = "1.4.0"
AL_TAG = "v1.3.0" AL_TAG = "v1.4.0"
AL_COMMIT_SHA = "local" AL_COMMIT_SHA = "local"
AL_COMMIT_DATE = "null" # time zone : UTC AL_COMMIT_DATE = "null" # time zone : UTC
AL_BUILD_DATE = "null" # time zone : UTC AL_BUILD_DATE = "null" # time zone : UTC
+98 -115
View File
@@ -8,6 +8,7 @@ You may use, modify, and distribute this file under the terms of the MIT License
See the LICENSE file for details. See the LICENSE file for details.
""" """
import threading import threading
from pathlib import Path
from typing import Optional from typing import Optional
from PySide6.QtCore import ( from PySide6.QtCore import (
@@ -33,11 +34,12 @@ from PySide6.QtGui import QCloseEvent
from managers.driver.WebDriverManager import ( from managers.driver.WebDriverManager import (
instance as webdriverInstance, instance as webdriverInstance,
WebDriverManager, WebDriverManager,
WebDriverInfo, WebDriverCtx,
WebDriverType, WebDriverType,
WebDriverStatus WebDriverStatus
) )
from gui.ALStatusLabel import ALStatusLabel from gui.ALStatusLabel import ALStatusLabel
from gui.ALWidgetMixin import CenterOnParentMixin
class DownloadWorker(QThread): class DownloadWorker(QThread):
@@ -53,12 +55,12 @@ class DownloadWorker(QThread):
def __init__( def __init__(
self, self,
driver_manager: WebDriverManager, driver_manager: WebDriverManager,
driver_info: WebDriverInfo driver_ctx: WebDriverCtx
): ):
super().__init__() super().__init__()
self.__driver_manager = driver_manager self.__driver_manager = driver_manager
self.__driver_info = driver_info self.__driver_ctx = driver_ctx
self.__driver_path = None self.__driver_path: Optional[Path] = None
self.__cancelled = False self.__cancelled = False
self.__cancel_event = threading.Event() self.__cancel_event = threading.Event()
@@ -80,7 +82,7 @@ class DownloadWorker(QThread):
self.downloadCancelled.emit() self.downloadCancelled.emit()
return return
self.__driver_path = self.__driver_manager.installDriver( self.__driver_path = self.__driver_manager.installDriver(
self.__driver_info, self.__driver_ctx,
progress_callback=self.onProgress, progress_callback=self.onProgress,
cancel_event=self.__cancel_event cancel_event=self.__cancel_event
) )
@@ -123,7 +125,7 @@ class DownloadWorker(QThread):
self.wait() self.wait()
class ALWebDriverDownloadDialog(QDialog): class ALWebDriverDownloadDialog(CenterOnParentMixin, QDialog):
def __init__( def __init__(
self, self,
@@ -139,12 +141,11 @@ class ALWebDriverDownloadDialog(QDialog):
""" """
super().__init__(parent) super().__init__(parent)
self.__driver_dir = driver_dir self.__driver_dir = driver_dir
self.__driver_manager: Optional[WebDriverManager] = None self.__driver_manager: Optional[WebDriverManager] = None
self.__confirmed = False self.__confirmed = False
self.__selected_driver_info: Optional[WebDriverInfo] = None self.__selected_driver_ctx: Optional[WebDriverCtx] = None
self.__driver_infos: list[WebDriverInfo] = [] self.__driver_ctxs: list[WebDriverCtx] = []
self.__download_thread: Optional[DownloadWorker] = None self.__download_thread: Optional[DownloadWorker] = None
self.setupUi() self.setupUi()
@@ -152,27 +153,26 @@ class ALWebDriverDownloadDialog(QDialog):
self.initializeDriverManager() self.initializeDriverManager()
self.refreshDriverList() self.refreshDriverList()
def showEvent( def closeEvent(
self, self,
event event: QCloseEvent
): ):
result = super().showEvent(event) if self.__download_thread and self.__download_thread.isRunning():
if self.parent(): reply = QMessageBox.question(
screen_rect = self.screen().geometry() self,
target_pos = self.parent().geometry().center() "确认关闭 - AutoLibrary",
target_pos.setX(target_pos.x() - self.width()//2) "驱动正在下载中, 确定要取消并关闭对话框吗 ?",
target_pos.setY(target_pos.y() - self.height()//2) QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
if target_pos.x() < 0: )
target_pos.setX(0) if reply == QMessageBox.StandardButton.No:
if target_pos.x() + self.width() > screen_rect.width(): event.ignore()
target_pos.setX(screen_rect.width() - self.width()) return
if target_pos.y() < 0: self.__download_thread.stop()
target_pos.setY(0) if not self.__confirmed:
if target_pos.y() + self.height() > screen_rect.height(): self.__selected_driver_ctx = None
target_pos.setY(screen_rect.height() - self.height()) event.accept()
self.move(target_pos) super().closeEvent(event)
return result
def setupUi( def setupUi(
self self
@@ -267,89 +267,42 @@ class ALWebDriverDownloadDialog(QDialog):
if not self.__driver_manager: if not self.__driver_manager:
return return
self.__driver_manager.refresh() self.__driver_manager.refresh()
self.__driver_infos = self.__driver_manager.getDriverInfos() self.__driver_ctxs = self.__driver_manager.getDriverCtxs()
self.DriverComboBox.clear() self.DriverComboBox.clear()
installed = 0 installed = 0
installed_idx = 0 installed_idx = 0
for i, driver_info in enumerate(self.__driver_infos): for i, driver_ctx in enumerate(self.__driver_ctxs):
display_text = f"{driver_info.driver_type.value} - {driver_info.browser_version}" display_text = f"{driver_ctx.driver_type.value} - {driver_ctx.browser_version}"
if driver_info.driver_status == WebDriverStatus.INSTALLED: if driver_ctx.driver_status == WebDriverStatus.INSTALLED:
installed += 1 installed += 1
installed_idx = i # get the installed driver index installed_idx = i # get the installed driver index
display_text += " : 已安装" display_text += " - 已安装"
self.DriverComboBox.addItem(display_text) self.DriverComboBox.addItem(display_text)
count = len(self.__driver_infos) count = len(self.__driver_ctxs)
self.BrowserCountLabel.setText(f"检测到 {count} 个可用浏览器,{installed} 个已安装驱动:") self.BrowserCountLabel.setText(f"检测到 {count} 个可用浏览器,{installed} 个已安装驱动:")
if self.__driver_infos: if self.__driver_ctxs:
self.DriverComboBox.setCurrentIndex(installed_idx) self.DriverComboBox.setCurrentIndex(installed_idx)
def onDriverComboBoxChanged(
self,
index: int
):
if not self.__driver_infos or index < 0 or index >= len(self.__driver_infos):
return
driver_info = self.__driver_infos[index]
self.updateDriverInfoDisplay(driver_info)
self.updateProgressBarStates(driver_info)
self.updateButtonStates(driver_info)
def closeEvent(
self,
event: QCloseEvent
):
if self.__download_thread and self.__download_thread.isRunning():
reply = QMessageBox.question(
self,
"确认关闭 - AutoLibrary",
"驱动正在下载中, 确定要取消并关闭对话框吗 ?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
)
if reply == QMessageBox.StandardButton.No:
event.ignore()
return
self.__download_thread.stop()
if not self.__confirmed:
self.__selected_driver_info = None
event.accept()
super().closeEvent(event)
def onThreadFinished(
self
):
if self.__download_thread:
self.__download_thread.deleteLater()
self.__download_thread = None
def getSelectedDriverInfo(
self
) -> Optional[WebDriverInfo]:
return self.__selected_driver_info
def updateDriverInfoDisplay( def updateDriverInfoDisplay(
self, self,
driver_info: WebDriverInfo driver_ctx: WebDriverCtx
): ):
if driver_info.driver_type == WebDriverType.CHROME: if driver_ctx.driver_type == WebDriverType.CHROME:
driver_type = "Google Chrome" driver_type = "Google Chrome"
elif driver_info.driver_type == WebDriverType.FIREFOX: elif driver_ctx.driver_type == WebDriverType.FIREFOX:
driver_type = "Mozilla Firefox" driver_type = "Mozilla Firefox"
elif driver_info.driver_type == WebDriverType.EDGE: elif driver_ctx.driver_type == WebDriverType.EDGE:
driver_type = "Microsoft Edge" driver_type = "Microsoft Edge"
else: else:
driver_type = "未知" driver_type = "未知"
self.BrowserTypeLabel.setText(f"类型:{driver_type}") self.BrowserTypeLabel.setText(f"类型:{driver_type}")
self.VersionLabel.setText(f"版本:{driver_info.driver_version}") self.VersionLabel.setText(f"版本:{driver_ctx.driver_version}")
if driver_info.driver_path: if driver_ctx.driver_path:
self.PathLabel.setText(str(driver_info.driver_path)) self.PathLabel.setText(str(driver_ctx.driver_path))
else: else:
self.PathLabel.setText("未安装") self.PathLabel.setText("未安装")
match driver_info.driver_status: match driver_ctx.driver_status:
case WebDriverStatus.NOT_INSTALLED: case WebDriverStatus.NOT_INSTALLED:
self.StatusLabel.status = ALStatusLabel.Status.WAITING self.StatusLabel.status = ALStatusLabel.Status.WAITING
case WebDriverStatus.INSTALLED: case WebDriverStatus.INSTALLED:
@@ -361,51 +314,70 @@ class ALWebDriverDownloadDialog(QDialog):
def updateProgressBarStates( def updateProgressBarStates(
self, self,
driver_info: WebDriverInfo driver_ctx: WebDriverCtx
): ):
if driver_info.driver_status == WebDriverStatus.NOT_INSTALLED: if driver_ctx.driver_status == WebDriverStatus.NOT_INSTALLED:
self.ProgressBar.setValue(0) self.ProgressBar.setValue(0)
self.ProgressText.setText("未安装") self.ProgressText.setText("未安装")
elif driver_info.driver_status == WebDriverStatus.INSTALLED: elif driver_ctx.driver_status == WebDriverStatus.INSTALLED:
self.ProgressBar.setValue(100) self.ProgressBar.setValue(100)
self.ProgressText.setText("已安装") self.ProgressText.setText("已安装")
elif driver_info.driver_status == WebDriverStatus.DOWNLOADING: elif driver_ctx.driver_status == WebDriverStatus.DOWNLOADING:
pass # update by worker thread pass # update by worker thread
elif driver_info.driver_status == WebDriverStatus.ERROR: elif driver_ctx.driver_status == WebDriverStatus.ERROR:
self.ProgressBar.setValue(0) self.ProgressBar.setValue(0)
self.ProgressText.setText("下载失败") self.ProgressText.setText("下载失败")
def updateButtonStates( def updateButtonStates(
self, self,
driver_info: WebDriverInfo driver_ctx: WebDriverCtx
): ):
if driver_info.driver_status == WebDriverStatus.NOT_INSTALLED: if driver_ctx.driver_status == WebDriverStatus.NOT_INSTALLED:
self.RefreshButton.setEnabled(True) self.RefreshButton.setEnabled(True)
self.DeleteButton.setEnabled(False) self.DeleteButton.setEnabled(False)
self.DownloadButton.setEnabled(True) self.DownloadButton.setEnabled(True)
self.CancelButton.setEnabled(True) self.CancelButton.setEnabled(True)
self.ConfirmButton.setEnabled(False) self.ConfirmButton.setEnabled(False)
elif driver_info.driver_status == WebDriverStatus.INSTALLED: elif driver_ctx.driver_status == WebDriverStatus.INSTALLED:
self.RefreshButton.setEnabled(True) self.RefreshButton.setEnabled(True)
self.DownloadButton.setEnabled(False) self.DownloadButton.setEnabled(False)
self.DeleteButton.setEnabled(True) self.DeleteButton.setEnabled(True)
self.CancelButton.setEnabled(True) self.CancelButton.setEnabled(True)
self.ConfirmButton.setEnabled(True) self.ConfirmButton.setEnabled(True)
elif driver_info.driver_status == WebDriverStatus.DOWNLOADING: elif driver_ctx.driver_status == WebDriverStatus.DOWNLOADING:
self.RefreshButton.setEnabled(False) self.RefreshButton.setEnabled(False)
self.DownloadButton.setEnabled(False) self.DownloadButton.setEnabled(False)
self.DeleteButton.setEnabled(False) self.DeleteButton.setEnabled(False)
self.CancelButton.setEnabled(True) self.CancelButton.setEnabled(True)
self.ConfirmButton.setEnabled(False) self.ConfirmButton.setEnabled(False)
elif driver_info.driver_status == WebDriverStatus.ERROR: elif driver_ctx.driver_status == WebDriverStatus.ERROR:
self.RefreshButton.setEnabled(True) self.RefreshButton.setEnabled(True)
self.DownloadButton.setEnabled(True) self.DownloadButton.setEnabled(True)
self.DeleteButton.setEnabled(False) self.DeleteButton.setEnabled(False)
self.CancelButton.setEnabled(True) self.CancelButton.setEnabled(True)
self.ConfirmButton.setEnabled(False) self.ConfirmButton.setEnabled(False)
def getSelectedDriverInfo(
self
) -> Optional[WebDriverCtx]:
return self.__selected_driver_ctx
@Slot(int)
def onDriverComboBoxChanged(
self,
index: int
):
if not self.__driver_ctxs or index < 0 or index >= len(self.__driver_ctxs):
return
driver_ctx = self.__driver_ctxs[index]
self.updateDriverInfoDisplay(driver_ctx)
self.updateProgressBarStates(driver_ctx)
self.updateButtonStates(driver_ctx)
@Slot() @Slot()
def onRefreshButtonClicked( def onRefreshButtonClicked(
self self
@@ -419,9 +391,9 @@ class ALWebDriverDownloadDialog(QDialog):
): ):
index = self.DriverComboBox.currentIndex() index = self.DriverComboBox.currentIndex()
if index < 0 or index >= len(self.__driver_infos): if index < 0 or index >= len(self.__driver_ctxs):
return return
driver_info = self.__driver_infos[index] driver_info = self.__driver_ctxs[index]
if driver_info.driver_status.name != "INSTALLED": if driver_info.driver_status.name != "INSTALLED":
QMessageBox.information(self, "提示 - AutoLibrary", "该驱动未安装, 无需删除") QMessageBox.information(self, "提示 - AutoLibrary", "该驱动未安装, 无需删除")
return return
@@ -447,9 +419,9 @@ class ALWebDriverDownloadDialog(QDialog):
self.DriverComboBox.setEnabled(False) self.DriverComboBox.setEnabled(False)
index = self.DriverComboBox.currentIndex() index = self.DriverComboBox.currentIndex()
if index < 0 or index >= len(self.__driver_infos): if index < 0 or index >= len(self.__driver_ctxs):
return return
driver_info = self.__driver_infos[index] driver_info = self.__driver_ctxs[index]
if driver_info.driver_status == WebDriverStatus.INSTALLED: if driver_info.driver_status == WebDriverStatus.INSTALLED:
return return
driver_info.driver_status = WebDriverStatus.DOWNLOADING # we set this only to update driver_info.driver_status = WebDriverStatus.DOWNLOADING # we set this only to update
@@ -465,7 +437,7 @@ class ALWebDriverDownloadDialog(QDialog):
self.__download_thread.downloadFinished.connect(self.onDownloadFinished) self.__download_thread.downloadFinished.connect(self.onDownloadFinished)
self.__download_thread.downloadError.connect(self.onDownloadError) self.__download_thread.downloadError.connect(self.onDownloadError)
self.__download_thread.downloadCancelled.connect(self.onDownloadCancelled) self.__download_thread.downloadCancelled.connect(self.onDownloadCancelled)
self.__download_thread.finished.connect(self.onThreadFinished) self.__download_thread.finished.connect(self.onDownloadThreadFinished)
self.__download_thread.start() self.__download_thread.start()
@Slot() @Slot()
@@ -493,9 +465,11 @@ class ALWebDriverDownloadDialog(QDialog):
self.DriverComboBox.setEnabled(True) self.DriverComboBox.setEnabled(True)
index = self.DriverComboBox.currentIndex() index = self.DriverComboBox.currentIndex()
if 0 <= index < len(self.__driver_infos): if 0 <= index < len(self.__driver_ctxs):
driver_info = self.__driver_infos[index] driver_info = self.__driver_ctxs[index]
driver_info.driver_status = WebDriverStatus.INSTALLED driver_info.driver_status = WebDriverStatus.INSTALLED
current_text = self.DriverComboBox.itemText(index)
self.DriverComboBox.setItemText(index, current_text + " - 已安装")
self.updateDriverInfoDisplay(driver_info) self.updateDriverInfoDisplay(driver_info)
self.updateProgressBarStates(driver_info) self.updateProgressBarStates(driver_info)
self.updateButtonStates(driver_info) self.updateButtonStates(driver_info)
@@ -508,8 +482,8 @@ class ALWebDriverDownloadDialog(QDialog):
self.DriverComboBox.setEnabled(True) self.DriverComboBox.setEnabled(True)
index = self.DriverComboBox.currentIndex() index = self.DriverComboBox.currentIndex()
if 0 <= index < len(self.__driver_infos): if 0 <= index < len(self.__driver_ctxs):
driver_info = self.__driver_infos[index] driver_info = self.__driver_ctxs[index]
driver_info.driver_status = WebDriverStatus.ERROR driver_info.driver_status = WebDriverStatus.ERROR
self.updateDriverInfoDisplay(driver_info) self.updateDriverInfoDisplay(driver_info)
self.updateProgressBarStates(driver_info) self.updateProgressBarStates(driver_info)
@@ -523,8 +497,8 @@ class ALWebDriverDownloadDialog(QDialog):
self.DriverComboBox.setEnabled(True) self.DriverComboBox.setEnabled(True)
index = self.DriverComboBox.currentIndex() index = self.DriverComboBox.currentIndex()
if 0 <= index < len(self.__driver_infos): if 0 <= index < len(self.__driver_ctxs):
driver_info = self.__driver_infos[index] driver_info = self.__driver_ctxs[index]
self.__driver_manager.cancelDriverDownload(driver_info) self.__driver_manager.cancelDriverDownload(driver_info)
driver_info.driver_status = WebDriverStatus.NOT_INSTALLED driver_info.driver_status = WebDriverStatus.NOT_INSTALLED
self.updateDriverInfoDisplay(driver_info) self.updateDriverInfoDisplay(driver_info)
@@ -532,18 +506,27 @@ class ALWebDriverDownloadDialog(QDialog):
self.updateButtonStates(driver_info) self.updateButtonStates(driver_info)
self.ProgressText.setText("下载已取消") self.ProgressText.setText("下载已取消")
@Slot()
def onDownloadThreadFinished(
self
):
if self.__download_thread:
self.__download_thread.deleteLater()
self.__download_thread = None
@Slot() @Slot()
def onConfirmButtonClicked( def onConfirmButtonClicked(
self self
): ):
index = self.DriverComboBox.currentIndex() index = self.DriverComboBox.currentIndex()
if index < 0 or index >= len(self.__driver_infos): if index < 0 or index >= len(self.__driver_ctxs):
return return
driver_info = self.__driver_infos[index] driver_ctx = self.__driver_ctxs[index]
if driver_info.driver_status != WebDriverStatus.INSTALLED: if driver_ctx.driver_status != WebDriverStatus.INSTALLED:
return return
self.__selected_driver_info = driver_info self.__selected_driver_ctx = driver_ctx
self.__confirmed = True self.__confirmed = True
self.accept() self.accept()
@@ -563,5 +546,5 @@ class ALWebDriverDownloadDialog(QDialog):
self.__download_thread.cancel() self.__download_thread.cancel()
else: else:
self.__confirmed = False self.__confirmed = False
self.__selected_driver_info = None self.__selected_driver_ctx = None
self.reject() self.reject()
+49
View File
@@ -0,0 +1,49 @@
# -*- coding: utf-8 -*-
"""
Copyright (c) 2026 KenanZhu.
All rights reserved.
This software is provided "as is", without any warranty of any kind.
You may use, modify, and distribute this file under the terms of the MIT License.
See the LICENSE file for details.
"""
from PySide6.QtGui import QShowEvent
class CenterOnParentMixin:
"""
Mixin that centres the widget relative to its parent on first show,
clamping the position to the screen bounds.
Usage::
class MyWidget(CenterOnParentMixin, QWidget, Ui_MyWidget):
pass
class MyDialog(CenterOnParentMixin, QDialog):
pass
The mixin must appear **before** QWidget / QDialog in the base list
so that ``super().showEvent(event)`` resolves up the MRO correctly.
"""
def showEvent(
self,
event: QShowEvent
):
super().showEvent(event)
if self.parent():
screen_rect = self.screen().geometry()
target_pos = self.parent().geometry().center()
target_pos.setX(target_pos.x() - self.width() // 2)
target_pos.setY(target_pos.y() - self.height() // 2)
if target_pos.x() < 0:
target_pos.setX(0)
if target_pos.x() + self.width() > screen_rect.width():
target_pos.setX(screen_rect.width() - self.width())
if target_pos.y() < 0:
target_pos.setY(0)
if target_pos.y() + self.height() > screen_rect.height():
target_pos.setY(screen_rect.height() - self.height())
self.move(target_pos)
+539
View File
@@ -0,0 +1,539 @@
/*
* Copyright (c) 2026 KenanZhu.
* All rights reserved.
*
* This software is provided "as is", without any warranty of any kind.
* You may use, modify, and distribute this file under the terms of the MIT License.
* See the LICENSE file for details.
*
*
* AutoLibrary Official Theme : BlueForest
*/
/* ---- Global ---- */
QMainWindow::separator {
background-color: #1c2840;
width: 1px;
height: 1px;
}
/* ---- Menu Bar ---- */
QMenuBar {
background-color: #0f1628;
border-bottom: 1px solid #1c2840;
padding: 2px 5px;
color: #d0daf0;
}
QMenuBar::item {
padding: 2px 10px;
border-radius: 4px;
}
QMenuBar::item:selected {
background-color: #1c2840;
}
QMenu {
background-color: #162038;
border-style: solid;
border-color: #253250;
border-width: 1px;
padding: 4px;
border-radius: 6px;
}
QMenu::item {
padding: 5px 15px 5px 10px;
border-radius: 4px;
}
QMenu::item:selected {
background-color: #2dd4bf;
color: #0f1119;
}
QMenu::separator {
height: 1px;
background-color: #253250;
margin: 4px 8px;
}
/* ---- Button ---- */
QPushButton {
border-style: solid;
border-color: #253250;
border-width: 1px;
border-radius: 5px;
color: #d0daf0;
padding: 4px 12px;
background-color: #1c2840;
}
QPushButton:hover {
background-color: #243458;
border-color: #334478;
}
QPushButton:pressed {
background-color: #162038;
border-color: #2dd4bf;
}
QPushButton:disabled {
background-color: #162038;
color: #5568a0;
border-color: #1c2840;
}
QPushButton[default="true"] {
background-color: #2dd4bf;
color: #0f1119;
border-color: #2dd4bf;
}
QPushButton[default="true"]:hover {
background-color: #3de0cc;
}
/* ---- Input ---- */
QLineEdit,
QPlainTextEdit,
QTextEdit,
QSpinBox,
QDoubleSpinBox,
QDateEdit,
QTimeEdit {
background-color: #0a1020;
border-style: solid;
border-color: #253250;
border-width: 1px;
border-radius: 5px;
padding: 4px 8px;
color: #d0daf0;
selection-background-color: #2dd4bf;
selection-color: #0f1119;
}
QLineEdit:focus,
QPlainTextEdit:focus,
QTextEdit:focus,
QSpinBox:focus,
QDoubleSpinBox:focus,
QDateEdit:focus,
QTimeEdit:focus {
border-color: #2dd4bf;
}
QPlainTextEdit,
QTextEdit {
background-color: #0a1020;
}
QLineEdit:disabled,
QPlainTextEdit:disabled,
QTextEdit:disabled,
QSpinBox:disabled,
QDoubleSpinBox:disabled,
QDateEdit:disabled,
QTimeEdit:disabled {
background-color: #162038;
color: #5568a0;
border-color: #1c2840;
}
/* ---- Spin Button Arrows ---- */
QSpinBox::up-button,
QDoubleSpinBox::up-button,
QDateEdit::up-button,
QTimeEdit::up-button {
subcontrol-origin: border;
subcontrol-position: top right;
width: 10px;
border-left: 1px solid #253250;
border-bottom: 1px solid #253250;
border-top-right-radius: 4px;
}
QSpinBox::up-button:hover,
QDoubleSpinBox::up-button:hover,
QDateEdit::up-button:hover,
QTimeEdit::up-button:hover {
background-color: #1c2840;
}
QSpinBox::up-arrow,
QDoubleSpinBox::up-arrow,
QDateEdit::up-arrow,
QTimeEdit::up-arrow {
border-left: 4px solid transparent;
border-right: 4px solid transparent;
border-bottom: 5px solid #7888b8;
margin-top: 2px;
}
QSpinBox::down-button,
QDoubleSpinBox::down-button,
QDateEdit::down-button,
QTimeEdit::down-button {
width: 10px;
subcontrol-origin: border;
subcontrol-position: bottom right;
border-left: 1px solid #253250;
border-bottom-right-radius: 4px;
}
QSpinBox::down-button:hover,
QDoubleSpinBox::down-button:hover,
QDateEdit::down-button:hover,
QTimeEdit::down-button:hover {
background-color: #1c2840;
}
QSpinBox::down-arrow,
QDoubleSpinBox::down-arrow,
QDateEdit::down-arrow,
QTimeEdit::down-arrow {
border-left: 4px solid transparent;
border-right: 4px solid transparent;
border-top: 5px solid #7888b8;
margin-bottom: 2px;
}
QSpinBox::up-button:disabled,
QDoubleSpinBox::up-button:disabled,
QDateEdit::up-button:disabled,
QTimeEdit::up-button:disabled,
QSpinBox::down-button:disabled,
QDoubleSpinBox::down-button:disabled,
QDateEdit::down-button:disabled,
QTimeEdit::down-button:disabled {
background-color: #162038;
}
QSpinBox::up-arrow:disabled,
QDoubleSpinBox::up-arrow:disabled,
QDateEdit::up-arrow:disabled,
QTimeEdit::up-arrow:disabled {
border-bottom-color: #5568a0;
}
QSpinBox::down-arrow:disabled,
QDoubleSpinBox::down-arrow:disabled,
QDateEdit::down-arrow:disabled,
QTimeEdit::down-arrow:disabled {
border-top-color: #5568a0;
}
/* ---- Combo Box ---- */
QComboBox {
background-color: #1c2840;
border-style: solid;
border-color: #253250;
border-width: 1px;
border-radius: 5px;
padding: 4px 10px;
color: #d0daf0;
}
QComboBox:hover {
border-color: #334478;
}
QComboBox:focus {
border-color: #2dd4bf;
}
QComboBox::drop-down {
subcontrol-origin: padding;
subcontrol-position: top right;
width: 24px;
border-left: 1px solid #253250;
border-top-right-radius: 5px;
border-bottom-right-radius: 5px;
}
QComboBox::down-arrow {
image: none;
border-left: 4px solid transparent;
border-right: 4px solid transparent;
border-top: 6px solid #7888b8;
margin-right: 6px;
}
QComboBox QAbstractItemView {
background-color: #162038;
border-style: solid;
border-color: #253250;
border-width: 1px;
border-radius: 4px;
selection-background-color: #2dd4bf;
selection-color: #0f1119;
outline: none;
}
QComboBox:disabled {
background-color: #162038;
color: #5568a0;
border-color: #1c2840;
}
/* ---- Check Box / Radio Button ---- */
QCheckBox,
QRadioButton {
spacing: 5px;
color: #d0daf0;
}
QCheckBox::indicator,
QRadioButton::indicator {
border-style: solid;
border-color: #334478;
border-width: 2px;
background-color: #0a1020;
}
QCheckBox::indicator {
border-radius: 3px;
}
QRadioButton::indicator {
border-radius: 7px;
}
QCheckBox::indicator:hover,
QRadioButton::indicator:hover {
border-color: #2dd4bf;
}
QCheckBox::indicator:checked {
background-color: #2dd4bf;
border-color: #2dd4bf;
}
QRadioButton::indicator:checked {
background-color: #2dd4bf;
border-color: #2dd4bf;
}
QCheckBox::indicator:disabled,
QRadioButton::indicator:disabled {
border-color: #253250;
background-color: #162038;
}
QCheckBox::indicator:checked:hover,
QRadioButton::indicator:checked:hover {
border-color: #a0f0e8;
}
/* Tree / List / Table Widget CheckBox Indicator */
QTreeWidget::indicator,
QListWidget::indicator,
QTableWidget::indicator {
border: 2px solid #5568a0;
border-radius: 3px;
background-color: #162038;
}
QTreeWidget::indicator:hover,
QListWidget::indicator:hover,
QTableWidget::indicator:hover {
border-color: #a0f0e8;
}
QTreeWidget::indicator:checked,
QListWidget::indicator:checked,
QTableWidget::indicator:checked {
background-color: #2dd4bf;
border-color: #2dd4bf;
}
QTreeWidget::indicator:checked:hover,
QListWidget::indicator:checked:hover,
QTableWidget::indicator:checked:hover {
border-color: #a0f0e8;
}
QTreeWidget::indicator:disabled,
QListWidget::indicator:disabled,
QTableWidget::indicator:disabled {
background-color: #1c2840;
border-color: #334478;
}
QTreeWidget::indicator:indeterminate,
QListWidget::indicator:indeterminate,
QTableWidget::indicator:indeterminate {
background-color: #2dd4bf;
border-color: #a0f0e8;
}
/* ---- Group Box ---- */
QGroupBox {
margin-top: 5px;
padding-top: 15px;
color: #b4c2f5;
font-weight: bold;
border-style: solid;
border-color: #253250;
border-width: 1px;
border-radius: 5px;
}
/* ---- Tab ---- */
QTabWidget::pane {
border-style: solid;
border-color: #253250;
border-width: 1px;
border-radius: 5px;
background-color: #0f1a2e;
top: -1px;
}
QTabBar::tab {
background-color: #162038;
border-style: solid;
border-color: #253250;
border-width: 1px;
border-bottom: none;
border-top-left-radius: 5px;
border-top-right-radius: 5px;
padding: 6px 16px;
margin-right: 2px;
color: #7888b8;
}
QTabBar::tab:selected {
background-color: #0f1a2e;
color: #2dd4bf;
border-bottom: 2px solid #2dd4bf;
}
/* ---- List / Tree ---- */
QListWidget,
QTreeWidget,
QTableWidget {
background-color: #0a1020;
border-style: solid;
border-color: #253250;
border-width: 1px;
border-radius: 5px;
outline: none;
color: #d0daf0;
alternate-background-color: #101c30;
}
QListWidget::item,
QTreeWidget::item,
QTableWidget::item {
padding: 5px 5px;
}
QHeaderView::section {
background-color: #0f1628;
border-right: 1px solid #253250;
border-bottom: 1px solid #253250;
padding: 5px 10px;
color: #8b9ad0;
font-weight: bold;
}
/* ---- Scroll Bar ---- */
QScrollBar:vertical {
background-color: #0f1a2e;
width: 10px;
border-radius: 5px;
}
QScrollBar::handle:vertical {
background-color: #334478;
min-height: 30px;
border-radius: 5px;
}
QScrollBar::handle:vertical:hover {
background-color: #5568a0;
}
QScrollBar::add-line:vertical,
QScrollBar::sub-line:vertical {
height: 0;
}
QScrollBar:horizontal {
background-color: #0f1a2e;
height: 10px;
border-radius: 5px;
}
QScrollBar::handle:horizontal {
background-color: #334478;
min-width: 30px;
border-radius: 5px;
}
QScrollBar::handle:horizontal:hover {
background-color: #5568a0;
}
QScrollBar::add-line:horizontal,
QScrollBar::sub-line:horizontal {
width: 0;
}
/* ---- Progress Bar ---- */
QProgressBar {
background-color: #0a1020;
border-style: solid;
border-color: #253250;
border-width: 1px;
border-radius: 5px;
height: 10px;
text-align: center;
color: #d0daf0;
}
QProgressBar::chunk {
background-color: #2dd4bf;
border-radius: 4px;
}
/* ---- Slider ---- */
QSlider::groove:horizontal {
background-color: #1c2840;
height: 6px;
border-radius: 3px;
}
QSlider::handle:horizontal {
background-color: #2dd4bf;
width: 16px;
height: 16px;
margin: -5px 0;
border-radius: 8px;
}
QSlider::sub-page:horizontal {
background-color: #2dd4bf;
border-radius: 3px;
}
QSlider::handle:horizontal:disabled {
background-color: #5568a0;
}
QSlider::sub-page:horizontal:disabled {
background-color: #5568a0;
}
/* ---- Tool Tip ---- */
QToolTip {
background-color: #1c2840;
border-style: solid;
border-color: #2dd4bf;
border-width: 1px;
border-radius: 4px;
padding: 4px 8px;
color: #d0daf0;
}
/* ---- Status Bar ---- */
QStatusBar {
background-color: #0f1628;
border-top: 1px solid #1c2840;
color: #7888b8;
}
/* ---- Splitter ---- */
QSplitter::handle {
background-color: #253250;
margin: 1px;
}
QSplitter::handle:horizontal {
width: 2px;
}
QSplitter::handle:vertical {
height: 2px;
}
/* ---- Dialog ---- */
QDialog {
background-color: #0f1a2e;
}
/* ---- Date / Time Editor Drop-down ---- */
QDateEdit::drop-down,
QTimeEdit::drop-down {
subcontrol-origin: padding;
subcontrol-position: top right;
width: 24px;
border-left: 1px solid #253250;
}
QCalendarWidget {
background-color: #162038;
border-style: solid;
border-color: #253250;
border-width: 1px;
border-radius: 6px;
}
QCalendarWidget QToolButton {
color: #d0daf0;
border-radius: 4px;
padding: 4px 8px;
}
QCalendarWidget QToolButton:hover {
background-color: #1c2840;
}
QCalendarWidget QMenu {
background-color: #162038;
}
/* ---- Frame ---- */
QFrame[frameShape="4"], /* HLine */
QFrame[frameShape="5"] /* VLine */ {
background-color: #253250;
}
+539
View File
@@ -0,0 +1,539 @@
/*
* Copyright (c) 2026 KenanZhu.
* All rights reserved.
*
* This software is provided "as is", without any warranty of any kind.
* You may use, modify, and distribute this file under the terms of the MIT License.
* See the LICENSE file for details.
*
*
* AutoLibrary Official Theme : LightLake
*/
/* ---- Global ---- */
QMainWindow::separator {
background-color: #c0cdda;
width: 1px;
height: 1px;
}
/* ---- Menu Bar ---- */
QMenuBar {
background-color: #dce4ee;
border-bottom: 1px solid #c0cdda;
padding: 2px 5px;
color: #1a2740;
}
QMenuBar::item {
padding: 2px 10px;
border-radius: 4px;
}
QMenuBar::item:selected {
background-color: #d5dde8;
}
QMenu {
background-color: #ffffff;
border-style: solid;
border-color: #d0d8e4;
border-width: 1px;
padding: 4px;
border-radius: 6px;
}
QMenu::item {
padding: 5px 15px 5px 10px;
border-radius: 4px;
}
QMenu::item:selected {
background-color: #0ea58a;
color: #ffffff;
}
QMenu::separator {
height: 1px;
background-color: #d0d8e4;
margin: 4px 8px;
}
/* ---- Button ---- */
QPushButton {
border-style: solid;
border-color: #c0cdda;
border-width: 1px;
border-radius: 5px;
color: #1a2740;
padding: 4px 12px;
background-color: #d5dde8;
}
QPushButton:hover {
background-color: #c8d4e2;
border-color: #90a4c4;
}
QPushButton:pressed {
background-color: #e2e8f0;
border-color: #0ea58a;
}
QPushButton:disabled {
background-color: #e8ecf2;
color: #98a8c0;
border-color: #d5dde8;
}
QPushButton[default="true"] {
background-color: #0ea58a;
color: #ffffff;
border-color: #0ea58a;
}
QPushButton[default="true"]:hover {
background-color: #14c7a4;
}
/* ---- Input ---- */
QLineEdit,
QPlainTextEdit,
QTextEdit,
QSpinBox,
QDoubleSpinBox,
QDateEdit,
QTimeEdit {
background-color: #ffffff;
border-style: solid;
border-color: #c0cdda;
border-width: 1px;
border-radius: 5px;
padding: 4px 8px;
color: #1a2740;
selection-background-color: #0ea58a;
selection-color: #ffffff;
}
QLineEdit:focus,
QPlainTextEdit:focus,
QTextEdit:focus,
QSpinBox:focus,
QDoubleSpinBox:focus,
QDateEdit:focus,
QTimeEdit:focus {
border-color: #0ea58a;
}
QPlainTextEdit,
QTextEdit {
background-color: #ffffff;
}
QLineEdit:disabled,
QPlainTextEdit:disabled,
QTextEdit:disabled,
QSpinBox:disabled,
QDoubleSpinBox:disabled,
QDateEdit:disabled,
QTimeEdit:disabled {
background-color: #e8ecf2;
color: #98a8c0;
border-color: #d5dde8;
}
/* ---- Spin Button Arrows ---- */
QSpinBox::up-button,
QDoubleSpinBox::up-button,
QDateEdit::up-button,
QTimeEdit::up-button {
subcontrol-origin: border;
subcontrol-position: top right;
width: 10px;
border-left: 1px solid #c0cdda;
border-bottom: 1px solid #c0cdda;
border-top-right-radius: 4px;
}
QSpinBox::up-button:hover,
QDoubleSpinBox::up-button:hover,
QDateEdit::up-button:hover,
QTimeEdit::up-button:hover {
background-color: #d5dde8;
}
QSpinBox::up-arrow,
QDoubleSpinBox::up-arrow,
QDateEdit::up-arrow,
QTimeEdit::up-arrow {
border-left: 4px solid transparent;
border-right: 4px solid transparent;
border-bottom: 5px solid #6a7898;
margin-top: 2px;
}
QSpinBox::down-button,
QDoubleSpinBox::down-button,
QDateEdit::down-button,
QTimeEdit::down-button {
width: 10px;
subcontrol-origin: border;
subcontrol-position: bottom right;
border-left: 1px solid #c0cdda;
border-bottom-right-radius: 4px;
}
QSpinBox::down-button:hover,
QDoubleSpinBox::down-button:hover,
QDateEdit::down-button:hover,
QTimeEdit::down-button:hover {
background-color: #d5dde8;
}
QSpinBox::down-arrow,
QDoubleSpinBox::down-arrow,
QDateEdit::down-arrow,
QTimeEdit::down-arrow {
border-left: 4px solid transparent;
border-right: 4px solid transparent;
border-top: 5px solid #6a7898;
margin-bottom: 2px;
}
QSpinBox::up-button:disabled,
QDoubleSpinBox::up-button:disabled,
QDateEdit::up-button:disabled,
QTimeEdit::up-button:disabled,
QSpinBox::down-button:disabled,
QDoubleSpinBox::down-button:disabled,
QDateEdit::down-button:disabled,
QTimeEdit::down-button:disabled {
background-color: #e8ecf2;
}
QSpinBox::up-arrow:disabled,
QDoubleSpinBox::up-arrow:disabled,
QDateEdit::up-arrow:disabled,
QTimeEdit::up-arrow:disabled {
border-bottom-color: #98a8c0;
}
QSpinBox::down-arrow:disabled,
QDoubleSpinBox::down-arrow:disabled,
QDateEdit::down-arrow:disabled,
QTimeEdit::down-arrow:disabled {
border-top-color: #98a8c0;
}
/* ---- Combo Box ---- */
QComboBox {
background-color: #d5dde8;
border-style: solid;
border-color: #c0cdda;
border-width: 1px;
border-radius: 5px;
padding: 4px 10px;
color: #1a2740;
}
QComboBox:hover {
border-color: #90a4c4;
}
QComboBox:focus {
border-color: #0ea58a;
}
QComboBox::drop-down {
subcontrol-origin: padding;
subcontrol-position: top right;
width: 24px;
border-left: 1px solid #c0cdda;
border-top-right-radius: 5px;
border-bottom-right-radius: 5px;
}
QComboBox::down-arrow {
image: none;
border-left: 4px solid transparent;
border-right: 4px solid transparent;
border-top: 6px solid #6a7898;
margin-right: 6px;
}
QComboBox QAbstractItemView {
background-color: #ffffff;
border-style: solid;
border-color: #d0d8e4;
border-width: 1px;
border-radius: 4px;
selection-background-color: #0ea58a;
selection-color: #ffffff;
outline: none;
}
QComboBox:disabled {
background-color: #e8ecf2;
color: #98a8c0;
border-color: #d5dde8;
}
/* ---- Check Box / Radio Button ---- */
QCheckBox,
QRadioButton {
spacing: 5px;
color: #1a2740;
}
QCheckBox::indicator,
QRadioButton::indicator {
border-style: solid;
border-color: #90a4c4;
border-width: 2px;
background-color: #ffffff;
}
QCheckBox::indicator {
border-radius: 3px;
}
QRadioButton::indicator {
border-radius: 7px;
}
QCheckBox::indicator:hover,
QRadioButton::indicator:hover {
border-color: #0ea58a;
}
QCheckBox::indicator:checked {
background-color: #0ea58a;
border-color: #0ea58a;
}
QRadioButton::indicator:checked {
background-color: #0ea58a;
border-color: #0ea58a;
}
QCheckBox::indicator:disabled,
QRadioButton::indicator:disabled {
border-color: #c0cdda;
background-color: #e8ecf2;
}
QCheckBox::indicator:checked:hover,
QRadioButton::indicator:checked:hover {
border-color: #14c7a4;
}
/* Tree / List / Table Widget CheckBox Indicator */
QTreeWidget::indicator,
QListWidget::indicator,
QTableWidget::indicator {
border: 2px solid #a0b4cc;
border-radius: 3px;
background-color: #e8ecf2;
}
QTreeWidget::indicator:hover,
QListWidget::indicator:hover,
QTableWidget::indicator:hover {
border-color: #14c7a4;
}
QTreeWidget::indicator:checked,
QListWidget::indicator:checked,
QTableWidget::indicator:checked {
background-color: #0ea58a;
border-color: #0ea58a;
}
QTreeWidget::indicator:checked:hover,
QListWidget::indicator:checked:hover,
QTableWidget::indicator:checked:hover {
border-color: #14c7a4;
}
QTreeWidget::indicator:disabled,
QListWidget::indicator:disabled,
QTableWidget::indicator:disabled {
background-color: #d5dde8;
border-color: #c0cdda;
}
QTreeWidget::indicator:indeterminate,
QListWidget::indicator:indeterminate,
QTableWidget::indicator:indeterminate {
background-color: #0ea58a;
border-color: #14c7a4;
}
/* ---- Group Box ---- */
QGroupBox {
margin-top: 5px;
padding-top: 15px;
color: #4a6080;
font-weight: bold;
border-style: solid;
border-color: #c0cdda;
border-width: 1px;
border-radius: 5px;
}
/* ---- Tab ---- */
QTabWidget::pane {
border-style: solid;
border-color: #c0cdda;
border-width: 1px;
border-radius: 5px;
background-color: #f0f4f8;
top: -1px;
}
QTabBar::tab {
background-color: #e0e6ee;
border-style: solid;
border-color: #c0cdda;
border-width: 1px;
border-bottom: none;
border-top-left-radius: 5px;
border-top-right-radius: 5px;
padding: 6px 16px;
margin-right: 2px;
color: #6a7898;
}
QTabBar::tab:selected {
background-color: #f0f4f8;
color: #0ea58a;
border-bottom: 2px solid #0ea58a;
}
/* ---- List / Tree ---- */
QListWidget,
QTreeWidget,
QTableWidget {
background-color: #ffffff;
border-style: solid;
border-color: #c0cdda;
border-width: 1px;
border-radius: 5px;
outline: none;
color: #1a2740;
alternate-background-color: #f4f7fa;
}
QListWidget::item,
QTreeWidget::item,
QTableWidget::item {
padding: 5px 5px;
}
QHeaderView::section {
background-color: #dce4ee;
border-right: 1px solid #c0cdda;
border-bottom: 1px solid #c0cdda;
padding: 5px 10px;
color: #4a6080;
font-weight: bold;
}
/* ---- Scroll Bar ---- */
QScrollBar:vertical {
background-color: #eef2f6;
width: 10px;
border-radius: 5px;
}
QScrollBar::handle:vertical {
background-color: #a0b4cc;
min-height: 30px;
border-radius: 5px;
}
QScrollBar::handle:vertical:hover {
background-color: #8098b8;
}
QScrollBar::add-line:vertical,
QScrollBar::sub-line:vertical {
height: 0;
}
QScrollBar:horizontal {
background-color: #eef2f6;
height: 10px;
border-radius: 5px;
}
QScrollBar::handle:horizontal {
background-color: #a0b4cc;
min-width: 30px;
border-radius: 5px;
}
QScrollBar::handle:horizontal:hover {
background-color: #8098b8;
}
QScrollBar::add-line:horizontal,
QScrollBar::sub-line:horizontal {
width: 0;
}
/* ---- Progress Bar ---- */
QProgressBar {
background-color: #ffffff;
border-style: solid;
border-color: #c0cdda;
border-width: 1px;
border-radius: 5px;
height: 10px;
text-align: center;
color: #1a2740;
}
QProgressBar::chunk {
background-color: #0ea58a;
border-radius: 4px;
}
/* ---- Slider ---- */
QSlider::groove:horizontal {
background-color: #d5dde8;
height: 6px;
border-radius: 3px;
}
QSlider::handle:horizontal {
background-color: #0ea58a;
width: 16px;
height: 16px;
margin: -5px 0;
border-radius: 8px;
}
QSlider::sub-page:horizontal {
background-color: #0ea58a;
border-radius: 3px;
}
QSlider::handle:horizontal:disabled {
background-color: #98a8c0;
}
QSlider::sub-page:horizontal:disabled {
background-color: #98a8c0;
}
/* ---- Tool Tip ---- */
QToolTip {
background-color: #d5dde8;
border-style: solid;
border-color: #0ea58a;
border-width: 1px;
border-radius: 4px;
padding: 4px 8px;
color: #1a2740;
}
/* ---- Status Bar ---- */
QStatusBar {
background-color: #e8ecf2;
border-top: 1px solid #c0cdda;
color: #6a7898;
}
/* ---- Splitter ---- */
QSplitter::handle {
background-color: #c0cdda;
margin: 1px;
}
QSplitter::handle:horizontal {
width: 2px;
}
QSplitter::handle:vertical {
height: 2px;
}
/* ---- Dialog ---- */
QDialog {
background-color: #f0f4f8;
}
/* ---- Date / Time Editor Drop-down ---- */
QDateEdit::drop-down,
QTimeEdit::drop-down {
subcontrol-origin: padding;
subcontrol-position: top right;
width: 24px;
border-left: 1px solid #c0cdda;
}
QCalendarWidget {
background-color: #ffffff;
border-style: solid;
border-color: #c0cdda;
border-width: 1px;
border-radius: 6px;
}
QCalendarWidget QToolButton {
color: #1a2740;
border-radius: 4px;
padding: 4px 8px;
}
QCalendarWidget QToolButton:hover {
background-color: #d5dde8;
}
QCalendarWidget QMenu {
background-color: #ffffff;
}
/* ---- Frame ---- */
QFrame[frameShape="4"], /* HLine */
QFrame[frameShape="5"] /* VLine */ {
background-color: #c0cdda;
}
+385
View File
@@ -0,0 +1,385 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>ALBulletinDialog</class>
<widget class="QDialog" name="ALBulletinDialog">
<property name="windowModality">
<enum>Qt::WindowModality::NonModal</enum>
</property>
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>640</width>
<height>450</height>
</rect>
</property>
<property name="minimumSize">
<size>
<width>400</width>
<height>450</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>800</width>
<height>450</height>
</size>
</property>
<property name="windowTitle">
<string>公告栏 - AutoLibrary</string>
</property>
<layout class="QVBoxLayout" name="ALBulletinDialogLayout">
<property name="spacing">
<number>5</number>
</property>
<property name="leftMargin">
<number>5</number>
</property>
<property name="topMargin">
<number>5</number>
</property>
<property name="rightMargin">
<number>5</number>
</property>
<property name="bottomMargin">
<number>5</number>
</property>
<item>
<layout class="QHBoxLayout" name="SyncLayout">
<property name="spacing">
<number>10</number>
</property>
<item>
<widget class="QPushButton" name="SyncButton">
<property name="enabled">
<bool>true</bool>
</property>
<property name="minimumSize">
<size>
<width>80</width>
<height>25</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>80</width>
<height>25</height>
</size>
</property>
<property name="focusPolicy">
<enum>Qt::FocusPolicy::NoFocus</enum>
</property>
<property name="text">
<string>同步</string>
</property>
<property name="autoDefault">
<bool>false</bool>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="SyncStatusPlaceholder">
<property name="minimumSize">
<size>
<width>30</width>
<height>30</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>30</width>
<height>30</height>
</size>
</property>
<property name="text">
<string/>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="SyncStatusDetailLabel">
<property name="minimumSize">
<size>
<width>80</width>
<height>25</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>200</width>
<height>25</height>
</size>
</property>
<property name="text">
<string/>
</property>
</widget>
</item>
<item>
<widget class="QFrame" name="SyncSpaceFrame">
<property name="minimumSize">
<size>
<width>0</width>
<height>25</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>800</width>
<height>25</height>
</size>
</property>
<property name="frameShape">
<enum>QFrame::Shape::NoFrame</enum>
</property>
<property name="frameShadow">
<enum>QFrame::Shadow::Plain</enum>
</property>
<property name="lineWidth">
<number>0</number>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="LastSyncTimeLabel">
<property name="minimumSize">
<size>
<width>50</width>
<height>25</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>50</width>
<height>25</height>
</size>
</property>
<property name="text">
<string>上次同步:</string>
</property>
</widget>
</item>
<item>
<widget class="QDateTimeEdit" name="LastSyncDateTimeEdit">
<property name="minimumSize">
<size>
<width>130</width>
<height>25</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>130</width>
<height>25</height>
</size>
</property>
<property name="frame">
<bool>false</bool>
</property>
<property name="readOnly">
<bool>true</bool>
</property>
<property name="buttonSymbols">
<enum>QAbstractSpinBox::ButtonSymbols::NoButtons</enum>
</property>
<property name="keyboardTracking">
<bool>false</bool>
</property>
<property name="displayFormat">
<string>yyyy/MM/dd HH:mm:ss</string>
</property>
<property name="timeSpec">
<enum>Qt::TimeSpec::LocalTime</enum>
</property>
</widget>
</item>
</layout>
</item>
<item>
<widget class="QSplitter" name="BulletinSplitter">
<property name="orientation">
<enum>Qt::Orientation::Horizontal</enum>
</property>
<widget class="QFrame" name="LeftFrame">
<property name="frameShape">
<enum>QFrame::Shape::NoFrame</enum>
</property>
<property name="frameShadow">
<enum>QFrame::Shadow::Plain</enum>
</property>
<layout class="QVBoxLayout" name="LeftFrameLayout">
<property name="spacing">
<number>5</number>
</property>
<property name="leftMargin">
<number>0</number>
</property>
<property name="topMargin">
<number>0</number>
</property>
<property name="rightMargin">
<number>0</number>
</property>
<property name="bottomMargin">
<number>0</number>
</property>
<item>
<widget class="QListWidget" name="BulletinListWidget">
<property name="frameShape">
<enum>QFrame::Shape::NoFrame</enum>
</property>
<property name="editTriggers">
<set>QAbstractItemView::EditTrigger::NoEditTriggers</set>
</property>
<property name="selectionMode">
<enum>QAbstractItemView::SelectionMode::SingleSelection</enum>
</property>
</widget>
</item>
</layout>
</widget>
<widget class="QFrame" name="RightFrame">
<property name="frameShape">
<enum>QFrame::Shape::NoFrame</enum>
</property>
<property name="frameShadow">
<enum>QFrame::Shadow::Plain</enum>
</property>
<layout class="QVBoxLayout" name="RightFrameLayout">
<property name="spacing">
<number>5</number>
</property>
<property name="leftMargin">
<number>0</number>
</property>
<property name="topMargin">
<number>0</number>
</property>
<property name="rightMargin">
<number>0</number>
</property>
<property name="bottomMargin">
<number>0</number>
</property>
<item>
<widget class="QLabel" name="BulletinTitleLabel">
<property name="text">
<string/>
</property>
</widget>
</item>
<item>
<layout class="QHBoxLayout" name="BulletinInfoLayout">
<property name="spacing">
<number>5</number>
</property>
<item>
<widget class="QLabel" name="BulletinDateLabel">
<property name="text">
<string/>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="BulletinAuthorLabel">
<property name="text">
<string/>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="BulletinIsEditedLabel">
<property name="text">
<string>(已编辑)</string>
</property>
</widget>
</item>
<item>
<spacer name="BulletinInfoSpacer">
<property name="orientation">
<enum>Qt::Orientation::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
</layout>
</item>
<item>
<widget class="QTextBrowser" name="BulletinContentTextBrowser">
<property name="readOnly">
<bool>true</bool>
</property>
<property name="openExternalLinks">
<bool>true</bool>
</property>
</widget>
</item>
</layout>
</widget>
</widget>
</item>
<item>
<widget class="QDialogButtonBox" name="BulletinDialogButtonBox">
<property name="minimumSize">
<size>
<width>0</width>
<height>25</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>16777215</width>
<height>25</height>
</size>
</property>
<property name="orientation">
<enum>Qt::Orientation::Horizontal</enum>
</property>
<property name="standardButtons">
<set>QDialogButtonBox::StandardButton::Cancel|QDialogButtonBox::StandardButton::Ok</set>
</property>
</widget>
</item>
</layout>
</widget>
<resources/>
<connections>
<connection>
<sender>BulletinDialogButtonBox</sender>
<signal>accepted()</signal>
<receiver>ALBulletinDialog</receiver>
<slot>accept()</slot>
<hints>
<hint type="sourcelabel">
<x>248</x>
<y>254</y>
</hint>
<hint type="destinationlabel">
<x>157</x>
<y>274</y>
</hint>
</hints>
</connection>
<connection>
<sender>BulletinDialogButtonBox</sender>
<signal>rejected()</signal>
<receiver>ALBulletinDialog</receiver>
<slot>reject()</slot>
<hints>
<hint type="sourcelabel">
<x>316</x>
<y>260</y>
</hint>
<hint type="destinationlabel">
<x>286</x>
<y>274</y>
</hint>
</hints>
</connection>
</connections>
</ui>
+2 -2
View File
@@ -1956,13 +1956,13 @@
<widget class="QPushButton" name="ExportConfigButton"> <widget class="QPushButton" name="ExportConfigButton">
<property name="minimumSize"> <property name="minimumSize">
<size> <size>
<width>100</width> <width>120</width>
<height>25</height> <height>25</height>
</size> </size>
</property> </property>
<property name="maximumSize"> <property name="maximumSize">
<size> <size>
<width>100</width> <width>120</width>
<height>25</height> <height>25</height>
</size> </size>
</property> </property>
+24
View File
@@ -281,6 +281,13 @@ font: 700 9pt;</string>
<property name="nativeMenuBar"> <property name="nativeMenuBar">
<bool>true</bool> <bool>true</bool>
</property> </property>
<widget class="QMenu" name="ToolsMenu">
<property name="title">
<string>工具</string>
</property>
<addaction name="SettingsAction"/>
<addaction name="BulletinAction"/>
</widget>
<widget class="QMenu" name="HelpMenu"> <widget class="QMenu" name="HelpMenu">
<property name="mouseTracking"> <property name="mouseTracking">
<bool>true</bool> <bool>true</bool>
@@ -289,8 +296,10 @@ font: 700 9pt;</string>
<string>帮助</string> <string>帮助</string>
</property> </property>
<addaction name="ManualAction"/> <addaction name="ManualAction"/>
<addaction name="CheckUpdateAction"/>
<addaction name="AboutAction"/> <addaction name="AboutAction"/>
</widget> </widget>
<addaction name="ToolsMenu"/>
<addaction name="HelpMenu"/> <addaction name="HelpMenu"/>
</widget> </widget>
<widget class="QStatusBar" name="StatusBar"> <widget class="QStatusBar" name="StatusBar">
@@ -303,11 +312,26 @@ font: 700 9pt;</string>
<string>在线手册</string> <string>在线手册</string>
</property> </property>
</action> </action>
<action name="CheckUpdateAction">
<property name="text">
<string>检查更新</string>
</property>
</action>
<action name="AboutAction"> <action name="AboutAction">
<property name="text"> <property name="text">
<string>关于</string> <string>关于</string>
</property> </property>
</action> </action>
<action name="SettingsAction">
<property name="text">
<string>全局设置</string>
</property>
</action>
<action name="BulletinAction">
<property name="text">
<string>公告栏</string>
</property>
</action>
</widget> </widget>
<resources/> <resources/>
<connections/> <connections/>
+788
View File
@@ -0,0 +1,788 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>ALSettingsWidget</class>
<widget class="QWidget" name="ALSettingsWidget">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>520</width>
<height>420</height>
</rect>
</property>
<property name="minimumSize">
<size>
<width>480</width>
<height>420</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>580</width>
<height>420</height>
</size>
</property>
<property name="windowTitle">
<string>全局设置 - AutoLibrary</string>
</property>
<layout class="QVBoxLayout" name="ALSettingsWidgetLayout">
<property name="spacing">
<number>0</number>
</property>
<property name="leftMargin">
<number>0</number>
</property>
<property name="topMargin">
<number>0</number>
</property>
<property name="rightMargin">
<number>0</number>
</property>
<property name="bottomMargin">
<number>0</number>
</property>
<item>
<layout class="QHBoxLayout" name="ContentLayout">
<property name="spacing">
<number>5</number>
</property>
<property name="leftMargin">
<number>3</number>
</property>
<property name="topMargin">
<number>3</number>
</property>
<property name="rightMargin">
<number>3</number>
</property>
<property name="bottomMargin">
<number>3</number>
</property>
<item>
<widget class="QListWidget" name="NavigationList">
<property name="minimumSize">
<size>
<width>100</width>
<height>0</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>100</width>
<height>16777215</height>
</size>
</property>
<property name="focusPolicy">
<enum>Qt::FocusPolicy::StrongFocus</enum>
</property>
<property name="frameShape">
<enum>QFrame::Shape::NoFrame</enum>
</property>
<property name="editTriggers">
<set>QAbstractItemView::EditTrigger::NoEditTriggers</set>
</property>
<property name="selectionMode">
<enum>QAbstractItemView::SelectionMode::SingleSelection</enum>
</property>
<property name="iconSize">
<size>
<width>20</width>
<height>20</height>
</size>
</property>
<property name="currentRow">
<number>0</number>
</property>
<item>
<property name="text">
<string>外观</string>
</property>
<property name="icon">
<iconset theme="preferences-desktop-color"/>
</property>
</item>
<item>
<property name="text">
<string>公告</string>
</property>
<property name="icon">
<iconset theme="preferences-desktop-color"/>
</property>
</item>
</widget>
</item>
<item>
<widget class="QStackedWidget" name="PageStack">
<property name="currentIndex">
<number>0</number>
</property>
<widget class="QScrollArea" name="AppearanceScrollArea">
<property name="frameShape">
<enum>QFrame::Shape::NoFrame</enum>
</property>
<property name="widgetResizable">
<bool>true</bool>
</property>
<widget class="QWidget" name="AppearancePageContent">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>397</width>
<height>434</height>
</rect>
</property>
<layout class="QVBoxLayout" name="AppearancePageLayout">
<property name="spacing">
<number>5</number>
</property>
<property name="leftMargin">
<number>3</number>
</property>
<property name="topMargin">
<number>3</number>
</property>
<property name="rightMargin">
<number>3</number>
</property>
<property name="bottomMargin">
<number>3</number>
</property>
<item>
<widget class="QGroupBox" name="AppearanceGroupBox">
<property name="title">
<string>主题模式</string>
</property>
<layout class="QVBoxLayout" name="AppearanceGroupBoxLayout">
<property name="spacing">
<number>5</number>
</property>
<property name="leftMargin">
<number>3</number>
</property>
<property name="topMargin">
<number>3</number>
</property>
<property name="rightMargin">
<number>3</number>
</property>
<property name="bottomMargin">
<number>3</number>
</property>
<item>
<widget class="QRadioButton" name="LightThemeRadio">
<property name="text">
<string>浅色</string>
</property>
</widget>
</item>
<item>
<widget class="QRadioButton" name="DarkThemeRadio">
<property name="text">
<string>深色</string>
</property>
</widget>
</item>
<item>
<widget class="QRadioButton" name="SystemThemeRadio">
<property name="text">
<string>跟随系统</string>
</property>
<property name="checked">
<bool>true</bool>
</property>
</widget>
</item>
</layout>
</widget>
</item>
<item>
<widget class="QGroupBox" name="InterfaceGroupBox">
<property name="title">
<string>界面风格</string>
</property>
<layout class="QVBoxLayout" name="InterfaceGroupBoxLayout">
<property name="spacing">
<number>5</number>
</property>
<property name="leftMargin">
<number>3</number>
</property>
<property name="topMargin">
<number>3</number>
</property>
<property name="rightMargin">
<number>3</number>
</property>
<property name="bottomMargin">
<number>3</number>
</property>
<item>
<layout class="QHBoxLayout" name="StyleSelectLayout">
<property name="spacing">
<number>5</number>
</property>
<item>
<widget class="QLabel" name="StyleSelectLabel">
<property name="minimumSize">
<size>
<width>100</width>
<height>25</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>100</width>
<height>25</height>
</size>
</property>
<property name="text">
<string>应用程序样式:</string>
</property>
</widget>
</item>
<item>
<widget class="QComboBox" name="StyleComboBox">
<property name="minimumSize">
<size>
<width>160</width>
<height>25</height>
</size>
</property>
</widget>
</item>
</layout>
</item>
<item>
<widget class="QLabel" name="StyleHintLabel">
<property name="text">
<string>更改样式将在下次启动应用程序时生效。</string>
</property>
</widget>
</item>
</layout>
</widget>
</item>
<item>
<widget class="QGroupBox" name="CustomThemeGroupBox">
<property name="title">
<string>自定义外观</string>
</property>
<layout class="QVBoxLayout" name="CustomQssGroupBoxLayout">
<property name="spacing">
<number>5</number>
</property>
<property name="leftMargin">
<number>3</number>
</property>
<property name="topMargin">
<number>3</number>
</property>
<property name="rightMargin">
<number>3</number>
</property>
<property name="bottomMargin">
<number>3</number>
</property>
<item>
<widget class="QLabel" name="CustomThemeHintLabel">
<property name="text">
<string>选择一个主题,或导入新的主题文件:</string>
</property>
<property name="wordWrap">
<bool>true</bool>
</property>
</widget>
</item>
<item>
<layout class="QHBoxLayout" name="CustomThemePathLayout">
<property name="spacing">
<number>5</number>
</property>
<item>
<widget class="QComboBox" name="CustomThemeComboBox">
<property name="minimumSize">
<size>
<width>160</width>
<height>25</height>
</size>
</property>
</widget>
</item>
<item>
<widget class="QLineEdit" name="CustomThemePathEdit">
<property name="minimumSize">
<size>
<width>0</width>
<height>25</height>
</size>
</property>
<property name="visible">
<bool>false</bool>
</property>
<property name="placeholderText">
<string>选择或输入 QSS 样式表文件路径...</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="ImportCustomThemeButton">
<property name="minimumSize">
<size>
<width>25</width>
<height>25</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>25</width>
<height>25</height>
</size>
</property>
<property name="text">
<string>+</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="RemoveCustomThemeButton">
<property name="minimumSize">
<size>
<width>25</width>
<height>25</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>25</width>
<height>25</height>
</size>
</property>
<property name="text">
<string>-</string>
</property>
</widget>
</item>
</layout>
</item>
<item>
<widget class="QLabel" name="CustomThemeInfoLabel">
<property name="minimumSize">
<size>
<width>0</width>
<height>60</height>
</size>
</property>
<property name="text">
<string/>
</property>
<property name="textFormat">
<enum>Qt::TextFormat::RichText</enum>
</property>
<property name="alignment">
<set>Qt::AlignmentFlag::AlignLeading|Qt::AlignmentFlag::AlignLeft|Qt::AlignmentFlag::AlignTop</set>
</property>
<property name="wordWrap">
<bool>true</bool>
</property>
</widget>
</item>
<item>
<layout class="QHBoxLayout" name="CustomThemeActionLayout">
<property name="spacing">
<number>5</number>
</property>
<item>
<widget class="QPushButton" name="ResetCustomThemeButton">
<property name="minimumSize">
<size>
<width>80</width>
<height>25</height>
</size>
</property>
<property name="text">
<string>重置主题</string>
</property>
</widget>
</item>
<item>
<spacer name="CustomThemeActionSpacer">
<property name="orientation">
<enum>Qt::Orientation::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
</layout>
</item>
<item>
<widget class="QLabel" name="CustomThemeStatusLabel">
<property name="text">
<string>当前使用程序 默认 外观。</string>
</property>
<property name="wordWrap">
<bool>true</bool>
</property>
</widget>
</item>
</layout>
</widget>
</item>
<item>
<spacer name="AppearancePageSpacer">
<property name="orientation">
<enum>Qt::Orientation::Vertical</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>20</width>
<height>40</height>
</size>
</property>
</spacer>
</item>
</layout>
</widget>
</widget>
<widget class="QScrollArea" name="BulletinScrollArea">
<property name="frameShape">
<enum>QFrame::Shape::NoFrame</enum>
</property>
<property name="widgetResizable">
<bool>true</bool>
</property>
<widget class="QWidget" name="BulletinPageContent">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>409</width>
<height>383</height>
</rect>
</property>
<layout class="QVBoxLayout" name="BulletinPageLayout">
<property name="spacing">
<number>5</number>
</property>
<property name="leftMargin">
<number>3</number>
</property>
<property name="topMargin">
<number>3</number>
</property>
<property name="rightMargin">
<number>3</number>
</property>
<property name="bottomMargin">
<number>3</number>
</property>
<item>
<widget class="QGroupBox" name="BulletinGroupBox">
<property name="title">
<string>公告设置</string>
</property>
<layout class="QVBoxLayout" name="BulletinGroupBoxLayout">
<property name="spacing">
<number>5</number>
</property>
<property name="leftMargin">
<number>3</number>
</property>
<property name="topMargin">
<number>3</number>
</property>
<property name="rightMargin">
<number>3</number>
</property>
<property name="bottomMargin">
<number>3</number>
</property>
<item>
<layout class="QHBoxLayout" name="BulletinServerUrlLayout">
<property name="spacing">
<number>5</number>
</property>
<item>
<widget class="QLabel" name="BulletinServerUrlLabel">
<property name="minimumSize">
<size>
<width>100</width>
<height>25</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>100</width>
<height>25</height>
</size>
</property>
<property name="text">
<string>服务器地址:</string>
</property>
</widget>
</item>
<item>
<widget class="QLineEdit" name="BulletinServerUrlEdit">
<property name="minimumSize">
<size>
<width>200</width>
<height>25</height>
</size>
</property>
<property name="placeholderText">
<string>https://api.autolibrary.kenanzhu.com</string>
</property>
</widget>
</item>
</layout>
</item>
<item>
<widget class="QCheckBox" name="BulletinAutoFetchCheck">
<property name="text">
<string>软件启动时自动获取公告</string>
</property>
</widget>
</item>
<item>
<layout class="QHBoxLayout" name="BulletinSyncIntervalLayout">
<property name="spacing">
<number>5</number>
</property>
<item>
<widget class="QLabel" name="BulletinSyncIntervalLabel">
<property name="minimumSize">
<size>
<width>100</width>
<height>25</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>100</width>
<height>25</height>
</size>
</property>
<property name="text">
<string>自动同步间隔:</string>
</property>
</widget>
</item>
<item>
<widget class="QSpinBox" name="BulletinSyncIntervalSpin">
<property name="minimumSize">
<size>
<width>80</width>
<height>25</height>
</size>
</property>
<property name="suffix">
<string> 分钟</string>
</property>
<property name="minimum">
<number>1</number>
</property>
<property name="maximum">
<number>1440</number>
</property>
</widget>
</item>
<item>
<spacer name="BulletinSyncIntervalSpacer">
<property name="orientation">
<enum>Qt::Orientation::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
</layout>
</item>
<item>
<widget class="QPushButton" name="BulletinTestButton">
<property name="minimumSize">
<size>
<width>100</width>
<height>25</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>100</width>
<height>25</height>
</size>
</property>
<property name="text">
<string>测试连接</string>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="BulletinTestStatusLabel">
<property name="minimumSize">
<size>
<width>0</width>
<height>25</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>16777215</width>
<height>25</height>
</size>
</property>
<property name="text">
<string/>
</property>
<property name="wordWrap">
<bool>true</bool>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="BulletinOpenButton">
<property name="minimumSize">
<size>
<width>100</width>
<height>25</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>100</width>
<height>25</height>
</size>
</property>
<property name="text">
<string>查看公告栏</string>
</property>
</widget>
</item>
</layout>
</widget>
</item>
<item>
<spacer name="BulletinPageSpacer">
<property name="orientation">
<enum>Qt::Orientation::Vertical</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>20</width>
<height>40</height>
</size>
</property>
</spacer>
</item>
</layout>
</widget>
</widget>
</widget>
</item>
</layout>
</item>
<item>
<layout class="QHBoxLayout" name="ButtonLayout">
<property name="spacing">
<number>5</number>
</property>
<property name="leftMargin">
<number>3</number>
</property>
<property name="topMargin">
<number>3</number>
</property>
<property name="rightMargin">
<number>3</number>
</property>
<property name="bottomMargin">
<number>3</number>
</property>
<item>
<spacer name="ButtonLeftSpacer">
<property name="orientation">
<enum>Qt::Orientation::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item>
<widget class="QPushButton" name="CancelButton">
<property name="minimumSize">
<size>
<width>80</width>
<height>25</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>80</width>
<height>25</height>
</size>
</property>
<property name="text">
<string>取消</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="ApplyButton">
<property name="minimumSize">
<size>
<width>80</width>
<height>25</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>80</width>
<height>25</height>
</size>
</property>
<property name="text">
<string>应用</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="ConfirmButton">
<property name="minimumSize">
<size>
<width>80</width>
<height>25</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>80</width>
<height>25</height>
</size>
</property>
<property name="text">
<string>确认</string>
</property>
<property name="default">
<bool>true</bool>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</widget>
<resources/>
<connections/>
</ui>
+2 -2
View File
@@ -7,13 +7,13 @@
<x>0</x> <x>0</x>
<y>0</y> <y>0</y>
<width>350</width> <width>350</width>
<height>400</height> <height>500</height>
</rect> </rect>
</property> </property>
<property name="minimumSize"> <property name="minimumSize">
<size> <size>
<width>350</width> <width>350</width>
<height>460</height> <height>500</height>
</size> </size>
</property> </property>
<property name="maximumSize"> <property name="maximumSize">
+12
View File
@@ -66,6 +66,18 @@ class CfgKey:
CURRENT = ConfigPath(ConfigType.GLOBAL, "automation.user_path.current") CURRENT = ConfigPath(ConfigType.GLOBAL, "automation.user_path.current")
PATHS = ConfigPath(ConfigType.GLOBAL, "automation.user_path.paths") PATHS = ConfigPath(ConfigType.GLOBAL, "automation.user_path.paths")
class APPEARANCE:
ROOT = ConfigPath(ConfigType.GLOBAL, "appearance")
THEME = ConfigPath(ConfigType.GLOBAL, "appearance.theme")
STYLE = ConfigPath(ConfigType.GLOBAL, "appearance.style")
CUSTOM_THEME = ConfigPath(ConfigType.GLOBAL, "appearance.custom_theme")
class BULLETIN:
ROOT = ConfigPath(ConfigType.GLOBAL, "bulletin")
AUTO_FETCH = ConfigPath(ConfigType.GLOBAL, "bulletin.auto_fetch")
SERVER_URL = ConfigPath(ConfigType.GLOBAL, "bulletin.server_url")
SYNC_INTERVAL = ConfigPath(ConfigType.GLOBAL, "bulletin.sync_interval")
class TIMERTASK: class TIMERTASK:
ROOT = ConfigPath(ConfigType.TIMERTASK, "") ROOT = ConfigPath(ConfigType.TIMERTASK, "")
TIMER_TASKS = ConfigPath(ConfigType.TIMERTASK, "timer_tasks") TIMER_TASKS = ConfigPath(ConfigType.TIMERTASK, "timer_tasks")
+340
View File
@@ -0,0 +1,340 @@
# -*- coding: utf-8 -*-
"""
Copyright (c) 2026 KenanZhu.
All rights reserved.
This software is provided "as is", without any warranty of any kind.
You may use, modify, and distribute this file under the terms of the MIT License.
See the LICENSE file for details.
"""
import threading
from datetime import datetime, timedelta, timezone
from typing import Optional
from interfaces.ConfigProvider import (
CfgKey,
ConfigProvider
)
from managers.config.ConfigManager import instance as configInstance
class BulletinManager:
"""
Bulletin Manager Singleton Class
Manages bulletin data persistence via ConfigManager and provides
merge, read-status and sync-parameter logic. The HTTP fetch and
UI concerns are handled by the GUI layer.
"""
def __init__(
self
):
self.__lock = threading.Lock()
self.__cfg: ConfigProvider = configInstance()
def bulletins(
self
) -> list[dict]:
"""
Get cached bulletins.
Returns:
list[dict]: Cached bulletin list.
"""
return self.__cfg.get(CfgKey.BULLETIN.BULLETIN, [])
def setBulletins(
self,
bulletins: list[dict]
):
"""
Replace the bulletin cache.
Args:
bulletins (list[dict]): Bulletin list to store.
"""
self.__cfg.set(CfgKey.BULLETIN.BULLETIN, bulletins)
def lastSyncTime(
self
) -> Optional[str]:
"""
Get last sync time string.
Returns:
Optional[str]: ISO-format last sync time, or None.
"""
return self.__cfg.get(CfgKey.BULLETIN.LAST_SYNC_TIME, None)
def setLastSyncTime(
self,
value: Optional[str]
):
"""
Set last sync time.
Args:
value (Optional[str]): ISO-format datetime string.
"""
self.__cfg.set(CfgKey.BULLETIN.LAST_SYNC_TIME, value)
def autoFetch(
self
) -> bool:
"""
Get auto-fetch-on-startup setting.
Returns:
bool: Whether to fetch bulletins on application startup.
"""
return self.__cfg.get(CfgKey.GLOBAL.BULLETIN.AUTO_FETCH, False)
def serverUrl(
self
) -> str:
"""
Get bulletin server URL.
Returns:
str: Server base URL.
"""
return self.__cfg.get(
CfgKey.GLOBAL.BULLETIN.SERVER_URL,
"https://api.autolibrary.kenanzhu.com"
)
def syncInterval(
self
) -> int:
"""
Get auto-sync interval in minutes.
Values below 1 are clamped upward. Non-integer or missing
values fall back to the default of 10 minutes.
Returns:
int: Sync interval (minutes), minimum 1.
"""
interval = self.__cfg.get(CfgKey.GLOBAL.BULLETIN.SYNC_INTERVAL, 10)
try:
interval = int(interval)
except (ValueError, TypeError):
return 10
if interval < 1:
return 5
return interval
def apiUrl(
self
) -> str:
"""
Build the full bulletin API endpoint URL.
Returns:
str: Full API URL (base + /bulletins).
"""
base = self.serverUrl().rstrip("/")
return f"{base}/bulletins"
@staticmethod
def _ensureAware(
dt_value: datetime
) -> datetime:
if dt_value.tzinfo is None:
return dt_value.replace(tzinfo=timezone.utc)
return dt_value
@staticmethod
def _parseBulletinDateTime(
b: dict
) -> datetime:
raw = b.get("dateTime", "")
if not raw:
raise ValueError("empty dateTime")
dt_value = datetime.fromisoformat(raw)
return BulletinManager._ensureAware(dt_value)
def isFirstSync(
self
) -> bool:
"""
Check whether this is the first sync.
Returns:
bool: True if no cached bulletins or no last sync time.
"""
with self.__lock:
last = self.lastSyncTime()
bulletins = self.bulletins()
return last is None or not bulletins
def shouldFullSync(
self
) -> bool:
"""
Check whether a full sync is needed.
A full sync is triggered when the last sync time is more than
one hour ago.
Returns:
bool: True if a full sync should be performed.
"""
with self.__lock:
last = self.lastSyncTime()
if last is None:
return True
try:
last_dt = datetime.fromisoformat(last)
last_dt = self._ensureAware(last_dt)
now = datetime.now(timezone.utc).astimezone()
return (now - last_dt) > timedelta(hours=1)
except (ValueError, TypeError):
return True
def getSyncDateTimeAndRange(
self
) -> dict:
"""
Calculate the date / time / range_hour query parameters.
Falls back to a first-sync (7-day window) on any parsing error.
Returns:
dict: Keys "date", "time", "range_hour" for the API request.
"""
now = datetime.now(timezone.utc).astimezone()
try:
if self.isFirstSync():
start_date = now - timedelta(days=7)
range_hour = str(24*8)
elif self.shouldFullSync():
with self.__lock:
bulletins = self.bulletins()
earliest = min(bulletins, key=self._parseBulletinDateTime)
start_date = self._parseBulletinDateTime(earliest)
diff = now - start_date
range_hour = str(int(diff.total_seconds()/3600) + 1)
else:
with self.__lock:
bulletins = self.bulletins()
latest = max(bulletins, key=self._parseBulletinDateTime)
start_date = self._parseBulletinDateTime(latest)
diff = now - start_date
range_hour = str(int(diff.total_seconds()/3600) + 1)
except (ValueError, TypeError, KeyError):
start_date = now - timedelta(days=7)
range_hour = str(24*8)
return {
"date": start_date.strftime("%Y-%m-%d"),
"time": start_date.strftime("%H:%M:%S"),
"range_hour": range_hour,
}
def updateAndMergeBulletins(
self,
new_bulletins: list[dict],
delete_ids: list[str]
) -> list[dict]:
"""
Merge incoming bulletins into the cache.
- New bulletins are added with isNew=True.
- Existing bulletins keep their current isNew state.
- Entries listed in delete_ids are removed.
- Bulletins missing an "id" field are skipped.
Args:
new_bulletins (list[dict]): Incoming bulletin list.
delete_ids (list[str]): IDs to remove.
Returns:
list[dict]: Merged bulletin list sorted by id.
"""
with self.__lock:
delete_set = set(str(d) for d in delete_ids)
bulletins_dict = {}
for b in self.bulletins():
bid = b.get("id")
if bid is not None:
bulletins_dict[str(bid)] = b
for bulletin in new_bulletins:
bid = bulletin.get("id")
if bid is None:
continue
bid = str(bid)
if bid in delete_set:
bulletins_dict.pop(bid, None)
continue
bulletin["isNew"] = (
True
if bid not in bulletins_dict
else bulletins_dict[bid].get("isNew", True)
)
bulletins_dict[bid] = bulletin
for bid in delete_set:
bulletins_dict.pop(bid, None)
result = list(bulletins_dict.values())
result.sort(key=lambda x: str(x.get("id", "")))
self.setBulletins(result)
return result
def markBulletinAsRead(
self,
bulletin_id: str
):
"""
Mark a bulletin as read by its id.
Args:
bulletin_id (str): The bulletin id.
"""
with self.__lock:
bulletins = self.bulletins()
for b in bulletins:
if str(b.get("id", "")) == str(bulletin_id):
b["isNew"] = False
self.setBulletins(bulletins)
break
# BulletinManager singleton instance.
_bulletin_manager_instance = None
# Singleton instance lock.
_instance_lock = threading.Lock()
def instance(
) -> BulletinManager:
"""
Get the BulletinManager singleton instance.
Returns:
BulletinManager: The singleton BulletinManager instance.
"""
global _bulletin_manager_instance
with _instance_lock:
if _bulletin_manager_instance is None:
_bulletin_manager_instance = BulletinManager()
return _bulletin_manager_instance
+9
View File
@@ -0,0 +1,9 @@
# -*- coding: utf-8 -*-
"""
Copyright (c) 2026 KenanZhu.
All rights reserved.
This software is provided "as is", without any warranty of any kind.
You may use, modify, and distribute this file under the terms of the MIT License.
See the LICENSE file for details.
"""
+18 -7
View File
@@ -7,6 +7,7 @@ This software is provided "as is", without any warranty of any kind.
You may use, modify, and distribute this file under the terms of the MIT License. You may use, modify, and distribute this file under the terms of the MIT License.
See the LICENSE file for details. See the LICENSE file for details.
""" """
import copy
import os import os
import threading import threading
@@ -18,7 +19,7 @@ from interfaces.ConfigProvider import ConfigType, ConfigPath
# This config manager class only responsible for global and other # This config manager class only responsible for global and other
# unconfigurable config files. # config files. NOT include run and user config files.
class ConfigTemplate: class ConfigTemplate:
@@ -54,6 +55,16 @@ class ConfigTemplate:
"current": 0, "current": 0,
"paths": [] "paths": []
} }
},
"appearance": {
"theme": "system",
"style": "Fusion",
"custom_theme": ""
},
"bulletin": {
"auto_fetch": False,
"server_url": "https://api.autolibrary.kenanzhu.com",
"sync_interval": 10
} }
} }
case ConfigType.BULLETIN: case ConfigType.BULLETIN:
@@ -77,8 +88,8 @@ class ConfigManager:
): ):
self.__config_dir = os.path.abspath(config_dir) self.__config_dir = os.path.abspath(config_dir)
self.__config_lock = threading.Lock()
self.__config_data = {} self.__config_data = {}
self.__lock = threading.Lock()
self.initialize() self.initialize()
@@ -100,7 +111,7 @@ class ConfigManager:
config_data = JSONReader(config_path).data() config_data = JSONReader(config_path).data()
self.__config_data[config_type.value] = config_data self.__config_data[config_type.value] = config_data
return return
except: except Exception:
pass pass
self.__config_data[config_type.value] = ConfigTemplate(config_type).template() self.__config_data[config_type.value] = ConfigTemplate(config_type).template()
JSONWriter(config_path, self.__config_data[config_type.value]) JSONWriter(config_path, self.__config_data[config_type.value])
@@ -111,16 +122,16 @@ class ConfigManager:
default: Optional[Any] = None default: Optional[Any] = None
) -> Any: ) -> Any:
with self.__config_lock: with self.__lock:
config_data = self.__config_data[key.config_type.value] config_data = self.__config_data[key.config_type.value]
if key.key == "": if key.key == "":
return config_data return copy.deepcopy(config_data)
keys = key.key.split('.') keys = key.key.split('.')
for k in keys[:-1]: for k in keys[:-1]:
config_data = config_data.get(k, None) config_data = config_data.get(k, None)
if config_data is None: if config_data is None:
return default return default
return config_data.get(keys[-1], default) return copy.deepcopy(config_data.get(keys[-1], default))
def set( def set(
self, self,
@@ -128,7 +139,7 @@ class ConfigManager:
value: Any = None value: Any = None
): ):
with self.__config_lock: with self.__lock:
root_data = self.__config_data[key.config_type.value] root_data = self.__config_data[key.config_type.value]
if key.key == "": if key.key == "":
self.__config_data[key.config_type.value] = value self.__config_data[key.config_type.value] = value
+2 -2
View File
@@ -181,7 +181,7 @@ class WebDriverFileName:
raise ValueError(f"不受支持的 web driver 类型 : {self.driver_type}") raise ValueError(f"不受支持的 web driver 类型 : {self.driver_type}")
class WebDriverURL: class WebDriverDownloadURL:
""" """
Web driver download URL Web driver download URL
""" """
@@ -242,7 +242,7 @@ class WebDriverDownloader:
self.driver_type = driver_type self.driver_type = driver_type
self.arch = driver_arch self.arch = driver_arch
self.version = driver_version self.version = driver_version
self.download_url = str(WebDriverURL(self.version, self.driver_type, self.arch)) self.download_url = str(WebDriverDownloadURL(self.version, self.driver_type, self.arch))
self.download_dir = Path(download_dir)/self.driver_type.value/self.version/self.arch.value self.download_dir = Path(download_dir)/self.driver_type.value/self.version/self.arch.value
self.download_dir.mkdir(mode=0o0755, parents=True, exist_ok=True) self.download_dir.mkdir(mode=0o0755, parents=True, exist_ok=True)
self.download_path = self.download_dir/str(WebDriverFileName(self.version, self.driver_type, self.arch)) self.download_path = self.download_dir/str(WebDriverFileName(self.version, self.driver_type, self.arch))
+60 -77
View File
@@ -8,6 +8,7 @@ You may use, modify, and distribute this file under the terms of the MIT License
See the LICENSE file for details. See the LICENSE file for details.
""" """
import os import os
import shutil
import threading import threading
import packaging.version as ver import packaging.version as ver
@@ -19,7 +20,7 @@ from managers.driver.WebBrowserDetector import (
WebBrowserType, WebBrowserArch, WebBrowserInfo, WebBrowserDetector WebBrowserType, WebBrowserArch, WebBrowserInfo, WebBrowserDetector
) )
from managers.driver.WebDriverDownloader import ( from managers.driver.WebDriverDownloader import (
WebDriverArch, WebDriverType, WebDriverArch, WebDriverType, WebDriverExecName,
ChromeDriverDownloader, FirefoxDriverDownloader, EdgeDriverDownloader ChromeDriverDownloader, FirefoxDriverDownloader, EdgeDriverDownloader
) )
@@ -35,9 +36,9 @@ class WebDriverStatus(Enum):
ERROR = 3 ERROR = 3
class WebDriverInfo: class WebDriverCtx:
""" """
Web driver information. Web browser and driver context.
Attributes: Attributes:
driver_type (WebDriverType): Web driver type driver_type (WebDriverType): Web driver type
@@ -52,8 +53,8 @@ class WebDriverInfo:
self self
): ):
self.driver_type = None self.driver_type : Optional[WebDriverType] = None
self.driver_arch = None self.driver_arch : Optional[WebDriverArch] = None
self.driver_version = "" self.driver_version = ""
self.browser_version = "" self.browser_version = ""
self.driver_path: Optional[Path] = None self.driver_path: Optional[Path] = None
@@ -75,7 +76,7 @@ class WebDriverManager:
self.__driver_dir = os.path.abspath(driver_dir) self.__driver_dir = os.path.abspath(driver_dir)
self.__browser_detector = WebBrowserDetector() self.__browser_detector = WebBrowserDetector()
self.__driver_infos: list[WebDriverInfo] = [] self.__driver_ctxs: list[WebDriverCtx] = []
self.__initialized = False self.__initialized = False
self.__lock = threading.Lock() self.__lock = threading.Lock()
@@ -98,8 +99,8 @@ class WebDriverManager:
with self.__lock: with self.__lock:
browser_infos = self.__browser_detector.detect() browser_infos = self.__browser_detector.detect()
self.__driver_infos = [ self.__driver_ctxs = [
self._getDriverInfo(info) self._getDriverCtx(info)
for info in browser_infos for info in browser_infos
] ]
@@ -108,7 +109,7 @@ class WebDriverManager:
): ):
with self.__lock: with self.__lock:
for driver_info in self.__driver_infos: for driver_info in self.__driver_ctxs:
driver_path = self._getDriverPath(driver_info) driver_path = self._getDriverPath(driver_info)
if driver_path and driver_path.exists() and driver_path.is_file(): if driver_path and driver_path.exists() and driver_path.is_file():
# Repair missing execute permission on Unix # Repair missing execute permission on Unix
@@ -198,7 +199,7 @@ class WebDriverManager:
else: else:
raise ValueError(f"不支持的 Web 浏览器类型 : {browser_type}") raise ValueError(f"不支持的 Web 浏览器类型 : {browser_type}")
def _mapFirefoxDriverVersion( def _mapFirefoxBrowserVersionToDriver(
self, self,
version: str version: str
) -> str: ) -> str:
@@ -238,46 +239,30 @@ class WebDriverManager:
except Exception as e: except Exception as e:
raise ValueError(f"无效的 Firefox 版本格式 : {version}") from e raise ValueError(f"无效的 Firefox 版本格式 : {version}") from e
def _getDriverInfo( def _getDriverCtx(
self, self,
browser_info: WebBrowserInfo browser_info: WebBrowserInfo
) -> WebDriverInfo: ) -> WebDriverCtx:
driver_info = WebDriverInfo() driver_ctx = WebDriverCtx()
driver_info.driver_type = self._mapWebBrowserTypeToDriver(browser_info.browser_type) driver_ctx.driver_type = self._mapWebBrowserTypeToDriver(browser_info.browser_type)
driver_info.driver_arch = self._mapWebBrowserArchToDriver(browser_info.browser_type, browser_info.browser_arch) driver_ctx.driver_arch = self._mapWebBrowserArchToDriver(browser_info.browser_type, browser_info.browser_arch)
if browser_info.browser_type == WebBrowserType.FIREFOX: if browser_info.browser_type == WebBrowserType.FIREFOX:
driver_info.driver_version = self._mapFirefoxDriverVersion(browser_info.browser_version) driver_ctx.driver_version = self._mapFirefoxBrowserVersionToDriver(browser_info.browser_version)
else: else:
driver_info.driver_version = browser_info.browser_version driver_ctx.driver_version = browser_info.browser_version
driver_info.browser_version = browser_info.browser_version driver_ctx.browser_version = browser_info.browser_version
return driver_info return driver_ctx
def _getDriverPath( def _getDriverPath(
self, self,
driver_info: WebDriverInfo driver_ctx: WebDriverCtx
) -> Optional[Path]: ) -> Optional[Path]:
driver_type = driver_info.driver_type driver_type = driver_ctx.driver_type
driver_arch = driver_info.driver_arch driver_arch = driver_ctx.driver_arch
driver_version = driver_info.driver_version driver_version = driver_ctx.driver_version
if driver_type == WebDriverType.CHROME: exe_name = str(WebDriverExecName(driver_type, driver_arch))
driver_name = "chromedriver"
elif driver_type == WebDriverType.FIREFOX:
driver_name = "geckodriver"
elif driver_type == WebDriverType.EDGE:
driver_name = "msedgedriver"
else:
return None
is_win = driver_arch in [
WebDriverArch.Chrome.WINX86_32,
WebDriverArch.Chrome.WINX86_64,
WebDriverArch.Firefox.WINX86_32,
WebDriverArch.Firefox.WINX86_64,
WebDriverArch.Edge.WINX86_32,
WebDriverArch.Edge.WINX86_64,
]
exe_name = f"{driver_name}.exe" if is_win else driver_name
driver_dir = Path(self.__driver_dir)/driver_type.value/driver_version/driver_arch.value driver_dir = Path(self.__driver_dir)/driver_type.value/driver_version/driver_arch.value
driver_path = driver_dir/exe_name driver_path = driver_dir/exe_name
return driver_path return driver_path
@@ -289,28 +274,28 @@ class WebDriverManager:
self._detectBrowsers() self._detectBrowsers()
self._checkDriverStatus() self._checkDriverStatus()
def getDriverInfos( def getDriverCtxs(
self self
) -> list[WebDriverInfo]: ) -> list[WebDriverCtx]:
with self.__lock: with self.__lock:
return self.__driver_infos.copy() return self.__driver_ctxs.copy()
def getDriverInfo( def getDriverCtx(
self, self,
driver_type: WebDriverType driver_type: WebDriverType
) -> list[WebDriverInfo]: ) -> list[WebDriverCtx]:
with self.__lock: with self.__lock:
return [ return [
info info
for info in self.__driver_infos for info in self.__driver_ctxs
if info.driver_type == driver_type if info.driver_type == driver_type
] ]
def getDriverPath( def getDriverPath(
self, self,
driver_info: WebDriverInfo driver_info: WebDriverCtx
) -> Optional[Path]: ) -> Optional[Path]:
if driver_info and driver_info.driver_status == WebDriverStatus.INSTALLED: if driver_info and driver_info.driver_status == WebDriverStatus.INSTALLED:
@@ -319,28 +304,28 @@ class WebDriverManager:
def installDriver( def installDriver(
self, self,
driver_info: WebDriverInfo, driver_ctx: WebDriverCtx,
progress_callback: Optional[Callable[[float, int, float, str], None]] = None, progress_callback: Optional[Callable[[float, int, float, str], None]] = None,
cancel_event: Optional[threading.Event] = None cancel_event: Optional[threading.Event] = None
) -> Optional[Path]: ) -> Optional[Path]:
with self.__lock: with self.__lock:
if not driver_info: if not driver_ctx:
if progress_callback: if progress_callback:
progress_callback(0, 0, 0, "未找到浏览器信息") progress_callback(0, 0, 0, "未找到浏览器信息")
else: else:
raise ValueError("未找到浏览器信息") raise ValueError("未找到浏览器信息")
if driver_info and driver_info.driver_status == WebDriverStatus.DOWNLOADING: if driver_ctx and driver_ctx.driver_status == WebDriverStatus.DOWNLOADING:
if progress_callback: if progress_callback:
progress_callback(0, 0, 0, f"{driver_info.driver_type} 驱动正在下载中") progress_callback(0, 0, 0, f"{driver_ctx.driver_type} 驱动正在下载中")
else: else:
raise ValueError(f"{driver_info.driver_type} 驱动正在下载中") raise ValueError(f"{driver_ctx.driver_type} 驱动正在下载中")
try: try:
if not driver_info: if not driver_ctx:
raise ValueError("未找到浏览器信息") raise ValueError("未找到浏览器信息")
driver_arch = driver_info.driver_arch driver_arch = driver_ctx.driver_arch
driver_type = driver_info.driver_type driver_type = driver_ctx.driver_type
driver_version = driver_info.driver_version driver_version = driver_ctx.driver_version
downloader = None downloader = None
if driver_type == WebDriverType.CHROME: if driver_type == WebDriverType.CHROME:
downloader = ChromeDriverDownloader( downloader = ChromeDriverDownloader(
@@ -366,68 +351,66 @@ class WebDriverManager:
else: else:
raise ValueError(f"不支持的 Web Driver 类型") raise ValueError(f"不支持的 Web Driver 类型")
with self.__lock: with self.__lock:
driver_info.driver_status = WebDriverStatus.DOWNLOADING driver_ctx.driver_status = WebDriverStatus.DOWNLOADING
driver_path = downloader.download(progress_callback=progress_callback, cancel_event=cancel_event) driver_path = downloader.download(progress_callback=progress_callback, cancel_event=cancel_event)
with self.__lock: with self.__lock:
if driver_path: if driver_path:
driver_info.driver_path = driver_path driver_ctx.driver_path = driver_path
driver_info.driver_version = driver_version driver_ctx.driver_version = driver_version
driver_info.driver_status = WebDriverStatus.INSTALLED driver_ctx.driver_status = WebDriverStatus.INSTALLED
else: else:
driver_info.driver_status = WebDriverStatus.ERROR driver_ctx.driver_status = WebDriverStatus.ERROR
return driver_path return driver_path
except Exception as e: except Exception as e:
with self.__lock: with self.__lock:
driver_info.driver_status = WebDriverStatus.ERROR driver_ctx.driver_status = WebDriverStatus.ERROR
raise e raise e
def cancelDriverDownload( def cancelDriverDownload(
self, self,
driver_info: WebDriverInfo driver_ctx: WebDriverCtx
) -> bool: ) -> bool:
import shutil
try: try:
driver_path = self._getDriverPath(driver_info) driver_path = self._getDriverPath(driver_ctx)
if driver_path: if driver_path:
download_dir = driver_path.parent download_dir = driver_path.parent
if download_dir.exists(): if download_dir.exists():
shutil.rmtree(download_dir, ignore_errors=True) shutil.rmtree(download_dir, ignore_errors=True)
with self.__lock: with self.__lock:
driver_info.driver_path = None driver_ctx.driver_path = None
driver_info.driver_status = WebDriverStatus.NOT_INSTALLED driver_ctx.driver_status = WebDriverStatus.NOT_INSTALLED
return True return True
except Exception: except Exception:
return False return False
def uninstallDriver( def uninstallDriver(
self, self,
driver_info: WebDriverInfo, driver_ctx: WebDriverCtx,
progress_callback: Optional[Callable[[int, int, float, str], None]] = None progress_callback: Optional[Callable[[int, int, float, str], None]] = None
) -> bool: ) -> bool:
with self.__lock: with self.__lock:
if not driver_info: if not driver_ctx:
if progress_callback: if progress_callback:
progress_callback(0, 0, 0, "未找到浏览器信息") progress_callback(0, 0, 0, "未找到浏览器信息")
else: else:
raise ValueError("未找到浏览器信息") raise ValueError("未找到浏览器信息")
if driver_info.driver_status != WebDriverStatus.INSTALLED: if driver_ctx.driver_status != WebDriverStatus.INSTALLED:
if progress_callback: if progress_callback:
progress_callback(0, 0, 0, f"{driver_info.driver_type} 驱动未安装") progress_callback(0, 0, 0, f"{driver_ctx.driver_type} 驱动未安装")
else: else:
raise ValueError(f"{driver_info.driver_type} 驱动未安装") raise ValueError(f"{driver_ctx.driver_type} 驱动未安装")
try: try:
driver_path = driver_info.driver_path driver_path = driver_ctx.driver_path
driver_path.unlink() driver_path.unlink()
with self.__lock: with self.__lock:
driver_info.driver_path = None driver_ctx.driver_path = None
driver_info.driver_status = WebDriverStatus.NOT_INSTALLED driver_ctx.driver_status = WebDriverStatus.NOT_INSTALLED
return True return True
except Exception: except Exception:
with self.__lock: with self.__lock:
driver_info.driver_status = WebDriverStatus.ERROR driver_ctx.driver_status = WebDriverStatus.ERROR
raise raise
def driverDir( def driverDir(
-5
View File
@@ -99,17 +99,14 @@ class LogManager:
self.__logger = logging.getLogger("AutoLibrary") self.__logger = logging.getLogger("AutoLibrary")
self.__logger.setLevel(logging.DEBUG) self.__logger.setLevel(logging.DEBUG)
self.__logger.handlers.clear() self.__logger.handlers.clear()
formatter = CallerInfoFormatter( formatter = CallerInfoFormatter(
'[%(asctime)s] - [%(name)s] - [%(levelname)s] - [%(filename)s:%(lineno)s] - %(message)s', '[%(asctime)s] - [%(name)s] - [%(levelname)s] - [%(filename)s:%(lineno)s] - %(message)s',
datefmt='%Y-%m-%d %H:%M:%S' datefmt='%Y-%m-%d %H:%M:%S'
) )
console_handler = logging.StreamHandler() console_handler = logging.StreamHandler()
console_handler.setLevel(logging.INFO) console_handler.setLevel(logging.INFO)
console_handler.setFormatter(formatter) console_handler.setFormatter(formatter)
self.__logger.addHandler(console_handler) self.__logger.addHandler(console_handler)
all_log_file = os.path.join(self.__log_dir, "all.log") all_log_file = os.path.join(self.__log_dir, "all.log")
file_handler_all = TimedRotatingFileHandler( file_handler_all = TimedRotatingFileHandler(
all_log_file, all_log_file,
@@ -122,7 +119,6 @@ class LogManager:
file_handler_all.setLevel(logging.DEBUG) file_handler_all.setLevel(logging.DEBUG)
file_handler_all.setFormatter(formatter) file_handler_all.setFormatter(formatter)
self.__logger.addHandler(file_handler_all) self.__logger.addHandler(file_handler_all)
error_log_file = os.path.join(self.__log_dir, "error.log") error_log_file = os.path.join(self.__log_dir, "error.log")
file_handler_error = TimedRotatingFileHandler( file_handler_error = TimedRotatingFileHandler(
error_log_file, error_log_file,
@@ -135,7 +131,6 @@ class LogManager:
file_handler_error.setLevel(logging.ERROR) file_handler_error.setLevel(logging.ERROR)
file_handler_error.setFormatter(formatter) file_handler_error.setFormatter(formatter)
self.__logger.addHandler(file_handler_error) self.__logger.addHandler(file_handler_error)
self.__initialized = True self.__initialized = True
def getLogger( def getLogger(
+359
View File
@@ -0,0 +1,359 @@
# -*- coding: utf-8 -*-
"""
Copyright (c) 2026 KenanZhu.
All rights reserved.
This software is provided "as is", without any warranty of any kind.
You may use, modify, and distribute this file under the terms of the MIT License.
See the LICENSE file for details.
"""
import os
import shutil
import threading
from PySide6.QtCore import Qt
from PySide6.QtWidgets import (
QApplication,
QStyleFactory
)
from interfaces.ConfigProvider import CfgKey
from managers.config.ConfigManager import instance as configInstance
from managers.log.LogManager import instance as logInstance
from utils.ThemeUtils import (
readThemeInfo,
readThemeQss,
validateTheme,
wrapQssToAtheme
)
# we use Fusion as the default active style name
# because it's cross-platform.
_active_style_name = "Fusion"
def setActiveStyle(
style_name: str
):
global _active_style_name
_active_style_name = style_name
def getActiveStyle(
) -> str:
return _active_style_name
class ThemeManager:
"""
Theme manager class.
Manages the themes storage directory, providing import,
list, remove, and apply operations for .altheme theme files.
Args:
themes_dir (str): Path to the themes storage directory.
"""
def __init__(
self,
themes_dir: str
):
self.__themes_dir = os.path.abspath(themes_dir)
self.__current_theme_name = ""
self.__lock = threading.Lock()
# directory must exist
os.makedirs(self.__themes_dir, exist_ok=True)
@staticmethod
def _colorSchemeFor(
theme: str
) -> Qt.ColorScheme:
"""
Map a theme identifier to the corresponding Qt color scheme.
"""
if theme == "dark":
return Qt.ColorScheme.Dark
elif theme == "light":
return Qt.ColorScheme.Light
else:
return Qt.ColorScheme.Unknown
def _deleteThemeFile(
self,
name: str
):
"""
Delete a theme file in the themes storage directory.
The caller must hold self.__lock before invoking this method.
**This method ONLY deletes the file**.
"""
filepath = os.path.join(self.__themes_dir, name + ".altheme")
if os.path.isfile(filepath):
os.remove(filepath)
def _resolveDestPath(
self,
theme_name: str,
author: str
) -> str:
"""
Resolve the destination path for an imported theme.
If the default {name}.altheme path does not exist, use it directly.
If it exists and has a different author, use {name}_{author}.altheme.
If it exists and has the same author, raise ValueError.
Args:
theme_name (str): Sanitised theme name.
author (str): Theme author string.
Returns:
str: The resolved destination file path.
Raises:
ValueError: If a theme with the same name and author already exists.
"""
default_path = os.path.join(self.__themes_dir, theme_name + ".altheme")
if not os.path.exists(default_path):
return default_path
try:
existing_info = validateTheme(default_path)
existing_author = existing_info.get("author", "")
except Exception:
self._deleteThemeFile(theme_name) # caller holds the lock
raise ValueError(
f"主题 '{theme_name}' 已存在但无法通过验证, 已清理该主题文件"
)
if existing_author == author:
raise ValueError(
f"主题名称 '{theme_name}' (作者 '{author}') 已存在"
)
safe_author = os.path.basename(author) if author else "未知作者"
alt_path = os.path.join(
self.__themes_dir, f"{theme_name}_{safe_author}.altheme"
)
if os.path.exists(alt_path):
raise ValueError(
f"主题名称 '{theme_name}' (作者 '{author}') 已存在"
)
return alt_path
def importTheme(
self,
source_path: str
) -> str:
"""
Import a theme file into the themes directory.
Supports .altheme (zip archive) and bare .qss files.
Bare .qss files are automatically wrapped into .altheme format.
For .altheme files, validates that theme.qss exists in the archive
and sanitises the theme name to prevent path traversal.
Args:
source_path (str): Path to the .altheme or .qss file.
Returns:
str: The imported theme name.
Raises:
FileNotFoundError: If source_path does not exist.
ValueError: If the file type is unsupported or the .altheme is invalid.
"""
if not os.path.isfile(source_path):
raise FileNotFoundError(source_path)
base_name, ext = os.path.splitext(os.path.basename(source_path))
ext = ext.lower()
with self.__lock:
if ext == ".qss":
dest_path = self._resolveDestPath(base_name, "未知作者")
wrapQssToAtheme(source_path, dest_path, "both")
return os.path.splitext(os.path.basename(dest_path))[0]
elif ext == ".altheme":
info = validateTheme(source_path)
name = info.get("name", base_name)
safe_name = os.path.basename(name)
new_author = info.get("author", "")
dest_path = self._resolveDestPath(safe_name, new_author)
shutil.copy2(source_path, dest_path)
return os.path.splitext(os.path.basename(dest_path))[0]
else:
raise ValueError(f"不支持的文件类型: {ext}")
def listThemes(
self
) -> list:
"""
List all available themes in the themes directory.
Scans the themes directory for .altheme files and reads
their info.json metadata.
Returns:
list[dict]: A list of theme info dictionaries.
"""
themes = []
seen_keys = set()
if not os.path.isdir(self.__themes_dir):
return themes
for filename in sorted(os.listdir(self.__themes_dir)):
if filename.endswith(".altheme"):
filepath = os.path.join(self.__themes_dir, filename)
try:
info = validateTheme(filepath)
name = info.get("name", "")
author = info.get("author", "")
key = (name, author)
if key in seen_keys:
logInstance().getLogger("ThemeManager").warning(
f"主题名称 '{name}' (作者 '{author}') 重复 (文件 '{filename}') 已跳过"
)
continue
seen_keys.add(key)
info["file"] = os.path.splitext(filename)[0]
themes.append(info)
except Exception as e:
logInstance().getLogger("ThemeManager").warning(
f"无法读取主题文件 '{filename}',已跳过: {e}"
)
else:
logInstance().getLogger("ThemeManager").warning(
f"未知文件类型 '{filename}',已跳过"
)
return themes
def removeTheme(
self,
name: str
):
"""
Remove a theme by name.
If the removed theme is currently active, clears the QSS
stylesheet from the application and reverts to the saved
colour scheme.
Args:
name (str): The theme name to remove.
"""
with self.__lock:
self._deleteThemeFile(name)
if self.__current_theme_name == name:
self.__current_theme_name = ""
saved_theme = configInstance().get(
CfgKey.GLOBAL.APPEARANCE.THEME, "system"
)
self.clearTheme(saved_theme)
def applyTheme(
self,
name: str
):
"""
Apply a theme by name.
Extracts the QSS from the .altheme file, applies it to
QApplication, and sets the Qt color scheme based on
the theme's need_theme metadata.
Args:
name (str): The theme name to apply.
Raises:
FileNotFoundError: If the theme .altheme file does not exist.
"""
filepath = os.path.join(self.__themes_dir, name + ".altheme")
if not os.path.isfile(filepath):
raise FileNotFoundError(filepath)
with self.__lock:
info = readThemeInfo(filepath)
qss = readThemeQss(filepath)
app = QApplication.instance()
if app:
app.setStyleSheet(qss)
need_theme = info.get("need_theme", "both")
app.styleHints().setColorScheme(
ThemeManager._colorSchemeFor(need_theme)
)
app.setStyle(QStyleFactory.create(_active_style_name))
self.__current_theme_name = name
def clearTheme(
self,
theme: str
):
"""
Clear the current QSS stylesheet and apply the given color scheme.
Args:
theme (str): The color scheme to apply after clearing
("light", "dark", or "system").
"""
app = QApplication.instance()
if not app:
return
app.setStyleSheet("")
app.styleHints().setColorScheme(
ThemeManager._colorSchemeFor(theme)
)
app.setStyle(QStyleFactory.create(_active_style_name))
def themesDir(
self
) -> str:
"""
Get the themes directory path.
Returns:
str: The absolute path to the themes storage directory.
"""
return self.__themes_dir
# ThemeManager singleton instance.
_theme_manager_instance = None
# Singleton instance lock.
_instance_lock = threading.Lock()
def instance(
themes_dir: str = ""
) -> ThemeManager:
"""
Get the ThemeManager singleton instance.
On first call, initialises the ThemeManager with the themes
directory derived from ConfigManager's config directory.
Args:
themes_dir (str): Optional themes directory path.
Returns:
ThemeManager: The singleton ThemeManager instance.
"""
global _theme_manager_instance
with _instance_lock:
if _theme_manager_instance is None:
if not themes_dir:
cfg = configInstance()
themes_dir = os.path.join(cfg.configDir(), "themes")
_theme_manager_instance = ThemeManager(themes_dir)
return _theme_manager_instance
+9
View File
@@ -0,0 +1,9 @@
# -*- coding: utf-8 -*-
"""
Copyright (c) 2026 KenanZhu.
All rights reserved.
This software is provided "as is", without any warranty of any kind.
You may use, modify, and distribute this file under the terms of the MIT License.
See the LICENSE file for details.
"""
+2 -1
View File
@@ -22,6 +22,7 @@ from pages.strategies.TimeSelectMaker import TimeSelectMaker
from pages.ReserveView import ReserveView from pages.ReserveView import ReserveView
from pages.components.ReserveResultDialog import ReserveResultDialog from pages.components.ReserveResultDialog import ReserveResultDialog
from pages.components.TimeSelectDialog import TimeSelectDialog from pages.components.TimeSelectDialog import TimeSelectDialog
from pages.components.SeatMapDialog import SeatMapDialog
@dataclass @dataclass
@@ -150,7 +151,7 @@ class ReserveFlow(MsgBase):
def _selectSeatAndSubmit( def _selectSeatAndSubmit(
self, self,
view: ReserveView, view: ReserveView,
seat_map, seat_map: SeatMapDialog,
ctx: ReserveContext, ctx: ReserveContext,
) -> tuple[bool, bool]: ) -> tuple[bool, bool]:
+194
View File
@@ -0,0 +1,194 @@
# -*- coding: utf-8 -*-
"""
Copyright (c) 2026 KenanZhu.
All rights reserved.
This software is provided "as is", without any warranty of any kind.
You may use, modify, and distribute this file under the terms of the MIT License.
See the LICENSE file for details.
"""
import json
import os
import zipfile
def packTheme(
qss_path: str,
info: dict,
output_path: str
):
"""
Pack a .qss file and info dict into a .altheme file.
The .altheme file is a zip archive containing info.json and theme.qss.
Args:
qss_path (str): Path to the .qss stylesheet file.
info (dict): Theme metadata dict with keys name, author, need_theme, brief.
output_path (str): Destination path for the .altheme file.
Raises:
FileNotFoundError: If qss_path does not exist.
"""
if not os.path.isfile(qss_path):
raise FileNotFoundError(qss_path)
with zipfile.ZipFile(output_path, "w", zipfile.ZIP_DEFLATED) as zf:
zf.writestr("info.json", json.dumps(info, ensure_ascii=False, indent=4))
zf.write(qss_path, "theme.qss")
def unpackTheme(
altheme_path: str,
output_dir: str
):
"""
Extract a .altheme file to a directory.
Performs Zip Slip validation before extraction.
Args:
altheme_path (str): Path to the .altheme file.
output_dir (str): Directory to extract contents into.
Raises:
FileNotFoundError: If altheme_path does not exist.
ValueError: If a zip entry contains an unsafe path.
"""
if not os.path.isfile(altheme_path):
raise FileNotFoundError(altheme_path)
os.makedirs(output_dir, exist_ok=True)
with zipfile.ZipFile(altheme_path, "r") as zf:
for name in zf.namelist():
if name.startswith("/") or ".." in name:
raise ValueError(f"不安全的 .altheme 入口: {name}")
zf.extractall(output_dir)
def readThemeInfo(
altheme_path: str
) -> dict:
"""
Read and validate the info.json metadata from a .altheme file.
Verifies that all required fields (name, author, need_theme, brief)
are present with valid values.
Args:
altheme_path (str): Path to the .altheme file.
Returns:
dict: The validated theme metadata dictionary.
Raises:
FileNotFoundError: If altheme_path does not exist.
ValueError: If info.json is missing or any field is invalid.
"""
if not os.path.isfile(altheme_path):
raise FileNotFoundError(altheme_path)
with zipfile.ZipFile(altheme_path, "r") as zf:
if "info.json" not in zf.namelist():
raise ValueError("无效的 .altheme: 缺少 info.json")
with zf.open("info.json") as fh:
try:
info = json.loads(fh.read().decode("utf-8"))
except (json.JSONDecodeError, UnicodeDecodeError) as e:
raise ValueError(f"无效的 .altheme: info.json 解析失败 — {e}")
if "name" not in info or not isinstance(info.get("name"), str) or not info["name"].strip():
raise ValueError("无效的 .altheme: info.json 缺少有效的 'name' 字段")
# reject blank author so that info.json does not drift from the
# "未知作者" filename fallback used by wrapQssToAtheme
if ("author" not in info or not isinstance(info.get("author"), str)
or not info["author"].strip()):
raise ValueError("无效的 .altheme: info.json 缺少有效的 'author' 字段")
need_theme = info.get("need_theme", "both")
if need_theme not in ("light", "dark", "both"):
raise ValueError(
f"无效的 .altheme: need_theme 值 '{need_theme}' 无效, "
f"应为 'light''dark''both'"
)
if "brief" not in info or not isinstance(info.get("brief"), str):
raise ValueError("无效的 .altheme: info.json 缺少有效的 'brief' 字段")
return info
def readThemeQss(
altheme_path: str
) -> str:
"""
Read the theme.qss content from a .altheme archive.
Args:
altheme_path (str): Path to the .altheme file.
Returns:
str: The non-empty QSS stylesheet content.
Raises:
FileNotFoundError: If altheme_path does not exist.
ValueError: If theme.qss is missing or empty.
"""
if not os.path.isfile(altheme_path):
raise FileNotFoundError(altheme_path)
with zipfile.ZipFile(altheme_path, "r") as zf:
if "theme.qss" not in zf.namelist():
raise ValueError("无效的 .altheme: 缺少 theme.qss")
qss = zf.read("theme.qss").decode("utf-8")
if not qss.strip():
raise ValueError("无效的 .altheme: theme.qss 为空")
return qss
def validateTheme(
altheme_path: str
) -> dict:
"""
Fully validate a .altheme file and return its metadata.
Delegates info validation to readThemeInfo and QSS validation
to readThemeQss, then additionally checks that theme.qss is
non-empty.
Args:
altheme_path (str): Path to the .altheme file.
Returns:
dict: The validated theme metadata dictionary.
Raises:
FileNotFoundError: If altheme_path does not exist.
ValueError: If validation fails for any reason.
"""
info = readThemeInfo(altheme_path)
readThemeQss(altheme_path) # validates existence and non-empty
return info
def wrapQssToAtheme(
qss_path: str,
output_path: str,
current_theme: str
):
"""
Wrap a bare .qss file into a .altheme file with auto-generated metadata.
The generated info.json uses the filename as the theme name
and sets default values for author and brief.
Args:
qss_path (str): Path to the bare .qss stylesheet file.
output_path (str): Destination path for the .altheme file.
current_theme (str): The need_theme value to embed in metadata
("light", "dark", or "both").
Raises:
FileNotFoundError: If qss_path does not exist.
"""
filename = os.path.splitext(os.path.basename(qss_path))[0]
info = {
"name": filename,
"author": "未知作者",
"need_theme": current_theme,
"brief": "没有相关简介"
}
packTheme(qss_path, info, output_path)