1
1
mirror of https://github.com/KenanZhu/AutoLibrary.git synced 2026-06-17 23:13:03 +08:00

Compare commits

...

79 Commits

Author SHA1 Message Date
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
Kenan Zhu c337904010 refactor(*): Page Object 架构迁移、AutoScript 引擎沙箱化与全项目代码规范化 (#9)
Page Object 架构迁移、AutoScript 引擎沙箱化与全项目代码规范化
2026-05-29 14:33:41 +08:00
KenanZhu 779aad13b8 refactor(gui): 简化关于对话框标签文字
SYSTEM INFORMATION → SYSTEM,
PROJECT INFORMATION → PROJECT

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 14:17:53 +08:00
KenanZhu f3360423e5 fix(build): 重命名 requirement.txt 并统一所有引用
- 重命名 requirement.txt → requirements.txt
- 更新 build.yml 和 build-test.yml 中的 pip cache 和
  install 路径引用
- README.md 恢复 pip install -r requirements.txt 构建步骤

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 14:16:35 +08:00
KenanZhu bea12d5f0c fix(docs): 更新手册域名并移除不存在的 requirements.txt 引用
- 手册 URL 已从 www.autolibrary.kenanzhu.com/manuals 迁移至
  manuals.autolibrary.kenanzhu.com
- 构建步骤中不再引用已不存在的 requirements.txt

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 14:12:06 +08:00
KenanZhu b24f39456e fix: 修复 Git 文件名大小写与文件系统不一致的问题
Windows 下 git core.ignorecase=true 导致文件重命名时 Git 无法
检测到大小写变化,推送后服务器上仍为旧命名。通过两步 git mv
强制更新索引,统一所有文件名为规范大小写。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 14:05:20 +08:00
KenanZhu bb63ee6f03 refactor(gui): 统一 Qt 控件变量命名风格为 PascalCase
将所有 self.xxx 形式的 Qt 控件属性名以及 Qt 对象局部变量由 snake_case
重命名为 PascalCase,提升代码可读性和一致性。涉及 14 个文件,涵盖:
- AutoScript 编排/编辑对话框子模块
- 配置/主窗口/用户树/座位图等核心界面组件
- 定时任务管理相关界面
- 状态标签/浏览器驱动下载对话框

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 19:35:03 +08:00
KenanZhu 3ebebe015f refactor(gui): 重构关于对话框,改用 QTabWidget 分页展示信息与许可证
将原本的单页文本浏览器替换为 TabWidget,分"关于"和"许可证"两个标签页。
同时优化了信息排版和样式,新增 Selenium 版本展示,移除了 UI 文件中的旧控件。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 19:34:36 +08:00
KenanZhu 02b3a62868 chore(autoscript): 添加模块版本号 v1.0.0
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 16:54:14 +08:00
KenanZhu d7e19dcd52 refactor(pages): 调整预约检查流程顺序,数据校验前置以避免无效浏览器操作
将 ReserveChecker.check(纯数据校验)移至 RecordChecker.canReserve(浏览器查询)之前,
解决 canReserve 在校验前使用未规范化日期的隐式缺陷,并避免无效配置触发昂贵的页面导航操作。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 11:33:17 +08:00
KenanZhu 59c06b3a19 fix(workflows): 修复图标引用、条件逻辑死代码并统一输出格式
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 02:49:46 +08:00
KenanZhu b78fd2d1e4 chore: 添加 AutoLibrary 应用图标资源
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 01:55:12 +08:00
KenanZhu 2aace40a26 fix(services): 修复验证码识别逻辑、预约时间校验与异常处理结构
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 01:36:28 +08:00
KenanZhu df7ad92f7f fix(pages): 移除裸 except Exception 改用精确异常类型并加固元素操作防护
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 01:36:18 +08:00
KenanZhu 910e3e3224 chore: 统一 __init__.py 许可头为版权声明并改用相对导入
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 01:35:55 +08:00
KenanZhu f7167c13f4 fix(ALAutoScript*Dialog): 统一编排窗口生成的 Lua 函数名与 ASEngine 运行时一致
- date_add → dateadd, time_add → timeadd
- CURRENT_DATE() → datenow(), CURRENT_TIME() → timenow()
- 编辑窗口 Date/Time 字面量按钮模板同步更新为 date()/time() 格式

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 20:25:19 +08:00
KenanZhu eb8da498a2 refactor(pages): 引入 Page Object 模式替代 operators/ 模块并移除旧代码
- 以 Page Object + Strategy + Flow 分层架构重写 pages/ 模块
- 将页面元素定位 (LoginPage/MainShell/ReserveView/RecordsView) 与业务编排 (ReserveFlow/CheckinFlow/RenewFlow) 分离
- 抽取 Dialog 上下文管理器统一弹窗生命周期,集成 TimeSelectMaker 策略模式处理时间选择
- 拆分 Service 层:CaptchaSolver、RecordChecker、ReserveChecker 独立可注入
- 统一闭馆时间为 TimeSelectMaker.LIBRARY_CLOSE_MINS (22:30)
- 移除旧 operators/ 模块及 base/LibOperator 层

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 20:07:42 +08:00
KenanZhu b279b51b42 refactor: 移除旧 operators/ 模块和 base/ 层
operators/ 模块已被 pages/ 模块完全替代,base/ 中的 LibOperator 和 MsgBase
不再被任何新代码引用。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 20:05:24 +08:00
KenanZhu 43336f98d2 fix: 统一闭馆时间为 TimeSelectMaker.LIBRARY_CLOSE_MINS (22:30)
ReserveChecker._finalCheck 中存在硬编码的 "23:30",与 TimeSelectMaker.LIBRARY_CLOSE_MINS (22:30)
不一致,导致校验阶段与选时阶段使用不同的闭馆时间上限。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 20:03:35 +08:00
KenanZhu e77c561685 refactor: 时间选择逻辑下沉至 Dialog、Worker 模板方法抽象、配置访问安全化与代码风格统一
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 19:54:26 +08:00
KenanZhu 345cb95b98 refactor(pages): 抽取时间选择策略为 TimeSelectMaker,将 Overlay 基类更名为 Dialog
将 findBestTimeOption 中的预约/续约双分支逻辑抽象为策略模式:
- TimeOptionReader 负责从 WebElement 提取时间数据(ReserveTimeReader / RenewTimeReader)
- TimeDecisionMaker 执行纯决策算法,零 Selenium 依赖
- TimeSelectMaker 作为工厂统一创建配置好的决策器
- 共享常量 LIBRARY_CLOSE_MINS 统一收敛至 TimeSelectMaker

同时将 Overlay 基类重命名为 Dialog,SeatMapOverlay 同步更名为 SeatMapDialog,保持命名一致性。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 13:13:43 +08:00
KenanZhu caa563e770 refactor(pages): 统一命名规范并修复 SeatMapOverlay 元素等待目标错误
- AutoLibPages → AutoLib(移除实现细节后缀)
- ReserveValidator → ReserveChecker(与 RecordChecker 命名一致)
- CaptchaHandler → CaptchaSolver(语义更准确,职责是"求解"验证码)
- ReserveChecker.validate() → check()(与 RecordChecker 风格统一)
- 修复 SeatMapOverlay.selectSeat() 中 _waitClickable 等待页面全局
  <a> 而非具体 seat_link 元素的时序缺陷
- ALMainWorkers 切换为 pages.AutoLib 新版实现

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 20:52:52 +08:00
KenanZhu 280028259f refactor(pages): 将 LoginPage 日志回调从方法参数改为构造器注入
消除 login() 方法签名中的 tracer/log_level 参数,通过构造器可选注入
tracer 统一日志模式,避免 Page Object 对外暴露 MsgBase 内部细节。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 18:01:25 +08:00
KenanZhu a6bc103c73 refactor(pages): 拆分 _dialogs 为独立组件文件,解耦 Service 构造函数,消除 PageObject 重复逻辑
- 将 _dialogs.py 拆分为 pages/components/ 下的独立文件,Overlay 基类同步移入
- CaptchaHandler / RecordChecker 构造函数不再持有 PageObject,改为方法参数注入
- LoginPage.login() 直接接收 auto_captcha 参数,简化 captcha_solver 调用链
- SeatMapOverlay.selectSeat 引入两层查找:先按 ID 直查,失败后遍历匹配
- 移除 ReserveView 中与 Dialog/Overlay 重复的方法(selectSeat、getAvailableTimeOptions)
- AutoLibPages 拆分 __initPagesServices / __initPagesFlows
- 修复 RecordsView.MORE_BTN 选择器被错误 snake_case 化(more_btn → moreBtn)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 13:41:55 +08:00
KenanZhu 2226e8ac90 refactor(pages): 引入 Page Object 模式重构全部页面模块,变量统一为 snake_case
将原始 Selenium 操作脚本重构为三层 Page Object 架构:
- Page Objects(LoginPage/ReserveView/RecordsView/MainShell)
- Component Objects(Overlay 基类 + SeatMapOverlay/ReserveResultDialog 等对话框)
- Flow 状态机(ReserveFlow/CheckinFlow/RenewFlow)
- Services(CaptchaHandler/ReserveValidator/RecordChecker)

变量命名统一为 snake_case,方法名保持 camelCase,类名保持 PascalCase。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 12:39:21 +08:00
KenanZhu 106463b9e5 refactor(autoscript): 对象化 ASEngine、移除旧变量导出、清理编排窗口解析逻辑
- ASEngine 转为类,目标变量注册作为 __init__ 接口,配套函数提取到 _helpers.py
- Lua 函数重命名 CURRENT_DATE→datenow, CURRENT_TIME→timenow, date_add→dateadd 等
- __init__.py 移除 ALL_VARIABLES/_TARGET_VAR_DEFS/_MOCK_TYPE_VALUES 导出,替换为接口函数
- 编排窗口移除脚本→控件的反向解析逻辑,合并常量定义为查询接口
- 编辑窗口新增工具函数 Tab、Tab 键插入 4 空格、图标改用 setIcon 加载

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 19:10:07 +08:00
KenanZhu 5e898180c7 refactor(style): 统一项目代码风格,整理导入顺序、间距规范与方法排列
- GUI 模块统一 QtCore → QtGui → QtWidgets 导入排列,各类独占一行按字母排序
- 统一类间两空行、类内方法间一空行、函数间一空行的间距规范
- 统一方法排列顺序:__init__ → setupUi → connectSignals → public → Slot → private
- 统一 _widgets 中 ConditionRowFrame/ActionStepFrame 方法命名(populate* / toScript / updateValueWidget)
- LibTimeSelector 迁入 operators/abs 抽象层

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 13:14:27 +08:00
KenanZhu a03ab38279 refactor(autoscript): 完善 Lua 错误分类与 Date/Time 严格校验,清理死代码并补齐类型注解
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 01:02:17 +08:00
KenanZhu 4761cade26 refactor(gui): 统一资源路径前缀并更新新版 SVG Logo 2026-05-23 20:05:39 +08:00
KenanZhu 531b05651e refactor(gui): 重构更新 AutoLibrary Logo 样式为全新设计样式 2026-05-23 19:26:00 +08:00
KenanZhu 3cea7df736 refactor(gui): 编排编辑窗口适配 Lua 引擎新接口
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 18:22:49 +08:00
KenanZhu a0fd03f12f refactor(autoscript): ASEngine 迁移至 Lua 沙箱引擎,强化类型安全与异常处理
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 18:22:36 +08:00
KenanZhu 9b47886e5b fix(autoscript): SET 赋值强制强类型检查,禁止跨类型隐式转换
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 04:22:06 +08:00
KenanZhu 82738be99a feat(gui): 编辑窗口支持调试运行与动态模拟目标数据输入
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 04:21:52 +08:00
KenanZhu e097b5afc9 refactor(gui): 编排窗口简化为纯代码生成器,移除脚本解析与预检逻辑
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 04:21:40 +08:00
KenanZhu fe7453fe02 feat(gui): 编排窗口支持算术表达式解析与回显
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 00:27:59 +08:00
KenanZhu 1d4b03d162 feat(autoscript): 支持算术表达式与变量参与加减运算
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 00:27:43 +08:00
KenanZhu 4642916fd5 fix(gui): 修正编排窗口日期映射 CURRENT_DATE 误识别为前天的问题 2026-05-18 20:47:35 +08:00
KenanZhu 5800437ba2 fix(gui): 编排窗口代码生成统一使用 END IF 结束块 2026-05-18 20:43:48 +08:00
KenanZhu 23467c1d3d feat(autoscript): 支持 // 行内注释与完整注释行解析 2026-05-18 20:13:46 +08:00
KenanZhu b8c0a29c59 fix(gui): 调整定时任务对话框布局边距与间距 2026-05-18 17:59:04 +08:00
KenanZhu 87787ad3dc style(gui): 编辑器高亮配色更改为 VSCode C 风格并为布尔字面量独立配色 2026-05-18 17:59:00 +08:00
KenanZhu e800f6ece1 refactor(gui): 统一 setupUi 命名并调整按钮布局 2026-05-18 16:01:22 +08:00
KenanZhu 600a304ab8 style(gui): 规范编排对话框属性命名并消除冗余代码 2026-05-18 16:01:16 +08:00
KenanZhu c038c8005d refactor(autoscript): 公开 splitTopLevel 并导出常量,消除冗余委托与重复变量 2026-05-18 16:01:10 +08:00
KenanZhu 6cf182c8c8 refactor(gui): 编排窗口迁移至新包并移除旧的预览/编排对话框 2026-05-18 11:15:35 +08:00
KenanZhu 33c0f4414c fix(autoscript): 为异常添加行号信息并补充类型兼容性检查 2026-05-17 02:58:47 +08:00
KenanZhu 2843300cf9 refactor(autoscript): 使用观察者模式解耦解析与预检查/编排流程 2026-05-17 01:48:25 +08:00
KenanZhu 9bdc9a3de9 refactor(autoscript): 使用 ASTokenizer 和 NodeVisitor 重构解析与执行流程 2026-05-17 01:33:22 +08:00
KenanZhu 500ddd41c5 refactor(autoscript): 替换 dsl 包为 autoscript 引擎模块 2026-05-12 11:49:43 +08:00
KenanZhu 14c6db3384 refactor(config): 引入 ConfigPath 值对象消除 ConfigType/ConfigKey 的消费者 API 冗余 2026-05-10 16:14:36 +08:00
KenanZhu bbd97970a6 refactor(modules): 将 AutoScriptEngine 移至 dsl/,ConfigUtils 移至 managers/config/,修复单一职责和依赖倒置问题 2026-05-10 15:33:10 +08:00
github-actions[bot] 22d3c3462c chore(release): merge release/v1.3.0 to main [auto release commit] 2026-05-09 06:08:33 +00:00
118 changed files with 9306 additions and 4575 deletions
+13 -13
View File
@@ -4,10 +4,6 @@ name: Build Test
# It is triggered when a pull request is opened, synchronized, or reopened against the main branch.
on:
push:
branches:
- main
pull_request:
branches:
- main
@@ -15,7 +11,6 @@ on:
- opened
- synchronize
- reopened
# Allow manual trigger for testing
workflow_dispatch:
#
@@ -49,11 +44,13 @@ jobs:
uses: actions/setup-python@v6
with:
python-version: '3.13'
cache: 'pip'
cache-dependency-path: requirements.txt
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirement.txt
pip install -r requirements.txt
- name: Solve ddddocr compatibility and copy model files
run: |
@@ -125,7 +122,7 @@ jobs:
" binaries=[],"
" datas=["
" ('models\\common.onnx', 'ddddocr'),"
" ('src\\gui\\resources\\icons\\AutoLibrary_32x32.ico', 'gui\\resources\\icons'),"
" ('src\\gui\\resources\\icons\\AutoLibrary_Logo_64.ico', 'gui\\resources\\icons'),"
" ],"
" hiddenimports=[],"
" hookspath=[],"
@@ -153,7 +150,7 @@ jobs:
" target_arch=None,"
" codesign_identity=None,"
" entitlements_file=None,"
" icon=['src\\gui\\resources\\icons\\AutoLibrary_32x32.ico'],"
" icon=['src\\gui\\resources\\icons\\AutoLibrary_Logo_64.ico'],"
")"
""
"coll = COLLECT("
@@ -169,9 +166,11 @@ jobs:
$specLines | Out-File -FilePath "Main.spec" -Encoding UTF8
Write-Host "✓ Main.spec (non-single file) generated successfully"
Write-Host "`nGenerated Main.spec ============"
Write-Host "`n========================================"
Write-Host "Generated Main.spec"
Write-Host "========================================"
Get-Content "Main.spec" | Write-Host
Write-Host "==================================`n"
Write-Host "========================================`n"
shell: pwsh
- name: Build with PyInstaller
@@ -186,7 +185,7 @@ jobs:
$distDir = "dist/AutoLibrary-$version"
$zipName = "AutoLibrary.$tagName-windows-x86_64.zip"
echo "ZIP_NAME=$zipName" >> $env:GITHUB_OUTPUT
"ZIP_NAME=$zipName" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append
Write-Host "Looking for distribution directory: $distDir"
if (Test-Path $distDir) {
@@ -212,10 +211,11 @@ jobs:
run: |
Write-Host "## Build Test Summary" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8
Write-Host "" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
Write-Host "========================================" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
Write-Host "✓ Pull request build test completed successfully!" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
Write-Host "- Version: ${{ steps.get_version.outputs.VERSION }}" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
Write-Host "- Tag: ${{ steps.get_version.outputs.TAG_NAME }}" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
Write-Host "- Pull Request #${{ github.event.pull_request.number }}" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
Write-Host "- Branch: ${{ github.event.pull_request.head.ref }}" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
Write-Host "- Pull Request #${{ github.event.pull_request.number || 'N/A' }}" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
Write-Host "- Branch: ${{ github.event.pull_request.head.ref || github.ref }}" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
Write-Host "- Event: ${{ github.event_name }}" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
shell: pwsh
+15 -10
View File
@@ -76,20 +76,22 @@ jobs:
run: |
$versionInfoFile = "src/gui/ALVersionInfo.py"
Write-Host "Verifying $versionInfoFile content:"
Write-Host "=================================="
Write-Host "========================================"
Get-Content $versionInfoFile | Write-Host
Write-Host "=================================="
Write-Host "========================================"
shell: pwsh
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: '3.13'
cache: 'pip'
cache-dependency-path: requirements.txt
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirement.txt
pip install -r requirements.txt
- name: Solve ddddocr compatibility and copy model files
run: |
@@ -161,7 +163,7 @@ jobs:
" binaries=[],"
" datas=["
" ('models\\common.onnx', 'ddddocr'),"
" ('src\\gui\\resources\\icons\\AutoLibrary_32x32.ico', 'gui\\resources\\icons'),"
" ('src\\gui\\resources\\icons\\AutoLibrary_Logo_64.ico', 'gui\\resources\\icons'),"
" ],"
" hiddenimports=[],"
" hookspath=[],"
@@ -189,7 +191,7 @@ jobs:
" target_arch=None,"
" codesign_identity=None,"
" entitlements_file=None,"
" icon=['src\\gui\\resources\\icons\\AutoLibrary_32x32.ico'],"
" icon=['src\\gui\\resources\\icons\\AutoLibrary_Logo_64.ico'],"
")"
""
"coll = COLLECT("
@@ -205,9 +207,11 @@ jobs:
$specLines | Out-File -FilePath "Main.spec" -Encoding UTF8
Write-Host "✓ Main.spec (non-single file) generated successfully"
Write-Host "`nGenerated Main.spec ============"
Write-Host "`n========================================"
Write-Host "Generated Main.spec"
Write-Host "========================================"
Get-Content "Main.spec" | Write-Host
Write-Host "==================================`n"
Write-Host "========================================`n"
shell: pwsh
- name: Build with PyInstaller
@@ -222,7 +226,7 @@ jobs:
$distDir = "dist/AutoLibrary-$version"
$zipName = "AutoLibrary.$tagName-windows-x86_64.zip"
echo "ZIP_NAME=$zipName" >> $env:GITHUB_OUTPUT
"ZIP_NAME=$zipName" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append
Write-Host "Looking for distribution directory: $distDir"
if (Test-Path $distDir) {
@@ -242,13 +246,14 @@ jobs:
name: AutoLibrary.${{ steps.get_version.outputs.TAG_NAME }}-windows-x86_64
path: |
${{ steps.zip_release.outputs.ZIP_NAME }}
retention-days: ${{ github.event_name != 'workflow_call' && 7 || 90 }}
retention-days: ${{ inputs.is_test == 'true' && 7 || 90 }}
- name: Upload build summary
if: ${{ github.event_name != 'workflow_call' }}
if: ${{ inputs.is_test == 'true' }}
run: |
Write-Host "## Build Summary" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8
Write-Host "" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
Write-Host "========================================" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
Write-Host "✓ Build test completed successfully!" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
Write-Host "- Version: ${{ steps.get_version.outputs.VERSION }}" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
Write-Host "- Tag: ${{ steps.get_version.outputs.TAG_NAME }}" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
+9 -1
View File
@@ -83,7 +83,9 @@ jobs:
echo "✓ File replaced: $FILE_PATH"
echo ""
echo "Updated file content ==================="
echo "========================================"
echo "Updated file content"
echo "========================================"
cat "$FILE_PATH"
echo "========================================"
@@ -151,3 +153,9 @@ jobs:
COMMIT_SHA=$(git rev-parse --short HEAD)
echo "commit_sha=$COMMIT_SHA" >> $GITHUB_OUTPUT
echo "✓ New commit SHA: $COMMIT_SHA"
echo "## Commit Release Summary" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "========================================" >> $GITHUB_STEP_SUMMARY
echo "- Version: ${{ inputs.version }}" >> $GITHUB_STEP_SUMMARY
echo "- Tag: ${{ inputs.tag_name }}" >> $GITHUB_STEP_SUMMARY
echo "- Commit SHA: $COMMIT_SHA" >> $GITHUB_STEP_SUMMARY
+14 -7
View File
@@ -47,7 +47,7 @@ on:
jobs:
#
# Start :
# virtual job that indacates the start of the release process
# virtual job that indicates the start of the release process
#
start:
@@ -158,7 +158,7 @@ jobs:
needs:
- update-version
- commit-release
if: always() && needs.update-version.result == 'success' && needs.commit-release.result == 'success'
if: always() && needs.update-version.result == 'success' && (needs.commit-release.result == 'success' || needs.commit-release.result == 'skipped')
uses: ./.github/workflows/build.yml
permissions:
contents: write
@@ -205,7 +205,7 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# End :
# virtual job that indacates the end of the release process
# virtual job that indicates the end of the release process
#
end:
@@ -227,7 +227,7 @@ jobs:
- release
- extract-version
- commit-release
if: ${{ needs.release.result == 'success' && needs.commit-release.result == 'success' }}
if: ${{ needs.release.result == 'success' && (needs.commit-release.result == 'success' || needs.commit-release.result == 'skipped') }}
runs-on: ubuntu-latest
permissions:
contents: write
@@ -267,9 +267,13 @@ jobs:
git checkout ${MAIN_BRANCH}
# Show branch status before merge
echo "=== Branch status before merge ==="
echo "========================================"
echo "Branch status before merge"
echo "========================================"
git log --oneline --graph --all -5
echo "=== Diff between ${MAIN_BRANCH} and origin/${BRANCH_NAME} ==="
echo "========================================"
echo "Diff: ${MAIN_BRANCH} vs origin/${BRANCH_NAME}"
echo "========================================"
git diff ${MAIN_BRANCH} origin/${BRANCH_NAME} --stat || echo "No differences found"
# Force create a merge commit even if there are no changes
@@ -279,7 +283,9 @@ jobs:
-m "chore(release): merge ${BRANCH_NAME} to ${MAIN_BRANCH} [auto release commit]"
# Show merge result
echo "=== Merge result ==="
echo "========================================"
echo "Merge result"
echo "========================================"
git log --oneline --graph -3
# Push to main
@@ -310,6 +316,7 @@ jobs:
echo "## Release Cleanup Summary" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "========================================" >> $GITHUB_STEP_SUMMARY
echo "✓ Release completed successfully!" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### Actions Performed:" >> $GITHUB_STEP_SUMMARY
+13 -3
View File
@@ -128,10 +128,15 @@ jobs:
echo "Build version file location: $VER_INFO_BUILDFILE"
echo "Commit version file location: $VER_INFO_COMMITFILE"
echo ""
echo "Build version ALVersionInfo.py content ="
echo "========================================"
echo "Build version ALVersionInfo.py"
echo "========================================"
cat "$VER_INFO_BUILDFILE"
echo "========================================"
echo ""
echo "Commit version ALVersionInfo.py content "
echo "========================================"
echo "Commit version ALVersionInfo.py"
echo "========================================"
cat "$VER_INFO_COMMITFILE"
echo "========================================"
@@ -140,11 +145,16 @@ jobs:
run: |
if git diff --quiet src/gui/ALVersionInfo.py; then
echo "has_changes=false" >> $GITHUB_OUTPUT
echo "! No changes detected in ALVersionInfo.py"
echo " No changes detected in ALVersionInfo.py"
else
echo "has_changes=true" >> $GITHUB_OUTPUT
echo "✓ ALVersionInfo.py has been modified"
fi
echo "## Update Version Summary" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "========================================" >> $GITHUB_STEP_SUMMARY
echo "- Version: ${{ steps.get_version.outputs.VERSION }}" >> $GITHUB_STEP_SUMMARY
echo "- Tag: ${{ steps.get_version.outputs.TAG_NAME }}" >> $GITHUB_STEP_SUMMARY
- name: Upload modified ALVersionInfo.py ready for build
if: steps.check_changes.outputs.has_changes == 'true'
View File
+2 -2
View File
@@ -2,7 +2,7 @@
# AutoLibrary
---
![AutoLibrary Logo](./src/gui/resources/icons/AutoLibrary_128x128.ico)
![AutoLibrary Logo](./src/gui/resources/icons/AutoLibrary_Logo_128.svg)
[![GitHub stars](https://img.shields.io/github/stars/KenanZhu/AutoLibrary.svg?style=social&label=Star)](https://github.com/KenanZhu/AutoLibrary)
![License](https://img.shields.io/github/license/KenanZhu/AutoLibrary?label=license)
@@ -25,7 +25,7 @@
6. 定时任务 - 使用内置定时任务管理,添加定时任务,指定时间后按当前预约信息自动运行,支持设置重复任务
7. 驱动管理 - 内置浏览器驱动自动管理,支持自动检测浏览器版本并下载对应驱动,无需手动下载
*具体操作方法和注意事项请访问我们的 [帮助手册](https://www.autolibrary.kenanzhu.com/manuals)*
*具体操作方法和注意事项请访问我们的 [帮助手册](https://manuals.autolibrary.kenanzhu.com)*
### 如何使用
+3
View File
@@ -0,0 +1,3 @@
This folder is used to store the manuals.
Our manuals are available at https://manuals.autolibrary.kenanzhu.com
-3
View File
@@ -1,3 +0,0 @@
This folder is used to store the manuals.
Our manuals are available at https://www.autolibrary.kenanzhu.com/manuals
BIN
View File
Binary file not shown.
+34
View File
@@ -0,0 +1,34 @@
attrs==26.1.0
certifi==2026.2.25
cffi==2.0.0
charset-normalizer==3.4.6
ddddocr==1.0.6
flatbuffers==25.12.19
h11==0.16.0
idna==3.11
lupa==2.8
numpy==2.4.3
onnxruntime==1.24.4
outcome==1.3.0.post0
packaging==26.0
pillow==12.1.1
protobuf==7.34.0
pybrowsers==1.3.2
pycparser==3.0
pyinstaller==6.19.0
PySide6==6.10.2
PySide6_Addons==6.10.2
PySide6_Essentials==6.10.2
PySocks==1.7.1
QtAwesome==1.4.2
QtPy==2.4.3
requests==2.32.5
selenium==4.38.0
shiboken6==6.10.2
sniffio==1.3.1
sortedcontainers==2.4.0
trio==0.33.0
trio-websocket==0.12.2
typing_extensions==4.15.0
urllib3==2.6.3
wsproto==1.3.2
+1 -2
View File
@@ -22,9 +22,8 @@ def main():
app = QApplication(sys.argv)
translator = QTranslator()
if translator.load(":/res/trans/translators/qtbase_zh_CN.ts"):
if translator.load(":/res/translators/qtbase_zh_CN.ts"):
app.installTranslator(translator)
app.setStyle('Fusion')
app.setApplicationName("AutoLibrary")
if not initializeApp():
sys.exit(-1)
+262
View File
@@ -0,0 +1,262 @@
# -*- 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 (
date,
datetime,
)
from lupa import LuaRuntime as _LuaRuntime
from autoscript._helpers import (
_TYPE_DEFAULT_VAR,
_assignPath,
_checkDateFormat,
_checkTimeFormat,
_checkType,
_cleanLuaError,
_navigatePath,
)
try:
from lupa.lua55 import LuaError as _LuaError, LuaSyntaxError as _LuaSyntaxError
except ImportError:
try:
from lupa.lua54 import LuaError as _LuaError, LuaSyntaxError as _LuaSyntaxError
except ImportError:
_LuaError = Exception
_LuaSyntaxError = Exception
__all__ = ["ASEngine"]
class ASEngine:
@staticmethod
def getCurrentDate(
) -> str:
return date.today().isoformat()
@staticmethod
def getCurrentTime(
) -> str:
return datetime.now().strftime("%H:%M")
@staticmethod
def _sandbox(
lua,
) -> None:
lua.execute("""
io = nil
require = nil
dofile = nil
loadfile = nil
load = nil
package = nil
rawget = nil
rawset = nil
rawequal = nil
getfenv = nil
setfenv = nil
debug = nil
if os then
os.execute = nil
os.exit = nil
os.getenv = nil
os.remove = nil
os.rename = nil
os.tmpname = nil
os.setlocale = nil
end
""")
@staticmethod
def _registerHelpers(
lua,
) -> None:
lua.execute("""
function date(y, m, d)
return os.time({year = y, month = m, day = d})
end
function time(h, m)
return h * 60 + m
end
function datenow()
local now = os.date("*t")
return os.time({year = now.year, month = now.month, day = now.day})
end
function timenow()
local now = os.date("*t")
return now.hour * 60 + now.min
end
function dateadd(date_val, n)
return date_val + n * 86400
end
function timeadd(time_val, n)
return (time_val + n * 60) % 1440
end
function strtodate(iso_str)
local y, m, d = iso_str:match("(%d+)-(%d+)-(%d+)")
return os.time({year = y, month = m, day = d})
end
function strtotime(hm_str)
local h, m = hm_str:match("(%d+):(%d+)")
return h * 60 + m
end
function datetostr(ts)
return os.date("%Y-%m-%d", ts)
end
function timetostr(m)
return string.format("%02d:%02d", math.floor(m / 60), m % 60)
end
""")
def __init__(
self,
targetVars: list[tuple] = None,
):
self._targetVars: dict[str, dict] = {}
self._lua = None
if targetVars:
for item in targetVars:
name, varType, keyPath = item[0], item[1], item[2]
self.addTargetVar(name, varType, keyPath)
def _getLua(
self,
):
if self._lua is None:
self._lua = _LuaRuntime(unpack_returned_tuples=True)
self._sandbox(self._lua)
self._registerHelpers(self._lua)
return self._lua
def _push(
self,
targetData: dict,
) -> None:
lua = self._getLua()
g = lua.globals()
strToDate = g["strtodate"]
strToTime = g["strtotime"]
for varName, info in self._targetVars.items():
keyPath = info["keyPath"]
vt = info["type"]
raw = _navigatePath(targetData, keyPath)
if vt == "Date":
if not isinstance(raw, str) or not raw.strip():
raise ValueError(
f"Date 类型变量 '{varName}' 对应的数据为空或不是字符串类型,"
f"请检查路径 {keyPath} 的值是否为合法的日期字符串 (YYYY-MM-DD)"
)
raw = raw.strip()
_checkDateFormat(raw, varName)
g[varName] = strToDate(raw)
elif vt == "Time":
if not isinstance(raw, str) or not raw.strip():
raise ValueError(
f"Time 类型变量 '{varName}' 对应的数据为空或不是字符串类型,"
f"请检查路径 {keyPath} 的值是否为合法的时间字符串 (HH:MM)"
)
raw = raw.strip()
_checkTimeFormat(raw, varName)
g[varName] = strToTime(raw)
else:
if raw is None:
raw = _TYPE_DEFAULT_VAR.get(vt, False)
g[varName] = raw
def _pull(
self,
targetData: dict,
) -> None:
lua = self._getLua()
g = lua.globals()
dateToStr = g["datetostr"]
timeToStr = g["timetostr"]
for varName, info in self._targetVars.items():
try:
luaVal = g[varName]
except KeyError:
continue
vt = info["type"]
if vt == "Date":
luaVal = dateToStr(luaVal)
elif vt == "Time":
luaVal = timeToStr(luaVal)
elif vt == "Float" and isinstance(luaVal, int) and not isinstance(luaVal, bool):
luaVal = float(luaVal)
_checkType(varName, vt, luaVal)
_assignPath(targetData, info["keyPath"], luaVal)
def addTargetVar(
self,
name: str,
varType: str,
keyPath: list,
) -> None:
upperName = name.upper().strip()
self._targetVars[upperName] = {
"type": varType,
"keyPath": keyPath,
}
def execute(
self,
scriptText: str,
targetData: dict,
) -> None:
if not scriptText or not scriptText.strip():
return
try:
self._push(targetData)
self._getLua().execute(scriptText)
self._pull(targetData)
except _LuaSyntaxError as e:
raise ValueError(
f"AutoScript 语法错误: {_cleanLuaError(str(e))}"
)
except _LuaError as e:
raise ValueError(
f"AutoScript 运行时错误: {_cleanLuaError(str(e))}"
)
except ValueError as e:
raise ValueError(f"AutoScript 数据错误: {e}")
except Exception as e:
raise ValueError(f"AutoScript 未知错误: {e}")
def reset(
self,
) -> None:
self._targetVars = {}
self._lua = None
+58
View File
@@ -0,0 +1,58 @@
# -*- 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 .ASEngine import ASEngine
__version__ = "1.0.0" # autoscript version
_TARGET_VAR_DEFS = [
("USERNAME", "String", ["username"], "用户名"),
("USER_ENABLE", "Boolean", ["enabled"], "用户启用"),
("RESERVE_DATE", "Date", ["reserve_info", "date"], "预约日期"),
("RESERVE_BEGIN_TIME", "Time", ["reserve_info", "begin_time", "time"], "预约开始时间"),
("RESERVE_END_TIME", "Time", ["reserve_info", "end_time", "time"], "预约结束时间"),
]
_MOCK_TYPE_VALUES = {
"String": "__mock__",
"Boolean": True,
"Date": "2099-01-01",
"Time": "00:00",
"Int": 0,
"Float": 0.0,
}
def createAllVariablesTable(
) -> dict:
return {
displayName: (name, varType)
for name, varType, _, displayName in _TARGET_VAR_DEFS
}
def createTargetVarDefs(
) -> list:
return list(_TARGET_VAR_DEFS)
def createMockTargetData(
) -> dict:
data = {}
for _, varType, keyPath, _ in _TARGET_VAR_DEFS:
d = data
for key in keyPath[:-1]:
d = d.setdefault(key, {})
d[keyPath[-1]] = _MOCK_TYPE_VALUES.get(varType, "")
return data
def createEngine(
) -> ASEngine:
return ASEngine(_TARGET_VAR_DEFS)
+153
View File
@@ -0,0 +1,153 @@
# -*- 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 (
date,
datetime,
)
_TYPE_DEFAULT_VAR: dict[str, str | int | float | bool] = {
"String": "",
"Int": 0,
"Float": 0.0,
"Boolean": False,
}
def _navigatePath(
data: dict,
keyPath: list,
default=None,
):
d = data
for key in keyPath[:-1]:
d = d.get(key, {})
if not isinstance(d, dict):
return default
return d.get(keyPath[-1], default)
def _assignPath(
data: dict,
keyPath: list,
value,
) -> None:
d = data
for key in keyPath[:-1]:
d = d.setdefault(key, {})
d[keyPath[-1]] = value
def _checkDateFormat(
dateStr: str,
varName: str = "",
) -> None:
prefix = f"Date 类型变量 '{varName}'" if varName else ""
try:
date.fromisoformat(dateStr)
except ValueError:
raise ValueError(
f"{prefix}'{dateStr}' 不是合法的日期格式,"
f"应为 YYYY-MM-DD"
)
def _checkTimeFormat(
timeStr: str,
varName: str = "",
) -> None:
prefix = f"Time 类型变量 '{varName}'" if varName else ""
try:
datetime.strptime(timeStr, "%H:%M")
except ValueError:
raise ValueError(
f"{prefix}'{timeStr}' 不是合法的时间格式,"
f"应为 HH:MM"
)
def _checkType(
varName: str,
varType: str,
value,
) -> None:
if varType == "Date":
if not isinstance(value, str):
raise ValueError(
f"Date 类型变量 '{varName}' 只能接受日期字符串,"
f"不能接受 {_pyTypeToASType(value)} 类型"
)
_checkDateFormat(value, varName)
return
if varType == "Time":
if not isinstance(value, str):
raise ValueError(
f"Time 类型变量 '{varName}' 只能接受时间字符串,"
f"不能接受 {_pyTypeToASType(value)} 类型"
)
_checkTimeFormat(value, varName)
return
if varType == "Int":
if isinstance(value, bool):
raise ValueError(
f"Int 类型变量 '{varName}' 不能接受 Boolean 类型的值"
)
if not isinstance(value, int) and not (isinstance(value, float) and value == int(value)):
raise ValueError(
f"Int 类型变量 '{varName}' 不能接受 {_pyTypeToASType(value)} 类型的值"
)
return
if varType == "Float":
if isinstance(value, bool):
raise ValueError(
f"Float 类型变量 '{varName}' 不能接受 Boolean 类型的值"
)
if not isinstance(value, (int, float)):
raise ValueError(
f"Float 类型变量 '{varName}' 不能接受 {_pyTypeToASType(value)} 类型的值"
)
return
if varType == "Boolean":
if not isinstance(value, bool):
raise ValueError(
f"Boolean 类型变量 '{varName}' 不能接受 {_pyTypeToASType(value)} 类型的值"
)
return
if varType == "String":
if not isinstance(value, str):
raise ValueError(
f"String 类型变量 '{varName}' 不能接受 {_pyTypeToASType(value)} 类型的值"
)
return
def _pyTypeToASType(
value,
) -> str:
if isinstance(value, bool):
return "Boolean"
if isinstance(value, int):
return "Int"
if isinstance(value, float):
return "Float"
if isinstance(value, str):
return "String"
return "Unknown"
def _cleanLuaError(
rawMsg: str,
) -> str:
msg = rawMsg.replace('[string "<python>"]:', "").strip()
stackIdx = msg.find("stack traceback:")
if stackIdx != -1:
msg = msg[:stackIdx].strip()
return msg
-37
View File
@@ -1,37 +0,0 @@
# -*- coding: utf-8 -*-
"""
Copyright (c) 2025 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 base.MsgBase import MsgBase
class LibOperator(MsgBase):
"""
Base abstract class for library operation.
This class provides the foundation for library-related operations, inheriting
message handling and tracing abilities from MsgBase. It serves as an abstract
base class that must be subclassed to implement specific library functionality.
"""
def __init__(
self,
input_queue: queue.Queue,
output_queue: queue.Queue
):
super().__init__(input_queue, output_queue)
def _waitResponseLoad(
self
) -> bool:
pass
-141
View File
@@ -1,141 +0,0 @@
# -*- 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 datetime import datetime
from base.LibOperator import LibOperator
class LibTimeSelector(LibOperator):
"""
Base class for time selection operations.
This class provides common time selection logic for reservation and renewal
operations, including time conversion utilities and best time option finding.
"""
def __init__(
self,
input_queue: queue.Queue,
output_queue: queue.Queue
):
super().__init__(input_queue, output_queue)
@staticmethod
def _timeStrToMins(
time_str: str
) -> int:
"""
Convert time string "HH:MM" to minutes since midnight.
Example:
"10:00" -> 600
"13:30" -> 810
"""
hour, minute = map(int, time_str.split(":"))
return hour*60 + minute
@staticmethod
def _minsToTimeStr(
mins: int
) -> str:
"""
Convert minutes since midnight to time string "HH:MM".
Example:
600 -> "10:00"
810 -> "13:30"
"""
hour, minute = divmod(int(mins), 60)
return f"{hour:02d}:{minute:02d}"
def _formatTimeRelation(
self,
abs_diff: int,
actual_diff: int,
time_type: str
) -> str:
"""
Format time difference relation string.
"""
if actual_diff < 0:
return f"早了 {abs_diff} 分钟"
elif actual_diff > 0:
return f"晚了 {abs_diff} 分钟"
else:
return f"正好等于 {time_type}"
def _findBestTimeOption(
self,
time_options: list,
target_time: int,
max_time_diff: int,
prefer_earlier: bool,
is_reserve: bool = True
) -> tuple:
"""
Find the best time option from available times.
Args:
time_options: List of WebElement time options
target_time: Target time in minutes
max_time_diff: Maximum acceptable time difference in minutes
prefer_earlier: If True, prefer earlier times when diffs are equal
is_reserve: If True, parse 'time' attribute; if False, parse 'id' attribute
Returns:
Tuple of (best_time_element, best_time_text, actual_diff, free_times_list)
or (None, None, None, []) if no suitable option found
"""
free_times = []
best_time_diff = max_time_diff
best_actual_diff = None
best_time_opt = None
for time_opt in time_options:
# Parse time value based on context
if is_reserve:
# Reservation context: parse 'time' attribute
time_attr = time_opt.get_attribute("time")
if time_attr == "now":
now = datetime.now()
time_val = now.hour*60 + now.minute
elif time_attr and time_attr.isdigit():
time_val = int(time_attr)
else:
continue
else:
# Renewal context: parse 'id' attribute
time_attr = time_opt.get_attribute("id")
if not (time_attr and time_attr.isdigit()):
continue
time_val = int(time_attr)
free_times.append(time_opt.text.strip() if not is_reserve else self._minsToTimeStr(time_val))
actual_diff = time_val - target_time
abs_diff = abs(actual_diff)
# Update best option if current is better
if (abs_diff < best_time_diff or
(abs_diff == best_time_diff and
((prefer_earlier and actual_diff <= 0) or
(not prefer_earlier and actual_diff >= 0)))):
best_time_diff = abs_diff
best_actual_diff = actual_diff
best_time_opt = time_opt
if best_time_opt is not None:
return (best_time_opt, best_time_opt.text.strip(), best_actual_diff, free_times)
return (None, None, None, free_times)
-4
View File
@@ -58,7 +58,6 @@ class MsgBase:
except RuntimeError:
self._logger = None
def _showMsg(
self,
msg: str
@@ -66,7 +65,6 @@ class MsgBase:
self._output_queue.put(f"[{self._class_name:<15}] >>> : {msg}")
def _showTrace(
self,
msg: str,
@@ -79,7 +77,6 @@ class MsgBase:
if self._logger and not no_log:
self._logger.log(level, msg)
def _showLog(
self,
msg: str,
@@ -89,7 +86,6 @@ class MsgBase:
if self._logger:
self._logger.log(level, msg)
def _waitMsg(
self,
timeout: float = 1.0
+7 -6
View File
@@ -1,8 +1,9 @@
# -*- coding: utf-8 -*-
"""
Base module for the AutoLibrary project.
Copyright (c) 2026 KenanZhu.
All rights reserved.
Here are the classes and modules in this package:
- MsgBase: Base class for messages.\
- LibOperator: Base class for library operators.
"""
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.
"""
+42 -7
View File
@@ -10,13 +10,19 @@ See the LICENSE file for details.
import os
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.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(
) -> bool:
app_dir = QStandardPaths.writableLocation(QStandardPaths.StandardLocation.AppDataLocation)
@@ -27,7 +33,7 @@ def initializeLogManager(
logInstance(log_dir)
return True
def initializeConfigManager(
def _initializeConfigManager(
) -> bool:
logger = logInstance().getLogger("AppInitializer")
@@ -49,7 +55,7 @@ def initializeConfigManager(
configInstance(new_config_dir)
return True
def initializeWebDriverManager(
def _initializeWebDriverManager(
) -> bool:
logger = logInstance().getLogger("AppInitializer")
@@ -64,13 +70,42 @@ def initializeWebDriverManager(
webdriverInstance(driver_dir)
return True
def _initializeAppearance(
):
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)
logger = logInstance().getLogger("AppInitializer")
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(
) -> bool:
"""
Initialize the application components
if not initializeLogManager():
Order:
LogManager -> ConfigManager -> WebDriverManager -> Appearance
"""
if not _initializeLogManager():
return False
if not initializeConfigManager():
if not _initializeConfigManager():
return False
if not initializeWebDriverManager():
if not _initializeWebDriverManager():
return False
_initializeAppearance()
return True
+7 -4
View File
@@ -1,6 +1,9 @@
# -*- coding: utf-8 -*-
"""
Boot module for the AutoLibrary project.
Copyright (c) 2026 KenanZhu.
All rights reserved.
Here are the classes and modules in this package:
- AppInitializer: Application initializer class.
"""
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.
"""
+86 -42
View File
@@ -9,18 +9,23 @@ See the LICENSE file for details.
"""
import platform
from PySide6.QtGui import (
QIcon, QFont
)
from PySide6.QtWidgets import (
QDialog, QApplication
)
from PySide6.QtCore import (
QTimer, Qt
Qt,
QTimer
)
from PySide6.QtGui import QIcon
from PySide6.QtWidgets import (
QApplication,
QDialog,
QTabWidget,
QTextBrowser
)
from gui.ALVersionInfo import (
AL_VERSION, AL_COMMIT_SHA, AL_COMMIT_DATE, AL_BUILD_DATE
AL_VERSION,
AL_COMMIT_SHA,
AL_COMMIT_DATE,
AL_BUILD_DATE
)
from gui.resources.ui.Ui_ALAboutDialog import Ui_ALAboutDialog
from gui.resources import ALResource
@@ -38,19 +43,28 @@ class ALAboutDialog(QDialog, Ui_ALAboutDialog):
self.modifyUi()
self.connectSignals()
def modifyUi(
self
):
self.LogoIconLabel.setPixmap(QIcon(":/res/icon/icons/AutoLibrary_32x32.ico").pixmap(48, 48))
info_text = self.generateAboutText()
self.AboutInfoBrowser.setHtml(info_text)
browser_font = self.AboutInfoBrowser.font()
browser_font.setFamily("Courier New")
self.AboutInfoBrowser.setFont(browser_font)
self.AboutInfoBrowser.setTextInteractionFlags(Qt.TextBrowserInteraction)
self.LogoIconLabel.setPixmap(QIcon(":/res/icons/AutoLibrary_Logo_64.svg").pixmap(48, 48))
self.TabWidget = QTabWidget()
self.TabWidget.setDocumentMode(True)
AboutBrowser = QTextBrowser()
AboutBrowser.setHtml(self.generateAboutText())
AboutBrowser.setOpenExternalLinks(True)
AboutBrowser.setLineWrapMode(QTextBrowser.LineWrapMode.NoWrap)
AboutBrowser.setTextInteractionFlags(Qt.TextBrowserInteraction)
BrowserFont = AboutBrowser.font()
BrowserFont.setFamilies(["Courier New", "Consolas", "Menlo", "DejaVu Sans Mono", "monospace"])
AboutBrowser.setFont(BrowserFont)
self.TabWidget.addTab(AboutBrowser, "关于")
LicenseBrowser = QTextBrowser()
LicenseBrowser.setHtml(self.generateLicenseText())
LicenseBrowser.setOpenExternalLinks(True)
LicenseBrowser.setTextInteractionFlags(Qt.TextBrowserInteraction)
self.TabWidget.addTab(LicenseBrowser, "许可证")
self.AboutInfoLayout.addWidget(self.TabWidget)
def connectSignals(
self
@@ -58,39 +72,61 @@ class ALAboutDialog(QDialog, Ui_ALAboutDialog):
self.CopyButton.clicked.connect(self.copyAboutInfo)
def generateAboutText(
self
) -> str:
os_info = self.getOSInfo()
run_on = f"{os_info['system']} {os_info['version']} {os_info['architecture']}"
selenium_ver = self.getSeleniumVersion()
about_text = f"""
<h4>Version Information:</h4>
Version: {AL_VERSION}<br>
<b style="font-size:14px;">VERSION: {AL_VERSION}</b><br>
Commit SHA: {AL_COMMIT_SHA}<br>
Commit date: {AL_COMMIT_DATE}<br>
Build date: {AL_BUILD_DATE}<br>
Python version: {platform.python_version()}<br>
Qt version: {self.getQtVersion()}<br>
<h4>System Information:</h4>
<br>
<b style="font-size:14px;">SYSTEM</b><br>
Running on: {run_on}<br>
Processor: {platform.processor()}<br>
Operating system: {os_info['system']}<br>
System version: {os_info['version']}<br>
System architecture: {os_info['architecture']}<br>
<h4>Project Information:</h4>
License: MIT License<br>
Project repository: <a href="https://www.github.com/KenanZhu/AutoLibrary" style="text-decoration: none;">https://www.github.com/KenanZhu/AutoLibrary</a><br>
Project website: <a href="https://www.autolibrary.kenanzhu.com" style="text-decoration: none;">https://www.autolibrary.kenanzhu.com</a><br>
<h4>Author Information:</h4>
Developer: KenanZhu<br>
Contact: nanoki_zh@163.com<br>
GitHub: <a href="https://www.github.com/KenanZhu" style="text-decoration: none;">https://www.github.com/KenanZhu</a><br>
<br>
<b style="font-size:14px;">DEPENDENCIES</b><br>
Python: {platform.python_version()}<br>
Qt(PySide6): {self.getQtVersion()}<br>
Selenium: {selenium_ver}<br>
<br>
<b style="font-size:14px;">PROJECT</b><br>
Website: <a href="https://www.autolibrary.kenanzhu.com" style="text-decoration:none;">https://www.autolibrary.kenanzhu.com</a><br>
Repository: <a href="https://www.github.com/KenanZhu/AutoLibrary" style="text-decoration:none;">https://www.github.com/KenanZhu/AutoLibrary</a><br>
<br>
<b style="font-size:14px;">AUTHOR</b><br>
Developer/Maintainer: KenanZhu<br>
Contact: <a href="mailto:nanoki_zh@163.com">nanoki_zh@163.com</a><br>
GitHub: <a href="https://www.github.com/KenanZhu" style="text-decoration:none;">https://www.github.com/KenanZhu</a><br>
"""
return about_text
def generateLicenseText(
self
) -> str:
return """
<b>MIT License</b>
<p>Copyright &copy; 2025 - 2026 KenanZhu</p>
<p>Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:</p>
<p>The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.</p>
<p>THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.</p>"""
def getOSInfo(
self
@@ -123,7 +159,6 @@ GitHub: <a href="https://www.github.com/KenanZhu" style="text-decoration: none;"
'architecture': architecture
}
def getQtVersion(
self
):
@@ -134,14 +169,23 @@ GitHub: <a href="https://www.github.com/KenanZhu" style="text-decoration: none;"
except:
return "Unknown"
def getSeleniumVersion(
self
):
try:
import selenium
return selenium.__version__
except Exception:
return "Unknown"
def copyAboutInfo(
self
):
about_text = self.AboutInfoBrowser.toPlainText()
clipboard = QApplication.clipboard()
clipboard.setText(about_text)
about_text = self.TabWidget.currentWidget().toPlainText()
Clipboard = QApplication.clipboard()
Clipboard.setText(about_text)
original_text = self.CopyButton.text()
self.CopyButton.setText("已复制")
QTimer.singleShot(2000, lambda: self.CopyButton.setText(original_text))
QTimer.singleShot(2000, lambda: self.CopyButton.setText(original_text))
+684
View File
@@ -0,0 +1,684 @@
# -*- 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 copy import deepcopy
import qtawesome as qta
from PySide6.QtCore import (
QDate,
QSize,
Qt,
QTime,
QTimer,
Slot
)
from PySide6.QtGui import (
QColor,
QFont,
QSyntaxHighlighter,
QTextCharFormat,
)
from PySide6.QtWidgets import (
QApplication,
QComboBox,
QDateEdit,
QDialog,
QDialogButtonBox,
QDoubleSpinBox,
QFormLayout,
QFrame,
QGridLayout,
QGroupBox,
QHBoxLayout,
QHeaderView,
QLabel,
QLineEdit,
QMessageBox,
QPlainTextEdit,
QPushButton,
QSpinBox,
QSplitter,
QStyle,
QTabWidget,
QTableWidget,
QTableWidgetItem,
QTimeEdit,
QVBoxLayout,
QWidget,
)
from autoscript import (
createAllVariablesTable,
createMockTargetData,
createTargetVarDefs,
createEngine,
)
class ALScriptHighlighter(QSyntaxHighlighter):
"""
Syntax highlighter for Lua-based AutoScript.
"""
def __init__(
self,
parent = None
):
super().__init__(parent)
self._rules = []
KeywordFmt = QTextCharFormat()
KeywordFmt.setForeground(QColor("#569CD6"))
KeywordFmt.setFontWeight(QFont.Weight.Bold)
for kw in [
"if", "elseif", "else", "end", "then",
"and", "or", "not",
"local", "function", "return", "nil",
]:
self._rules.append((r"\b" + kw + r"\b", KeywordFmt))
BoolFmt = QTextCharFormat()
BoolFmt.setForeground(QColor("#4FC1FF"))
BoolFmt.setFontWeight(QFont.Weight.Bold)
self._rules.append((r"\btrue\b", BoolFmt))
self._rules.append((r"\bfalse\b", BoolFmt))
CmpFmt = QTextCharFormat()
CmpFmt.setForeground(QColor("#C586C0"))
CmpFmt.setFontWeight(QFont.Weight.Normal)
for op in [r"==", r"~=", r">=", r"<=", r">", r"<"]:
self._rules.append((op, CmpFmt))
ArithFmt = QTextCharFormat()
ArithFmt.setForeground(QColor("#C586C0"))
ArithFmt.setFontWeight(QFont.Weight.Normal)
for op in [r"\+", r"-", r"\*", r"/", r"\.\."]:
self._rules.append((op, ArithFmt))
FuncFmt = QTextCharFormat()
FuncFmt.setForeground(QColor("#DCDCAA"))
FuncFmt.setFontWeight(QFont.Weight.Normal)
for fn in [ "time", "date", "datenow", "timenow", "dateadd", "timeadd"]:
self._rules.append((r"\b" + fn + r"\b", FuncFmt))
VarFmt = QTextCharFormat()
VarFmt.setForeground(QColor("#9CDCFE"))
VarFmt.setFontWeight(QFont.Weight.Normal)
var_names = [name for _, (name, _) in createAllVariablesTable().items()]
for var in var_names:
self._rules.append((r"\b" + var + r"\b", VarFmt))
StrFmt = QTextCharFormat()
StrFmt.setForeground(QColor("#CE9178"))
StrFmt.setFontWeight(QFont.Weight.Normal)
self._rules.append((r'"[^"]*"', StrFmt))
self._rules.append((r"'[^']*'", StrFmt))
NumFmt = QTextCharFormat()
NumFmt.setForeground(QColor("#B5CEA8"))
NumFmt.setFontWeight(QFont.Weight.Normal)
self._rules.append((r"\b\d+(?:\.\d+)?\b", NumFmt))
CommentFmt = QTextCharFormat()
CommentFmt.setForeground(QColor("#6A9955"))
CommentFmt.setFontItalic(True)
self._rules.append((r"--[^\n]*", CommentFmt))
def highlightBlock(
self,
text
):
import re
for pattern, fmt in self._rules:
for match in re.finditer(pattern, text, re.IGNORECASE):
start = match.start()
length = match.end() - match.start()
self.setFormat(start, length, fmt)
class _DebugResultDialog(QDialog):
def __init__(
self,
changes: list,
parent = None
):
super().__init__(parent)
self.setWindowTitle("调试运行结果 - AutoLibrary")
self.setMinimumSize(600, 200)
DbgLayout = QVBoxLayout(self)
DbgTable = QTableWidget(len(changes), 3)
DbgTable.setHorizontalHeaderLabels(["目标变量", "原始数据", "运行后数据"])
DbgTable.horizontalHeader().setStretchLastSection(True)
DbgTable.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch)
DbgTable.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers)
for row, (display_name, name, var_type, before_val, after_val) in enumerate(changes):
label = f"{display_name}: {name}({var_type})"
DbgTable.setItem(row, 0, QTableWidgetItem(label))
DbgTable.setItem(row, 1, QTableWidgetItem(str(before_val)))
DbgTable.setItem(row, 2, QTableWidgetItem(str(after_val)))
DbgLayout.addWidget(DbgTable)
DbgBtnBox = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok)
DbgBtnBox.button(QDialogButtonBox.StandardButton.Ok).setText("确定")
DbgBtnBox.accepted.connect(self.accept)
DbgLayout.addWidget(DbgBtnBox)
class _TabToSpacesEditor(QPlainTextEdit):
def keyPressEvent(
self,
event
):
if event.key() == Qt.Key.Key_Tab:
self.insertPlainText(" ")
return
super().keyPressEvent(event)
class ALAutoScriptEditDialog(QDialog):
def __init__(
self,
parent = None,
script: str = "",
mockData: dict = None
):
super().__init__(parent)
self._fontSize = 21
self._mockWidgets = {}
self.setupUi()
self.connectSignals()
self.TextEdit.setPlainText(script)
self._Highlighter = ALScriptHighlighter(
self.TextEdit.document()
)
if mockData:
self.setMockData(mockData)
def setupUi(
self
):
self.setWindowTitle("AutoScript 编辑 - AutoLibrary")
self.setMinimumSize(660, 600)
Layout = QVBoxLayout(self)
Layout.setSpacing(3)
Layout.setContentsMargins(3, 3, 3, 3)
ToolbarLayout = QHBoxLayout()
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.ZoomOutBtn = QPushButton("")
self.ZoomOutBtn.setIcon(qta.icon("fa6s.minus", color=self._iconColor()))
self.ZoomOutBtn.setIconSize(QSize(14, 14))
self.ZoomOutBtn.setFixedSize(25, 25)
self.ZoomResetBtn = QPushButton("")
self.ZoomResetBtn.setIcon(qta.icon("fa6s.rotate-left", color=self._iconColor()))
self.ZoomResetBtn.setIconSize(QSize(14, 14))
self.ZoomResetBtn.setFixedSize(25, 25)
self.ZoomResetBtn.setToolTip("重置缩放")
self.ZoomLabel = QLabel(f"{self._fontSize}px")
self.ZoomLabel.setFixedHeight(25)
self.OrchBtn = QPushButton("编排")
self.OrchBtn.setFixedSize(80, 25)
self.OrchBtn.setToolTip("可视化生成 AutoScript 代码并插入到光标位置")
ToolbarLayout.addWidget(self.OrchBtn)
self.DebugBtn = QPushButton("▶ 调试运行")
self.DebugBtn.setFixedSize(80, 25)
self.DebugBtn.setToolTip("使用右侧模拟数据执行脚本,查看目标变量变化")
ToolbarLayout.addWidget(self.DebugBtn)
Sep = QFrame()
Sep.setFrameShape(QFrame.Shape.VLine)
Sep.setFrameShadow(QFrame.Shadow.Sunken)
Sep.setFixedWidth(1)
ToolbarLayout.addWidget(Sep)
ToolbarLayout.addWidget(self.ZoomInBtn)
ToolbarLayout.addWidget(self.ZoomOutBtn)
ToolbarLayout.addWidget(self.ZoomResetBtn)
ToolbarLayout.addWidget(self.ZoomLabel)
ToolbarLayout.addStretch()
self.CopyBtn = QPushButton("")
self.CopyBtn.setIcon(qta.icon("fa6s.copy", color=self._iconColor()))
self.CopyBtn.setIconSize(QSize(14, 14))
self.CopyBtn.setFixedSize(25, 25)
self.CopyBtn.setToolTip("复制脚本")
ToolbarLayout.addWidget(self.CopyBtn)
Layout.addLayout(ToolbarLayout)
self.TextEdit = _TabToSpacesEditor(self)
self.TextEdit.setTabStopDistance(40)
self.TextEdit.setLineWrapMode(
QPlainTextEdit.LineWrapMode.NoWrap
)
self.TextEdit.setStyleSheet(
"QPlainTextEdit {"
" font-family: 'Courier New', 'Consolas', monospace;"
f" font-size: {self._fontSize}px;"
"}"
)
Layout.addWidget(self.TextEdit)
self.createButtonPanel(Layout)
self.BtnBox = QDialogButtonBox(
QDialogButtonBox.StandardButton.Ok |
QDialogButtonBox.StandardButton.Cancel
)
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).setFixedSize(80, 25)
Layout.addWidget(self.BtnBox)
def createButtonPanel(
self,
ParentLayout
):
Splitter = QSplitter(Qt.Orientation.Horizontal)
TabWidget = QTabWidget()
TabWidget.setMaximumHeight(150)
BasicWidget = QWidget()
BasicLayout = QGridLayout(BasicWidget)
BasicLayout.setSpacing(4)
BasicLayout.setContentsMargins(4, 4, 4, 4)
BasicLayout.setAlignment(Qt.AlignLeft | Qt.AlignTop)
controlButtons = [
("如果 (if...)", "if then\n \nend"),
("再如果 (elseif...)", "elseif then\n "),
("否则 (else)", "else"),
("结束 (end)", "end"),
("跳过 (pass)", "-- pass"),
]
self.addButtonsToGrid(BasicLayout, controlButtons, 0, 0, 3)
assignButtons = [
("赋值 (=)", " = "),
]
self.addButtonsToGrid(BasicLayout, assignButtons, 1, 2, 3)
TabWidget.addTab(BasicWidget, "基本语法")
OperatorWidget = QWidget()
OperatorLayout = QGridLayout(OperatorWidget)
OperatorLayout.setSpacing(4)
OperatorLayout.setContentsMargins(4, 4, 4, 4)
OperatorLayout.setAlignment(Qt.AlignLeft | Qt.AlignTop)
arithmeticButtons = [
("加 (+)", " + "),
("减 (-)", " - "),
]
self.addButtonsToGrid(OperatorLayout, arithmeticButtons, 0, 0, 3)
compareButtons = [
("等于 (==)", " == "),
("不等于 (~=)", " ~= "),
("大于 (>)", " > "),
("小于 (<)", " < "),
("大于等于 (>=)", " >= "),
("小于等于 (<=)", " <= "),
]
self.addButtonsToGrid(OperatorLayout, compareButtons, 1, 0, 3)
logic_buttons = [
("且 (and)", " and "),
("或 (or)", " or "),
]
self.addButtonsToGrid(OperatorLayout, logic_buttons, 2, 0, 3)
TabWidget.addTab(OperatorWidget, "运算符")
LiteralWidget = QWidget()
LiteralLayout = QGridLayout(LiteralWidget)
LiteralLayout.setSpacing(4)
LiteralLayout.setContentsMargins(4, 4, 4, 4)
LiteralLayout.setAlignment(Qt.AlignLeft | Qt.AlignTop)
bool_buttons = [
("真 (true)", "true"),
("假 (false)", "false"),
]
self.addButtonsToGrid(LiteralLayout, bool_buttons, 0, 0, 3)
dateTimeButtons = [
("日期", 'date(2026, 1, 1)'),
("时间", 'time(0, 0)'),
]
self.addButtonsToGrid(LiteralLayout, dateTimeButtons, 1, 0, 3)
hintButtons = [
("字符串", '"请输入文本"'),
("数字", "123"),
("注释", "-- 请输入注释"),
]
self.addButtonsToGrid(LiteralLayout, hintButtons, 2, 0, 3)
TabWidget.addTab(LiteralWidget, "字面量")
VarWidget = QWidget()
VarLayout = QGridLayout(VarWidget)
VarLayout.setSpacing(4)
VarLayout.setContentsMargins(4, 4, 4, 4)
VarLayout.setAlignment(Qt.AlignLeft | Qt.AlignTop)
varButtons = [
(display_name, name) for display_name, (name, _) in createAllVariablesTable().items()
]
self.addButtonsToGrid(VarLayout, varButtons, 0, 0, 3)
TabWidget.addTab(VarWidget, "变量")
FuncWidget = QWidget()
FuncLayout = QGridLayout(FuncWidget)
FuncLayout.setSpacing(4)
FuncLayout.setContentsMargins(4, 4, 4, 4)
FuncLayout.setAlignment(Qt.AlignLeft | Qt.AlignTop)
funcButtons = [
("datenow()", "datenow()", "返回当前日期的 Unix 时间戳"),
("timenow()", "timenow()", "返回当前时间在一天中的分钟数"),
("dateadd(day, n)", "dateadd(, )", "日期偏移: dateadd(日期时间戳, 天数)"),
("timeadd(time, n)", "timeadd(, )", "时间偏移: timeadd(分钟数, 分钟数)"),
]
for i, (text, template, tooltip) in enumerate(funcButtons):
Btn = QPushButton(text)
Btn.setProperty("template", template)
Btn.clicked.connect(self.insertTemplate)
Btn.setFixedWidth(100)
Btn.setFixedHeight(25)
Btn.setToolTip(tooltip)
FuncLayout.addWidget(Btn, i // 2, i % 2)
TabWidget.addTab(FuncWidget, "工具函数")
MockPanel = self.createMockPanel()
MockPanel.setMinimumWidth(260)
Splitter.addWidget(TabWidget)
Splitter.addWidget(MockPanel)
Splitter.setStretchFactor(0, 1)
Splitter.setStretchFactor(1, 1)
Splitter.setSizes([530, 530])
ParentLayout.addWidget(Splitter)
def addButtonsToGrid(
self,
grid_layout,
buttons,
start_row,
start_col,
max_columns
):
col = start_col
row = start_row
for btn_text, template in buttons:
Btn = QPushButton(btn_text)
Btn.setProperty("template", template)
Btn.clicked.connect(self.insertTemplate)
Btn.setFixedWidth(100)
Btn.setFixedHeight(25)
Btn.setToolTip(f"插入: {template}")
grid_layout.addWidget(Btn, row, col)
col += 1
if col >= start_col + max_columns:
col = start_col
row += 1
def createMockPanel(
self
) -> QGroupBox:
Group = QGroupBox("模拟目标数据")
Form = QFormLayout(Group)
Form.setSpacing(4)
Form.setContentsMargins(5, 10, 5, 5)
self._mockWidgets = {}
mockData = createMockTargetData()
for name, var_type, key_path, display_name in createTargetVarDefs():
d = mockData
for key in key_path:
d = d[key]
default = d
Widget = self.makeMockInput(var_type, default)
Label = QLabel(f"{display_name}: {name}({var_type})")
Form.addRow(Label, Widget)
self._mockWidgets[name] = (Widget, var_type, key_path)
return Group
def makeMockInput(
self,
var_type: str,
default
) -> QWidget:
if var_type == "String":
W = QLineEdit()
W.setText(str(default))
return W
if var_type == "Boolean":
W = QComboBox()
W.addItems(["", ""])
W.setCurrentIndex(0 if default else 1)
return W
if var_type == "Date":
W = QDateEdit()
W.setCalendarPopup(True)
W.setDisplayFormat("yyyy-MM-dd")
W.setDate(QDate.fromString(str(default), "yyyy-MM-dd"))
return W
if var_type == "Time":
W = QTimeEdit()
W.setDisplayFormat("HH:mm")
W.setTime(QTime.fromString(str(default), "HH:mm"))
return W
if var_type == "Int":
W = QSpinBox()
W.setMinimum(-999999)
W.setMaximum(999999)
W.setValue(int(default) if default else 0)
return W
if var_type == "Float":
W = QDoubleSpinBox()
W.setMinimum(-999999.0)
W.setMaximum(999999.0)
W.setDecimals(2)
W.setValue(float(default) if default else 0.0)
return W
W = QLineEdit()
W.setText(str(default))
return W
def getMockData(
self
) -> dict:
data = {}
for name, var_type, key_path, display_name in createTargetVarDefs():
widget, _, _ = self._mockWidgets[name]
value = self.getMockValue(widget, var_type)
d = data
for key in key_path[:-1]:
d = d.setdefault(key, {})
d[key_path[-1]] = value
return data
def setMockData(
self,
data: dict
):
if not data:
return
for name, var_type, key_path, display_name in createTargetVarDefs():
d = data
try:
for key in key_path:
d = d[key]
except (KeyError, TypeError):
continue
widget, _, _ = self._mockWidgets[name]
self.setMockValue(widget, var_type, d)
def getMockValue(
self,
widget: QWidget,
var_type: str
):
if var_type == "Boolean":
return widget.currentIndex() == 0
if var_type == "Date":
return widget.date().toString("yyyy-MM-dd")
if var_type == "Time":
return widget.time().toString("HH:mm")
if var_type == "Int":
return widget.value()
if var_type == "Float":
return widget.value()
return widget.text()
def setMockValue(
self,
widget: QWidget,
var_type: str,
value
):
if var_type == "Boolean":
widget.setCurrentIndex(0 if value else 1)
elif var_type == "Date":
widget.setDate(QDate.fromString(str(value), "yyyy-MM-dd"))
elif var_type == "Time":
widget.setTime(QTime.fromString(str(value), "HH:mm"))
elif var_type == "Int":
widget.setValue(int(value))
elif var_type == "Float":
widget.setValue(float(value))
else:
widget.setText(str(value))
def _iconColor(
self
) -> str:
return QApplication.instance().palette().color(
QApplication.instance().palette().ColorRole.WindowText
).name()
def connectSignals(
self
):
self.BtnBox.accepted.connect(self.accept)
self.BtnBox.rejected.connect(self.reject)
self.OrchBtn.clicked.connect(self.onOpenOrchDialog)
self.DebugBtn.clicked.connect(self.onDebugRun)
self.ZoomInBtn.clicked.connect(self.onZoomIn)
self.ZoomOutBtn.clicked.connect(self.onZoomOut)
self.ZoomResetBtn.clicked.connect(self.onZoomReset)
self.CopyBtn.clicked.connect(self.onCopy)
def getScript(
self
) -> str:
return self.TextEdit.toPlainText()
def updateFontSize(
self
):
self.TextEdit.setStyleSheet(
"QPlainTextEdit {"
" font-family: 'Courier New', 'Consolas', monospace;"
f" font-size: {self._fontSize}px;"
"}"
)
self.ZoomLabel.setText(f"{self._fontSize}px")
@Slot()
def insertTemplate(
self
):
Btn = self.sender()
if not isinstance(Btn, QPushButton):
return
template = Btn.property("template")
if not template:
return
cursor = self.TextEdit.textCursor()
cursor.insertText(template)
@Slot()
def onZoomIn(
self
):
self._fontSize = min(self._fontSize + 2, 40)
self.updateFontSize()
@Slot()
def onZoomOut(
self
):
self._fontSize = max(self._fontSize - 2, 8)
self.updateFontSize()
@Slot()
def onZoomReset(
self
):
self._fontSize = 21
self.updateFontSize()
@Slot()
def onCopy(
self
):
Clipboard = QApplication.clipboard()
Clipboard.setText(self.TextEdit.toPlainText())
self.CopyBtn.setEnabled(False)
QTimer.singleShot(2000, lambda: (
self.CopyBtn.setEnabled(True)
))
@Slot()
def onOpenOrchDialog(
self
):
from gui.ALAutoScriptOrchDialog import ALAutoScriptOrchDialog
Dlg = ALAutoScriptOrchDialog(self)
if Dlg.exec() == QDialog.DialogCode.Accepted:
script = Dlg.getScript()
if script:
cursor = self.TextEdit.textCursor()
cursor.insertText(script)
Dlg.deleteLater()
@Slot()
def onDebugRun(
self
):
script = self.TextEdit.toPlainText().strip()
if not script:
QMessageBox.warning(self, "提示", "脚本内容为空。")
return
target_data = self.getMockData()
before = deepcopy(target_data)
try:
engine = createEngine()
engine.execute(script, target_data)
except ValueError as e:
QMessageBox.warning(self, "运行错误", str(e))
return
changes = []
for name, var_type, key_path, display_name in createTargetVarDefs():
before_val = before
after_val = target_data
try:
for key in key_path:
before_val = before_val[key]
after_val = after_val[key]
except (KeyError, TypeError):
continue
if before_val != after_val:
changes.append((display_name, name, var_type, before_val, after_val))
if not changes:
QMessageBox.information(self, "调试运行", "目标变量未发生变化。")
return
Dlg = _DebugResultDialog(changes, self)
Dlg.exec()
Dlg.deleteLater()
-884
View File
@@ -1,884 +0,0 @@
# -*- 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 QTime, QDate, Slot
from PySide6.QtWidgets import (
QDialog, QWidget, QVBoxLayout, QHBoxLayout, QLabel,
QComboBox, QPushButton, QScrollArea, QTimeEdit,
QDateEdit, QLineEdit, QSpinBox, QDoubleSpinBox,
QStackedWidget, QFrame, QDialogButtonBox,
QGroupBox, QSizePolicy
)
from utils.AutoScriptEngine import AutoScriptEngine
VARIABLE_META = AutoScriptEngine.VARIABLE_META
_VAR_COMBO_ITEMS = [
(display, varname, vartype)
for display, (varname, vartype) in VARIABLE_META.items()
]
_VAR_COMBO_ITEMS_SET = [
(display, varname, vartype)
for display, (varname, vartype) in VARIABLE_META.items()
if not varname.startswith("CURRENT_")
]
OP_ITEMS = [
("等于", ".EQ."),
("不等于", ".NEQ."),
("大于", ".BGT."),
("小于", ".BLT."),
("大于等于", ".BGE."),
("小于等于", ".BLE."),
]
def _makeVarCombo(
) -> QComboBox:
cb = QComboBox()
for display, varname, vartype in _VAR_COMBO_ITEMS:
cb.addItem(display, (varname, vartype))
cb.setMinimumWidth(120)
cb.setFixedHeight(25)
return cb
def _makeSetVarCombo(
) -> QComboBox:
cb = QComboBox()
for display, varname, vartype in _VAR_COMBO_ITEMS_SET:
cb.addItem(display, (varname, vartype))
cb.setMinimumWidth(120)
cb.setFixedHeight(25)
return cb
def _makeOpCombo(
) -> QComboBox:
cb = QComboBox()
for display, op in OP_ITEMS:
cb.addItem(display, op)
cb.setMinimumWidth(80)
cb.setFixedHeight(25)
return cb
def _makeValueWidget(
data_type: str
) -> QWidget:
if data_type == "Time":
w = QTimeEdit()
w.setDisplayFormat("HH:mm")
w.setMinimumWidth(100)
w.setFixedHeight(25)
elif data_type == "Date":
w = QDateEdit()
w.setDisplayFormat("yyyy-MM-dd")
w.setCalendarPopup(True)
w.setMinimumWidth(130)
w.setFixedHeight(25)
elif data_type == "Integer":
w = QSpinBox()
w.setMinimum(-999999)
w.setMaximum(999999)
w.setMinimumWidth(100)
w.setFixedHeight(25)
elif data_type == "Float":
w = QDoubleSpinBox()
w.setMinimum(-999999)
w.setMaximum(999999)
w.setDecimals(2)
w.setMinimumWidth(100)
w.setFixedHeight(25)
elif data_type == "Boolean":
w = QComboBox()
w.addItem(".TRUE.", ".TRUE.")
w.addItem(".FALSE.", ".FALSE.")
w.setMinimumWidth(100)
w.setFixedHeight(25)
else:
w = QLineEdit()
w.setPlaceholderText("输入值")
w.setMinimumWidth(120)
w.setFixedHeight(25)
return w
def _makeActionValueWidget(
data_type: str
) -> QWidget:
if data_type == "Date":
w = QComboBox()
w.addItem("今天", "today")
w.addItem("明天", "tomorrow")
w.setFixedHeight(25)
w.setMinimumWidth(100)
w._is_date_action = True
return w
if data_type == "Time":
container = QWidget()
layout = QHBoxLayout(container)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(2)
modeCombo = QComboBox()
modeCombo.addItem("固定时间", "fixed")
modeCombo.addItem("相对当前", "relative")
modeCombo.setFixedHeight(25)
stack = QStackedWidget()
timeEdit = QTimeEdit()
timeEdit.setDisplayFormat("HH:mm")
timeEdit.setFixedHeight(25)
spinBox = QSpinBox()
spinBox.setRange(0, 23)
spinBox.setSuffix("小时")
spinBox.setFixedHeight(25)
stack.addWidget(timeEdit)
stack.addWidget(spinBox)
modeCombo.currentIndexChanged.connect(
lambda i: stack.setCurrentIndex(i)
)
layout.addWidget(modeCombo)
layout.addWidget(stack)
container._modeCombo = modeCombo
container._timeEdit = timeEdit
container._spinBox = spinBox
container._isActionTime = True
return container
return _makeValueWidget(data_type)
def _getValueFromWidget(
w: QWidget
) -> str:
if getattr(w, '_isActionTime', False):
if w._modeCombo.currentData() == "fixed":
return w._timeEdit.time().toString("HH:mm")
else:
return f"+{w._spinBox.value()}"
if isinstance(w, QTimeEdit):
return w.time().toString("HH:mm")
if isinstance(w, QDateEdit):
return w.date().toString("yyyy-MM-dd")
if isinstance(w, QComboBox):
return w.currentText()
if isinstance(w, QSpinBox):
return str(w.value())
if isinstance(w, QDoubleSpinBox):
return str(w.value())
if isinstance(w, QLineEdit):
return w.text()
return ""
def _encodeValueStr(
raw_value: str,
data_type: str
) -> str:
if data_type == "Time":
if raw_value.startswith("+"):
return raw_value
return f"TIME({raw_value})"
elif data_type == "Date":
if raw_value == "今天":
return "CURRENT_DATE"
elif raw_value == "明天":
return "CURRENT_DATE + 1"
return f"DATE({raw_value})"
elif data_type == "Boolean":
return raw_value
elif data_type == "String":
escaped = raw_value.replace("'", "''")
return f"'{escaped}'"
else:
return raw_value
def _setWidgetValue(
w: QWidget,
vartype: str,
expr: str
):
import re
s = expr.strip()
if getattr(w, '_isActionTime', False):
timeMatch = re.match(r"TIME\((\d{1,2}:\d{2})\)", s, re.IGNORECASE)
if timeMatch:
w._modeCombo.setCurrentIndex(0)
parts = timeMatch.group(1).split(":")
w._timeEdit.setTime(QTime(int(parts[0]), int(parts[1])))
return
relMatch = re.match(r"^\+(\d+)$", s)
if relMatch:
w._modeCombo.setCurrentIndex(1)
w._spinBox.setValue(int(relMatch.group(1)))
return
return
if getattr(w, '_is_date_action', False) and isinstance(w, QComboBox):
if s.upper() in ("CURRENT_DATE", "TODAY"):
w.setCurrentIndex(0)
elif s.upper() in ("CURRENT_DATE + 1", "TOMORROW"):
w.setCurrentIndex(1)
else:
dateMatch = re.match(
r"DATE\((\d{4}-\d{2}-\d{2})\)", s, re.IGNORECASE
)
if dateMatch:
from datetime import datetime, timedelta
dateStr = dateMatch.group(1)
today = datetime.now().strftime("%Y-%m-%d")
tomorrow = (
datetime.now() + timedelta(days=1)
).strftime("%Y-%m-%d")
if dateStr == today:
w.setCurrentIndex(0)
elif dateStr == tomorrow:
w.setCurrentIndex(1)
return
if vartype == "Time":
m = re.match(r"TIME\((\d{1,2}:\d{2})\)", s, re.IGNORECASE)
if m and isinstance(w, QTimeEdit):
parts = m.group(1).split(":")
w.setTime(QTime(int(parts[0]), int(parts[1])))
elif vartype == "Date":
m = re.match(r"DATE\((\d{4}-\d{2}-\d{2})\)", s, re.IGNORECASE)
if m and isinstance(w, QDateEdit):
parts = m.group(1).split("-")
w.setDate(QDate(int(parts[0]), int(parts[1]), int(parts[2])))
elif vartype == "Boolean" and isinstance(w, QComboBox):
for i in range(w.count()):
if w.itemData(i) == s.upper():
w.setCurrentIndex(i)
break
elif vartype == "Integer" and isinstance(w, QSpinBox):
try:
w.setValue(int(s))
except ValueError:
pass
elif vartype == "Float" and isinstance(w, QDoubleSpinBox):
try:
w.setValue(float(s))
except ValueError:
pass
elif isinstance(w, QLineEdit):
inner = s
if (inner.startswith("'") and inner.endswith("'")) or \
(inner.startswith('"') and inner.endswith('"')):
inner = inner[1:-1].replace("''", "'")
w.setText(inner)
class ActionStepFrame(QFrame):
def __init__(
self,
parent=None
):
super().__init__(parent)
self.setupUi()
self.connectSignals()
self.onTargetChanged(0)
def setupUi(
self
):
self.setFrameShape(QFrame.Shape.StyledPanel)
self.setFrameShadow(QFrame.Shadow.Raised)
self.setFixedHeight(35)
layout = QHBoxLayout(self)
layout.setContentsMargins(2, 2, 2, 2)
layout.setSpacing(4)
self.targetCombo = _makeSetVarCombo()
self.valueWidgetStack = QStackedWidget()
self.valueWidgetStack.setFixedHeight(25)
self.initValueStack()
setLabel = QLabel("设置")
setLabel.setFixedHeight(25)
layout.addWidget(setLabel)
layout.addWidget(self.targetCombo)
toLabel = QLabel("")
toLabel.setFixedHeight(25)
layout.addWidget(toLabel)
layout.addWidget(self.valueWidgetStack)
self.deleteBtn = QPushButton("×")
self.deleteBtn.setFixedSize(24, 25)
self.deleteBtn.setStyleSheet("color: red; font-weight: bold;")
layout.addWidget(self.deleteBtn)
def connectSignals(
self
):
self.targetCombo.currentIndexChanged.connect(self.onTargetChanged)
def initValueStack(
self
):
self._valueWidgets = {}
for _, _, vartype in _VAR_COMBO_ITEMS:
if vartype not in self._valueWidgets:
w = _makeActionValueWidget(vartype)
self._valueWidgets[vartype] = w
self.valueWidgetStack.addWidget(w)
self.valueWidgetStack.setCurrentWidget(
self._valueWidgets.get("String", self.valueWidgetStack.widget(0))
)
def getTarget(
self
) -> str:
data = self.targetCombo.currentData()
return data[0] if data else ""
def getTargetType(
self
) -> str:
data = self.targetCombo.currentData()
return data[1] if data else "String"
def getValueRaw(
self
) -> str:
currentType = self.getTargetType()
w = self._valueWidgets.get(currentType)
if w:
return _getValueFromWidget(w)
return ""
def toScriptLine(
self
) -> str:
target = self.getTarget()
if not target:
return ""
rawVal = self.getValueRaw()
targetType = self.getTargetType()
encoded = _encodeValueStr(rawVal, targetType)
if targetType == "Time" and rawVal.startswith("+"):
hours = rawVal[1:]
return f" {target} .ADD. {hours}"
return f" SET {target} = {encoded}"
def loadFromScript(
self,
targetVar: str,
valueExpr: str
):
for idx in range(self.targetCombo.count()):
data = self.targetCombo.itemData(idx)
if data and data[0] == targetVar:
self.targetCombo.setCurrentIndex(idx)
break
self.setValueFromExpr(valueExpr)
def setValueFromExpr(
self,
expr: str
):
targetType = self.getTargetType()
w = self._valueWidgets.get(targetType)
if not w:
return
_setWidgetValue(w, targetType, expr)
@Slot(int)
def onTargetChanged(
self,
idx
):
if idx < 0:
return
data = self.targetCombo.itemData(idx)
if data:
_, vartype = data
w = self._valueWidgets.get(vartype)
if w:
self.valueWidgetStack.setCurrentWidget(w)
class ConditionalBlock(QGroupBox):
def __init__(
self,
blockIndex: int,
parent=None
):
super().__init__(parent)
self.blockIndex = blockIndex
self._actionWidgets = []
self.setupUi()
self.connectSignals()
self.onOperandChanged(0)
def setupUi(
self
):
self.setStyleSheet(
"QGroupBox { font-weight: bold; border: 1px solid #ccc; "
"margin-top: 5px; padding-top: 5px; }"
)
self.setSizePolicy(
QSizePolicy.Policy.Preferred,
QSizePolicy.Policy.Fixed
)
mainLayout = QVBoxLayout(self)
mainLayout.setSpacing(4)
mainLayout.setContentsMargins(5, 5, 5, 5)
headerLayout = QHBoxLayout()
self.typeCombo = QComboBox()
self.typeCombo.addItem("IF", "IF")
self.typeCombo.addItem("ELSE IF", "ELSE IF")
self.typeCombo.addItem("ELSE", "ELSE")
if self.blockIndex == 0:
self.typeCombo.setEnabled(False)
typeLabel = QLabel("类型:")
typeLabel.setFixedHeight(25)
headerLayout.addWidget(typeLabel)
headerLayout.addWidget(self.typeCombo)
headerLayout.addStretch()
self.deleteBlockBtn = QPushButton("删除此块")
self.deleteBlockBtn.setStyleSheet("color: red;")
self.deleteBlockBtn.setFixedHeight(25)
headerLayout.addWidget(self.deleteBlockBtn)
mainLayout.addLayout(headerLayout)
self.conditionWidget = QWidget()
self.conditionWidget.setFixedHeight(60)
condLayout = QHBoxLayout(self.conditionWidget)
condLayout.setContentsMargins(0, 0, 0, 0)
ifLabel = QLabel("如果")
ifLabel.setFixedHeight(25)
condLayout.addWidget(ifLabel)
self.operandCombo = _makeVarCombo()
condLayout.addWidget(self.operandCombo)
self.opCombo = _makeOpCombo()
condLayout.addWidget(self.opCombo)
self.condValueStack = QStackedWidget()
self.condValueStack.setFixedHeight(25)
self._condValueWidgets = {}
for vartype in ["Time", "Date", "String", "Integer", "Float", "Boolean"]:
w = _makeValueWidget(vartype)
self._condValueWidgets[vartype] = w
self.condValueStack.addWidget(w)
self.condValueStack.setCurrentWidget(self._condValueWidgets.get("String"))
condLayout.addWidget(self.condValueStack)
mainLayout.addWidget(self.conditionWidget)
self.actionLabel = QLabel("执行步骤:")
self.actionLabel.setFixedHeight(25)
mainLayout.addWidget(self.actionLabel)
self.actionsLayout = QVBoxLayout()
self.actionsLayout.setSpacing(2)
mainLayout.addLayout(self.actionsLayout)
self.addActionBtn = QPushButton("+ 添加执行步骤")
self.addActionBtn.setFixedHeight(25)
mainLayout.addWidget(self.addActionBtn)
def connectSignals(
self
):
self.operandCombo.currentIndexChanged.connect(self.onOperandChanged)
self.typeCombo.currentIndexChanged.connect(self.onTypeChanged)
self.addActionBtn.clicked.connect(self.addActionStep)
def removeActionStep(
self,
step: ActionStepFrame
):
if step in self._actionWidgets:
self._actionWidgets.remove(step)
self.actionsLayout.removeWidget(step)
step.hide()
step.deleteLater()
def getBlockType(
self
) -> str:
return self.typeCombo.currentData()
def toScriptLines(
self
) -> list:
blockType = self.getBlockType()
lines = []
if blockType in ("IF", "ELSE IF"):
operand = self.operandCombo.currentData()
operandName = operand[0] if operand else ""
operandType = operand[1] if operand else "String"
opSym = self.opCombo.currentData()
rawVal = _getValueFromWidget(
self._condValueWidgets.get(operandType, QLineEdit())
)
encodedVal = _encodeValueStr(rawVal, operandType)
if blockType == "IF":
lines.append(f"IF({operandName} {opSym} {encodedVal}) THEN")
else:
lines.append(f"ELSE IF({operandName} {opSym} {encodedVal}) THEN")
else:
lines.append("ELSE")
for step in self._actionWidgets:
scriptLine = step.toScriptLine()
if scriptLine:
lines.append(scriptLine)
return lines
def getConditionSummary(
self
) -> str:
bt = self.getBlockType()
if bt == "ELSE":
return "ELSE"
operandData = self.operandCombo.currentData()
if not operandData:
return bt
operandDisplay = self.operandCombo.currentText()
opDisplay = self.opCombo.currentText()
rawVal = self.getConditionRawValuePreview()
return f"{bt} ({operandDisplay} {opDisplay} {rawVal})"
def getConditionRawValuePreview(
self
) -> str:
data = self.operandCombo.currentData()
if not data:
return ""
_, vartype = data
w = self._condValueWidgets.get(vartype)
if w:
return _getValueFromWidget(w)
return ""
def countActionSteps(
self
) -> int:
return len(self._actionWidgets)
@Slot(int)
def onOperandChanged(
self,
idx
):
if idx < 0:
return
data = self.operandCombo.itemData(idx)
if data:
_, vartype = data
w = self._condValueWidgets.get(vartype)
if w:
self.condValueStack.setCurrentWidget(w)
@Slot(int)
def onTypeChanged(
self,
idx
):
isCond = self.typeCombo.currentData() in ("IF", "ELSE IF")
self.conditionWidget.setVisible(isCond)
self.actionLabel.setText("执行步骤:" if isCond else "ELSE 执行步骤:")
@Slot()
def addActionStep(
self
):
step = ActionStepFrame(self)
step.deleteBtn.clicked.connect(lambda: self.removeActionStep(step))
self._actionWidgets.append(step)
self.actionsLayout.addWidget(step)
class ALAutoScriptOrchDialog(QDialog):
def __init__(
self,
parent=None,
existingScript: str = ""
):
super().__init__(parent)
self._blocks: list[ConditionalBlock] = []
self.modifyUi()
self.connectSignals()
if existingScript and existingScript.strip():
self.loadFromScript(existingScript)
else:
self.addBlock()
self._scrollLayout.addStretch()
def modifyUi(
self
):
self.setWindowTitle("AutoScript 指令编排 - AutoLibrary")
self.setMinimumSize(420, 400)
self.setModal(True)
mainLayout = QVBoxLayout(self)
scroll = QScrollArea()
scroll.setWidgetResizable(True)
scroll.setFrameShape(QFrame.Shape.NoFrame)
scrollContent = QWidget()
self._scrollLayout = QVBoxLayout(scrollContent)
self._scrollLayout.setSpacing(5)
scroll.setWidget(scrollContent)
mainLayout.addWidget(scroll)
addBlockLayout = QHBoxLayout()
self.addBlockBtn = QPushButton("+ 添加判断块")
self.addBlockBtn.setFixedHeight(25)
addBlockLayout.addStretch()
addBlockLayout.addWidget(self.addBlockBtn)
addBlockLayout.addStretch()
mainLayout.addLayout(addBlockLayout)
self.btnBox = QDialogButtonBox(
QDialogButtonBox.StandardButton.Ok |
QDialogButtonBox.StandardButton.Cancel
)
self.btnBox.button(QDialogButtonBox.StandardButton.Ok).setText("确定")
self.btnBox.button(QDialogButtonBox.StandardButton.Cancel).setText("取消")
mainLayout.addWidget(self.btnBox)
def connectSignals(
self
):
self.btnBox.accepted.connect(self.accept)
self.btnBox.rejected.connect(self.reject)
self.addBlockBtn.clicked.connect(self.addBlock)
def removeBlock(
self,
block: ConditionalBlock
):
if block in self._blocks:
self._blocks.remove(block)
self._scrollLayout.removeWidget(block)
block.hide()
block.deleteLater()
def getScript(
self
) -> str:
parts = []
for i, block in enumerate(self._blocks):
blockType = block.getBlockType()
if blockType == "IF" and i > 0:
parts.append("ENDIF")
lines = block.toScriptLines()
parts.extend(lines)
if self._blocks and self._blocks[0].getBlockType() == "IF":
parts.append("ENDIF")
return "\n".join(parts)
def getScriptPreview(
self
) -> str:
s = self.getScript()
if len(s) > 10:
return s[:7] + "..."
return s
def loadFromScript(
self,
script: str
):
import re
lines = [l.strip() for l in script.split("\n") if l.strip()]
if not lines:
self.addBlock()
return
currentBlock = None
currentBlockType = None
actionsBuffer = []
def flushBlock():
nonlocal currentBlock, currentBlockType, actionsBuffer
if currentBlock is None:
return
typeIdxMap = {"IF": 0, "ELSE IF": 1, "ELSE": 2}
idx = typeIdxMap.get(currentBlockType, 0)
currentBlock.typeCombo.setCurrentIndex(idx)
currentBlock.onTypeChanged(idx)
for oldStep in list(currentBlock._actionWidgets):
currentBlock.removeActionStep(oldStep)
for target, valueExpr in actionsBuffer:
currentBlock.addActionStep()
step = currentBlock._actionWidgets[-1]
step.loadFromScript(target, valueExpr)
self._blocks.clear()
while self._scrollLayout.count():
item = self._scrollLayout.takeAt(0)
if item.widget():
item.widget().deleteLater()
for line in lines:
upper = line.upper()
ifMatch = re.match(r"^IF\((.+)\)\s*THEN\s*$", upper)
if ifMatch:
flushBlock()
currentBlockType = "IF"
actionsBuffer = []
self.addBlock()
currentBlock = self._blocks[-1]
self.parseConditionToBlock(currentBlock, ifMatch.group(1))
continue
elifIfMatch = re.match(r"^ELSE\s+IF\((.+)\)\s*THEN\s*$", upper)
if elifIfMatch:
flushBlock()
currentBlockType = "ELSE IF"
actionsBuffer = []
self.addBlock()
currentBlock = self._blocks[-1]
self.parseConditionToBlock(currentBlock, elifIfMatch.group(1))
continue
if upper == "ELSE":
flushBlock()
currentBlockType = "ELSE"
actionsBuffer = []
self.addBlock()
currentBlock = self._blocks[-1]
currentBlock.conditionWidget.setVisible(False)
continue
setMatch = re.match(r"^SET\s+(\w+)\s*=\s*(.+)$", line, re.IGNORECASE)
if setMatch:
target = setMatch.group(1).strip()
valueExpr = setMatch.group(2).strip()
actionsBuffer.append((target, valueExpr))
continue
addMatch = re.match(r"^(\w+)\s+\.ADD\.\s+(\d+)$", line, re.IGNORECASE)
if addMatch:
target = addMatch.group(1).strip()
hours = addMatch.group(2).strip()
actionsBuffer.append((target, f"+{hours}"))
continue
if upper in ("ENDIF", "END IF"):
flushBlock()
currentBlock = None
currentBlockType = None
actionsBuffer = []
continue
flushBlock()
if not self._blocks:
self.addBlock()
def parseConditionToBlock(
self,
block: ConditionalBlock,
condStr: str
):
condStr = condStr.strip()
for _, opSym in OP_ITEMS:
idx = condStr.upper().find(opSym)
if idx >= 0:
leftPart = condStr[:idx].strip()
rightPart = condStr[idx + len(opSym):].strip()
for ci in range(block.operandCombo.count()):
data = block.operandCombo.itemData(ci)
if data and data[0] == leftPart:
block.operandCombo.setCurrentIndex(ci)
break
for oi in range(block.opCombo.count()):
if block.opCombo.itemData(oi) == opSym:
block.opCombo.setCurrentIndex(oi)
break
opData = block.operandCombo.currentData()
vartype = opData[1] if opData else "String"
w = block._condValueWidgets.get(vartype)
if w:
_setWidgetValue(w, vartype, rightPart)
return
@Slot()
def addBlock(
self
):
block = ConditionalBlock(len(self._blocks), self)
block.deleteBlockBtn.clicked.connect(lambda: self.removeBlock(block))
self._blocks.append(block)
block.addActionStep()
if self._scrollLayout.count() > 0:
lastItem = self._scrollLayout.itemAt(
self._scrollLayout.count() - 1
)
if lastItem and lastItem.spacerItem():
self._scrollLayout.insertWidget(
self._scrollLayout.count() - 1, block
)
return
self._scrollLayout.addWidget(block)
@@ -0,0 +1,10 @@
# -*- 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 ._dialog import ALAutoScriptOrchDialog
+265
View File
@@ -0,0 +1,265 @@
# -*- 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.
"""
"""
Conditional block widget for the AutoScript orchestration dialog.
"""
from PySide6.QtCore import Slot
from PySide6.QtWidgets import (
QComboBox,
QGroupBox,
QHBoxLayout,
QLabel,
QPushButton,
QSizePolicy,
QVBoxLayout,
QWidget,
)
from gui.ALAutoScriptOrchDialog._widgets import (
ActionStepFrame,
ConditionRowFrame,
)
class ConditionalBlock(QGroupBox):
def __init__(
self,
blockIndex: int,
varMgr = None,
parent = None
):
super().__init__(parent)
self.blockIndex = blockIndex
self._varMgr = varMgr
self._actionWidgets = []
self._conditionRows = []
self.setupUi()
self.connectSignals()
self.addInitialConditionRow()
def setupUi(
self
):
self.setUpdatesEnabled(False)
self.setStyleSheet(
"QGroupBox { font-weight: bold; border: 1px solid #ccc; "
"margin-top: 5px; padding-top: 5px; }"
)
self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
MainLayout = QVBoxLayout(self)
MainLayout.setSpacing(6)
MainLayout.setContentsMargins(8, 8, 8, 8)
HeaderLayout = QHBoxLayout()
HeaderLayout.setSpacing(8)
self.TypeCombo = QComboBox(self)
self.TypeCombo.addItem("IF", "IF")
self.TypeCombo.addItem("ELSE IF", "ELSE IF")
self.TypeCombo.addItem("ELSE", "ELSE")
self.TypeCombo.setFixedHeight(25)
if self.blockIndex == 0:
self.TypeCombo.setEnabled(False)
HeaderLayout.addWidget(QLabel("类型:", self))
HeaderLayout.addWidget(self.TypeCombo)
HeaderLayout.addStretch()
self.DeleteBlockBtn = QPushButton("删除此块", self)
self.DeleteBlockBtn.setStyleSheet("color: red;")
self.DeleteBlockBtn.setFixedHeight(25)
HeaderLayout.addWidget(self.DeleteBlockBtn)
MainLayout.addLayout(HeaderLayout)
self.ConditionWidget = QWidget(self)
self.ConditionWidget.setSizePolicy(
QSizePolicy.Preferred, QSizePolicy.Preferred
)
CondLayout = QVBoxLayout(self.ConditionWidget)
CondLayout.setContentsMargins(4, 4, 4, 4)
CondLayout.setSpacing(6)
self.CondRowsLayout = QVBoxLayout()
self.CondRowsLayout.setSpacing(4)
CondLayout.addLayout(self.CondRowsLayout)
self.AddCondBtn = QPushButton("+ 添加条件", self.ConditionWidget)
self.AddCondBtn.setFixedHeight(25)
CondLayout.addWidget(self.AddCondBtn)
MainLayout.addWidget(self.ConditionWidget)
self.ActionLabel = QLabel("执行步骤:", self)
self.ActionLabel.setFixedHeight(25)
MainLayout.addWidget(self.ActionLabel)
self.ActionsLayout = QVBoxLayout()
self.ActionsLayout.setSpacing(4)
MainLayout.addLayout(self.ActionsLayout)
self.AddActionBtn = QPushButton("+ 添加执行步骤", self)
self.AddActionBtn.setFixedHeight(25)
MainLayout.addWidget(self.AddActionBtn)
self.setUpdatesEnabled(True)
def connectSignals(
self
):
self.TypeCombo.currentIndexChanged.connect(self.onTypeChanged)
self.AddCondBtn.clicked.connect(self.addConditionRow)
self.AddActionBtn.clicked.connect(self.addActionStep)
def addInitialConditionRow(
self
):
Row = ConditionRowFrame(
self._varMgr, self.blockIndex,
isFirst=True, parent=self
)
self._conditionRows.append(Row)
self.CondRowsLayout.addWidget(Row)
def addConditionRow(
self
):
Row = ConditionRowFrame(
self._varMgr, self.blockIndex,
isFirst=False, parent=self
)
Row.DeleteBtn.clicked.connect(lambda: self.removeConditionRow(Row))
self._conditionRows.append(Row)
self.CondRowsLayout.addWidget(Row)
def removeConditionRow(
self,
row: ConditionRowFrame
):
if row in self._conditionRows and len(self._conditionRows) > 1:
self._conditionRows.remove(row)
self.CondRowsLayout.removeWidget(row)
row.hide()
row.deleteLater()
def addActionStep(
self
):
Step = ActionStepFrame(self._varMgr, self.blockIndex, parent=self)
Step.DeleteBtn.clicked.connect(lambda: self.removeActionStep(Step))
self._actionWidgets.append(Step)
self.ActionsLayout.addWidget(Step)
def removeActionStep(
self,
step: ActionStepFrame
):
if step in self._actionWidgets:
self._actionWidgets.remove(step)
self.ActionsLayout.removeWidget(step)
step.hide()
step.deleteLater()
def getBlockType(
self
) -> str:
return self.TypeCombo.currentData()
def getConditionRows(
self
):
return list(self._conditionRows)
def getActionSteps(
self
):
return list(self._actionWidgets)
def countActionSteps(
self
) -> int:
return len(self._actionWidgets)
def toScript(
self
) -> list:
"""
Generate Lua script lines for this conditional block.
"""
blockType = self.getBlockType()
lines = []
if blockType in ("IF", "ELSE IF"):
condTexts = [
r.toScript() for r in self._conditionRows if r.toScript()
]
if not condTexts:
condTexts = ["true"]
if len(condTexts) == 1:
combined = condTexts[0]
else:
parts = []
for i, ct in enumerate(condTexts):
if i > 0:
logic = self._conditionRows[i].getLogic() or "and"
parts.append(f" {logic} ")
parts.append(f"({ct})")
combined = "".join(parts)
if blockType == "IF":
lines.append(f"if {combined} then")
else:
lines.append(f"elseif {combined} then")
else:
lines.append("else")
for step in self._actionWidgets:
scriptLine = step.toScript()
if scriptLine:
lines.append(scriptLine)
return lines
def refreshVarCombos(
self
):
for row in self._conditionRows:
row.refreshVarCombos()
for step in self._actionWidgets:
step.refreshVarCombos()
def setPrevBlockType(
self,
prevType: str | None
):
model = self.TypeCombo.model()
if model is None:
return
for data in ("ELSE IF", "ELSE"):
idx = self.TypeCombo.findData(data)
if idx < 0:
continue
item = model.item(idx)
shouldEnable = prevType != "ELSE"
item.setEnabled(shouldEnable)
if prevType == "ELSE" and self.TypeCombo.currentData() in ("ELSE IF", "ELSE"):
self.TypeCombo.setCurrentIndex(0)
@Slot(int)
def onTypeChanged(
self,
_idx
):
isCond = self.TypeCombo.currentData() in ("IF", "ELSE IF")
self.ConditionWidget.setVisible(isCond)
self.ActionLabel.setText(
"执行步骤:" if isCond else "ELSE 执行步骤:"
)
+164
View File
@@ -0,0 +1,164 @@
# -*- 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.
"""
"""
Orchestration dialog for visually composing AutoScript scripts.
"""
from PySide6.QtCore import Slot
from PySide6.QtWidgets import (
QDialog,
QDialogButtonBox,
QFrame,
QMessageBox,
QPushButton,
QScrollArea,
QVBoxLayout,
QWidget,
)
from gui.ALAutoScriptOrchDialog._helpers import VariableManager
from gui.ALAutoScriptOrchDialog._blocks import ConditionalBlock
class ALAutoScriptOrchDialog(QDialog):
def __init__(
self,
parent = None
):
super().__init__(parent)
self._blocks = []
self._varMgr = VariableManager(self)
self.setupUi()
self.connectSignals()
self.addBlock()
self.ScrollLayout.addStretch()
def setupUi(
self
):
self.setWindowTitle("AutoScript 指令编排 - AutoLibrary")
self.setMinimumSize(640, 600)
self.setModal(True)
MainLayout = QVBoxLayout(self)
Scroll = QScrollArea()
Scroll.setWidgetResizable(True)
Scroll.setFrameShape(QFrame.NoFrame)
ScrollContent = QWidget()
self.ScrollLayout = QVBoxLayout(ScrollContent)
self.ScrollLayout.setSpacing(5)
Scroll.setWidget(ScrollContent)
MainLayout.addWidget(Scroll)
self.AddBlockBtn = QPushButton("+ 添加判断块")
self.AddBlockBtn.setFixedHeight(25)
MainLayout.addWidget(self.AddBlockBtn)
self.BtnBox = QDialogButtonBox(
QDialogButtonBox.StandardButton.Ok |
QDialogButtonBox.StandardButton.Cancel
)
self.BtnBox.button(QDialogButtonBox.StandardButton.Ok).setText("确定")
self.BtnBox.button(QDialogButtonBox.StandardButton.Cancel).setText("取消")
MainLayout.addWidget(self.BtnBox)
def connectSignals(
self
):
self.BtnBox.accepted.connect(self.onAccept)
self.BtnBox.rejected.connect(self.reject)
self.AddBlockBtn.clicked.connect(self.addBlock)
def updateBlockTypeRestrictions(
self
):
prevType = None
for block in self._blocks:
block.setPrevBlockType(prevType)
prevType = block.getBlockType()
def addBlock(
self
):
Block = ConditionalBlock(
len(self._blocks), self._varMgr, parent=self
)
Block.DeleteBlockBtn.clicked.connect(lambda: self.removeBlock(Block))
Block.TypeCombo.currentIndexChanged.connect(self.updateBlockTypeRestrictions)
Block.addActionStep()
self._blocks.append(Block)
self.updateBlockTypeRestrictions()
if self.ScrollLayout.count() > 0:
lastItem = self.ScrollLayout.itemAt(
self.ScrollLayout.count() - 1
)
if lastItem and lastItem.spacerItem():
self.ScrollLayout.insertWidget(
self.ScrollLayout.count() - 1, Block
)
return
self.ScrollLayout.addWidget(Block)
def removeBlock(
self,
block: ConditionalBlock
):
if len(self._blocks) <= 1:
QMessageBox.information(self, "提示", "至少保留一个判断块。")
return
if block in self._blocks:
self._blocks.remove(block)
self.ScrollLayout.removeWidget(block)
block.hide()
block.deleteLater()
for i, blk in enumerate(self._blocks):
blk.blockIndex = i
if i == 0:
blk.TypeCombo.setEnabled(False)
blk.TypeCombo.setCurrentIndex(0)
else:
blk.TypeCombo.setEnabled(True)
blk.refreshVarCombos()
self.updateBlockTypeRestrictions()
def getScript(
self
) -> str:
"""
Generate the complete Lua script from all blocks.
"""
parts = []
prevType = None
for block in self._blocks:
blockType = block.getBlockType()
if blockType == "IF" and prevType is not None:
parts.append("end")
lines = block.toScript()
parts.extend(lines)
prevType = blockType
if self._blocks and self._blocks[0].getBlockType() == "IF":
parts.append("end")
return "\n".join(parts)
@Slot()
def onAccept(
self
):
script = self.getScript().strip()
if not script:
QMessageBox.warning(self, "提示", "脚本内容为空,请添加至少一个操作步骤。")
return
self.accept()
+516
View File
@@ -0,0 +1,516 @@
# -*- 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.
"""
"""
Helper utilities and constants for the AutoScript orchestration dialog.
"""
import re
from PySide6.QtCore import QObject
from PySide6.QtWidgets import (
QComboBox,
QDateEdit,
QDoubleSpinBox,
QHBoxLayout,
QLabel,
QLineEdit,
QSizePolicy,
QSpinBox,
QStackedWidget,
QTimeEdit,
QWidget,
)
from autoscript import createAllVariablesTable
VARTYPE_INFOS = [
# varType, isArithType
("String", False),
("Int", True),
("Float", True),
("Boolean", False),
("Date", True),
("Time", True),
]
def getTypeOrder(
) -> list:
return [t for t, _ in VARTYPE_INFOS]
def getArithType(
varType: str
) -> bool:
for t, a in VARTYPE_INFOS:
if t == varType:
return a
def getPresetVars(
) -> list:
return [
{"name": name.upper(), "type": vtype, "display": display}
for display, (name, vtype) in createAllVariablesTable().items()
]
COMPARE_OPTIONS = [
("等于", "=="),
("不等于", "~="),
("大于", ">"),
("小于", "<"),
("大于等于", ">="),
("小于等于", "<="),
]
LOGIC_OPTIONS = [
("并且 (and)", "and"),
("或者 (or)", "or"),
]
ACTION_OPTIONS = [
("设置为", "set"),
("增加", "add"),
("减少", "sub"),
]
DATE_OPTIONS = [
("前天", "day_before_yesterday"),
("昨天", "yesterday"),
("今天", "today"),
("明天", "tomorrow"),
("后天", "day_after_tomorrow")
]
DATE_OFFSET_OPTIONS = [
("", "days"),
("", "weeks"),
# NOTE: "月" and "年" use fixed day counts (30 / 365), not calendar months/years,
# because dateadd() works with second-level offsets (n * 86400).
("", "months"),
("", "years"),
]
class _DateInputContainer(QWidget):
def __init__(
self,
parent = None
):
super().__init__(parent)
self.setupUi()
def setupUi(
self
):
Layout = QHBoxLayout(self)
Layout.setContentsMargins(0, 0, 0, 0)
Layout.setSpacing(4)
self._ModeCombo = QComboBox(self)
self._ModeCombo.addItem("相对日期", "relative")
self._ModeCombo.addItem("绝对日期", "absolute")
self._ModeCombo.setFixedHeight(25)
self._Stack = QStackedWidget(self)
self._RelCombo = QComboBox(self)
for display, data in DATE_OPTIONS:
self._RelCombo.addItem(display, data)
self._RelCombo.setFixedHeight(25)
self._Stack.addWidget(self._RelCombo)
self._DateEdit = QDateEdit(self)
self._DateEdit.setDisplayFormat("yyyy-MM-dd")
self._DateEdit.setCalendarPopup(True)
self._DateEdit.setFixedHeight(25)
self._Stack.addWidget(self._DateEdit)
self._ModeCombo.currentIndexChanged.connect(
lambda i: self._Stack.setCurrentIndex(i)
)
Layout.addWidget(self._ModeCombo)
Layout.addWidget(self._Stack)
Layout.addStretch()
def getValue(
self
) -> str:
mode = self._ModeCombo.currentData()
if mode == "relative":
return self._RelCombo.currentText()
return self._DateEdit.date().toString("yyyy-MM-dd")
class _TimeInputContainer(QWidget):
def __init__(
self,
parent = None
):
super().__init__(parent)
self._TimeEdit = QTimeEdit(self)
self._TimeEdit.setDisplayFormat("HH:mm")
self._TimeEdit.setFixedHeight(25)
Layout = QHBoxLayout(self)
Layout.setContentsMargins(0, 0, 0, 0)
Layout.addWidget(self._TimeEdit)
def getValue(
self
) -> str:
return self._TimeEdit.time().toString("HH:mm")
class _DateOffsetContainer(QWidget):
def __init__(
self,
parent = None
):
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)
Layout = QHBoxLayout(self)
Layout.setContentsMargins(0, 0, 0, 0)
Layout.setSpacing(4)
Layout.addWidget(self._SpinBox)
Layout.addWidget(self._UnitCombo)
Layout.addStretch()
def getValue(
self
) -> str:
return str(self.getOffsetDays())
def getOffsetDays(
self
) -> int:
val = self._SpinBox.value()
unit = self._UnitCombo.currentData()
if unit == "weeks":
return val*7
if unit == "months":
return val*30
if unit == "years":
return val*365
return val
class _TimeOffsetContainer(QWidget):
def __init__(
self,
parent = None
):
super().__init__(parent)
self._SpinBox = QSpinBox(self)
self._SpinBox.setRange(0, 99999)
self._SpinBox.setSuffix(" 小时")
self._SpinBox.setFixedHeight(25)
Layout = QHBoxLayout(self)
Layout.setContentsMargins(0, 0, 0, 0)
Layout.addWidget(self._SpinBox)
def getValue(
self
) -> str:
return str(self.getOffsetHours())
def getOffsetHours(
self
) -> int:
return self._SpinBox.value()
class VariableManager(QObject):
def __init__(
self,
parent = None
):
super().__init__(parent)
self._vars = []
self._nameMap = {}
self.initPresetVars()
def initPresetVars(
self
):
for p in getPresetVars():
entry = {"name": p["name"], "type": p["type"], "display": p["display"]}
self._vars.append(entry)
self._nameMap[p["name"]] = entry
def getInfoByName(
self,
name: str
):
return self._nameMap.get(name.upper().strip())
def populateCombo(
self,
combo: QComboBox
):
currentData = combo.currentData()
combo.blockSignals(True)
combo.clear()
for entry in self._vars:
combo.addItem(
entry["display"],
(entry["name"], entry["type"])
)
if currentData:
for i in range(combo.count()):
d = combo.itemData(i)
if d and d[0] == currentData[0]:
combo.setCurrentIndex(i)
break
combo.blockSignals(False)
def makeValueWidget(
var_type: str,
parent: QWidget = None
) -> QWidget:
if var_type == "Int":
w = QSpinBox(parent)
w.setRange(-999999, 999999)
w.setFixedHeight(25)
w.setMinimumWidth(100)
return w
if var_type == "Float":
w = QDoubleSpinBox(parent)
w.setRange(-999999.0, 999999.0)
w.setDecimals(2)
w.setFixedHeight(25)
w.setMinimumWidth(100)
return w
if var_type == "String":
w = QLineEdit(parent)
w.setPlaceholderText("输入值")
w.setFixedHeight(25)
w.setMinimumWidth(120)
return w
if var_type == "Boolean":
w = QComboBox(parent)
w.addItem("是 (true)", "true")
w.addItem("否 (false)", "false")
w.setFixedHeight(25)
w.setMinimumWidth(100)
return w
if var_type == "Date":
return _DateInputContainer(parent)
if var_type == "Time":
return _TimeInputContainer(parent)
w = QLineEdit(parent)
w.setPlaceholderText("输入值")
w.setFixedHeight(25)
w.setMinimumWidth(120)
return w
def makeOffsetWidget(
var_type: str,
parent: QWidget = None
) -> QWidget:
if var_type == "Int":
w = QSpinBox(parent)
w.setRange(-999999, 999999)
w.setFixedHeight(25)
w.setMinimumWidth(100)
return w
if var_type == "Float":
w = QDoubleSpinBox(parent)
w.setRange(-999999.0, 999999.0)
w.setDecimals(2)
w.setFixedHeight(25)
w.setMinimumWidth(100)
return w
if var_type == "Date":
return _DateOffsetContainer(parent)
if var_type == "Time":
return _TimeOffsetContainer(parent)
w = QLabel("(不支持该操作)", parent)
w.setFixedHeight(25)
return w
def makeVarRefCombo(
parent: QWidget = None
) -> QComboBox:
Cb = QComboBox(parent)
Cb.setFixedHeight(25)
Cb.setMinimumWidth(120)
Cb.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
return Cb
def makeComboWidget(
items,
min_width: int = 80,
parent: QWidget = None
) -> QComboBox:
Cb = QComboBox(parent)
for display, data in items:
Cb.addItem(display, data)
Cb.setFixedHeight(25)
Cb.setMinimumWidth(min_width)
return Cb
def makeLabel(
text: str,
parent: QWidget = None,
width: int = None
) -> QLabel:
Lbl = QLabel(text, parent)
Lbl.setFixedHeight(25)
if width:
Lbl.setFixedWidth(width)
return Lbl
def getValueFromWidget(
w: QWidget
) -> str:
if hasattr(w, "getValue"):
return w.getValue()
if isinstance(w, QTimeEdit):
return w.time().toString("HH:mm")
if isinstance(w, QDateEdit):
return w.date().toString("yyyy-MM-dd")
if isinstance(w, QComboBox):
return w.currentData() or w.currentText()
if isinstance(w, QSpinBox):
return str(w.value())
if isinstance(w, QDoubleSpinBox):
return str(w.value())
if isinstance(w, QLineEdit):
return w.text()
return ""
def encodeValueStr(
raw_value: str,
var_type: str
) -> str:
"""
Encode a raw widget value as a Lua expression.
Arithmetic expressions (A + B) are passed through for numeric types;
Date/Time arithmetic is translated to ``dateadd()`` / ``timeadd()`` calls.
"""
if var_type in ("Date", "Time"):
return encodeDateOrTime(str(raw_value), var_type)
if isinstance(raw_value, bool):
return "true" if raw_value else "false"
s = str(raw_value)
if isArithExpr(s):
return s
if var_type == "Boolean":
up = s.upper().strip()
if up in ("TRUE", "FALSE"):
return up.lower()
return "true" if raw_value else "false"
if var_type == "String":
escaped = s.replace("\\", "\\\\").replace('"', '\\"')
return f'"{escaped}"'
return s
def encodeDateOrTime(
raw_value: str,
var_type: str
) -> str:
"""
Translate a date/time widget value into a Lua expression.
"""
s = raw_value.strip()
up = s.upper()
# Input comes from widget values — single binary expressions only (e.g. "A + 3",
# "CURRENT_DATE + 5"). Multi-operator expressions are not produced by the UI.
m_arith_spaced = re.match(r'^(.+?)\s+([+-])\s+(.+)$', s)
m_arith_nospace = re.match(r'^([A-Za-z_]\w*)([+-])(\d+|[A-Za-z_]\w*)$', s)
m_arith = m_arith_spaced or m_arith_nospace
if m_arith:
left = m_arith.group(1).strip().upper()
sign = m_arith.group(2)
right = m_arith.group(3).strip()
operand = right if sign == "+" else f"-{right}"
if left == "CURRENT_DATE":
return f"dateadd(datenow(), {operand})"
if left == "CURRENT_TIME":
return f"timeadd(timenow(), {operand})"
if var_type == "Date":
return f"dateadd({left}, {operand})"
if var_type == "Time":
return f"timeadd({left}, {operand})"
return f"{left} {sign} {right}"
if up == "CURRENT_DATE":
return "datenow()"
if up == "CURRENT_TIME":
return "timenow()"
_REL_MAP = {
"前天": "dateadd(datenow(), -2)",
"昨天": "dateadd(datenow(), -1)",
"今天": "datenow()",
"明天": "dateadd(datenow(), 1)",
"后天": "dateadd(datenow(), 2)",
}
if s in _REL_MAP:
return _REL_MAP[s]
if var_type == "Date":
m_date = re.match(r"^(\d{4})-(\d{2})-(\d{2})$", s)
if m_date:
y, m, d = int(m_date.group(1)), int(m_date.group(2)), int(m_date.group(3))
return f"date({y}, {m}, {d})"
if var_type == "Time":
m_time = re.match(r"^(\d{1,2}):(\d{2})$", s)
if m_time:
h, m = int(m_time.group(1)), int(m_time.group(2))
return f"time({h}, {m})"
if re.match(r"^[+-]?\d+$", s):
return s
if re.match(r"^[A-Za-z_]\w*$", s):
return s
return f'"{s}"'
# Pre-compiled patterns for detecting arithmetic expressions (A + B / A - B)
_RE_ARITH_SPACED = re.compile(r'^(.+?)\s+([+-])\s+(.+)$')
_RE_ARITH_NOSPACE = re.compile(r'^([A-Za-z_]\w*)([+-])(\d+|[A-Za-z_]\w*)$')
def isArithExpr(
expr: str
) -> bool:
"""
Return True if expr looks like a two-operand arithmetic expression (A ± B).
"""
s = expr.strip()
return bool(_RE_ARITH_SPACED.match(s) or _RE_ARITH_NOSPACE.match(s))
+468
View File
@@ -0,0 +1,468 @@
# -*- 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.
"""
"""
Widget components for the AutoScript orchestration dialog.
"""
from PySide6.QtCore import Slot
from PySide6.QtWidgets import (
QComboBox,
QFrame,
QHBoxLayout,
QLabel,
QPushButton,
QSizePolicy,
QStackedWidget
)
from gui.ALAutoScriptOrchDialog._helpers import (
ACTION_OPTIONS,
COMPARE_OPTIONS,
LOGIC_OPTIONS,
encodeValueStr,
getPresetVars,
getTypeOrder,
getValueFromWidget,
getArithType,
makeComboWidget,
makeLabel,
makeOffsetWidget,
makeValueWidget,
makeVarRefCombo,
)
class ConditionRowFrame(QFrame):
def __init__(
self,
varMgr,
parentBlockIndex: int = 0,
isFirst: bool = False,
parent = None
):
super().__init__(parent)
self._varMgr = varMgr
self._blockIndex = parentBlockIndex
self._isFirst = isFirst
self._isBoolMode = False
self._rawRhsExpr = ""
self.setupUi()
self.connectSignals()
def setupUi(
self
):
self.setUpdatesEnabled(False)
self.setFrameShape(QFrame.StyledPanel)
self.setFrameShadow(QFrame.Raised)
self.setFixedHeight(32)
Layout = QHBoxLayout(self)
Layout.setContentsMargins(2, 2, 2, 2)
Layout.setSpacing(4)
if self._isFirst:
self.LogicCombo = None
else:
self.LogicCombo = makeComboWidget(LOGIC_OPTIONS, min_width=110, parent=self)
Layout.addWidget(self.LogicCombo)
self.LeftVarCombo = QComboBox(self)
self.LeftVarCombo.setFixedHeight(25)
self.LeftVarCombo.setMinimumWidth(120)
self.LeftVarCombo.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
self.populateLeftVarCombo()
Layout.addWidget(self.LeftVarCombo)
self.OpCombo = makeComboWidget(COMPARE_OPTIONS, min_width=80, parent=self)
Layout.addWidget(self.OpCombo)
self._CompTypeCombo = makeComboWidget([
("特定值", "literal"),
("变量", "variable"),
], min_width=70, parent=self)
Layout.addWidget(self._CompTypeCombo)
self.RhsStack = QStackedWidget(self)
self.RhsStack.setFixedHeight(25)
self.initLiteralStack()
self.RhsVarCombo = makeVarRefCombo(self)
self.RhsStack.addWidget(self.RhsVarCombo)
self.RhsStack.setCurrentIndex(0)
Layout.addWidget(self.RhsStack)
if not self._isFirst:
self.DeleteBtn = QPushButton("×", self)
self.DeleteBtn.setFixedSize(25, 25)
self.DeleteBtn.setStyleSheet("color: red; font-weight: bold;")
Layout.addWidget(self.DeleteBtn)
else:
self.DeleteBtn = None
Layout.addStretch()
self.setUpdatesEnabled(True)
def populateLeftVarCombo(
self
):
wasBool = self._isBoolMode
boolName = None
if wasBool:
data = self.LeftVarCombo.currentData()
if data:
boolName = data[0]
self._varMgr.populateCombo(self.LeftVarCombo)
# Append boolean literal sentinels at the end
self.LeftVarCombo.insertSeparator(self.LeftVarCombo.count())
self.LeftVarCombo.addItem("true", ("true", "Boolean"))
self.LeftVarCombo.addItem("false", ("false", "Boolean"))
if wasBool and boolName:
for ci in range(self.LeftVarCombo.count()):
d = self.LeftVarCombo.itemData(ci)
if d and d[0] == boolName:
self.LeftVarCombo.setCurrentIndex(ci)
break
def populateRHSVarCombo(
self
):
self._varMgr.populateCombo(self.RhsVarCombo)
def initLiteralStack(
self
):
self.LiteralStack = QStackedWidget(self)
self.LiteralStack.setFixedHeight(25)
self._literalWidgets = {}
for vt in getTypeOrder():
W = makeValueWidget(vt, self.LiteralStack)
self._literalWidgets[vt] = W
self.LiteralStack.addWidget(W)
self.LiteralStack.setCurrentWidget(self._literalWidgets.get("String"))
self.RhsStack.addWidget(self.LiteralStack)
def connectSignals(
self
):
self.LeftVarCombo.currentIndexChanged.connect(self.onLeftVarChanged)
self._CompTypeCombo.currentIndexChanged.connect(self.onCompTypeChanged)
def getLogic(
self
) -> str:
return self.LogicCombo.currentData() if self.LogicCombo else ""
def updateRHSLiteralWidget(
self,
vartype: str
):
if vartype not in self._literalWidgets:
vartype = "String"
self.LiteralStack.setCurrentWidget(self._literalWidgets[vartype])
def toScript(
self
) -> str:
data = self.LeftVarCombo.currentData()
if self._isBoolMode and data:
return data[0]
if not data:
return ""
name, vartype = data
# CURRENT_DATE / CURRENT_TIME map to datenow() / timenow()
if name == "CURRENT_DATE":
name = "datenow()"
elif name == "CURRENT_TIME":
name = "timenow()"
opSym = self.OpCombo.currentData()
if self._rawRhsExpr:
return f"{name} {opSym} {self._rawRhsExpr}"
isVarRef = (self._CompTypeCombo.currentData() == "variable")
if isVarRef:
rd = self.RhsVarCombo.currentData()
if rd:
rhsName = rd[0]
if rhsName == "CURRENT_DATE":
rhsName = "datenow()"
elif rhsName == "CURRENT_TIME":
rhsName = "timenow()"
return f"{name} {opSym} {rhsName}"
rhsText = self.RhsVarCombo.currentText().strip()
if rhsText:
return f"{name} {opSym} {rhsText}"
return ""
w = self._literalWidgets.get(vartype)
if w:
rawVal = getValueFromWidget(w)
encoded = encodeValueStr(rawVal, vartype)
return f"{name} {opSym} {encoded}"
return ""
def refreshVarCombos(
self
):
self.populateLeftVarCombo()
self.populateRHSVarCombo()
@Slot(int)
def onLeftVarChanged(
self,
idx
):
self._rawRhsExpr = ""
if idx < 0:
return
data = self.LeftVarCombo.itemData(idx)
if not data:
return
name, vartype = data
isBool = name in ("true", "false")
self._isBoolMode = isBool
self.OpCombo.setVisible(not isBool)
self._CompTypeCombo.setVisible(not isBool)
self.RhsStack.setVisible(not isBool)
if not isBool:
self.updateRHSLiteralWidget(vartype)
@Slot(int)
def onCompTypeChanged(
self,
idx
):
self._rawRhsExpr = ""
isVar = (self._CompTypeCombo.currentData() == "variable")
self.RhsStack.setCurrentIndex(1 if isVar else 0)
if isVar:
self.populateRHSVarCombo()
class ActionStepFrame(QFrame):
def __init__(
self,
varMgr,
parentBlockIndex: int = 0,
parent = None
):
super().__init__(parent)
self._varMgr = varMgr
self._blockIndex = parentBlockIndex
self._currentTargetType = "String"
self.setupUi()
self.connectSignals()
def setupUi(
self
):
self.setUpdatesEnabled(False)
self.setFrameShape(QFrame.StyledPanel)
self.setFrameShadow(QFrame.Raised)
self.setFixedHeight(35)
Layout = QHBoxLayout(self)
Layout.setContentsMargins(2, 2, 2, 2)
Layout.setSpacing(4)
self.OpTypeCombo = makeComboWidget(ACTION_OPTIONS, min_width=70, parent=self)
Layout.addWidget(self.OpTypeCombo)
Layout.addWidget(makeLabel("设置", self))
self.TargetCombo = QComboBox(self)
self.TargetCombo.setFixedHeight(25)
self.TargetCombo.setMinimumWidth(120)
self.populateTargetCombo()
Layout.addWidget(self.TargetCombo)
Layout.addWidget(makeLabel("", self))
self.ValueSrcCombo = makeComboWidget([
("特定值", "literal"),
("变量", "variable"),
], min_width=70, parent=self)
Layout.addWidget(self.ValueSrcCombo)
self.ValueStack = QStackedWidget(self)
self.ValueStack.setFixedHeight(25)
self.initValueStacks()
Layout.addWidget(self.ValueStack)
self.ExistingVarCombo = makeVarRefCombo(self)
self.ExistingVarCombo.setVisible(False)
Layout.addWidget(self.ExistingVarCombo)
self.DeleteBtn = QPushButton("×", self)
self.DeleteBtn.setFixedSize(25, 25)
self.DeleteBtn.setStyleSheet("color: red; font-weight: bold;")
Layout.addWidget(self.DeleteBtn)
self.setUpdatesEnabled(True)
def populateTargetCombo(
self
):
self.TargetCombo.blockSignals(True)
self.TargetCombo.clear()
for p in getPresetVars():
if p["name"] in ("CURRENT_TIME", "CURRENT_DATE"):
continue
info = self._varMgr.getInfoByName(p["name"])
if info:
self.TargetCombo.addItem(
info["display"],
(info["name"], info["type"])
)
self.TargetCombo.blockSignals(False)
def initValueStacks(
self
):
self._literalWidgets = {}
self._offsetWidgets = {}
for vt in getTypeOrder():
self._literalWidgets[vt] = makeValueWidget(vt, self.ValueStack)
self.ValueStack.addWidget(self._literalWidgets[vt])
if getArithType(vt):
self._offsetWidgets[vt] = makeOffsetWidget(vt, self.ValueStack)
self.ValueStack.addWidget(self._offsetWidgets[vt])
else:
Lbl = QLabel("(不支持该操作)", self.ValueStack)
Lbl.setFixedHeight(25)
self._offsetWidgets[vt] = Lbl
self.ValueStack.addWidget(Lbl)
def connectSignals(
self
):
self.OpTypeCombo.currentIndexChanged.connect(self.onOpTypeChanged)
self.TargetCombo.currentIndexChanged.connect(self.onTargetChanged)
self.ValueSrcCombo.currentIndexChanged.connect(self.onValueSrcChanged)
def getTargetName(
self
) -> str:
data = self.TargetCombo.currentData()
return data[0] if data else ""
def updateValueWidget(
self
):
op = self.OpTypeCombo.currentData()
isArith = (op in ("add", "sub"))
actualType = self._currentTargetType
if isArith and actualType in self._offsetWidgets:
self.ValueStack.setCurrentWidget(self._offsetWidgets[actualType])
elif actualType in self._literalWidgets:
self.ValueStack.setCurrentWidget(self._literalWidgets[actualType])
else:
self.ValueStack.setCurrentWidget(self._literalWidgets.get("String"))
def toScript(
self
) -> str:
"""
Generate a single line of Lua script from the current widget state.
"""
target = self.getTargetName()
op = self.OpTypeCombo.currentData()
if op == "pass":
return " -- pass"
if not target:
return ""
rawVal = self.getValueRaw()
vartype = self._currentTargetType
if op == "set":
encoded = encodeValueStr(rawVal, vartype)
return f" {target} = {encoded}"
elif op == "add":
if vartype == "Date" and hasattr(self.ValueStack.currentWidget(), "getOffsetDays"):
days = self.ValueStack.currentWidget().getOffsetDays()
return f" {target} = dateadd({target}, {days})"
if vartype == "Time" and hasattr(self.ValueStack.currentWidget(), "getOffsetHours"):
hours = self.ValueStack.currentWidget().getOffsetHours()
return f" {target} = timeadd({target}, {hours})"
return f" {target} = {target} + {rawVal}"
elif op == "sub":
if vartype == "Date" and hasattr(self.ValueStack.currentWidget(), "getOffsetDays"):
days = self.ValueStack.currentWidget().getOffsetDays()
return f" {target} = dateadd({target}, -{days})"
if vartype == "Time" and hasattr(self.ValueStack.currentWidget(), "getOffsetHours"):
hours = self.ValueStack.currentWidget().getOffsetHours()
return f" {target} = timeadd({target}, -{hours})"
return f" {target} = {target} - {rawVal}"
return ""
def getValueRaw(
self
) -> str:
if self.ValueSrcCombo.currentData() == "variable":
data = self.ExistingVarCombo.currentData()
return data[0] if data else ""
w = self.ValueStack.currentWidget()
if w:
return getValueFromWidget(w)
return ""
def refreshVarCombos(
self
):
currentData = self.TargetCombo.currentData()
self.populateTargetCombo()
if currentData:
for i in range(self.TargetCombo.count()):
d = self.TargetCombo.itemData(i)
if d and d[0] == currentData[0]:
self.TargetCombo.setCurrentIndex(i)
break
self._varMgr.populateCombo(self.ExistingVarCombo)
@Slot(int)
def onTargetChanged(
self,
idx
):
if idx < 0:
return
data = self.TargetCombo.itemData(idx)
if not data:
return
_, vartype = data
self._currentTargetType = vartype
self.updateValueWidget()
self.onValueSrcChanged(self.ValueSrcCombo.currentIndex())
@Slot(int)
def onOpTypeChanged(
self,
idx
):
self.updateValueWidget()
@Slot(int)
def onValueSrcChanged(
self,
idx
):
isVar = (self.ValueSrcCombo.currentData() == "variable")
self.ValueStack.setVisible(not isVar)
self.ExistingVarCombo.setVisible(isVar)
if isVar:
self._varMgr.populateCombo(self.ExistingVarCombo)
else:
self.updateValueWidget()
-226
View File
@@ -1,226 +0,0 @@
# -*- 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 Slot
from PySide6.QtGui import (
QSyntaxHighlighter, QTextCharFormat, QColor, QFont, QIcon
)
from PySide6.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QPlainTextEdit,
QDialogButtonBox, QPushButton, QLabel, QApplication, QStyle
)
class ALScriptHighlighter(QSyntaxHighlighter):
def __init__(
self,
parent=None
):
super().__init__(parent)
self._rules = []
keywordFmt = QTextCharFormat()
keywordFmt.setForeground(QColor("#316BFF"))
keywordFmt.setFontWeight(QFont.Weight.Bold)
for kw in ["IF", "ELSE IF", "ELSE", "ENDIF", "END IF",
"SET", "PASS", "THEN"]:
pattern = r"\b" + kw.replace(" ", r"\s+") + r"\b"
self._rules.append((pattern, keywordFmt))
literalFmt = QTextCharFormat()
literalFmt.setForeground(QColor("#C2185B"))
literalFmt.setFontWeight(QFont.Weight.Bold)
for lit in [".TRUE.", ".FALSE."]:
self._rules.append((r"\b" + lit.replace(".", r"\.") + r"\b", literalFmt))
opFmt = QTextCharFormat()
opFmt.setForeground(QColor("#9C27B0"))
for op in [r"\.EQ\.", r"\.NEQ\.", r"\.BGT\.", r"\.BLT\.",
r"\.BGE\.", r"\.BLE\.", r"\.ADD\.", r"\.SUB\."]:
self._rules.append((op, opFmt))
varFmt = QTextCharFormat()
varFmt.setForeground(QColor("#E65100"))
for var in ["RESERVE_BEGIN_TIME", "RESERVE_END_TIME",
"RESERVE_DATE", "USERNAME", "USER_ENABLE",
"PRIORITY", "CURRENT_TIME", "CURRENT_DATE"]:
self._rules.append((r"\b" + var + r"\b", varFmt))
funcFmt = QTextCharFormat()
funcFmt.setForeground(QColor("#2E7D32"))
self._rules.append((r"\bTIME\([^)]+\)", funcFmt))
self._rules.append((r"\bDATE\([^)]+\)", funcFmt))
strFmt = QTextCharFormat()
strFmt.setForeground(QColor("#388E3C"))
self._rules.append((r"'[^']*'", strFmt))
numFmt = QTextCharFormat()
numFmt.setForeground(QColor("#D32F2F"))
self._rules.append((r"\b\d+\b", numFmt))
commentFmt = QTextCharFormat()
commentFmt.setForeground(QColor("#999999"))
commentFmt.setFontItalic(True)
self._rules.append((r"//[^\n]*", commentFmt))
def highlightBlock(
self,
text
):
import re
for pattern, fmt in self._rules:
for match in re.finditer(pattern, text, re.IGNORECASE):
start = match.start()
length = match.end() - match.start()
self.setFormat(start, length, fmt)
class ALAutoScriptPreviewDialog(QDialog):
def __init__(
self,
parent=None,
script: str = ""
):
super().__init__(parent)
self.__fontSize = 13
self.modifyUi()
self.connectSignals()
self._textEdit.setPlainText(script)
self._highlighter = ALScriptHighlighter(
self._textEdit.document()
)
def modifyUi(
self
):
self.setWindowTitle("AutoScript 预览 - AutoLibrary")
self.setMinimumSize(520, 360)
layout = QVBoxLayout(self)
toolbarLayout = QHBoxLayout()
self._zoomInBtn = QPushButton("")
self._zoomInBtn.setFixedSize(30, 25)
self._zoomOutBtn = QPushButton("")
self._zoomOutBtn.setFixedSize(30, 25)
self._zoomResetBtn = QPushButton(
QApplication.style().standardIcon(
QStyle.StandardPixmap.SP_BrowserReload
), ""
)
self._zoomResetBtn.setFixedSize(30, 25)
self._zoomResetBtn.setToolTip("重置缩放")
self._zoomLabel = QLabel(f"{self.__fontSize}px")
self._zoomLabel.setFixedHeight(25)
toolbarLayout.addWidget(self._zoomInBtn)
toolbarLayout.addWidget(self._zoomOutBtn)
toolbarLayout.addWidget(self._zoomResetBtn)
toolbarLayout.addWidget(self._zoomLabel)
toolbarLayout.addStretch()
self._copyBtn = QPushButton(
QApplication.style().standardIcon(
QStyle.StandardPixmap.SP_FileDialogDetailedView
), ""
)
self._copyBtn.setFixedSize(30, 25)
self._copyBtn.setToolTip("复制脚本")
toolbarLayout.addWidget(self._copyBtn)
layout.addLayout(toolbarLayout)
self._textEdit = QPlainTextEdit(self)
self._textEdit.setReadOnly(True)
self._textEdit.setLineWrapMode(
QPlainTextEdit.LineWrapMode.NoWrap
)
self._textEdit.setStyleSheet(
"QPlainTextEdit {"
" font-family: 'Courier New', 'Consolas', monospace;"
" font-size: 13px;"
"}"
)
layout.addWidget(self._textEdit)
self._btnBox = QDialogButtonBox(
QDialogButtonBox.StandardButton.Close
)
self._btnBox.button(
QDialogButtonBox.StandardButton.Close
).setText("关闭")
layout.addWidget(self._btnBox)
def connectSignals(
self
):
self._btnBox.rejected.connect(self.reject)
self._zoomInBtn.clicked.connect(self.onZoomIn)
self._zoomOutBtn.clicked.connect(self.onZoomOut)
self._zoomResetBtn.clicked.connect(self.onZoomReset)
self._copyBtn.clicked.connect(self.onCopy)
def updateFontSize(
self
):
font = self._textEdit.font()
font.setPointSize(self.__fontSize)
self._textEdit.setFont(font)
self._textEdit.setStyleSheet(
"QPlainTextEdit {"
" font-family: 'Courier New', 'Consolas', monospace;"
f" font-size: {self.__fontSize}px;"
"}"
)
self._zoomLabel.setText(f"{self.__fontSize}px")
@Slot()
def onZoomIn(
self
):
self.__fontSize = min(self.__fontSize + 2, 40)
self.updateFontSize()
@Slot()
def onZoomOut(
self
):
self.__fontSize = max(self.__fontSize - 2, 8)
self.updateFontSize()
@Slot()
def onZoomReset(
self
):
self.__fontSize = 13
self.updateFontSize()
@Slot()
def onCopy(
self
):
clipboard = QApplication.clipboard()
clipboard.setText(self._textEdit.toPlainText())
original = self._copyBtn.text()
self._copyBtn.setText("已复制")
self._copyBtn.setEnabled(False)
from PySide6.QtCore import QTimer
QTimer.singleShot(2000, lambda: (
self._copyBtn.setText(original),
self._copyBtn.setEnabled(True)
))
+136 -150
View File
@@ -10,27 +10,46 @@ See the LICENSE file for details.
import os
from PySide6.QtCore import (
Qt, Signal, Slot, QTime, QDate, QDir, QFileInfo
)
from PySide6.QtWidgets import (
QDialog, QWidget, QLineEdit, QMessageBox, QFileDialog,
QTreeWidgetItem, QMenu, QInputDialog
QDate,
QDir,
QFileInfo,
Qt,
QTime,
Signal,
Slot
)
from PySide6.QtGui import (
QCloseEvent, QAction
QAction,
QCloseEvent
)
from PySide6.QtWidgets import (
QDialog,
QFileDialog,
QInputDialog,
QLineEdit,
QMenu,
QMessageBox,
QTreeWidgetItem,
QWidget
)
import managers.config.ConfigManager as ConfigManager
from utils.JSONReader import JSONReader
from utils.JSONWriter import JSONWriter
from utils.ConfigUtils import ConfigUtils
from gui.resources.ui.Ui_ALConfigWidget import Ui_ALConfigWidget
from gui.ALSeatMapSelectDialog import ALSeatMapSelectDialog
from gui.ALSeatMapTable import ALSeatMapTable
from gui.ALUserTreeWidget import ALUserTreeWidget, ALUserTreeItemType
from gui.ALUserTreeWidget import (
ALUserTreeItemType,
ALUserTreeWidget
)
from gui.ALWebDriverDownloadDialog import ALWebDriverDownloadDialog
from gui.resources.ui.Ui_ALConfigWidget import Ui_ALConfigWidget
from interfaces.ConfigProvider import (
CfgKey,
ConfigProvider
)
from managers.config.ConfigUtils import ConfigUtils
from utils.JSONReader import JSONReader
from utils.JSONWriter import JSONWriter
class ALConfigWidget(QWidget, Ui_ALConfigWidget):
@@ -43,7 +62,7 @@ class ALConfigWidget(QWidget, Ui_ALConfigWidget):
):
super().__init__(parent)
self.__cfg_mgr = ConfigManager.instance()
self.__cfg_mgr: ConfigProvider = ConfigManager.instance()
self.__config_paths = ConfigUtils.getAutomationConfigPaths()
self.__config_data = {"run": {}, "user": {}}
@@ -53,7 +72,6 @@ class ALConfigWidget(QWidget, Ui_ALConfigWidget):
if not self.initializeConfigs():
self.close()
def modifyUi(
self
):
@@ -69,7 +87,6 @@ class ALConfigWidget(QWidget, Ui_ALConfigWidget):
self.initializeFloorRoomMap()
self.initializeUserInfoWidget()
def connectSignals(
self
):
@@ -93,7 +110,6 @@ class ALConfigWidget(QWidget, Ui_ALConfigWidget):
self.ConfirmButton.clicked.connect(self.onConfirmButtonClicked)
self.CancelButton.clicked.connect(self.onCancelButtonClicked)
def showEvent(
self,
event
@@ -117,7 +133,6 @@ class ALConfigWidget(QWidget, Ui_ALConfigWidget):
return result
def closeEvent(
self,
event: QCloseEvent
@@ -126,7 +141,6 @@ class ALConfigWidget(QWidget, Ui_ALConfigWidget):
self.configWidgetIsClosed.emit()
super().closeEvent(event)
def initializeFloorRoomMap(
self
):
@@ -160,7 +174,6 @@ class ALConfigWidget(QWidget, Ui_ALConfigWidget):
"五层": ["五层考研"]
}
def initializeConfigToWidget(
self,
which: str,
@@ -175,7 +188,6 @@ class ALConfigWidget(QWidget, Ui_ALConfigWidget):
self.setUsersToTreeWidget(config_data)
self.CurrentUserConfigEdit.setText(self.__config_paths["user"])
def initializeConfig(
self,
which: str
@@ -209,7 +221,6 @@ class ALConfigWidget(QWidget, Ui_ALConfigWidget):
is_success = False
return is_success
def initializeConfigs(
self
) -> bool:
@@ -222,7 +233,6 @@ class ALConfigWidget(QWidget, Ui_ALConfigWidget):
self.initializeConfigToWidget(which, self.__config_data[which])
return is_success
def defaultRunConfig(
self
) -> dict:
@@ -246,7 +256,6 @@ class ALConfigWidget(QWidget, Ui_ALConfigWidget):
}
}
def defaultUserConfig(
self
) -> dict:
@@ -256,7 +265,6 @@ class ALConfigWidget(QWidget, Ui_ALConfigWidget):
]
}
def collectRunConfigFromWidget(
self
) -> dict:
@@ -278,7 +286,6 @@ class ALConfigWidget(QWidget, Ui_ALConfigWidget):
run_config["mode"]["run_mode"] = run_mode
return run_config
def setRunConfigToWidget(
self,
run_config: dict
@@ -317,7 +324,6 @@ class ALConfigWidget(QWidget, Ui_ALConfigWidget):
"文件可能被意外修改或已经损坏\n"
)
def initializeUserInfoWidget(
self
):
@@ -342,7 +348,6 @@ class ALConfigWidget(QWidget, Ui_ALConfigWidget):
self.MaxRenewTimeDiffSpinBox.setValue(30)
self.PreferLateRenewTimeCheckBox.setChecked(False)
def collectUserFromWidget(
self
) -> dict:
@@ -375,30 +380,28 @@ class ALConfigWidget(QWidget, Ui_ALConfigWidget):
user["reserve_info"]["renew_time"]["prefer_early"] = not self.PreferLateRenewTimeCheckBox.isChecked()
return user
def collectUsersFromTreeWidget(
self
) -> dict:
user_config = self.defaultUserConfig()
for i in range(self.UserTreeWidget.topLevelItemCount()):
group_item = self.UserTreeWidget.topLevelItem(i)
GroupItem = self.UserTreeWidget.topLevelItem(i)
group_config = {
"name": group_item.text(0),
"enabled": group_item.checkState(1) == Qt.CheckState.Checked,
"name": GroupItem.text(0),
"enabled": GroupItem.checkState(1) == Qt.CheckState.Checked,
"users": []
}
for j in range(group_item.childCount()):
user_item = group_item.child(j)
user = user_item.data(0, Qt.UserRole)
for j in range(GroupItem.childCount()):
UserItem = GroupItem.child(j)
user = UserItem.data(0, Qt.UserRole)
if not user:
continue
user["enabled"] = user_item.checkState(1) == Qt.CheckState.Checked
user["enabled"] = UserItem.checkState(1) == Qt.CheckState.Checked
group_config["users"].append(user)
user_config["groups"].append(group_config)
return user_config
def setUserToWidget(
self,
user: dict
@@ -440,7 +443,6 @@ class ALConfigWidget(QWidget, Ui_ALConfigWidget):
"文件可能被意外修改或已经损坏\n"
)
def setUsersToTreeWidget(
self,
users: dict
@@ -451,18 +453,18 @@ class ALConfigWidget(QWidget, Ui_ALConfigWidget):
try:
if "groups" in users:
for group_config in users["groups"]:
group_item = QTreeWidgetItem(self.UserTreeWidget, ALUserTreeItemType.GROUP.value)
group_item.setText(0, group_config["name"])
group_item.setFlags(group_item.flags() | Qt.ItemIsEditable)
group_item.setCheckState(1, Qt.Checked if group_config.get("enabled", True) else Qt.Unchecked)
GroupItem = QTreeWidgetItem(self.UserTreeWidget, ALUserTreeItemType.GROUP.value)
GroupItem.setText(0, group_config["name"])
GroupItem.setFlags(GroupItem.flags() | Qt.ItemIsEditable)
GroupItem.setCheckState(1, Qt.Checked if group_config.get("enabled", True) else Qt.Unchecked)
for user_config in group_config["users"]:
user_item = QTreeWidgetItem(group_item, ALUserTreeItemType.USER.value)
user_item.setText(0, user_config["username"])
user_item.setText(1, "" if user_config.get("enabled", True) else "跳过")
user_item.setData(0, Qt.UserRole, user_config)
user_item.setCheckState(1, Qt.Checked if user_config.get("enabled", True) else Qt.Unchecked)
user_item.setDisabled(not group_config.get("enabled", True))
group_item.setExpanded(True)
UserItem = QTreeWidgetItem(GroupItem, ALUserTreeItemType.USER.value)
UserItem.setText(0, user_config["username"])
UserItem.setText(1, "" if user_config.get("enabled", True) else "跳过")
UserItem.setData(0, Qt.UserRole, user_config)
UserItem.setCheckState(1, Qt.Checked if user_config.get("enabled", True) else Qt.Unchecked)
UserItem.setDisabled(not group_config.get("enabled", True))
GroupItem.setExpanded(True)
except KeyError as e:
QMessageBox.warning(
self,
@@ -482,7 +484,6 @@ class ALConfigWidget(QWidget, Ui_ALConfigWidget):
finally:
self.UserTreeWidget.itemChanged.connect(self.onUserTreeWidgetItemChanged)
def loadRunConfig(
self,
run_config_path: str
@@ -506,7 +507,6 @@ class ALConfigWidget(QWidget, Ui_ALConfigWidget):
)
return None
def saveRunConfig(
self,
run_config_path: str,
@@ -528,7 +528,6 @@ class ALConfigWidget(QWidget, Ui_ALConfigWidget):
)
return False
def loadUserConfig(
self,
user_config_path: str
@@ -562,7 +561,6 @@ class ALConfigWidget(QWidget, Ui_ALConfigWidget):
)
return None
def saveUserConfig(
self,
user_config_path: str,
@@ -584,7 +582,6 @@ class ALConfigWidget(QWidget, Ui_ALConfigWidget):
)
return False
def saveConfigs(
self,
run_config_path: str,
@@ -607,7 +604,6 @@ class ALConfigWidget(QWidget, Ui_ALConfigWidget):
return False
return True
def loadConfig(
self,
config_path: str
@@ -636,52 +632,49 @@ class ALConfigWidget(QWidget, Ui_ALConfigWidget):
except:
return False
def addGroup(
self,
group_name: str = ""
) -> QTreeWidgetItem:
self.UserTreeWidget.itemChanged.disconnect(self.onUserTreeWidgetItemChanged)
group_item = QTreeWidgetItem(self.UserTreeWidget, ALUserTreeItemType.GROUP.value)
GroupItem = QTreeWidgetItem(self.UserTreeWidget, ALUserTreeItemType.GROUP.value)
if not group_name:
group_name = f"新分组-{self.UserTreeWidget.topLevelItemCount()}"
group_item.setText(0, group_name)
group_item.setFlags(group_item.flags() | Qt.ItemIsEditable)
group_item.setCheckState(1, Qt.Checked)
self.UserTreeWidget.setCurrentItem(group_item)
GroupItem.setText(0, group_name)
GroupItem.setFlags(GroupItem.flags() | Qt.ItemIsEditable)
GroupItem.setCheckState(1, Qt.Checked)
self.UserTreeWidget.setCurrentItem(GroupItem)
self.UserTreeWidget.itemChanged.connect(self.onUserTreeWidgetItemChanged)
return group_item
return GroupItem
def delGroup(
self,
group_item: QTreeWidgetItem = None
GroupItem: QTreeWidgetItem = None
):
if group_item is None:
if GroupItem is None:
return
if group_item.type() != ALUserTreeItemType.GROUP.value:
if GroupItem.type() != ALUserTreeItemType.GROUP.value:
return
index = self.UserTreeWidget.indexOfTopLevelItem(group_item)
index = self.UserTreeWidget.indexOfTopLevelItem(GroupItem)
self.UserTreeWidget.takeTopLevelItem(index)
def addUser(
self,
group_item: QTreeWidgetItem = None
GroupItem: QTreeWidgetItem = None
) -> QTreeWidgetItem:
if group_item is None:
current_item = self.UserTreeWidget.currentItem()
if current_item is None:
group_item = self.addGroup()
if group_item.type() == ALUserTreeItemType.USER.value:
group_item = group_item.parent()
if group_item.checkState(1) == Qt.CheckState.Unchecked:
if GroupItem is None:
CurrentItem = self.UserTreeWidget.currentItem()
if CurrentItem is None:
GroupItem = self.addGroup()
if GroupItem.type() == ALUserTreeItemType.USER.value:
GroupItem = GroupItem.parent()
if GroupItem.checkState(1) == Qt.CheckState.Unchecked:
return None
new_user = {
"username": f"新用户-{group_item.childCount()}",
"username": f"新用户-{GroupItem.childCount()}",
"password": "000000",
"enabled": True,
"reserve_info": {
@@ -710,34 +703,32 @@ class ALConfigWidget(QWidget, Ui_ALConfigWidget):
}
}
self.UserTreeWidget.itemChanged.disconnect(self.onUserTreeWidgetItemChanged)
user_item = QTreeWidgetItem(group_item, ALUserTreeItemType.USER.value)
user_item.setText(0, new_user["username"])
user_item.setText(1, "")
user_item.setData(0, Qt.UserRole, new_user)
user_item.setCheckState(1, Qt.CheckState.Checked)
group_item.setExpanded(True)
self.UserTreeWidget.setCurrentItem(user_item)
UserItem = QTreeWidgetItem(GroupItem, ALUserTreeItemType.USER.value)
UserItem.setText(0, new_user["username"])
UserItem.setText(1, "")
UserItem.setData(0, Qt.UserRole, new_user)
UserItem.setCheckState(1, Qt.CheckState.Checked)
GroupItem.setExpanded(True)
self.UserTreeWidget.setCurrentItem(UserItem)
self.setUserToWidget(new_user)
self.UserTreeWidget.itemChanged.connect(self.onUserTreeWidgetItemChanged)
return user_item
return UserItem
def delUser(
self,
user_item: QTreeWidgetItem = None
UserItem: QTreeWidgetItem = None
):
if user_item is None:
if UserItem is None:
return
if user_item.type() != ALUserTreeItemType.USER.value:
if UserItem.type() != ALUserTreeItemType.USER.value:
return
parent_item = user_item.parent()
index = parent_item.indexOfChild(user_item)
parent_item.takeChild(index)
if parent_item.childCount() == 0:
ParentItem = UserItem.parent()
index = ParentItem.indexOfChild(UserItem)
ParentItem.takeChild(index)
if ParentItem.childCount() == 0:
self.UserTreeWidget.setCurrentItem(None)
def renameItem(
self,
item: QTreeWidgetItem,
@@ -796,19 +787,19 @@ class ALConfigWidget(QWidget, Ui_ALConfigWidget):
room = self.RoomComboBox.currentText()
floor_idx = self.__floor_rmap[floor]
room_idx = self.__room_rmap[room]
dialog = ALSeatMapSelectDialog(
Dialog = ALSeatMapSelectDialog(
self,
floor,
room,
ALSeatMapTable[floor_idx][room_idx]
)
dialog.selectSeats(self.SeatIDEdit.text().split(","))
if dialog.exec() == QDialog.DialogCode.Accepted:
selected_seats = dialog.getSelectedSeats()
Dialog.selectSeats(self.SeatIDEdit.text().split(","))
if Dialog.exec() == QDialog.DialogCode.Accepted:
selected_seats = Dialog.getSelectedSeats()
if len(selected_seats) == 0:
self.SeatIDEdit.clear()
return
self.SeatIDEdit.setText(",".join(dialog.getSelectedSeats()))
self.SeatIDEdit.setText(",".join(Dialog.getSelectedSeats()))
@Slot()
def onUserTreeWidgetCurrentItemChanged(
@@ -853,57 +844,54 @@ class ALConfigWidget(QWidget, Ui_ALConfigWidget):
if item.type() == ALUserTreeItemType.GROUP.value:
is_checked = item.checkState(1) == Qt.CheckState.Checked
for i in range(item.childCount()):
child = item.child(i)
if self.UserTreeWidget.currentItem() == child:
Child = item.child(i)
if self.UserTreeWidget.currentItem() == Child:
self.UserTreeWidget.setCurrentItem(item)
child.setDisabled(not is_checked)
Child.setDisabled(not is_checked)
else:
is_checked = item.checkState(1) == Qt.CheckState.Checked
item.setText(1, "" if is_checked else "跳过")
def showTreeMenu(
self,
menu: QMenu
):
add_group_action = QAction("添加分组", menu)
add_group_action.triggered.connect(self.addGroup)
menu.addAction(add_group_action)
AddGroupAction = QAction("添加分组", menu)
AddGroupAction.triggered.connect(self.addGroup)
menu.addAction(AddGroupAction)
def showGroupMenu(
self,
menu: QMenu,
group_item: QTreeWidgetItem = None
GroupItem: QTreeWidgetItem = None
):
add_user_action = QAction("添加用户", menu)
rename_group_action = QAction("重命名分组", menu)
del_group_action = QAction("删除分组", menu)
add_user_action.triggered.connect(lambda: self.addUser(group_item))
rename_group_action.triggered.connect(lambda: self.renameItem(group_item))
del_group_action.triggered.connect(lambda: self.delGroup(group_item))
menu.addAction(add_user_action)
AddUserAction = QAction("添加用户", menu)
RenameGroupAction = QAction("重命名分组", menu)
DelGroupAction = QAction("删除分组", menu)
AddUserAction.triggered.connect(lambda: self.addUser(GroupItem))
RenameGroupAction.triggered.connect(lambda: self.renameItem(GroupItem))
DelGroupAction.triggered.connect(lambda: self.delGroup(GroupItem))
menu.addAction(AddUserAction)
menu.addSeparator()
menu.addAction(rename_group_action)
menu.addAction(del_group_action)
if group_item.checkState(1) == Qt.CheckState.Unchecked:
add_user_action.setEnabled(False)
menu.addAction(RenameGroupAction)
menu.addAction(DelGroupAction)
if GroupItem.checkState(1) == Qt.CheckState.Unchecked:
AddUserAction.setEnabled(False)
def showUserMenu(
self,
menu: QMenu,
user_item: QTreeWidgetItem = None
UserItem: QTreeWidgetItem = None
):
rename_user_action = QAction("重命名用户", menu)
del_user_action = QAction("删除用户", menu)
rename_user_action.triggered.connect(lambda: self.renameItem(user_item))
del_user_action.triggered.connect(lambda: self.delUser(user_item))
menu.addAction(rename_user_action)
menu.addAction(del_user_action)
RenameUserAction = QAction("重命名用户", menu)
DelUserAction = QAction("删除用户", menu)
RenameUserAction.triggered.connect(lambda: self.renameItem(UserItem))
DelUserAction.triggered.connect(lambda: self.delUser(UserItem))
menu.addAction(RenameUserAction)
menu.addAction(DelUserAction)
@Slot()
def onUserTreeWidgetContextMenu(
@@ -911,31 +899,31 @@ class ALConfigWidget(QWidget, Ui_ALConfigWidget):
pos
):
current_item = self.UserTreeWidget.itemAt(pos)
menu = QMenu(self.UserTreeWidget)
if current_item is None:
self.showTreeMenu(menu)
elif current_item.type() == ALUserTreeItemType.GROUP.value:
self.showGroupMenu(menu, current_item)
CurrentItem = self.UserTreeWidget.itemAt(pos)
Menu = QMenu(self.UserTreeWidget)
if CurrentItem is None:
self.showTreeMenu(Menu)
elif CurrentItem.type() == ALUserTreeItemType.GROUP.value:
self.showGroupMenu(Menu, CurrentItem)
else:
self.showUserMenu(menu, current_item)
menu.exec_(self.UserTreeWidget.mapToGlobal(pos))
self.showUserMenu(Menu, CurrentItem)
Menu.exec_(self.UserTreeWidget.mapToGlobal(pos))
@Slot()
def onAddUserButtonClicked(
self
):
current_item = self.UserTreeWidget.currentItem()
self.addUser(current_item)
CurrentItem = self.UserTreeWidget.currentItem()
self.addUser(CurrentItem)
@Slot()
def onDelUserButtonClicked(
self
):
current_item = self.UserTreeWidget.currentItem()
self.delUser(current_item)
CurrentItem = self.UserTreeWidget.currentItem()
self.delUser(CurrentItem)
@Slot()
def onBrowseBrowserDriverButtonClicked(
@@ -951,21 +939,19 @@ class ALConfigWidget(QWidget, Ui_ALConfigWidget):
if browser_driver_path:
self.BrowseBrowserDriverEdit.setText(QDir.toNativeSeparators(browser_driver_path))
@Slot()
def onAutoDownloadWebDriverButtonClicked(
self
):
dialog = ALWebDriverDownloadDialog(self)
dialog.show()
dialog.exec_()
selected_driver_info = dialog.getSelectedDriverInfo()
Dialog = ALWebDriverDownloadDialog(self)
Dialog.show()
Dialog.exec_()
selected_driver_info = Dialog.getSelectedDriverInfo()
if selected_driver_info and selected_driver_info.driver_path:
self.BrowserTypeComboBox.setCurrentText(selected_driver_info.driver_type.value)
self.BrowseBrowserDriverEdit.setText(QDir.toNativeSeparators(str(selected_driver_info.driver_path)))
@Slot()
def onBrowseCurrentRunConfigButtonClicked(
self
@@ -985,13 +971,13 @@ class ALConfigWidget(QWidget, Ui_ALConfigWidget):
self.setRunConfigToWidget(data)
self.__config_paths["run"] = run_config_path
self.CurrentRunConfigEdit.setText(run_config_path)
paths = self.__cfg_mgr.get(ConfigManager.ConfigType.GLOBAL, "automation.run_path.paths", [])
paths = self.__cfg_mgr.get(CfgKey.GLOBAL.AUTOMATION.RUN_PATH.PATHS, [])
if run_config_path not in paths:
paths.append(run_config_path)
index = len(paths) - 1
else:
index = paths.index(run_config_path)
self.__cfg_mgr.set(ConfigManager.ConfigType.GLOBAL, "automation.run_path", {"current": index, "paths": paths})
self.__cfg_mgr.set(CfgKey.GLOBAL.AUTOMATION.RUN_PATH.ROOT, {"current": index, "paths": paths})
else:
QMessageBox.warning(
self,
@@ -1020,13 +1006,13 @@ class ALConfigWidget(QWidget, Ui_ALConfigWidget):
self.setUsersToTreeWidget(data)
self.__config_paths["user"] = user_config_path
self.CurrentUserConfigEdit.setText(user_config_path)
paths = self.__cfg_mgr.get(ConfigManager.ConfigType.GLOBAL, "automation.user_path.paths", [])
paths = self.__cfg_mgr.get(CfgKey.GLOBAL.AUTOMATION.USER_PATH.PATHS, [])
if user_config_path not in paths:
paths.append(user_config_path)
index = len(paths) - 1
else:
index = paths.index(user_config_path)
self.__cfg_mgr.set(ConfigManager.ConfigType.GLOBAL, "automation.user_path", {"current": index, "paths": paths})
self.__cfg_mgr.set(CfgKey.GLOBAL.AUTOMATION.USER_PATH.ROOT, {"current": index, "paths": paths})
else:
QMessageBox.warning(
self,
@@ -1147,8 +1133,8 @@ class ALConfigWidget(QWidget, Ui_ALConfigWidget):
self
):
current_item = self.UserTreeWidget.currentItem()
if current_item and current_item.type() == ALUserTreeItemType.USER.value:
CurrentItem = self.UserTreeWidget.currentItem()
if CurrentItem and CurrentItem.type() == ALUserTreeItemType.USER.value:
self.UserTreeWidget.setCurrentItem(None)
if self.saveConfigs(
self.__config_paths["run"],
+63 -33
View File
@@ -10,24 +10,38 @@ See the LICENSE file for details.
import queue
from PySide6.QtCore import (
Qt, Signal, Slot, QTimer, QUrl,
)
from PySide6.QtWidgets import (
QMainWindow, QMenu, QSystemTrayIcon, QMessageBox
QTimer,
QUrl,
Qt,
Signal,
Slot
)
from PySide6.QtGui import (
QTextCursor, QCloseEvent, QFont, QIcon, QDesktopServices
QCloseEvent,
QDesktopServices,
QFont,
QIcon,
QTextCursor
)
from PySide6.QtWidgets import (
QMainWindow,
QMenu,
QMessageBox,
QSystemTrayIcon
)
from base.MsgBase import MsgBase
from utils.ConfigUtils import ConfigUtils
from gui.resources.ui.Ui_ALMainWindow import Ui_ALMainWindow
from gui.resources import ALResource
from gui.ALConfigWidget import ALConfigWidget
from gui.ALTimerTaskManageWidget import ALTimerTaskManageWidget
from gui.ALAboutDialog import ALAboutDialog
from gui.ALMainWorkers import TimerTaskWorker, AutoLibWorker
from gui.ALConfigWidget import ALConfigWidget
from gui.ALSettingsWidget import ALSettingsWidget
from gui.ALMainWorkers import (
AutoLibWorker,
TimerTaskWorker
)
from gui.ALTimerTaskManageWidget import ALTimerTaskManageWidget
from gui.resources import ALResource
from gui.resources.ui.Ui_ALMainWindow import Ui_ALMainWindow
from managers.config.ConfigUtils import ConfigUtils
class ALMainWindow(MsgBase, QMainWindow, Ui_ALMainWindow):
@@ -47,6 +61,7 @@ class ALMainWindow(MsgBase, QMainWindow, Ui_ALMainWindow):
self.__config_paths = ConfigUtils.getAutomationConfigPaths()
self.__alTimerTaskManageWidget = None
self.__alConfigWidget = None
self.__alSettingsWidget = None
self.__auto_lib_thread = None
self.__current_timer_task_thread = None
self.__is_running_timer_task = False
@@ -59,16 +74,16 @@ class ALMainWindow(MsgBase, QMainWindow, Ui_ALMainWindow):
self.startTimerTaskPolling()
self._showLog("主窗口初始化完成")
def modifyUi(
self
):
self.icon = QIcon(":/res/icon/icons/AutoLibrary_32x32.ico")
self.setWindowIcon(self.icon)
self.Icon = QIcon(":/res/icons/AutoLibrary_Logo_64.svg")
self.setWindowIcon(self.Icon)
self.MessageIOTextEdit.setFont(QFont("Courier New", 10))
self.ManualAction.triggered.connect(self.onManualActionTriggered)
self.AboutAction.triggered.connect(self.onAboutActionTriggered)
self.SettingsAction.triggered.connect(self.onSettingsActionTriggered)
# initialize timer task widget, but not show it
try:
@@ -90,22 +105,19 @@ class ALMainWindow(MsgBase, QMainWindow, Ui_ALMainWindow):
self.__alTimerTaskManageWidget.timerTaskManageWidgetIsClosed.connect(self.onTimerTaskManageWidgetClosed)
self.__alTimerTaskManageWidget.setWindowFlags(Qt.WindowType.Window|Qt.WindowType.WindowCloseButtonHint)
def onAboutActionTriggered(
self
):
about_dialog = ALAboutDialog(self)
about_dialog.exec()
AboutDialog = ALAboutDialog(self)
AboutDialog.exec()
def onManualActionTriggered(
self
):
url = QUrl("https://www.autolibrary.kenanzhu.com/manuals")
QDesktopServices.openUrl(url)
Url = QUrl("https://www.autolibrary.kenanzhu.com/manuals")
QDesktopServices.openUrl(Url)
def setupTray(
self
@@ -114,9 +126,8 @@ class ALMainWindow(MsgBase, QMainWindow, Ui_ALMainWindow):
if not QSystemTrayIcon.isSystemTrayAvailable():
self._showTrace("操作系统不支持系统托盘功能, 无法创建系统托盘图标", self.TraceLevel.WARNING)
return
self.TrayIcon = QSystemTrayIcon(self.icon, self)
self.TrayIcon = QSystemTrayIcon(self.Icon, self)
self.TrayIcon.setToolTip("AutoLibrary")
self.TrayMenu = QMenu()
self.TrayMenu.addAction("显示主窗口", self.showNormal)
self.TrayMenu.addAction("显示定时窗口", self.onTimerTaskManageWidgetButtonClicked)
@@ -128,7 +139,6 @@ class ALMainWindow(MsgBase, QMainWindow, Ui_ALMainWindow):
self.TrayIcon.activated.connect(self.onTrayIconActivated)
self.TrayIcon.show()
def hideToTray(
self
):
@@ -141,7 +151,6 @@ class ALMainWindow(MsgBase, QMainWindow, Ui_ALMainWindow):
2000
)
def onTrayIconActivated(
self,
reason: QSystemTrayIcon.ActivationReason
@@ -150,7 +159,6 @@ class ALMainWindow(MsgBase, QMainWindow, Ui_ALMainWindow):
if reason == QSystemTrayIcon.DoubleClick:
self.showNormal()
def connectSignals(
self
):
@@ -162,7 +170,6 @@ class ALMainWindow(MsgBase, QMainWindow, Ui_ALMainWindow):
self.SendButton.clicked.connect(self.onSendButtonClicked)
self.MessageEdit.returnPressed.connect(self.onSendButtonClicked)
def closeEvent(
self,
event: QCloseEvent
@@ -185,10 +192,12 @@ class ALMainWindow(MsgBase, QMainWindow, Ui_ALMainWindow):
if self.__alConfigWidget:
self.__alConfigWidget.close()
# 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'
self._showLog("主窗口关闭")
QMainWindow.closeEvent(self, event)
def appendToTextEdit(
self,
text: str
@@ -202,7 +211,6 @@ class ALMainWindow(MsgBase, QMainWindow, Ui_ALMainWindow):
scrollbar = self.MessageIOTextEdit.verticalScrollBar()
scrollbar.setValue(scrollbar.maximum())
def startMsgPolling(
self
):
@@ -211,7 +219,6 @@ class ALMainWindow(MsgBase, QMainWindow, Ui_ALMainWindow):
self.__msg_queue_timer.timeout.connect(self.pollMsgQueue)
self.__msg_queue_timer.start(100)
def startTimerTaskPolling(
self
):
@@ -220,7 +227,6 @@ class ALMainWindow(MsgBase, QMainWindow, Ui_ALMainWindow):
self.__timer_task_timer.timeout.connect(self.pollTimerTaskQueue)
self.__timer_task_timer.start(500)
def pollTimerTaskQueue(
self
):
@@ -254,7 +260,6 @@ class ALMainWindow(MsgBase, QMainWindow, Ui_ALMainWindow):
self.__is_running_timer_task = False
pass
def setControlButtons(
self,
config_button_enabled: bool,
@@ -302,6 +307,31 @@ class ALMainWindow(MsgBase, QMainWindow, Ui_ALMainWindow):
self.setControlButtons(True, None, None)
self._showLog("配置窗口已关闭,配置文件路径已更新")
@Slot()
def onSettingsWidgetClosed(
self
):
if self.__alSettingsWidget:
self.__alSettingsWidget.settingsWidgetIsClosed.disconnect(self.onSettingsWidgetClosed)
self.__alSettingsWidget.deleteLater()
self.__alSettingsWidget = None
self.SettingsAction.setEnabled(True)
@Slot()
def onSettingsActionTriggered(
self
):
if self.__alSettingsWidget is None:
self.__alSettingsWidget = ALSettingsWidget(self)
self.__alSettingsWidget.settingsWidgetIsClosed.connect(self.onSettingsWidgetClosed)
self.__alSettingsWidget.show()
self.__alSettingsWidget.raise_()
self.__alSettingsWidget.activateWindow()
self.SettingsAction.setEnabled(False)
self._showLog("打开全局设置窗口")
@Slot(dict)
def onTimerTaskIsReady(
self,
+100 -110
View File
@@ -12,13 +12,14 @@ import time
import queue
from PySide6.QtCore import (
Slot, Signal, QThread
Signal,
QThread,
)
from base.MsgBase import MsgBase
from operators.AutoLib import AutoLib
from pages.AutoLib import AutoLib
from utils.JSONReader import JSONReader
from utils.AutoScriptEngine import AutoScriptEngine
from autoscript import createEngine
class AutoLibWorker(MsgBase, QThread):
@@ -30,14 +31,13 @@ class AutoLibWorker(MsgBase, QThread):
self,
input_queue: queue.Queue,
output_queue: queue.Queue,
config_paths: dict
config_paths: dict,
):
MsgBase.__init__(self, input_queue, output_queue)
QThread.__init__(self)
self.__config_paths = config_paths
def checkTimeAvailable(
self,
) -> bool:
@@ -46,13 +46,12 @@ class AutoLibWorker(MsgBase, QThread):
if current_time >= "23:30" or current_time <= "07:30":
self._showTrace(
"当前时间不在图书馆开放时间内, 请在 07:30 - 23:30 之间尝试",
self.TraceLevel.WARNING
self.TraceLevel.WARNING,
)
return False
self._showLog(f"时间检查通过, 当前时间: {current_time}", self.TraceLevel.INFO)
return True
def checkConfigPaths(
self,
) -> bool:
@@ -62,85 +61,113 @@ class AutoLibWorker(MsgBase, QThread):
):
self._showTrace(
"配置文件路径不存在, 请检查配置文件路径是否正确",
self.TraceLevel.ERROR
self.TraceLevel.ERROR,
)
return False
self._showLog(f"配置文件路径检查通过, 路径: {self.__config_paths}", self.TraceLevel.INFO)
self._showLog(
f"配置文件路径检查通过, 路径: {self.__config_paths}",
self.TraceLevel.INFO,
)
return True
def loadConfigs(
self
self,
) -> bool:
self._showTrace(
f"正在加载配置文件, 运行配置文件路径: {self.__config_paths["run"]}",
no_log=True
no_log=True,
)
self._run_config = JSONReader(self.__config_paths["run"]).data()
self._showTrace(
f"正在加载配置文件, 用户配置文件路径: {self.__config_paths["user"]}",
no_log=True
no_log=True,
)
self._user_config = JSONReader(self.__config_paths["user"]).data()
if self._run_config is None or self._user_config is None:
self._showTrace(
"配置文件加载失败, 请检查配置文件是否正确",
self.TraceLevel.ERROR
self.TraceLevel.ERROR,
)
return False
if not self._user_config.get("groups"):
self._showTrace(
"用户配置文件中无有效任务组, 请检查用户配置文件是否正确",
self.TraceLevel.WARNING
self.TraceLevel.WARNING,
)
return False
self._showLog(
f"配置文件加载成功, 任务组数量: {len(self._user_config.get('groups', []))}",
self.TraceLevel.INFO
f"配置文件加载成功, 任务组数量: {len(self._user_config.get("groups"))}",
self.TraceLevel.INFO,
)
return True
def _runName(
self,
) -> str:
return "常规任务"
def _beforeCreateAutoLib(
self,
):
return
def _onChecksFailed(
self,
) -> bool:
return True
def _onFinished(
self,
):
self.autoLibWorkerIsFinished.emit()
def _onError(
self,
error_msg: str,
):
self._showTrace(error_msg, self.TraceLevel.ERROR)
self.autoLibWorkerFinishedWithError.emit()
def run(
self
self,
):
auto_lib = None
self._showTrace("AutoLibrary 开始运行")
if not self.checkTimeAvailable()\
or not self.checkConfigPaths():
# time or config existence check failed, skip and finish
pass
self._showTrace(f"{self._runName()} 开始运行")
if not self.checkTimeAvailable() or not self.checkConfigPaths():
if not self._onChecksFailed():
return
else:
try:
if not self.loadConfigs():
raise Exception("配置文件加载失败")
self._beforeCreateAutoLib()
auto_lib = AutoLib(
self._input_queue,
self._output_queue,
self._run_config
self._run_config,
)
groups = self._user_config.get("groups")
for group in groups:
if not group["enabled"]:
self._showTrace(f"任务组 {group["name"]} 已跳过", no_log=True)
if not group.get("enabled", False):
self._showTrace(f"任务组 {group.get("name", "未知")} 已跳过", no_log=True)
continue
self._showTrace(f"正在运行任务组 {group["name"]}", no_log=True)
auto_lib.run(
{ "users": group.get("users", []) }
)
self._showTrace(f"正在运行任务组 {group.get("name", "未知")}", no_log=True)
auto_lib.run({"users": group.get("users", [])})
except Exception as e:
self._showTrace(
f"AutoLibrary 运行时发生异常 : {e}",
self.TraceLevel.ERROR
)
self.autoLibWorkerFinishedWithError.emit()
self._onError(f"{self._runName()} 运行时发生异常 : {e}")
return
if auto_lib:
auto_lib.close()
self._showTrace("AutoLibrary 运行结束")
self.autoLibWorkerIsFinished.emit()
self._showTrace(f"{self._runName()} 运行结束")
self._onFinished()
class TimerTaskWorker(AutoLibWorker):
@@ -152,72 +179,54 @@ class TimerTaskWorker(AutoLibWorker):
timer_task: dict,
input_queue: queue.Queue,
output_queue: queue.Queue,
config_paths: dict
config_paths: dict,
):
super().__init__(input_queue, output_queue, config_paths)
self.__timer_task = timer_task
self.autoLibWorkerIsFinished.connect(self.onTimerTaskIsFinished)
self.autoLibWorkerFinishedWithError.connect(self.onTimerTaskFinishedWithError)
def _runName(
self,
) -> str:
return f"定时任务 '{self.__timer_task.get("name", "未知")}'"
def run(
self
def _beforeCreateAutoLib(
self,
):
self.applyRepeatAutoScript()
def _onChecksFailed(
self,
) -> bool:
self._showTrace("定时任务跳过执行: 时间或配置文件检查未通过")
self.timerTaskWorkerIsFinished.emit(False, self.__timer_task)
return False
def _onFinished(
self,
):
self._showTrace(f"定时任务 {self.__timer_task['name']} 开始运行")
if not self.checkTimeAvailable() or not self.checkConfigPaths():
self._showTrace("定时任务跳过执行 (时间或配置文件检查未通过)")
self.timerTaskWorkerIsFinished.emit(False, self.__timer_task)
return
try:
if not self.loadConfigs():
raise Exception("配置文件加载失败")
self.applyRepeatAutoScript()
auto_lib = AutoLib(
self._input_queue,
self._output_queue,
self._run_config
)
groups = self._user_config.get("groups")
for group in groups:
if not group["enabled"]:
self._showTrace(
f"任务组 {group['name']} 已跳过",
no_log=True
)
continue
self._showTrace(
f"正在运行任务组 {group['name']}",
no_log=True
)
auto_lib.run(
{"users": group.get("users", [])}
)
auto_lib.close()
except Exception as e:
self._showTrace(
f"定时任务 {self.__timer_task['name']} 运行时发生异常: {e}",
self.TraceLevel.ERROR
)
self.timerTaskWorkerIsFinished.emit(True, self.__timer_task)
return
self._showTrace(f"定时任务 {self.__timer_task['name']} 运行结束")
self.timerTaskWorkerIsFinished.emit(False, self.__timer_task)
def _onError(
self,
error_msg: str,
):
self._showTrace(error_msg, self.TraceLevel.ERROR)
self.timerTaskWorkerIsFinished.emit(True, self.__timer_task)
def applyRepeatAutoScript(
self
self,
):
auto_script = self.__timer_task.get("repeat_auto_script", "")
if not auto_script or not auto_script.strip():
return
self._showTrace(
f"检测到重复定时任务 AutoScript, 开始执行...",
no_log=True
)
self._showTrace("检测到重复定时任务 AutoScript, 开始执行...", no_log=True)
groups = self._user_config.get("groups", [])
affected_count = 0
for group in groups:
@@ -225,34 +234,15 @@ class TimerTaskWorker(AutoLibWorker):
continue
for user in group.get("users", []):
try:
AutoScriptEngine.execute(auto_script, user)
engine = createEngine()
engine.execute(auto_script, user)
affected_count += 1
except ValueError as e:
self._showTrace(
f"AutoScript 执行错误 (用户 {user['username']}): {e}",
self.TraceLevel.ERROR
f"AutoScript 执行错误 (用户 {user.get("username", "未知")}): {e}",
self.TraceLevel.ERROR,
)
self._showLog(
f"AutoScript 执行完毕, "
f"影响 {affected_count} 个用户",
self.TraceLevel.INFO
f"AutoScript 执行完毕, 影响 {affected_count} 个用户",
self.TraceLevel.INFO,
)
@Slot()
def onTimerTaskIsFinished(
self
):
self._showTrace(f"定时任务 {self.__timer_task['name']} 运行结束")
self.timerTaskWorkerIsFinished.emit(False, self.__timer_task)
@Slot()
def onTimerTaskFinishedWithError(
self
):
self._showTrace(
f"定时任务 {self.__timer_task['name']} 运行时发生异常",
self.TraceLevel.ERROR
)
self.timerTaskWorkerIsFinished.emit(True, self.__timer_task)
-2
View File
@@ -63,14 +63,12 @@ class ALSeatFrame(QFrame):
self.toggleSelection()
self.clicked.emit(self.__seat_number)
def isSelected(
self
):
return self.__is_selected
def toggleSelection(self):
self.__is_selected = not self.__is_selected
+10 -13
View File
@@ -8,15 +8,20 @@ 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, Slot, Signal
)
from PySide6.QtWidgets import (
QDialog, QLabel, QHBoxLayout, QVBoxLayout,
QPushButton,
Qt,
Signal,
Slot
)
from PySide6.QtGui import (
QCloseEvent
)
from PySide6.QtWidgets import (
QDialog,
QHBoxLayout,
QLabel,
QPushButton,
QVBoxLayout
)
from gui.ALSeatMapView import ALSeatMapView
@@ -42,7 +47,6 @@ class ALSeatMapSelectDialog(QDialog):
self.setupUi()
self.connectSignals()
def setupUi(
self
):
@@ -85,7 +89,6 @@ class ALSeatMapSelectDialog(QDialog):
self.SeatMapWidgetControlLayout.addWidget(self.ConfirmButton)
self.SeatMapWidgetMainLayout.addLayout(self.SeatMapWidgetControlLayout)
def connectSignals(
self
):
@@ -93,7 +96,6 @@ class ALSeatMapSelectDialog(QDialog):
self.ConfirmButton.clicked.connect(self.onConfirmButtonClicked)
self.CancelButton.clicked.connect(self.onCancelButtonClicked)
def showEvent(
self,
event
@@ -117,7 +119,6 @@ class ALSeatMapSelectDialog(QDialog):
return result
def closeEvent(
self,
event: QCloseEvent
@@ -131,7 +132,6 @@ class ALSeatMapSelectDialog(QDialog):
self.seatMapSelectDialogIsClosed.emit(self.getSelectedSeats())
super().closeEvent(event)
def selectSeat(
self,
seat_number: str
@@ -139,7 +139,6 @@ class ALSeatMapSelectDialog(QDialog):
self.SeatMapGraphicsView.selectSeat(seat_number)
def selectSeats(
self,
seat_numbers: list[str]
@@ -147,14 +146,12 @@ class ALSeatMapSelectDialog(QDialog):
return self.SeatMapGraphicsView.selectSeats(seat_numbers)
def getSelectedSeats(
self
) -> list[str]:
return self.SeatMapGraphicsView.getSelectedSeats()
def clearSelections(
self
):
+31 -32
View File
@@ -8,14 +8,21 @@ 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, Slot, QEvent
Qt,
Slot,
QEvent
)
from PySide6.QtWidgets import (
QFrame, QWidget,
QGridLayout, QGraphicsView, QGraphicsScene, QGraphicsItem
QFrame,
QWidget,
QGridLayout,
QGraphicsView,
QGraphicsScene,
QGraphicsItem
)
from PySide6.QtGui import (
QPainter, QWheelEvent
QPainter,
QWheelEvent
)
from gui.ALSeatFrame import ALSeatFrame
@@ -35,18 +42,6 @@ class ALSeatMapView(QGraphicsView):
self.setupUi()
@staticmethod
def formatSeatNumber(
seat_number: str
) -> str:
if seat_number and not seat_number[-1].isdigit():
digits = seat_number[:-1]
letter = seat_number[-1]
return digits.zfill(3) + letter
return seat_number.zfill(3)
def eventFilter(
self,
watched,
@@ -61,7 +56,6 @@ class ALSeatMapView(QGraphicsView):
return True
return super().eventFilter(watched, event)
def zoomGraphicsView(
self,
event: QWheelEvent
@@ -80,7 +74,6 @@ class ALSeatMapView(QGraphicsView):
self.setTransformationAnchor(QGraphicsView.ViewportAnchor.AnchorUnderMouse)
self.scale(zoom_factor, zoom_factor)
def setupUi(
self
):
@@ -100,7 +93,6 @@ class ALSeatMapView(QGraphicsView):
self.ContainerProxy = self.SeatMapGraphicsScene.addWidget(self.SeatsContainerWidget)
self.ContainerProxy.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsSelectable, False)
def setupSeatMap(
self
):
@@ -111,21 +103,20 @@ class ALSeatMapView(QGraphicsView):
seats_number = [seat.strip() for seat in row.split(",")]
for seat_number in seats_number:
if seat_number:
seat_widget = ALSeatFrame(seat_number)
seat_widget.clicked.connect(self.onSeatClicked)
self.SeatsContainerLayout.addWidget(seat_widget, row_idx, col_idx)
self.__seat_frames[seat_number] = seat_widget
SeatWidget = ALSeatFrame(seat_number)
SeatWidget.clicked.connect(self.onSeatClicked)
self.SeatsContainerLayout.addWidget(SeatWidget, row_idx, col_idx)
self.__seat_frames[seat_number] = SeatWidget
else:
spacer = QFrame()
spacer.setFixedSize(20, 30)
spacer.setStyleSheet("background-color: transparent; border: none;")
self.SeatsContainerLayout.addWidget(spacer, row_idx, col_idx)
Spacer = QFrame()
Spacer.setFixedSize(20, 30)
Spacer.setStyleSheet("background-color: transparent; border: none;")
self.SeatsContainerLayout.addWidget(Spacer, row_idx, col_idx)
col_idx += 1
self.SeatsContainerLayout.setSpacing(20)
self.SeatsContainerLayout.setContentsMargins(20, 20, 20, 20)
self.SeatsContainerWidget.adjustSize()
def selectSeat(
self,
seat_number: str
@@ -142,7 +133,6 @@ class ALSeatMapView(QGraphicsView):
widget.toggleSelection()
self.__selected_seats.append(seat_number)
def selectSeats(
self,
selected_seats: list
@@ -152,14 +142,12 @@ class ALSeatMapView(QGraphicsView):
for seat_number in selected_seats:
self.selectSeat(seat_number)
def getSelectedSeats(
self
) -> list[str]:
return self.__selected_seats
def clearSelections(
self
):
@@ -185,4 +173,15 @@ class ALSeatMapView(QGraphicsView):
if len(self.__selected_seats) < 1:
self.__selected_seats.append(seat_number)
else:
self.__seat_frames[seat_number].toggleSelection()
self.__seat_frames[seat_number].toggleSelection()
@staticmethod
def formatSeatNumber(
seat_number: str
) -> str:
if seat_number and not seat_number[-1].isdigit():
digits = seat_number[:-1]
letter = seat_number[-1]
return digits.zfill(3) + letter
return seat_number.zfill(3)
+458
View File
@@ -0,0 +1,458 @@
# -*- 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 qtawesome as qta
from PySide6.QtCore import (
QProcess,
Qt,
Signal,
Slot
)
from PySide6.QtGui import (
QCloseEvent,
QShowEvent
)
from PySide6.QtWidgets import (
QApplication,
QFileDialog,
QMessageBox,
QStyleFactory,
QWidget
)
import managers.config.ConfigManager as ConfigManager
from managers.log.LogManager import instance as logInstance
from managers.theme.ThemeManager import(
getActiveStyle,
setActiveStyle,
instance as themeInstance
)
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(QWidget, Ui_ALSettingsWidget):
settingsWidgetIsClosed = 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.setupUi(self)
self.modifyUi()
self.connectSignals()
self.loadSettings()
def modifyUi(
self
):
self.setWindowFlags(Qt.WindowType.Window)
self.NavigationList.setCurrentRow(0)
self.populateStyles()
self.setNavigationIcons()
color = QApplication.instance().palette().color(
QApplication.instance().palette().ColorRole.WindowText
).name()
self.BrowseQssButton.setIcon(qta.icon("fa6s.plus", color=color))
self.BrowseQssButton.setText("")
self.RemoveThemeButton.setIcon(qta.icon("fa6s.minus", color=color))
self.RemoveThemeButton.setText("")
self.ThemeInfoLabel.setTextFormat(Qt.TextFormat.RichText)
self.ThemeInfoLabel.setStyleSheet(
"border: 1px solid palette(mid);"\
"border-radius: 2px;"\
"padding: 5px;"
)
def setNavigationIcons(
self
):
app : QApplication | None = QApplication.instance()
color = app.palette().color(app.palette().ColorRole.WindowText).name()
item = self.NavigationList.item(0)
if item:
item.setIcon(qta.icon("fa6s.palette", color=color))
def populateStyles(
self
):
self.StyleComboBox.clear()
self.StyleComboBox.addItems(QStyleFactory.keys())
def connectSignals(
self
):
self.BrowseQssButton.clicked.connect(self.onImportThemeButtonClicked)
self.RemoveThemeButton.clicked.connect(self.onRemoveThemeButtonClicked)
self.ThemeComboBox.currentIndexChanged.connect(self.onThemeComboBoxChanged)
self.ResetThemeButton.clicked.connect(self.onResetThemeButtonClicked)
self.CancelButton.clicked.connect(self.onCancelButtonClicked)
self.ApplyButton.clicked.connect(self.onApplyButtonClicked)
self.ConfirmButton.clicked.connect(self.onConfirmButtonClicked)
def showEvent(
self,
event: QShowEvent
):
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(
self,
event: QCloseEvent
):
self.settingsWidgetIsClosed.emit()
super().closeEvent(event)
def loadSettings(
self
):
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)
self.populateThemeList()
if custom_theme:
idx = self.ThemeComboBox.findData(custom_theme)
if idx >= 0:
self.ThemeComboBox.setCurrentIndex(idx)
self.updateThemeStatus()
self.updateThemeInfo()
def updateThemeStatus(
self
):
file = self.ThemeComboBox.currentData()
t = self.__theme_cache.get(file) if file else None
name = t.get("name", "") if t else ""
if name:
self.QssStatusLabel.setText(f"当前使用 {name} 主题。")
else:
self.QssStatusLabel.setText("当前使用 默认 主题。")
def updateThemeInfo(
self
):
file = self.ThemeComboBox.currentData()
if not file:
self.ThemeInfoLabel.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.ThemeInfoLabel.setText(
f"<b>{name}</b> - 适用于 <i>{_themeToReadable(need_theme)}</i> 主题<br>"
f"作者:{author}<br><br>"
f"{brief}"
)
else:
self.ThemeInfoLabel.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 collectSettings(
self
):
if self.LightThemeRadio.isChecked():
theme = "light"
elif self.DarkThemeRadio.isChecked():
theme = "dark"
else:
theme = "system"
style = self.StyleComboBox.currentText()
custom_theme = self.ThemeComboBox.currentData() or ""
if not custom_theme:
custom_theme = ""
return theme, style, custom_theme
def saveAndApply(
self
):
theme, style, custom_theme = self.collectSettings()
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)
# Re-read theme after syncRadioFromNeedTheme — the radio may have
# changed to match the custom theme's need_theme
theme, _, _ = self.collectSettings()
self.__cfg_mgr.set(CfgKey.GLOBAL.APPEARANCE.THEME, theme)
self.setNavigationIcons()
self.updateThemeStatus()
self.updateThemeInfo()
self.__original_theme = theme
self.__original_custom_theme = custom_theme if custom_theme else ""
self.__original_style = getActiveStyle()
def maybeRestart(
self
) -> bool:
reply = QMessageBox.question(
self,
"提示 - AutoLibrary",
"界面风格已修改,需要重启程序才能生效。是否立即重启?",
QMessageBox.Yes | QMessageBox.No,
QMessageBox.Yes
)
if reply == QMessageBox.Yes:
_restartApp()
return True
return False
def populateThemeList(
self
):
self.ThemeComboBox.blockSignals(True)
self.ThemeComboBox.clear()
self.ThemeComboBox.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.ThemeComboBox.addItem(name, file)
self.ThemeComboBox.blockSignals(False)
@Slot()
def onRemoveThemeButtonClicked(
self
):
file = self.ThemeComboBox.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.populateThemeList()
self.ThemeComboBox.setCurrentIndex(0)
self.updateThemeStatus()
self.updateThemeInfo()
except Exception as e:
QMessageBox.warning(
self,
"删除失败 - AutoLibrary",
f"无法删除主题:{e}"
)
@Slot()
def onImportThemeButtonClicked(
self
):
file_path, _ = QFileDialog.getOpenFileName(
self,
"导入主题 - AutoLibrary",
"",
"主题文件 (*.altheme *.qss);;所有文件 (*)"
)
if not file_path:
return
try:
file_id = themeInstance().importTheme(file_path)
self.populateThemeList()
idx = self.ThemeComboBox.findData(file_id)
if idx >= 0:
self.ThemeComboBox.setCurrentIndex(idx)
self.updateThemeStatus()
self.updateThemeInfo()
except Exception as e:
QMessageBox.warning(
self,
"导入失败 - AutoLibrary",
f"无法导入主题文件:{e}"
)
@Slot()
def onThemeComboBoxChanged(
self,
index: int
):
self.updateThemeInfo()
@Slot()
def onResetThemeButtonClicked(
self
):
self.ThemeComboBox.blockSignals(True)
if self.__original_custom_theme:
idx = self.ThemeComboBox.findData(self.__original_custom_theme)
if idx >= 0:
self.ThemeComboBox.setCurrentIndex(idx)
else:
self.ThemeComboBox.setCurrentIndex(0)
else:
self.ThemeComboBox.setCurrentIndex(0)
self.ThemeComboBox.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)
_applyCustomTheme(self.__original_custom_theme, self.__original_theme)
self.updateThemeStatus()
self.updateThemeInfo()
@Slot()
def onCancelButtonClicked(
self
):
self.close()
@Slot()
def onApplyButtonClicked(
self
):
_, style, _ = self.collectSettings()
style_changed = self.__original_style != style
self.saveAndApply()
if style_changed:
self.maybeRestart()
@Slot()
def onConfirmButtonClicked(
self
):
_, style, _ = self.collectSettings()
style_changed = self.__original_style != style
self.saveAndApply()
if style_changed:
self.maybeRestart()
self.close()
+78 -77
View File
@@ -9,14 +9,20 @@ See the LICENSE file for details.
"""
from enum import Enum
from PySide6.QtWidgets import (
QLabel
)
from PySide6.QtCore import (
Qt, Property, QPropertyAnimation, QEasingCurve
Property,
QEasingCurve,
QPropertyAnimation,
Qt
)
from PySide6.QtGui import (
QPainter, QColor, QConicalGradient, QPalette
QColor,
QConicalGradient,
QPainter,
QPalette
)
from PySide6.QtWidgets import (
QLabel
)
@@ -44,14 +50,12 @@ class ALStatusLabel(QLabel):
self.setupUi()
def setupUi(
self
):
self.setFixedSize(36, 36)
self.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.RunningAnimation = QPropertyAnimation(self, b"iconAngle")
self.RunningAnimation.setDuration(1000)
self.RunningAnimation.setStartValue(0)
@@ -59,14 +63,12 @@ class ALStatusLabel(QLabel):
self.RunningAnimation.setLoopCount(-1)
self.RunningAnimation.setEasingCurve(QEasingCurve.Type.Linear)
def isDarkMode(
self
) -> bool:
return self.palette().color(QPalette.ColorRole.Window).value() < 128
def getMarkColor(
self
) -> QColor:
@@ -111,41 +113,40 @@ class ALStatusLabel(QLabel):
self.__icon_angle = value
self.update()
def paintEvent(
self,
event
):
painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
Painter = QPainter(self)
Painter.setRenderHint(QPainter.RenderHint.Antialiasing)
center_x = self.width()/2
center_y = self.height()/2
radius = min(center_x, center_y) - 3
match self.__status:
case self.Status.WAITING:
pen = painter.pen()
pen.setWidth(2)
pen.setBrush(Qt.BrushStyle.NoBrush)
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
pen.setColor(QColor("#969696")) # grey
painter.setPen(pen)
painter.drawEllipse(
Pen = Painter.pen()
Pen.setWidth(2)
Pen.setBrush(Qt.BrushStyle.NoBrush)
Pen.setCapStyle(Qt.PenCapStyle.RoundCap)
Pen.setColor(QColor("#969696")) # grey
Painter.setPen(Pen)
Painter.drawEllipse(
int(center_x - radius),
int(center_y - radius),
int(radius*2),
int(radius*2)
)
case self.Status.RUNNING:
gradient = QConicalGradient(center_x, center_y, self.__icon_angle)
gradient.setColorAt(0.0, QColor("#2294FF" if self.isDarkMode() else "#0094FF"))
gradient.setColorAt(1.0, QColor("#2294FF00"))
pen = painter.pen()
pen.setWidth(3)
pen.setBrush(gradient)
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
painter.setPen(pen)
painter.drawEllipse(
Gradient = QConicalGradient(center_x, center_y, self.__icon_angle)
Gradient.setColorAt(0.0, QColor("#2294FF" if self.isDarkMode() else "#0094FF"))
Gradient.setColorAt(1.0, QColor("#2294FF00"))
Pen = Painter.pen()
Pen.setWidth(3)
Pen.setBrush(Gradient)
Pen.setCapStyle(Qt.PenCapStyle.RoundCap)
Painter.setPen(Pen)
Painter.drawEllipse(
int(center_x - radius),
int(center_y - radius),
int(radius*2),
@@ -153,102 +154,102 @@ class ALStatusLabel(QLabel):
)
case self.Status.SUCCESS:
# draw the success green circle
pen = painter.pen()
pen.setWidth(2)
pen.setBrush(Qt.BrushStyle.NoBrush)
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
pen.setColor(QColor("#4CAF50" if self.isDarkMode() else "#00AF50")) # green
painter.setPen(pen)
painter.drawEllipse(
Pen = Painter.pen()
Pen.setWidth(2)
Pen.setBrush(Qt.BrushStyle.NoBrush)
Pen.setCapStyle(Qt.PenCapStyle.RoundCap)
Pen.setColor(QColor("#4CAF50" if self.isDarkMode() else "#00AF50")) # green
Painter.setPen(Pen)
Painter.drawEllipse(
int(center_x - radius),
int(center_y - radius),
int(radius*2),
int(radius*2)
)
# draw the success check mark '✓'
painter.setPen(Qt.PenStyle.SolidLine)
pen = painter.pen()
pen.setWidth(3)
pen.setBrush(Qt.BrushStyle.NoBrush)
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
Painter.setPen(Qt.PenStyle.SolidLine)
Pen = Painter.pen()
Pen.setWidth(3)
Pen.setBrush(Qt.BrushStyle.NoBrush)
Pen.setCapStyle(Qt.PenCapStyle.RoundCap)
# white when dark mode, black when light mode
pen.setColor(self.getMarkColor())
painter.setPen(pen)
Pen.setColor(self.getMarkColor())
Painter.setPen(Pen)
mark_size = radius/2
mark_path = [
(center_x - mark_size, center_y),
(center_x - mark_size/3, 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[1][0]),int(mark_path[1][1])
)
painter.drawLine(
Painter.drawLine(
int(mark_path[1][0]),int(mark_path[1][1]),
int(mark_path[2][0]),int(mark_path[2][1])
)
case self.Status.WARNING:
# draw the warning orange circle
pen = painter.pen()
pen.setWidth(2)
pen.setBrush(Qt.BrushStyle.NoBrush)
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
pen.setColor(QColor("#FF9800")) # orange
painter.setPen(pen)
painter.drawEllipse(
Pen = Painter.pen()
Pen.setWidth(2)
Pen.setBrush(Qt.BrushStyle.NoBrush)
Pen.setCapStyle(Qt.PenCapStyle.RoundCap)
Pen.setColor(QColor("#FF9800")) # orange
Painter.setPen(Pen)
Painter.drawEllipse(
int(center_x - radius),
int(center_y - radius),
int(radius*2),
int(radius*2)
)
# draw the warning exclamation mark '!'
painter.setPen(Qt.PenStyle.SolidLine)
pen = painter.pen()
pen.setWidth(3)
pen.setBrush(Qt.BrushStyle.NoBrush)
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
Painter.setPen(Qt.PenStyle.SolidLine)
Pen = Painter.pen()
Pen.setWidth(3)
Pen.setBrush(Qt.BrushStyle.NoBrush)
Pen.setCapStyle(Qt.PenCapStyle.RoundCap)
# white when dark mode, black when light mode
pen.setColor(self.getMarkColor())
painter.setPen(pen)
painter.drawLine(
Pen.setColor(self.getMarkColor())
Painter.setPen(Pen)
Painter.drawLine(
int(center_x), int(center_y - radius/2),
int(center_x), int(center_y + radius/6)
)
painter.drawPoint(
Painter.drawPoint(
int(center_x), int(center_y + radius/2)
)
case self.Status.FAILURE:
# draw the failure red circle
pen = painter.pen()
pen.setWidth(2)
pen.setBrush(Qt.BrushStyle.NoBrush)
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
pen.setColor(QColor("#DC0000")) # red
painter.setPen(pen)
painter.drawEllipse(
Pen = Painter.pen()
Pen.setWidth(2)
Pen.setBrush(Qt.BrushStyle.NoBrush)
Pen.setCapStyle(Qt.PenCapStyle.RoundCap)
Pen.setColor(QColor("#DC0000")) # red
Painter.setPen(Pen)
Painter.drawEllipse(
int(center_x - radius),
int(center_y - radius),
int(radius*2),
int(radius*2)
)
# draw the failure cross mark '✗'
painter.setPen(Qt.PenStyle.SolidLine)
pen = painter.pen()
pen.setWidth(3)
pen.setBrush(Qt.BrushStyle.NoBrush)
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
Painter.setPen(Qt.PenStyle.SolidLine)
Pen = Painter.pen()
Pen.setWidth(3)
Pen.setBrush(Qt.BrushStyle.NoBrush)
Pen.setCapStyle(Qt.PenCapStyle.RoundCap)
# white when dark mode, black when light mode
pen.setColor(self.getMarkColor())
painter.setPen(pen)
Pen.setColor(self.getMarkColor())
Painter.setPen(Pen)
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)
)
painter.drawLine(
Painter.drawLine(
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)
+44 -45
View File
@@ -12,12 +12,25 @@ import uuid
from enum import Enum
from datetime import datetime, timedelta
from PySide6.QtCore import Slot, QDateTime, QUrl
from PySide6.QtCore import (
Slot,
QDateTime,
QUrl
)
from PySide6.QtGui import QDesktopServices
from PySide6.QtWidgets import QLabel, QDialog, QWidget, QSpinBox, QHBoxLayout, QVBoxLayout, QGridLayout, QDateTimeEdit, QGroupBox, QPushButton
from PySide6.QtWidgets import (
QLabel,
QDialog,
QWidget,
QSpinBox,
QHBoxLayout,
QVBoxLayout,
QDateTimeEdit,
QGroupBox,
QPushButton
)
from gui.resources.ui.Ui_ALTimerTaskAddDialog import Ui_ALTimerTaskAddDialog
from gui.ALAutoScriptOrchDialog import ALAutoScriptOrchDialog
from utils.TimerUtils import TimerUtils
@@ -50,7 +63,6 @@ class ALTimerTaskAddDialog(QDialog, Ui_ALTimerTaskAddDialog):
if self.__edit_timer_task:
self.loadTask(self.__edit_timer_task)
def modifyUi(
self
):
@@ -58,6 +70,8 @@ class ALTimerTaskAddDialog(QDialog, Ui_ALTimerTaskAddDialog):
self.TimerTypeComboBox.setCurrentIndex(0)
self.SpecificTimerWidget = QWidget()
self.SpecificTimerLayout = QHBoxLayout(self.SpecificTimerWidget)
self.SpecificTimerLayout.setContentsMargins(0, 0, 0, 0)
self.SpecificTimerLayout.setSpacing(5)
self.SpecificTimerLayout.addWidget(QLabel("定时时间:"))
self.SpecificDateTimeEdit = QDateTimeEdit()
self.SpecificDateTimeEdit.setCalendarPopup(True)
@@ -66,9 +80,10 @@ class ALTimerTaskAddDialog(QDialog, Ui_ALTimerTaskAddDialog):
self.SpecificDateTimeEdit.setDateTime(QDateTime.currentDateTime().addSecs(60))
self.SpecificTimerLayout.addWidget(self.SpecificDateTimeEdit)
self.TimerConfigLayout.addWidget(self.SpecificTimerWidget)
self.RelativeTimerWidget = QWidget()
self.RelativeTimerLayout = QHBoxLayout(self.RelativeTimerWidget)
self.RelativeTimerLayout.setContentsMargins(0, 0, 0, 0)
self.RelativeTimerLayout.setSpacing(5)
self.RelativeTimerLayout.addWidget(QLabel("相对时间:"))
self.RelativeDaySpinBox = QSpinBox()
self.RelativeDaySpinBox.setMinimum(0)
@@ -92,26 +107,20 @@ class ALTimerTaskAddDialog(QDialog, Ui_ALTimerTaskAddDialog):
self.RelativeTimerLayout.addWidget(self.RelativeSecondSpinBox)
self.TimerConfigLayout.addWidget(self.RelativeTimerWidget)
self.RelativeTimerWidget.setVisible(False)
self.AutoScriptGroupBox = QGroupBox("AutoScript 指令")
self.AutoScriptLayout = QVBoxLayout(self.AutoScriptGroupBox)
self.AutoScriptLayout.setContentsMargins(3, 3, 3, 3)
self.AutoScriptLayout.setSpacing(3)
autoScriptBtnLayout = QHBoxLayout()
self.AutoScriptSetButton = QPushButton("设置指令")
self.AutoScriptSetButton.setMinimumHeight(25)
self.AutoScriptSetButton.setFixedWidth(130)
autoScriptBtnLayout.addWidget(self.AutoScriptSetButton)
self.AutoScriptPreviewButton = QPushButton("预览")
self.AutoScriptPreviewButton.setMinimumHeight(25)
self.AutoScriptPreviewButton.setFixedWidth(60)
self.AutoScriptPreviewButton.setEnabled(False)
autoScriptBtnLayout.addWidget(self.AutoScriptPreviewButton)
autoScriptBtnLayout.addStretch()
AutoScriptBtnLayout = QHBoxLayout()
self.AutoScriptEditButton = QPushButton("编辑")
self.AutoScriptEditButton.setMinimumHeight(25)
self.AutoScriptEditButton.setFixedWidth(80)
AutoScriptBtnLayout.addWidget(self.AutoScriptEditButton)
AutoScriptBtnLayout.addStretch()
self.AutoScriptHelpButton = QPushButton("?")
self.AutoScriptHelpButton.setFixedSize(20, 20)
self.AutoScriptHelpButton.setToolTip(
"AutoScript 是一种轻量级 DSL\n"
"AutoScript 是一种轻量级 DSL 语言,基于 Lua 实现。\n"
"用于在重复定时任务执行前,对用户的预约数据进行预处理\n"
"\n"
"点击查看完整在线文档"
@@ -121,19 +130,19 @@ class ALTimerTaskAddDialog(QDialog, Ui_ALTimerTaskAddDialog):
"font-weight: bold; color: #555; }"
"QPushButton:hover { background-color: #E0E0E0; }"
)
autoScriptBtnLayout.addWidget(self.AutoScriptHelpButton)
AutoScriptBtnLayout.addWidget(self.AutoScriptHelpButton)
self.AutoScriptStatusLabel = QLabel("未设置")
self.AutoScriptStatusLabel.setStyleSheet("color: #969696;")
self.AutoScriptStatusLabel.setFixedHeight(25)
autoScriptBtnLayout.addWidget(self.AutoScriptStatusLabel)
self.AutoScriptLayout.addLayout(autoScriptBtnLayout)
AutoScriptBtnLayout.addWidget(self.AutoScriptStatusLabel)
self.AutoScriptLayout.addLayout(AutoScriptBtnLayout)
self.ALAddTimerTaskLayout.insertWidget(
self.ALAddTimerTaskLayout.indexOf(self.TaskConfigGroupBox) + 1,
self.AutoScriptGroupBox
)
self.AutoScriptGroupBox.setVisible(False)
self.__auto_script = ""
self.__mock_target_data = None
def loadTask(
self,
@@ -170,10 +179,11 @@ class ALTimerTaskAddDialog(QDialog, Ui_ALTimerTaskAddDialog):
self.__auto_script = auto_script
self.AutoScriptStatusLabel.setText("已设置")
self.AutoScriptStatusLabel.setStyleSheet("color: #4CAF50;")
self.AutoScriptPreviewButton.setEnabled(True)
mock_data = task.get("mock_target_data")
if mock_data:
self.__mock_target_data = mock_data
self.ConfirmButton.setText("保存")
def connectSignals(
self
):
@@ -182,11 +192,9 @@ class ALTimerTaskAddDialog(QDialog, Ui_ALTimerTaskAddDialog):
self.ConfirmButton.clicked.connect(self.accept)
self.TimerTypeComboBox.currentIndexChanged.connect(self.onTimerTypeComboBoxIndexChanged)
self.RepeatCheckBox.toggled.connect(self.onRepeatCheckBoxToggled)
self.AutoScriptSetButton.clicked.connect(self.onSetAutoScript)
self.AutoScriptPreviewButton.clicked.connect(self.onPreviewAutoScript)
self.AutoScriptEditButton.clicked.connect(self.onPreviewAutoScript)
self.AutoScriptHelpButton.clicked.connect(self.onAutoScriptHelp)
def getTimerTask(
self
) -> dict:
@@ -218,6 +226,7 @@ class ALTimerTaskAddDialog(QDialog, Ui_ALTimerTaskAddDialog):
task_data["status"] = ALTimerTaskStatus.PENDING
task_data["executed"] = False
task_data["repeat_auto_script"] = self.__auto_script
task_data["mock_target_data"] = self.__mock_target_data
else:
task_data = {
"name": name,
@@ -230,6 +239,7 @@ class ALTimerTaskAddDialog(QDialog, Ui_ALTimerTaskAddDialog):
"executed": False,
"repeat": self.RepeatCheckBox.isChecked(),
"repeat_auto_script": self.__auto_script,
"mock_target_data": self.__mock_target_data,
}
repeat = self.RepeatCheckBox.isChecked()
@@ -291,29 +301,20 @@ class ALTimerTaskAddDialog(QDialog, Ui_ALTimerTaskAddDialog):
self.AutoScriptGroupBox.setVisible(checked)
@Slot()
def onSetAutoScript(self):
dlg = ALAutoScriptOrchDialog(self, existingScript=self.__auto_script)
if dlg.exec() == QDialog.DialogCode.Accepted:
script = dlg.getScript()
def onPreviewAutoScript(self):
from gui.ALAutoScriptEditDialog import ALAutoScriptEditDialog
Dlg = ALAutoScriptEditDialog(self, self.__auto_script, self.__mock_target_data)
if Dlg.exec() == QDialog.DialogCode.Accepted:
script = Dlg.getScript()
self.__auto_script = script
self.__mock_target_data = Dlg.getMockData()
if script:
self.AutoScriptStatusLabel.setText("已设置")
self.AutoScriptStatusLabel.setStyleSheet("color: #4CAF50;")
self.AutoScriptPreviewButton.setEnabled(True)
else:
self.AutoScriptStatusLabel.setText("未设置")
self.AutoScriptStatusLabel.setStyleSheet("color: #969696;")
self.AutoScriptPreviewButton.setEnabled(False)
dlg.deleteLater()
@Slot()
def onPreviewAutoScript(self):
if not self.__auto_script:
return
from gui.ALAutoScriptPrevDialog import ALAutoScriptPreviewDialog
dlg = ALAutoScriptPreviewDialog(self, self.__auto_script)
dlg.exec()
dlg.deleteLater()
Dlg.deleteLater()
@Slot()
def onAutoScriptHelp(
@@ -323,5 +324,3 @@ class ALTimerTaskAddDialog(QDialog, Ui_ALTimerTaskAddDialog):
QDesktopServices.openUrl(
QUrl("https://www.autolibrary.kenanzhu.com/manuals/autoscript")
)
+7 -16
View File
@@ -28,22 +28,19 @@ class ALTimerTaskHistoryDialog(QDialog):
):
super().__init__(parent)
self.__task_data = task_data
self.__history = task_data.get("repeat_history", [])
self.modifyUi()
self.setupUi()
self.connectSignals()
def modifyUi(
def setupUi(
self
):
self.setWindowTitle("定时任务执行历史 - AutoLibrary")
self.setMinimumSize(300, 300)
self.setMaximumSize(500, 400)
MainLayout = QVBoxLayout(self)
InfoLayout = QGridLayout()
TaskNameLabel = QLabel(f"任务: {self.__task_data.get('name', '未命名')}")
@@ -53,7 +50,6 @@ class ALTimerTaskHistoryDialog(QDialog):
TaskUUIDLabel.setStyleSheet("color: #969696; font-size: 11px;")
InfoLayout.addWidget(TaskUUIDLabel, 1, 0)
InfoLayout.setColumnStretch(0, 1)
if self.__task_data.get("repeat", False):
RepeatLabel = QLabel("可重复性任务")
RepeatLabel.setStyleSheet("color: #2294FF; font-size: 12px;")
@@ -70,7 +66,6 @@ class ALTimerTaskHistoryDialog(QDialog):
self.HistoryTableWidget.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows)
self.loadHistory()
MainLayout.addWidget(self.HistoryTableWidget)
ButtonLayout = QHBoxLayout()
ButtonLayout.addStretch()
self.CloseButton = QPushButton("关闭")
@@ -83,7 +78,6 @@ class ALTimerTaskHistoryDialog(QDialog):
ButtonLayout.addWidget(self.CloseButton)
MainLayout.addLayout(ButtonLayout)
def connectSignals(
self
):
@@ -91,7 +85,6 @@ class ALTimerTaskHistoryDialog(QDialog):
self.CloseButton.clicked.connect(self.accept)
self.ClearHistoryButton.clicked.connect(self.onClearHistoryButtonClicked)
def loadHistory(
self
):
@@ -100,6 +93,11 @@ class ALTimerTaskHistoryDialog(QDialog):
for row, record in enumerate(self.__history):
self.addHistoryRow(row, record)
def getHistory(
self
) -> list:
return self.__history
def addHistoryRow(
self,
@@ -130,13 +128,6 @@ class ALTimerTaskHistoryDialog(QDialog):
self.HistoryTableWidget.setItem(row, 2, DurationItem)
self.HistoryTableWidget.setRowHeight(row, 25)
def getHistory(
self
) -> list:
return self.__history
@Slot()
def onClearHistoryButtonClicked(
self
+74 -74
View File
@@ -15,22 +15,40 @@ from enum import Enum
from datetime import datetime, timedelta
from PySide6.QtCore import (
Qt, Signal, Slot, QTimer
)
from PySide6.QtWidgets import (
QDialog, QWidget, QListWidgetItem, QMessageBox,
QHBoxLayout, QVBoxLayout, QLabel, QPushButton, QMenu
QTimer,
Qt,
Signal,
Slot
)
from PySide6.QtGui import (
QCloseEvent, QAction
QAction,
QCloseEvent
)
from PySide6.QtWidgets import (
QDialog,
QHBoxLayout,
QLabel,
QListWidgetItem,
QMenu,
QMessageBox,
QPushButton,
QVBoxLayout,
QWidget
)
import managers.config.ConfigManager as ConfigManager
from utils.TimerUtils import TimerUtils
from gui.resources.ui.Ui_ALTimerTaskManageWidget import Ui_ALTimerTaskManageWidget
from gui.ALTimerTaskAddDialog import ALTimerTaskAddDialog, ALTimerTaskStatus
from gui.ALTimerTaskAddDialog import (
ALTimerTaskAddDialog,
ALTimerTaskStatus
)
from gui.ALTimerTaskHistoryDialog import ALTimerTaskHistoryDialog
from gui.resources.ui.Ui_ALTimerTaskManageWidget import Ui_ALTimerTaskManageWidget
from interfaces.ConfigProvider import (
CfgKey,
ConfigProvider
)
from utils.TimerUtils import TimerUtils
class ALTimerTaskItemWidget(QWidget):
@@ -51,7 +69,6 @@ class ALTimerTaskItemWidget(QWidget):
self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
self.customContextMenuRequested.connect(self.showContextMenu)
def modifyUi(
self
):
@@ -156,20 +173,20 @@ class ALTimerTaskItemWidget(QWidget):
pos
):
menu = QMenu(self)
edit_action = QAction("编辑", self)
edit_action.triggered.connect(
Menu = QMenu(self)
EditAction = QAction("编辑", self)
EditAction.triggered.connect(
lambda: self.editRequested.emit(self.__timer_task)
)
menu.addAction(edit_action)
Menu.addAction(EditAction)
if self.__timer_task["status"] != ALTimerTaskStatus.RUNNING\
and self.__timer_task["status"] != ALTimerTaskStatus.READY:
delete_action = QAction("删除", self)
delete_action.triggered.connect(
DeleteAction = QAction("删除", self)
DeleteAction.triggered.connect(
lambda: self.__manage_widget.deleteTask(self.__timer_task)
)
menu.addAction(delete_action)
menu.exec(self.mapToGlobal(pos))
Menu.addAction(DeleteAction)
Menu.exec(self.mapToGlobal(pos))
class ALTimerTaskManageWidget(QWidget, Ui_ALTimerTaskManageWidget):
@@ -190,9 +207,9 @@ class ALTimerTaskManageWidget(QWidget, Ui_ALTimerTaskManageWidget):
):
super().__init__(parent)
self.__cfg_mgr = ConfigManager.instance()
self.__cfg_mgr: ConfigProvider = ConfigManager.instance()
self.__timer_tasks = []
self.__check_timer = None
self.__CheckTimer = None
self.__sort_policy = self.SortPolicy.BY_EXECUTE_TIME
self.__sort_order = Qt.SortOrder.AscendingOrder
@@ -202,7 +219,6 @@ class ALTimerTaskManageWidget(QWidget, Ui_ALTimerTaskManageWidget):
if not self.initializeTimerTasks():
raise Exception("定时任务配置文件初始化失败 !")
def connectSignals(
self
):
@@ -213,15 +229,13 @@ class ALTimerTaskManageWidget(QWidget, Ui_ALTimerTaskManageWidget):
self.TimerTaskSortOrderToggleButton.clicked.connect(self.onSortOrderToggleButtonClicked)
self.timerTasksChanged.connect(self.onTimerTasksChanged)
def setupTimer(
self
):
self.__check_timer = QTimer(self)
self.__check_timer.timeout.connect(self.checkTasks)
self.__check_timer.start(500)
self.__CheckTimer = QTimer(self)
self.__CheckTimer.timeout.connect(self.checkTasks)
self.__CheckTimer.start(500)
def initializeTimerTasks(
self
@@ -238,13 +252,12 @@ class ALTimerTaskManageWidget(QWidget, Ui_ALTimerTaskManageWidget):
return True
return False
def getTimerTasks(
self
) -> list:
try:
timer_tasks = self.__cfg_mgr.get(ConfigManager.ConfigType.TIMERTASK)
timer_tasks = self.__cfg_mgr.get(CfgKey.TIMERTASK.ROOT)
if timer_tasks and "timer_tasks" in timer_tasks:
for task in timer_tasks["timer_tasks"]:
task["added_time"] = datetime.strptime(task["added_time"], "%Y-%m-%d %H:%M:%S")
@@ -263,7 +276,6 @@ class ALTimerTaskManageWidget(QWidget, Ui_ALTimerTaskManageWidget):
)
return None
def setTimerTasks(
self,
timer_tasks: list
@@ -277,7 +289,7 @@ class ALTimerTaskManageWidget(QWidget, Ui_ALTimerTaskManageWidget):
if "repeat_history" in task:
for item in task["repeat_history"]:
item["result"] = item["result"].value
self.__cfg_mgr.set(ConfigManager.ConfigType.TIMERTASK, "", { "timer_tasks": timer_tasks })
self.__cfg_mgr.set(CfgKey.TIMERTASK.ROOT, { "timer_tasks": timer_tasks })
return True
except Exception as e:
QMessageBox.warning(
@@ -287,7 +299,6 @@ class ALTimerTaskManageWidget(QWidget, Ui_ALTimerTaskManageWidget):
)
return False
def showEvent(
self,
event
@@ -311,7 +322,6 @@ class ALTimerTaskManageWidget(QWidget, Ui_ALTimerTaskManageWidget):
return result
def closeEvent(
self,
event: QCloseEvent
@@ -321,7 +331,6 @@ class ALTimerTaskManageWidget(QWidget, Ui_ALTimerTaskManageWidget):
self.timerTaskManageWidgetIsClosed.emit()
event.ignore()
def sortTimerTasks(
self,
policy: SortPolicy = SortPolicy.BY_EXECUTE_TIME,
@@ -344,7 +353,6 @@ class ALTimerTaskManageWidget(QWidget, Ui_ALTimerTaskManageWidget):
reverse = order is Qt.SortOrder.DescendingOrder
)
def updateStat(
self
):
@@ -371,7 +379,6 @@ class ALTimerTaskManageWidget(QWidget, Ui_ALTimerTaskManageWidget):
self.ExecutedTaskLabel.setText(f"已执行:{executed}")
self.InvalidTaskLabel.setText(f"无效的:{invalid}")
def updateTimerTaskList(
self
):
@@ -379,41 +386,39 @@ class ALTimerTaskManageWidget(QWidget, Ui_ALTimerTaskManageWidget):
self.TimerTasksListWidget.clear()
self.sortTimerTasks(self.__sort_policy, self.__sort_order)
for timer_task in self.__timer_tasks:
item = QListWidgetItem()
item.setData(Qt.UserRole, timer_task)
widget = ALTimerTaskItemWidget(self, timer_task)
widget.DeleteButton.clicked.connect(
Item = QListWidgetItem()
Item.setData(Qt.UserRole, timer_task)
Widget = ALTimerTaskItemWidget(self, timer_task)
Widget.DeleteButton.clicked.connect(
lambda _, task = timer_task: self.deleteTask(task)
)
if timer_task.get("repeat", False) and hasattr(widget, "HistoryButton"):
widget.HistoryButton.clicked.connect(
if timer_task.get("repeat", False) and hasattr(Widget, "HistoryButton"):
Widget.HistoryButton.clicked.connect(
lambda _, task = timer_task: self.showTaskHistory(task)
)
widget.editRequested.connect(self.editTask)
item.setSizeHint(widget.size())
self.TimerTasksListWidget.addItem(item)
self.TimerTasksListWidget.setItemWidget(item, widget)
Widget.editRequested.connect(self.editTask)
Item.setSizeHint(Widget.size())
self.TimerTasksListWidget.addItem(Item)
self.TimerTasksListWidget.setItemWidget(Item, Widget)
def addTask(
self
):
dialog = ALTimerTaskAddDialog(self)
if dialog.exec() == QDialog.DialogCode.Accepted:
timer_task = dialog.getTimerTask()
Dialog = ALTimerTaskAddDialog(self)
if Dialog.exec() == QDialog.DialogCode.Accepted:
timer_task = Dialog.getTimerTask()
self.__timer_tasks.append(timer_task)
self.timerTasksChanged.emit()
def editTask(
self,
timer_task: dict
):
dialog = ALTimerTaskAddDialog(self, timer_task)
if dialog.exec() == QDialog.DialogCode.Accepted:
updated = dialog.getTimerTask()
Dialog = ALTimerTaskAddDialog(self, timer_task)
if Dialog.exec() == QDialog.DialogCode.Accepted:
updated = Dialog.getTimerTask()
for i, task in enumerate(self.__timer_tasks):
if task["uuid"] == updated["uuid"]:
self.__timer_tasks[i] = updated
@@ -437,7 +442,6 @@ class ALTimerTaskManageWidget(QWidget, Ui_ALTimerTaskManageWidget):
f"下次执行时间:{datetime.strftime(timer_task["execute_time"], "%Y-%m-%d %H:%M:%S")}\n"
f"已记录次数:{history_count}"
)
def deleteTask(
self,
@@ -445,19 +449,19 @@ class ALTimerTaskManageWidget(QWidget, Ui_ALTimerTaskManageWidget):
):
if timer_task["repeat"]: # when delete a repeat task
msgbox = QMessageBox(self)
msgbox.setIcon(QMessageBox.Icon.Question)
msgbox.setWindowTitle("警告 - AutoLibrary")
msgbox.setStandardButtons(
MsgBox = QMessageBox(self)
MsgBox.setIcon(QMessageBox.Icon.Question)
MsgBox.setWindowTitle("警告 - AutoLibrary")
MsgBox.setStandardButtons(
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
)
msgbox.setText("删除可重复性任务将同时删除所有已执行的记录 !\n是否继续 ?")
msgbox.setDetailedText(
MsgBox.setText("删除可重复性任务将同时删除所有已执行的记录 !\n是否继续 ?")
MsgBox.setDetailedText(
"以下可重复性任务将被删除:\n"\
"\n"
f"{self.getTimerTaskDetailMessage(timer_task)}"
)
result = msgbox.exec()
result = MsgBox.exec()
if result != QMessageBox.StandardButton.Yes:
return
task_uuid = timer_task["uuid"]
@@ -467,7 +471,6 @@ class ALTimerTaskManageWidget(QWidget, Ui_ALTimerTaskManageWidget):
]
self.timerTasksChanged.emit()
def clearAllTasks(
self
):
@@ -503,13 +506,13 @@ class ALTimerTaskManageWidget(QWidget, Ui_ALTimerTaskManageWidget):
]
repeat_tasks_count = len(repeat_tasks)
if repeat_tasks_count > 0:
msgbox = QMessageBox(self)
msgbox.setIcon(QMessageBox.Icon.Question)
msgbox.setWindowTitle("警告 - AutoLibrary")
msgbox.setStandardButtons(
MsgBox = QMessageBox(self)
MsgBox.setIcon(QMessageBox.Icon.Question)
MsgBox.setWindowTitle("警告 - AutoLibrary")
MsgBox.setStandardButtons(
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
)
msgbox.setText(
MsgBox.setText(
f"存在 {repeat_tasks_count} 个可重复性任务,\n"
"删除可重复性任务将同时删除所有已执行的记录 !\n"
"是否继续 ?"
@@ -517,29 +520,27 @@ class ALTimerTaskManageWidget(QWidget, Ui_ALTimerTaskManageWidget):
delete_msgs = [
self.getTimerTaskDetailMessage(x) for x in repeat_tasks
]
msgbox.setDetailedText(
MsgBox.setDetailedText(
"以下可重复性任务将被删除:\n"\
"\n"
f"{"\n\n".join(delete_msgs)}"
)
result = msgbox.exec()
result = MsgBox.exec()
if result != QMessageBox.StandardButton.Yes:
return
# clear all tasks
self.__timer_tasks.clear()
self.timerTasksChanged.emit()
def showTaskHistory(
self,
task: dict
):
dialog = ALTimerTaskHistoryDialog(self, task)
if dialog.exec() == QDialog.DialogCode.Accepted:
Dialog = ALTimerTaskHistoryDialog(self, task)
if Dialog.exec() == QDialog.DialogCode.Accepted:
self.timerTasksChanged.emit()
def checkTasks(
self
):
@@ -613,7 +614,6 @@ class ALTimerTaskManageWidget(QWidget, Ui_ALTimerTaskManageWidget):
break
self.timerTasksChanged.emit()
def onRepeatTimerTaskIs(
self,
status: ALTimerTaskStatus,
+27 -25
View File
@@ -10,14 +10,22 @@ See the LICENSE file for details.
from enum import Enum
from PySide6.QtCore import (
Qt, QSize, QCoreApplication, QRect, QPoint
Qt,
QSize,
QCoreApplication,
QRect,
QPoint
)
from PySide6.QtWidgets import (
QAbstractScrollArea, QAbstractItemView,
QTreeWidget, QTreeWidgetItem
QAbstractScrollArea,
QAbstractItemView,
QTreeWidget,
QTreeWidgetItem
)
from PySide6.QtGui import (
QDragEnterEvent, QDragMoveEvent, QDropEvent
QDragEnterEvent,
QDragMoveEvent,
QDropEvent
)
@@ -39,14 +47,13 @@ class ALUserTreeWidget(QTreeWidget):
self.setupUi()
self.translateUi()
def setupUi(
self
):
__qtreewidgetitem = QTreeWidgetItem()
__qtreewidgetitem.setText(0, u"\u5206\u7ec4/\u7528\u6237");
self.setHeaderItem(__qtreewidgetitem)
__QTreeWidgetItem = QTreeWidgetItem()
__QTreeWidgetItem.setText(0, u"\u5206\u7ec4/\u7528\u6237");
self.setHeaderItem(__QTreeWidgetItem)
self.setObjectName(u"UserTreeWidget")
self.setMinimumSize(QSize(230, 0))
self.setMaximumSize(QSize(250, 16777215))
@@ -70,14 +77,12 @@ class ALUserTreeWidget(QTreeWidget):
self.header().setHighlightSections(False)
self.header().setProperty(u"showSortIndicator", True)
def translateUi(
self
):
___qtreewidgetitem = self.headerItem()
___qtreewidgetitem.setText(1, QCoreApplication.translate("ALConfigWidget", u"\u72b6\u6001", None));
___QTreeWidgetItem = self.headerItem()
___QTreeWidgetItem.setText(1, QCoreApplication.translate("ALConfigWidget", u"\u72b6\u6001", None));
@staticmethod
def isDragPositionValid(
@@ -90,7 +95,6 @@ class ALUserTreeWidget(QTreeWidget):
y_offset < target_rect.height()*0.8)
return valid
def dragEnterEvent(
self,
event: QDragEnterEvent
@@ -98,7 +102,6 @@ class ALUserTreeWidget(QTreeWidget):
super().dragEnterEvent(event)
def dragMoveEvent(
self,
event: QDragMoveEvent
@@ -106,27 +109,27 @@ class ALUserTreeWidget(QTreeWidget):
super().dragMoveEvent(event)
source_item = self.currentItem()
target_item = self.itemAt(event.position().toPoint())
if source_item is None:
SourceItem = self.currentItem()
TargetItem = self.itemAt(event.position().toPoint())
if SourceItem is None:
event.ignore()
return
if source_item.type() == ALUserTreeItemType.GROUP.value:
if target_item is not None:
if SourceItem.type() == ALUserTreeItemType.GROUP.value:
if TargetItem is not None:
event.ignore()
return
elif source_item.type() == ALUserTreeItemType.USER.value:
if target_item is None:
elif SourceItem.type() == ALUserTreeItemType.USER.value:
if TargetItem is None:
event.ignore()
return
if target_item.type() != ALUserTreeItemType.GROUP.value:
if TargetItem.type() != ALUserTreeItemType.GROUP.value:
event.ignore()
return
if target_item.checkState(1) == Qt.CheckState.Unchecked:
if TargetItem.checkState(1) == Qt.CheckState.Unchecked:
event.ignore()
return
if not self.isDragPositionValid(
self.visualItemRect(target_item),
self.visualItemRect(TargetItem),
event.position().toPoint()
):
event.ignore()
@@ -136,7 +139,6 @@ class ALUserTreeWidget(QTreeWidget):
return
event.acceptProposedAction()
def dropEvent(
self,
event: QDropEvent
+137 -146
View File
@@ -11,20 +11,30 @@ import threading
from typing import Optional
from PySide6.QtCore import (
Qt, Slot, QThread, Signal
Qt,
Slot,
QThread,
Signal
)
from PySide6.QtWidgets import (
QDialog, QLabel, QComboBox, QProgressBar,
QPushButton, QVBoxLayout, QHBoxLayout,
QMessageBox, QFrame, QLineEdit
)
from PySide6.QtGui import (
QCloseEvent
QDialog,
QLabel,
QComboBox,
QProgressBar,
QPushButton,
QVBoxLayout,
QHBoxLayout,
QMessageBox,
QFrame,
QLineEdit
)
from PySide6.QtGui import QCloseEvent
from managers.driver.WebDriverManager import (
instance as webdriver_manager_instance,
WebDriverManager, WebDriverInfo, WebDriverType,
instance as webdriverInstance,
WebDriverManager,
WebDriverInfo,
WebDriverType,
WebDriverStatus
)
from gui.ALStatusLabel import ALStatusLabel
@@ -142,7 +152,6 @@ class ALWebDriverDownloadDialog(QDialog):
self.initializeDriverManager()
self.refreshDriverList()
def showEvent(
self,
event
@@ -165,7 +174,6 @@ class ALWebDriverDownloadDialog(QDialog):
self.move(target_pos)
return result
def setupUi(
self
):
@@ -174,14 +182,11 @@ class ALWebDriverDownloadDialog(QDialog):
self.setMaximumHeight(240)
self.setMinimumHeight(240)
self.setWindowTitle("浏览器驱动下载 - AutoLibrary")
self.MainLayout = QVBoxLayout(self)
self.MainLayout.setContentsMargins(5, 5, 5, 5)
self.MainLayout.setSpacing(5)
self.BrowserCountLabel = QLabel("检测到 0 个可用浏览器:")
self.MainLayout.addWidget(self.BrowserCountLabel)
self.DriverInfoLayout = QHBoxLayout()
self.DriverInfoLayout.setSpacing(5)
self.DriverComboBox = QComboBox()
@@ -190,7 +195,6 @@ class ALWebDriverDownloadDialog(QDialog):
self.StatusLabel.setFixedSize(32, 32)
self.DriverInfoLayout.addWidget(self.StatusLabel)
self.MainLayout.addLayout(self.DriverInfoLayout)
self.DetailLayout = QVBoxLayout()
self.DetailLayout.setSpacing(5)
self.DetailLayout.setContentsMargins(5, 5, 5, 5)
@@ -203,7 +207,6 @@ class ALWebDriverDownloadDialog(QDialog):
self.PathLabel.setText("路径:未安装")
self.DetailLayout.addWidget(self.PathLabel)
self.MainLayout.addLayout(self.DetailLayout)
self.Line = QFrame()
self.Line.setFrameShape(QFrame.Shape.HLine)
self.Line.setFrameShadow(QFrame.Shadow.Sunken)
@@ -229,7 +232,6 @@ class ALWebDriverDownloadDialog(QDialog):
self.ConfirmButton = QPushButton("确认")
self.ConfirmButton.setFixedSize(80, 25)
self.ConfirmButton.setEnabled(False)
self.ControlLayout.addWidget(self.RefreshButton)
self.ControlLayout.addWidget(self.DownloadButton)
self.ControlLayout.addWidget(self.DeleteButton)
@@ -237,7 +239,6 @@ class ALWebDriverDownloadDialog(QDialog):
self.ControlLayout.addWidget(self.ConfirmButton)
self.MainLayout.addLayout(self.ControlLayout)
def connectSignals(
self
):
@@ -249,18 +250,16 @@ class ALWebDriverDownloadDialog(QDialog):
self.ConfirmButton.clicked.connect(self.onConfirmButtonClicked)
self.DriverComboBox.currentIndexChanged.connect(self.onDriverComboBoxChanged)
def initializeDriverManager(
self
):
try:
self.__driver_manager = webdriver_manager_instance(self.__driver_dir)
self.__driver_manager = webdriverInstance(self.__driver_dir)
except ValueError as e:
QMessageBox.warning(self, "初始化失败", f"WebDriverManager 初始化失败:\n{str(e)}")
self.reject()
def refreshDriverList(
self
):
@@ -270,18 +269,20 @@ class ALWebDriverDownloadDialog(QDialog):
self.__driver_manager.refresh()
self.__driver_infos = self.__driver_manager.getDriverInfos()
self.DriverComboBox.clear()
installed = 0
installed_idx = 0
for i, driver_info in enumerate(self.__driver_infos):
if driver_info.driver_status == WebDriverStatus.INSTALLED:
installed_idx = i # get the installed driver index
display_text = f"{driver_info.driver_type.value} - {driver_info.browser_version}"
if driver_info.driver_status == WebDriverStatus.INSTALLED:
installed += 1
installed_idx = i # get the installed driver index
display_text += " : 已安装"
self.DriverComboBox.addItem(display_text)
count = len(self.__driver_infos)
self.BrowserCountLabel.setText(f"检测到 {count} 个可用浏览器:")
self.BrowserCountLabel.setText(f"检测到 {count} 个可用浏览器{installed} 个已安装驱动")
if self.__driver_infos:
self.DriverComboBox.setCurrentIndex(installed_idx)
def onDriverComboBoxChanged(
self,
index: int
@@ -294,6 +295,116 @@ class ALWebDriverDownloadDialog(QDialog):
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(
self,
driver_info: WebDriverInfo
):
if driver_info.driver_type == WebDriverType.CHROME:
driver_type = "Google Chrome"
elif driver_info.driver_type == WebDriverType.FIREFOX:
driver_type = "Mozilla Firefox"
elif driver_info.driver_type == WebDriverType.EDGE:
driver_type = "Microsoft Edge"
else:
driver_type = "未知"
self.BrowserTypeLabel.setText(f"类型:{driver_type}")
self.VersionLabel.setText(f"版本:{driver_info.driver_version}")
if driver_info.driver_path:
self.PathLabel.setText(str(driver_info.driver_path))
else:
self.PathLabel.setText("未安装")
match driver_info.driver_status:
case WebDriverStatus.NOT_INSTALLED:
self.StatusLabel.status = ALStatusLabel.Status.WAITING
case WebDriverStatus.INSTALLED:
self.StatusLabel.status = ALStatusLabel.Status.SUCCESS
case WebDriverStatus.DOWNLOADING:
self.StatusLabel.status = ALStatusLabel.Status.RUNNING
case WebDriverStatus.ERROR:
self.StatusLabel.status = ALStatusLabel.Status.FAILURE
def updateProgressBarStates(
self,
driver_info: WebDriverInfo
):
if driver_info.driver_status == WebDriverStatus.NOT_INSTALLED:
self.ProgressBar.setValue(0)
self.ProgressText.setText("未安装")
elif driver_info.driver_status == WebDriverStatus.INSTALLED:
self.ProgressBar.setValue(100)
self.ProgressText.setText("已安装")
elif driver_info.driver_status == WebDriverStatus.DOWNLOADING:
pass # update by worker thread
elif driver_info.driver_status == WebDriverStatus.ERROR:
self.ProgressBar.setValue(0)
self.ProgressText.setText("下载失败")
def updateButtonStates(
self,
driver_info: WebDriverInfo
):
if driver_info.driver_status == WebDriverStatus.NOT_INSTALLED:
self.RefreshButton.setEnabled(True)
self.DeleteButton.setEnabled(False)
self.DownloadButton.setEnabled(True)
self.CancelButton.setEnabled(True)
self.ConfirmButton.setEnabled(False)
elif driver_info.driver_status == WebDriverStatus.INSTALLED:
self.RefreshButton.setEnabled(True)
self.DownloadButton.setEnabled(False)
self.DeleteButton.setEnabled(True)
self.CancelButton.setEnabled(True)
self.ConfirmButton.setEnabled(True)
elif driver_info.driver_status == WebDriverStatus.DOWNLOADING:
self.RefreshButton.setEnabled(False)
self.DownloadButton.setEnabled(False)
self.DeleteButton.setEnabled(False)
self.CancelButton.setEnabled(True)
self.ConfirmButton.setEnabled(False)
elif driver_info.driver_status == WebDriverStatus.ERROR:
self.RefreshButton.setEnabled(True)
self.DownloadButton.setEnabled(True)
self.DeleteButton.setEnabled(False)
self.CancelButton.setEnabled(True)
self.ConfirmButton.setEnabled(False)
@Slot()
def onRefreshButtonClicked(
@@ -302,7 +413,6 @@ class ALWebDriverDownloadDialog(QDialog):
self.refreshDriverList()
@Slot()
def onDeleteButtonClicked(
self
@@ -355,7 +465,7 @@ class ALWebDriverDownloadDialog(QDialog):
self.__download_thread.downloadFinished.connect(self.onDownloadFinished)
self.__download_thread.downloadError.connect(self.onDownloadError)
self.__download_thread.downloadCancelled.connect(self.onDownloadCancelled)
self.__download_thread.finished.connect(self.__onThreadFinished)
self.__download_thread.finished.connect(self.onThreadFinished)
self.__download_thread.start()
@Slot()
@@ -406,7 +516,6 @@ class ALWebDriverDownloadDialog(QDialog):
self.updateButtonStates(driver_info)
QMessageBox.critical(self, "下载失败 - AutoLibrary", error_message)
@Slot()
def onDownloadCancelled(
self
@@ -423,7 +532,6 @@ class ALWebDriverDownloadDialog(QDialog):
self.updateButtonStates(driver_info)
self.ProgressText.setText("下载已取消")
@Slot()
def onConfirmButtonClicked(
self
@@ -439,7 +547,6 @@ class ALWebDriverDownloadDialog(QDialog):
self.__confirmed = True
self.accept()
@Slot()
def onCancelButtonClicked(
self
@@ -458,119 +565,3 @@ class ALWebDriverDownloadDialog(QDialog):
self.__confirmed = False
self.__selected_driver_info = None
self.reject()
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(
self,
driver_info: WebDriverInfo
):
if driver_info.driver_type == WebDriverType.CHROME:
driver_type = "Google Chrome"
elif driver_info.driver_type == WebDriverType.FIREFOX:
driver_type = "Mozilla Firefox"
elif driver_info.driver_type == WebDriverType.EDGE:
driver_type = "Microsoft Edge"
else:
driver_type = "未知"
self.BrowserTypeLabel.setText(f"类型:{driver_type}")
self.VersionLabel.setText(f"版本:{driver_info.driver_version}")
if driver_info.driver_path:
self.PathLabel.setText(str(driver_info.driver_path))
else:
self.PathLabel.setText("未安装")
match driver_info.driver_status:
case WebDriverStatus.NOT_INSTALLED:
self.StatusLabel.status = ALStatusLabel.Status.WAITING
case WebDriverStatus.INSTALLED:
self.StatusLabel.status = ALStatusLabel.Status.SUCCESS
case WebDriverStatus.DOWNLOADING:
self.StatusLabel.status = ALStatusLabel.Status.RUNNING
case WebDriverStatus.ERROR:
self.StatusLabel.status = ALStatusLabel.Status.FAILURE
def updateProgressBarStates(
self,
driver_info: WebDriverInfo
):
if driver_info.driver_status == WebDriverStatus.NOT_INSTALLED:
self.ProgressBar.setValue(0)
self.ProgressText.setText("未安装")
elif driver_info.driver_status == WebDriverStatus.INSTALLED:
self.ProgressBar.setValue(100)
self.ProgressText.setText("已安装")
elif driver_info.driver_status == WebDriverStatus.DOWNLOADING:
pass # update by worker thread
elif driver_info.driver_status == WebDriverStatus.ERROR:
self.ProgressBar.setValue(0)
self.ProgressText.setText("下载失败")
def updateButtonStates(
self,
driver_info: WebDriverInfo
):
if driver_info.driver_status == WebDriverStatus.NOT_INSTALLED:
self.RefreshButton.setEnabled(True)
self.DeleteButton.setEnabled(False)
self.DownloadButton.setEnabled(True)
self.CancelButton.setEnabled(True)
self.ConfirmButton.setEnabled(False)
elif driver_info.driver_status == WebDriverStatus.INSTALLED:
self.RefreshButton.setEnabled(True)
self.DownloadButton.setEnabled(False)
self.DeleteButton.setEnabled(True)
self.CancelButton.setEnabled(True)
self.ConfirmButton.setEnabled(True)
elif driver_info.driver_status == WebDriverStatus.DOWNLOADING:
self.RefreshButton.setEnabled(False)
self.DownloadButton.setEnabled(False)
self.DeleteButton.setEnabled(False)
self.CancelButton.setEnabled(True)
self.ConfirmButton.setEnabled(False)
elif driver_info.driver_status == WebDriverStatus.ERROR:
self.RefreshButton.setEnabled(True)
self.DownloadButton.setEnabled(True)
self.DeleteButton.setEnabled(False)
self.CancelButton.setEnabled(True)
self.ConfirmButton.setEnabled(False)
+7 -17
View File
@@ -1,19 +1,9 @@
# -*- coding: utf-8 -*-
"""
GUI module for the AutoLibrary project.
Copyright (c) 2026 KenanZhu.
All rights reserved.
Here are the classes and modules in this package:
- ALMainWindow: Main window class.
- ALAboutDialog: About dialog class.
- ALConfigWidget: Configuration widget class.
- ALSeatFrame: Seat frame class.
- ALSeatMapView: Seat map view class.
- ALSeatMapTable: Seat map table class.
- ALSeatMapSelectDialog: Seat map select dialog class.
- ALTimerTaskAddDialog: Timer task add dialog class.
- ALAutoScriptOrchDialog: AutoScript orchestration dialog class.
- ALTimerTaskHistoryDialog: Timer task history dialog class.
- ALTimerTaskManageWidget: Timer task manage widget class.
- ALUserTreeWidget: User tree widget class.
- ALMainWorkers: Main workers class.
- ALVersionInfo: Version info class.
"""
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.
"""
+7 -4
View File
@@ -1,8 +1,11 @@
<RCC>
<qresource prefix="/res/icon">
<file>icons/AutoLibrary_32x32.ico</file>
</qresource>
<qresource prefix="/res/trans">
<qresource prefix="/res">
<file>icons/AutoLibrary_Logo_64.svg</file>
<file>icons/AutoLibrary_Logo_128.svg</file>
<file>icons/Copy.svg</file>
<file>icons/Reset.svg</file>
<file>translators/qtbase_zh_CN.qm</file>
</qresource>
</RCC>
+8 -2
View File
@@ -1,3 +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.
"""
GUI resources module for the AutoLibrary project.
"""
Binary file not shown.

Before

Width:  |  Height:  |  Size: 785 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 173 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 178 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 30 KiB

+6
View File
@@ -0,0 +1,6 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M13 7H7V5H13V7Z" fill="currentColor"/>
<path d="M13 11H7V9H13V11Z" fill="currentColor"/>
<path d="M7 15H13V13H7V15Z" fill="currentColor"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M3 19V1H17V5H21V23H7V19H3ZM15 17V3H5V17H15ZM17 7V19H9V21H19V7H17Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 396 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M20.817 11.186a8.94 8.94 0 0 0-1.355-3.219 9.053 9.053 0 0 0-2.43-2.43 8.95 8.95 0 0 0-3.219-1.355 9.028 9.028 0 0 0-1.838-.18V2L8 5l3.975 3V6.002c.484-.002.968.044 1.435.14a6.961 6.961 0 0 1 2.502 1.053 7.005 7.005 0 0 1 1.892 1.892A6.967 6.967 0 0 1 19 13a7.032 7.032 0 0 1-.55 2.725 7.11 7.11 0 0 1-.644 1.188 7.2 7.2 0 0 1-.858 1.039 7.028 7.028 0 0 1-3.536 1.907 7.13 7.13 0 0 1-2.822 0 6.961 6.961 0 0 1-2.503-1.054 7.002 7.002 0 0 1-1.89-1.89A6.996 6.996 0 0 1 5 13H3a9.02 9.02 0 0 0 1.539 5.034 9.096 9.096 0 0 0 2.428 2.428A8.95 8.95 0 0 0 12 22a9.09 9.09 0 0 0 1.814-.183 9.014 9.014 0 0 0 3.218-1.355 8.886 8.886 0 0 0 1.331-1.099 9.228 9.228 0 0 0 1.1-1.332A8.952 8.952 0 0 0 21 13a9.09 9.09 0 0 0-.183-1.814z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 866 B

+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;
}
+1 -48
View File
@@ -19,7 +19,7 @@
<property name="maximumSize">
<size>
<width>800</width>
<height>400</height>
<height>600</height>
</size>
</property>
<property name="windowTitle">
@@ -103,53 +103,6 @@
<property name="spacing">
<number>0</number>
</property>
<item>
<widget class="QFrame" name="AboutInfoSpaceFrame">
<property name="minimumSize">
<size>
<width>56</width>
<height>0</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>56</width>
<height>16777215</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="QTextBrowser" name="AboutInfoBrowser">
<property name="frameShadow">
<enum>QFrame::Shadow::Plain</enum>
</property>
<property name="verticalScrollBarPolicy">
<enum>Qt::ScrollBarPolicy::ScrollBarAlwaysOff</enum>
</property>
<property name="horizontalScrollBarPolicy">
<enum>Qt::ScrollBarPolicy::ScrollBarAlwaysOff</enum>
</property>
<property name="lineWrapMode">
<enum>QTextEdit::LineWrapMode::NoWrap</enum>
</property>
<property name="openExternalLinks">
<bool>true</bool>
</property>
<property name="openLinks">
<bool>true</bool>
</property>
</widget>
</item>
</layout>
</item>
<item>
+2 -2
View File
@@ -1956,13 +1956,13 @@
<widget class="QPushButton" name="ExportConfigButton">
<property name="minimumSize">
<size>
<width>100</width>
<width>120</width>
<height>25</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>100</width>
<width>120</width>
<height>25</height>
</size>
</property>
+12
View File
@@ -281,6 +281,12 @@ font: 700 9pt;</string>
<property name="nativeMenuBar">
<bool>true</bool>
</property>
<widget class="QMenu" name="ToolsMenu">
<property name="title">
<string>工具</string>
</property>
<addaction name="SettingsAction"/>
</widget>
<widget class="QMenu" name="HelpMenu">
<property name="mouseTracking">
<bool>true</bool>
@@ -291,6 +297,7 @@ font: 700 9pt;</string>
<addaction name="ManualAction"/>
<addaction name="AboutAction"/>
</widget>
<addaction name="ToolsMenu"/>
<addaction name="HelpMenu"/>
</widget>
<widget class="QStatusBar" name="StatusBar">
@@ -308,6 +315,11 @@ font: 700 9pt;</string>
<string>关于</string>
</property>
</action>
<action name="SettingsAction">
<property name="text">
<string>全局设置</string>
</property>
</action>
</widget>
<resources/>
<connections/>
+555
View File
@@ -0,0 +1,555 @@
<?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>
</widget>
</item>
<item>
<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>450</width>
<height>380</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="CustomQssGroupBox">
<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="CustomQssHintLabel">
<property name="text">
<string>选择一个主题,或导入新的主题文件:</string>
</property>
<property name="wordWrap">
<bool>true</bool>
</property>
</widget>
</item>
<item>
<layout class="QHBoxLayout" name="QssPathLayout">
<property name="spacing">
<number>5</number>
</property>
<item>
<widget class="QComboBox" name="ThemeComboBox">
<property name="minimumSize">
<size>
<width>160</width>
<height>25</height>
</size>
</property>
</widget>
</item>
<item>
<widget class="QLineEdit" name="QssPathEdit">
<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="BrowseQssButton">
<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="RemoveThemeButton">
<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="ThemeInfoLabel">
<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::AlignTop</set>
</property>
<property name="wordWrap">
<bool>true</bool>
</property>
</widget>
</item>
<item>
<layout class="QHBoxLayout" name="QssActionLayout">
<property name="spacing">
<number>5</number>
</property>
<item>
<widget class="QPushButton" name="ApplyQssButton">
<property name="minimumSize">
<size>
<width>80</width>
<height>25</height>
</size>
</property>
<property name="visible">
<bool>false</bool>
</property>
<property name="text">
<string>应用样式</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="ResetThemeButton">
<property name="minimumSize">
<size>
<width>80</width>
<height>25</height>
</size>
</property>
<property name="text">
<string>重置主题</string>
</property>
</widget>
</item>
<item>
<spacer name="QssActionSpacer">
<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="QssStatusLabel">
<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>
</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>
<y>0</y>
<width>350</width>
<height>400</height>
<height>500</height>
</rect>
</property>
<property name="minimumSize">
<size>
<width>350</width>
<height>460</height>
<height>500</height>
</size>
</property>
<property name="maximumSize">
+125
View File
@@ -0,0 +1,125 @@
# -*- 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 dataclasses import dataclass
from enum import Enum
from typing import Any, Optional, Protocol
class ConfigType(Enum):
"""
Config type enum. Values represent the default filename.
"""
GLOBAL = "autolibrary.json"
BULLETIN = "bulletin.json"
TIMERTASK = "timer_task.json"
@dataclass(frozen=True)
class ConfigPath:
"""
A typed configuration path that carries both the config file
and the dot-separated key in a single object.
Consumers pass this directly to ConfigProvider.get/set,
eliminating the need to import ConfigType separately.
"""
config_type: ConfigType
key: str = ""
class CfgKey:
"""
Type-safe hierarchical configuration key constants.
Each leaf is a ConfigPath that can be passed directly to
``ConfigProvider.get()`` or ``ConfigProvider.set()``.
Usage::
CfgKey.GLOBAL.AUTOMATION.RUN_PATH.PATHS
# -> ConfigPath(ConfigType.GLOBAL, "automation.run_path.paths")
config.get(CfgKey.GLOBAL.AUTOMATION.RUN_PATH.PATHS, [])
config.set(CfgKey.GLOBAL.AUTOMATION.RUN_PATH.PATHS, value)
"""
class GLOBAL:
class AUTOMATION:
ROOT = ConfigPath(ConfigType.GLOBAL, "automation")
class RUN_PATH:
ROOT = ConfigPath(ConfigType.GLOBAL, "automation.run_path")
CURRENT = ConfigPath(ConfigType.GLOBAL, "automation.run_path.current")
PATHS = ConfigPath(ConfigType.GLOBAL, "automation.run_path.paths")
class USER_PATH:
ROOT = ConfigPath(ConfigType.GLOBAL, "automation.user_path")
CURRENT = ConfigPath(ConfigType.GLOBAL, "automation.user_path.current")
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 TIMERTASK:
ROOT = ConfigPath(ConfigType.TIMERTASK, "")
TIMER_TASKS = ConfigPath(ConfigType.TIMERTASK, "timer_tasks")
class BULLETIN:
ROOT = ConfigPath(ConfigType.BULLETIN, "")
BULLETIN = ConfigPath(ConfigType.BULLETIN, "bulletin")
LAST_SYNC_TIME = ConfigPath(ConfigType.BULLETIN, "last_sync_time")
class ConfigProvider(Protocol):
"""
Abstract interface for configuration storage access.
Concrete implementations (e.g. ConfigManager) conform to
this protocol structurally rather than through explicit
inheritance.
"""
def get(
self,
key: ConfigPath,
default: Optional[Any] = None
) -> Any:
"""
Retrieve a configuration value.
Args:
key: A ConfigPath object specifying which config file
and key to read from.
default: Fallback value if the key is not found.
Returns:
The configuration value at the given key path.
"""
...
def set(
self,
key: ConfigPath,
value: Any = None
) -> None:
"""
Set a configuration value and persist to disk.
Args:
key: A ConfigPath object specifying which config file
and key to write to.
value: The value to store.
"""
...
+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.
"""
+7 -6
View File
@@ -1,8 +1,9 @@
# -*- coding: utf-8 -*-
"""
Managers module for the AutoLibrary project.
Copyright (c) 2026 KenanZhu.
All rights reserved.
Here are the classes and modules in this package:
- ConfigManager: Config manager for managing configuration files.
- LogManager: Log manager for logging.
- WebDriverManager: Web driver manager for managing web drivers.
"""
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.
"""
+17 -29
View File
@@ -10,26 +10,17 @@ See the LICENSE file for details.
import os
import threading
from enum import Enum
from typing import Any, Optional
from utils.JSONReader import JSONReader
from utils.JSONWriter import JSONWriter
from interfaces.ConfigProvider import ConfigType, ConfigPath
# This config manager class only responsible for global and other
# unconfigurable config files.
class ConfigType(Enum):
"""
Config type class. Values represent the default filename.
"""
GLOBAL = "autolibrary.json" # Global config file.
BULLETIN = "bulletin.json" # Bulletin board config file.
TIMERTASK = "timer_task.json" # Timer task config file.
class ConfigTemplate:
"""
Config template class.
@@ -42,7 +33,6 @@ class ConfigTemplate:
self.__config_type = config_type
def template(
self
) -> dict:
@@ -64,6 +54,11 @@ class ConfigTemplate:
"current": 0,
"paths": []
}
},
"appearance": {
"theme": "system",
"style": "Fusion",
"custom_theme": ""
}
}
case ConfigType.BULLETIN:
@@ -92,7 +87,6 @@ class ConfigManager:
self.initialize()
def initialize(
self
):
@@ -100,7 +94,6 @@ class ConfigManager:
for config_type in ConfigType:
self.load(config_type)
def load(
self,
config_type: ConfigType
@@ -117,47 +110,42 @@ class ConfigManager:
self.__config_data[config_type.value] = ConfigTemplate(config_type).template()
JSONWriter(config_path, self.__config_data[config_type.value])
def get(
self,
config_type: ConfigType,
key: str = "",
key: ConfigPath,
default: Optional[Any] = None
) -> Any:
with self.__config_lock:
config_data = self.__config_data[config_type.value]
if key == "":
config_data = self.__config_data[key.config_type.value]
if key.key == "":
return config_data
keys = key.split('.')
keys = key.key.split('.')
for k in keys[:-1]:
config_data = config_data.get(k, None)
if config_data is None:
return default
return config_data.get(keys[-1], default)
def set(
self,
config_type: ConfigType,
key: str = "",
key: ConfigPath,
value: Any = None
):
with self.__config_lock:
root_data = self.__config_data[config_type.value]
if key == "":
self.__config_data[config_type.value] = value
root_data = self.__config_data[key.config_type.value]
if key.key == "":
self.__config_data[key.config_type.value] = value
else:
keys = key.split('.')
keys = key.key.split('.')
config_data = root_data
for k in keys[:-1]:
if k not in config_data:
config_data[k] = {}
config_data = config_data[k]
config_data[keys[-1]] = value
self.save(config_type)
self.save(key.config_type)
def save(
self,
@@ -167,7 +155,6 @@ class ConfigManager:
config_path = os.path.join(self.__config_dir, config_type.value)
JSONWriter(config_path, self.__config_data[config_type.value])
def configDir(
self
) -> str:
@@ -180,6 +167,7 @@ _config_manager_instance : ConfigManager | None = None
# Singleton instance of ConfigManager.
_instance_lock = threading.Lock()
def instance(
config_dir: str = ""
) -> ConfigManager:
@@ -11,6 +11,8 @@ import os
import managers.config.ConfigManager as ConfigManager
from interfaces.ConfigProvider import CfgKey
class ConfigUtils:
"""
Config utilities class.
@@ -29,7 +31,7 @@ class ConfigUtils:
cfg_mgr = ConfigManager.instance() # config manager instance
config_paths = {"run": "", "user": ""}
auto_config = cfg_mgr.get(ConfigManager.ConfigType.GLOBAL, "automation", {})
auto_config = cfg_mgr.get(CfgKey.GLOBAL.AUTOMATION.ROOT, {})
for cfg_type in ["run", "user"]:
paths = auto_config.get(f"{cfg_type}_path", {}).get("paths", [])
index = auto_config.get(f"{cfg_type}_path", {}).get("current", 0)
@@ -42,5 +44,5 @@ class ConfigUtils:
config_paths[cfg_type] = paths[index]
data = {"current": index, "paths": paths}
auto_config[f"{cfg_type}_path"] = data
cfg_mgr.set(ConfigManager.ConfigType.GLOBAL, "automation", auto_config)
return config_paths
cfg_mgr.set(CfgKey.GLOBAL.AUTOMATION.ROOT, auto_config)
return config_paths
+7 -4
View File
@@ -1,6 +1,9 @@
# -*- coding: utf-8 -*-
"""
Config managers module for the AutoLibrary project.
Copyright (c) 2026 KenanZhu.
All rights reserved.
Here are the classes and modules in this package:
- ConfigManager: Config manager for managing configuration files.
"""
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.
"""
+1 -2
View File
@@ -41,6 +41,7 @@ class WebBrowserArch(Enum):
MACX86_64 = 6
MACARM = 7
@dataclass
class WebBrowserInfo:
"""
@@ -70,7 +71,6 @@ class WebBrowserArchDetector:
pass
def detect(
self
) -> WebBrowserArch:
@@ -123,7 +123,6 @@ class WebBrowserDetector:
self.browser_arch = WebBrowserArchDetector().detect()
self.browser_infos : list[WebBrowserInfo] = []
def detect(
self
) -> list[WebBrowserInfo]:
+23 -31
View File
@@ -95,7 +95,6 @@ class WebDriverName:
self.driver_type = driver_type
def __str__(
self
) -> str:
@@ -125,7 +124,6 @@ class WebDriverExecName:
self.driver_type = driver_type
self.arch = arch
def __str__(
self
) -> str:
@@ -200,7 +198,6 @@ class WebDriverURL:
self.arch = arch
self.file_name = str(WebDriverFileName(self.version, self.driver_type, self.arch))
def __str__(
self
) -> str:
@@ -250,31 +247,6 @@ class WebDriverDownloader:
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))
def download(
self,
progress_callback: Optional[Callable[[float, int, float, str], None]] = None,
cancel_event: Optional[threading.Event] = None
) -> Optional[Path]:
try:
# downlaod file : 0% - 98%
if not self._download(progress_callback, cancel_event=cancel_event):
return None
# verify file : 98% - 99%
if not self._verify(progress_callback):
progress_callback(0, 100, 0.0, "验证失败")
return None
# extract file : 99% - 100%
driver_path = self._extract(progress_callback)
if not driver_path:
progress_callback(0, 100, 0.0, "解压失败")
return None
return driver_path
except Exception as e:
raise e
def _download(
self,
progress_callback: Optional[Callable[[float, int, float, str], None]] = None,
@@ -352,7 +324,6 @@ class WebDriverDownloader:
continue
raise e
def _verify(
self,
progress_callback: Optional[Callable[[float, int, float, str], None]] = None
@@ -361,7 +332,6 @@ class WebDriverDownloader:
progress_callback(98, 100, 0.0, "验证完成")
return True
def _extract(
self,
progress_callback: Optional[Callable[[float, int, float, str], None]] = None
@@ -397,7 +367,6 @@ class WebDriverDownloader:
except Exception:
return None
def _cleanup(
self,
driver_file: Path
@@ -410,6 +379,29 @@ class WebDriverDownloader:
else:
item.unlink()
def download(
self,
progress_callback: Optional[Callable[[float, int, float, str], None]] = None,
cancel_event: Optional[threading.Event] = None
) -> Optional[Path]:
try:
# downlaod file : 0% - 98%
if not self._download(progress_callback, cancel_event=cancel_event):
return None
# verify file : 98% - 99%
if not self._verify(progress_callback):
progress_callback(0, 100, 0.0, "验证失败")
return None
# extract file : 99% - 100%
driver_path = self._extract(progress_callback)
if not driver_path:
progress_callback(0, 100, 0.0, "解压失败")
return None
return driver_path
except Exception as e:
raise e
class ChromeDriverDownloader(WebDriverDownloader):
"""
-16
View File
@@ -81,7 +81,6 @@ class WebDriverManager:
self.initialize()
def initialize(
self
):
@@ -93,7 +92,6 @@ class WebDriverManager:
self._checkDriverStatus()
self.__initialized = True
def _detectBrowsers(
self
):
@@ -105,7 +103,6 @@ class WebDriverManager:
for info in browser_infos
]
def _checkDriverStatus(
self
):
@@ -117,7 +114,6 @@ class WebDriverManager:
driver_info.driver_path = driver_path
driver_info.driver_status = WebDriverStatus.INSTALLED
def _mapWebBrowserTypeToDriver(
self,
browser_type: WebBrowserType
@@ -132,7 +128,6 @@ class WebDriverManager:
else:
raise ValueError(f"不支持的 Web 浏览器类型 : {browser_type}")
def _mapWebBrowserArchToDriver(
self,
browser_type: WebBrowserType,
@@ -199,7 +194,6 @@ class WebDriverManager:
else:
raise ValueError(f"不支持的 Web 浏览器类型 : {browser_type}")
def _mapFirefoxDriverVersion(
self,
version: str
@@ -240,7 +234,6 @@ class WebDriverManager:
except Exception as e:
raise ValueError(f"无效的 Firefox 版本格式 : {version}") from e
def _getDriverInfo(
self,
browser_info: WebBrowserInfo
@@ -256,7 +249,6 @@ class WebDriverManager:
driver_info.browser_version = browser_info.browser_version
return driver_info
def _getDriverPath(
self,
driver_info: WebDriverInfo
@@ -286,7 +278,6 @@ class WebDriverManager:
driver_path = driver_dir/exe_name
return driver_path
def refresh(
self
):
@@ -294,7 +285,6 @@ class WebDriverManager:
self._detectBrowsers()
self._checkDriverStatus()
def getDriverInfos(
self
) -> list[WebDriverInfo]:
@@ -302,7 +292,6 @@ class WebDriverManager:
with self.__lock:
return self.__driver_infos.copy()
def getDriverInfo(
self,
driver_type: WebDriverType
@@ -315,7 +304,6 @@ class WebDriverManager:
if info.driver_type == driver_type
]
def getDriverPath(
self,
driver_info: WebDriverInfo
@@ -325,7 +313,6 @@ class WebDriverManager:
return driver_info.driver_path
return None
def installDriver(
self,
driver_info: WebDriverInfo,
@@ -390,7 +377,6 @@ class WebDriverManager:
driver_info.driver_status = WebDriverStatus.ERROR
raise e
def cancelDriverDownload(
self,
driver_info: WebDriverInfo
@@ -411,7 +397,6 @@ class WebDriverManager:
except Exception:
return False
def uninstallDriver(
self,
driver_info: WebDriverInfo,
@@ -441,7 +426,6 @@ class WebDriverManager:
driver_info.driver_status = WebDriverStatus.ERROR
raise
def driverDir(
self
) -> str:
+7 -6
View File
@@ -1,8 +1,9 @@
# -*- coding: utf-8 -*-
"""
Driver managers module for the AutoLibrary project.
Copyright (c) 2026 KenanZhu.
All rights reserved.
Here are the classes and modules in this package:
- WebBrowserDetector: Web browser detector class.
- WebDriverDownloader: Web driver downloader class.
- WebDriverManager: Web driver manager class.
"""
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.
"""
+1 -4
View File
@@ -89,7 +89,6 @@ class LogManager:
self.initialize()
def initialize(
self
):
@@ -139,7 +138,6 @@ class LogManager:
self.__initialized = True
def getLogger(
self,
name: Optional[str] = None
@@ -149,7 +147,6 @@ class LogManager:
return self.__logger.getChild(name)
return self.__logger
def setLevel(
self,
level: int
@@ -158,7 +155,6 @@ class LogManager:
if self.__logger:
self.__logger.setLevel(level)
def logDir(
self
) -> str:
@@ -171,6 +167,7 @@ _log_manager_instance = None
# Singleton instance lock.
_instance_lock = threading.Lock()
def instance(
log_dir: str = ""
) -> LogManager:
+7 -4
View File
@@ -1,6 +1,9 @@
# -*- coding: utf-8 -*-
"""
Log managers module for the AutoLibrary project.
Copyright (c) 2026 KenanZhu.
All rights reserved.
Here are the classes and modules in this package:
- LogManager: Log manager for logging.
"""
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.
"""
+351
View File
@@ -0,0 +1,351 @@
# -*- 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 (
readThemeQss,
validateTheme,
wrapQssToAtheme
)
_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.__lock = threading.Lock()
self.__current_theme_name = ""
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 themesDir(
self
) -> str:
"""
Get the themes directory path.
Returns:
str: The absolute path to the themes storage directory.
"""
return self.__themes_dir
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._removeThemeFile(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)
ext = os.path.splitext(source_path)[1].lower()
with self.__lock:
if ext == ".qss":
name = os.path.splitext(os.path.basename(source_path))[0]
dest_path = self._resolveDestPath(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", os.path.splitext(os.path.basename(source_path))[0])
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, check_qss=False) # skip QSS read for list scan
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 _removeThemeFile(
self,
name: str
):
"""
Remove a theme file without locking.
The caller must hold self.__lock before invoking this method.
"""
filepath = os.path.join(self.__themes_dir, name + ".altheme")
if os.path.isfile(filepath):
os.remove(filepath)
if self.__current_theme_name == name:
self.__current_theme_name = ""
saved_theme = configInstance().get(
CfgKey.GLOBAL.APPEARANCE.THEME, "system"
)
self.clearTheme(saved_theme)
def removeTheme(
self,
name: str
):
"""
Remove a theme by name.
If the removed theme is currently active, clears the QSS
stylesheet from the application.
Args:
name (str): The theme name to remove.
"""
with self.__lock:
self._removeThemeFile(name)
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 = validateTheme(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))
# 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.
"""
-377
View File
@@ -1,377 +0,0 @@
# -*- coding: utf-8 -*-
"""
Copyright (c) 2025 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 re
import time
import queue
from datetime import datetime, timedelta
from selenium.webdriver.common.by import By
from selenium.webdriver.chrome.webdriver import WebDriver
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from base.LibOperator import LibOperator
class LibChecker(LibOperator):
def __init__(
self,
input_queue: queue.Queue,
output_queue: queue.Queue,
driver: WebDriver
):
super().__init__(input_queue, output_queue)
self.__driver = driver
def _waitResponseLoad(
self
) -> bool:
pass
@staticmethod
def __formatDiffTime(
seconds: float
) -> str:
hours = int(seconds//3600)
minutes = int(seconds%3600//60)
seconds = int(seconds%60)
return f"{hours}{minutes}{seconds}"
def __navigateToReserveRecordPage(
self
) -> bool:
try:
WebDriverWait(self.__driver, 2).until(
EC.element_to_be_clickable((By.XPATH, "//a[@href='/history?type=SEAT']"))
).click()
WebDriverWait(self.__driver, 2).until(
EC.presence_of_element_located((By.CLASS_NAME, "myReserveList"))
)
except:
self._showTrace("加载预约记录页面失败 !", self.TraceLevel.ERROR)
return False
return True
def __decodeReserveTime(
self,
time_element
) -> dict:
time_str = time_element.text.strip()
today = datetime.now().date()
if "明天" in time_str:
target_date = today + timedelta(days=1)
date = target_date.strftime("%Y-%m-%d")
elif "今天" in time_str:
target_date = today
date = target_date.strftime("%Y-%m-%d")
elif "昨天" in time_str:
target_date = today - timedelta(days=1)
date = target_date.strftime("%Y-%m-%d")
else:
date_match = re.search(r"(\d{4}-\d{1,2}-\d{1,2})", time_str)
if date_match:
date = date_match.group(1)
else:
date = ""
time_match = re.search(r"(\d{1,2}:\d{2}) -- (\d{1,2}:\d{2})", time_str)
if time_match:
begin_time = time_match.group(1)
end_time = time_match.group(2)
else:
begin_time = ""
end_time = ""
return {
"date": date,
"time": {
"begin": begin_time,
"end": end_time
}
}
def __decodeReserveInfo(
self,
info_elements
) -> str:
location = ""
status = ""
for info in info_elements:
if "已预约" in info.text:
status = "已预约"
elif "使用中" in info.text:
status = "使用中"
elif "已完成" in info.text:
status = "已完成"
elif "已结束使用" in info.text:
status = "已结束使用"
elif "已取消" in info.text:
status = "已取消"
elif "失约" in info.text:
status = "失约"
elif "图书馆" in info.text:
location = info.text.strip()
return {
"location": location,
"status": status,
}
def __decodeReserveRecord(
self,
reservation
) -> dict:
try:
time_element = reservation.find_element(
By.CSS_SELECTOR, "dt"
)
info_elements = reservation.find_elements(
By.CSS_SELECTOR, "a"
)
except:
return {
"date": "",
"time": {"begin": "", "end": ""},
"info": {"location": "", "status": ""}
}
time = self.__decodeReserveTime(time_element)
info = self.__decodeReserveInfo(info_elements)
return {
"date": time["date"],
"time": time["time"],
"info": info
}
def __loadReserveRecords(
self
) -> list:
try:
# check if there's any reservation on the date
WebDriverWait(self.__driver, 2).until(
EC.presence_of_element_located((By.CSS_SELECTOR, ".myReserveList > dl"))
)
reservations = self.__driver.find_elements(
By.CSS_SELECTOR, ".myReserveList > dl:not(#moreBlock)"
)
return reservations
except:
self._showTrace("加载预约记录失败 !", self.TraceLevel.ERROR)
return None
def __showMoreReserveRecords(
self
) -> bool:
# load new reservations if still not sure
try:
WebDriverWait(self.__driver, 0.1).until(
EC.element_to_be_clickable((By.ID, "moreBtn"))
)
except:
# the reservation is the last one
return False
try:
more_btn = self.__driver.find_element(By.ID, "moreBtn")
if more_btn.is_displayed() and more_btn.is_enabled():
self.__driver.execute_script("arguments[0].scrollIntoView(true);", more_btn)
self.__driver.execute_script("arguments[0].click();", more_btn)
return True
else:
self._showTrace("用户无法加载更多预约记录", self.TraceLevel.WARNING)
return False
except:
self._showTrace("加载更多预约记录失败 !", self.TraceLevel.ERROR)
return False
def __getReserveRecord(
self,
wanted_date: str,
wanted_status: str
) -> dict:
if wanted_date is None:
self._showTrace("日期未指定, 无法检查当前预约状态", self.TraceLevel.WARNING)
return None
self._showTrace(f"正在检查用户在 {wanted_date} 是否有预约状态为 {wanted_status} 的预约记录......", no_log=True)
checked_count = 0
max_check_times = 6 # we only check (4*(6-1)=)20 reservations, the last time cant be checked
if not self.__navigateToReserveRecordPage():
return None
for _ in range(max_check_times):
reservations = self.__loadReserveRecords()
if reservations is None:
return None
for reservation in reservations[checked_count:]:
record = self.__decodeReserveRecord(reservation)
checked_count += 1
if record is None:
continue
if record["date"] == "":
continue
if record["time"] == {"begin": "", "end": ""}:
continue
# record date is later than the given date, check the next one
if datetime.strptime(record["date"], "%Y-%m-%d").date() >\
datetime.strptime(wanted_date, "%Y-%m-%d").date():
continue
# record date is earlier than the given date, so there is no wanted record
if datetime.strptime(record["date"], "%Y-%m-%d").date() <\
datetime.strptime(wanted_date, "%Y-%m-%d").date():
return None
if record["info"]["status"] == wanted_status:
self._showTrace(
f"寻找到用户第 {checked_count} 条状态为 {wanted_status} 的预约记录, "
f"详细信息: {record["date"]} "
f"{record["time"]["begin"]} - {record["time"]["end"]} {record["info"]["location"]}",
no_log=True
)
return record
if not self.__showMoreReserveRecords():
break
return None
def canReserve(
self,
date: str
) -> bool:
# no reserved or using record in the given date
# then can reserve
if self.__getReserveRecord(date, "已预约") is None:
if self.__getReserveRecord(date, "使用中") is None:
self._showTrace(f"用户在 {date} 可以预约")
return True
self._showTrace(f"用户在 {date} 有使用中的预约, 无法预约")
return False
self._showTrace(f"用户在 {date} 已存在有效预约, 无法预约")
return False
def canCheckin(
self
) -> bool:
# only check the current date
date = time.strftime("%Y-%m-%d", time.localtime())
record = self.__getReserveRecord(date, "已预约")
if record is not None:
begin_time = record["time"]["begin"]
begin_time = datetime.strptime(f"{date} {begin_time}", "%Y-%m-%d %H:%M")
time_diff = datetime.now() - begin_time
time_diff_seconds = time_diff.total_seconds()
# before 30 minutes, cant checkin
if time_diff_seconds < -30*60:
self._showTrace(
f"用户在 {date} 的预约开始时间为 {begin_time}, "
f"当前距离预约开始时间还有 {self.__formatDiffTime(abs(time_diff_seconds))}, 无法签到"
)
return False
# before in 30 minutes, can checkin
elif -30*60 <= time_diff_seconds < 0:
self._showTrace(
f"用户在 {date} 的预约开始时间为 {begin_time}, "
f"当前距离预约开始时间还有 {self.__formatDiffTime(abs(time_diff_seconds))}, 可以签到"
)
return True
# past less than 30 minutes, can checkin
elif 0 <= time_diff_seconds < 30*60 - 5: # spare 5 seconds for the checkin process
self._showTrace(
f"用户在 {date} 的预约开始时间为 {begin_time}, "
f"当前距离预约开始时间已经过去 {self.__formatDiffTime(abs(time_diff_seconds))}, 可以签到"
)
return True
self._showTrace(f"用户在 {date} 没有有效预约记录, 无法签到")
return False
def canRenew(
self
) -> tuple[bool, dict]:
# only check the current date
date = time.strftime("%Y-%m-%d", time.localtime())
record = self.__getReserveRecord(date, "使用中")
if record is not None:
end_time = record["time"]["end"]
end_time = datetime.strptime(f"{date} {end_time}", "%Y-%m-%d %H:%M")
time_diff = end_time - datetime.now()
time_diff_seconds = time_diff.total_seconds()
# a using record is definitely after the begin time
trace_msg = (
f"用户在 {date} 的预约结束时间为 {end_time}, "
f"当前距离预约结束时间还有 {self.__formatDiffTime(abs(time_diff_seconds))}"
)
if abs(time_diff_seconds) < 120*60:
self._showTrace(f"{trace_msg}, 可以续约")
return True, record
else:
self._showTrace(f"{trace_msg}, 无法续约")
return False, None # we do not need to return the record, because if current
# time is not available for renewal, the record is not required
self._showTrace(f"用户在 {date} 没有有效预约记录, 无法续约")
return False, None
def postRenewCheck(
self,
record: dict
) -> bool:
"""
Check if the renew operation is successful
Args:
record (dict): The expected record after renewal
Returns:
bool: True if the renew operation is successful, False otherwise
"""
# because the special circumstance that the renew operation
# do not show the success message or anything else,
# we need to check the record data to make sure the renew operation is successful.
# only check the given record date
date = record["date"]
act_record = self.__getReserveRecord(date, "使用中")
if act_record is not None:
if act_record["time"]["begin"] == record["time"]["begin"] and\
act_record["time"]["end"] == record["time"]["end"]:
self._showTrace(f"\n"\
f" 续约成功 !\n"\
f" 日 期 {date}\n"\
f" 时 间 {act_record["time"]["begin"]} - {act_record["time"]["end"]}\n"\
f" 位 置 {act_record["info"]["location"]}\n"
f" 状 态 {act_record["info"]["status"]}"
)
return True
else:
self._showTrace(f"\n"\
f" 续约失败 !\n"\
f" 续约后结束时间为 {act_record["time"]["end"]},与预期结束时间 {record["time"]["end"]} 不符 !"
)
return False
self._showTrace(f"用户在 {date} 没有有效预约记录, 无法检查续约结果")
return False
-142
View File
@@ -1,142 +0,0 @@
# -*- coding: utf-8 -*-
"""
Copyright (c) 2025 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 time
import queue
from selenium.webdriver.common.by import By
from selenium.webdriver.chrome.webdriver import WebDriver
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from base.LibOperator import LibOperator
class LibCheckin(LibOperator):
def __init__(
self,
input_queue: queue.Queue,
output_queue: queue.Queue,
driver: WebDriver
):
super().__init__(input_queue, output_queue)
self.__driver = driver
def _waitResponseLoad(
self
) -> bool:
try:
WebDriverWait(self.__driver, 2).until(
EC.presence_of_element_located((By.CLASS_NAME, "ui_dialog"))
)
WebDriverWait(self.__driver, 2).until(
EC.presence_of_element_located((By.CLASS_NAME, "resultMessage"))
)
WebDriverWait(self.__driver, 2).until(
EC.element_to_be_clickable((By.CLASS_NAME, "btnOK"))
)
result_message_element = self.__driver.find_element(
By.CLASS_NAME, "resultMessage"
)
ok_btn = self.__driver.find_element(By.CLASS_NAME, "btnOK")
except:
self._showTrace("签到时发生未知错误 !", self.TraceLevel.ERROR)
return False
result_message = result_message_element.text
if "签到成功" in result_message:
try:
detail_elements = self.__driver.find_elements(
By.CSS_SELECTOR, ".resultMessage dd"
)
except:
pass
if detail_elements:
details = [element.text for element in detail_elements if element.text.strip()]
if len(details) >= 5:
self._showTrace(f"\n"\
f" 签到成功 !\n"\
f" {details[1]}\n"\
f" {details[2]}\n"\
f" {details[3]}\n"\
f" {details[4]}"
)
else:
self._showTrace(f"\n"\
" 签到成功 !\n"\
" 未获取到签到详情 !"
)
ok_btn.click()
return True
else:
failure_reason = result_message.replace("签到失败", "").strip()
self._showTrace(f"\n"\
" 签到失败 !\n"\
f" {failure_reason}"
)
ok_btn.click()
return False
def __enableCheckinBtn(
self
) -> bool:
script = """
try {
var checkin_btn = document.getElementById('btnCheckIn');
if (checkin_btn) {
checkin_btn.classList.remove('disabled');
return true;
}
return false;
} catch (e) {
return false;
}
"""
result = self.__driver.execute_script(script)
time.sleep(0.1)
if result:
self._showTrace("签到按钮已启用", no_log=True)
else:
self._showTrace("签到按钮启用失败", self.TraceLevel.WARNING)
return result
def checkin(
self,
username: str
) -> bool:
if self.__driver is None:
self._showTrace("未提供有效 WebDriver 实例 !", self.TraceLevel.WARNING)
return False
try:
checkin_btn = WebDriverWait(self.__driver, 2).until(
EC.element_to_be_clickable((By.ID, "btnCheckIn"))
)
except:
self._showTrace(f"用户 {username} 签到界面加载失败 !", self.TraceLevel.ERROR)
return False
if "disabled" in checkin_btn.get_attribute("class"):
self._showTrace("签到按钮不可用, 可能不在场馆内, 正在尝试启用......", no_log=True)
if not self.__enableCheckinBtn():
self._showTrace(f"签到按钮启用失败 !", self.TraceLevel.ERROR)
return False
checkin_btn.click()
if self._waitResponseLoad():
self._showTrace(f"用户 {username} 签到成功 !", no_log=True)
return True
else:
self._showTrace(f"用户 {username} 签到失败 !", self.TraceLevel.ERROR)
return False
-41
View File
@@ -1,41 +0,0 @@
# -*- coding: utf-8 -*-
"""
Copyright (c) 2025 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 re
import time
import queue
from datetime import datetime, timedelta
from selenium.webdriver.common.by import By
from selenium.webdriver.chrome.webdriver import WebDriver
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from base.LibOperator import LibOperator
class LibCheckout(LibOperator):
def __init__(
self,
input_queue: queue.Queue,
output_queue: queue.Queue,
driver: WebDriver
):
super().__init__(input_queue, output_queue)
self.__driver = driver
def _waitResponseLoad(
self
) -> bool:
pass
-215
View File
@@ -1,215 +0,0 @@
# -*- coding: utf-8 -*-
"""
Copyright (c) 2025 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
import base64
import ddddocr
from selenium.webdriver.common.by import By
from selenium.webdriver.chrome.webdriver import WebDriver
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from base.LibOperator import LibOperator
class LibLogin(LibOperator):
def __init__(
self,
input_queue: queue.Queue,
output_queue: queue.Queue,
driver: WebDriver
):
super().__init__(input_queue, output_queue)
self.__driver = driver
self.__ddddocr = ddddocr.DdddOcr()
def _waitResponseLoad(
self
) -> bool:
# wait to verify login success
try:
WebDriverWait(self.__driver, 2).until( # title contains "自选座位 :: 座位预约系统"
EC.title_contains("自选座位 :: 座位预约系统")
)
WebDriverWait(self.__driver, 2).until( # search button presence
EC.presence_of_element_located((By.ID, "search"))
)
WebDriverWait(self.__driver, 2).until( # select content presence
EC.presence_of_element_located((By.CLASS_NAME, "selectContent"))
)
return True
except:
self._showTrace(
f"登录页面加载失败 ! : 用户账号或者密码错误/验证码错误, 具体以页面提示为准",
self.TraceLevel.ERROR
)
return False
def __fillLogInElements(
self,
username: str,
password: str
) -> bool:
# ensure elements presence and fill them
try:
username_element = self.__driver.find_element(By.NAME, "username")
username_element.clear()
username_element.send_keys(username)
password_element = self.__driver.find_element(By.NAME, "password")
password_element.clear()
password_element.send_keys(password)
except Exception as e:
self._showTrace(f"用户名或密码填写失败 ! : {e}", self.TraceLevel.ERROR)
return False
return True
def __autoRecognizeCaptcha(
self
) -> str:
# auto recognize captcha
try:
captcha_img = self.__driver.find_element(By.ID, "loadImgId")
img_src = captcha_img.get_attribute("src")
base64_str = img_src.split(',', 1)[1]
captcha_img = base64.b64decode(base64_str)
captcha_text = self.__ddddocr.classification(captcha_img)
captcha_text = ''.join(filter(str.isalnum, captcha_text)).lower()
self._showTrace(f"识别到验证码为 : '{captcha_text}'", no_log=True)
if len(captcha_text) != 4:
self._showLog("识别到的验证码长度不等于 4 个字符 !", self.TraceLevel.WARNING)
raise Exception("识别到的验证码长度不等于 4 个字符 !")
return captcha_text
except Exception as e:
self._showTrace(f"验证码识别失败 ! : {e}", self.TraceLevel.ERROR)
return ""
def __manualRecognizeCaptcha(
self
) -> str:
# manual recognize captcha
try:
self._showMsg("请输入验证码:")
captcha_text = self._waitMsg(timeout=15)
self._showTrace(f"输入的验证码为 : '{captcha_text}'", no_log=True)
if len(captcha_text) != 4:
self._showLog("输入的验证码长度不等于 4 个字符 !", self.TraceLevel.WARNING)
raise Exception("输入的验证码长度不等于 4 个字符 !")
return captcha_text
except Exception as e:
self._showTrace(f"输入验证码失败 ! : {e}", self.TraceLevel.ERROR)
return ""
def __refreshCaptcha(
self
):
# refresh captcha
try:
self._showTrace("刷新验证码......", no_log=True)
self.__driver.find_element(
By.ID, "loadImgId"
).click()
return True
except Exception as e:
self._showTrace(f"刷新验证码失败 ! : {e}", self.TraceLevel.ERROR)
return False
def __solveCaptcha(
self,
auto_captcha: bool = True
) -> str:
max_attempts = 3 # the possibility of 3 times failed is less than (10%^3)
for _ in range(max_attempts):
if auto_captcha:
captcha_text = self.__autoRecognizeCaptcha()
else:
self._showTrace(f"用户未配置自动识别验证码, 请手动输入验证码 !", no_log=True)
captcha_text = self.__manualRecognizeCaptcha()
if captcha_text:
return captcha_text
else:
if not self.__refreshCaptcha():
return ""
self._showTrace(
f"验证码识别失败 {max_attempts} 次, 达到最大尝试次数 !",
self.TraceLevel.WARNING
)
return ""
def __fillCaptchaElement(
self,
captcha_text: str
) -> bool:
try:
captcha_element = self.__driver.find_element(By.NAME, "answer")
captcha_element.clear()
captcha_element.send_keys(captcha_text)
return True
except Exception as e:
self._showTrace(f"验证码填写失败 ! : {e}", self.TraceLevel.ERROR)
return False
def login(
self,
username: str,
password: str,
max_attempts: int = 5,
auto_captcha: bool = True
) -> bool:
if self.__driver is None:
self._showTrace("未提供有效 WebDriver 实例 !", self.TraceLevel.WARNING)
return False
# begin login process
for attempt in range(max_attempts):
self._showTrace(f"用户 {username}{attempt + 1} 次尝试登录......", no_log=True)
if not self.__fillLogInElements(
username,
password,
):
continue
captcha_text = self.__solveCaptcha(auto_captcha)
if not captcha_text:
continue
if not self.__fillCaptchaElement(captcha_text):
continue
self._showTrace("尝试登录...", no_log=True)
try:
self.__driver.find_element(
By.XPATH,
"//input[@type='button' and @value='登录']"
).click()
except Exception as e:
self._showTrace(f"尝试登录失败 ! : {e}")
continue
if self._waitResponseLoad():
self._showTrace(f"用户 {username}{attempt + 1} 次登录成功 !")
return True
else:
self._showTrace(f"用户 {username}{attempt + 1} 次登录失败 !",self.TraceLevel.WARNING)
return False
-55
View File
@@ -1,55 +0,0 @@
# -*- coding: utf-8 -*-
"""
Copyright (c) 2025 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 selenium.webdriver.common.by import By
from selenium.webdriver.chrome.webdriver import WebDriver
from base.LibOperator import LibOperator
class LibLogout(LibOperator):
def __init__(
self,
input_queue: queue.Queue,
output_queue: queue.Queue,
driver: WebDriver
):
super().__init__(input_queue, output_queue)
self.__driver = driver
def _waitResponseLoad(
self
) -> bool:
return True
def logout(
self,
username: str
) -> bool:
if self.__driver is None:
self._showTrace("未提供有效 WebDriver 实例 !", self.TraceLevel.WARNING)
return False
try:
self.__driver.find_element(
By.XPATH, "//a[@href='/logout']"
).click()
self._showTrace(f"用户 {username} 注销成功 !")
return True
except Exception as e:
self._showTrace(f"用户 {username} 注销失败 ! : {e}", self.TraceLevel.ERROR)
return False
-205
View File
@@ -1,205 +0,0 @@
# -*- coding: utf-8 -*-
"""
Copyright (c) 2025 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 selenium.webdriver.common.by import By
from selenium.webdriver.chrome.webdriver import WebDriver
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from base.LibTimeSelector import LibTimeSelector
class LibRenew(LibTimeSelector):
def __init__(
self,
input_queue: queue.Queue,
output_queue: queue.Queue,
driver: WebDriver
):
super().__init__(input_queue, output_queue)
self.__driver = driver
def _waitResponseLoad(
self
) -> bool:
self.__driver.refresh()
return True
def __waitRenewDialog(
self
) -> bool:
try:
WebDriverWait(self.__driver, 2).until(
EC.visibility_of_element_located((By.ID, "extendDiv"))
)
head_message = WebDriverWait(self.__driver, 2).until(
EC.presence_of_element_located((By.CSS_SELECTOR, "#extendDiv p.messageHead"))
)
result_message = WebDriverWait(self.__driver, 2).until(
EC.presence_of_element_located((By.CSS_SELECTOR, "#extendDiv div.resultMessage"))
)
except:
self._showTrace("续约时间选择界面加载失败 !", self.TraceLevel.ERROR)
return False
head_message = head_message.text.strip()
if "警告" in head_message:
result_message = result_message.text.strip()
self._showTrace(f"\n"\
f" 续约失败 !\n"\
f" {result_message}", no_log=True)
return False
try:
WebDriverWait(self.__driver, 2).until(
EC.presence_of_all_elements_located(
(By.CSS_SELECTOR, "#extendDiv .renewal_List li")
)
)
WebDriverWait(self.__driver, 2).until(
EC.presence_of_element_located((By.CSS_SELECTOR, "#extendDiv .btnOK"))
)
except:
self._showTrace("续约时间选择界面加载失败 !", self.TraceLevel.ERROR)
return False
return True
def __selectNearestTime(
self,
record: dict,
reserve_info: dict
) -> bool:
"""
Select the nearest available renewal time.
"""
end_time = record["time"]["end"]
renew_info = reserve_info["renew_time"]
max_diff = renew_info["max_diff"]
prefer_earlier = renew_info["prefer_early"]
target_renew_mins = self._timeStrToMins(end_time) + renew_info["expect_duration"]*60
# Validate and adjust target renew time to library closing time
if not self.__validateAndAdjustRenewTime(end_time, target_renew_mins):
return False
renew_ok_btn = self.__driver.find_element(By.CSS_SELECTOR, "#extendDiv .btnOK")
renew_time_opts = self.__driver.find_elements(By.CSS_SELECTOR, "#extendDiv .renewal_List li")
if not renew_time_opts:
self._showTrace("当前未查询到可用续约时间 !", self.TraceLevel.WARNING)
return False
# Find best renewal time option
best_opt, best_text, actual_diff, free_times = self._findBestTimeOption(
renew_time_opts, target_renew_mins, max_diff, prefer_earlier, is_reserve=False
)
if best_opt is not None:
return self.__confirmRenewal(best_opt, best_text, actual_diff, record, renew_ok_btn)
self._showTrace(
"无法选择最近的可用续约时间 ! "
f"所有可选时间与目标时间相差都超过了 {max_diff} 分钟 !",
self.TraceLevel.WARNING
)
self._showTrace(f"当前可供续约的时间有: {free_times}")
return False
def __validateAndAdjustRenewTime(
self,
end_time: str,
target_renew_mins: int
) -> bool:
"""
Validate and adjust renewal time to library closing time if needed.
"""
LIBRARY_CLOSE_TIME = 1410 # 23:30 in minutes
if target_renew_mins > LIBRARY_CLOSE_TIME:
actual_renew_duration = LIBRARY_CLOSE_TIME - self._timeStrToMins(end_time)
if actual_renew_duration <= 0:
self._showTrace(f"当前结束时间 {end_time} 已接近闭馆时间,无法续约 !", self.TraceLevel.ERROR)
return False
self._showTrace(
f"续约时间已调整至闭馆时间 {self._minsToTimeStr(LIBRARY_CLOSE_TIME)},"
f"实际续约时长为 {actual_renew_duration//60} 小时 {actual_renew_duration%60} 分钟"
)
return True
return True
def __confirmRenewal(
self,
best_opt,
best_text: str,
actual_diff: int,
record: dict,
ok_btn
) -> bool:
"""
Confirm the selected renewal time.
"""
try:
best_opt.click()
abs_diff = abs(actual_diff)
time_relation = self._formatTimeRelation(abs_diff, actual_diff, "续约时间")
self._showTrace(
f"选择距离期望续约时间最近的 {best_text}, "
f"与期望续约时间相比 {time_relation}"
)
record["time"]["end"] = best_text.strip()
ok_btn.click()
return True
except:
self._showTrace("确认续约时发生错误 !", self.TraceLevel.ERROR)
return False
def renew(
self,
username: str,
record: dict,
reserve_info: dict
) -> bool:
if self.__driver is None:
self._showTrace("未提供有效 WebDriver 实例 !", self.TraceLevel.WARNING)
return False
try:
renew_btn = WebDriverWait(self.__driver, 2).until(
EC.element_to_be_clickable((By.ID, "btnExtend"))
)
except:
self._showTrace(f"用户 {username} 续约界面加载失败 !", self.TraceLevel.ERROR)
return False
if "disabled" in renew_btn.get_attribute("class"):
self._showLog(f"用户 {username} 续约按钮不可用, 可能不在场馆内")
self._showTrace(f"用户 {username} 续约按钮不可用, 可能不在场馆内, 请连接图书馆网络后重试", no_log=True)
return False
renew_btn.click()
if not self.__waitRenewDialog():
self._showTrace(f"用户 {username} 续约失败 !", self.TraceLevel.ERROR)
# After the renewal, the webpage will display a mask overlay,
# so we need to refresh the page for subsequent operations.
self.__driver.refresh()
return False
if not self.__selectNearestTime(record, reserve_info):
self._showTrace(f"用户 {username} 续约失败 !", self.TraceLevel.ERROR)
self.__driver.refresh()
return False
if self._waitResponseLoad():
return True
-693
View File
@@ -1,693 +0,0 @@
# -*- coding: utf-8 -*-
"""
Copyright (c) 2025 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 time
import queue
from selenium.webdriver.common.by import By
from selenium.webdriver.chrome.webdriver import WebDriver
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from base.LibTimeSelector import LibTimeSelector
class LibReserve(LibTimeSelector):
def __init__(
self,
input_queue: queue.Queue,
output_queue: queue.Queue,
driver: WebDriver
):
super().__init__(input_queue, output_queue)
self.__driver = driver
# library floor and room mapping in website
self.__floor_map = {
"2": "二层",
"3": "三层",
"4": "四层",
"5": "五层"
}
self.__room_map = {
"1": "二层内环",
"2": "二层西区",
"3": "三层内环",
"4": "三层外环",
"5": "四层内环",
"6": "四层外环",
"7": "四层期刊",
"8": "五层考研"
}
def _waitResponseLoad(
self,
) -> bool:
try:
WebDriverWait(self.__driver, 2).until(
EC.presence_of_element_located((By.CLASS_NAME, "layoutSeat"))
)
title_elements = []
# reserve failed without title elements, so we need to try
try:
WebDriverWait(self.__driver, 2).until(
EC.presence_of_element_located((By.CSS_SELECTOR, ".layoutSeat dt"))
)
title_elements = self.__driver.find_elements(
By.CSS_SELECTOR, ".layoutSeat dt"
)
except:
pass
content_elements = self.__driver.find_elements(
By.CSS_SELECTOR, ".layoutSeat dd"
)
if not content_elements:
self._showTrace("未找到预约结果", self.TraceLevel.WARNING)
raise
title = title_elements[0].text if title_elements else ""
contents = [element.text for element in content_elements if element.text.strip()]
for message in contents:
if "预约失败" in message or "已有1个有效预约" in message:
self._showTrace(f"预约失败 - {"".join(contents)}", self.TraceLevel.ERROR)
raise
if "预定好了" in title or "预约成功" in title or "操作成功" in title:
if len(contents) >= 6:
self._showTrace(f"\n"\
f" 预约成功 !\n"\
f" {contents[1]}\n"\
f" {contents[2]}\n"\
f" {contents[3]}\n"\
f" 签到时间 {contents[5]}"
)
else:
self._showTrace("\n"\
" 预约成功 !\n"\
" 未找获取到详细信息"
)
return True
except:
self._showTrace(f"预约结果加载失败 !", self.TraceLevel.ERROR)
return False
def __containRequiredInfo(
self,
reserve_info: dict
) -> bool:
try:
# must contain the required infomation
# key 'place' is no need to check
# because 'place' is only has one possible value '1' or '图书馆'
if reserve_info.get("floor") is None: # if existence ?
raise ValueError("未指定楼层")
if reserve_info["floor"] not in self.__floor_map: # if in the mao ?
raise ValueError(f"该楼层 '{reserve_info['floor']}' 不存在")
if reserve_info.get("room") is None:
raise ValueError("未指定房间")
if reserve_info["room"] not in self.__room_map:
raise ValueError(f"该房间 '{reserve_info['room']}' 不存在")
if reserve_info.get("seat_id") is None:
raise ValueError("未指定座位")
if reserve_info["seat_id"] == "":
raise ValueError("未指定座位号")
return True
except ValueError as e:
self._showTrace(
f"预约信息错误 ! : {e}, "\
f"由于缺少必要的预约信息, 无法开始预约流程",
self.TraceLevel.ERROR
)
self._showTrace(
f"预约信息错误 ! : {e}, "\
f"由于缺少必要的预约信息, 无法开始预约流程, 请检查预约信息是否完整",
no_log=True
)
return False
def __isValidDate(
self,
reserve_info: dict
) -> bool:
cur_date_str = time.strftime("%Y-%m-%d", time.localtime())
cur_timestamp = time.mktime(time.strptime(cur_date_str, "%Y-%m-%d"))
if reserve_info.get("date") is None:
reserve_info["date"] = cur_date_str
self._showTrace(f"预约日期未指定, 自动设置为当前日期: {cur_date_str}")
else:
res_timestamp = time.mktime(time.strptime(reserve_info["date"], "%Y-%m-%d"))
if res_timestamp < cur_timestamp:
self._showTrace(
f"预约日期错误 ! :"\
f"{reserve_info['date']} 早于当前日期 {cur_date_str}, 自动设置为当前日期",
self.TraceLevel.WARNING
)
reserve_info["date"] = cur_date_str
return True
def __isValidBeginTime(
self,
reserve_info: dict
) -> bool:
cur_time = time.strftime("%H:%M", time.localtime())
if reserve_info.get("begin_time") is None:
reserve_info["begin_time"] = {}
if "time" not in reserve_info["begin_time"]:
reserve_info["begin_time"]["time"] = cur_time
self._showTrace(f"开始时间未指定, 自动设置为当前时间: {cur_time}")
if "max_diff" not in reserve_info["begin_time"]:
reserve_info["begin_time"]["max_diff"] = 30
self._showTrace(f"开始时间最大时间差未指定, 自动设置为 30 分钟")
if "prefer_early" not in reserve_info["begin_time"]:
reserve_info["begin_time"]["prefer_early"] = True
self._showTrace(f"是否优先选择更早开始时间未指定, 自动设置为 True")
return True
def __isValidExpectDuration(
self,
reserve_info: dict
) -> bool:
if reserve_info.get("satisfy_duration") is None:
reserve_info["satisfy_duration"] = True
self._showTrace("预约满足时长要求未指定, 默认满足")
if reserve_info["satisfy_duration"]:
if reserve_info.get("expect_duration") is None:
reserve_info["expect_duration"] = 4
self._showTrace("需要满足预约持续时间, 但未指定, 使用默认时长为 4 小时")
return True
def __isValidEndTime(
self,
reserve_info: dict
) -> bool:
if reserve_info.get("end_time") is None:
reserve_info["end_time"] = {}
if "time" not in reserve_info["end_time"]:
# here we add the expect duration to the begin time first,
# the edge case that the end time is later than 23:30 will
# be handled in __finalCheck. so no need to concern about it.
end_mins = self._timeStrToMins(reserve_info["begin_time"]["time"])
end_mins = end_mins + int(reserve_info["expect_duration"]*60)
reserve_info["end_time"] = {
"time": self._minsToTimeStr(end_mins),
"max_diff": 30,
"prefer_early": False
}
self._showTrace(
f"结束时间未指定, 自动设置为开始时间加上期望时长: {reserve_info['end_time']['time']}"
)
if "max_diff" not in reserve_info["end_time"]:
reserve_info["end_time"]["max_diff"] = 30
self._showTrace(f"结束时间最大时间差未指定, 自动设置为 30 分钟")
if "prefer_early" not in reserve_info["end_time"]:
reserve_info["end_time"]["prefer_early"] = False
self._showTrace(f"是否优先选择较晚结束时间未指定, 自动设置为 True")
return True
def __finalCheck(
self,
reserve_info: dict
):
begin_time, end_time = reserve_info["begin_time"], reserve_info["end_time"]
begin_mins = self._timeStrToMins(begin_time["time"])
end_mins = self._timeStrToMins(end_time["time"])
# if end time is earlier than begin_time, exchange them
# except that the user has set the satisfy_duration to True
if end_mins < begin_mins and reserve_info["satisfy_duration"] is False:
self._showTrace(
f"结束时间 {end_time['time']} 早于开始时间 {begin_time['time']}, 尝试交换时间",
self.TraceLevel.WARNING
)
reserve_info["end_time"], reserve_info["begin_time"] = begin_time, end_time
begin_time, end_time = end_time, begin_time
begin_mins = self._timeStrToMins(begin_time["time"])
end_mins = self._timeStrToMins(end_time["time"])
# ensure the end time is not later than 23:30
max_end_mins = self._timeStrToMins("23:30")
if end_mins > max_end_mins:
self._showTrace(
f"结束时间 {end_time['time']} 晚于 23:30, 自动设置为 23:30",
self.TraceLevel.WARNING
)
reserve_info["end_time"]["time"] = "23:30"
end_mins = max_end_mins
# ensure the duration is not longer than 8 hours
if reserve_info["satisfy_duration"]:
if reserve_info["expect_duration"] > 8:
self._showTrace(
f"该用户设置了优先满足时长要求, 但是预约期望持续时间 "
f"{reserve_info['expect_duration']} 小时 "
f"超出最大时长 8 小时, 自动设置为 8 小时",
self.TraceLevel.WARNING
)
reserve_info["expect_duration"] = 8
else:
if end_mins - begin_mins > 8*60:
self._showTrace(
f"该用户未设置优先满足时长要求, 但是检查到预约持续时间 "
f"{float((end_mins - begin_mins)/60)} 小时 "
f"超出最大时长 8 小时, 自动设置为 8 小时",
self.TraceLevel.WARNING
)
reserve_info["end_time"]["time"] = self._minsToTimeStr(begin_mins + 8*60)
return True
def __checkReserveInfo(
self,
reserve_info: dict
) -> bool:
if not self.__containRequiredInfo(reserve_info):
return False
if not self.__isValidDate(reserve_info):
return False
if not self.__isValidBeginTime(reserve_info):
return False
if not self.__isValidExpectDuration(reserve_info):
return False
if not self.__isValidEndTime(reserve_info):
return False
if not self.__finalCheck(reserve_info):
return False
self._showTrace(
f"预约信息检查完成, 准备预约 "
f"{reserve_info['date']} "
f"{reserve_info['begin_time']['time']} - "
f"{reserve_info['end_time']['time']} "
f"图书馆 "
f"{self.__floor_map[reserve_info['floor']]} "
f"{self.__room_map[reserve_info['room']]} "
f"的座位 {reserve_info['seat_id']}"
)
return True
def __clickElement(
self,
trigger_locator: tuple,
fail_msg: str,
success_msg: str,
option_locator: tuple = None
) -> bool:
try:
# click the trigger element
WebDriverWait(self.__driver, 2).until(
EC.element_to_be_clickable(trigger_locator)
).click()
if option_locator:
# select the option element if specified
WebDriverWait(self.__driver, 2).until(
EC.element_to_be_clickable(option_locator)
).click()
self._showTrace(success_msg)
return True
except:
self._showTrace(fail_msg)
return False
def __clickElementByJS(
self,
trigger_locator_id: str,
option_query_selector: str,
fail_msg: str,
success_msg: str,
) -> bool:
script = f"""
try {{
var trigger = document.getElementById('{trigger_locator_id}');
if (trigger) {{
trigger.click();
var option = document.querySelector("{option_query_selector}");
if (option) {{
option.click();
return true;
}}
return false;
}}
return false;
}} catch (e) {{
return false;
}}
"""
result = self.__driver.execute_script(script)
time.sleep(0.1)
if result:
self._showTrace(success_msg)
else:
self._showTrace(fail_msg)
return result
def __selectDate(
self,
date_str: str
) -> bool:
if self.__clickElementByJS(
trigger_locator_id="onDate_select",
option_query_selector=f"p#options_onDate a[value='{date_str}']",
success_msg=f"日期 {date_str} 选择成功 !",
fail_msg=f"选择日期失败 ! : {date_str} 不可用"
):
return True
return self.__clickElement(
trigger_locator=(By.ID, "onDate_select"),
option_locator=(By.XPATH, f"//p[@id='options_onDate']/a[@value='{date_str}']"),
success_msg=f"日期 {date_str} 选择成功 !",
fail_msg=f"选择日期失败 ! : {date_str} 不可用"
)
def __selectPlace(
self,
place: str
) -> bool:
place = "1" # the library only have this place :)
display_place = "图书馆"
if self.__clickElementByJS(
trigger_locator_id="display_building",
option_query_selector=f"p#options_building a[value='{place}']",
success_msg=f"预约场所 {display_place} 选择成功 !",
fail_msg=f"选择预约场所失败 ! : {display_place} 不可用"
):
return True
return self.__clickElement(
trigger_locator=(By.ID, "display_building"),
option_locator=(By.XPATH, f"//p[@id='options_building']/a[@value='{place}']"),
success_msg=f"预约场所 {display_place} 选择成功 !",
fail_msg=f"选择预约场所失败 ! : {display_place} 不可用"
)
def __selectFloor(
self,
floor: str
) -> bool:
display_floor = self.__floor_map.get(floor)
if self.__clickElementByJS(
trigger_locator_id="floor_select",
option_query_selector=f"p#options_floor a[value='{floor}']",
success_msg=f"楼层 {display_floor} 选择成功 !",
fail_msg=f"选择楼层失败 ! : {display_floor} 不可用"
):
return True
return self.__clickElement(
trigger_locator=(By.ID, "floor_select"),
option_locator=(By.XPATH, f"//p[@id='options_floor']/a[@value='{floor}']"),
success_msg=f"楼层 {display_floor} 选择成功 !",
fail_msg=f"选择楼层失败 ! : {display_floor} 不可用"
)
def __selectRoom(
self,
room: str
) -> bool:
display_room = self.__room_map.get(room)
# find room
try:
WebDriverWait(self.__driver, 2).until(
EC.element_to_be_clickable((By.ID, "findRoom"))
).click()
except:
self._showTrace("加载房间/区域失败 !", self.TraceLevel.ERROR)
return False
# select room
try:
WebDriverWait(self.__driver, 2).until(
EC.element_to_be_clickable((By.ID, f"room_{room}"))
).click()
self._showTrace(f"房间 {display_room} 选择成功 !")
return True
except:
self._showTrace(f"选择房间失败 ! : {display_room} 不可用", self.TraceLevel.ERROR)
return False
def __selectSeat(
self,
seat_id: str
) -> bool:
try:
# wait fot seat layout element to load
WebDriverWait(self.__driver, 2).until(
EC.presence_of_element_located((By.ID, "seatLayout"))
)
WebDriverWait(self.__driver, 2).until(
EC.presence_of_all_elements_located((By.CSS_SELECTOR, "li[id^='seat_']"))
)
except:
self._showTrace(f"座位加载失败 !", self.TraceLevel.ERROR)
return False
try:
all_seats = self.__driver.find_elements(
By.CSS_SELECTOR, "li[id^='seat_']"
)
seat_id_upper = seat_id.lstrip('0').upper()
for seat in all_seats:
if not seat_id_upper == seat.text.lstrip('0'):
continue
seat_link = seat.find_element(By.TAG_NAME, "a")
WebDriverWait(self.__driver, 2).until(
EC.element_to_be_clickable(seat_link)
)
seat_link.click()
seat_status = seat_link.get_attribute("title")
self._showTrace(f"座位 {seat_id} 选择成功 ! : 当前状态 - '{seat_status}'")
return True
self._showLog(f"座位 {seat_id} 在该楼层区域中不存在", self.TraceLevel.WARNING)
self._showTrace(f"座位 {seat_id} 在该楼层区域中不存在, 请检查座位号是否正确", no_log=True)
except:
self._showTrace(f"座位选择失败 !", self.TraceLevel.ERROR)
return False
def __selectNearestTime(
self,
time_id: str,
time_type: str,
target_time: int,
max_time_diff: int = 30,
prefer_earlier: bool = True
) -> int:
"""
Select the nearest available time option.
Returns:
int: The actual selected time value in minutes.
"""
# Wait for time options to load
try:
WebDriverWait(self.__driver, 2).until(
EC.presence_of_all_elements_located(
(By.CSS_SELECTOR, f"#{time_id} ul li a")
)
)
except:
self._showTrace(f"{time_type} 选择失败 ! : 当前未查询到可用时间", self.TraceLevel.ERROR)
return -1
# Find best time option
all_time_opts = self.__driver.find_elements(
By.CSS_SELECTOR,
f"#{time_id} ul li a"
)
if not all_time_opts:
self._showTrace(f"{time_type} 选择失败 ! : 当前未查询到可用时间", self.TraceLevel.ERROR)
return -1
best_opt, best_text, actual_diff, free_times = self._findBestTimeOption(
all_time_opts, target_time, max_time_diff, prefer_earlier, is_reserve=True
)
if best_opt is not None:
best_opt.click()
abs_diff = abs(actual_diff)
time_relation = self._formatTimeRelation(abs_diff, actual_diff, time_type)
target_time += actual_diff
self._showTrace(
f"选择距离期望 {time_type} 最近的 {best_text}, "
f"与期望 {time_type} 相比 {time_relation}"
)
return target_time
self._showTrace(
f"无法选择最近的 {time_type} {self._minsToTimeStr(target_time)}, "
f"所有可选时间与目标时间相差都超过 {max_time_diff} 分钟", self.TraceLevel.WARNING
)
self._showTrace(f"当前可供预约的 {time_type} 有: {free_times}")
return -1
def __selectSeatTime(
self,
begin_time: dict,
end_time: dict,
expect_duration: int = 4,
satisfy_duration: bool = True
) -> bool:
"""
Select seat begin and end time.
"""
exp_beg_tm_str = begin_time["time"]
exp_end_tm_str = end_time["time"]
# Initialize actual time strings for logging
act_beg_tm_str = exp_beg_tm_str
act_end_tm_str = exp_end_tm_str
exp_beg_mins = self._timeStrToMins(exp_beg_tm_str)
act_beg_mins = exp_beg_mins
exp_end_mins = self._timeStrToMins(exp_end_tm_str)
act_end_mins = exp_end_mins
# Select begin time
act_beg_mins = self.__selectNearestTime(
time_id="startTime",
time_type="开始时间",
target_time=exp_beg_mins,
max_time_diff=begin_time["max_diff"],
prefer_earlier=begin_time["prefer_early"]
)
if act_beg_mins == -1:
return False
act_beg_tm_str = self._minsToTimeStr(act_beg_mins)
# If 'satisfy_duration' is True, select end time based on actual begin time
if satisfy_duration:
exp_end_mins = int(self.validateAndAdjustEndTime(act_beg_mins, expect_duration))
exp_end_tm_str = self._minsToTimeStr(exp_end_mins)
self._showTrace(
f"需要满足期望预约持续时间: {expect_duration} 小时, "
f"根据开始时间 {act_beg_tm_str} 计算结束时间: {exp_end_tm_str}"
)
# Select end time
act_end_mins = self.__selectNearestTime(
time_id="endTime",
time_type="结束时间",
target_time=exp_end_mins,
max_time_diff=end_time["max_diff"],
prefer_earlier=end_time["prefer_early"]
)
if act_end_mins == -1:
return False
act_end_tm_str = self._minsToTimeStr(act_end_mins)
self._showTrace(
f"期望预约时间段: {exp_beg_tm_str} - {exp_end_tm_str}, "
f"实际预约时间段: {act_beg_tm_str} - {act_end_tm_str}"
)
return True
def validateAndAdjustEndTime(
self,
begin_mins: int,
duration: int
) -> int:
"""
Validate and adjust reserve end time to library closing time if needed.
"""
LIBRARY_CLOSE_TIME = self._timeStrToMins("23:30")
expect_end_mins = int(begin_mins + duration*60)
if expect_end_mins > LIBRARY_CLOSE_TIME:
expect_end_mins = LIBRARY_CLOSE_TIME
self._showTrace(
f"预约持续时间 {duration} 小时, 超过最大预约时间 23:30, 自动调整为 23:30",
self.TraceLevel.WARNING
)
return expect_end_mins
def reserve(
self,
username: str,
reserve_info: dict
) -> bool:
submit_reserve = False
reserve_success = False
have_hover_on_page = False
# reserve info
if not self.__checkReserveInfo(reserve_info):
return False
# map page
try:
WebDriverWait(self.__driver, 2).until(
EC.element_to_be_clickable((By.XPATH, "//a[@href='/map']"))
).click()
WebDriverWait(self.__driver, 2).until(
EC.presence_of_element_located((By.ID, "seatLayout"))
)
except:
self._showTrace(f"加载预约选座页面失败 !", self.TraceLevel.ERROR)
return False
# date, place, floor, room
if not self.__selectDate(reserve_info["date"]):
return False
if not self.__selectPlace(reserve_info["place"]):
return False
if not self.__selectFloor(reserve_info["floor"]):
return False
if not self.__selectRoom(reserve_info["room"]):
return False
else:
have_hover_on_page = True
# seat selections
if not self.__selectSeat(reserve_info["seat_id"]):
pass
elif not self.__selectSeatTime(
begin_time=reserve_info["begin_time"],
end_time=reserve_info["end_time"],
expect_duration=reserve_info["expect_duration"],
satisfy_duration=reserve_info["satisfy_duration"]
):
pass
else:
try:
WebDriverWait(self.__driver, 2).until(
EC.element_to_be_clickable((By.ID, "reserveBtn"))
).click()
submit_reserve = True
if not self._waitResponseLoad():
raise
reserve_success = True
except:
self._showTrace(f"预约提交失败 !", self.TraceLevel.ERROR)
if not submit_reserve and have_hover_on_page:
self.__driver.refresh()
if reserve_success:
self._showTrace(f"用户 {username} 预约成功 !")
else:
self._showTrace(f"用户 {username} 预约失败 !", self.TraceLevel.ERROR)
return reserve_success
-13
View File
@@ -1,13 +0,0 @@
"""
Operators module for the AutoLibrary project.
Here are the classes and modules in this package:
- AutoLib: AutoLibrary operator.
- LibLogin: Library operator for logging in.
- LibLogout: Library operator for logging out.
- LibReserve: Library operator for reserving seat.
- LibCheckin: Library operator for checking in seat.
- LibCheckout: Library operator for checking out seat.
- LibChecker: Library operator for checking record status.
- LibRenew: Library operator for renewing seat.
"""
+173 -138
View File
@@ -9,23 +9,24 @@ See the LICENSE file for details.
"""
import os
import queue
from selenium import webdriver
from selenium.common.exceptions import TimeoutException
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from selenium.common.exceptions import (
TimeoutException,
WebDriverException,
)
from selenium.webdriver.edge.service import Service as EdgeService
from selenium.webdriver.chrome.service import Service as ChromeService
from selenium.webdriver.firefox.service import Service as FirefoxService
from base.MsgBase import MsgBase
from operators.LibChecker import LibChecker
from operators.LibLogin import LibLogin
from operators.LibLogout import LibLogout
from operators.LibReserve import LibReserve
from operators.LibCheckin import LibCheckin
from operators.LibRenew import LibRenew
from pages.LoginPage import LoginPage
from pages.MainShell import MainShell
from pages.flows.ReserveFlow import ReserveFlow, ReserveContext
from pages.flows.CheckinFlow import CheckinFlow
from pages.flows.RenewFlow import RenewFlow
from pages.services.CaptchaSolver import CaptchaSolver
from pages.services.ReserveChecker import ReserveChecker
from pages.services.RecordChecker import RecordChecker
class AutoLib(MsgBase):
@@ -34,30 +35,40 @@ class AutoLib(MsgBase):
self,
input_queue: queue.Queue,
output_queue: queue.Queue,
run_config: dict
):
run_config: dict,
) -> None:
super().__init__(input_queue, output_queue)
self.__run_config = run_config
self.__user_config = None
self.__run_config: dict = run_config
self.__user_config: dict | None = None
self.__driver = None
self.__driver_type: str = ""
self.__driver_path: str = ""
self.__login_page: LoginPage = None
self.__shell: MainShell = None
self.__captcha_solver: CaptchaSolver = None
self.__record_checker: RecordChecker = None
self.__reserve_checker: ReserveChecker = None
self.__reserve_flow: ReserveFlow = None
self.__checkin_flow: CheckinFlow = None
self.__renew_flow: RenewFlow = None
if not self.__initBrowserDriver():
raise Exception("浏览器驱动初始化失败 !")
else:
if not self.__initDriverUrl():
self.close()
raise Exception("浏览器驱动URL初始化失败 !")
self.__initLibOperators()
self.__initPagesServices()
self.__initPagesFlows()
def __initBrowserDriver(
self
self,
) -> bool:
self._showTrace("正在初始化浏览器驱动......", no_log=True)
web_driver_config = self.__run_config.get("web_driver", None)
self.__driver_type = web_driver_config.get("driver_type")
web_driver_config: dict = self.__run_config.get("web_driver", None)
self.__driver_type = web_driver_config.get("driver_type", "none")
match self.__driver_type.lower():
case "edge":
driver_options = webdriver.EdgeOptions()
@@ -68,14 +79,13 @@ class AutoLib(MsgBase):
case _:
self._showTrace(
f"不支持的浏览器驱动类型: {self.__driver_type} !",
self.TraceLevel.WARNING
self.TraceLevel.WARNING,
)
return False
if not web_driver_config:
self._showTrace("未配置浏览器驱动参数 !", self.TraceLevel.ERROR)
return False
if web_driver_config.get("headless"):
if web_driver_config.get("headless", False):
driver_options.add_argument("--headless")
driver_options.add_argument("--disable-gpu")
driver_options.add_argument("--no-sandbox")
@@ -102,6 +112,7 @@ class AutoLib(MsgBase):
"Safari/537.36"
if self.__driver_type.lower() == "edge":
user_agent += " Edg/120.0.0.0"
# set options for firefox
elif self.__driver_type.lower() == "firefox":
driver_options.set_preference("dom.webdriver.enabled", False)
@@ -111,12 +122,12 @@ class AutoLib(MsgBase):
driver_options.add_argument(f"user-agent={user_agent}")
# init browser driver
self.__driver_path = web_driver_config.get("driver_path")
self.__driver_path = web_driver_config.get("driver_path", "")
if not self.__driver_path:
self._showTrace("未配置浏览器驱动路径 !", self.TraceLevel.WARNING)
return False
self.__driver_path = os.path.abspath(self.__driver_path)
try:
self.__driver_path = os.path.abspath(self.__driver_path)
service = None
match self.__driver_type.lower():
case "edge":
@@ -126,87 +137,90 @@ class AutoLib(MsgBase):
service = ChromeService(executable_path=self.__driver_path)
self.__driver = webdriver.Chrome(service=service, options=driver_options)
case "firefox":
self._showTrace(f"Firefox 浏览器驱动初始化略慢, 请耐心等待...", no_log=True)
self._showTrace("Firefox 浏览器驱动初始化略慢, 请耐心等待...", no_log=True)
service = FirefoxService(executable_path=self.__driver_path)
self.__driver = webdriver.Firefox(service=service, options=driver_options)
case _: # actually will not happen, beacuse we have checked it at the initlization
# of 'driver_options'
case _:
raise Exception(f"不支持的浏览器驱动类型: {self.__driver_type} !")
self.__driver.implicitly_wait(1)
self.__driver.execute_script(
"Object.defineProperty(navigator, 'webdriver', {get: () => undefined})"
)
except Exception as e:
except WebDriverException as e:
self._showTrace(f"浏览器驱动初始化失败: {e}", self.TraceLevel.ERROR)
return False
self._showTrace(f"浏览器驱动已初始化, 类型: {self.__driver_type}, 路径: {self.__driver_path}")
return True
def __initLibOperators(
self
):
if not self.__driver:
self._showTrace(f"浏览器驱动未初始化, 请先初始化浏览器驱动 !", self.TraceLevel.WARNING)
return
self.__lib_checker = LibChecker(self._input_queue, self._output_queue, self.__driver)
self.__lib_login = LibLogin(self._input_queue, self._output_queue, self.__driver)
self.__lib_logout = LibLogout(self._input_queue, self._output_queue, self.__driver)
self.__lib_reserve = LibReserve(self._input_queue, self._output_queue, self.__driver)
self.__lib_checkin = LibCheckin(self._input_queue, self._output_queue, self.__driver)
self.__lib_renew = LibRenew(self._input_queue, self._output_queue, self.__driver)
def __waitResponseLoad(
self
) -> bool:
# wait for page load
try:
WebDriverWait(self.__driver, 2).until( # title contains "首页"
EC.title_contains("首页")
)
WebDriverWait(self.__driver, 2).until( # username field presence
EC.presence_of_element_located((By.NAME, "username"))
)
WebDriverWait(self.__driver, 2).until( # password field presence
EC.presence_of_element_located((By.NAME, "password"))
)
WebDriverWait(self.__driver, 2).until( # captcha field presence
EC.presence_of_element_located((By.NAME, "answer"))
)
WebDriverWait(self.__driver, 2).until( # captcha image presence
EC.presence_of_element_located((By.ID, "loadImgId"))
)
return True
except:
self._showTrace(f"登录页面加载失败 !", self.TraceLevel.ERROR)
return False
def __initDriverUrl(
self,
) -> bool:
lib_config = self.__run_config.get("library", None)
lib_config: dict = self.__run_config.get("library", None)
if not lib_config:
self._showTrace("未配置图书馆参数 !", self.TraceLevel.ERROR)
return False
url = lib_config.get("host_url") + lib_config.get("login_url")
url: str = lib_config.get("host_url") + lib_config.get("login_url")
self.__login_page = LoginPage(self.__driver, tracer=self._showTrace)
self.__driver.set_page_load_timeout(5)
try:
self.__driver.get(url)
except TimeoutException:
self.__driver.execute_script("window.stop();")
self.__login_page.stopPageLoad()
self._showTrace(
f"图书馆登录页面加载超时 ! 请检查网络环境是否正常", self.TraceLevel.ERROR
"图书馆登录页面加载超时 ! 请检查网络环境是否正常", self.TraceLevel.ERROR
)
return False
if not self.__waitResponseLoad():
except WebDriverException as e:
self._showTrace(f"图书馆页面加载失败: {e}", self.TraceLevel.ERROR)
return False
if not self.__login_page.waitUntilLoaded():
return False
return True
def __initPagesServices(
self,
) -> None:
if not self.__driver:
self._showTrace("浏览器驱动未初始化, 请先初始化浏览器驱动 !", self.TraceLevel.WARNING)
return
self.__shell = MainShell(self.__driver)
self.__captcha_solver = CaptchaSolver(
input_queue=self._input_queue,
output_queue=self._output_queue,
)
self.__record_checker = RecordChecker(
input_queue=self._input_queue,
output_queue=self._output_queue,
)
self.__reserve_checker = ReserveChecker(
input_queue=self._input_queue,
output_queue=self._output_queue,
)
def __initPagesFlows(
self,
) -> None:
self.__reserve_flow = ReserveFlow(
input_queue=self._input_queue,
output_queue=self._output_queue,
driver=self.__driver,
shell=self.__shell,
)
self.__checkin_flow = CheckinFlow(
input_queue=self._input_queue,
output_queue=self._output_queue,
driver=self.__driver,
shell=self.__shell,
)
self.__renew_flow = RenewFlow(
input_queue=self._input_queue,
output_queue=self._output_queue,
driver=self.__driver,
shell=self.__shell,
)
def __run(
self,
@@ -214,63 +228,84 @@ class AutoLib(MsgBase):
password: str,
login_config: dict,
run_mode_config: dict,
reserve_info: dict
reserve_info: dict,
) -> int:
# result : -1 - terminate, 0 - success, 1 - failed, 2 - passed
result = 2
result: int = 2
# login
if not self.__lib_login.login(
auto_captcha: bool = login_config.get("auto_captcha", True)
if not self.__login_page.login(
username,
password,
login_config.get("max_attempt", 3),
login_config.get("auto_captcha", True),
captcha_solver=self.__captcha_solver.solveCaptcha,
auto_captcha=auto_captcha,
max_attempts=login_config.get("max_attempt", 3),
):
return 1
# Here, we collect the run mode from the run config.
run_mode = run_mode_config.get("run_mode", 0)
run_mode = {
"auto_reserve": run_mode&0x1,
"auto_checkin": run_mode&0x2,
"auto_renewal": run_mode&0x4,
run_mode_raw: int = run_mode_config.get("run_mode", 0)
run_mode: dict[str, bool] = {
"auto_reserve": run_mode_raw & 0x1,
"auto_checkin": run_mode_raw & 0x2,
"auto_renewal": run_mode_raw & 0x4,
}
# reserve
if run_mode["auto_reserve"]:
if self.__lib_checker.canReserve(reserve_info.get("date")):
if self.__lib_reserve.reserve(username, reserve_info):
result = 0
if self.__reserve_checker.check(reserve_info):
if self.__record_checker.canReserve(self.__shell, reserve_info["date"]):
ctx = ReserveContext(
username=username,
date=reserve_info["date"],
floor=reserve_info["floor"],
room=reserve_info["room"],
seat_id=reserve_info["seat_id"],
begin_time=reserve_info["begin_time"]["time"],
end_time=reserve_info["end_time"]["time"],
begin_max_diff=reserve_info["begin_time"]["max_diff"],
end_max_diff=reserve_info["end_time"]["max_diff"],
begin_prefer_early=reserve_info["begin_time"]["prefer_early"],
end_prefer_early=reserve_info["end_time"]["prefer_early"],
expect_duration=reserve_info["expect_duration"],
satisfy_duration=reserve_info["satisfy_duration"],
)
if self.__reserve_flow.execute(ctx):
result = 0
else:
result = 1
else:
result = 1
self._showTrace(f"用户 {username} 无法预约, 已跳过")
result = 2
else:
self._showTrace(f"用户 {username} 无法预约, 已跳过")
result = 2
result = 1
# checkin
last_result = result
last_result: int = result
if run_mode["auto_checkin"] and last_result != 1:
if self.__lib_checker.canCheckin():
if self.__lib_checkin.checkin(username):
if self.__record_checker.canCheckin(self.__shell):
if self.__checkin_flow.execute(username):
result = 0
else:
result = 1
else:
self._showTrace(f"用户 {username} 无法签到, 已跳过")
result = 2
if last_result == 0: # partly success
if last_result == 0: # partly success
result = 0
# renewal
last_result = result
if run_mode["auto_renewal"] and last_result != 1:
can_renew, record = self.__lib_checker.canRenew()
can_renew, record = self.__record_checker.canRenew(self.__shell)
if can_renew:
if self.__lib_renew.renew(username, record, reserve_info):
if self.__lib_checker.postRenewCheck(record):
renew_info: dict = reserve_info.get("renew_time", {})
if self.__renew_flow.execute(username, record, renew_info):
if self.__record_checker.postRenewCheck(self.__shell, record):
self._showTrace(f"用户 {username} 续约成功 !")
result = 0
else:
if result != 1: # partly success
if result != 1: # partly success
result = 0
else:
result = 1
@@ -279,51 +314,48 @@ class AutoLib(MsgBase):
else:
self._showTrace(f"用户 {username} 无法续约, 已跳过")
result = 2
if last_result == 0: # partly success
if last_result == 0: # partly success
result = 0
# logout
if not self.__lib_logout.logout(
username
):
# if logout is failed, we must make sure the host to be reloaded
# otherwise, the next login may fail
if not self.__shell.logout():
self._showTrace(f"用户 {username} 退出登录失败, 尝试直接重载页面")
if not self.__initDriverUrl():
self._showTrace(f"用户 {username} 重载页面失败, 无法继续操作, 该任务已终止 !")
return -1
self._showTrace(f"用户 {username} 已退出登录")
return result
def run(
self,
user_config: dict
):
user_config: dict,
) -> None:
self.__user_config = user_config
user_counter = {"current": 0, "success": 0, "failed": 0, "passed": 0}
users = self.__user_config["users"]
user_counter: dict[str, int] = {"current": 0, "success": 0, "failed": 0, "passed": 0}
users: list = self.__user_config.get("users", [])
self._showTrace(f"共发现 {len(users)} 个用户")
for user in users:
user_counter["current"] += 1
self._showTrace(
f"正在处理第 {user_counter["current"]}/{len(users)} 个用户: {user["username"]}......",
no_log=True
f"正在处理第 {user_counter["current"]}/{len(users)} 个用户: {user.get("username", "未知")}......",
no_log=True,
)
if not user["enabled"]:
self._showTrace(f"用户 {user["username"]} 已跳过")
if not user.get("enabled", False):
self._showTrace(f"用户 {user.get("username", "未知")} 已跳过")
user_counter["passed"] += 1
continue
r = self.__run(
username=user["username"],
password=user["password"],
login_config=self.__run_config["login"],
run_mode_config=self.__run_config["mode"],
reserve_info=user["reserve_info"],
r: int = self.__run(
username=user.get("username", ""),
password=user.get("password", ""),
login_config=self.__run_config.get("login", {}),
run_mode_config=self.__run_config.get("mode", {}),
reserve_info=user.get("reserve_info", {}),
)
if r == -1:
self._showTrace(
f"用户 {user["username"]} 处理过程中页面发生异常, 无法继续操作, 任务已终止 !",
self.TraceLevel.WARNING
f"用户 {user.get("username", "未知")} 处理过程中页面发生异常, 无法继续操作, 任务已终止 !",
self.TraceLevel.WARNING,
)
break
elif r == 0:
@@ -332,28 +364,31 @@ class AutoLib(MsgBase):
user_counter["failed"] += 1
elif r == 2:
user_counter["passed"] += 1
self._showTrace(f"处理完成, 共计 {user_counter["current"]} 个用户, "\
f"成功 {user_counter["success"]} 个用户, "\
f"失败 {user_counter["failed"]} 个用户, "\
self._showTrace(
f"处理完成, 共计 {user_counter["current"]} 个用户, "
f"成功 {user_counter["success"]} 个用户, "
f"失败 {user_counter["failed"]} 个用户, "
f"跳过 {user_counter["passed"]} 个用户"
)
return
def close(
self
self,
) -> bool:
if self.__driver:
if self.__driver_type.lower() == "firefox":
self._showTrace(
f"Firefox 浏览器驱动关闭略慢, 请耐心等待...",
no_log=True
"Firefox 浏览器驱动关闭略慢, 请耐心等待...",
no_log=True,
)
self.__driver.quit()
try:
self.__driver.quit()
except WebDriverException as e:
self._showTrace(f"浏览器驱动关闭时发生异常: {e}", self.TraceLevel.WARNING)
self.__driver = None
self._showTrace(f"浏览器驱动已关闭")
self._showTrace("浏览器驱动已关闭")
return True
else:
self._showTrace(f"浏览器驱动未初始化, 无需关闭", no_log=True)
return False
self._showTrace("浏览器驱动未初始化, 无需关闭", no_log=True)
return False
+211
View File
@@ -0,0 +1,211 @@
# -*- 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 typing import Callable, Optional
from selenium.common.exceptions import (
ElementNotInteractableException,
NoSuchElementException,
TimeoutException,
)
from selenium.webdriver.common.by import By
from selenium.webdriver.remote.webdriver import WebDriver
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
class LoginPage:
USERNAME_INPUT = (By.NAME, "username")
PASSWORD_INPUT = (By.NAME, "password")
CAPTCHA_INPUT = (By.NAME, "answer")
CAPTCHA_IMG = (By.ID, "loadImgId")
LOGIN_BUTTON = (By.XPATH, "//input[@type='button' and @value='登录']")
SUCCESS_INDICATOR_SEARCH = (By.ID, "search")
SUCCESS_INDICATOR_CONTENT = (By.CLASS_NAME, "selectContent")
SUCCESS_TITLE_KEYWORD = "自选座位 :: 座位预约系统"
PAGE_LOAD_TIMEOUT = 5
def __init__(
self,
driver: WebDriver,
tracer: Optional[Callable[..., None]] = None,
) -> None:
self._driver: WebDriver = driver
self._tracer: Optional[Callable[..., None]] = tracer
def _trace(
self,
msg: str,
level: int = 20,
no_log: bool = False,
) -> None:
if self._tracer:
self._tracer(msg, level, no_log)
def navigate(
self,
url: str,
) -> bool:
self._driver.set_page_load_timeout(self.PAGE_LOAD_TIMEOUT)
self._driver.get(url)
if not self.waitUntilLoaded():
return False
return True
def waitUntilLoaded(
self,
) -> bool:
try:
WebDriverWait(self._driver, 2).until(
EC.title_contains("首页")
)
WebDriverWait(self._driver, 2).until(
EC.presence_of_element_located(self.USERNAME_INPUT)
)
WebDriverWait(self._driver, 2).until(
EC.presence_of_element_located(self.PASSWORD_INPUT)
)
WebDriverWait(self._driver, 2).until(
EC.presence_of_element_located(self.CAPTCHA_INPUT)
)
WebDriverWait(self._driver, 2).until(
EC.presence_of_element_located(self.CAPTCHA_IMG)
)
return True
except TimeoutException:
return False
def fillCredentials(
self,
username: str,
password: str,
) -> bool:
try:
el = self._driver.find_element(*self.USERNAME_INPUT)
el.clear()
el.send_keys(username)
el = self._driver.find_element(*self.PASSWORD_INPUT)
el.clear()
el.send_keys(password)
return True
except (NoSuchElementException, ElementNotInteractableException):
return False
def getCaptchaImageSrc(
self,
) -> str | None:
# return 'None' if captcha image element is not found.
# But the 'get_attribute("src")' also return 'None' if there's no attribute with
# that name, which is not what we want.
try:
captcha_el = self._driver.find_element(*self.CAPTCHA_IMG)
return captcha_el.get_attribute("src")
except NoSuchElementException:
return None
def refreshCaptcha(
self,
) -> bool:
try:
self._driver.find_element(*self.CAPTCHA_IMG).click()
return True
except (NoSuchElementException, ElementNotInteractableException):
return False
def fillCaptcha(
self,
captcha_text: str,
) -> bool:
try:
el = self._driver.find_element(*self.CAPTCHA_INPUT)
el.clear()
el.send_keys(captcha_text)
return True
except (NoSuchElementException, ElementNotInteractableException):
return False
def clickLogin(
self,
) -> bool:
try:
self._driver.find_element(*self.LOGIN_BUTTON).click()
return True
except (NoSuchElementException, ElementNotInteractableException):
return False
def waitLoginSuccess(
self,
) -> bool:
try:
WebDriverWait(self._driver, 2).until(
EC.title_contains(self.SUCCESS_TITLE_KEYWORD)
)
WebDriverWait(self._driver, 2).until(
EC.presence_of_element_located(self.SUCCESS_INDICATOR_SEARCH)
)
WebDriverWait(self._driver, 2).until(
EC.presence_of_element_located(self.SUCCESS_INDICATOR_CONTENT)
)
return True
except TimeoutException:
return False
def stopPageLoad(
self,
) -> None:
self._driver.execute_script("window.stop();")
def login(
self,
username: str,
password: str,
captcha_solver: Callable[["LoginPage", bool], str],
auto_captcha: bool,
max_attempts: int = 5,
) -> bool:
for attempt in range(max_attempts):
self._trace(
f"用户 {username}{attempt + 1} 次尝试登录......",
no_log=True,
)
if not self.fillCredentials(username, password):
continue
captcha_text = captcha_solver(self, auto_captcha)
if not captcha_text:
continue
if not self.fillCaptcha(captcha_text):
continue
self._trace("尝试登录...", no_log=True)
if not self.clickLogin():
continue
if self.waitLoginSuccess():
self._trace(f"用户 {username}{attempt + 1} 次登录成功 !")
return True
else:
self._trace(
"登录页面加载失败 ! : "
"用户账号或者密码错误/验证码错误, 具体以页面提示为准",
level=40,
)
return False
+177
View File
@@ -0,0 +1,177 @@
# -*- 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 time
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver.remote.webdriver import WebDriver
from selenium.common.exceptions import (
ElementNotInteractableException,
NoSuchElementException,
TimeoutException,
WebDriverException,
)
from pages.ReserveView import ReserveView
from pages.RecordsView import RecordsView
class MainShell:
TAB_RESERVE = (By.XPATH, "//a[@href='/map']")
TAB_HISTORY = (By.XPATH, "//a[@href='/history?type=SEAT']")
TAB_LOGOUT = (By.XPATH, "//a[@href='/logout']")
BTN_CHECKIN = (By.ID, "btnCheckIn")
BTN_EXTEND = (By.ID, "btnExtend")
def __init__(
self,
driver: WebDriver,
) -> None:
self._driver = driver
def _clickTab(
self,
locator: tuple,
) -> None:
WebDriverWait(self._driver, 2).until(
EC.element_to_be_clickable(locator)
).click()
def gotoReserveView(
self,
) -> ReserveView:
self._clickTab(self.TAB_RESERVE)
WebDriverWait(self._driver, 2).until(
EC.presence_of_element_located((By.ID, "seatLayout"))
)
return ReserveView(self._driver)
def gotoRecordsView(
self,
) -> RecordsView:
self._clickTab(self.TAB_HISTORY)
WebDriverWait(self._driver, 2).until(
EC.presence_of_element_located((By.CLASS_NAME, "myReserveList"))
)
return RecordsView(self._driver)
def logout(
self,
) -> bool:
try:
self._driver.find_element(*self.TAB_LOGOUT).click()
return True
except (NoSuchElementException, ElementNotInteractableException):
return False
def waitCheckinButton(
self,
) -> bool:
try:
WebDriverWait(self._driver, 2).until(
EC.element_to_be_clickable(self.BTN_CHECKIN)
)
return True
except TimeoutException:
return False
def waitExtendButton(
self,
) -> bool:
try:
WebDriverWait(self._driver, 2).until(
EC.element_to_be_clickable(self.BTN_EXTEND)
)
return True
except TimeoutException:
return False
def isCheckinButtonDisabled(
self,
) -> bool:
try:
btn = self._driver.find_element(*self.BTN_CHECKIN)
return "disabled" in btn.get_attribute("class")
except NoSuchElementException:
return True
def isExtendButtonDisabled(
self,
) -> bool:
try:
btn = self._driver.find_element(*self.BTN_EXTEND)
return "disabled" in btn.get_attribute("class")
except NoSuchElementException:
return True
def clickCheckinButton(
self,
) -> None:
try:
btn = WebDriverWait(self._driver, 2).until(
EC.element_to_be_clickable(self.BTN_CHECKIN)
)
btn.click()
except (TimeoutException, ElementNotInteractableException):
return
def clickExtendButton(
self,
) -> None:
try:
btn = WebDriverWait(self._driver, 2).until(
EC.element_to_be_clickable(self.BTN_EXTEND)
)
btn.click()
except (TimeoutException, ElementNotInteractableException):
return
def enableCheckinButtonByJS(
self,
) -> bool:
script = """
try {
var checkin_btn = document.getElementById('btnCheckIn');
if (checkin_btn) {
checkin_btn.classList.remove('disabled');
return true;
}
return false;
} catch (e) {
return false;
}
"""
result = self._driver.execute_script(script)
time.sleep(0.1)
return result
def refresh(
self,
) -> None:
try:
self._driver.refresh()
except (TimeoutException, WebDriverException):
return
+87
View File
@@ -0,0 +1,87 @@
# -*- 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 selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver.remote.webdriver import WebDriver
from selenium.webdriver.remote.webelement import WebElement
from selenium.common.exceptions import (
NoSuchElementException,
StaleElementReferenceException,
TimeoutException,
)
class RecordsView:
RECORDS_LIST = (By.CSS_SELECTOR, ".myReserveList > dl:not(#moreBlock)")
MORE_BTN = (By.ID, "moreBtn")
RECORD_TIME = (By.CSS_SELECTOR, "dt")
RECORD_INFO = (By.CSS_SELECTOR, "a")
def __init__(
self,
driver: WebDriver,
) -> None:
self._driver = driver
def loadRecords(
self,
) -> list | None:
try:
WebDriverWait(self._driver, 2).until(
EC.presence_of_element_located(self.RECORDS_LIST)
)
return self._driver.find_elements(*self.RECORDS_LIST)
except TimeoutException:
return None
def getRecordTimeElement(
self,
record: WebElement,
) -> WebElement:
return record.find_element(*self.RECORD_TIME)
def getRecordInfoElements(
self,
record: WebElement,
) -> list[WebElement]:
return record.find_elements(*self.RECORD_INFO)
def showMoreRecords(
self,
) -> bool:
try:
WebDriverWait(self._driver, 2).until(
EC.element_to_be_clickable(self.MORE_BTN)
)
except TimeoutException:
return False
try:
more_btn = self._driver.find_element(*self.MORE_BTN)
if more_btn.is_displayed() and more_btn.is_enabled():
self._driver.execute_script("arguments[0].scrollIntoView(true);", more_btn)
self._driver.execute_script("arguments[0].click();", more_btn)
return True
return False
except (NoSuchElementException, StaleElementReferenceException):
return False
def getRecordText(
self,
record: WebElement,
) -> str:
return record.text.strip()
+186
View File
@@ -0,0 +1,186 @@
# -*- 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 time
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver.remote.webdriver import WebDriver
from selenium.common.exceptions import (
ElementNotInteractableException,
TimeoutException,
)
from pages.components.SeatMapDialog import SeatMapDialog
class ReserveView:
DATE_SELECT = (By.ID, "onDate_select")
DATE_OPTION_FMT = "p#options_onDate a[value='{value}']"
DATE_XPATH_FMT = "//p[@id='options_onDate']/a[@value='{value}']"
PLACE_SELECT = (By.ID, "display_building")
PLACE_OPTION_FMT = "p#options_building a[value='{value}']"
PLACE_XPATH_FMT = "//p[@id='options_building']/a[@value='{value}']"
FLOOR_SELECT = (By.ID, "floor_select")
FLOOR_OPTION_FMT = "p#options_floor a[value='{value}']"
FLOOR_XPATH_FMT = "//p[@id='options_floor']/a[@value='{value}']"
FIND_ROOM_BTN = (By.ID, "findRoom")
ROOM_BTN_FMT = "room_{room}"
RESERVE_BTN = (By.ID, "reserveBtn")
FLOOR_MAP = {"2": "二层", "3": "三层", "4": "四层", "5": "五层"}
ROOM_MAP = {
"1": "二层内环", "2": "二层西区", "3": "三层内环", "4": "三层外环",
"5": "四层内环", "6": "四层外环", "7": "四层期刊", "8": "五层考研",
}
def __init__(
self,
driver: WebDriver,
) -> None:
self._driver = driver
def _clickOptionByJS(
self,
trigger_id: str,
option_css: str,
) -> bool:
script = f"""
try {{
var trigger = document.getElementById('{trigger_id}');
if (trigger) {{
trigger.click();
var option = document.querySelector("{option_css}");
if (option) {{
option.click();
return true;
}}
return false;
}}
return false;
}} catch (e) {{
return false;
}}
"""
result = self._driver.execute_script(script)
time.sleep(0.1)
return result
def _clickOption(
self,
trigger: tuple,
option: tuple,
) -> bool:
try:
WebDriverWait(self._driver, 2).until(
EC.element_to_be_clickable(trigger)
).click()
WebDriverWait(self._driver, 2).until(
EC.element_to_be_clickable(option)
).click()
return True
except (TimeoutException, ElementNotInteractableException):
return False
def selectDate(
self,
date_str: str,
) -> bool:
if self._clickOptionByJS(
trigger_id="onDate_select",
option_css=self.DATE_OPTION_FMT.format(value=date_str),
):
return True
return self._clickOption(
trigger=self.DATE_SELECT,
option=(By.XPATH, self.DATE_XPATH_FMT.format(value=date_str)),
)
def selectPlace(
self,
place: str = "1",
) -> bool:
if self._clickOptionByJS(
trigger_id="display_building",
option_css=self.PLACE_OPTION_FMT.format(value=place),
):
return True
return self._clickOption(
trigger=self.PLACE_SELECT,
option=(By.XPATH, self.PLACE_XPATH_FMT.format(value=place)),
)
def selectFloor(
self,
floor: str,
) -> bool:
if self._clickOptionByJS(
trigger_id="floor_select",
option_css=self.FLOOR_OPTION_FMT.format(value=floor),
):
return True
return self._clickOption(
trigger=self.FLOOR_SELECT,
option=(By.XPATH, self.FLOOR_XPATH_FMT.format(value=floor)),
)
def selectRoom(
self,
room: str,
) -> SeatMapDialog | None:
try:
WebDriverWait(self._driver, 2).until(
EC.element_to_be_clickable(self.FIND_ROOM_BTN)
).click()
except (TimeoutException, ElementNotInteractableException):
return None
try:
WebDriverWait(self._driver, 2).until(
EC.element_to_be_clickable((By.ID, self.ROOM_BTN_FMT.format(room=room)))
).click()
except (TimeoutException, ElementNotInteractableException):
return None
try:
return SeatMapDialog(self._driver)
except TimeoutException:
return None
def submitReserve(
self,
) -> bool:
try:
WebDriverWait(self._driver, 2).until(
EC.element_to_be_clickable(self.RESERVE_BTN)
).click()
return True
except (TimeoutException, ElementNotInteractableException):
return False
def refresh(
self,
) -> None:
try:
self._driver.refresh()
except TimeoutException:
return
+19
View File
@@ -0,0 +1,19 @@
# -*- 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 .AutoLib import AutoLib
from .LoginPage import LoginPage
from .MainShell import MainShell
from .ReserveView import ReserveView
from .RecordsView import RecordsView
from .components.SeatMapDialog import SeatMapDialog
from .components.TimeSelectDialog import TimeSelectDialog
from .components.ReserveResultDialog import ReserveResultDialog
from .components.CheckinResultDialog import CheckinResultDialog
from .components.RenewDialog import RenewDialog
@@ -0,0 +1,70 @@
# -*- 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 selenium.common.exceptions import (
ElementNotInteractableException,
NoSuchElementException,
StaleElementReferenceException,
TimeoutException,
)
from selenium.webdriver.common.by import By
from selenium.webdriver.remote.webdriver import WebDriver
from pages.components.Dialog import Dialog
class CheckinResultDialog(Dialog):
"""
Check-in result dialog.
"""
ROOT = (By.CLASS_NAME, "ui_dialog")
RESULT_MSG = (By.CLASS_NAME, "resultMessage")
OK_BTN = (By.CLASS_NAME, "btnOK")
DETAIL_DD = (By.CSS_SELECTOR, ".resultMessage dd")
def __init__(
self,
driver: WebDriver,
) -> None:
super().__init__(driver, self.ROOT, auto_close_on_exit=False)
def getResultMessage(
self,
) -> str:
try:
self._waitPresence(self.RESULT_MSG)
el = self._find(*self.RESULT_MSG)
return el.text
except (TimeoutException, NoSuchElementException,
StaleElementReferenceException):
return ""
def getDetails(
self,
) -> list[str]:
try:
elements = self._findAll(*self.DETAIL_DD)
return [el.text for el in elements if el.text.strip()]
except (NoSuchElementException, StaleElementReferenceException):
return []
def clickOk(
self,
) -> bool:
try:
self._waitClickable(self.OK_BTN).click()
return True
except (TimeoutException, ElementNotInteractableException):
return False
+114
View File
@@ -0,0 +1,114 @@
# -*- 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 selenium.webdriver.remote.webdriver import WebDriver
from selenium.webdriver.remote.webelement import WebElement
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
class Dialog:
"""
Context-managed overlay / modal / dialog on a page.
The constructor verifies that the root element is visible if not,
the dialog is not on screen and a :exc:`TimeoutException` is raised.
Automates the lifecycle: wait for appearance on enter,
optionally wait for disappearance on exit.
"""
def __init__(
self,
driver: WebDriver,
root_locator: tuple,
auto_close_on_exit: bool = True,
wait_timeout: float = 3.0,
) -> None:
self._driver: WebDriver = driver
self._root_locator: tuple = root_locator
self._auto_close: bool = auto_close_on_exit
self._timeout: float = wait_timeout
WebDriverWait(self._driver, self._timeout).until(
EC.visibility_of_element_located(self._root_locator)
)
def __enter__(
self,
) -> "Dialog":
return self
def __exit__(
self,
*args: object,
) -> None:
if self._auto_close:
WebDriverWait(self._driver, self._timeout).until(
EC.invisibility_of_element_located(self._root_locator)
)
def _find(
self,
by: str,
value: str,
) -> WebElement:
return self._driver.find_element(by, value)
def _findAll(
self,
by: str,
value: str,
) -> list[WebElement]:
return self._driver.find_elements(by, value)
def _waitClickable(
self,
locator: tuple,
timeout: float = 2.0,
) -> WebElement:
return WebDriverWait(self._driver, timeout).until(
EC.element_to_be_clickable(locator)
)
def _waitPresence(
self,
locator: tuple,
timeout: float = 2.0,
) -> WebElement:
return WebDriverWait(self._driver, timeout).until(
EC.presence_of_element_located(locator)
)
def _waitVisible(
self,
locator: tuple,
timeout: float = 2.0,
) -> WebElement:
return WebDriverWait(self._driver, timeout).until(
EC.visibility_of_element_located(locator)
)
def _waitAllPresence(
self,
locator: tuple,
timeout: float = 2.0,
) -> list[WebElement]:
return WebDriverWait(self._driver, timeout).until(
EC.presence_of_all_elements_located(locator)
)
+127
View File
@@ -0,0 +1,127 @@
# -*- 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 selenium.common.exceptions import (
ElementNotInteractableException,
NoSuchElementException,
StaleElementReferenceException,
TimeoutException,
)
from selenium.webdriver.common.by import By
from selenium.webdriver.remote.webdriver import WebDriver
from selenium.webdriver.remote.webelement import WebElement
from pages.components.Dialog import Dialog
from pages.strategies.TimeSelectMaker import (
TimeSelectionResult,
TimeSelectMaker,
)
class RenewDialog(Dialog):
"""
Renewal time selection dialog.
"""
ROOT = (By.ID, "extendDiv")
MESSAGE_HEAD = (By.CSS_SELECTOR, "#extendDiv p.messageHead")
RESULT_MSG = (By.CSS_SELECTOR, "#extendDiv div.resultMessage")
TIME_OPTS = (By.CSS_SELECTOR, "#extendDiv .renewal_List li")
OK_BTN = (By.CSS_SELECTOR, "#extendDiv .btnOK")
def __init__(
self,
driver: WebDriver,
) -> None:
super().__init__(driver, self.ROOT, auto_close_on_exit=False)
def waitUntilReady(
self,
) -> bool:
try:
self._waitVisible(self.ROOT)
self._waitPresence(self.MESSAGE_HEAD)
self._waitPresence(self.RESULT_MSG)
except TimeoutException:
return False
head_msg = self._find(*self.MESSAGE_HEAD).text.strip()
if "警告" in head_msg:
return False
try:
self._waitAllPresence(self.TIME_OPTS)
self._waitPresence(self.OK_BTN)
except TimeoutException:
return False
return True
def getHeadMessage(
self,
) -> str:
try:
return self._find(*self.MESSAGE_HEAD).text.strip()
except (NoSuchElementException, StaleElementReferenceException):
return ""
def getResultMessage(
self,
) -> str:
try:
return self._find(*self.RESULT_MSG).text.strip()
except (NoSuchElementException, StaleElementReferenceException):
return ""
def getTimeOptions(
self,
) -> list[WebElement]:
return self._findAll(*self.TIME_OPTS)
def selectBestTime(
self,
target_time: int,
max_time_diff: int,
prefer_earlier: bool,
) -> TimeSelectionResult:
all_time_opts = self.getTimeOptions()
if not all_time_opts:
return TimeSelectionResult()
result = TimeSelectMaker.forRenew().decide(
all_time_opts,
target_time,
max_time_diff,
prefer_earlier,
)
if result.selected_index >= 0:
try:
all_time_opts[result.selected_index].click()
except (ElementNotInteractableException, StaleElementReferenceException):
return TimeSelectionResult(free_times=result.free_times)
return result
def getOkButton(
self,
) -> WebElement:
return self._find(*self.OK_BTN)
def clickOk(
self,
) -> bool:
try:
self._find(*self.OK_BTN).click()
return True
except (NoSuchElementException, ElementNotInteractableException):
return False
@@ -0,0 +1,77 @@
# -*- 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 selenium.common.exceptions import (
NoSuchElementException,
StaleElementReferenceException,
)
from selenium.webdriver.common.by import By
from selenium.webdriver.remote.webdriver import WebDriver
from pages.components.Dialog import Dialog
class ReserveResultDialog(Dialog):
"""
Reservation result dialog shown after submitting a reserve request.
"""
ROOT = (By.CLASS_NAME, "layoutSeat")
def __init__(
self,
driver: WebDriver,
) -> None:
super().__init__(driver, self.ROOT, auto_close_on_exit=False)
def _titleLocator(
self,
) -> tuple:
return (By.CSS_SELECTOR, ".layoutSeat dt")
def getTitle(
self,
) -> str:
try:
return self._find(*self._titleLocator()).text
except (NoSuchElementException, StaleElementReferenceException):
return ""
def isSuccess(
self,
) -> bool:
title = self.getTitle()
return any(
kw in title
for kw in ("预定好了", "预约成功", "操作成功")
)
def isFailure(
self,
) -> bool:
contents = self.getDetailTexts()
return any(
"预约失败" in msg or "已有1个有效预约" in msg
for msg in contents
)
def getDetailTexts(
self,
) -> list[str]:
try:
elements = self._findAll(By.CSS_SELECTOR, ".layoutSeat dd")
return [el.text for el in elements if el.text.strip()]
except (NoSuchElementException, StaleElementReferenceException):
return []
+74
View File
@@ -0,0 +1,74 @@
# -*- 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 selenium.common.exceptions import (
ElementNotInteractableException,
NoSuchElementException,
StaleElementReferenceException,
TimeoutException,
)
from selenium.webdriver.common.by import By
from selenium.webdriver.remote.webdriver import WebDriver
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from pages.components.Dialog import Dialog
class SeatMapDialog(Dialog):
"""
Seat selection overlay that opens after choosing a floor and room.
"""
ROOT = (By.ID, "seatLayout")
SEAT_ITEMS = (By.CSS_SELECTOR, "li[id^='seat_']")
def __init__(
self,
driver: WebDriver,
) -> None:
super().__init__(driver, self.ROOT)
def selectSeat(
self,
seat_id: str,
) -> str | None:
try:
self._waitAllPresence(self.SEAT_ITEMS)
except TimeoutException:
return None
try:
seat_el = self._find(By.ID, f"seat_{int(seat_id):03d}")
seat_link = seat_el.find_element(By.TAG_NAME, "a")
WebDriverWait(self._driver, 2).until(
EC.element_to_be_clickable(seat_link)
)
seat_link.click()
return seat_link.get_attribute("title")
except (NoSuchElementException, ValueError, TimeoutException,
ElementNotInteractableException, StaleElementReferenceException):
pass
try:
all_seats = self._findAll(*self.SEAT_ITEMS)
seat_id_upper = seat_id.lstrip('0').upper()
for seat in all_seats:
if not seat_id_upper == seat.text.lstrip('0'):
continue
seat_link = seat.find_element(By.TAG_NAME, "a")
WebDriverWait(self._driver, 2).until(
EC.element_to_be_clickable(seat_link)
)
seat_link.click()
return seat_link.get_attribute("title")
return None
except (NoSuchElementException, TimeoutException,
ElementNotInteractableException, StaleElementReferenceException):
return None
+234
View File
@@ -0,0 +1,234 @@
# -*- 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 __future__ import annotations
import logging
from typing import TYPE_CHECKING, Callable, Optional
from selenium.common.exceptions import (
ElementNotInteractableException,
StaleElementReferenceException,
TimeoutException,
)
from selenium.webdriver.common.by import By
from selenium.webdriver.remote.webdriver import WebDriver
from selenium.webdriver.remote.webelement import WebElement
from pages.components.Dialog import Dialog
from pages.strategies.TimeSelectMaker import (
TimeRangeResult,
TimeSelectionResult,
TimeSelectMaker,
minsToTimeStr,
timeStrToMins,
)
if TYPE_CHECKING:
from pages.flows.ReserveFlow import ReserveContext
class TimeSelectDialog(Dialog):
"""
Time selection panel that appears after selecting a seat.
Contains start-time and end-time option lists.
Does NOT auto-close the reserve submission handles cleanup.
"""
ROOT = (By.CSS_SELECTOR, "#startTime ul")
def __init__(
self,
driver: WebDriver,
tracer: Optional[Callable[[str, int], None]] = None,
) -> None:
super().__init__(driver, self.ROOT, auto_close_on_exit=False)
self._tracer = tracer
def _trace(
self,
msg: str,
level: int = logging.INFO,
) -> None:
if self._tracer is not None:
self._tracer(msg, level)
def _logTimeStep(
self,
time_type: str,
target_mins: int,
max_diff: int,
step_result: TimeSelectionResult,
) -> bool:
if step_result.selected_index >= 0:
abs_diff = abs(step_result.actual_diff)
if step_result.actual_diff < 0:
relation = f"早了 {abs_diff} 分钟"
elif step_result.actual_diff > 0:
relation = f"晚了 {abs_diff} 分钟"
else:
relation = f"正好等于 {time_type}"
self._trace(
f"选择距离期望 {time_type} 最近的 {step_result.display_text}, "
f"与期望 {time_type} 相比 {relation}"
)
return True
if not step_result.free_times:
self._trace(
f"{time_type} 选择失败 ! : 当前未查询到可用时间",
logging.ERROR,
)
else:
target_str = minsToTimeStr(target_mins)
self._trace(
f"无法选择最近的 {time_type} {target_str}, "
f"所有可选时间与目标时间相差都超过 {max_diff} 分钟",
logging.WARNING,
)
self._trace(f"当前可供预约的 {time_type} 有: {step_result.free_times}")
return False
def getTimeOptions(
self,
time_id: str,
) -> list[WebElement]:
try:
self._waitAllPresence(
(By.CSS_SELECTOR, f"#{time_id} ul li a")
)
except TimeoutException:
return []
return self._findAll(
By.CSS_SELECTOR,
f"#{time_id} ul li a",
)
def selectNearestTime(
self,
time_id: str,
target_time: int,
max_time_diff: int,
prefer_earlier: bool,
) -> TimeSelectionResult:
all_time_opts = self.getTimeOptions(time_id)
if not all_time_opts:
return TimeSelectionResult()
result = TimeSelectMaker.forReserve().decide(
all_time_opts,
target_time,
max_time_diff,
prefer_earlier,
)
if result.selected_index >= 0:
try:
all_time_opts[result.selected_index].click()
except (ElementNotInteractableException, StaleElementReferenceException):
return TimeSelectionResult(free_times=result.free_times)
return result
def selectTimeRange(
self,
begin_target: int,
end_target: int,
begin_max_diff: int = 30,
end_max_diff: int = 30,
begin_prefer_early: bool = True,
end_prefer_early: bool = False,
satisfy_duration: bool = True,
expect_duration: int = 4,
library_close_mins: int = TimeSelectMaker.LIBRARY_CLOSE_MINS,
) -> TimeRangeResult:
begin_result = self.selectNearestTime(
"startTime",
begin_target,
begin_max_diff,
begin_prefer_early,
)
if begin_result.selected_index < 0:
return TimeRangeResult(begin_result=begin_result)
actual_begin = begin_result.selected_value
if satisfy_duration:
end_target = TimeSelectMaker.calcEndTime(
actual_begin,
expect_duration,
library_close_mins,
)
end_result = self.selectNearestTime(
"endTime",
end_target,
end_max_diff,
end_prefer_early,
)
if end_result.selected_index < 0:
return TimeRangeResult(
begin_result=begin_result,
actual_begin_mins=actual_begin,
end_result=end_result,
expect_end_mins=end_target,
)
return TimeRangeResult(
begin_result=begin_result,
end_result=end_result,
actual_begin_mins=actual_begin,
actual_end_mins=end_result.selected_value,
expect_end_mins=end_target,
)
def selectSeatTime(
self,
ctx: ReserveContext,
library_close_mins: int = TimeSelectMaker.LIBRARY_CLOSE_MINS,
) -> bool:
exp_beg_mins = timeStrToMins(ctx.begin_time)
exp_end_mins = timeStrToMins(ctx.end_time)
result = self.selectTimeRange(
begin_target=exp_beg_mins,
end_target=exp_end_mins,
begin_max_diff=ctx.begin_max_diff,
end_max_diff=ctx.end_max_diff,
begin_prefer_early=ctx.begin_prefer_early,
end_prefer_early=ctx.end_prefer_early,
satisfy_duration=ctx.satisfy_duration,
expect_duration=ctx.expect_duration,
library_close_mins=library_close_mins,
)
if not self._logTimeStep("开始时间", exp_beg_mins, ctx.begin_max_diff, result.begin_result):
return False
if ctx.satisfy_duration:
unclipped = result.actual_begin_mins + ctx.expect_duration*60
if unclipped > library_close_mins:
self._trace(
f"预约持续时间 {ctx.expect_duration} 小时, 超过最大预约时间 {minsToTimeStr(library_close_mins)}, "
f"自动调整为 {minsToTimeStr(library_close_mins)}",
logging.WARNING,
)
act_beg_str = minsToTimeStr(result.actual_begin_mins)
exp_end_str = minsToTimeStr(result.expect_end_mins)
self._trace(
f"需要满足期望预约持续时间: {ctx.expect_duration} 小时, "
f"根据开始时间 {act_beg_str} 计算结束时间: {exp_end_str}"
)
if not self._logTimeStep("结束时间", result.expect_end_mins, ctx.end_max_diff, result.end_result):
return False
act_beg_str = minsToTimeStr(result.actual_begin_mins)
act_end_str = minsToTimeStr(result.actual_end_mins)
exp_end_str = minsToTimeStr(result.expect_end_mins)
self._trace(
f"期望预约时间段: {ctx.begin_time} - {exp_end_str}, "
f"实际预约时间段: {act_beg_str} - {act_end_str}"
)
return True
+14
View File
@@ -0,0 +1,14 @@
# -*- 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 .SeatMapDialog import SeatMapDialog
from .TimeSelectDialog import TimeSelectDialog
from .ReserveResultDialog import ReserveResultDialog
from .CheckinResultDialog import CheckinResultDialog
from .RenewDialog import RenewDialog

Some files were not shown because too many files have changed in this diff Show More