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

Compare commits

...

22 Commits

Author SHA1 Message Date
github-actions[bot] dc287f3aa5 chore(release): v1.3.0 [auto release commit] 2026-05-09 06:05:24 +00:00
Kenan Zhu 7886379875 feat(*): 支持编辑定时任务,支持AutoScript的重复性定时任务预处理指令 (#7)
feat: 支持编辑定时任务,支持AutoScript的重复性定时任务预处理指令
2026-05-09 13:20:37 +08:00
KenanZhu 967ede4b04 fix(ALTimerTaskManageWidget): 修复右键菜单删除任务时 parent() 类型错误 2026-05-09 12:59:23 +08:00
KenanZhu 27250dba2f feat(ALTimerTask*): 实现定时任务编辑功能,统一代码规范并重命名重复任务历史字段 2026-05-09 10:07:25 +08:00
KenanZhu 46b3447d1e feat(autoscript): 将预处理脚本重构为 AutoScript DSL,新增可视化编排与预览对话框 2026-05-08 20:46:54 +08:00
Gogs 4d0d7a952c feat(preproc): 新增适用于重复性定时任务的预处理脚本以及可视化编排对话框 2026-05-08 15:23:24 +08:00
KenanZhu e11f696b76 style(*): 添加缺失的版权信息,并同一版权年份为文件创建时间的年份 2026-05-06 01:01:52 +08:00
KenanZhu ffae43d5bd fix(ConfigUtils): 添加未导入的 os 模块 2026-03-24 21:49:52 +08:00
Gogs baa4f23136 refactor(config): 新增 ConfigUtils 工具类并优化配置管理逻辑
- 新增 ConfigUtils 工具类,提供配置路径获取等工具方法
- 将 ConfigManager.getValidateAutomationConfigPaths() 重构为 ConfigUtils.getAutomationConfigPaths()
- 优化 MsgBase 中 LogManager 的导入方式,使用模块导入替代函数导入
- 规范化 TimerUtils.py 中 calculate_next_repeat_time() 的文档字符串格式
2026-03-23 13:31:06 +08:00
KenanZhu 1c88d3db7b chore(requirement): 移除 opencv-python 和 pywin32 冗余依赖 2026-03-22 22:56:43 +08:00
github-actions[bot] 3880f90916 chore(release): merge release/v1.2.1 to main [auto release commit] 2026-03-22 14:17:40 +00:00
github-actions[bot] d3d146b1b3 chore(release): v1.2.1 [auto release commit] 2026-03-22 14:14:27 +00:00
KenanZhu 0f74a3b0ec chore(requirement): 将 installed-browsers 替换为 pybrowsers 依赖 2026-03-22 22:05:52 +08:00
KenanZhu 9305c559cd refactor(WebBrowserDetector): 切换浏览器检测库为 browsers 并添加检测结果去重 2026-03-22 22:04:31 +08:00
KenanZhu f56945f29e fix(AppInitializer): 优化驱动目录初始化日志逻辑,仅在目录不存在时输出日志 2026-03-22 21:43:23 +08:00
KenanZhu 37132de4fc fix(ALTimerTaskManageWidget): 修复重复性定时任务删除时因 history 字段不存在导致 len(int) 异常 2026-03-22 21:34:08 +08:00
github-actions[bot] ac5385bcfe chore(release): merge release/v1.2.0 to main [auto release commit] 2026-03-21 10:58:40 +00:00
github-actions[bot] f984217bda chore(release): v1.2.0 [auto release commit] 2026-03-21 10:55:01 +00:00
KenanZhu 4e7780fe70 docs(readme): 更新自述文件以包含最新功能变化 2026-03-21 18:49:48 +08:00
Kenan Zhu 7149cb2b7d feat(*): 远程签到、定时任务重复执行与浏览器驱动自动管理 (#6)
- 图书馆远程签到
- 定时任务优化
- 浏览器驱动自动管理
2026-03-21 18:34:05 +08:00
KenanZhu 2c90008fcd refactor(WebDriverManager, ALWebDriverDownloadDialog): 重命名驱动状态枚举并完善对话框状态感知 2026-03-21 17:22:25 +08:00
KenanZhu 1cd39ec84c docs(readme): 更新 “后续会有哪些功能?” 部分,可重复性定时任务功能已完成 2026-03-17 16:08:54 +08:00
42 changed files with 2095 additions and 249 deletions
+14 -13
View File
@@ -20,33 +20,34 @@
1. 自动预约 - 支持自动预约 1. 自动预约 - 支持自动预约
2. 自动续约 - 支持自动续约 2. 自动续约 - 支持自动续约
3. 自动签到 - 支持自动签到 3. 自动签到 - 支持自动签到
4. 批量操作 - 支持同时预约多个用户,可以指定当前需要跳过的用户,并将用户分成多个组 4. 远程签到 - 支持远程签到,无需在图书馆网络环境下即可签到
5. 定时任务 - 使用内置定时任务管理,添加定时任务,指定时间后按当前预约信息自动运行 5. 批量操作 - 支持同时预约多个用户,可以指定当前需要跳过的用户,并将用户分成多个组
6. 定时任务 - 使用内置定时任务管理,添加定时任务,指定时间后按当前预约信息自动运行,支持设置重复任务
7. 驱动管理 - 内置浏览器驱动自动管理,支持自动检测浏览器版本并下载对应驱动,无需手动下载
*1,2,3 的具体操作方法和注意事项请访问我们的 [帮助手册](https://www.autolibrary.kenanzhu.com/manuals)* *具体操作方法和注意事项请访问我们的 [帮助手册](https://www.autolibrary.kenanzhu.com/manuals)*
### 如何使用 ### 如何使用
1. 下载最新版本的 [AutoLibrary 压缩包](https://github.com/KenanZhu/AutoLibrary/releases/latest)。 1. 下载最新版本的 [AutoLibrary 安装程序](https://github.com/KenanZhu/AutoLibrary/releases/latest) 或 [压缩包](https://github.com/KenanZhu/AutoLibrary/releases/latest)
2. 解压下载的文件到任意目录。 2. 双击运行安装程序进行安装,或将压缩包解压到任意目录。
3. 下载对应浏览器类型和版本(具体操作请参考适用软件版本的 [帮助手册](https://www.autolibrary.kenanzhu.com/manuals))的驱动文件,并在配置界面的运行配置选项卡对应位置选择你下载好的浏览器驱动 3. 运行 `AutoLibrary`,即可打开主界面
4. 运行 `AutoLibrary-[主版本号].[次版本号].[修订版本号].Z.exe` 文件 (如 `AutoLibrary-1.0.0.exe` 4. 点击 [配置] 按钮,在配置界面填写好预约信息和运行配置后,点击 [确认] 按钮
5. 点击 [配置] 按钮,在配置界面填写好预约信息和运行配置后,点击 [确认] 按钮 5. 点击 [启动脚本] 按钮,即可开始自动预约、续约、签到等操作
6. 点击 [启动脚本] 按钮,即可开始自动预约、续约、签到等操作。
*注意 1*: 关于浏览器驱动的下载和其它相关问题,请参考我们的 [帮助手册](https://www.autolibrary.kenanzhu.com/manuals) 中对应软件版本的内容 *注意 1*: 工具内置浏览器驱动自动管理功能,会自动检测本地浏览器版本并下载对应的驱动文件。如果自动下载失败,也可以手动下载驱动文件并在配置界面的运行配置选项卡对应位置选择驱动文件路径
#### 平台支持 & 编译步骤 #### 平台支持 & 编译步骤
本工具目前仅支持 Windows 平台,由于使用 PySide6 库开发,理论上是可以自行编译并在 Linux 和 macOS 上运行,这里提供简单的编译步骤: 本工具目前仅支持 Windows 平台,由于使用 PySide6 库开发,理论上是可以自行编译并在 Linux 和 macOS 上运行,这里提供简单的编译步骤:
1. 确保系统安装了 Python 3.13 版本 (推荐,过低或高版本会导致兼容问题)。 1. 确保系统安装了 Python 3.13 版本 (推荐,过低或高版本会导致兼容问题)。
2. 安装 pyside6 selenium ddddocr 库,命令为 `pip install pyside6 selenium ddddocr` 2. 安装所有依赖库,命令为 `pip install -r requirements.txt` (建议在虚拟环境下操作)
3.`batchs` 目录下运行 `compile_ui.bat` linux 和 macOS 系统使用 `compile_ui.sh`) 文件来编译 Qt 的 UI 文件。 3.`batchs` 目录下运行 `compile_ui.bat` linux 和 macOS 系统使用 `compile_ui.sh`) 文件来编译 Qt 的 UI 文件。
4. 在上一步相同目录内运行 `compile_rc.bat` linux 和 macOS 系统使用 `compile_rc.sh` 文件来编译 Qt 的资源文件。 4. 在上一步相同目录内运行 `compile_rc.bat` linux 和 macOS 系统使用 `compile_rc.sh` 文件来编译 Qt 的资源文件。
5. 待上述步骤完成后,运行 `src/Main.py` 文件即可。 5. 待上述步骤完成后,运行 `src/Main.py` 文件即可。
*注意 1*:如果 python 使用的是虚拟环境,请在虚拟环境安装依赖后,在激活的虚拟环境终端中使用 `cd src/gui/batchs` 命令切换到 `batchs` 目录下,再运行编译脚本。否则会提示缺少必要的 Qt PySide 依赖库。 *注意 1*:如果 python 使用的是虚拟环境,请在虚拟环境安装依赖后,在激活的虚拟环境终端中使用 `cd batchs` 命令切换到 `batchs` 目录下,再运行编译脚本。否则会提示缺少必要的 Qt PySide 依赖库。
*注意 2*:由于 ddddocr 的代码版本问题,其中 `__init__.py` 文件中的函数 `def classification(self, img: bytes):` 中的 `image.resize` 方法传入了不符合当前 pillow 版本的 `resample` 参数 `Image.ANTIALIAS`,该重采样常量已经在 10.0.0 版中删除 [1](@ref)。请将 `image.resize` 方法中的参数替换为 `resample=Image.Resampling.LANCZOS`,具体函数如下: *注意 2*:由于 ddddocr 的代码版本问题,其中 `__init__.py` 文件中的函数 `def classification(self, img: bytes):` 中的 `image.resize` 方法传入了不符合当前 pillow 版本的 `resample` 参数 `Image.ANTIALIAS`,该重采样常量已经在 10.0.0 版中删除 [1](@ref)。请将 `image.resize` 方法中的参数替换为 `resample=Image.Resampling.LANCZOS`,具体函数如下:
```python ```python
@@ -103,7 +104,7 @@ def classification(self, img: bytes):
当前版本的功能对于正常使用已经足够,不过后续会着重完善预约时的使用体验,暂时有以下构想: 当前版本的功能对于正常使用已经足够,不过后续会着重完善预约时的使用体验,暂时有以下构想:
- 引入交互预约面板功能,预约时直接在座位分布图中选择可用座位,并按用户分配,无需事先配置预约信息。 - 引入交互预约面板功能,预约时直接在座位分布图中选择可用座位,并按用户分配,无需事先配置预约信息。
- 优化定时任务管理功能,用户可以在定时任务管理界面设置重复运行的定时任务,如每日预约、每周预约等。 - ~~优化定时任务管理功能,用户可以在定时任务管理界面设置重复运行的定时任务,如每日预约、每周预约等。~~ (已完成)
- 软件的自动更新以及公告栏功能,用户可以自动更新最新版本并获取最新公告事项。 - 软件的自动更新以及公告栏功能,用户可以自动更新最新版本并获取最新公告事项。
不过由于本人的时间和能力有限,也需要考虑到图书馆的正常运行,所以后续功能会有所取舍,但也许会进行一些小的功能验证。 不过由于本人的时间和能力有限,也需要考虑到图书馆的正常运行,所以后续功能会有所取舍,但也许会进行一些小的功能验证。
BIN
View File
Binary file not shown.
+1 -1
View File
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
Copyright (c) 2025 - 2026 KenanZhu. Copyright (c) 2025 KenanZhu.
All rights reserved. All rights reserved.
This software is provided "as is", without any warranty of any kind. This software is provided "as is", without any warranty of any kind.
+1 -1
View File
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
Copyright (c) 2025 - 2026 KenanZhu. Copyright (c) 2025 KenanZhu.
All rights reserved. All rights reserved.
This software is provided "as is", without any warranty of any kind. This software is provided "as is", without any warranty of any kind.
+1 -1
View File
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
Copyright (c) 2025 - 2026 KenanZhu. Copyright (c) 2026 KenanZhu.
All rights reserved. All rights reserved.
This software is provided "as is", without any warranty of any kind. This software is provided "as is", without any warranty of any kind.
+3 -3
View File
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
Copyright (c) 2025 - 2026 KenanZhu. Copyright (c) 2025 KenanZhu.
All rights reserved. All rights reserved.
This software is provided "as is", without any warranty of any kind. This software is provided "as is", without any warranty of any kind.
@@ -11,7 +11,7 @@ import logging
import queue import queue
import datetime import datetime
from managers.log.LogManager import getLogger import managers.log.LogManager as LogManager
class MsgBase: class MsgBase:
@@ -54,7 +54,7 @@ class MsgBase:
self._input_queue = input_queue self._input_queue = input_queue
self._output_queue = output_queue self._output_queue = output_queue
try: try:
self._logger = getLogger(self._class_name) self._logger = LogManager.getLogger(self._class_name)
except RuntimeError: except RuntimeError:
self._logger = None self._logger = None
+2 -3
View File
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
Copyright (c) 2025 - 2026 KenanZhu. Copyright (c) 2026 KenanZhu.
All rights reserved. All rights reserved.
This software is provided "as is", without any warranty of any kind. This software is provided "as is", without any warranty of any kind.
@@ -56,9 +56,8 @@ def initializeWebDriverManager(
app_dir = QStandardPaths.writableLocation(QStandardPaths.StandardLocation.AppDataLocation) app_dir = QStandardPaths.writableLocation(QStandardPaths.StandardLocation.AppDataLocation)
driver_dir = os.path.join(app_dir, "drivers") driver_dir = os.path.join(app_dir, "drivers")
logger.info("初始化驱动目录 %s", driver_dir)
if not QDir(driver_dir).exists(): if not QDir(driver_dir).exists():
logger.error("创建驱动目录 %s 失败", driver_dir) logger.info("初始化驱动目录 %s", driver_dir)
if not QDir().mkpath(driver_dir): if not QDir().mkpath(driver_dir):
logger.error("创建驱动目录 %s 失败", driver_dir) logger.error("创建驱动目录 %s 失败", driver_dir)
return False return False
+1 -1
View File
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
Copyright (c) 2025 - 2026 KenanZhu. Copyright (c) 2025 KenanZhu.
All rights reserved. All rights reserved.
This software is provided "as is", without any warranty of any kind. This software is provided "as is", without any warranty of any kind.
+884
View File
@@ -0,0 +1,884 @@
# -*- 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)
+226
View File
@@ -0,0 +1,226 @@
# -*- 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)
))
+3 -2
View File
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
Copyright (c) 2025 - 2026 KenanZhu. Copyright (c) 2025 KenanZhu.
All rights reserved. All rights reserved.
This software is provided "as is", without any warranty of any kind. This software is provided "as is", without any warranty of any kind.
@@ -24,6 +24,7 @@ import managers.config.ConfigManager as ConfigManager
from utils.JSONReader import JSONReader from utils.JSONReader import JSONReader
from utils.JSONWriter import JSONWriter from utils.JSONWriter import JSONWriter
from utils.ConfigUtils import ConfigUtils
from gui.resources.ui.Ui_ALConfigWidget import Ui_ALConfigWidget from gui.resources.ui.Ui_ALConfigWidget import Ui_ALConfigWidget
from gui.ALSeatMapSelectDialog import ALSeatMapSelectDialog from gui.ALSeatMapSelectDialog import ALSeatMapSelectDialog
@@ -43,7 +44,7 @@ class ALConfigWidget(QWidget, Ui_ALConfigWidget):
super().__init__(parent) super().__init__(parent)
self.__cfg_mgr = ConfigManager.instance() self.__cfg_mgr = ConfigManager.instance()
self.__config_paths = ConfigManager.getValidateAutomationConfigPaths() self.__config_paths = ConfigUtils.getAutomationConfigPaths()
self.__config_data = {"run": {}, "user": {}} self.__config_data = {"run": {}, "user": {}}
self.setupUi(self) self.setupUi(self)
+4 -6
View File
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
Copyright (c) 2025 - 2026 KenanZhu. Copyright (c) 2025 KenanZhu.
All rights reserved. All rights reserved.
This software is provided "as is", without any warranty of any kind. This software is provided "as is", without any warranty of any kind.
@@ -19,9 +19,8 @@ from PySide6.QtGui import (
QTextCursor, QCloseEvent, QFont, QIcon, QDesktopServices QTextCursor, QCloseEvent, QFont, QIcon, QDesktopServices
) )
import managers.config.ConfigManager as ConfigManager
from base.MsgBase import MsgBase from base.MsgBase import MsgBase
from utils.ConfigUtils import ConfigUtils
from gui.resources.ui.Ui_ALMainWindow import Ui_ALMainWindow from gui.resources.ui.Ui_ALMainWindow import Ui_ALMainWindow
from gui.resources import ALResource from gui.resources import ALResource
@@ -44,9 +43,8 @@ class ALMainWindow(MsgBase, QMainWindow, Ui_ALMainWindow):
MsgBase.__init__(self, queue.Queue(), queue.Queue()) MsgBase.__init__(self, queue.Queue(), queue.Queue())
QMainWindow.__init__(self) QMainWindow.__init__(self)
self.__cfg_mgr = ConfigManager.instance()
self.__timer_task_queue = queue.Queue() self.__timer_task_queue = queue.Queue()
self.__config_paths = ConfigManager.getValidateAutomationConfigPaths() self.__config_paths = ConfigUtils.getAutomationConfigPaths()
self.__alTimerTaskManageWidget = None self.__alTimerTaskManageWidget = None
self.__alConfigWidget = None self.__alConfigWidget = None
self.__auto_lib_thread = None self.__auto_lib_thread = None
@@ -300,7 +298,7 @@ class ALMainWindow(MsgBase, QMainWindow, Ui_ALMainWindow):
self.__alConfigWidget.configWidgetIsClosed.disconnect(self.onConfigWidgetClosed) self.__alConfigWidget.configWidgetIsClosed.disconnect(self.onConfigWidgetClosed)
self.__alConfigWidget.deleteLater() self.__alConfigWidget.deleteLater()
self.__alConfigWidget = None self.__alConfigWidget = None
self.__config_paths = ConfigManager.getValidateAutomationConfigPaths() self.__config_paths = ConfigUtils.getAutomationConfigPaths()
self.setControlButtons(True, None, None) self.setControlButtons(True, None, None)
self._showLog("配置窗口已关闭,配置文件路径已更新") self._showLog("配置窗口已关闭,配置文件路径已更新")
+91 -17
View File
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
Copyright (c) 2025 - 2026 KenanZhu. Copyright (c) 2025 KenanZhu.
All rights reserved. All rights reserved.
This software is provided "as is", without any warranty of any kind. This software is provided "as is", without any warranty of any kind.
@@ -18,6 +18,7 @@ from PySide6.QtCore import (
from base.MsgBase import MsgBase from base.MsgBase import MsgBase
from operators.AutoLib import AutoLib from operators.AutoLib import AutoLib
from utils.JSONReader import JSONReader from utils.JSONReader import JSONReader
from utils.AutoScriptEngine import AutoScriptEngine
class AutoLibWorker(MsgBase, QThread): class AutoLibWorker(MsgBase, QThread):
@@ -76,25 +77,28 @@ class AutoLibWorker(MsgBase, QThread):
f"正在加载配置文件, 运行配置文件路径: {self.__config_paths["run"]}", f"正在加载配置文件, 运行配置文件路径: {self.__config_paths["run"]}",
no_log=True no_log=True
) )
self.__run_config = JSONReader(self.__config_paths["run"]).data() self._run_config = JSONReader(self.__config_paths["run"]).data()
self._showTrace( self._showTrace(
f"正在加载配置文件, 用户配置文件路径: {self.__config_paths["user"]}", f"正在加载配置文件, 用户配置文件路径: {self.__config_paths["user"]}",
no_log=True no_log=True
) )
self.__user_config = JSONReader(self.__config_paths["user"]).data() self._user_config = JSONReader(self.__config_paths["user"]).data()
if self.__run_config is None or self.__user_config is None: if self._run_config is None or self._user_config is None:
self._showTrace( self._showTrace(
"配置文件加载失败, 请检查配置文件是否正确", "配置文件加载失败, 请检查配置文件是否正确",
self.TraceLevel.ERROR self.TraceLevel.ERROR
) )
return False return False
if not self.__user_config.get("groups"): if not self._user_config.get("groups"):
self._showTrace( self._showTrace(
"用户配置文件中无有效任务组, 请检查用户配置文件是否正确", "用户配置文件中无有效任务组, 请检查用户配置文件是否正确",
self.TraceLevel.WARNING self.TraceLevel.WARNING
) )
return False return False
self._showLog(f"配置文件加载成功, 任务组数量: {len(self.__user_config.get('groups', []))}", self.TraceLevel.INFO) self._showLog(
f"配置文件加载成功, 任务组数量: {len(self._user_config.get('groups', []))}",
self.TraceLevel.INFO
)
return True return True
@@ -115,9 +119,9 @@ class AutoLibWorker(MsgBase, QThread):
auto_lib = AutoLib( auto_lib = AutoLib(
self._input_queue, self._input_queue,
self._output_queue, self._output_queue,
self.__run_config self._run_config
) )
groups = self.__user_config.get("groups") groups = self._user_config.get("groups")
for group in groups: for group in groups:
if not group["enabled"]: if not group["enabled"]:
self._showTrace(f"任务组 {group["name"]} 已跳过", no_log=True) self._showTrace(f"任务组 {group["name"]} 已跳过", no_log=True)
@@ -157,12 +161,90 @@ class TimerTaskWorker(AutoLibWorker):
self.autoLibWorkerIsFinished.connect(self.onTimerTaskIsFinished) self.autoLibWorkerIsFinished.connect(self.onTimerTaskIsFinished)
self.autoLibWorkerFinishedWithError.connect(self.onTimerTaskFinishedWithError) self.autoLibWorkerFinishedWithError.connect(self.onTimerTaskFinishedWithError)
def run( def run(
self self
): ):
self._showTrace(f"定时任务 {self.__timer_task['name']} 开始运行") self._showTrace(f"定时任务 {self.__timer_task['name']} 开始运行")
super().run() 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 applyRepeatAutoScript(
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
)
groups = self._user_config.get("groups", [])
affected_count = 0
for group in groups:
if not group.get("enabled", False):
continue
for user in group.get("users", []):
try:
AutoScriptEngine.execute(auto_script, user)
affected_count += 1
except ValueError as e:
self._showTrace(
f"AutoScript 执行错误 (用户 {user['username']}): {e}",
self.TraceLevel.ERROR
)
self._showLog(
f"AutoScript 执行完毕, "
f"影响 {affected_count} 个用户",
self.TraceLevel.INFO
)
@Slot()
def onTimerTaskIsFinished(
self
):
self._showTrace(f"定时任务 {self.__timer_task['name']} 运行结束")
self.timerTaskWorkerIsFinished.emit(False, self.__timer_task)
@Slot() @Slot()
def onTimerTaskFinishedWithError( def onTimerTaskFinishedWithError(
@@ -174,11 +256,3 @@ class TimerTaskWorker(AutoLibWorker):
self.TraceLevel.ERROR self.TraceLevel.ERROR
) )
self.timerTaskWorkerIsFinished.emit(True, self.__timer_task) self.timerTaskWorkerIsFinished.emit(True, self.__timer_task)
@Slot()
def onTimerTaskIsFinished(
self
):
self._showTrace(f"定时任务 {self.__timer_task['name']} 运行结束")
self.timerTaskWorkerIsFinished.emit(False, self.__timer_task)
+1 -1
View File
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
Copyright (c) 2025 - 2026 KenanZhu. Copyright (c) 2025 KenanZhu.
All rights reserved. All rights reserved.
This software is provided "as is", without any warranty of any kind. This software is provided "as is", without any warranty of any kind.
+1 -1
View File
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
Copyright (c) 2025 - 2026 KenanZhu. Copyright (c) 2026 KenanZhu.
All rights reserved. All rights reserved.
This software is provided "as is", without any warranty of any kind. This software is provided "as is", without any warranty of any kind.
+1 -1
View File
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
Copyright (c) 2025 - 2026 KenanZhu. Copyright (c) 2026 KenanZhu.
All rights reserved. All rights reserved.
This software is provided "as is", without any warranty of any kind. This software is provided "as is", without any warranty of any kind.
+8
View File
@@ -1,4 +1,12 @@
# -*- 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 enum import Enum from enum import Enum
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
+162 -19
View File
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
Copyright (c) 2025 - 2026 KenanZhu. Copyright (c) 2025 KenanZhu.
All rights reserved. All rights reserved.
This software is provided "as is", without any warranty of any kind. This software is provided "as is", without any warranty of any kind.
@@ -12,11 +12,13 @@ import uuid
from enum import Enum from enum import Enum
from datetime import datetime, timedelta from datetime import datetime, timedelta
from PySide6.QtCore import Slot, QDateTime from PySide6.QtCore import Slot, QDateTime, QUrl
from PySide6.QtWidgets import QLabel, QDialog, QWidget, QSpinBox, QHBoxLayout, QGridLayout, QDateTimeEdit from PySide6.QtGui import QDesktopServices
from PySide6.QtWidgets import QLabel, QDialog, QWidget, QSpinBox, QHBoxLayout, QVBoxLayout, QGridLayout, QDateTimeEdit, QGroupBox, QPushButton
from gui.resources.ui.Ui_ALTimerTaskAddDialog import Ui_ALTimerTaskAddDialog from gui.resources.ui.Ui_ALTimerTaskAddDialog import Ui_ALTimerTaskAddDialog
import utils.TimerUtils as TimerUtils from gui.ALAutoScriptOrchDialog import ALAutoScriptOrchDialog
from utils.TimerUtils import TimerUtils
class ALTimerTaskStatus(Enum): class ALTimerTaskStatus(Enum):
@@ -34,15 +36,20 @@ class ALTimerTaskAddDialog(QDialog, Ui_ALTimerTaskAddDialog):
def __init__( def __init__(
self, self,
parent = None parent = None,
timer_task: dict = None
): ):
super().__init__(parent) super().__init__(parent)
self.__edit_timer_task = timer_task
self.setupUi(self) self.setupUi(self)
self.modifyUi() self.modifyUi()
self.connectSignals() self.connectSignals()
if self.__edit_timer_task:
self.loadTask(self.__edit_timer_task)
def modifyUi( def modifyUi(
self self
@@ -86,6 +93,86 @@ class ALTimerTaskAddDialog(QDialog, Ui_ALTimerTaskAddDialog):
self.TimerConfigLayout.addWidget(self.RelativeTimerWidget) self.TimerConfigLayout.addWidget(self.RelativeTimerWidget)
self.RelativeTimerWidget.setVisible(False) 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()
self.AutoScriptHelpButton = QPushButton("?")
self.AutoScriptHelpButton.setFixedSize(20, 20)
self.AutoScriptHelpButton.setToolTip(
"AutoScript 是一种轻量级 DSL\n"
"用于在重复定时任务执行前,对用户的预约数据进行预处理\n"
"\n"
"点击查看完整在线文档"
)
self.AutoScriptHelpButton.setStyleSheet(
"QPushButton { border-radius: 10px; border: 1px solid #999; "
"font-weight: bold; color: #555; }"
"QPushButton:hover { background-color: #E0E0E0; }"
)
autoScriptBtnLayout.addWidget(self.AutoScriptHelpButton)
self.AutoScriptStatusLabel = QLabel("未设置")
self.AutoScriptStatusLabel.setStyleSheet("color: #969696;")
self.AutoScriptStatusLabel.setFixedHeight(25)
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 = ""
def loadTask(
self,
task: dict
):
self.TaskNameLineEdit.setText(task.get("name", ""))
time_type = task.get("time_type", "特定时间")
self.TimerTypeComboBox.setCurrentText(time_type)
self.SpecificDateTimeEdit.setDateTime(
QDateTime(task["execute_time"])
)
self.RelativeDaySpinBox.setValue(0)
self.RelativeHourSpinBox.setValue(0)
self.RelativeMinuteSpinBox.setValue(0)
self.RelativeSecondSpinBox.setValue(0)
if task.get("silent", False):
self.SilentlyRunRadioButton.setChecked(True)
else:
self.ShowBeforeRunRadioButton.setChecked(True)
repeat = task.get("repeat", False)
self.RepeatCheckBox.setChecked(repeat)
if repeat:
repeat_days = task.get("repeat_days", [])
self.MonCheckBox.setChecked(0 in repeat_days)
self.TueCheckBox.setChecked(1 in repeat_days)
self.WedCheckBox.setChecked(2 in repeat_days)
self.ThuCheckBox.setChecked(3 in repeat_days)
self.FriCheckBox.setChecked(4 in repeat_days)
self.SatCheckBox.setChecked(5 in repeat_days)
self.SunCheckBox.setChecked(6 in repeat_days)
auto_script = task.get("repeat_auto_script", "")
if auto_script:
self.__auto_script = auto_script
self.AutoScriptStatusLabel.setText("已设置")
self.AutoScriptStatusLabel.setStyleSheet("color: #4CAF50;")
self.AutoScriptPreviewButton.setEnabled(True)
self.ConfirmButton.setText("保存")
def connectSignals( def connectSignals(
self self
@@ -95,6 +182,9 @@ class ALTimerTaskAddDialog(QDialog, Ui_ALTimerTaskAddDialog):
self.ConfirmButton.clicked.connect(self.accept) self.ConfirmButton.clicked.connect(self.accept)
self.TimerTypeComboBox.currentIndexChanged.connect(self.onTimerTypeComboBoxIndexChanged) self.TimerTypeComboBox.currentIndexChanged.connect(self.onTimerTypeComboBoxIndexChanged)
self.RepeatCheckBox.toggled.connect(self.onRepeatCheckBoxToggled) self.RepeatCheckBox.toggled.connect(self.onRepeatCheckBoxToggled)
self.AutoScriptSetButton.clicked.connect(self.onSetAutoScript)
self.AutoScriptPreviewButton.clicked.connect(self.onPreviewAutoScript)
self.AutoScriptHelpButton.clicked.connect(self.onAutoScriptHelp)
def getTimerTask( def getTimerTask(
@@ -119,18 +209,34 @@ class ALTimerTaskAddDialog(QDialog, Ui_ALTimerTaskAddDialog):
minutes = self.RelativeMinuteSpinBox.value(), minutes = self.RelativeMinuteSpinBox.value(),
seconds = self.RelativeSecondSpinBox.value() seconds = self.RelativeSecondSpinBox.value()
) )
task_data = {
"name": name, if self.__edit_timer_task:
"uuid": uuid.uuid4().hex.upper() + f"-{added_time.strftime("%Y%m%d%H%M%S")}", task_data = dict(self.__edit_timer_task)
"time_type": self.TimerTypeComboBox.currentText(), task_data["name"] = name
"execute_time": execute_time, task_data["execute_time"] = execute_time
"silent": silent, task_data["silent"] = silent
"added_time": added_time, task_data["status"] = ALTimerTaskStatus.PENDING
"status": ALTimerTaskStatus.PENDING, task_data["executed"] = False
"executed": False, task_data["repeat_auto_script"] = self.__auto_script
"repeat": self.RepeatCheckBox.isChecked(), else:
} task_data = {
if task_data["repeat"]: "name": name,
"uuid": uuid.uuid4().hex.upper() + f"-{added_time.strftime("%Y%m%d%H%M%S")}",
"time_type": self.TimerTypeComboBox.currentText(),
"execute_time": execute_time,
"silent": silent,
"added_time": added_time,
"status": ALTimerTaskStatus.PENDING,
"executed": False,
"repeat": self.RepeatCheckBox.isChecked(),
"repeat_auto_script": self.__auto_script,
}
repeat = self.RepeatCheckBox.isChecked()
task_data["repeat"] = repeat
if repeat:
if "repeat_history" not in task_data:
task_data["repeat_history"] = []
repeat_days = [] repeat_days = []
if self.MonCheckBox.isChecked(): if self.MonCheckBox.isChecked():
repeat_days.append(0) repeat_days.append(0)
@@ -152,7 +258,7 @@ class ALTimerTaskAddDialog(QDialog, Ui_ALTimerTaskAddDialog):
task_data["repeat_hour"] = execute_time.hour task_data["repeat_hour"] = execute_time.hour
task_data["repeat_minute"] = execute_time.minute task_data["repeat_minute"] = execute_time.minute
task_data["repeat_second"] = execute_time.second task_data["repeat_second"] = execute_time.second
task_data["execute_time"] = TimerUtils.calculateNextRepeatTime( task_data["execute_time"] = TimerUtils.getNextTimerRepeatTime(
task_data["repeat_days"], task_data["repeat_days"],
task_data["repeat_hour"], task_data["repeat_hour"],
task_data["repeat_minute"], task_data["repeat_minute"],
@@ -181,4 +287,41 @@ class ALTimerTaskAddDialog(QDialog, Ui_ALTimerTaskAddDialog):
self.ThuCheckBox.setEnabled(checked) self.ThuCheckBox.setEnabled(checked)
self.FriCheckBox.setEnabled(checked) self.FriCheckBox.setEnabled(checked)
self.SatCheckBox.setEnabled(checked) self.SatCheckBox.setEnabled(checked)
self.SunCheckBox.setEnabled(checked) self.SunCheckBox.setEnabled(checked)
self.AutoScriptGroupBox.setVisible(checked)
@Slot()
def onSetAutoScript(self):
dlg = ALAutoScriptOrchDialog(self, existingScript=self.__auto_script)
if dlg.exec() == QDialog.DialogCode.Accepted:
script = dlg.getScript()
self.__auto_script = script
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()
@Slot()
def onAutoScriptHelp(
self
):
QDesktopServices.openUrl(
QUrl("https://www.autolibrary.kenanzhu.com/manuals/autoscript")
)
+9 -9
View File
@@ -30,7 +30,7 @@ class ALTimerTaskHistoryDialog(QDialog):
super().__init__(parent) super().__init__(parent)
self.__task_data = task_data self.__task_data = task_data
self.__history = task_data.get("history", []) self.__history = task_data.get("repeat_history", [])
self.modifyUi() self.modifyUi()
self.connectSignals() self.connectSignals()
@@ -130,6 +130,13 @@ class ALTimerTaskHistoryDialog(QDialog):
self.HistoryTableWidget.setItem(row, 2, DurationItem) self.HistoryTableWidget.setItem(row, 2, DurationItem)
self.HistoryTableWidget.setRowHeight(row, 25) self.HistoryTableWidget.setRowHeight(row, 25)
def getHistory(
self
) -> list:
return self.__history
@Slot() @Slot()
def onClearHistoryButtonClicked( def onClearHistoryButtonClicked(
self self
@@ -137,11 +144,4 @@ class ALTimerTaskHistoryDialog(QDialog):
self.__history.clear() self.__history.clear()
self.HistoryTableWidget.setRowCount(0) self.HistoryTableWidget.setRowCount(0)
self.__task_data["history"] = self.__history self.__task_data["repeat_history"] = self.__history
def getHistory(
self
) -> list:
return self.__history
+62 -16
View File
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
Copyright (c) 2025 - 2026 KenanZhu. Copyright (c) 2025 KenanZhu.
All rights reserved. All rights reserved.
This software is provided "as is", without any warranty of any kind. This software is provided "as is", without any warranty of any kind.
@@ -19,14 +19,14 @@ from PySide6.QtCore import (
) )
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QDialog, QWidget, QListWidgetItem, QMessageBox, QDialog, QWidget, QListWidgetItem, QMessageBox,
QHBoxLayout, QVBoxLayout, QLabel, QPushButton QHBoxLayout, QVBoxLayout, QLabel, QPushButton, QMenu
) )
from PySide6.QtGui import ( from PySide6.QtGui import (
QCloseEvent QCloseEvent, QAction
) )
import managers.config.ConfigManager as ConfigManager import managers.config.ConfigManager as ConfigManager
import utils.TimerUtils as TimerUtils from utils.TimerUtils import TimerUtils
from gui.resources.ui.Ui_ALTimerTaskManageWidget import Ui_ALTimerTaskManageWidget from gui.resources.ui.Ui_ALTimerTaskManageWidget import Ui_ALTimerTaskManageWidget
from gui.ALTimerTaskAddDialog import ALTimerTaskAddDialog, ALTimerTaskStatus from gui.ALTimerTaskAddDialog import ALTimerTaskAddDialog, ALTimerTaskStatus
@@ -35,6 +35,8 @@ from gui.ALTimerTaskHistoryDialog import ALTimerTaskHistoryDialog
class ALTimerTaskItemWidget(QWidget): class ALTimerTaskItemWidget(QWidget):
editRequested = Signal(dict)
def __init__( def __init__(
self, self,
parent = None, parent = None,
@@ -43,8 +45,11 @@ class ALTimerTaskItemWidget(QWidget):
super().__init__(parent) super().__init__(parent)
self.__timer_task = timer_task self.__timer_task = timer_task
self.__manage_widget = parent
self.modifyUi() self.modifyUi()
self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
self.customContextMenuRequested.connect(self.showContextMenu)
def modifyUi( def modifyUi(
@@ -145,6 +150,27 @@ class ALTimerTaskItemWidget(QWidget):
self.DeleteButton.setEnabled(False) self.DeleteButton.setEnabled(False)
self.setFixedHeight(55) self.setFixedHeight(55)
@Slot(object)
def showContextMenu(
self,
pos
):
menu = QMenu(self)
edit_action = QAction("编辑", self)
edit_action.triggered.connect(
lambda: self.editRequested.emit(self.__timer_task)
)
menu.addAction(edit_action)
if self.__timer_task["status"] != ALTimerTaskStatus.RUNNING\
and self.__timer_task["status"] != ALTimerTaskStatus.READY:
delete_action = QAction("删除", self)
delete_action.triggered.connect(
lambda: self.__manage_widget.deleteTask(self.__timer_task)
)
menu.addAction(delete_action)
menu.exec(self.mapToGlobal(pos))
class ALTimerTaskManageWidget(QWidget, Ui_ALTimerTaskManageWidget): class ALTimerTaskManageWidget(QWidget, Ui_ALTimerTaskManageWidget):
@@ -224,8 +250,8 @@ class ALTimerTaskManageWidget(QWidget, Ui_ALTimerTaskManageWidget):
task["added_time"] = datetime.strptime(task["added_time"], "%Y-%m-%d %H:%M:%S") task["added_time"] = datetime.strptime(task["added_time"], "%Y-%m-%d %H:%M:%S")
task["execute_time"] = datetime.strptime(task["execute_time"], "%Y-%m-%d %H:%M:%S") task["execute_time"] = datetime.strptime(task["execute_time"], "%Y-%m-%d %H:%M:%S")
task["status"] = ALTimerTaskStatus(task["status"]) task["status"] = ALTimerTaskStatus(task["status"])
if "history" in task: if "repeat_history" in task:
for item in task["history"]: for item in task["repeat_history"]:
item["result"] = ALTimerTaskStatus(item["result"]) item["result"] = ALTimerTaskStatus(item["result"])
return timer_tasks["timer_tasks"] return timer_tasks["timer_tasks"]
raise Exception("定时任务配置文件格式错误") raise Exception("定时任务配置文件格式错误")
@@ -248,8 +274,8 @@ class ALTimerTaskManageWidget(QWidget, Ui_ALTimerTaskManageWidget):
task["added_time"] = task["added_time"].strftime("%Y-%m-%d %H:%M:%S") task["added_time"] = task["added_time"].strftime("%Y-%m-%d %H:%M:%S")
task["execute_time"] = task["execute_time"].strftime("%Y-%m-%d %H:%M:%S") task["execute_time"] = task["execute_time"].strftime("%Y-%m-%d %H:%M:%S")
task["status"] = task["status"].value task["status"] = task["status"].value
if "history" in task: if "repeat_history" in task:
for item in task["history"]: for item in task["repeat_history"]:
item["result"] = item["result"].value item["result"] = item["result"].value
self.__cfg_mgr.set(ConfigManager.ConfigType.TIMERTASK, "", { "timer_tasks": timer_tasks }) self.__cfg_mgr.set(ConfigManager.ConfigType.TIMERTASK, "", { "timer_tasks": timer_tasks })
return True return True
@@ -363,6 +389,7 @@ class ALTimerTaskManageWidget(QWidget, Ui_ALTimerTaskManageWidget):
widget.HistoryButton.clicked.connect( widget.HistoryButton.clicked.connect(
lambda _, task = timer_task: self.showTaskHistory(task) lambda _, task = timer_task: self.showTaskHistory(task)
) )
widget.editRequested.connect(self.editTask)
item.setSizeHint(widget.size()) item.setSizeHint(widget.size())
self.TimerTasksListWidget.addItem(item) self.TimerTasksListWidget.addItem(item)
self.TimerTasksListWidget.setItemWidget(item, widget) self.TimerTasksListWidget.setItemWidget(item, widget)
@@ -378,19 +405,39 @@ class ALTimerTaskManageWidget(QWidget, Ui_ALTimerTaskManageWidget):
self.__timer_tasks.append(timer_task) self.__timer_tasks.append(timer_task)
self.timerTasksChanged.emit() self.timerTasksChanged.emit()
def editTask(
self,
timer_task: dict
):
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
break
self.timerTasksChanged.emit()
@staticmethod @staticmethod
def getTimerTaskDetailMessage( def getTimerTaskDetailMessage(
timer_task: dict timer_task: dict
): ):
if "repeat_history" not in timer_task:
history = []
else:
history = timer_task["repeat_history"]
history_count = len(history)
return ( return (
f"任务名称:{timer_task["name"]}\n" f"任务名称:{timer_task["name"]}\n"
f"添加时间:{timer_task["added_time"]}\n" f"添加时间:{timer_task["added_time"]}\n"
f"当前状态:{timer_task["status"].value}\n" f"当前状态:{timer_task["status"].value}\n"
f"下次执行时间:{datetime.strftime(timer_task["execute_time"], "%Y-%m-%d %H:%M:%S")}\n" f"下次执行时间:{datetime.strftime(timer_task["execute_time"], "%Y-%m-%d %H:%M:%S")}\n"
f"已记录次数:{len(timer_task['history'] if 'history' in timer_task else 0)}" f"已记录次数:{history_count}"
) )
def deleteTask( def deleteTask(
self, self,
@@ -554,7 +601,6 @@ class ALTimerTaskManageWidget(QWidget, Ui_ALTimerTaskManageWidget):
self.updateTimerTaskList() self.updateTimerTaskList()
self.updateStat() self.updateStat()
@Slot(dict) @Slot(dict)
def onTimerTaskIsRunning( def onTimerTaskIsRunning(
self, self,
@@ -579,12 +625,12 @@ class ALTimerTaskManageWidget(QWidget, Ui_ALTimerTaskManageWidget):
ALTimerTaskStatus.OUTDATED} ALTimerTaskStatus.OUTDATED}
if status not in valid_statuses: if status not in valid_statuses:
return timer_task return timer_task
if "history" not in timer_task: if "repeat_history" not in timer_task:
timer_task["history"] = [] timer_task["repeat_history"] = []
if status != ALTimerTaskStatus.OUTDATED: if status != ALTimerTaskStatus.OUTDATED:
executed_time = datetime.now() executed_time = datetime.now()
duration = (executed_time - timer_task["execute_time"]).total_seconds() duration = (executed_time - timer_task["execute_time"]).total_seconds()
timer_task["history"].append({ timer_task["repeat_history"].append({
"execute_time": timer_task["execute_time"].strftime("%Y-%m-%d %H:%M:%S"), "execute_time": timer_task["execute_time"].strftime("%Y-%m-%d %H:%M:%S"),
"executed_time": executed_time.strftime("%Y-%m-%d %H:%M:%S"), "executed_time": executed_time.strftime("%Y-%m-%d %H:%M:%S"),
"result": status, "result": status,
@@ -598,14 +644,14 @@ class ALTimerTaskManageWidget(QWidget, Ui_ALTimerTaskManageWidget):
delta_days = (current_time - execute_time).days delta_days = (current_time - execute_time).days
for i in range(delta_days + 1): for i in range(delta_days + 1):
if (execute_weekday + i)%7 in timer_task["repeat_days"]: if (execute_weekday + i)%7 in timer_task["repeat_days"]:
timer_task["history"].append({ timer_task["repeat_history"].append({
"execute_time": (execute_time + timedelta(days=i)).strftime("%Y-%m-%d %H:%M:%S"), "execute_time": (execute_time + timedelta(days=i)).strftime("%Y-%m-%d %H:%M:%S"),
"executed_time": (execute_time + timedelta(days=i)).strftime("%Y-%m-%d %H:%M:%S"), "executed_time": (execute_time + timedelta(days=i)).strftime("%Y-%m-%d %H:%M:%S"),
"result": status, "result": status,
"duration": 0, "duration": 0,
"uuid": timer_task["uuid"] "uuid": timer_task["uuid"]
}) })
next_time = TimerUtils.calculateNextRepeatTime( next_time = TimerUtils.getNextTimerRepeatTime(
timer_task["repeat_days"], timer_task["repeat_days"],
timer_task["repeat_hour"], timer_task["repeat_hour"],
timer_task["repeat_minute"], timer_task["repeat_minute"],
+1 -1
View File
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
Copyright (c) 2025 - 2026 KenanZhu. Copyright (c) 2025 KenanZhu.
All rights reserved. All rights reserved.
This software is provided "as is", without any warranty of any kind. This software is provided "as is", without any warranty of any kind.
+3 -3
View File
@@ -5,11 +5,11 @@
workflow process. Do not edit manually. workflow process. Do not edit manually.
This file is auto-generated during the workflow process. This file is auto-generated during the workflow process.
Last updated: 2026-02-26 15:04:28 UTC Last updated: 2026-05-09 06:05:13 UTC
""" """
AL_VERSION = "1.1.0" AL_VERSION = "1.3.0"
AL_TAG = "v1.1.0" AL_TAG = "v1.3.0"
AL_COMMIT_SHA = "local" AL_COMMIT_SHA = "local"
AL_COMMIT_DATE = "null" # time zone : UTC AL_COMMIT_DATE = "null" # time zone : UTC
AL_BUILD_DATE = "null" # time zone : UTC AL_BUILD_DATE = "null" # time zone : UTC
+82 -42
View File
@@ -24,7 +24,8 @@ from PySide6.QtGui import (
from managers.driver.WebDriverManager import ( from managers.driver.WebDriverManager import (
instance as webdriver_manager_instance, instance as webdriver_manager_instance,
WebDriverManager, WebDriverInfo, WebDriverType WebDriverManager, WebDriverInfo, WebDriverType,
WebDriverStatus
) )
from gui.ALStatusLabel import ALStatusLabel from gui.ALStatusLabel import ALStatusLabel
@@ -141,6 +142,7 @@ class ALWebDriverDownloadDialog(QDialog):
self.initializeDriverManager() self.initializeDriverManager()
self.refreshDriverList() self.refreshDriverList()
def showEvent( def showEvent(
self, self,
event event
@@ -163,6 +165,7 @@ class ALWebDriverDownloadDialog(QDialog):
self.move(target_pos) self.move(target_pos)
return result return result
def setupUi( def setupUi(
self self
): ):
@@ -267,13 +270,16 @@ class ALWebDriverDownloadDialog(QDialog):
self.__driver_manager.refresh() self.__driver_manager.refresh()
self.__driver_infos = self.__driver_manager.getDriverInfos() self.__driver_infos = self.__driver_manager.getDriverInfos()
self.DriverComboBox.clear() self.DriverComboBox.clear()
for driver_info in self.__driver_infos: 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}" display_text = f"{driver_info.driver_type.value} - {driver_info.browser_version}"
self.DriverComboBox.addItem(display_text) self.DriverComboBox.addItem(display_text)
count = len(self.__driver_infos) count = len(self.__driver_infos)
self.BrowserCountLabel.setText(f"检测到 {count} 个可用浏览器:") self.BrowserCountLabel.setText(f"检测到 {count} 个可用浏览器:")
if self.__driver_infos: if self.__driver_infos:
self.onDriverComboBoxChanged(0) self.DriverComboBox.setCurrentIndex(installed_idx)
def onDriverComboBoxChanged( def onDriverComboBoxChanged(
@@ -285,6 +291,7 @@ class ALWebDriverDownloadDialog(QDialog):
return return
driver_info = self.__driver_infos[index] driver_info = self.__driver_infos[index]
self.updateDriverInfoDisplay(driver_info) self.updateDriverInfoDisplay(driver_info)
self.updateProgressBarStates(driver_info)
self.updateButtonStates(driver_info) self.updateButtonStates(driver_info)
@@ -328,18 +335,21 @@ class ALWebDriverDownloadDialog(QDialog):
self self
): ):
self.DriverComboBox.setEnabled(False)
index = self.DriverComboBox.currentIndex() index = self.DriverComboBox.currentIndex()
if index < 0 or index >= len(self.__driver_infos): if index < 0 or index >= len(self.__driver_infos):
return return
driver_info = self.__driver_infos[index] driver_info = self.__driver_infos[index]
if driver_info.driver_status.name == "INSTALLED": if driver_info.driver_status == WebDriverStatus.INSTALLED:
return return
self.StatusLabel.status = ALStatusLabel.Status.RUNNING driver_info.driver_status = WebDriverStatus.DOWNLOADING # we set this only to update
self.DownloadButton.setEnabled(False) # the display, and we will set to not installed in the download thread
self.RefreshButton.setEnabled(False) self.updateDriverInfoDisplay(driver_info)
self.DriverComboBox.setEnabled(False) self.updateProgressBarStates(driver_info)
self.ProgressBar.setValue(0) self.ProgressText.setText("准备开始下载...")
self.ProgressText.setText("正在下载驱动...") self.updateButtonStates(driver_info)
# set to not installed
driver_info.driver_status = WebDriverStatus.NOT_INSTALLED
self.__download_thread = DownloadWorker(self.__driver_manager, driver_info) self.__download_thread = DownloadWorker(self.__driver_manager, driver_info)
self.__download_thread.progress.connect(self.onDownloadProgress) self.__download_thread.progress.connect(self.onDownloadProgress)
self.__download_thread.downloadFinished.connect(self.onDownloadFinished) self.__download_thread.downloadFinished.connect(self.onDownloadFinished)
@@ -371,18 +381,14 @@ class ALWebDriverDownloadDialog(QDialog):
self self
): ):
self.ProgressBar.setValue(100) self.DriverComboBox.setEnabled(True)
self.ProgressText.setText("下载完成 !")
self.StatusLabel.status = ALStatusLabel.Status.SUCCESS
index = self.DriverComboBox.currentIndex() index = self.DriverComboBox.currentIndex()
if 0 <= index < len(self.__driver_infos): if 0 <= index < len(self.__driver_infos):
driver_info = self.__driver_infos[index] driver_info = self.__driver_infos[index]
driver_info.driver_status = WebDriverStatus.INSTALLED
self.updateDriverInfoDisplay(driver_info) self.updateDriverInfoDisplay(driver_info)
self.ConfirmButton.setEnabled(True) self.updateProgressBarStates(driver_info)
self.DownloadButton.setEnabled(False) self.updateButtonStates(driver_info)
self.RefreshButton.setEnabled(True)
self.DriverComboBox.setEnabled(True)
self.DeleteButton.setEnabled(True)
@Slot() @Slot()
def onDownloadError( def onDownloadError(
@@ -390,12 +396,15 @@ class ALWebDriverDownloadDialog(QDialog):
error_message: str error_message: str
): ):
self.StatusLabel.status = ALStatusLabel.Status.FAILURE
QMessageBox.critical(self, "下载失败 - AutoLibrary", error_message)
self.DownloadButton.setEnabled(True)
self.RefreshButton.setEnabled(True)
self.DriverComboBox.setEnabled(True) self.DriverComboBox.setEnabled(True)
self.CancelButton.setEnabled(True) index = self.DriverComboBox.currentIndex()
if 0 <= index < len(self.__driver_infos):
driver_info = self.__driver_infos[index]
driver_info.driver_status = WebDriverStatus.ERROR
self.updateDriverInfoDisplay(driver_info)
self.updateProgressBarStates(driver_info)
self.updateButtonStates(driver_info)
QMessageBox.critical(self, "下载失败 - AutoLibrary", error_message)
@Slot() @Slot()
@@ -403,19 +412,16 @@ class ALWebDriverDownloadDialog(QDialog):
self self
): ):
self.DriverComboBox.setEnabled(True)
index = self.DriverComboBox.currentIndex() index = self.DriverComboBox.currentIndex()
if 0 <= index < len(self.__driver_infos): if 0 <= index < len(self.__driver_infos):
driver_info = self.__driver_infos[index] driver_info = self.__driver_infos[index]
self.__driver_manager.cancelDriverDownload(driver_info) self.__driver_manager.cancelDriverDownload(driver_info)
driver_info.driver_status = WebDriverStatus.NOT_INSTALLED
self.updateDriverInfoDisplay(driver_info) self.updateDriverInfoDisplay(driver_info)
self.ProgressText.setText("下载已取消") self.updateProgressBarStates(driver_info)
self.ProgressBar.setValue(0) self.updateButtonStates(driver_info)
self.StatusLabel.status = ALStatusLabel.Status.WAITING self.ProgressText.setText("下载已取消")
self.DownloadButton.setEnabled(True)
self.RefreshButton.setEnabled(True)
self.DriverComboBox.setEnabled(True)
self.CancelButton.setEnabled(True)
self.DeleteButton.setEnabled(False)
@Slot() @Slot()
@@ -427,7 +433,7 @@ class ALWebDriverDownloadDialog(QDialog):
if index < 0 or index >= len(self.__driver_infos): if index < 0 or index >= len(self.__driver_infos):
return return
driver_info = self.__driver_infos[index] driver_info = self.__driver_infos[index]
if driver_info.driver_status.name != "INSTALLED": if driver_info.driver_status != WebDriverStatus.INSTALLED:
return return
self.__selected_driver_info = driver_info self.__selected_driver_info = driver_info
self.__confirmed = True self.__confirmed = True
@@ -510,27 +516,61 @@ class ALWebDriverDownloadDialog(QDialog):
self.PathLabel.setText(str(driver_info.driver_path)) self.PathLabel.setText(str(driver_info.driver_path))
else: else:
self.PathLabel.setText("未安装") self.PathLabel.setText("未安装")
match driver_info.driver_status.name: match driver_info.driver_status:
case "NOT_INSTALLED": case WebDriverStatus.NOT_INSTALLED:
self.StatusLabel.status = ALStatusLabel.Status.WAITING self.StatusLabel.status = ALStatusLabel.Status.WAITING
case "INSTALLED": case WebDriverStatus.INSTALLED:
self.StatusLabel.status = ALStatusLabel.Status.SUCCESS self.StatusLabel.status = ALStatusLabel.Status.SUCCESS
case "DOWNLOADING": case WebDriverStatus.DOWNLOADING:
self.StatusLabel.status = ALStatusLabel.Status.RUNNING self.StatusLabel.status = ALStatusLabel.Status.RUNNING
case "ERROR": case WebDriverStatus.ERROR:
self.StatusLabel.status = ALStatusLabel.Status.FAILURE 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( def updateButtonStates(
self, self,
driver_info: WebDriverInfo driver_info: WebDriverInfo
): ):
if driver_info.driver_status.name == "INSTALLED": if driver_info.driver_status == WebDriverStatus.NOT_INSTALLED:
self.DownloadButton.setEnabled(False) self.RefreshButton.setEnabled(True)
self.DeleteButton.setEnabled(True)
self.ConfirmButton.setEnabled(True)
else:
self.DeleteButton.setEnabled(False) self.DeleteButton.setEnabled(False)
self.DownloadButton.setEnabled(True) 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) self.ConfirmButton.setEnabled(False)
+1
View File
@@ -10,6 +10,7 @@
- ALSeatMapTable: Seat map table class. - ALSeatMapTable: Seat map table class.
- ALSeatMapSelectDialog: Seat map select dialog class. - ALSeatMapSelectDialog: Seat map select dialog class.
- ALTimerTaskAddDialog: Timer task add dialog class. - ALTimerTaskAddDialog: Timer task add dialog class.
- ALAutoScriptOrchDialog: AutoScript orchestration dialog class.
- ALTimerTaskHistoryDialog: Timer task history dialog class. - ALTimerTaskHistoryDialog: Timer task history dialog class.
- ALTimerTaskManageWidget: Timer task manage widget class. - ALTimerTaskManageWidget: Timer task manage widget class.
- ALUserTreeWidget: User tree widget class. - ALUserTreeWidget: User tree widget class.
+1 -1
View File
@@ -13,7 +13,7 @@
<property name="minimumSize"> <property name="minimumSize">
<size> <size>
<width>350</width> <width>350</width>
<height>400</height> <height>460</height>
</size> </size>
</property> </property>
<property name="maximumSize"> <property name="maximumSize">
+2 -44
View File
@@ -176,49 +176,7 @@ class ConfigManager:
# ConfigManager singleton instance. # ConfigManager singleton instance.
_config_manager_instance = None _config_manager_instance : ConfigManager | None = None
# Utility functions.
#
# Utility function to get validated automation config paths.
def getValidateAutomationConfigPaths(
) -> dict:
"""
Get validated automation config paths from ConfigManager instance.
These function will validate the config paths and return the validated paths in a dict.
Returns:
dict: Validated automation config paths.
"""
config_paths = {"run": "", "user": ""}
auto_config = _config_manager_instance.get(ConfigType.GLOBAL, "automation", {})
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)
if paths == []:
paths.append(os.path.join(_config_manager_instance.configDir(), f"{cfg_type}.json"))
if index < 0:
index = 0
if index >= len(paths):
index = len(paths) - 1
config_paths[cfg_type] = paths[index]
data = {"current": index, "paths": paths}
auto_config[f"{cfg_type}_path"] = data
_config_manager_instance.set(ConfigType.GLOBAL, "automation", auto_config)
return config_paths
# Utility function to get base config directory.
def getBaseConfigDir(
) -> str:
"""
Get base config directory, on Windows, it is usually at :
'C:\\Users\\<username>\\AppData\\Local\\AutoLibrary\\config'.
Returns:
str: Base config directory.
"""
return _config_manager_instance.configDir()
# Singleton instance of ConfigManager. # Singleton instance of ConfigManager.
_instance_lock = threading.Lock() _instance_lock = threading.Lock()
@@ -240,6 +198,6 @@ def instance(
else: else:
if config_dir == "": if config_dir == "":
return _config_manager_instance return _config_manager_instance
if getBaseConfigDir() != config_dir: if _config_manager_instance.configDir() != config_dir:
raise ValueError("ConfigManager 的实例已初始化,不能使用不同的配置目录。") raise ValueError("ConfigManager 的实例已初始化,不能使用不同的配置目录。")
return _config_manager_instance return _config_manager_instance
+23 -5
View File
@@ -1,5 +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.
"""
import platform import platform
import installed_browsers import browsers
from pathlib import Path from pathlib import Path
from enum import Enum from enum import Enum
@@ -128,7 +137,7 @@ class WebBrowserDetector:
self.browser_infos = [] self.browser_infos = []
try: try:
all_browsers = installed_browsers.browsers() all_browsers = list(browsers.browsers())
except Exception as e: except Exception as e:
self.browser_infos = [] self.browser_infos = []
return self.browser_infos return self.browser_infos
@@ -140,14 +149,14 @@ class WebBrowserDetector:
'msedge': WebBrowserType.EDGE, 'msedge': WebBrowserType.EDGE,
} }
for browser in all_browsers: for browser in all_browsers:
internal_name = browser.get('name', '').lower() internal_name = browser.get("browser_type", "").lower()
if internal_name not in type_map: if internal_name not in type_map:
continue # Not one of the browsers we care about continue # Not one of the browsers we care about
version = browser.get('version') version = browser.get("version", "")
if not version: if not version:
# Skip browsers with no version info (unlikely, but defensive) # Skip browsers with no version info (unlikely, but defensive)
continue continue
exe_path = browser.get('location') exe_path = browser.get("path", "")
if not exe_path: if not exe_path:
continue continue
try: try:
@@ -163,4 +172,13 @@ class WebBrowserDetector:
browser_path=path, browser_path=path,
) )
self.browser_infos.append(info) self.browser_infos.append(info)
# Deduplicate: keep only one entry per (type, version)
seen = set()
unique = []
for info in self.browser_infos:
key = (info.browser_type, info.browser_version)
if key not in seen:
seen.add(key)
unique.append(info)
self.browser_infos = unique
return self.browser_infos return self.browser_infos
@@ -1,3 +1,12 @@
# -*- 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 os
import time import time
import shutil import shutil
+13 -13
View File
@@ -24,7 +24,7 @@ from managers.driver.WebDriverDownloader import (
) )
class DriverStatus(Enum): class WebDriverStatus(Enum):
""" """
Web driver status. Web driver status.
""" """
@@ -57,7 +57,7 @@ class WebDriverInfo:
self.driver_version = "" self.driver_version = ""
self.browser_version = "" self.browser_version = ""
self.driver_path: Optional[Path] = None self.driver_path: Optional[Path] = None
self.driver_status = DriverStatus.NOT_INSTALLED self.driver_status = WebDriverStatus.NOT_INSTALLED
class WebDriverManager: class WebDriverManager:
@@ -115,7 +115,7 @@ class WebDriverManager:
driver_path = self._getDriverPath(driver_info) driver_path = self._getDriverPath(driver_info)
if driver_path and driver_path.exists() and driver_path.is_file(): if driver_path and driver_path.exists() and driver_path.is_file():
driver_info.driver_path = driver_path driver_info.driver_path = driver_path
driver_info.driver_status = DriverStatus.INSTALLED driver_info.driver_status = WebDriverStatus.INSTALLED
def _mapWebBrowserTypeToDriver( def _mapWebBrowserTypeToDriver(
@@ -321,7 +321,7 @@ class WebDriverManager:
driver_info: WebDriverInfo driver_info: WebDriverInfo
) -> Optional[Path]: ) -> Optional[Path]:
if driver_info and driver_info.driver_status == DriverStatus.INSTALLED: if driver_info and driver_info.driver_status == WebDriverStatus.INSTALLED:
return driver_info.driver_path return driver_info.driver_path
return None return None
@@ -339,7 +339,7 @@ class WebDriverManager:
progress_callback(0, 0, 0, "未找到浏览器信息") progress_callback(0, 0, 0, "未找到浏览器信息")
else: else:
raise ValueError("未找到浏览器信息") raise ValueError("未找到浏览器信息")
if driver_info and driver_info.driver_status == DriverStatus.DOWNLOADING: if driver_info and driver_info.driver_status == WebDriverStatus.DOWNLOADING:
if progress_callback: if progress_callback:
progress_callback(0, 0, 0, f"{driver_info.driver_type} 驱动正在下载中") progress_callback(0, 0, 0, f"{driver_info.driver_type} 驱动正在下载中")
else: else:
@@ -375,19 +375,19 @@ class WebDriverManager:
else: else:
raise ValueError(f"不支持的 Web Driver 类型") raise ValueError(f"不支持的 Web Driver 类型")
with self.__lock: with self.__lock:
driver_info.driver_status = DriverStatus.DOWNLOADING driver_info.driver_status = WebDriverStatus.DOWNLOADING
driver_path = downloader.download(progress_callback=progress_callback, cancel_event=cancel_event) driver_path = downloader.download(progress_callback=progress_callback, cancel_event=cancel_event)
with self.__lock: with self.__lock:
if driver_path: if driver_path:
driver_info.driver_path = driver_path driver_info.driver_path = driver_path
driver_info.driver_version = driver_version driver_info.driver_version = driver_version
driver_info.driver_status = DriverStatus.INSTALLED driver_info.driver_status = WebDriverStatus.INSTALLED
else: else:
driver_info.driver_status = DriverStatus.ERROR driver_info.driver_status = WebDriverStatus.ERROR
return driver_path return driver_path
except Exception as e: except Exception as e:
with self.__lock: with self.__lock:
driver_info.driver_status = DriverStatus.ERROR driver_info.driver_status = WebDriverStatus.ERROR
raise e raise e
@@ -406,7 +406,7 @@ class WebDriverManager:
shutil.rmtree(download_dir, ignore_errors=True) shutil.rmtree(download_dir, ignore_errors=True)
with self.__lock: with self.__lock:
driver_info.driver_path = None driver_info.driver_path = None
driver_info.driver_status = DriverStatus.NOT_INSTALLED driver_info.driver_status = WebDriverStatus.NOT_INSTALLED
return True return True
except Exception: except Exception:
return False return False
@@ -424,7 +424,7 @@ class WebDriverManager:
progress_callback(0, 0, 0, "未找到浏览器信息") progress_callback(0, 0, 0, "未找到浏览器信息")
else: else:
raise ValueError("未找到浏览器信息") raise ValueError("未找到浏览器信息")
if driver_info.driver_status != DriverStatus.INSTALLED: if driver_info.driver_status != WebDriverStatus.INSTALLED:
if progress_callback: if progress_callback:
progress_callback(0, 0, 0, f"{driver_info.driver_type} 驱动未安装") progress_callback(0, 0, 0, f"{driver_info.driver_type} 驱动未安装")
else: else:
@@ -434,11 +434,11 @@ class WebDriverManager:
driver_path.unlink() driver_path.unlink()
with self.__lock: with self.__lock:
driver_info.driver_path = None driver_info.driver_path = None
driver_info.driver_status = DriverStatus.NOT_INSTALLED driver_info.driver_status = WebDriverStatus.NOT_INSTALLED
return True return True
except Exception: except Exception:
with self.__lock: with self.__lock:
driver_info.driver_status = DriverStatus.ERROR driver_info.driver_status = WebDriverStatus.ERROR
raise raise
+1 -1
View File
@@ -186,7 +186,7 @@ def instance(
raise ValueError("LogManager 的实例已初始化, 不能使用不同的日志目录") raise ValueError("LogManager 的实例已初始化, 不能使用不同的日志目录")
return _log_manager_instance return _log_manager_instance
# export function to get logger
def getLogger( def getLogger(
name: Optional[str] = None name: Optional[str] = None
) -> logging.Logger: ) -> logging.Logger:
+1 -1
View File
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
Copyright (c) 2025 - 2026 KenanZhu. Copyright (c) 2025 KenanZhu.
All rights reserved. All rights reserved.
This software is provided "as is", without any warranty of any kind. This software is provided "as is", without any warranty of any kind.
+1 -1
View File
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
Copyright (c) 2025 - 2026 KenanZhu. Copyright (c) 2025 KenanZhu.
All rights reserved. All rights reserved.
This software is provided "as is", without any warranty of any kind. This software is provided "as is", without any warranty of any kind.
+1 -1
View File
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
Copyright (c) 2025 - 2026 KenanZhu. Copyright (c) 2025 KenanZhu.
All rights reserved. All rights reserved.
This software is provided "as is", without any warranty of any kind. This software is provided "as is", without any warranty of any kind.
+1 -1
View File
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
Copyright (c) 2025 - 2026 KenanZhu. Copyright (c) 2025 KenanZhu.
All rights reserved. All rights reserved.
This software is provided "as is", without any warranty of any kind. This software is provided "as is", without any warranty of any kind.
+1 -1
View File
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
Copyright (c) 2025 - 2026 KenanZhu. Copyright (c) 2025 KenanZhu.
All rights reserved. All rights reserved.
This software is provided "as is", without any warranty of any kind. This software is provided "as is", without any warranty of any kind.
+1 -1
View File
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
Copyright (c) 2025 - 2026 KenanZhu. Copyright (c) 2025 KenanZhu.
All rights reserved. All rights reserved.
This software is provided "as is", without any warranty of any kind. This software is provided "as is", without any warranty of any kind.
+1 -1
View File
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
Copyright (c) 2025 - 2026 KenanZhu. Copyright (c) 2025 KenanZhu.
All rights reserved. All rights reserved.
This software is provided "as is", without any warranty of any kind. This software is provided "as is", without any warranty of any kind.
+1 -1
View File
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
Copyright (c) 2025 - 2026 KenanZhu. Copyright (c) 2025 KenanZhu.
All rights reserved. All rights reserved.
This software is provided "as is", without any warranty of any kind. This software is provided "as is", without any warranty of any kind.
+386
View File
@@ -0,0 +1,386 @@
# -*- 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 re
from datetime import datetime, timedelta
class AutoScriptEngine:
"""
AutoScript script engine.
Parses and executes AutoScript a lightweight scripting DSL
used in repeatable timer tasks to preprocess user reservation
data before automation runs.
Supports IF/ELSE IF/ELSE/END IF control flow, SET assignments,
.ADD./.SUB. operations on Date/Time fields, and rich comparison
operators (.EQ. .NEQ. .BGT. .BLT. .BGE. .BLE.).
Examples:
>>> engine = AutoScriptEngine
>>> user = {
... "username": "test",
... "enabled": True,
... "reserve_info": {"date": "2026-05-07"}
... }
>>> engine.execute(
... 'IF(CURRENT_TIME .BGT. TIME(19:00))\\n'
... ' RESERVE_DATE .ADD. 1\\n'
... 'END IF',
... user
... )
"""
COMPARE_OPS = { # compare operators
".EQ." : lambda a, b: a == b,
".NEQ.": lambda a, b: a != b,
".BGT.": lambda a, b: a > b,
".BLT.": lambda a, b: a < b,
".BGE.": lambda a, b: a >= b,
".BLE.": lambda a, b: a <= b,
}
VARIABLE_META = { # variable metadata
"预约开始时间": ("RESERVE_BEGIN_TIME", "Time"),
"预约结束时间": ("RESERVE_END_TIME", "Time"),
"预约日期": ("RESERVE_DATE", "Date"),
"用户名": ("USERNAME", "String"),
"用户启用": ("USER_ENABLE", "Boolean"),
"当前时间": ("CURRENT_TIME", "Time"),
"当前日期": ("CURRENT_DATE", "Date"),
}
_FIELD_TYPE_MAP = {meta[0]: meta[1] for meta in VARIABLE_META.values()}
@staticmethod
def execute(
script_text: str,
user_data: dict
):
"""
Execute an AutoScript against the given user data.
The script is parsed line-by-line. All modifications are
applied directly to ``user_data`` in-place.
Args:
script_text (str): Raw AutoScript source code.
user_data (dict): User data dictionary to read from and
write to. Must conform to the standard user profile
structure (username, enabled, reserve_info, etc.).
Raises:
ValueError: On any syntax or type error encountered
during parsing or execution.
"""
if not script_text or not script_text.strip():
return
lines = [l.strip() for l in script_text.split("\n") if l.strip()]
if not lines:
return
if_stack = []
for line in lines:
upper_line = line.upper().strip()
if upper_line.startswith("IF("):
cond_end = _findConditionEnd(upper_line)
if cond_end < 0:
raise ValueError("AutoScript 语法错误: IF 缺少右括号")
condition_str = line[3:cond_end].strip()
matched = AutoScriptEngine._evaluateCondition(
condition_str, user_data
)
if_stack.append([matched, matched])
elif upper_line.startswith("ELSE IF("):
if not if_stack:
raise ValueError("AutoScript 语法错误: ELSE IF 前缺少 IF")
cond_end = _findConditionEnd(upper_line)
if cond_end < 0:
raise ValueError("AutoScript 语法错误: ELSE IF 缺少右括号")
condition_str = line[8:cond_end].strip()
_, has_matched = if_stack[-1]
if not has_matched:
matched = AutoScriptEngine._evaluateCondition(
condition_str, user_data
)
if_stack[-1] = [matched, matched]
else:
if_stack[-1][0] = False
elif upper_line == "ELSE":
if not if_stack:
raise ValueError("AutoScript 语法错误: ELSE 前缺少 IF")
_, has_matched = if_stack[-1]
if not has_matched:
if_stack[-1] = [True, True]
else:
if_stack[-1][0] = False
elif upper_line in ("ENDIF", "END IF"):
if not if_stack:
raise ValueError("AutoScript 语法错误: ENDIF/END IF 前缺少 IF")
if_stack.pop()
elif upper_line.startswith("SET "):
should_execute = (
all(ctx[0] for ctx in if_stack) if if_stack else True
)
if should_execute:
AutoScriptEngine._executeSet(line, user_data)
elif upper_line == "PASS":
continue
else:
should_execute = (
all(ctx[0] for ctx in if_stack) if if_stack else True
)
if should_execute:
AutoScriptEngine._executeOperation(line, user_data)
if if_stack:
raise ValueError("AutoScript 语法错误: IF 与 ENDIF/END IF 不匹配")
@staticmethod
def _resolveField(
field_name: str,
user_data: dict
):
upper_name = field_name.upper().strip()
if upper_name == "CURRENT_DATE":
return datetime.now().strftime("%Y-%m-%d")
elif upper_name == "CURRENT_TIME":
return datetime.now().strftime("%H:%M")
elif upper_name == "USERNAME":
return user_data.get("username", "")
elif upper_name == "USER_ENABLE":
return user_data.get("enabled", False)
elif upper_name == "RESERVE_DATE":
return user_data.get("reserve_info", {}).get("date", "")
elif upper_name == "RESERVE_BEGIN_TIME":
return (
user_data
.get("reserve_info", {})
.get("begin_time", {})
.get("time", "")
)
elif upper_name == "RESERVE_END_TIME":
return (
user_data
.get("reserve_info", {})
.get("end_time", {})
.get("time", "")
)
return ""
@staticmethod
def _resolveValue(
value_str: str,
user_data: dict
):
s = value_str.strip()
time_match = re.match(r"^TIME\((\d{1,2}):(\d{2})\)$", s, re.IGNORECASE)
if time_match:
h, m = time_match.group(1), time_match.group(2)
return f"{int(h):02d}:{int(m):02d}"
date_match = re.match(r"^DATE\((\d{4})-(\d{2})-(\d{2})\)$", s, re.IGNORECASE)
if date_match:
y, mo, d = date_match.group(1), date_match.group(2), date_match.group(3)
return f"{int(y):04d}-{int(mo):02d}-{int(d):02d}"
if s.upper() == ".TRUE.":
return True
if s.upper() == ".FALSE.":
return False
if s.startswith("'") and s.endswith("'"):
inner = s[1:-1].replace("''", "'")
return inner
if s.startswith('"') and s.endswith('"'):
return s[1:-1]
relDate = re.match(r"^CURRENT_DATE\s*\+\s*(\d+)$", s, re.IGNORECASE)
if relDate:
days = int(relDate.group(1))
return (datetime.now() + timedelta(days=days)).strftime("%Y-%m-%d")
relTime = re.match(r"^CURRENT_TIME\s*\+\s*(\d+)$", s, re.IGNORECASE)
if relTime:
hours = int(relTime.group(1))
return (datetime.now() + timedelta(hours=hours)).strftime("%H:%M")
try:
return int(s)
except ValueError:
pass
try:
return float(s)
except ValueError:
pass
resolved = AutoScriptEngine._resolveField(s, user_data)
return resolved
@staticmethod
def _setField(
field_name: str,
value: str,
user_data: dict
):
upper_name = field_name.upper().strip()
if upper_name == "RESERVE_DATE":
user_data.setdefault("reserve_info", {})["date"] = value
elif upper_name == "RESERVE_BEGIN_TIME":
ri = user_data.setdefault("reserve_info", {})
ri.setdefault("begin_time", {})["time"] = value
elif upper_name == "RESERVE_END_TIME":
ri = user_data.setdefault("reserve_info", {})
ri.setdefault("end_time", {})["time"] = value
elif upper_name == "USERNAME":
user_data["username"] = value
elif upper_name == "USER_ENABLE":
if isinstance(value, bool):
user_data["enabled"] = value
else:
user_data["enabled"] = (str(value).upper() == "TRUE")
@staticmethod
def _evaluateCondition(
condition_str: str,
user_data: dict
) -> bool:
for op, cmp_func in AutoScriptEngine.COMPARE_OPS.items():
if op not in condition_str.upper():
continue
idx = condition_str.upper().find(op)
parts = [condition_str[:idx], condition_str[idx + len(op):]]
if len(parts) != 2:
continue
field_name = parts[0].strip()
value_str = parts[1].strip()
left_val = AutoScriptEngine._resolveField(field_name, user_data)
right_val = AutoScriptEngine._resolveValue(value_str, user_data)
try:
return cmp_func(left_val, right_val)
except TypeError:
raise ValueError(
f"AutoScript 语法错误: 无法比较 "
f"'{field_name}' ({type(left_val).__name__}) "
f"'{value_str}' ({type(right_val).__name__})"
)
return False
@staticmethod
def _executeSet(
line: str,
user_data: dict
):
rest = line[3:].strip()
eq_idx = rest.find("=")
if eq_idx < 0:
return
field_name = rest[:eq_idx].strip()
value_str = rest[eq_idx + 1:].strip()
if not field_name:
return
resolved = AutoScriptEngine._resolveValue(value_str, user_data)
AutoScriptEngine._setField(field_name, resolved, user_data)
@staticmethod
def _executeOperation(
line: str,
user_data: dict
):
parts = line.split()
if len(parts) < 3:
return
field_name = parts[0].upper().strip()
op = parts[1].upper().strip()
raw_value = parts[2].strip()
field_type = AutoScriptEngine._FIELD_TYPE_MAP.get(field_name)
if not field_type:
raise ValueError(
f"AutoScript 语法错误: 未知字段 '{field_name}'"
)
try:
num_value = float(raw_value) if "." in raw_value else int(raw_value)
except (ValueError, TypeError):
raise ValueError(
f"AutoScript 语法错误: 无效操作数 '{raw_value}'"
)
if field_type == "Date":
date_str = AutoScriptEngine._resolveField(field_name, user_data)
if not date_str:
return
try:
date_obj = datetime.strptime(date_str, "%Y-%m-%d")
except (ValueError, TypeError):
return
if op == ".ADD.":
date_obj += timedelta(days=num_value)
elif op == ".SUB.":
date_obj -= timedelta(days=num_value)
else:
raise ValueError(
f"AutoScript 语法错误: Date 类型不支持操作 '{op}'"
)
AutoScriptEngine._setField(
field_name, date_obj.strftime("%Y-%m-%d"), user_data
)
elif field_type == "Time":
time_str = AutoScriptEngine._resolveField(field_name, user_data)
if not time_str:
return
try:
time_obj = datetime.strptime(time_str, "%H:%M")
except (ValueError, TypeError):
return
if op == ".ADD.":
time_obj += timedelta(hours=num_value)
elif op == ".SUB.":
time_obj -= timedelta(hours=num_value)
else:
raise ValueError(
f"AutoScript 语法错误: Time 类型不支持操作 '{op}'"
)
AutoScriptEngine._setField(
field_name, time_obj.strftime("%H:%M"), user_data
)
elif field_type in ("String", "Boolean"):
raise ValueError(
f"AutoScript 语法错误: '{field_type}' 类型字段不支持操作运算"
)
else:
raise ValueError(
f"AutoScript 语法错误: 未知字段类型 '{field_type}'"
)
def _findConditionEnd(
upper_line: str
) -> int:
"""
Find the index of the closing parenthesis that matches the
opening parenthesis in a condition expression, handling nested
parentheses and optional ``THEN`` keyword.
Args:
upper_line (str): The uppercased line text containing the
condition, e.g. ``"IF(A .BGT. B) THEN"``.
Returns:
int: Index of the matching ``)``, or ``-1`` if no match
is found.
"""
line = upper_line.rstrip()
if line.endswith(" THEN"):
line = line[:-5].rstrip()
paren_depth = 0
start_found = False
for i, ch in enumerate(line):
if ch == "(":
paren_depth += 1
start_found = True
elif ch == ")":
paren_depth -= 1
if start_found and paren_depth == 0:
return i
return -1
+46
View File
@@ -0,0 +1,46 @@
# -*- 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 managers.config.ConfigManager as ConfigManager
class ConfigUtils:
"""
Config utilities class.
"""
@staticmethod
def getAutomationConfigPaths(
) -> dict[str]:
"""
Get validated automation config paths from ConfigManager instance.
These function will validate the config paths and return the validated paths in a dict.
Returns:
dict[str]: Validated automation config paths (include user and run config paths).
"""
cfg_mgr = ConfigManager.instance() # config manager instance
config_paths = {"run": "", "user": ""}
auto_config = cfg_mgr.get(ConfigManager.ConfigType.GLOBAL, "automation", {})
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)
if paths == []:
paths.append(os.path.join(cfg_mgr.configDir(), f"{cfg_type}.json"))
if index < 0:
index = 0
if index >= len(paths):
index = len(paths) - 1
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
+42 -36
View File
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
Copyright (c) 2025 - 2026 KenanZhu. Copyright (c) 2026 KenanZhu.
All rights reserved. All rights reserved.
This software is provided "as is", without any warranty of any kind. This software is provided "as is", without any warranty of any kind.
@@ -10,41 +10,47 @@ See the LICENSE file for details.
from datetime import datetime, timedelta from datetime import datetime, timedelta
def calculateNextRepeatTime( class TimerUtils:
repeat_days: list,
hour: int,
minute: int,
second: int
) -> datetime:
""" """
Calculate the next repeat time based on repeat days and target time. Timer utilities class.
This function calculates the next execution time for a repeatable task.
If the current day is in repeat_days and the target time has not passed,
it returns today's target time. Otherwise, it finds the next matching day.
Args:
repeat_days (list): List of weekdays to repeat (0=Monday, 6=Sunday).
hour (int): Target hour (0-23).
minute (int): Target minute (0-59).
second (int): Target second (0-59).
Returns:
datetime: The next repeat execution time.
""" """
current_time = datetime.now() @staticmethod
current_weekday = current_time.weekday() def getNextTimerRepeatTime(
target_time = current_time.replace(hour=hour, minute=minute, second=second, microsecond=0) repeat_days: list[int],
if current_weekday in repeat_days: hour: int,
if target_time > current_time: minute: int,
return target_time second: int
repeat_days_sorted = sorted(repeat_days) ) -> datetime:
for day in repeat_days_sorted: """
if day > current_weekday: Calculate the next repeat time based on repeat days and target time.
days_until = day - current_weekday
next_time = target_time + timedelta(days=days_until) This function calculates the next execution time for a repeatable task.
return next_time If the current day is in repeat_days and the target time has not passed,
days_until = 7 - current_weekday + repeat_days_sorted[0] it returns today's target time. Otherwise, it finds the next matching day.
next_time = target_time + timedelta(days=days_until)
return next_time Args:
repeat_days (list[int]): List of weekdays to repeat (0=Monday, 6=Sunday).
hour (int): Target hour (0-23).
minute (int): Target minute (0-59).
second (int): Target second (0-59).
Returns:
datetime: The next repeat execution time.
"""
current_time = datetime.now()
current_weekday = current_time.weekday()
target_time = current_time.replace(hour=hour, minute=minute, second=second, microsecond=0)
if current_weekday in repeat_days:
if target_time > current_time:
return target_time
repeat_days_sorted = sorted(repeat_days)
for day in repeat_days_sorted:
if day > current_weekday:
days_until = day - current_weekday
next_time = target_time + timedelta(days=days_until)
return next_time
days_until = 7 - current_weekday + repeat_days_sorted[0]
next_time = target_time + timedelta(days=days_until)
return next_time
+2
View File
@@ -5,4 +5,6 @@
- TimerUtils: Timer utils class for the AutoLibrary project. - TimerUtils: Timer utils class for the AutoLibrary project.
- JSONReader: JSON reader class for the AutoLibrary project. - JSONReader: JSON reader class for the AutoLibrary project.
- JSONWriter: JSON writer class for the AutoLibrary project. - JSONWriter: JSON writer class for the AutoLibrary project.
- ConfigUtils: Config utils class for the AutoLibrary project.
- AutoScriptEngine: AutoScript script engine class for the AutoLibrary project.
""" """