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

Compare commits

...

45 Commits

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

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

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 13:14:27 +08:00
KenanZhu a03ab38279 refactor(autoscript): 完善 Lua 错误分类与 Date/Time 严格校验,清理死代码并补齐类型注解
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 01:02:17 +08:00
KenanZhu 4761cade26 refactor(gui): 统一资源路径前缀并更新新版 SVG Logo 2026-05-23 20:05:39 +08:00
KenanZhu 531b05651e refactor(gui): 重构更新 AutoLibrary Logo 样式为全新设计样式 2026-05-23 19:26:00 +08:00
KenanZhu 3cea7df736 refactor(gui): 编排编辑窗口适配 Lua 引擎新接口
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 18:22:49 +08:00
KenanZhu a0fd03f12f refactor(autoscript): ASEngine 迁移至 Lua 沙箱引擎,强化类型安全与异常处理
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 18:22:36 +08:00
KenanZhu 9b47886e5b fix(autoscript): SET 赋值强制强类型检查,禁止跨类型隐式转换
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 04:22:06 +08:00
KenanZhu 82738be99a feat(gui): 编辑窗口支持调试运行与动态模拟目标数据输入
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 04:21:52 +08:00
KenanZhu e097b5afc9 refactor(gui): 编排窗口简化为纯代码生成器,移除脚本解析与预检逻辑
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 04:21:40 +08:00
KenanZhu fe7453fe02 feat(gui): 编排窗口支持算术表达式解析与回显
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 00:27:59 +08:00
KenanZhu 1d4b03d162 feat(autoscript): 支持算术表达式与变量参与加减运算
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 00:27:43 +08:00
KenanZhu 4642916fd5 fix(gui): 修正编排窗口日期映射 CURRENT_DATE 误识别为前天的问题 2026-05-18 20:47:35 +08:00
KenanZhu 5800437ba2 fix(gui): 编排窗口代码生成统一使用 END IF 结束块 2026-05-18 20:43:48 +08:00
KenanZhu 23467c1d3d feat(autoscript): 支持 // 行内注释与完整注释行解析 2026-05-18 20:13:46 +08:00
KenanZhu b8c0a29c59 fix(gui): 调整定时任务对话框布局边距与间距 2026-05-18 17:59:04 +08:00
KenanZhu 87787ad3dc style(gui): 编辑器高亮配色更改为 VSCode C 风格并为布尔字面量独立配色 2026-05-18 17:59:00 +08:00
KenanZhu e800f6ece1 refactor(gui): 统一 setupUi 命名并调整按钮布局 2026-05-18 16:01:22 +08:00
KenanZhu 600a304ab8 style(gui): 规范编排对话框属性命名并消除冗余代码 2026-05-18 16:01:16 +08:00
KenanZhu c038c8005d refactor(autoscript): 公开 splitTopLevel 并导出常量,消除冗余委托与重复变量 2026-05-18 16:01:10 +08:00
KenanZhu 6cf182c8c8 refactor(gui): 编排窗口迁移至新包并移除旧的预览/编排对话框 2026-05-18 11:15:35 +08:00
KenanZhu 33c0f4414c fix(autoscript): 为异常添加行号信息并补充类型兼容性检查 2026-05-17 02:58:47 +08:00
KenanZhu 2843300cf9 refactor(autoscript): 使用观察者模式解耦解析与预检查/编排流程 2026-05-17 01:48:25 +08:00
KenanZhu 9bdc9a3de9 refactor(autoscript): 使用 ASTokenizer 和 NodeVisitor 重构解析与执行流程 2026-05-17 01:33:22 +08:00
KenanZhu 500ddd41c5 refactor(autoscript): 替换 dsl 包为 autoscript 引擎模块 2026-05-12 11:49:43 +08:00
KenanZhu 14c6db3384 refactor(config): 引入 ConfigPath 值对象消除 ConfigType/ConfigKey 的消费者 API 冗余 2026-05-10 16:14:36 +08:00
KenanZhu bbd97970a6 refactor(modules): 将 AutoScriptEngine 移至 dsl/,ConfigUtils 移至 managers/config/,修复单一职责和依赖倒置问题 2026-05-10 15:33:10 +08:00
github-actions[bot] 22d3c3462c chore(release): merge release/v1.3.0 to main [auto release commit] 2026-05-09 06:08:33 +00:00
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
62 changed files with 3566 additions and 675 deletions
+1 -1
View File
@@ -2,7 +2,7 @@
# AutoLibrary
---
![AutoLibrary Logo](./src/gui/resources/icons/AutoLibrary_128x128.ico)
![AutoLibrary Logo](./src/gui/resources/icons/AutoLibrary_Logo_128.svg)
[![GitHub stars](https://img.shields.io/github/stars/KenanZhu/AutoLibrary.svg?style=social&label=Star)](https://github.com/KenanZhu/AutoLibrary)
![License](https://img.shields.io/github/license/KenanZhu/AutoLibrary?label=license)
BIN
View File
Binary file not shown.
+2 -2
View File
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
"""
Copyright (c) 2025 - 2026 KenanZhu.
Copyright (c) 2025 KenanZhu.
All rights reserved.
This software is provided "as is", without any warranty of any kind.
@@ -22,7 +22,7 @@ def main():
app = QApplication(sys.argv)
translator = QTranslator()
if translator.load(":/res/trans/translators/qtbase_zh_CN.ts"):
if translator.load(":/res/translators/qtbase_zh_CN.ts"):
app.installTranslator(translator)
app.setStyle('Fusion')
app.setApplicationName("AutoLibrary")
+262
View File
@@ -0,0 +1,262 @@
# -*- coding: utf-8 -*-
"""
Copyright (c) 2026 KenanZhu.
All rights reserved.
This software is provided "as is", without any warranty of any kind.
You may use, modify, and distribute this file under the terms of the MIT License.
See the LICENSE file for details.
"""
from datetime import (
date,
datetime,
)
from lupa import LuaRuntime as _LuaRuntime
from autoscript._helpers import (
_TYPE_DEFAULT_VAR,
_assignPath,
_checkDateFormat,
_checkTimeFormat,
_checkType,
_cleanLuaError,
_navigatePath,
)
try:
from lupa.lua55 import LuaError as _LuaError, LuaSyntaxError as _LuaSyntaxError
except ImportError:
try:
from lupa.lua54 import LuaError as _LuaError, LuaSyntaxError as _LuaSyntaxError
except ImportError:
_LuaError = Exception
_LuaSyntaxError = Exception
__all__ = ["ASEngine"]
class ASEngine:
@staticmethod
def getCurrentDate(
) -> str:
return date.today().isoformat()
@staticmethod
def getCurrentTime(
) -> str:
return datetime.now().strftime("%H:%M")
@staticmethod
def _sandbox(
lua,
) -> None:
lua.execute("""
io = nil
require = nil
dofile = nil
loadfile = nil
load = nil
package = nil
rawget = nil
rawset = nil
rawequal = nil
getfenv = nil
setfenv = nil
debug = nil
if os then
os.execute = nil
os.exit = nil
os.getenv = nil
os.remove = nil
os.rename = nil
os.tmpname = nil
os.setlocale = nil
end
""")
@staticmethod
def _registerHelpers(
lua,
) -> None:
lua.execute("""
function date(y, m, d)
return os.time({year = y, month = m, day = d})
end
function time(h, m)
return h * 60 + m
end
function datenow()
local now = os.date("*t")
return os.time({year = now.year, month = now.month, day = now.day})
end
function timenow()
local now = os.date("*t")
return now.hour * 60 + now.min
end
function dateadd(date_val, n)
return date_val + n * 86400
end
function timeadd(time_val, n)
return (time_val + n * 60) % 1440
end
function strtodate(iso_str)
local y, m, d = iso_str:match("(%d+)-(%d+)-(%d+)")
return os.time({year = y, month = m, day = d})
end
function strtotime(hm_str)
local h, m = hm_str:match("(%d+):(%d+)")
return h * 60 + m
end
function datetostr(ts)
return os.date("%Y-%m-%d", ts)
end
function timetostr(m)
return string.format("%02d:%02d", math.floor(m / 60), m % 60)
end
""")
def __init__(
self,
targetVars: list[tuple] = None,
):
self._targetVars: dict[str, dict] = {}
self._lua = None
if targetVars:
for item in targetVars:
name, varType, keyPath = item[0], item[1], item[2]
self.addTargetVar(name, varType, keyPath)
def _getLua(
self,
):
if self._lua is None:
self._lua = _LuaRuntime(unpack_returned_tuples=True)
self._sandbox(self._lua)
self._registerHelpers(self._lua)
return self._lua
def _push(
self,
targetData: dict,
) -> None:
lua = self._getLua()
g = lua.globals()
strToDate = g["strtodate"]
strToTime = g["strtotime"]
for varName, info in self._targetVars.items():
keyPath = info["keyPath"]
vt = info["type"]
raw = _navigatePath(targetData, keyPath)
if vt == "Date":
if not isinstance(raw, str) or not raw.strip():
raise ValueError(
f"Date 类型变量 '{varName}' 对应的数据为空或不是字符串类型,"
f"请检查路径 {keyPath} 的值是否为合法的日期字符串 (YYYY-MM-DD)"
)
raw = raw.strip()
_checkDateFormat(raw, varName)
g[varName] = strToDate(raw)
elif vt == "Time":
if not isinstance(raw, str) or not raw.strip():
raise ValueError(
f"Time 类型变量 '{varName}' 对应的数据为空或不是字符串类型,"
f"请检查路径 {keyPath} 的值是否为合法的时间字符串 (HH:MM)"
)
raw = raw.strip()
_checkTimeFormat(raw, varName)
g[varName] = strToTime(raw)
else:
if raw is None:
raw = _TYPE_DEFAULT_VAR.get(vt, False)
g[varName] = raw
def _pull(
self,
targetData: dict,
) -> None:
lua = self._getLua()
g = lua.globals()
dateToStr = g["datetostr"]
timeToStr = g["timetostr"]
for varName, info in self._targetVars.items():
try:
luaVal = g[varName]
except KeyError:
continue
vt = info["type"]
if vt == "Date":
luaVal = dateToStr(luaVal)
elif vt == "Time":
luaVal = timeToStr(luaVal)
elif vt == "Float" and isinstance(luaVal, int) and not isinstance(luaVal, bool):
luaVal = float(luaVal)
_checkType(varName, vt, luaVal)
_assignPath(targetData, info["keyPath"], luaVal)
def addTargetVar(
self,
name: str,
varType: str,
keyPath: list,
) -> None:
upperName = name.upper().strip()
self._targetVars[upperName] = {
"type": varType,
"keyPath": keyPath,
}
def execute(
self,
scriptText: str,
targetData: dict,
) -> None:
if not scriptText or not scriptText.strip():
return
try:
self._push(targetData)
self._getLua().execute(scriptText)
self._pull(targetData)
except _LuaSyntaxError as e:
raise ValueError(
f"AutoScript 语法错误: {_cleanLuaError(str(e))}"
)
except _LuaError as e:
raise ValueError(
f"AutoScript 运行时错误: {_cleanLuaError(str(e))}"
)
except ValueError as e:
raise ValueError(f"AutoScript 数据错误: {e}")
except Exception as e:
raise ValueError(f"AutoScript 未知错误: {e}")
def reset(
self,
) -> None:
self._targetVars = {}
self._lua = None
+66
View File
@@ -0,0 +1,66 @@
# -*- 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 autoscript.ASEngine import ASEngine
__all__ = [
"ASEngine",
"createEngine",
"createMockTargetData",
"createAllVariablesTable",
"createTargetVarDefs",
]
_TARGET_VAR_DEFS = [
("USERNAME", "String", ["username"], "用户名"),
("USER_ENABLE", "Boolean", ["enabled"], "用户启用"),
("RESERVE_DATE", "Date", ["reserve_info", "date"], "预约日期"),
("RESERVE_BEGIN_TIME", "Time", ["reserve_info", "begin_time", "time"], "预约开始时间"),
("RESERVE_END_TIME", "Time", ["reserve_info", "end_time", "time"], "预约结束时间"),
]
_MOCK_TYPE_VALUES = {
"String": "__mock__",
"Boolean": True,
"Date": "2099-01-01",
"Time": "00:00",
"Int": 0,
"Float": 0.0,
}
def createAllVariablesTable(
) -> dict:
return {
displayName: (name, varType)
for name, varType, _, displayName in _TARGET_VAR_DEFS
}
def createTargetVarDefs(
) -> list:
return list(_TARGET_VAR_DEFS)
def createMockTargetData(
) -> dict:
data = {}
for _, varType, keyPath, _ in _TARGET_VAR_DEFS:
d = data
for key in keyPath[:-1]:
d = d.setdefault(key, {})
d[keyPath[-1]] = _MOCK_TYPE_VALUES.get(varType, "")
return data
def createEngine(
) -> ASEngine:
return ASEngine(_TARGET_VAR_DEFS)
+153
View File
@@ -0,0 +1,153 @@
# -*- coding: utf-8 -*-
"""
Copyright (c) 2026 KenanZhu.
All rights reserved.
This software is provided "as is", without any warranty of any kind.
You may use, modify, and distribute this file under the terms of the MIT License.
See the LICENSE file for details.
"""
from datetime import (
date,
datetime,
)
_TYPE_DEFAULT_VAR: dict[str, str | int | float | bool] = {
"String": "",
"Int": 0,
"Float": 0.0,
"Boolean": False,
}
def _navigatePath(
data: dict,
keyPath: list,
default=None,
):
d = data
for key in keyPath[:-1]:
d = d.get(key, {})
if not isinstance(d, dict):
return default
return d.get(keyPath[-1], default)
def _assignPath(
data: dict,
keyPath: list,
value,
) -> None:
d = data
for key in keyPath[:-1]:
d = d.setdefault(key, {})
d[keyPath[-1]] = value
def _checkDateFormat(
dateStr: str,
varName: str = "",
) -> None:
prefix = f"Date 类型变量 '{varName}'" if varName else ""
try:
date.fromisoformat(dateStr)
except ValueError:
raise ValueError(
f"{prefix}'{dateStr}' 不是合法的日期格式,"
f"应为 YYYY-MM-DD"
)
def _checkTimeFormat(
timeStr: str,
varName: str = "",
) -> None:
prefix = f"Time 类型变量 '{varName}'" if varName else ""
try:
datetime.strptime(timeStr, "%H:%M")
except ValueError:
raise ValueError(
f"{prefix}'{timeStr}' 不是合法的时间格式,"
f"应为 HH:MM"
)
def _checkType(
varName: str,
varType: str,
value,
) -> None:
if varType == "Date":
if not isinstance(value, str):
raise ValueError(
f"Date 类型变量 '{varName}' 只能接受日期字符串,"
f"不能接受 {_pyTypeToASType(value)} 类型"
)
_checkDateFormat(value, varName)
return
if varType == "Time":
if not isinstance(value, str):
raise ValueError(
f"Time 类型变量 '{varName}' 只能接受时间字符串,"
f"不能接受 {_pyTypeToASType(value)} 类型"
)
_checkTimeFormat(value, varName)
return
if varType == "Int":
if isinstance(value, bool):
raise ValueError(
f"Int 类型变量 '{varName}' 不能接受 Boolean 类型的值"
)
if not isinstance(value, int) and not (isinstance(value, float) and value == int(value)):
raise ValueError(
f"Int 类型变量 '{varName}' 不能接受 {_pyTypeToASType(value)} 类型的值"
)
return
if varType == "Float":
if isinstance(value, bool):
raise ValueError(
f"Float 类型变量 '{varName}' 不能接受 Boolean 类型的值"
)
if not isinstance(value, (int, float)):
raise ValueError(
f"Float 类型变量 '{varName}' 不能接受 {_pyTypeToASType(value)} 类型的值"
)
return
if varType == "Boolean":
if not isinstance(value, bool):
raise ValueError(
f"Boolean 类型变量 '{varName}' 不能接受 {_pyTypeToASType(value)} 类型的值"
)
return
if varType == "String":
if not isinstance(value, str):
raise ValueError(
f"String 类型变量 '{varName}' 不能接受 {_pyTypeToASType(value)} 类型的值"
)
return
def _pyTypeToASType(
value,
) -> str:
if isinstance(value, bool):
return "Boolean"
if isinstance(value, int):
return "Int"
if isinstance(value, float):
return "Float"
if isinstance(value, str):
return "String"
return "Unknown"
def _cleanLuaError(
rawMsg: str,
) -> str:
msg = rawMsg.replace('[string "<python>"]:', "").strip()
stackIdx = msg.find("stack traceback:")
if stackIdx != -1:
msg = msg[:stackIdx].strip()
return msg
+1 -2
View File
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
"""
Copyright (c) 2025 - 2026 KenanZhu.
Copyright (c) 2025 KenanZhu.
All rights reserved.
This software is provided "as is", without any warranty of any kind.
@@ -29,7 +29,6 @@ class LibOperator(MsgBase):
super().__init__(input_queue, output_queue)
def _waitResponseLoad(
self
) -> bool:
+3 -7
View File
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
"""
Copyright (c) 2025 - 2026 KenanZhu.
Copyright (c) 2025 KenanZhu.
All rights reserved.
This software is provided "as is", without any warranty of any kind.
@@ -11,7 +11,7 @@ import logging
import queue
import datetime
from managers.log.LogManager import getLogger
import managers.log.LogManager as LogManager
class MsgBase:
@@ -54,11 +54,10 @@ class MsgBase:
self._input_queue = input_queue
self._output_queue = output_queue
try:
self._logger = getLogger(self._class_name)
self._logger = LogManager.getLogger(self._class_name)
except RuntimeError:
self._logger = None
def _showMsg(
self,
msg: str
@@ -66,7 +65,6 @@ class MsgBase:
self._output_queue.put(f"[{self._class_name:<15}] >>> : {msg}")
def _showTrace(
self,
msg: str,
@@ -79,7 +77,6 @@ class MsgBase:
if self._logger and not no_log:
self._logger.log(level, msg)
def _showLog(
self,
msg: str,
@@ -89,7 +86,6 @@ class MsgBase:
if self._logger:
self._logger.log(level, msg)
def _waitMsg(
self,
timeout: float = 1.0
+1 -2
View File
@@ -1,8 +1,7 @@
"""
Base module for the AutoLibrary project.
Here are the classes and modules in this package:
- MsgBase: Base class for messages.\
- MsgBase: Base class for messages.
- LibOperator: Base class for library operators.
"""
+14 -9
View File
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
"""
Copyright (c) 2025 - 2026 KenanZhu.
Copyright (c) 2026 KenanZhu.
All rights reserved.
This software is provided "as is", without any warranty of any kind.
@@ -16,7 +16,7 @@ from managers.config.ConfigManager import instance as configInstance
from managers.driver.WebDriverManager import instance as webdriverInstance
def initializeLogManager(
def _initializeLogManager(
) -> bool:
app_dir = QStandardPaths.writableLocation(QStandardPaths.StandardLocation.AppDataLocation)
@@ -27,7 +27,7 @@ def initializeLogManager(
logInstance(log_dir)
return True
def initializeConfigManager(
def _initializeConfigManager(
) -> bool:
logger = logInstance().getLogger("AppInitializer")
@@ -49,16 +49,15 @@ def initializeConfigManager(
configInstance(new_config_dir)
return True
def initializeWebDriverManager(
def _initializeWebDriverManager(
) -> bool:
logger = logInstance().getLogger("AppInitializer")
app_dir = QStandardPaths.writableLocation(QStandardPaths.StandardLocation.AppDataLocation)
driver_dir = os.path.join(app_dir, "drivers")
logger.info("初始化驱动目录 %s", driver_dir)
if not QDir(driver_dir).exists():
logger.error("创建驱动目录 %s 失败", driver_dir)
logger.info("初始化驱动目录 %s", driver_dir)
if not QDir().mkpath(driver_dir):
logger.error("创建驱动目录 %s 失败", driver_dir)
return False
@@ -67,11 +66,17 @@ def initializeWebDriverManager(
def initializeApp(
) -> bool:
"""
Initialize the application components
if not initializeLogManager():
Order:
LogManager -> ConfigManager -> WebDriverManager
"""
if not _initializeLogManager():
return False
if not initializeConfigManager():
if not _initializeConfigManager():
return False
if not initializeWebDriverManager():
if not _initializeWebDriverManager():
return False
return True
+9 -15
View File
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
"""
Copyright (c) 2025 - 2026 KenanZhu.
Copyright (c) 2025 KenanZhu.
All rights reserved.
This software is provided "as is", without any warranty of any kind.
@@ -9,14 +9,14 @@ See the LICENSE file for details.
"""
import platform
from PySide6.QtGui import (
QIcon, QFont
)
from PySide6.QtWidgets import (
QDialog, QApplication
)
from PySide6.QtCore import (
QTimer, Qt
Qt,
QTimer
)
from PySide6.QtGui import QIcon
from PySide6.QtWidgets import (
QApplication,
QDialog
)
from gui.ALVersionInfo import (
@@ -38,12 +38,11 @@ class ALAboutDialog(QDialog, Ui_ALAboutDialog):
self.modifyUi()
self.connectSignals()
def modifyUi(
self
):
self.LogoIconLabel.setPixmap(QIcon(":/res/icon/icons/AutoLibrary_32x32.ico").pixmap(48, 48))
self.LogoIconLabel.setPixmap(QIcon(":/res/icons/AutoLibrary_Logo_64.svg").pixmap(48, 48))
info_text = self.generateAboutText()
self.AboutInfoBrowser.setHtml(info_text)
browser_font = self.AboutInfoBrowser.font()
@@ -51,14 +50,12 @@ class ALAboutDialog(QDialog, Ui_ALAboutDialog):
self.AboutInfoBrowser.setFont(browser_font)
self.AboutInfoBrowser.setTextInteractionFlags(Qt.TextBrowserInteraction)
def connectSignals(
self
):
self.CopyButton.clicked.connect(self.copyAboutInfo)
def generateAboutText(
self
) -> str:
@@ -91,7 +88,6 @@ GitHub: <a href="https://www.github.com/KenanZhu" style="text-decoration: none;"
"""
return about_text
def getOSInfo(
self
):
@@ -123,7 +119,6 @@ GitHub: <a href="https://www.github.com/KenanZhu" style="text-decoration: none;"
'architecture': architecture
}
def getQtVersion(
self
):
@@ -134,7 +129,6 @@ GitHub: <a href="https://www.github.com/KenanZhu" style="text-decoration: none;"
except:
return "Unknown"
def copyAboutInfo(
self
):
+669
View File
@@ -0,0 +1,669 @@
# -*- coding: utf-8 -*-
"""
Copyright (c) 2026 KenanZhu.
All rights reserved.
This software is provided "as is", without any warranty of any kind.
You may use, modify, and distribute this file under the terms of the MIT License.
See the LICENSE file for details.
"""
from copy import deepcopy
from PySide6.QtCore import (
QDate,
QSize,
Qt,
QTime,
QTimer,
Slot
)
from PySide6.QtGui import (
QColor,
QFont,
QIcon,
QSyntaxHighlighter,
QTextCharFormat,
)
from PySide6.QtWidgets import (
QApplication,
QComboBox,
QDateEdit,
QDialog,
QDialogButtonBox,
QDoubleSpinBox,
QFormLayout,
QFrame,
QGridLayout,
QGroupBox,
QHBoxLayout,
QHeaderView,
QLabel,
QLineEdit,
QMessageBox,
QPlainTextEdit,
QPushButton,
QSpinBox,
QSplitter,
QStyle,
QTabWidget,
QTableWidget,
QTableWidgetItem,
QTimeEdit,
QVBoxLayout,
QWidget,
)
from autoscript import (
createAllVariablesTable,
createMockTargetData,
createTargetVarDefs,
createEngine,
)
class ALScriptHighlighter(QSyntaxHighlighter):
"""
Syntax highlighter for Lua-based AutoScript.
"""
def __init__(
self,
parent = None
):
super().__init__(parent)
self._rules = []
keywordFmt = QTextCharFormat()
keywordFmt.setForeground(QColor("#569CD6"))
keywordFmt.setFontWeight(QFont.Weight.Bold)
for kw in [
"if", "elseif", "else", "end", "then",
"and", "or", "not",
"local", "function", "return", "nil",
]:
self._rules.append((r"\b" + kw + r"\b", keywordFmt))
boolFmt = QTextCharFormat()
boolFmt.setForeground(QColor("#4FC1FF"))
boolFmt.setFontWeight(QFont.Weight.Bold)
self._rules.append((r"\btrue\b", boolFmt))
self._rules.append((r"\bfalse\b", boolFmt))
cmpFmt = QTextCharFormat()
cmpFmt.setForeground(QColor("#C586C0"))
cmpFmt.setFontWeight(QFont.Weight.Normal)
for op in [r"==", r"~=", r">=", r"<=", r">", r"<"]:
self._rules.append((op, cmpFmt))
arithFmt = QTextCharFormat()
arithFmt.setForeground(QColor("#C586C0"))
arithFmt.setFontWeight(QFont.Weight.Normal)
for op in [r"\+", r"-", r"\*", r"/", r"\.\."]:
self._rules.append((op, arithFmt))
funcFmt = QTextCharFormat()
funcFmt.setForeground(QColor("#DCDCAA"))
funcFmt.setFontWeight(QFont.Weight.Normal)
for fn in [ "time", "date", "datenow", "timenow", "dateadd", "timeadd"]:
self._rules.append((r"\b" + fn + r"\b", funcFmt))
varFmt = QTextCharFormat()
varFmt.setForeground(QColor("#9CDCFE"))
varFmt.setFontWeight(QFont.Weight.Normal)
var_names = [name for _, (name, _) in createAllVariablesTable().items()]
for var in var_names:
self._rules.append((r"\b" + var + r"\b", varFmt))
strFmt = QTextCharFormat()
strFmt.setForeground(QColor("#CE9178"))
strFmt.setFontWeight(QFont.Weight.Normal)
self._rules.append((r'"[^"]*"', strFmt))
self._rules.append((r"'[^']*'", strFmt))
numFmt = QTextCharFormat()
numFmt.setForeground(QColor("#B5CEA8"))
numFmt.setFontWeight(QFont.Weight.Normal)
self._rules.append((r"\b\d+(?:\.\d+)?\b", numFmt))
commentFmt = QTextCharFormat()
commentFmt.setForeground(QColor("#6A9955"))
commentFmt.setFontItalic(True)
self._rules.append((r"--[^\n]*", commentFmt))
def highlightBlock(
self,
text
):
import re
for pattern, fmt in self._rules:
for match in re.finditer(pattern, text, re.IGNORECASE):
start = match.start()
length = match.end() - match.start()
self.setFormat(start, length, fmt)
class _DebugResultDialog(QDialog):
def __init__(
self,
changes: list,
parent = None
):
super().__init__(parent)
self.setWindowTitle("调试运行结果 - AutoLibrary")
self.setMinimumSize(600, 200)
layout = QVBoxLayout(self)
table = QTableWidget(len(changes), 3)
table.setHorizontalHeaderLabels(["目标变量", "原始数据", "运行后数据"])
table.horizontalHeader().setStretchLastSection(True)
table.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch)
table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers)
for row, (display_name, name, var_type, before_val, after_val) in enumerate(changes):
label = f"{display_name}: {name}({var_type})"
table.setItem(row, 0, QTableWidgetItem(label))
table.setItem(row, 1, QTableWidgetItem(str(before_val)))
table.setItem(row, 2, QTableWidgetItem(str(after_val)))
layout.addWidget(table)
btnBox = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok)
btnBox.button(QDialogButtonBox.StandardButton.Ok).setText("确定")
btnBox.accepted.connect(self.accept)
layout.addWidget(btnBox)
class _TabToSpacesEditor(QPlainTextEdit):
def keyPressEvent(
self,
event
):
if event.key() == Qt.Key.Key_Tab:
self.insertPlainText(" ")
return
super().keyPressEvent(event)
class ALAutoScriptEditDialog(QDialog):
def __init__(
self,
parent = None,
script: str = "",
mockData: dict = None
):
super().__init__(parent)
self._fontSize = 21
self._mockWidgets = {}
self.setupUi()
self.connectSignals()
self.textEdit.setPlainText(script)
self._highlighter = ALScriptHighlighter(
self.textEdit.document()
)
if mockData:
self.setMockData(mockData)
def setupUi(
self
):
self.setWindowTitle("AutoScript 编辑 - AutoLibrary")
self.setMinimumSize(660, 600)
layout = QVBoxLayout(self)
layout.setSpacing(3)
layout.setContentsMargins(3, 3, 3, 3)
toolbarLayout = QHBoxLayout()
self.zoomInBtn = QPushButton("")
self.zoomInBtn.setFixedSize(25, 25)
self.zoomOutBtn = QPushButton("")
self.zoomOutBtn.setFixedSize(25, 25)
self.zoomResetBtn = QPushButton("")
self.zoomResetBtn.setIcon(QIcon(":/res/icons/Reset.svg"))
self.zoomResetBtn.setIconSize(QSize(20, 20))
self.zoomResetBtn.setFixedSize(25, 25)
self.zoomResetBtn.setToolTip("重置缩放")
self.zoomLabel = QLabel(f"{self._fontSize}px")
self.zoomLabel.setFixedHeight(25)
self.orchBtn = QPushButton("编排")
self.orchBtn.setFixedHeight(25)
self.orchBtn.setToolTip("可视化生成 AutoScript 代码并插入到光标位置")
toolbarLayout.addWidget(self.orchBtn)
self.debugBtn = QPushButton("▶ 调试运行")
self.debugBtn.setFixedHeight(25)
self.debugBtn.setToolTip("使用右侧模拟数据执行脚本,查看目标变量变化")
toolbarLayout.addWidget(self.debugBtn)
sep = QFrame()
sep.setFrameShape(QFrame.Shape.VLine)
sep.setFrameShadow(QFrame.Shadow.Sunken)
sep.setFixedWidth(1)
toolbarLayout.addWidget(sep)
toolbarLayout.addWidget(self.zoomInBtn)
toolbarLayout.addWidget(self.zoomOutBtn)
toolbarLayout.addWidget(self.zoomResetBtn)
toolbarLayout.addWidget(self.zoomLabel)
toolbarLayout.addStretch()
self.copyBtn = QPushButton("")
self.copyBtn.setIcon(QIcon(":/res/icons/Copy.svg"))
self.copyBtn.setIconSize(QSize(20, 20))
self.copyBtn.setFixedSize(25, 25)
self.copyBtn.setToolTip("复制脚本")
toolbarLayout.addWidget(self.copyBtn)
layout.addLayout(toolbarLayout)
self.textEdit = _TabToSpacesEditor(self)
self.textEdit.setTabStopDistance(40)
self.textEdit.setLineWrapMode(
QPlainTextEdit.LineWrapMode.NoWrap
)
self.textEdit.setStyleSheet(
"QPlainTextEdit {"
" font-family: 'Courier New', 'Consolas', monospace;"
f" font-size: {self._fontSize}px;"
"}"
)
layout.addWidget(self.textEdit)
self.createButtonPanel(layout)
self.btnBox = QDialogButtonBox(
QDialogButtonBox.StandardButton.Ok |
QDialogButtonBox.StandardButton.Cancel
)
self.btnBox.button(QDialogButtonBox.StandardButton.Ok).setText("确定")
self.btnBox.button(QDialogButtonBox.StandardButton.Cancel).setText("取消")
layout.addWidget(self.btnBox)
def createButtonPanel(
self,
parent_layout
):
splitter = QSplitter(Qt.Orientation.Horizontal)
tabWidget = QTabWidget()
tabWidget.setMaximumHeight(150)
basicWidget = QWidget()
basicLayout = QGridLayout(basicWidget)
basicLayout.setSpacing(4)
basicLayout.setContentsMargins(4, 4, 4, 4)
basicLayout.setAlignment(Qt.AlignLeft | Qt.AlignTop)
controlButtons = [
("如果 (if...)", "if then\n \nend"),
("再如果 (elseif...)", "elseif then\n "),
("否则 (else)", "else"),
("结束 (end)", "end"),
("跳过 (pass)", "-- pass"),
]
self.addButtonsToGrid(basicLayout, controlButtons, 0, 0, 3)
assignButtons = [
("赋值 (=)", " = "),
]
self.addButtonsToGrid(basicLayout, assignButtons, 1, 2, 3)
tabWidget.addTab(basicWidget, "基本语法")
operatorWidget = QWidget()
operatorLayout = QGridLayout(operatorWidget)
operatorLayout.setSpacing(4)
operatorLayout.setContentsMargins(4, 4, 4, 4)
operatorLayout.setAlignment(Qt.AlignLeft | Qt.AlignTop)
arithmeticButtons = [
("加 (+)", " + "),
("减 (-)", " - "),
]
self.addButtonsToGrid(operatorLayout, arithmeticButtons, 0, 0, 3)
compareButtons = [
("等于 (==)", " == "),
("不等于 (~=)", " ~= "),
("大于 (>)", " > "),
("小于 (<)", " < "),
("大于等于 (>=)", " >= "),
("小于等于 (<=)", " <= "),
]
self.addButtonsToGrid(operatorLayout, compareButtons, 1, 0, 3)
logic_buttons = [
("且 (and)", " and "),
("或 (or)", " or "),
]
self.addButtonsToGrid(operatorLayout, logic_buttons, 2, 0, 3)
tabWidget.addTab(operatorWidget, "运算符")
literalWidget = QWidget()
literalLayout = QGridLayout(literalWidget)
literalLayout.setSpacing(4)
literalLayout.setContentsMargins(4, 4, 4, 4)
literalLayout.setAlignment(Qt.AlignLeft | Qt.AlignTop)
bool_buttons = [
("真 (true)", "true"),
("假 (false)", "false"),
]
self.addButtonsToGrid(literalLayout, bool_buttons, 0, 0, 3)
dateTimeButtons = [
("日期", '"2099-01-01"'),
("时间", '"00:00"'),
]
self.addButtonsToGrid(literalLayout, dateTimeButtons, 1, 0, 3)
hintButtons = [
("字符串", '"请输入文本"'),
("数字", "123"),
("注释", "-- 请输入注释"),
]
self.addButtonsToGrid(literalLayout, hintButtons, 2, 0, 3)
tabWidget.addTab(literalWidget, "字面量")
varWidget = QWidget()
varLayout = QGridLayout(varWidget)
varLayout.setSpacing(4)
varLayout.setContentsMargins(4, 4, 4, 4)
varLayout.setAlignment(Qt.AlignLeft | Qt.AlignTop)
varButtons = [
(display_name, name) for display_name, (name, _) in createAllVariablesTable().items()
]
self.addButtonsToGrid(varLayout, varButtons, 0, 0, 3)
tabWidget.addTab(varWidget, "变量")
funcWidget = QWidget()
funcLayout = QGridLayout(funcWidget)
funcLayout.setSpacing(4)
funcLayout.setContentsMargins(4, 4, 4, 4)
funcLayout.setAlignment(Qt.AlignLeft | Qt.AlignTop)
funcButtons = [
("datenow()", "datenow()", "返回当前日期的 Unix 时间戳"),
("timenow()", "timenow()", "返回当前时间在一天中的分钟数"),
("dateadd(d, n)", "dateadd(, )", "日期偏移: dateadd(日期时间戳, 天数)"),
("timeadd(t, n)", "timeadd(, )", "时间偏移: timeadd(分钟数, 分钟数)"),
]
for i, (text, template, tooltip) in enumerate(funcButtons):
btn = QPushButton(text)
btn.setProperty("template", template)
btn.clicked.connect(self.insertTemplate)
btn.setFixedWidth(100)
btn.setFixedHeight(25)
btn.setToolTip(tooltip)
funcLayout.addWidget(btn, i // 2, i % 2)
tabWidget.addTab(funcWidget, "工具函数")
mockPanel = self.createMockPanel()
mockPanel.setMinimumWidth(260)
splitter.addWidget(tabWidget)
splitter.addWidget(mockPanel)
splitter.setStretchFactor(0, 1)
splitter.setStretchFactor(1, 1)
splitter.setSizes([530, 530])
parent_layout.addWidget(splitter)
def addButtonsToGrid(
self,
grid_layout,
buttons,
start_row,
start_col,
max_columns
):
col = start_col
row = start_row
for btn_text, template in buttons:
btn = QPushButton(btn_text)
btn.setProperty("template", template)
btn.clicked.connect(self.insertTemplate)
btn.setFixedWidth(100)
btn.setFixedHeight(25)
btn.setToolTip(f"插入: {template}")
grid_layout.addWidget(btn, row, col)
col += 1
if col >= start_col + max_columns:
col = start_col
row += 1
def createMockPanel(
self
) -> QGroupBox:
group = QGroupBox("模拟目标数据")
form = QFormLayout(group)
form.setSpacing(4)
form.setContentsMargins(5, 10, 5, 5)
self._mockWidgets = {}
mockData = createMockTargetData()
for name, var_type, key_path, display_name in createTargetVarDefs():
d = mockData
for key in key_path:
d = d[key]
default = d
widget = self.makeMockInput(var_type, default)
label = QLabel(f"{display_name}: {name}({var_type})")
form.addRow(label, widget)
self._mockWidgets[name] = (widget, var_type, key_path)
return group
def makeMockInput(
self,
var_type: str,
default
) -> QWidget:
if var_type == "String":
w = QLineEdit()
w.setText(str(default))
return w
if var_type == "Boolean":
w = QComboBox()
w.addItems(["", ""])
w.setCurrentIndex(0 if default else 1)
return w
if var_type == "Date":
w = QDateEdit()
w.setCalendarPopup(True)
w.setDisplayFormat("yyyy-MM-dd")
w.setDate(QDate.fromString(str(default), "yyyy-MM-dd"))
return w
if var_type == "Time":
w = QTimeEdit()
w.setDisplayFormat("HH:mm")
w.setTime(QTime.fromString(str(default), "HH:mm"))
return w
if var_type == "Int":
w = QSpinBox()
w.setMinimum(-999999)
w.setMaximum(999999)
w.setValue(int(default) if default else 0)
return w
if var_type == "Float":
w = QDoubleSpinBox()
w.setMinimum(-999999.0)
w.setMaximum(999999.0)
w.setDecimals(2)
w.setValue(float(default) if default else 0.0)
return w
w = QLineEdit()
w.setText(str(default))
return w
def getMockData(
self
) -> dict:
data = {}
for name, var_type, key_path, display_name in createTargetVarDefs():
widget, _, _ = self._mockWidgets[name]
value = self.getMockValue(widget, var_type)
d = data
for key in key_path[:-1]:
d = d.setdefault(key, {})
d[key_path[-1]] = value
return data
def setMockData(
self,
data: dict
):
if not data:
return
for name, var_type, key_path, display_name in createTargetVarDefs():
d = data
try:
for key in key_path:
d = d[key]
except (KeyError, TypeError):
continue
widget, _, _ = self._mockWidgets[name]
self.setMockValue(widget, var_type, d)
def getMockValue(
self,
widget: QWidget,
var_type: str
):
if var_type == "Boolean":
return widget.currentIndex() == 0
if var_type == "Date":
return widget.date().toString("yyyy-MM-dd")
if var_type == "Time":
return widget.time().toString("HH:mm")
if var_type == "Int":
return widget.value()
if var_type == "Float":
return widget.value()
return widget.text()
def setMockValue(
self,
widget: QWidget,
var_type: str,
value
):
if var_type == "Boolean":
widget.setCurrentIndex(0 if value else 1)
elif var_type == "Date":
widget.setDate(QDate.fromString(str(value), "yyyy-MM-dd"))
elif var_type == "Time":
widget.setTime(QTime.fromString(str(value), "HH:mm"))
elif var_type == "Int":
widget.setValue(int(value))
elif var_type == "Float":
widget.setValue(float(value))
else:
widget.setText(str(value))
def connectSignals(
self
):
self.btnBox.accepted.connect(self.accept)
self.btnBox.rejected.connect(self.reject)
self.orchBtn.clicked.connect(self.onOpenOrchDialog)
self.debugBtn.clicked.connect(self.onDebugRun)
self.zoomInBtn.clicked.connect(self.onZoomIn)
self.zoomOutBtn.clicked.connect(self.onZoomOut)
self.zoomResetBtn.clicked.connect(self.onZoomReset)
self.copyBtn.clicked.connect(self.onCopy)
def getScript(
self
) -> str:
return self.textEdit.toPlainText()
def updateFontSize(
self
):
self.textEdit.setStyleSheet(
"QPlainTextEdit {"
" font-family: 'Courier New', 'Consolas', monospace;"
f" font-size: {self._fontSize}px;"
"}"
)
self.zoomLabel.setText(f"{self._fontSize}px")
@Slot()
def insertTemplate(
self
):
btn = self.sender()
if not isinstance(btn, QPushButton):
return
template = btn.property("template")
if not template:
return
cursor = self.textEdit.textCursor()
cursor.insertText(template)
@Slot()
def onZoomIn(
self
):
self._fontSize = min(self._fontSize + 2, 40)
self.updateFontSize()
@Slot()
def onZoomOut(
self
):
self._fontSize = max(self._fontSize - 2, 8)
self.updateFontSize()
@Slot()
def onZoomReset(
self
):
self._fontSize = 21
self.updateFontSize()
@Slot()
def onCopy(
self
):
clipboard = QApplication.clipboard()
clipboard.setText(self.textEdit.toPlainText())
self.copyBtn.setEnabled(False)
QTimer.singleShot(2000, lambda: (
self.copyBtn.setEnabled(True)
))
@Slot()
def onOpenOrchDialog(
self
):
from gui.ALAutoScriptOrchDialog import ALAutoScriptOrchDialog
dlg = ALAutoScriptOrchDialog(self)
if dlg.exec() == QDialog.DialogCode.Accepted:
script = dlg.getScript()
if script:
cursor = self.textEdit.textCursor()
cursor.insertText(script)
dlg.deleteLater()
@Slot()
def onDebugRun(
self
):
script = self.textEdit.toPlainText().strip()
if not script:
QMessageBox.warning(self, "提示", "脚本内容为空。")
return
target_data = self.getMockData()
before = deepcopy(target_data)
try:
engine = createEngine()
engine.execute(script, target_data)
except ValueError as e:
QMessageBox.warning(self, "运行错误", str(e))
return
changes = []
for name, var_type, key_path, display_name in createTargetVarDefs():
before_val = before
after_val = target_data
try:
for key in key_path:
before_val = before_val[key]
after_val = after_val[key]
except (KeyError, TypeError):
continue
if before_val != after_val:
changes.append((display_name, name, var_type, before_val, after_val))
if not changes:
QMessageBox.information(self, "调试运行", "目标变量未发生变化。")
return
dlg = _DebugResultDialog(changes, self)
dlg.exec()
dlg.deleteLater()
@@ -0,0 +1,3 @@
from gui.ALAutoScriptOrchDialog._dialog import ALAutoScriptOrchDialog
__all__ = ["ALAutoScriptOrchDialog"]
+266
View File
@@ -0,0 +1,266 @@
# -*- coding: utf-8 -*-
"""
Copyright (c) 2026 KenanZhu.
All rights reserved.
This software is provided "as is", without any warranty of any kind.
You may use, modify, and distribute this file under the terms of the MIT License.
See the LICENSE file for details.
"""
"""
Conditional block widget for the AutoScript orchestration dialog.
"""
from PySide6.QtCore import Slot
from PySide6.QtWidgets import (
QComboBox,
QGroupBox,
QHBoxLayout,
QLabel,
QPushButton,
QSizePolicy,
QVBoxLayout,
QWidget,
)
from gui.ALAutoScriptOrchDialog._widgets import (
ActionStepFrame,
ConditionRowFrame,
)
class ConditionalBlock(QGroupBox):
def __init__(
self,
blockIndex: int,
varMgr = None,
parent = None
):
super().__init__(parent)
self.blockIndex = blockIndex
self._varMgr = varMgr
self._actionWidgets = []
self._conditionRows = []
self.setupUi()
self.connectSignals()
self.addInitialConditionRow()
def setupUi(
self
):
self.setUpdatesEnabled(False)
self.setStyleSheet(
"QGroupBox { font-weight: bold; border: 1px solid #ccc; "
"margin-top: 5px; padding-top: 5px; }"
)
self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
mainLayout = QVBoxLayout(self)
mainLayout.setSpacing(6)
mainLayout.setContentsMargins(8, 8, 8, 8)
headerLayout = QHBoxLayout()
headerLayout.setSpacing(8)
self.typeCombo = QComboBox(self)
self.typeCombo.addItem("IF", "IF")
self.typeCombo.addItem("ELSE IF", "ELSE IF")
self.typeCombo.addItem("ELSE", "ELSE")
self.typeCombo.setFixedHeight(25)
if self.blockIndex == 0:
self.typeCombo.setEnabled(False)
headerLayout.addWidget(QLabel("类型:", self))
headerLayout.addWidget(self.typeCombo)
headerLayout.addStretch()
self.deleteBlockBtn = QPushButton("删除此块", self)
self.deleteBlockBtn.setStyleSheet("color: red;")
self.deleteBlockBtn.setFixedHeight(25)
headerLayout.addWidget(self.deleteBlockBtn)
mainLayout.addLayout(headerLayout)
self.conditionWidget = QWidget(self)
self.conditionWidget.setSizePolicy(
QSizePolicy.Preferred, QSizePolicy.Preferred
)
condLayout = QVBoxLayout(self.conditionWidget)
condLayout.setContentsMargins(4, 4, 4, 4)
condLayout.setSpacing(6)
self.condRowsLayout = QVBoxLayout()
self.condRowsLayout.setSpacing(4)
condLayout.addLayout(self.condRowsLayout)
self.addCondBtn = QPushButton("+ 添加条件", self.conditionWidget)
self.addCondBtn.setFixedHeight(25)
condLayout.addWidget(self.addCondBtn)
mainLayout.addWidget(self.conditionWidget)
self.actionLabel = QLabel("执行步骤:", self)
self.actionLabel.setFixedHeight(25)
mainLayout.addWidget(self.actionLabel)
self.actionsLayout = QVBoxLayout()
self.actionsLayout.setSpacing(4)
mainLayout.addLayout(self.actionsLayout)
self.addActionBtn = QPushButton("+ 添加执行步骤", self)
self.addActionBtn.setFixedHeight(25)
mainLayout.addWidget(self.addActionBtn)
self.setUpdatesEnabled(True)
def connectSignals(
self
):
self.typeCombo.currentIndexChanged.connect(self.onTypeChanged)
self.addCondBtn.clicked.connect(self.addConditionRow)
self.addActionBtn.clicked.connect(self.addActionStep)
def addInitialConditionRow(
self
):
row = ConditionRowFrame(
self._varMgr, self.blockIndex,
isFirst=True, parent=self
)
self._conditionRows.append(row)
self.condRowsLayout.addWidget(row)
def addConditionRow(
self
):
row = ConditionRowFrame(
self._varMgr, self.blockIndex,
isFirst=False, parent=self
)
row.deleteBtn.clicked.connect(lambda: self.removeConditionRow(row))
self._conditionRows.append(row)
self.condRowsLayout.addWidget(row)
def removeConditionRow(
self,
row: ConditionRowFrame
):
if row in self._conditionRows and len(self._conditionRows) > 1:
self._conditionRows.remove(row)
self.condRowsLayout.removeWidget(row)
row.hide()
row.deleteLater()
def addActionStep(
self
):
step = ActionStepFrame(self._varMgr, self.blockIndex, parent=self)
step.deleteBtn.clicked.connect(lambda: self.removeActionStep(step))
self._actionWidgets.append(step)
self.actionsLayout.addWidget(step)
def removeActionStep(
self,
step: ActionStepFrame
):
if step in self._actionWidgets:
self._actionWidgets.remove(step)
self.actionsLayout.removeWidget(step)
step.hide()
step.deleteLater()
def getBlockType(
self
) -> str:
return self.typeCombo.currentData()
def getConditionRows(
self
):
return list(self._conditionRows)
def getActionSteps(
self
):
return list(self._actionWidgets)
def countActionSteps(
self
) -> int:
return len(self._actionWidgets)
def toScript(
self
) -> list:
"""
Generate Lua script lines for this conditional block.
"""
blockType = self.getBlockType()
lines = []
if blockType in ("IF", "ELSE IF"):
condTexts = [
r.toScript() for r in self._conditionRows if r.toScript()
]
if not condTexts:
condTexts = ["true"]
if len(condTexts) == 1:
combined = condTexts[0]
else:
parts = []
for i, ct in enumerate(condTexts):
if i > 0:
logic = self._conditionRows[i].getLogic() or "and"
parts.append(f" {logic} ")
parts.append(f"({ct})")
combined = "".join(parts)
if blockType == "IF":
lines.append(f"if {combined} then")
else:
lines.append(f"elseif {combined} then")
else:
lines.append("else")
for step in self._actionWidgets:
scriptLine = step.toScript()
if scriptLine:
lines.append(scriptLine)
return lines
def refreshVarCombos(
self
):
for row in self._conditionRows:
row.refreshVarCombos()
for step in self._actionWidgets:
step.refreshVarCombos()
def setPrevBlockType(
self,
prevType: str | None
):
model = self.typeCombo.model()
if model is None:
return
for data in ("ELSE IF", "ELSE"):
idx = self.typeCombo.findData(data)
if idx < 0:
continue
item = model.item(idx)
shouldEnable = prevType != "ELSE"
item.setEnabled(shouldEnable)
if prevType == "ELSE" and self.typeCombo.currentData() in ("ELSE IF", "ELSE"):
self.typeCombo.setCurrentIndex(0)
@Slot(int)
def onTypeChanged(
self,
_idx
):
isCond = self.typeCombo.currentData() in ("IF", "ELSE IF")
self.conditionWidget.setVisible(isCond)
self.actionLabel.setText(
"执行步骤:" if isCond else "ELSE 执行步骤:"
)
+164
View File
@@ -0,0 +1,164 @@
# -*- coding: utf-8 -*-
"""
Copyright (c) 2026 KenanZhu.
All rights reserved.
This software is provided "as is", without any warranty of any kind.
You may use, modify, and distribute this file under the terms of the MIT License.
See the LICENSE file for details.
"""
"""
Orchestration dialog for visually composing AutoScript scripts.
"""
from PySide6.QtCore import Slot
from PySide6.QtWidgets import (
QDialog,
QDialogButtonBox,
QFrame,
QMessageBox,
QPushButton,
QScrollArea,
QVBoxLayout,
QWidget,
)
from gui.ALAutoScriptOrchDialog._helpers import VariableManager
from gui.ALAutoScriptOrchDialog._blocks import ConditionalBlock
class ALAutoScriptOrchDialog(QDialog):
def __init__(
self,
parent = None
):
super().__init__(parent)
self._blocks = []
self._varMgr = VariableManager(self)
self.setupUi()
self.connectSignals()
self.addBlock()
self.scrollLayout.addStretch()
def setupUi(
self
):
self.setWindowTitle("AutoScript 指令编排 - AutoLibrary")
self.setMinimumSize(640, 600)
self.setModal(True)
mainLayout = QVBoxLayout(self)
scroll = QScrollArea()
scroll.setWidgetResizable(True)
scroll.setFrameShape(QFrame.NoFrame)
scrollContent = QWidget()
self.scrollLayout = QVBoxLayout(scrollContent)
self.scrollLayout.setSpacing(5)
scroll.setWidget(scrollContent)
mainLayout.addWidget(scroll)
self.addBlockBtn = QPushButton("+ 添加判断块")
self.addBlockBtn.setFixedHeight(25)
mainLayout.addWidget(self.addBlockBtn)
self.btnBox = QDialogButtonBox(
QDialogButtonBox.StandardButton.Ok |
QDialogButtonBox.StandardButton.Cancel
)
self.btnBox.button(QDialogButtonBox.StandardButton.Ok).setText("确定")
self.btnBox.button(QDialogButtonBox.StandardButton.Cancel).setText("取消")
mainLayout.addWidget(self.btnBox)
def connectSignals(
self
):
self.btnBox.accepted.connect(self.onAccept)
self.btnBox.rejected.connect(self.reject)
self.addBlockBtn.clicked.connect(self.addBlock)
def updateBlockTypeRestrictions(
self
):
prevType = None
for block in self._blocks:
block.setPrevBlockType(prevType)
prevType = block.getBlockType()
def addBlock(
self
):
block = ConditionalBlock(
len(self._blocks), self._varMgr, parent=self
)
block.deleteBlockBtn.clicked.connect(lambda: self.removeBlock(block))
block.typeCombo.currentIndexChanged.connect(self.updateBlockTypeRestrictions)
block.addActionStep()
self._blocks.append(block)
self.updateBlockTypeRestrictions()
if self.scrollLayout.count() > 0:
lastItem = self.scrollLayout.itemAt(
self.scrollLayout.count() - 1
)
if lastItem and lastItem.spacerItem():
self.scrollLayout.insertWidget(
self.scrollLayout.count() - 1, block
)
return
self.scrollLayout.addWidget(block)
def removeBlock(
self,
block: ConditionalBlock
):
if len(self._blocks) <= 1:
QMessageBox.information(self, "提示", "至少保留一个判断块。")
return
if block in self._blocks:
self._blocks.remove(block)
self.scrollLayout.removeWidget(block)
block.hide()
block.deleteLater()
for i, blk in enumerate(self._blocks):
blk.blockIndex = i
if i == 0:
blk.typeCombo.setEnabled(False)
blk.typeCombo.setCurrentIndex(0)
else:
blk.typeCombo.setEnabled(True)
blk.refreshVarCombos()
self.updateBlockTypeRestrictions()
def getScript(
self
) -> str:
"""
Generate the complete Lua script from all blocks.
"""
parts = []
prevType = None
for block in self._blocks:
blockType = block.getBlockType()
if blockType == "IF" and prevType is not None:
parts.append("end")
lines = block.toScript()
parts.extend(lines)
prevType = blockType
if self._blocks and self._blocks[0].getBlockType() == "IF":
parts.append("end")
return "\n".join(parts)
@Slot()
def onAccept(
self
):
script = self.getScript().strip()
if not script:
QMessageBox.warning(self, "提示", "脚本内容为空,请添加至少一个操作步骤。")
return
self.accept()
+516
View File
@@ -0,0 +1,516 @@
# -*- coding: utf-8 -*-
"""
Copyright (c) 2026 KenanZhu.
All rights reserved.
This software is provided "as is", without any warranty of any kind.
You may use, modify, and distribute this file under the terms of the MIT License.
See the LICENSE file for details.
"""
"""
Helper utilities and constants for the AutoScript orchestration dialog.
"""
import re
from PySide6.QtCore import QObject
from PySide6.QtWidgets import (
QComboBox,
QDateEdit,
QDoubleSpinBox,
QHBoxLayout,
QLabel,
QLineEdit,
QSizePolicy,
QSpinBox,
QStackedWidget,
QTimeEdit,
QWidget,
)
from autoscript import createAllVariablesTable
VARTYPE_INFOS = [
# varType, isArithType
("String", False),
("Int", True),
("Float", True),
("Boolean", False),
("Date", True),
("Time", True),
]
def getTypeOrder(
) -> list:
return [t for t, _ in VARTYPE_INFOS]
def getArithType(
varType: str
) -> bool:
for t, a in VARTYPE_INFOS:
if t == varType:
return a
def getPresetVars(
) -> list:
return [
{"name": name.upper(), "type": vtype, "display": display}
for display, (name, vtype) in createAllVariablesTable().items()
]
COMPARE_OPTIONS = [
("等于", "=="),
("不等于", "~="),
("大于", ">"),
("小于", "<"),
("大于等于", ">="),
("小于等于", "<="),
]
LOGIC_OPTIONS = [
("并且 (and)", "and"),
("或者 (or)", "or"),
]
ACTION_OPTIONS = [
("设置为", "set"),
("增加", "add"),
("减少", "sub"),
]
DATE_OPTIONS = [
("前天", "day_before_yesterday"),
("昨天", "yesterday"),
("今天", "today"),
("明天", "tomorrow"),
("后天", "day_after_tomorrow")
]
DATE_OFFSET_OPTIONS = [
("", "days"),
("", "weeks"),
# NOTE: "月" and "年" use fixed day counts (30 / 365), not calendar months/years,
# because date_add() works with second-level offsets (n * 86400).
("", "months"),
("", "years"),
]
class _DateInputContainer(QWidget):
def __init__(
self,
parent = None
):
super().__init__(parent)
self.setupUi()
def setupUi(
self
):
layout = QHBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(4)
self._modeCombo = QComboBox(self)
self._modeCombo.addItem("相对日期", "relative")
self._modeCombo.addItem("绝对日期", "absolute")
self._modeCombo.setFixedHeight(25)
self._stack = QStackedWidget(self)
self._relCombo = QComboBox(self)
for display, data in DATE_OPTIONS:
self._relCombo.addItem(display, data)
self._relCombo.setFixedHeight(25)
self._stack.addWidget(self._relCombo)
self._dateEdit = QDateEdit(self)
self._dateEdit.setDisplayFormat("yyyy-MM-dd")
self._dateEdit.setCalendarPopup(True)
self._dateEdit.setFixedHeight(25)
self._stack.addWidget(self._dateEdit)
self._modeCombo.currentIndexChanged.connect(
lambda i: self._stack.setCurrentIndex(i)
)
layout.addWidget(self._modeCombo)
layout.addWidget(self._stack)
layout.addStretch()
def getValue(
self
) -> str:
mode = self._modeCombo.currentData()
if mode == "relative":
return self._relCombo.currentText()
return self._dateEdit.date().toString("yyyy-MM-dd")
class _TimeInputContainer(QWidget):
def __init__(
self,
parent = None
):
super().__init__(parent)
self._timeEdit = QTimeEdit(self)
self._timeEdit.setDisplayFormat("HH:mm")
self._timeEdit.setFixedHeight(25)
layout = QHBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.addWidget(self._timeEdit)
def getValue(
self
) -> str:
return self._timeEdit.time().toString("HH:mm")
class _DateOffsetContainer(QWidget):
def __init__(
self,
parent = None
):
super().__init__(parent)
self._spinBox = QSpinBox(self)
self._spinBox.setRange(0, 99999)
self._spinBox.setFixedHeight(25)
self._unitCombo = QComboBox(self)
for display, data in DATE_OFFSET_OPTIONS:
self._unitCombo.addItem(display, data)
self._unitCombo.setFixedHeight(25)
layout = QHBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(4)
layout.addWidget(self._spinBox)
layout.addWidget(self._unitCombo)
layout.addStretch()
def getValue(
self
) -> str:
return str(self.getOffsetDays())
def getOffsetDays(
self
) -> int:
val = self._spinBox.value()
unit = self._unitCombo.currentData()
if unit == "weeks":
return val * 7
if unit == "months":
return val * 30
if unit == "years":
return val * 365
return val
class _TimeOffsetContainer(QWidget):
def __init__(
self,
parent = None
):
super().__init__(parent)
self._spinBox = QSpinBox(self)
self._spinBox.setRange(0, 99999)
self._spinBox.setSuffix(" 小时")
self._spinBox.setFixedHeight(25)
layout = QHBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.addWidget(self._spinBox)
def getValue(
self
) -> str:
return str(self.getOffsetHours())
def getOffsetHours(
self
) -> int:
return self._spinBox.value()
class VariableManager(QObject):
def __init__(
self,
parent = None
):
super().__init__(parent)
self._vars = []
self._nameMap = {}
self.initPresetVars()
def initPresetVars(
self
):
for p in getPresetVars():
entry = {"name": p["name"], "type": p["type"], "display": p["display"]}
self._vars.append(entry)
self._nameMap[p["name"]] = entry
def getInfoByName(
self,
name: str
):
return self._nameMap.get(name.upper().strip())
def populateCombo(
self,
combo: QComboBox
):
currentData = combo.currentData()
combo.blockSignals(True)
combo.clear()
for entry in self._vars:
combo.addItem(
entry["display"],
(entry["name"], entry["type"])
)
if currentData:
for i in range(combo.count()):
d = combo.itemData(i)
if d and d[0] == currentData[0]:
combo.setCurrentIndex(i)
break
combo.blockSignals(False)
def makeValueWidget(
var_type: str,
parent: QWidget = None
) -> QWidget:
if var_type == "Int":
w = QSpinBox(parent)
w.setRange(-999999, 999999)
w.setFixedHeight(25)
w.setMinimumWidth(100)
return w
if var_type == "Float":
w = QDoubleSpinBox(parent)
w.setRange(-999999.0, 999999.0)
w.setDecimals(2)
w.setFixedHeight(25)
w.setMinimumWidth(100)
return w
if var_type == "String":
w = QLineEdit(parent)
w.setPlaceholderText("输入值")
w.setFixedHeight(25)
w.setMinimumWidth(120)
return w
if var_type == "Boolean":
w = QComboBox(parent)
w.addItem("是 (true)", "true")
w.addItem("否 (false)", "false")
w.setFixedHeight(25)
w.setMinimumWidth(100)
return w
if var_type == "Date":
return _DateInputContainer(parent)
if var_type == "Time":
return _TimeInputContainer(parent)
w = QLineEdit(parent)
w.setPlaceholderText("输入值")
w.setFixedHeight(25)
w.setMinimumWidth(120)
return w
def makeOffsetWidget(
var_type: str,
parent: QWidget = None
) -> QWidget:
if var_type == "Int":
w = QSpinBox(parent)
w.setRange(-999999, 999999)
w.setFixedHeight(25)
w.setMinimumWidth(100)
return w
if var_type == "Float":
w = QDoubleSpinBox(parent)
w.setRange(-999999.0, 999999.0)
w.setDecimals(2)
w.setFixedHeight(25)
w.setMinimumWidth(100)
return w
if var_type == "Date":
return _DateOffsetContainer(parent)
if var_type == "Time":
return _TimeOffsetContainer(parent)
w = QLabel("(不支持该操作)", parent)
w.setFixedHeight(25)
return w
def makeVarRefCombo(
parent: QWidget = None
) -> QComboBox:
cb = QComboBox(parent)
cb.setFixedHeight(25)
cb.setMinimumWidth(120)
cb.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
return cb
def makeComboWidget(
items,
min_width: int = 80,
parent: QWidget = None
) -> QComboBox:
cb = QComboBox(parent)
for display, data in items:
cb.addItem(display, data)
cb.setFixedHeight(25)
cb.setMinimumWidth(min_width)
return cb
def makeLabel(
text: str,
parent: QWidget = None,
width: int = None
) -> QLabel:
lbl = QLabel(text, parent)
lbl.setFixedHeight(25)
if width:
lbl.setFixedWidth(width)
return lbl
def getValueFromWidget(
w: QWidget
) -> str:
if hasattr(w, "getValue"):
return w.getValue()
if isinstance(w, QTimeEdit):
return w.time().toString("HH:mm")
if isinstance(w, QDateEdit):
return w.date().toString("yyyy-MM-dd")
if isinstance(w, QComboBox):
return w.currentData() or w.currentText()
if isinstance(w, QSpinBox):
return str(w.value())
if isinstance(w, QDoubleSpinBox):
return str(w.value())
if isinstance(w, QLineEdit):
return w.text()
return ""
def encodeValueStr(
raw_value: str,
var_type: str
) -> str:
"""
Encode a raw widget value as a Lua expression.
Arithmetic expressions (A + B) are passed through for numeric types;
Date/Time arithmetic is translated to ``date_add()`` / ``time_add()`` calls.
"""
if var_type in ("Date", "Time"):
return encodeDateOrTime(str(raw_value), var_type)
if isinstance(raw_value, bool):
return "true" if raw_value else "false"
s = str(raw_value)
if isArithExpr(s):
return s
if var_type == "Boolean":
up = s.upper().strip()
if up in ("TRUE", "FALSE"):
return up.lower()
return "true" if raw_value else "false"
if var_type == "String":
escaped = s.replace("\\", "\\\\").replace('"', '\\"')
return f'"{escaped}"'
return s
def encodeDateOrTime(
raw_value: str,
var_type: str
) -> str:
"""
Translate a date/time widget value into a Lua expression.
"""
s = raw_value.strip()
up = s.upper()
# Input comes from widget values — single binary expressions only (e.g. "A + 3",
# "CURRENT_DATE + 5"). Multi-operator expressions are not produced by the UI.
m_arith_spaced = re.match(r'^(.+?)\s+([+-])\s+(.+)$', s)
m_arith_nospace = re.match(r'^([A-Za-z_]\w*)([+-])(\d+|[A-Za-z_]\w*)$', s)
m_arith = m_arith_spaced or m_arith_nospace
if m_arith:
left = m_arith.group(1).strip().upper()
sign = m_arith.group(2)
right = m_arith.group(3).strip()
operand = right if sign == "+" else f"-{right}"
if left == "CURRENT_DATE":
return f"date_add(CURRENT_DATE(), {operand})"
if left == "CURRENT_TIME":
return f"time_add(CURRENT_TIME(), {operand})"
if var_type == "Date":
return f"date_add({left}, {operand})"
if var_type == "Time":
return f"time_add({left}, {operand})"
return f"{left} {sign} {right}"
if up == "CURRENT_DATE":
return "CURRENT_DATE()"
if up == "CURRENT_TIME":
return "CURRENT_TIME()"
_REL_MAP = {
"前天": "date_add(CURRENT_DATE(), -2)",
"昨天": "date_add(CURRENT_DATE(), -1)",
"今天": "CURRENT_DATE()",
"明天": "date_add(CURRENT_DATE(), 1)",
"后天": "date_add(CURRENT_DATE(), 2)",
}
if s in _REL_MAP:
return _REL_MAP[s]
if var_type == "Date":
m_date = re.match(r"^(\d{4})-(\d{2})-(\d{2})$", s)
if m_date:
y, m, d = int(m_date.group(1)), int(m_date.group(2)), int(m_date.group(3))
return f"date({y}, {m}, {d})"
if var_type == "Time":
m_time = re.match(r"^(\d{1,2}):(\d{2})$", s)
if m_time:
h, m = int(m_time.group(1)), int(m_time.group(2))
return f"time({h}, {m})"
if re.match(r"^[+-]?\d+$", s):
return s
if re.match(r"^[A-Za-z_]\w*$", s):
return s
return f'"{s}"'
# Pre-compiled patterns for detecting arithmetic expressions (A + B / A - B)
_RE_ARITH_SPACED = re.compile(r'^(.+?)\s+([+-])\s+(.+)$')
_RE_ARITH_NOSPACE = re.compile(r'^([A-Za-z_]\w*)([+-])(\d+|[A-Za-z_]\w*)$')
def isArithExpr(
expr: str
) -> bool:
"""
Return True if expr looks like a two-operand arithmetic expression (A ± B).
"""
s = expr.strip()
return bool(_RE_ARITH_SPACED.match(s) or _RE_ARITH_NOSPACE.match(s))
+464
View File
@@ -0,0 +1,464 @@
# -*- coding: utf-8 -*-
"""
Copyright (c) 2026 KenanZhu.
All rights reserved.
This software is provided "as is", without any warranty of any kind.
You may use, modify, and distribute this file under the terms of the MIT License.
See the LICENSE file for details.
"""
"""
Widget components for the AutoScript orchestration dialog.
"""
from PySide6.QtCore import Slot
from PySide6.QtWidgets import (
QComboBox,
QFrame,
QHBoxLayout,
QLabel,
QPushButton,
QSizePolicy,
QStackedWidget
)
from gui.ALAutoScriptOrchDialog._helpers import (
ACTION_OPTIONS,
COMPARE_OPTIONS,
LOGIC_OPTIONS,
encodeValueStr,
getPresetVars,
getTypeOrder,
getValueFromWidget,
getArithType,
makeComboWidget,
makeLabel,
makeOffsetWidget,
makeValueWidget,
makeVarRefCombo,
)
class ConditionRowFrame(QFrame):
def __init__(
self,
varMgr,
parentBlockIndex: int = 0,
isFirst: bool = False,
parent = None
):
super().__init__(parent)
self._varMgr = varMgr
self._blockIndex = parentBlockIndex
self._isFirst = isFirst
self._isBoolMode = False
self._rawRhsExpr = ""
self.setupUi()
self.connectSignals()
def setupUi(
self
):
self.setUpdatesEnabled(False)
self.setFrameShape(QFrame.StyledPanel)
self.setFrameShadow(QFrame.Raised)
self.setFixedHeight(32)
layout = QHBoxLayout(self)
layout.setContentsMargins(2, 2, 2, 2)
layout.setSpacing(4)
if self._isFirst:
self.logicCombo = None
else:
self.logicCombo = makeComboWidget(LOGIC_OPTIONS, min_width=110, parent=self)
layout.addWidget(self.logicCombo)
self.leftVarCombo = QComboBox(self)
self.leftVarCombo.setFixedHeight(25)
self.leftVarCombo.setMinimumWidth(120)
self.leftVarCombo.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
self.populateLeftVarCombo()
layout.addWidget(self.leftVarCombo)
self.opCombo = makeComboWidget(COMPARE_OPTIONS, min_width=80, parent=self)
layout.addWidget(self.opCombo)
self._compTypeCombo = makeComboWidget([
("特定值", "literal"),
("变量", "variable"),
], min_width=70, parent=self)
layout.addWidget(self._compTypeCombo)
self.rhsStack = QStackedWidget(self)
self.rhsStack.setFixedHeight(25)
self.initLiteralStack()
self.rhsVarCombo = makeVarRefCombo(self)
self.rhsStack.addWidget(self.rhsVarCombo)
self.rhsStack.setCurrentIndex(0)
layout.addWidget(self.rhsStack)
if not self._isFirst:
self.deleteBtn = QPushButton("×", self)
self.deleteBtn.setFixedSize(25, 25)
self.deleteBtn.setStyleSheet("color: red; font-weight: bold;")
layout.addWidget(self.deleteBtn)
else:
self.deleteBtn = None
layout.addStretch()
self.setUpdatesEnabled(True)
def populateLeftVarCombo(
self
):
wasBool = self._isBoolMode
boolName = None
if wasBool:
data = self.leftVarCombo.currentData()
if data:
boolName = data[0]
self._varMgr.populateCombo(self.leftVarCombo)
# Append boolean literal sentinels at the end
self.leftVarCombo.insertSeparator(self.leftVarCombo.count())
self.leftVarCombo.addItem("true", ("true", "Boolean"))
self.leftVarCombo.addItem("false", ("false", "Boolean"))
if wasBool and boolName:
for ci in range(self.leftVarCombo.count()):
d = self.leftVarCombo.itemData(ci)
if d and d[0] == boolName:
self.leftVarCombo.setCurrentIndex(ci)
break
def populateRHSVarCombo(
self
):
self._varMgr.populateCombo(self.rhsVarCombo)
def initLiteralStack(
self
):
self.literalStack = QStackedWidget(self)
self.literalStack.setFixedHeight(25)
self._literalWidgets = {}
for vt in getTypeOrder():
w = makeValueWidget(vt, self.literalStack)
self._literalWidgets[vt] = w
self.literalStack.addWidget(w)
self.literalStack.setCurrentWidget(self._literalWidgets.get("String"))
self.rhsStack.addWidget(self.literalStack)
def connectSignals(
self
):
self.leftVarCombo.currentIndexChanged.connect(self.onLeftVarChanged)
self._compTypeCombo.currentIndexChanged.connect(self.onCompTypeChanged)
def getLogic(
self
) -> str:
return self.logicCombo.currentData() if self.logicCombo else ""
def updateRHSLiteralWidget(
self,
vartype: str
):
if vartype not in self._literalWidgets:
vartype = "String"
self.literalStack.setCurrentWidget(self._literalWidgets[vartype])
def toScript(
self
) -> str:
data = self.leftVarCombo.currentData()
if self._isBoolMode and data:
return data[0]
if not data:
return ""
name, vartype = data
# CURRENT_DATE / CURRENT_TIME are Lua functions — call them, not reference them
if name in ("CURRENT_DATE", "CURRENT_TIME"):
name = f"{name}()"
opSym = self.opCombo.currentData()
if self._rawRhsExpr:
return f"{name} {opSym} {self._rawRhsExpr}"
isVarRef = (self._compTypeCombo.currentData() == "variable")
if isVarRef:
rd = self.rhsVarCombo.currentData()
if rd:
rhsName = rd[0]
if rhsName in ("CURRENT_DATE", "CURRENT_TIME"):
rhsName = f"{rhsName}()"
return f"{name} {opSym} {rhsName}"
rhsText = self.rhsVarCombo.currentText().strip()
if rhsText:
return f"{name} {opSym} {rhsText}"
return ""
w = self._literalWidgets.get(vartype)
if w:
rawVal = getValueFromWidget(w)
encoded = encodeValueStr(rawVal, vartype)
return f"{name} {opSym} {encoded}"
return ""
def refreshVarCombos(
self
):
self.populateLeftVarCombo()
self.populateRHSVarCombo()
@Slot(int)
def onLeftVarChanged(
self,
idx
):
self._rawRhsExpr = ""
if idx < 0:
return
data = self.leftVarCombo.itemData(idx)
if not data:
return
name, vartype = data
isBool = name in ("true", "false")
self._isBoolMode = isBool
self.opCombo.setVisible(not isBool)
self._compTypeCombo.setVisible(not isBool)
self.rhsStack.setVisible(not isBool)
if not isBool:
self.updateRHSLiteralWidget(vartype)
@Slot(int)
def onCompTypeChanged(
self,
idx
):
self._rawRhsExpr = ""
isVar = (self._compTypeCombo.currentData() == "variable")
self.rhsStack.setCurrentIndex(1 if isVar else 0)
if isVar:
self.populateRHSVarCombo()
class ActionStepFrame(QFrame):
def __init__(
self,
varMgr,
parentBlockIndex: int = 0,
parent = None
):
super().__init__(parent)
self._varMgr = varMgr
self._blockIndex = parentBlockIndex
self._currentTargetType = "String"
self.setupUi()
self.connectSignals()
def setupUi(
self
):
self.setUpdatesEnabled(False)
self.setFrameShape(QFrame.StyledPanel)
self.setFrameShadow(QFrame.Raised)
self.setFixedHeight(35)
layout = QHBoxLayout(self)
layout.setContentsMargins(2, 2, 2, 2)
layout.setSpacing(4)
self.opTypeCombo = makeComboWidget(ACTION_OPTIONS, min_width=70, parent=self)
layout.addWidget(self.opTypeCombo)
layout.addWidget(makeLabel("设置", self))
self.targetCombo = QComboBox(self)
self.targetCombo.setFixedHeight(25)
self.targetCombo.setMinimumWidth(120)
self.populateTargetCombo()
layout.addWidget(self.targetCombo)
layout.addWidget(makeLabel("", self))
self.valueSrcCombo = makeComboWidget([
("特定值", "literal"),
("变量", "variable"),
], min_width=70, parent=self)
layout.addWidget(self.valueSrcCombo)
self.valueStack = QStackedWidget(self)
self.valueStack.setFixedHeight(25)
self.initValueStacks()
layout.addWidget(self.valueStack)
self.existingVarCombo = makeVarRefCombo(self)
self.existingVarCombo.setVisible(False)
layout.addWidget(self.existingVarCombo)
self.deleteBtn = QPushButton("×", self)
self.deleteBtn.setFixedSize(25, 25)
self.deleteBtn.setStyleSheet("color: red; font-weight: bold;")
layout.addWidget(self.deleteBtn)
self.setUpdatesEnabled(True)
def populateTargetCombo(
self
):
self.targetCombo.blockSignals(True)
self.targetCombo.clear()
for p in getPresetVars():
if p["name"] in ("CURRENT_TIME", "CURRENT_DATE"):
continue
info = self._varMgr.getInfoByName(p["name"])
if info:
self.targetCombo.addItem(
info["display"],
(info["name"], info["type"])
)
self.targetCombo.blockSignals(False)
def initValueStacks(
self
):
self._literalWidgets = {}
self._offsetWidgets = {}
for vt in getTypeOrder():
self._literalWidgets[vt] = makeValueWidget(vt, self.valueStack)
self.valueStack.addWidget(self._literalWidgets[vt])
if getArithType(vt):
self._offsetWidgets[vt] = makeOffsetWidget(vt, self.valueStack)
self.valueStack.addWidget(self._offsetWidgets[vt])
else:
lbl = QLabel("(不支持该操作)", self.valueStack)
lbl.setFixedHeight(25)
self._offsetWidgets[vt] = lbl
self.valueStack.addWidget(lbl)
def connectSignals(
self
):
self.opTypeCombo.currentIndexChanged.connect(self.onOpTypeChanged)
self.targetCombo.currentIndexChanged.connect(self.onTargetChanged)
self.valueSrcCombo.currentIndexChanged.connect(self.onValueSrcChanged)
def getTargetName(
self
) -> str:
data = self.targetCombo.currentData()
return data[0] if data else ""
def updateValueWidget(
self
):
op = self.opTypeCombo.currentData()
isArith = (op in ("add", "sub"))
actualType = self._currentTargetType
if isArith and actualType in self._offsetWidgets:
self.valueStack.setCurrentWidget(self._offsetWidgets[actualType])
elif actualType in self._literalWidgets:
self.valueStack.setCurrentWidget(self._literalWidgets[actualType])
else:
self.valueStack.setCurrentWidget(self._literalWidgets.get("String"))
def toScript(
self
) -> str:
"""
Generate a single line of Lua script from the current widget state.
"""
target = self.getTargetName()
op = self.opTypeCombo.currentData()
if op == "pass":
return " -- pass"
if not target:
return ""
rawVal = self.getValueRaw()
vartype = self._currentTargetType
if op == "set":
encoded = encodeValueStr(rawVal, vartype)
return f" {target} = {encoded}"
elif op == "add":
if vartype == "Date" and hasattr(self.valueStack.currentWidget(), "getOffsetDays"):
days = self.valueStack.currentWidget().getOffsetDays()
return f" {target} = date_add({target}, {days})"
if vartype == "Time" and hasattr(self.valueStack.currentWidget(), "getOffsetHours"):
hours = self.valueStack.currentWidget().getOffsetHours()
return f" {target} = time_add({target}, {hours})"
return f" {target} = {target} + {rawVal}"
elif op == "sub":
if vartype == "Date" and hasattr(self.valueStack.currentWidget(), "getOffsetDays"):
days = self.valueStack.currentWidget().getOffsetDays()
return f" {target} = date_add({target}, -{days})"
if vartype == "Time" and hasattr(self.valueStack.currentWidget(), "getOffsetHours"):
hours = self.valueStack.currentWidget().getOffsetHours()
return f" {target} = time_add({target}, -{hours})"
return f" {target} = {target} - {rawVal}"
return ""
def getValueRaw(
self
) -> str:
if self.valueSrcCombo.currentData() == "variable":
data = self.existingVarCombo.currentData()
return data[0] if data else ""
w = self.valueStack.currentWidget()
if w:
return getValueFromWidget(w)
return ""
def refreshVarCombos(
self
):
currentData = self.targetCombo.currentData()
self.populateTargetCombo()
if currentData:
for i in range(self.targetCombo.count()):
d = self.targetCombo.itemData(i)
if d and d[0] == currentData[0]:
self.targetCombo.setCurrentIndex(i)
break
self._varMgr.populateCombo(self.existingVarCombo)
@Slot(int)
def onTargetChanged(
self,
idx
):
if idx < 0:
return
data = self.targetCombo.itemData(idx)
if not data:
return
_, vartype = data
self._currentTargetType = vartype
self.updateValueWidget()
self.onValueSrcChanged(self.valueSrcCombo.currentIndex())
@Slot(int)
def onOpTypeChanged(
self,
idx
):
self.updateValueWidget()
@Slot(int)
def onValueSrcChanged(
self,
idx
):
isVar = (self.valueSrcCombo.currentData() == "variable")
self.valueStack.setVisible(not isVar)
self.existingVarCombo.setVisible(isVar)
if isVar:
self._varMgr.populateCombo(self.existingVarCombo)
else:
self.updateValueWidget()
+38 -51
View File
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
"""
Copyright (c) 2025 - 2026 KenanZhu.
Copyright (c) 2025 KenanZhu.
All rights reserved.
This software is provided "as is", without any warranty of any kind.
@@ -10,26 +10,46 @@ See the LICENSE file for details.
import os
from PySide6.QtCore import (
Qt, Signal, Slot, QTime, QDate, QDir, QFileInfo
)
from PySide6.QtWidgets import (
QDialog, QWidget, QLineEdit, QMessageBox, QFileDialog,
QTreeWidgetItem, QMenu, QInputDialog
QDate,
QDir,
QFileInfo,
Qt,
QTime,
Signal,
Slot
)
from PySide6.QtGui import (
QCloseEvent, QAction
QAction,
QCloseEvent
)
from PySide6.QtWidgets import (
QDialog,
QFileDialog,
QInputDialog,
QLineEdit,
QMenu,
QMessageBox,
QTreeWidgetItem,
QWidget
)
import managers.config.ConfigManager as ConfigManager
from utils.JSONReader import JSONReader
from utils.JSONWriter import JSONWriter
from gui.resources.ui.Ui_ALConfigWidget import Ui_ALConfigWidget
from gui.ALSeatMapSelectDialog import ALSeatMapSelectDialog
from gui.ALSeatMapTable import ALSeatMapTable
from gui.ALUserTreeWidget import ALUserTreeWidget, ALUserTreeItemType
from gui.ALUserTreeWidget import (
ALUserTreeItemType,
ALUserTreeWidget
)
from gui.ALWebDriverDownloadDialog import ALWebDriverDownloadDialog
from gui.resources.ui.Ui_ALConfigWidget import Ui_ALConfigWidget
from interfaces.ConfigProvider import (
CfgKey,
ConfigProvider
)
from managers.config.ConfigUtils import ConfigUtils
from utils.JSONReader import JSONReader
from utils.JSONWriter import JSONWriter
class ALConfigWidget(QWidget, Ui_ALConfigWidget):
@@ -42,8 +62,8 @@ class ALConfigWidget(QWidget, Ui_ALConfigWidget):
):
super().__init__(parent)
self.__cfg_mgr = ConfigManager.instance()
self.__config_paths = ConfigManager.getValidateAutomationConfigPaths()
self.__cfg_mgr: ConfigProvider = ConfigManager.instance()
self.__config_paths = ConfigUtils.getAutomationConfigPaths()
self.__config_data = {"run": {}, "user": {}}
self.setupUi(self)
@@ -52,7 +72,6 @@ class ALConfigWidget(QWidget, Ui_ALConfigWidget):
if not self.initializeConfigs():
self.close()
def modifyUi(
self
):
@@ -68,7 +87,6 @@ class ALConfigWidget(QWidget, Ui_ALConfigWidget):
self.initializeFloorRoomMap()
self.initializeUserInfoWidget()
def connectSignals(
self
):
@@ -92,7 +110,6 @@ class ALConfigWidget(QWidget, Ui_ALConfigWidget):
self.ConfirmButton.clicked.connect(self.onConfirmButtonClicked)
self.CancelButton.clicked.connect(self.onCancelButtonClicked)
def showEvent(
self,
event
@@ -116,7 +133,6 @@ class ALConfigWidget(QWidget, Ui_ALConfigWidget):
return result
def closeEvent(
self,
event: QCloseEvent
@@ -125,7 +141,6 @@ class ALConfigWidget(QWidget, Ui_ALConfigWidget):
self.configWidgetIsClosed.emit()
super().closeEvent(event)
def initializeFloorRoomMap(
self
):
@@ -159,7 +174,6 @@ class ALConfigWidget(QWidget, Ui_ALConfigWidget):
"五层": ["五层考研"]
}
def initializeConfigToWidget(
self,
which: str,
@@ -174,7 +188,6 @@ class ALConfigWidget(QWidget, Ui_ALConfigWidget):
self.setUsersToTreeWidget(config_data)
self.CurrentUserConfigEdit.setText(self.__config_paths["user"])
def initializeConfig(
self,
which: str
@@ -208,7 +221,6 @@ class ALConfigWidget(QWidget, Ui_ALConfigWidget):
is_success = False
return is_success
def initializeConfigs(
self
) -> bool:
@@ -221,7 +233,6 @@ class ALConfigWidget(QWidget, Ui_ALConfigWidget):
self.initializeConfigToWidget(which, self.__config_data[which])
return is_success
def defaultRunConfig(
self
) -> dict:
@@ -245,7 +256,6 @@ class ALConfigWidget(QWidget, Ui_ALConfigWidget):
}
}
def defaultUserConfig(
self
) -> dict:
@@ -255,7 +265,6 @@ class ALConfigWidget(QWidget, Ui_ALConfigWidget):
]
}
def collectRunConfigFromWidget(
self
) -> dict:
@@ -277,7 +286,6 @@ class ALConfigWidget(QWidget, Ui_ALConfigWidget):
run_config["mode"]["run_mode"] = run_mode
return run_config
def setRunConfigToWidget(
self,
run_config: dict
@@ -316,7 +324,6 @@ class ALConfigWidget(QWidget, Ui_ALConfigWidget):
"文件可能被意外修改或已经损坏\n"
)
def initializeUserInfoWidget(
self
):
@@ -341,7 +348,6 @@ class ALConfigWidget(QWidget, Ui_ALConfigWidget):
self.MaxRenewTimeDiffSpinBox.setValue(30)
self.PreferLateRenewTimeCheckBox.setChecked(False)
def collectUserFromWidget(
self
) -> dict:
@@ -374,7 +380,6 @@ class ALConfigWidget(QWidget, Ui_ALConfigWidget):
user["reserve_info"]["renew_time"]["prefer_early"] = not self.PreferLateRenewTimeCheckBox.isChecked()
return user
def collectUsersFromTreeWidget(
self
) -> dict:
@@ -397,7 +402,6 @@ class ALConfigWidget(QWidget, Ui_ALConfigWidget):
user_config["groups"].append(group_config)
return user_config
def setUserToWidget(
self,
user: dict
@@ -439,7 +443,6 @@ class ALConfigWidget(QWidget, Ui_ALConfigWidget):
"文件可能被意外修改或已经损坏\n"
)
def setUsersToTreeWidget(
self,
users: dict
@@ -481,7 +484,6 @@ class ALConfigWidget(QWidget, Ui_ALConfigWidget):
finally:
self.UserTreeWidget.itemChanged.connect(self.onUserTreeWidgetItemChanged)
def loadRunConfig(
self,
run_config_path: str
@@ -505,7 +507,6 @@ class ALConfigWidget(QWidget, Ui_ALConfigWidget):
)
return None
def saveRunConfig(
self,
run_config_path: str,
@@ -527,7 +528,6 @@ class ALConfigWidget(QWidget, Ui_ALConfigWidget):
)
return False
def loadUserConfig(
self,
user_config_path: str
@@ -561,7 +561,6 @@ class ALConfigWidget(QWidget, Ui_ALConfigWidget):
)
return None
def saveUserConfig(
self,
user_config_path: str,
@@ -583,7 +582,6 @@ class ALConfigWidget(QWidget, Ui_ALConfigWidget):
)
return False
def saveConfigs(
self,
run_config_path: str,
@@ -606,7 +604,6 @@ class ALConfigWidget(QWidget, Ui_ALConfigWidget):
return False
return True
def loadConfig(
self,
config_path: str
@@ -635,7 +632,6 @@ class ALConfigWidget(QWidget, Ui_ALConfigWidget):
except:
return False
def addGroup(
self,
group_name: str = ""
@@ -652,7 +648,6 @@ class ALConfigWidget(QWidget, Ui_ALConfigWidget):
self.UserTreeWidget.itemChanged.connect(self.onUserTreeWidgetItemChanged)
return group_item
def delGroup(
self,
group_item: QTreeWidgetItem = None
@@ -665,7 +660,6 @@ class ALConfigWidget(QWidget, Ui_ALConfigWidget):
index = self.UserTreeWidget.indexOfTopLevelItem(group_item)
self.UserTreeWidget.takeTopLevelItem(index)
def addUser(
self,
group_item: QTreeWidgetItem = None
@@ -720,7 +714,6 @@ class ALConfigWidget(QWidget, Ui_ALConfigWidget):
self.UserTreeWidget.itemChanged.connect(self.onUserTreeWidgetItemChanged)
return user_item
def delUser(
self,
user_item: QTreeWidgetItem = None
@@ -736,7 +729,6 @@ class ALConfigWidget(QWidget, Ui_ALConfigWidget):
if parent_item.childCount() == 0:
self.UserTreeWidget.setCurrentItem(None)
def renameItem(
self,
item: QTreeWidgetItem,
@@ -860,7 +852,6 @@ class ALConfigWidget(QWidget, Ui_ALConfigWidget):
is_checked = item.checkState(1) == Qt.CheckState.Checked
item.setText(1, "" if is_checked else "跳过")
def showTreeMenu(
self,
menu: QMenu
@@ -870,7 +861,6 @@ class ALConfigWidget(QWidget, Ui_ALConfigWidget):
add_group_action.triggered.connect(self.addGroup)
menu.addAction(add_group_action)
def showGroupMenu(
self,
menu: QMenu,
@@ -890,7 +880,6 @@ class ALConfigWidget(QWidget, Ui_ALConfigWidget):
if group_item.checkState(1) == Qt.CheckState.Unchecked:
add_user_action.setEnabled(False)
def showUserMenu(
self,
menu: QMenu,
@@ -950,7 +939,6 @@ class ALConfigWidget(QWidget, Ui_ALConfigWidget):
if browser_driver_path:
self.BrowseBrowserDriverEdit.setText(QDir.toNativeSeparators(browser_driver_path))
@Slot()
def onAutoDownloadWebDriverButtonClicked(
self
@@ -964,7 +952,6 @@ class ALConfigWidget(QWidget, Ui_ALConfigWidget):
self.BrowserTypeComboBox.setCurrentText(selected_driver_info.driver_type.value)
self.BrowseBrowserDriverEdit.setText(QDir.toNativeSeparators(str(selected_driver_info.driver_path)))
@Slot()
def onBrowseCurrentRunConfigButtonClicked(
self
@@ -984,13 +971,13 @@ class ALConfigWidget(QWidget, Ui_ALConfigWidget):
self.setRunConfigToWidget(data)
self.__config_paths["run"] = run_config_path
self.CurrentRunConfigEdit.setText(run_config_path)
paths = self.__cfg_mgr.get(ConfigManager.ConfigType.GLOBAL, "automation.run_path.paths", [])
paths = self.__cfg_mgr.get(CfgKey.GLOBAL.AUTOMATION.RUN_PATH.PATHS, [])
if run_config_path not in paths:
paths.append(run_config_path)
index = len(paths) - 1
else:
index = paths.index(run_config_path)
self.__cfg_mgr.set(ConfigManager.ConfigType.GLOBAL, "automation.run_path", {"current": index, "paths": paths})
self.__cfg_mgr.set(CfgKey.GLOBAL.AUTOMATION.RUN_PATH.ROOT, {"current": index, "paths": paths})
else:
QMessageBox.warning(
self,
@@ -1019,13 +1006,13 @@ class ALConfigWidget(QWidget, Ui_ALConfigWidget):
self.setUsersToTreeWidget(data)
self.__config_paths["user"] = user_config_path
self.CurrentUserConfigEdit.setText(user_config_path)
paths = self.__cfg_mgr.get(ConfigManager.ConfigType.GLOBAL, "automation.user_path.paths", [])
paths = self.__cfg_mgr.get(CfgKey.GLOBAL.AUTOMATION.USER_PATH.PATHS, [])
if user_config_path not in paths:
paths.append(user_config_path)
index = len(paths) - 1
else:
index = paths.index(user_config_path)
self.__cfg_mgr.set(ConfigManager.ConfigType.GLOBAL, "automation.user_path", {"current": index, "paths": paths})
self.__cfg_mgr.set(CfgKey.GLOBAL.AUTOMATION.USER_PATH.ROOT, {"current": index, "paths": paths})
else:
QMessageBox.warning(
self,
+29 -31
View File
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
"""
Copyright (c) 2025 - 2026 KenanZhu.
Copyright (c) 2025 KenanZhu.
All rights reserved.
This software is provided "as is", without any warranty of any kind.
@@ -10,25 +10,37 @@ See the LICENSE file for details.
import queue
from PySide6.QtCore import (
Qt, Signal, Slot, QTimer, QUrl,
)
from PySide6.QtWidgets import (
QMainWindow, QMenu, QSystemTrayIcon, QMessageBox
QTimer,
QUrl,
Qt,
Signal,
Slot
)
from PySide6.QtGui import (
QTextCursor, QCloseEvent, QFont, QIcon, QDesktopServices
QCloseEvent,
QDesktopServices,
QFont,
QIcon,
QTextCursor
)
from PySide6.QtWidgets import (
QMainWindow,
QMenu,
QMessageBox,
QSystemTrayIcon
)
import managers.config.ConfigManager as ConfigManager
from base.MsgBase import MsgBase
from gui.resources.ui.Ui_ALMainWindow import Ui_ALMainWindow
from gui.resources import ALResource
from gui.ALConfigWidget import ALConfigWidget
from gui.ALTimerTaskManageWidget import ALTimerTaskManageWidget
from gui.ALAboutDialog import ALAboutDialog
from gui.ALMainWorkers import TimerTaskWorker, AutoLibWorker
from gui.ALConfigWidget import ALConfigWidget
from gui.ALMainWorkers import (
AutoLibWorker,
TimerTaskWorker
)
from gui.ALTimerTaskManageWidget import ALTimerTaskManageWidget
from gui.resources import ALResource
from gui.resources.ui.Ui_ALMainWindow import Ui_ALMainWindow
from managers.config.ConfigUtils import ConfigUtils
class ALMainWindow(MsgBase, QMainWindow, Ui_ALMainWindow):
@@ -44,9 +56,8 @@ class ALMainWindow(MsgBase, QMainWindow, Ui_ALMainWindow):
MsgBase.__init__(self, queue.Queue(), queue.Queue())
QMainWindow.__init__(self)
self.__cfg_mgr = ConfigManager.instance()
self.__timer_task_queue = queue.Queue()
self.__config_paths = ConfigManager.getValidateAutomationConfigPaths()
self.__config_paths = ConfigUtils.getAutomationConfigPaths()
self.__alTimerTaskManageWidget = None
self.__alConfigWidget = None
self.__auto_lib_thread = None
@@ -61,12 +72,11 @@ class ALMainWindow(MsgBase, QMainWindow, Ui_ALMainWindow):
self.startTimerTaskPolling()
self._showLog("主窗口初始化完成")
def modifyUi(
self
):
self.icon = QIcon(":/res/icon/icons/AutoLibrary_32x32.ico")
self.icon = QIcon(":/res/icons/AutoLibrary_Logo_64.svg")
self.setWindowIcon(self.icon)
self.MessageIOTextEdit.setFont(QFont("Courier New", 10))
self.ManualAction.triggered.connect(self.onManualActionTriggered)
@@ -92,7 +102,6 @@ class ALMainWindow(MsgBase, QMainWindow, Ui_ALMainWindow):
self.__alTimerTaskManageWidget.timerTaskManageWidgetIsClosed.connect(self.onTimerTaskManageWidgetClosed)
self.__alTimerTaskManageWidget.setWindowFlags(Qt.WindowType.Window|Qt.WindowType.WindowCloseButtonHint)
def onAboutActionTriggered(
self
):
@@ -100,7 +109,6 @@ class ALMainWindow(MsgBase, QMainWindow, Ui_ALMainWindow):
about_dialog = ALAboutDialog(self)
about_dialog.exec()
def onManualActionTriggered(
self
):
@@ -108,7 +116,6 @@ class ALMainWindow(MsgBase, QMainWindow, Ui_ALMainWindow):
url = QUrl("https://www.autolibrary.kenanzhu.com/manuals")
QDesktopServices.openUrl(url)
def setupTray(
self
):
@@ -130,7 +137,6 @@ class ALMainWindow(MsgBase, QMainWindow, Ui_ALMainWindow):
self.TrayIcon.activated.connect(self.onTrayIconActivated)
self.TrayIcon.show()
def hideToTray(
self
):
@@ -143,7 +149,6 @@ class ALMainWindow(MsgBase, QMainWindow, Ui_ALMainWindow):
2000
)
def onTrayIconActivated(
self,
reason: QSystemTrayIcon.ActivationReason
@@ -152,7 +157,6 @@ class ALMainWindow(MsgBase, QMainWindow, Ui_ALMainWindow):
if reason == QSystemTrayIcon.DoubleClick:
self.showNormal()
def connectSignals(
self
):
@@ -164,7 +168,6 @@ class ALMainWindow(MsgBase, QMainWindow, Ui_ALMainWindow):
self.SendButton.clicked.connect(self.onSendButtonClicked)
self.MessageEdit.returnPressed.connect(self.onSendButtonClicked)
def closeEvent(
self,
event: QCloseEvent
@@ -190,7 +193,6 @@ class ALMainWindow(MsgBase, QMainWindow, Ui_ALMainWindow):
self._showLog("主窗口关闭")
QMainWindow.closeEvent(self, event)
def appendToTextEdit(
self,
text: str
@@ -204,7 +206,6 @@ class ALMainWindow(MsgBase, QMainWindow, Ui_ALMainWindow):
scrollbar = self.MessageIOTextEdit.verticalScrollBar()
scrollbar.setValue(scrollbar.maximum())
def startMsgPolling(
self
):
@@ -213,7 +214,6 @@ class ALMainWindow(MsgBase, QMainWindow, Ui_ALMainWindow):
self.__msg_queue_timer.timeout.connect(self.pollMsgQueue)
self.__msg_queue_timer.start(100)
def startTimerTaskPolling(
self
):
@@ -222,7 +222,6 @@ class ALMainWindow(MsgBase, QMainWindow, Ui_ALMainWindow):
self.__timer_task_timer.timeout.connect(self.pollTimerTaskQueue)
self.__timer_task_timer.start(500)
def pollTimerTaskQueue(
self
):
@@ -256,7 +255,6 @@ class ALMainWindow(MsgBase, QMainWindow, Ui_ALMainWindow):
self.__is_running_timer_task = False
pass
def setControlButtons(
self,
config_button_enabled: bool,
@@ -300,7 +298,7 @@ class ALMainWindow(MsgBase, QMainWindow, Ui_ALMainWindow):
self.__alConfigWidget.configWidgetIsClosed.disconnect(self.onConfigWidgetClosed)
self.__alConfigWidget.deleteLater()
self.__alConfigWidget = None
self.__config_paths = ConfigManager.getValidateAutomationConfigPaths()
self.__config_paths = ConfigUtils.getAutomationConfigPaths()
self.setControlButtons(True, None, None)
self._showLog("配置窗口已关闭,配置文件路径已更新")
+90 -21
View File
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
"""
Copyright (c) 2025 - 2026 KenanZhu.
Copyright (c) 2025 KenanZhu.
All rights reserved.
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 operators.AutoLib import AutoLib
from utils.JSONReader import JSONReader
from autoscript import createEngine
class AutoLibWorker(MsgBase, QThread):
@@ -36,7 +37,6 @@ class AutoLibWorker(MsgBase, QThread):
QThread.__init__(self)
self.__config_paths = config_paths
def checkTimeAvailable(
self,
) -> bool:
@@ -51,7 +51,6 @@ class AutoLibWorker(MsgBase, QThread):
self._showLog(f"时间检查通过, 当前时间: {current_time}", self.TraceLevel.INFO)
return True
def checkConfigPaths(
self,
) -> bool:
@@ -67,7 +66,6 @@ class AutoLibWorker(MsgBase, QThread):
self._showLog(f"配置文件路径检查通过, 路径: {self.__config_paths}", self.TraceLevel.INFO)
return True
def loadConfigs(
self
) -> bool:
@@ -76,28 +74,30 @@ class AutoLibWorker(MsgBase, QThread):
f"正在加载配置文件, 运行配置文件路径: {self.__config_paths["run"]}",
no_log=True
)
self.__run_config = JSONReader(self.__config_paths["run"]).data()
self._run_config = JSONReader(self.__config_paths["run"]).data()
self._showTrace(
f"正在加载配置文件, 用户配置文件路径: {self.__config_paths["user"]}",
no_log=True
)
self.__user_config = JSONReader(self.__config_paths["user"]).data()
if self.__run_config is None or self.__user_config is None:
self._user_config = JSONReader(self.__config_paths["user"]).data()
if self._run_config is None or self._user_config is None:
self._showTrace(
"配置文件加载失败, 请检查配置文件是否正确",
self.TraceLevel.ERROR
)
return False
if not self.__user_config.get("groups"):
if not self._user_config.get("groups"):
self._showTrace(
"用户配置文件中无有效任务组, 请检查用户配置文件是否正确",
self.TraceLevel.WARNING
)
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
def run(
self
):
@@ -115,9 +115,9 @@ class AutoLibWorker(MsgBase, QThread):
auto_lib = AutoLib(
self._input_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:
if not group["enabled"]:
self._showTrace(f"任务组 {group["name"]} 已跳过", no_log=True)
@@ -162,7 +162,84 @@ class TimerTaskWorker(AutoLibWorker):
):
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:
engine = createEngine()
engine.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()
def onTimerTaskFinishedWithError(
@@ -174,11 +251,3 @@ class TimerTaskWorker(AutoLibWorker):
self.TraceLevel.ERROR
)
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 -3
View File
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
"""
Copyright (c) 2025 - 2026 KenanZhu.
Copyright (c) 2025 KenanZhu.
All rights reserved.
This software is provided "as is", without any warranty of any kind.
@@ -63,14 +63,12 @@ class ALSeatFrame(QFrame):
self.toggleSelection()
self.clicked.emit(self.__seat_number)
def isSelected(
self
):
return self.__is_selected
def toggleSelection(self):
self.__is_selected = not self.__is_selected
+11 -14
View File
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
"""
Copyright (c) 2025 - 2026 KenanZhu.
Copyright (c) 2026 KenanZhu.
All rights reserved.
This software is provided "as is", without any warranty of any kind.
@@ -8,15 +8,20 @@ You may use, modify, and distribute this file under the terms of the MIT License
See the LICENSE file for details.
"""
from PySide6.QtCore import (
Qt, Slot, Signal
)
from PySide6.QtWidgets import (
QDialog, QLabel, QHBoxLayout, QVBoxLayout,
QPushButton,
Qt,
Signal,
Slot
)
from PySide6.QtGui import (
QCloseEvent
)
from PySide6.QtWidgets import (
QDialog,
QHBoxLayout,
QLabel,
QPushButton,
QVBoxLayout
)
from gui.ALSeatMapView import ALSeatMapView
@@ -42,7 +47,6 @@ class ALSeatMapSelectDialog(QDialog):
self.setupUi()
self.connectSignals()
def setupUi(
self
):
@@ -85,7 +89,6 @@ class ALSeatMapSelectDialog(QDialog):
self.SeatMapWidgetControlLayout.addWidget(self.ConfirmButton)
self.SeatMapWidgetMainLayout.addLayout(self.SeatMapWidgetControlLayout)
def connectSignals(
self
):
@@ -93,7 +96,6 @@ class ALSeatMapSelectDialog(QDialog):
self.ConfirmButton.clicked.connect(self.onConfirmButtonClicked)
self.CancelButton.clicked.connect(self.onCancelButtonClicked)
def showEvent(
self,
event
@@ -117,7 +119,6 @@ class ALSeatMapSelectDialog(QDialog):
return result
def closeEvent(
self,
event: QCloseEvent
@@ -131,7 +132,6 @@ class ALSeatMapSelectDialog(QDialog):
self.seatMapSelectDialogIsClosed.emit(self.getSelectedSeats())
super().closeEvent(event)
def selectSeat(
self,
seat_number: str
@@ -139,7 +139,6 @@ class ALSeatMapSelectDialog(QDialog):
self.SeatMapGraphicsView.selectSeat(seat_number)
def selectSeats(
self,
seat_numbers: list[str]
@@ -147,14 +146,12 @@ class ALSeatMapSelectDialog(QDialog):
return self.SeatMapGraphicsView.selectSeats(seat_numbers)
def getSelectedSeats(
self
) -> list[str]:
return self.SeatMapGraphicsView.getSelectedSeats()
def clearSelections(
self
):
+21 -24
View File
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
"""
Copyright (c) 2025 - 2026 KenanZhu.
Copyright (c) 2026 KenanZhu.
All rights reserved.
This software is provided "as is", without any warranty of any kind.
@@ -11,11 +11,16 @@ from PySide6.QtCore import (
Qt, Slot, QEvent
)
from PySide6.QtWidgets import (
QFrame, QWidget,
QGridLayout, QGraphicsView, QGraphicsScene, QGraphicsItem
QFrame,
QWidget,
QGridLayout,
QGraphicsView,
QGraphicsScene,
QGraphicsItem
)
from PySide6.QtGui import (
QPainter, QWheelEvent
QPainter,
QWheelEvent
)
from gui.ALSeatFrame import ALSeatFrame
@@ -35,18 +40,6 @@ class ALSeatMapView(QGraphicsView):
self.setupUi()
@staticmethod
def formatSeatNumber(
seat_number: str
) -> str:
if seat_number and not seat_number[-1].isdigit():
digits = seat_number[:-1]
letter = seat_number[-1]
return digits.zfill(3) + letter
return seat_number.zfill(3)
def eventFilter(
self,
watched,
@@ -61,7 +54,6 @@ class ALSeatMapView(QGraphicsView):
return True
return super().eventFilter(watched, event)
def zoomGraphicsView(
self,
event: QWheelEvent
@@ -80,7 +72,6 @@ class ALSeatMapView(QGraphicsView):
self.setTransformationAnchor(QGraphicsView.ViewportAnchor.AnchorUnderMouse)
self.scale(zoom_factor, zoom_factor)
def setupUi(
self
):
@@ -100,7 +91,6 @@ class ALSeatMapView(QGraphicsView):
self.ContainerProxy = self.SeatMapGraphicsScene.addWidget(self.SeatsContainerWidget)
self.ContainerProxy.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsSelectable, False)
def setupSeatMap(
self
):
@@ -125,7 +115,6 @@ class ALSeatMapView(QGraphicsView):
self.SeatsContainerLayout.setContentsMargins(20, 20, 20, 20)
self.SeatsContainerWidget.adjustSize()
def selectSeat(
self,
seat_number: str
@@ -142,7 +131,6 @@ class ALSeatMapView(QGraphicsView):
widget.toggleSelection()
self.__selected_seats.append(seat_number)
def selectSeats(
self,
selected_seats: list
@@ -152,14 +140,12 @@ class ALSeatMapView(QGraphicsView):
for seat_number in selected_seats:
self.selectSeat(seat_number)
def getSelectedSeats(
self
) -> list[str]:
return self.__selected_seats
def clearSelections(
self
):
@@ -185,4 +171,15 @@ class ALSeatMapView(QGraphicsView):
if len(self.__selected_seats) < 1:
self.__selected_seats.append(seat_number)
else:
self.__seat_frames[seat_number].toggleSelection()
self.__seat_frames[seat_number].toggleSelection()
@staticmethod
def formatSeatNumber(
seat_number: str
) -> str:
if seat_number and not seat_number[-1].isdigit():
digits = seat_number[:-1]
letter = seat_number[-1]
return digits.zfill(3) + letter
return seat_number.zfill(3)
+19 -9
View File
@@ -1,14 +1,28 @@
# -*- 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 PySide6.QtWidgets import (
QLabel
)
from PySide6.QtCore import (
Qt, Property, QPropertyAnimation, QEasingCurve
Property,
QEasingCurve,
QPropertyAnimation,
Qt
)
from PySide6.QtGui import (
QPainter, QColor, QConicalGradient, QPalette
QColor,
QConicalGradient,
QPainter,
QPalette
)
from PySide6.QtWidgets import (
QLabel
)
@@ -36,7 +50,6 @@ class ALStatusLabel(QLabel):
self.setupUi()
def setupUi(
self
):
@@ -51,14 +64,12 @@ class ALStatusLabel(QLabel):
self.RunningAnimation.setLoopCount(-1)
self.RunningAnimation.setEasingCurve(QEasingCurve.Type.Linear)
def isDarkMode(
self
) -> bool:
return self.palette().color(QPalette.ColorRole.Window).value() < 128
def getMarkColor(
self
) -> QColor:
@@ -103,7 +114,6 @@ class ALStatusLabel(QLabel):
self.__icon_angle = value
self.update()
def paintEvent(
self,
event
+150 -20
View File
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
"""
Copyright (c) 2025 - 2026 KenanZhu.
Copyright (c) 2025 KenanZhu.
All rights reserved.
This software is provided "as is", without any warranty of any kind.
@@ -12,11 +12,12 @@ import uuid
from enum import Enum
from datetime import datetime, timedelta
from PySide6.QtCore import Slot, QDateTime
from PySide6.QtWidgets import QLabel, QDialog, QWidget, QSpinBox, QHBoxLayout, QGridLayout, QDateTimeEdit
from PySide6.QtCore import Slot, QDateTime, QUrl
from PySide6.QtGui import QDesktopServices
from PySide6.QtWidgets import QLabel, QDialog, QWidget, QSpinBox, QHBoxLayout, QVBoxLayout, QGridLayout, QDateTimeEdit, QGroupBox, QPushButton
from gui.resources.ui.Ui_ALTimerTaskAddDialog import Ui_ALTimerTaskAddDialog
import utils.TimerUtils as TimerUtils
from utils.TimerUtils import TimerUtils
class ALTimerTaskStatus(Enum):
@@ -34,15 +35,19 @@ class ALTimerTaskAddDialog(QDialog, Ui_ALTimerTaskAddDialog):
def __init__(
self,
parent = None
parent = None,
timer_task: dict = None
):
super().__init__(parent)
self.__edit_timer_task = timer_task
self.setupUi(self)
self.modifyUi()
self.connectSignals()
if self.__edit_timer_task:
self.loadTask(self.__edit_timer_task)
def modifyUi(
self
@@ -51,6 +56,8 @@ class ALTimerTaskAddDialog(QDialog, Ui_ALTimerTaskAddDialog):
self.TimerTypeComboBox.setCurrentIndex(0)
self.SpecificTimerWidget = QWidget()
self.SpecificTimerLayout = QHBoxLayout(self.SpecificTimerWidget)
self.SpecificTimerLayout.setContentsMargins(0, 0, 0, 0)
self.SpecificTimerLayout.setSpacing(5)
self.SpecificTimerLayout.addWidget(QLabel("定时时间:"))
self.SpecificDateTimeEdit = QDateTimeEdit()
self.SpecificDateTimeEdit.setCalendarPopup(True)
@@ -62,6 +69,8 @@ class ALTimerTaskAddDialog(QDialog, Ui_ALTimerTaskAddDialog):
self.RelativeTimerWidget = QWidget()
self.RelativeTimerLayout = QHBoxLayout(self.RelativeTimerWidget)
self.RelativeTimerLayout.setContentsMargins(0, 0, 0, 0)
self.RelativeTimerLayout.setSpacing(5)
self.RelativeTimerLayout.addWidget(QLabel("相对时间:"))
self.RelativeDaySpinBox = QSpinBox()
self.RelativeDaySpinBox.setMinimum(0)
@@ -86,6 +95,82 @@ class ALTimerTaskAddDialog(QDialog, Ui_ALTimerTaskAddDialog):
self.TimerConfigLayout.addWidget(self.RelativeTimerWidget)
self.RelativeTimerWidget.setVisible(False)
self.AutoScriptGroupBox = QGroupBox("AutoScript 指令")
self.AutoScriptLayout = QVBoxLayout(self.AutoScriptGroupBox)
self.AutoScriptLayout.setContentsMargins(3, 3, 3, 3)
self.AutoScriptLayout.setSpacing(3)
autoScriptBtnLayout = QHBoxLayout()
self.AutoScriptEditButton = QPushButton("编辑")
self.AutoScriptEditButton.setMinimumHeight(25)
self.AutoScriptEditButton.setFixedWidth(80)
autoScriptBtnLayout.addWidget(self.AutoScriptEditButton)
autoScriptBtnLayout.addStretch()
self.AutoScriptHelpButton = QPushButton("?")
self.AutoScriptHelpButton.setFixedSize(20, 20)
self.AutoScriptHelpButton.setToolTip(
"AutoScript 是一种轻量级 DSL 语言,基于 Lua 实现。\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 = ""
self.__mock_target_data = None
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;")
mock_data = task.get("mock_target_data")
if mock_data:
self.__mock_target_data = mock_data
self.ConfirmButton.setText("保存")
def connectSignals(
self
@@ -95,7 +180,8 @@ class ALTimerTaskAddDialog(QDialog, Ui_ALTimerTaskAddDialog):
self.ConfirmButton.clicked.connect(self.accept)
self.TimerTypeComboBox.currentIndexChanged.connect(self.onTimerTypeComboBoxIndexChanged)
self.RepeatCheckBox.toggled.connect(self.onRepeatCheckBoxToggled)
self.AutoScriptEditButton.clicked.connect(self.onPreviewAutoScript)
self.AutoScriptHelpButton.clicked.connect(self.onAutoScriptHelp)
def getTimerTask(
self
@@ -119,18 +205,36 @@ class ALTimerTaskAddDialog(QDialog, Ui_ALTimerTaskAddDialog):
minutes = self.RelativeMinuteSpinBox.value(),
seconds = self.RelativeSecondSpinBox.value()
)
task_data = {
"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(),
}
if task_data["repeat"]:
if self.__edit_timer_task:
task_data = dict(self.__edit_timer_task)
task_data["name"] = name
task_data["execute_time"] = execute_time
task_data["silent"] = silent
task_data["status"] = ALTimerTaskStatus.PENDING
task_data["executed"] = False
task_data["repeat_auto_script"] = self.__auto_script
task_data["mock_target_data"] = self.__mock_target_data
else:
task_data = {
"name": name,
"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,
"mock_target_data": self.__mock_target_data,
}
repeat = self.RepeatCheckBox.isChecked()
task_data["repeat"] = repeat
if repeat:
if "repeat_history" not in task_data:
task_data["repeat_history"] = []
repeat_days = []
if self.MonCheckBox.isChecked():
repeat_days.append(0)
@@ -152,7 +256,7 @@ class ALTimerTaskAddDialog(QDialog, Ui_ALTimerTaskAddDialog):
task_data["repeat_hour"] = execute_time.hour
task_data["repeat_minute"] = execute_time.minute
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_hour"],
task_data["repeat_minute"],
@@ -181,4 +285,30 @@ class ALTimerTaskAddDialog(QDialog, Ui_ALTimerTaskAddDialog):
self.ThuCheckBox.setEnabled(checked)
self.FriCheckBox.setEnabled(checked)
self.SatCheckBox.setEnabled(checked)
self.SunCheckBox.setEnabled(checked)
self.SunCheckBox.setEnabled(checked)
self.AutoScriptGroupBox.setVisible(checked)
@Slot()
def onPreviewAutoScript(self):
from gui.ALAutoScriptEditDialog import ALAutoScriptEditDialog
dlg = ALAutoScriptEditDialog(self, self.__auto_script, self.__mock_target_data)
if dlg.exec() == QDialog.DialogCode.Accepted:
script = dlg.getScript()
self.__auto_script = script
self.__mock_target_data = dlg.getMockData()
if script:
self.AutoScriptStatusLabel.setText("已设置")
self.AutoScriptStatusLabel.setStyleSheet("color: #4CAF50;")
else:
self.AutoScriptStatusLabel.setText("未设置")
self.AutoScriptStatusLabel.setStyleSheet("color: #969696;")
dlg.deleteLater()
@Slot()
def onAutoScriptHelp(
self
):
QDesktopServices.openUrl(
QUrl("https://www.autolibrary.kenanzhu.com/manuals/autoscript")
)
+9 -15
View File
@@ -28,15 +28,13 @@ class ALTimerTaskHistoryDialog(QDialog):
):
super().__init__(parent)
self.__task_data = task_data
self.__history = task_data.get("history", [])
self.__history = task_data.get("repeat_history", [])
self.modifyUi()
self.setupUi()
self.connectSignals()
def modifyUi(
def setupUi(
self
):
@@ -83,7 +81,6 @@ class ALTimerTaskHistoryDialog(QDialog):
ButtonLayout.addWidget(self.CloseButton)
MainLayout.addLayout(ButtonLayout)
def connectSignals(
self
):
@@ -91,7 +88,6 @@ class ALTimerTaskHistoryDialog(QDialog):
self.CloseButton.clicked.connect(self.accept)
self.ClearHistoryButton.clicked.connect(self.onClearHistoryButtonClicked)
def loadHistory(
self
):
@@ -100,6 +96,11 @@ class ALTimerTaskHistoryDialog(QDialog):
for row, record in enumerate(self.__history):
self.addHistoryRow(row, record)
def getHistory(
self
) -> list:
return self.__history
def addHistoryRow(
self,
@@ -137,11 +138,4 @@ class ALTimerTaskHistoryDialog(QDialog):
self.__history.clear()
self.HistoryTableWidget.setRowCount(0)
self.__task_data["history"] = self.__history
def getHistory(
self
) -> list:
return self.__history
self.__task_data["repeat_history"] = self.__history
+86 -40
View File
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
"""
Copyright (c) 2025 - 2026 KenanZhu.
Copyright (c) 2025 KenanZhu.
All rights reserved.
This software is provided "as is", without any warranty of any kind.
@@ -15,26 +15,46 @@ from enum import Enum
from datetime import datetime, timedelta
from PySide6.QtCore import (
Qt, Signal, Slot, QTimer
)
from PySide6.QtWidgets import (
QDialog, QWidget, QListWidgetItem, QMessageBox,
QHBoxLayout, QVBoxLayout, QLabel, QPushButton
QTimer,
Qt,
Signal,
Slot
)
from PySide6.QtGui import (
QAction,
QCloseEvent
)
from PySide6.QtWidgets import (
QDialog,
QHBoxLayout,
QLabel,
QListWidgetItem,
QMenu,
QMessageBox,
QPushButton,
QVBoxLayout,
QWidget
)
import managers.config.ConfigManager as ConfigManager
import utils.TimerUtils as TimerUtils
from gui.resources.ui.Ui_ALTimerTaskManageWidget import Ui_ALTimerTaskManageWidget
from gui.ALTimerTaskAddDialog import ALTimerTaskAddDialog, ALTimerTaskStatus
from gui.ALTimerTaskAddDialog import (
ALTimerTaskAddDialog,
ALTimerTaskStatus
)
from gui.ALTimerTaskHistoryDialog import ALTimerTaskHistoryDialog
from gui.resources.ui.Ui_ALTimerTaskManageWidget import Ui_ALTimerTaskManageWidget
from interfaces.ConfigProvider import (
CfgKey,
ConfigProvider
)
from utils.TimerUtils import TimerUtils
class ALTimerTaskItemWidget(QWidget):
editRequested = Signal(dict)
def __init__(
self,
parent = None,
@@ -43,9 +63,11 @@ class ALTimerTaskItemWidget(QWidget):
super().__init__(parent)
self.__timer_task = timer_task
self.__manage_widget = parent
self.modifyUi()
self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
self.customContextMenuRequested.connect(self.showContextMenu)
def modifyUi(
self
@@ -145,6 +167,27 @@ class ALTimerTaskItemWidget(QWidget):
self.DeleteButton.setEnabled(False)
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):
@@ -164,7 +207,7 @@ class ALTimerTaskManageWidget(QWidget, Ui_ALTimerTaskManageWidget):
):
super().__init__(parent)
self.__cfg_mgr = ConfigManager.instance()
self.__cfg_mgr: ConfigProvider = ConfigManager.instance()
self.__timer_tasks = []
self.__check_timer = None
self.__sort_policy = self.SortPolicy.BY_EXECUTE_TIME
@@ -176,7 +219,6 @@ class ALTimerTaskManageWidget(QWidget, Ui_ALTimerTaskManageWidget):
if not self.initializeTimerTasks():
raise Exception("定时任务配置文件初始化失败 !")
def connectSignals(
self
):
@@ -187,7 +229,6 @@ class ALTimerTaskManageWidget(QWidget, Ui_ALTimerTaskManageWidget):
self.TimerTaskSortOrderToggleButton.clicked.connect(self.onSortOrderToggleButtonClicked)
self.timerTasksChanged.connect(self.onTimerTasksChanged)
def setupTimer(
self
):
@@ -196,7 +237,6 @@ class ALTimerTaskManageWidget(QWidget, Ui_ALTimerTaskManageWidget):
self.__check_timer.timeout.connect(self.checkTasks)
self.__check_timer.start(500)
def initializeTimerTasks(
self
) -> bool:
@@ -212,20 +252,19 @@ class ALTimerTaskManageWidget(QWidget, Ui_ALTimerTaskManageWidget):
return True
return False
def getTimerTasks(
self
) -> list:
try:
timer_tasks = self.__cfg_mgr.get(ConfigManager.ConfigType.TIMERTASK)
timer_tasks = self.__cfg_mgr.get(CfgKey.TIMERTASK.ROOT)
if timer_tasks and "timer_tasks" in timer_tasks:
for task in timer_tasks["timer_tasks"]:
task["added_time"] = datetime.strptime(task["added_time"], "%Y-%m-%d %H:%M:%S")
task["execute_time"] = datetime.strptime(task["execute_time"], "%Y-%m-%d %H:%M:%S")
task["status"] = ALTimerTaskStatus(task["status"])
if "history" in task:
for item in task["history"]:
if "repeat_history" in task:
for item in task["repeat_history"]:
item["result"] = ALTimerTaskStatus(item["result"])
return timer_tasks["timer_tasks"]
raise Exception("定时任务配置文件格式错误")
@@ -237,7 +276,6 @@ class ALTimerTaskManageWidget(QWidget, Ui_ALTimerTaskManageWidget):
)
return None
def setTimerTasks(
self,
timer_tasks: list
@@ -248,10 +286,10 @@ class ALTimerTaskManageWidget(QWidget, Ui_ALTimerTaskManageWidget):
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["status"] = task["status"].value
if "history" in task:
for item in task["history"]:
if "repeat_history" in task:
for item in task["repeat_history"]:
item["result"] = item["result"].value
self.__cfg_mgr.set(ConfigManager.ConfigType.TIMERTASK, "", { "timer_tasks": timer_tasks })
self.__cfg_mgr.set(CfgKey.TIMERTASK.ROOT, { "timer_tasks": timer_tasks })
return True
except Exception as e:
QMessageBox.warning(
@@ -261,7 +299,6 @@ class ALTimerTaskManageWidget(QWidget, Ui_ALTimerTaskManageWidget):
)
return False
def showEvent(
self,
event
@@ -285,7 +322,6 @@ class ALTimerTaskManageWidget(QWidget, Ui_ALTimerTaskManageWidget):
return result
def closeEvent(
self,
event: QCloseEvent
@@ -295,7 +331,6 @@ class ALTimerTaskManageWidget(QWidget, Ui_ALTimerTaskManageWidget):
self.timerTaskManageWidgetIsClosed.emit()
event.ignore()
def sortTimerTasks(
self,
policy: SortPolicy = SortPolicy.BY_EXECUTE_TIME,
@@ -318,7 +353,6 @@ class ALTimerTaskManageWidget(QWidget, Ui_ALTimerTaskManageWidget):
reverse = order is Qt.SortOrder.DescendingOrder
)
def updateStat(
self
):
@@ -345,7 +379,6 @@ class ALTimerTaskManageWidget(QWidget, Ui_ALTimerTaskManageWidget):
self.ExecutedTaskLabel.setText(f"已执行:{executed}")
self.InvalidTaskLabel.setText(f"无效的:{invalid}")
def updateTimerTaskList(
self
):
@@ -363,11 +396,11 @@ class ALTimerTaskManageWidget(QWidget, Ui_ALTimerTaskManageWidget):
widget.HistoryButton.clicked.connect(
lambda _, task = timer_task: self.showTaskHistory(task)
)
widget.editRequested.connect(self.editTask)
item.setSizeHint(widget.size())
self.TimerTasksListWidget.addItem(item)
self.TimerTasksListWidget.setItemWidget(item, widget)
def addTask(
self
):
@@ -378,20 +411,38 @@ class ALTimerTaskManageWidget(QWidget, Ui_ALTimerTaskManageWidget):
self.__timer_tasks.append(timer_task)
self.timerTasksChanged.emit()
def editTask(
self,
timer_task: dict
):
dialog = ALTimerTaskAddDialog(self, timer_task)
if dialog.exec() == QDialog.DialogCode.Accepted:
updated = dialog.getTimerTask()
for i, task in enumerate(self.__timer_tasks):
if task["uuid"] == updated["uuid"]:
self.__timer_tasks[i] = updated
break
self.timerTasksChanged.emit()
@staticmethod
def getTimerTaskDetailMessage(
timer_task: dict
):
if "repeat_history" not in timer_task:
history = []
else:
history = timer_task["repeat_history"]
history_count = len(history)
return (
f"任务名称:{timer_task["name"]}\n"
f"添加时间:{timer_task["added_time"]}\n"
f"当前状态:{timer_task["status"].value}\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(
self,
timer_task: dict
@@ -420,7 +471,6 @@ class ALTimerTaskManageWidget(QWidget, Ui_ALTimerTaskManageWidget):
]
self.timerTasksChanged.emit()
def clearAllTasks(
self
):
@@ -482,7 +532,6 @@ class ALTimerTaskManageWidget(QWidget, Ui_ALTimerTaskManageWidget):
self.__timer_tasks.clear()
self.timerTasksChanged.emit()
def showTaskHistory(
self,
task: dict
@@ -492,7 +541,6 @@ class ALTimerTaskManageWidget(QWidget, Ui_ALTimerTaskManageWidget):
if dialog.exec() == QDialog.DialogCode.Accepted:
self.timerTasksChanged.emit()
def checkTasks(
self
):
@@ -554,7 +602,6 @@ class ALTimerTaskManageWidget(QWidget, Ui_ALTimerTaskManageWidget):
self.updateTimerTaskList()
self.updateStat()
@Slot(dict)
def onTimerTaskIsRunning(
self,
@@ -567,7 +614,6 @@ class ALTimerTaskManageWidget(QWidget, Ui_ALTimerTaskManageWidget):
break
self.timerTasksChanged.emit()
def onRepeatTimerTaskIs(
self,
status: ALTimerTaskStatus,
@@ -579,12 +625,12 @@ class ALTimerTaskManageWidget(QWidget, Ui_ALTimerTaskManageWidget):
ALTimerTaskStatus.OUTDATED}
if status not in valid_statuses:
return timer_task
if "history" not in timer_task:
timer_task["history"] = []
if "repeat_history" not in timer_task:
timer_task["repeat_history"] = []
if status != ALTimerTaskStatus.OUTDATED:
executed_time = datetime.now()
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"),
"executed_time": executed_time.strftime("%Y-%m-%d %H:%M:%S"),
"result": status,
@@ -598,14 +644,14 @@ class ALTimerTaskManageWidget(QWidget, Ui_ALTimerTaskManageWidget):
delta_days = (current_time - execute_time).days
for i in range(delta_days + 1):
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"),
"executed_time": (execute_time + timedelta(days=i)).strftime("%Y-%m-%d %H:%M:%S"),
"result": status,
"duration": 0,
"uuid": timer_task["uuid"]
})
next_time = TimerUtils.calculateNextRepeatTime(
next_time = TimerUtils.getNextTimerRepeatTime(
timer_task["repeat_days"],
timer_task["repeat_hour"],
timer_task["repeat_minute"],
+13 -11
View File
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
"""
Copyright (c) 2025 - 2026 KenanZhu.
Copyright (c) 2025 KenanZhu.
All rights reserved.
This software is provided "as is", without any warranty of any kind.
@@ -10,14 +10,22 @@ See the LICENSE file for details.
from enum import Enum
from PySide6.QtCore import (
Qt, QSize, QCoreApplication, QRect, QPoint
Qt,
QSize,
QCoreApplication,
QRect,
QPoint
)
from PySide6.QtWidgets import (
QAbstractScrollArea, QAbstractItemView,
QTreeWidget, QTreeWidgetItem
QAbstractScrollArea,
QAbstractItemView,
QTreeWidget,
QTreeWidgetItem
)
from PySide6.QtGui import (
QDragEnterEvent, QDragMoveEvent, QDropEvent
QDragEnterEvent,
QDragMoveEvent,
QDropEvent
)
@@ -39,7 +47,6 @@ class ALUserTreeWidget(QTreeWidget):
self.setupUi()
self.translateUi()
def setupUi(
self
):
@@ -70,7 +77,6 @@ class ALUserTreeWidget(QTreeWidget):
self.header().setHighlightSections(False)
self.header().setProperty(u"showSortIndicator", True)
def translateUi(
self
):
@@ -78,7 +84,6 @@ class ALUserTreeWidget(QTreeWidget):
___qtreewidgetitem = self.headerItem()
___qtreewidgetitem.setText(1, QCoreApplication.translate("ALConfigWidget", u"\u72b6\u6001", None));
@staticmethod
def isDragPositionValid(
target_rect: QRect,
@@ -90,7 +95,6 @@ class ALUserTreeWidget(QTreeWidget):
y_offset < target_rect.height()*0.8)
return valid
def dragEnterEvent(
self,
event: QDragEnterEvent
@@ -98,7 +102,6 @@ class ALUserTreeWidget(QTreeWidget):
super().dragEnterEvent(event)
def dragMoveEvent(
self,
event: QDragMoveEvent
@@ -136,7 +139,6 @@ class ALUserTreeWidget(QTreeWidget):
return
event.acceptProposedAction()
def dropEvent(
self,
event: QDropEvent
+3 -3
View File
@@ -5,11 +5,11 @@
workflow process. Do not edit manually.
This file is auto-generated during the workflow process.
Last updated: 2026-03-21 10:54:51 UTC
Last updated: 2026-05-09 06:05:13 UTC
"""
AL_VERSION = "1.2.0"
AL_TAG = "v1.2.0"
AL_VERSION = "1.3.0"
AL_TAG = "v1.3.0"
AL_COMMIT_SHA = "local"
AL_COMMIT_DATE = "null" # time zone : UTC
AL_BUILD_DATE = "null" # time zone : UTC
+137 -140
View File
@@ -11,20 +11,30 @@ import threading
from typing import Optional
from PySide6.QtCore import (
Qt, Slot, QThread, Signal
Qt,
Slot,
QThread,
Signal
)
from PySide6.QtWidgets import (
QDialog, QLabel, QComboBox, QProgressBar,
QPushButton, QVBoxLayout, QHBoxLayout,
QMessageBox, QFrame, QLineEdit
)
from PySide6.QtGui import (
QCloseEvent
QDialog,
QLabel,
QComboBox,
QProgressBar,
QPushButton,
QVBoxLayout,
QHBoxLayout,
QMessageBox,
QFrame,
QLineEdit
)
from PySide6.QtGui import QCloseEvent
from managers.driver.WebDriverManager import (
instance as webdriver_manager_instance,
WebDriverManager, WebDriverInfo, WebDriverType,
instance as webdriverInstance,
WebDriverManager,
WebDriverInfo,
WebDriverType,
WebDriverStatus
)
from gui.ALStatusLabel import ALStatusLabel
@@ -142,7 +152,6 @@ class ALWebDriverDownloadDialog(QDialog):
self.initializeDriverManager()
self.refreshDriverList()
def showEvent(
self,
event
@@ -165,7 +174,6 @@ class ALWebDriverDownloadDialog(QDialog):
self.move(target_pos)
return result
def setupUi(
self
):
@@ -237,7 +245,6 @@ class ALWebDriverDownloadDialog(QDialog):
self.ControlLayout.addWidget(self.ConfirmButton)
self.MainLayout.addLayout(self.ControlLayout)
def connectSignals(
self
):
@@ -249,18 +256,16 @@ class ALWebDriverDownloadDialog(QDialog):
self.ConfirmButton.clicked.connect(self.onConfirmButtonClicked)
self.DriverComboBox.currentIndexChanged.connect(self.onDriverComboBoxChanged)
def initializeDriverManager(
self
):
try:
self.__driver_manager = webdriver_manager_instance(self.__driver_dir)
self.__driver_manager = webdriverInstance(self.__driver_dir)
except ValueError as e:
QMessageBox.warning(self, "初始化失败", f"WebDriverManager 初始化失败:\n{str(e)}")
self.reject()
def refreshDriverList(
self
):
@@ -270,18 +275,20 @@ class ALWebDriverDownloadDialog(QDialog):
self.__driver_manager.refresh()
self.__driver_infos = self.__driver_manager.getDriverInfos()
self.DriverComboBox.clear()
installed = 0
installed_idx = 0
for i, driver_info in enumerate(self.__driver_infos):
if driver_info.driver_status == WebDriverStatus.INSTALLED:
installed_idx = i # get the installed driver index
display_text = f"{driver_info.driver_type.value} - {driver_info.browser_version}"
if driver_info.driver_status == WebDriverStatus.INSTALLED:
installed += 1
installed_idx = i # get the installed driver index
display_text += " : 已安装"
self.DriverComboBox.addItem(display_text)
count = len(self.__driver_infos)
self.BrowserCountLabel.setText(f"检测到 {count} 个可用浏览器:")
self.BrowserCountLabel.setText(f"检测到 {count} 个可用浏览器{installed} 个已安装驱动")
if self.__driver_infos:
self.DriverComboBox.setCurrentIndex(installed_idx)
def onDriverComboBoxChanged(
self,
index: int
@@ -294,6 +301,116 @@ class ALWebDriverDownloadDialog(QDialog):
self.updateProgressBarStates(driver_info)
self.updateButtonStates(driver_info)
def closeEvent(
self,
event: QCloseEvent
):
if self.__download_thread and self.__download_thread.isRunning():
reply = QMessageBox.question(
self,
"确认关闭 - AutoLibrary",
"驱动正在下载中, 确定要取消并关闭对话框吗 ?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
)
if reply == QMessageBox.StandardButton.No:
event.ignore()
return
self.__download_thread.stop()
if not self.__confirmed:
self.__selected_driver_info = None
event.accept()
super().closeEvent(event)
def onThreadFinished(
self
):
if self.__download_thread:
self.__download_thread.deleteLater()
self.__download_thread = None
def getSelectedDriverInfo(
self
) -> Optional[WebDriverInfo]:
return self.__selected_driver_info
def updateDriverInfoDisplay(
self,
driver_info: WebDriverInfo
):
if driver_info.driver_type == WebDriverType.CHROME:
driver_type = "Google Chrome"
elif driver_info.driver_type == WebDriverType.FIREFOX:
driver_type = "Mozilla Firefox"
elif driver_info.driver_type == WebDriverType.EDGE:
driver_type = "Microsoft Edge"
else:
driver_type = "未知"
self.BrowserTypeLabel.setText(f"类型:{driver_type}")
self.VersionLabel.setText(f"版本:{driver_info.driver_version}")
if driver_info.driver_path:
self.PathLabel.setText(str(driver_info.driver_path))
else:
self.PathLabel.setText("未安装")
match driver_info.driver_status:
case WebDriverStatus.NOT_INSTALLED:
self.StatusLabel.status = ALStatusLabel.Status.WAITING
case WebDriverStatus.INSTALLED:
self.StatusLabel.status = ALStatusLabel.Status.SUCCESS
case WebDriverStatus.DOWNLOADING:
self.StatusLabel.status = ALStatusLabel.Status.RUNNING
case WebDriverStatus.ERROR:
self.StatusLabel.status = ALStatusLabel.Status.FAILURE
def updateProgressBarStates(
self,
driver_info: WebDriverInfo
):
if driver_info.driver_status == WebDriverStatus.NOT_INSTALLED:
self.ProgressBar.setValue(0)
self.ProgressText.setText("未安装")
elif driver_info.driver_status == WebDriverStatus.INSTALLED:
self.ProgressBar.setValue(100)
self.ProgressText.setText("已安装")
elif driver_info.driver_status == WebDriverStatus.DOWNLOADING:
pass # update by worker thread
elif driver_info.driver_status == WebDriverStatus.ERROR:
self.ProgressBar.setValue(0)
self.ProgressText.setText("下载失败")
def updateButtonStates(
self,
driver_info: WebDriverInfo
):
if driver_info.driver_status == WebDriverStatus.NOT_INSTALLED:
self.RefreshButton.setEnabled(True)
self.DeleteButton.setEnabled(False)
self.DownloadButton.setEnabled(True)
self.CancelButton.setEnabled(True)
self.ConfirmButton.setEnabled(False)
elif driver_info.driver_status == WebDriverStatus.INSTALLED:
self.RefreshButton.setEnabled(True)
self.DownloadButton.setEnabled(False)
self.DeleteButton.setEnabled(True)
self.CancelButton.setEnabled(True)
self.ConfirmButton.setEnabled(True)
elif driver_info.driver_status == WebDriverStatus.DOWNLOADING:
self.RefreshButton.setEnabled(False)
self.DownloadButton.setEnabled(False)
self.DeleteButton.setEnabled(False)
self.CancelButton.setEnabled(True)
self.ConfirmButton.setEnabled(False)
elif driver_info.driver_status == WebDriverStatus.ERROR:
self.RefreshButton.setEnabled(True)
self.DownloadButton.setEnabled(True)
self.DeleteButton.setEnabled(False)
self.CancelButton.setEnabled(True)
self.ConfirmButton.setEnabled(False)
@Slot()
def onRefreshButtonClicked(
@@ -302,7 +419,6 @@ class ALWebDriverDownloadDialog(QDialog):
self.refreshDriverList()
@Slot()
def onDeleteButtonClicked(
self
@@ -355,7 +471,7 @@ class ALWebDriverDownloadDialog(QDialog):
self.__download_thread.downloadFinished.connect(self.onDownloadFinished)
self.__download_thread.downloadError.connect(self.onDownloadError)
self.__download_thread.downloadCancelled.connect(self.onDownloadCancelled)
self.__download_thread.finished.connect(self.__onThreadFinished)
self.__download_thread.finished.connect(self.onThreadFinished)
self.__download_thread.start()
@Slot()
@@ -406,7 +522,6 @@ class ALWebDriverDownloadDialog(QDialog):
self.updateButtonStates(driver_info)
QMessageBox.critical(self, "下载失败 - AutoLibrary", error_message)
@Slot()
def onDownloadCancelled(
self
@@ -423,7 +538,6 @@ class ALWebDriverDownloadDialog(QDialog):
self.updateButtonStates(driver_info)
self.ProgressText.setText("下载已取消")
@Slot()
def onConfirmButtonClicked(
self
@@ -439,7 +553,6 @@ class ALWebDriverDownloadDialog(QDialog):
self.__confirmed = True
self.accept()
@Slot()
def onCancelButtonClicked(
self
@@ -458,119 +571,3 @@ class ALWebDriverDownloadDialog(QDialog):
self.__confirmed = False
self.__selected_driver_info = None
self.reject()
def closeEvent(
self,
event: QCloseEvent
):
if self.__download_thread and self.__download_thread.isRunning():
reply = QMessageBox.question(
self,
"确认关闭 - AutoLibrary",
"驱动正在下载中, 确定要取消并关闭对话框吗 ?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
)
if reply == QMessageBox.StandardButton.No:
event.ignore()
return
self.__download_thread.stop()
if not self.__confirmed:
self.__selected_driver_info = None
event.accept()
super().closeEvent(event)
def __onThreadFinished(
self
):
if self.__download_thread:
self.__download_thread.deleteLater()
self.__download_thread = None
def getSelectedDriverInfo(
self
) -> Optional[WebDriverInfo]:
return self.__selected_driver_info
def updateDriverInfoDisplay(
self,
driver_info: WebDriverInfo
):
if driver_info.driver_type == WebDriverType.CHROME:
driver_type = "Google Chrome"
elif driver_info.driver_type == WebDriverType.FIREFOX:
driver_type = "Mozilla Firefox"
elif driver_info.driver_type == WebDriverType.EDGE:
driver_type = "Microsoft Edge"
else:
driver_type = "未知"
self.BrowserTypeLabel.setText(f"类型:{driver_type}")
self.VersionLabel.setText(f"版本:{driver_info.driver_version}")
if driver_info.driver_path:
self.PathLabel.setText(str(driver_info.driver_path))
else:
self.PathLabel.setText("未安装")
match driver_info.driver_status:
case WebDriverStatus.NOT_INSTALLED:
self.StatusLabel.status = ALStatusLabel.Status.WAITING
case WebDriverStatus.INSTALLED:
self.StatusLabel.status = ALStatusLabel.Status.SUCCESS
case WebDriverStatus.DOWNLOADING:
self.StatusLabel.status = ALStatusLabel.Status.RUNNING
case WebDriverStatus.ERROR:
self.StatusLabel.status = ALStatusLabel.Status.FAILURE
def updateProgressBarStates(
self,
driver_info: WebDriverInfo
):
if driver_info.driver_status == WebDriverStatus.NOT_INSTALLED:
self.ProgressBar.setValue(0)
self.ProgressText.setText("未安装")
elif driver_info.driver_status == WebDriverStatus.INSTALLED:
self.ProgressBar.setValue(100)
self.ProgressText.setText("已安装")
elif driver_info.driver_status == WebDriverStatus.DOWNLOADING:
pass # update by worker thread
elif driver_info.driver_status == WebDriverStatus.ERROR:
self.ProgressBar.setValue(0)
self.ProgressText.setText("下载失败")
def updateButtonStates(
self,
driver_info: WebDriverInfo
):
if driver_info.driver_status == WebDriverStatus.NOT_INSTALLED:
self.RefreshButton.setEnabled(True)
self.DeleteButton.setEnabled(False)
self.DownloadButton.setEnabled(True)
self.CancelButton.setEnabled(True)
self.ConfirmButton.setEnabled(False)
elif driver_info.driver_status == WebDriverStatus.INSTALLED:
self.RefreshButton.setEnabled(True)
self.DownloadButton.setEnabled(False)
self.DeleteButton.setEnabled(True)
self.CancelButton.setEnabled(True)
self.ConfirmButton.setEnabled(True)
elif driver_info.driver_status == WebDriverStatus.DOWNLOADING:
self.RefreshButton.setEnabled(False)
self.DownloadButton.setEnabled(False)
self.DeleteButton.setEnabled(False)
self.CancelButton.setEnabled(True)
self.ConfirmButton.setEnabled(False)
elif driver_info.driver_status == WebDriverStatus.ERROR:
self.RefreshButton.setEnabled(True)
self.DownloadButton.setEnabled(True)
self.DeleteButton.setEnabled(False)
self.CancelButton.setEnabled(True)
self.ConfirmButton.setEnabled(False)
+1
View File
@@ -10,6 +10,7 @@
- ALSeatMapTable: Seat map table class.
- ALSeatMapSelectDialog: Seat map select dialog class.
- ALTimerTaskAddDialog: Timer task add dialog class.
- ALAutoScriptOrchDialog: AutoScript orchestration dialog class.
- ALTimerTaskHistoryDialog: Timer task history dialog class.
- ALTimerTaskManageWidget: Timer task manage widget class.
- ALUserTreeWidget: User tree widget class.
+7 -4
View File
@@ -1,8 +1,11 @@
<RCC>
<qresource prefix="/res/icon">
<file>icons/AutoLibrary_32x32.ico</file>
</qresource>
<qresource prefix="/res/trans">
<qresource prefix="/res">
<file>icons/AutoLibrary_Logo_64.svg</file>
<file>icons/AutoLibrary_Logo_128.svg</file>
<file>icons/Copy.svg</file>
<file>icons/Reset.svg</file>
<file>translators/qtbase_zh_CN.qm</file>
</qresource>
</RCC>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 785 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 30 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 30 KiB

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

After

Width:  |  Height:  |  Size: 396 B

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

After

Width:  |  Height:  |  Size: 866 B

+1 -1
View File
@@ -13,7 +13,7 @@
<property name="minimumSize">
<size>
<width>350</width>
<height>400</height>
<height>460</height>
</size>
</property>
<property name="maximumSize">
+119
View File
@@ -0,0 +1,119 @@
# -*- coding: utf-8 -*-
"""
Copyright (c) 2026 KenanZhu.
All rights reserved.
This software is provided "as is", without any warranty of any kind.
You may use, modify, and distribute this file under the terms of the MIT License.
See the LICENSE file for details.
"""
from dataclasses import dataclass
from enum import Enum
from typing import Any, Optional, Protocol
class ConfigType(Enum):
"""
Config type enum. Values represent the default filename.
"""
GLOBAL = "autolibrary.json"
BULLETIN = "bulletin.json"
TIMERTASK = "timer_task.json"
@dataclass(frozen=True)
class ConfigPath:
"""
A typed configuration path that carries both the config file
and the dot-separated key in a single object.
Consumers pass this directly to ConfigProvider.get/set,
eliminating the need to import ConfigType separately.
"""
config_type: ConfigType
key: str = ""
class CfgKey:
"""
Type-safe hierarchical configuration key constants.
Each leaf is a ConfigPath that can be passed directly to
``ConfigProvider.get()`` or ``ConfigProvider.set()``.
Usage::
CfgKey.GLOBAL.AUTOMATION.RUN_PATH.PATHS
# -> ConfigPath(ConfigType.GLOBAL, "automation.run_path.paths")
config.get(CfgKey.GLOBAL.AUTOMATION.RUN_PATH.PATHS, [])
config.set(CfgKey.GLOBAL.AUTOMATION.RUN_PATH.PATHS, value)
"""
class GLOBAL:
class AUTOMATION:
ROOT = ConfigPath(ConfigType.GLOBAL, "automation")
class RUN_PATH:
ROOT = ConfigPath(ConfigType.GLOBAL, "automation.run_path")
CURRENT = ConfigPath(ConfigType.GLOBAL, "automation.run_path.current")
PATHS = ConfigPath(ConfigType.GLOBAL, "automation.run_path.paths")
class USER_PATH:
ROOT = ConfigPath(ConfigType.GLOBAL, "automation.user_path")
CURRENT = ConfigPath(ConfigType.GLOBAL, "automation.user_path.current")
PATHS = ConfigPath(ConfigType.GLOBAL, "automation.user_path.paths")
class TIMERTASK:
ROOT = ConfigPath(ConfigType.TIMERTASK, "")
TIMER_TASKS = ConfigPath(ConfigType.TIMERTASK, "timer_tasks")
class BULLETIN:
ROOT = ConfigPath(ConfigType.BULLETIN, "")
BULLETIN = ConfigPath(ConfigType.BULLETIN, "bulletin")
LAST_SYNC_TIME = ConfigPath(ConfigType.BULLETIN, "last_sync_time")
class ConfigProvider(Protocol):
"""
Abstract interface for configuration storage access.
Concrete implementations (e.g. ConfigManager) conform to
this protocol structurally rather than through explicit
inheritance.
"""
def get(
self,
key: ConfigPath,
default: Optional[Any] = None
) -> Any:
"""
Retrieve a configuration value.
Args:
key: A ConfigPath object specifying which config file
and key to read from.
default: Fallback value if the key is not found.
Returns:
The configuration value at the given key path.
"""
...
def set(
self,
key: ConfigPath,
value: Any = None
) -> None:
"""
Set a configuration value and persist to disk.
Args:
key: A ConfigPath object specifying which config file
and key to write to.
value: The value to store.
"""
...
+11
View File
@@ -0,0 +1,11 @@
"""
Interfaces module for the AutoLibrary project.
Defines abstract interfaces (Protocols) and shared type definitions
used across layers to decouple consumers from concrete implementations.
Key components:
- ConfigProvider: Abstract interface for configuration access.
- ConfigType: Enumeration of configuration file types.
- ConfigKey: Type-safe hierarchical key constants for config lookups.
"""
+14 -73
View File
@@ -10,26 +10,17 @@ See the LICENSE file for details.
import os
import threading
from enum import Enum
from typing import Any, Optional
from utils.JSONReader import JSONReader
from utils.JSONWriter import JSONWriter
from interfaces.ConfigProvider import ConfigType, ConfigPath
# This config manager class only responsible for global and other
# unconfigurable config files.
class ConfigType(Enum):
"""
Config type class. Values represent the default filename.
"""
GLOBAL = "autolibrary.json" # Global config file.
BULLETIN = "bulletin.json" # Bulletin board config file.
TIMERTASK = "timer_task.json" # Timer task config file.
class ConfigTemplate:
"""
Config template class.
@@ -42,7 +33,6 @@ class ConfigTemplate:
self.__config_type = config_type
def template(
self
) -> dict:
@@ -92,7 +82,6 @@ class ConfigManager:
self.initialize()
def initialize(
self
):
@@ -100,7 +89,6 @@ class ConfigManager:
for config_type in ConfigType:
self.load(config_type)
def load(
self,
config_type: ConfigType
@@ -117,47 +105,42 @@ class ConfigManager:
self.__config_data[config_type.value] = ConfigTemplate(config_type).template()
JSONWriter(config_path, self.__config_data[config_type.value])
def get(
self,
config_type: ConfigType,
key: str = "",
key: ConfigPath,
default: Optional[Any] = None
) -> Any:
with self.__config_lock:
config_data = self.__config_data[config_type.value]
if key == "":
config_data = self.__config_data[key.config_type.value]
if key.key == "":
return config_data
keys = key.split('.')
keys = key.key.split('.')
for k in keys[:-1]:
config_data = config_data.get(k, None)
if config_data is None:
return default
return config_data.get(keys[-1], default)
def set(
self,
config_type: ConfigType,
key: str = "",
key: ConfigPath,
value: Any = None
):
with self.__config_lock:
root_data = self.__config_data[config_type.value]
if key == "":
self.__config_data[config_type.value] = value
root_data = self.__config_data[key.config_type.value]
if key.key == "":
self.__config_data[key.config_type.value] = value
else:
keys = key.split('.')
keys = key.key.split('.')
config_data = root_data
for k in keys[:-1]:
if k not in config_data:
config_data[k] = {}
config_data = config_data[k]
config_data[keys[-1]] = value
self.save(config_type)
self.save(key.config_type)
def save(
self,
@@ -167,7 +150,6 @@ class ConfigManager:
config_path = os.path.join(self.__config_dir, config_type.value)
JSONWriter(config_path, self.__config_data[config_type.value])
def configDir(
self
) -> str:
@@ -176,52 +158,11 @@ class ConfigManager:
# ConfigManager singleton instance.
_config_manager_instance = 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()
_config_manager_instance : ConfigManager | None = None
# Singleton instance of ConfigManager.
_instance_lock = threading.Lock()
def instance(
config_dir: str = ""
) -> ConfigManager:
@@ -240,6 +181,6 @@ def instance(
else:
if config_dir == "":
return _config_manager_instance
if getBaseConfigDir() != config_dir:
if _config_manager_instance.configDir() != config_dir:
raise ValueError("ConfigManager 的实例已初始化,不能使用不同的配置目录。")
return _config_manager_instance
+48
View File
@@ -0,0 +1,48 @@
# -*- 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
from interfaces.ConfigProvider import CfgKey
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(CfgKey.GLOBAL.AUTOMATION.ROOT, {})
for cfg_type in ["run", "user"]:
paths = auto_config.get(f"{cfg_type}_path", {}).get("paths", [])
index = auto_config.get(f"{cfg_type}_path", {}).get("current", 0)
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(CfgKey.GLOBAL.AUTOMATION.ROOT, auto_config)
return config_paths
+24 -7
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 installed_browsers
import browsers
from pathlib import Path
from enum import Enum
@@ -32,6 +41,7 @@ class WebBrowserArch(Enum):
MACX86_64 = 6
MACARM = 7
@dataclass
class WebBrowserInfo:
"""
@@ -61,7 +71,6 @@ class WebBrowserArchDetector:
pass
def detect(
self
) -> WebBrowserArch:
@@ -114,7 +123,6 @@ class WebBrowserDetector:
self.browser_arch = WebBrowserArchDetector().detect()
self.browser_infos : list[WebBrowserInfo] = []
def detect(
self
) -> list[WebBrowserInfo]:
@@ -128,7 +136,7 @@ class WebBrowserDetector:
self.browser_infos = []
try:
all_browsers = installed_browsers.browsers()
all_browsers = list(browsers.browsers())
except Exception as e:
self.browser_infos = []
return self.browser_infos
@@ -140,14 +148,14 @@ class WebBrowserDetector:
'msedge': WebBrowserType.EDGE,
}
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:
continue # Not one of the browsers we care about
version = browser.get('version')
version = browser.get("version", "")
if not version:
# Skip browsers with no version info (unlikely, but defensive)
continue
exe_path = browser.get('location')
exe_path = browser.get("path", "")
if not exe_path:
continue
try:
@@ -163,4 +171,13 @@ class WebBrowserDetector:
browser_path=path,
)
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
+32 -31
View File
@@ -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 time
import shutil
@@ -86,7 +95,6 @@ class WebDriverName:
self.driver_type = driver_type
def __str__(
self
) -> str:
@@ -116,7 +124,6 @@ class WebDriverExecName:
self.driver_type = driver_type
self.arch = arch
def __str__(
self
) -> str:
@@ -191,7 +198,6 @@ class WebDriverURL:
self.arch = arch
self.file_name = str(WebDriverFileName(self.version, self.driver_type, self.arch))
def __str__(
self
) -> str:
@@ -241,31 +247,6 @@ class WebDriverDownloader:
self.download_dir.mkdir(mode=0o0755, parents=True, exist_ok=True)
self.download_path = self.download_dir/str(WebDriverFileName(self.version, self.driver_type, self.arch))
def download(
self,
progress_callback: Optional[Callable[[float, int, float, str], None]] = None,
cancel_event: Optional[threading.Event] = None
) -> Optional[Path]:
try:
# downlaod file : 0% - 98%
if not self._download(progress_callback, cancel_event=cancel_event):
return None
# verify file : 98% - 99%
if not self._verify(progress_callback):
progress_callback(0, 100, 0.0, "验证失败")
return None
# extract file : 99% - 100%
driver_path = self._extract(progress_callback)
if not driver_path:
progress_callback(0, 100, 0.0, "解压失败")
return None
return driver_path
except Exception as e:
raise e
def _download(
self,
progress_callback: Optional[Callable[[float, int, float, str], None]] = None,
@@ -343,7 +324,6 @@ class WebDriverDownloader:
continue
raise e
def _verify(
self,
progress_callback: Optional[Callable[[float, int, float, str], None]] = None
@@ -352,7 +332,6 @@ class WebDriverDownloader:
progress_callback(98, 100, 0.0, "验证完成")
return True
def _extract(
self,
progress_callback: Optional[Callable[[float, int, float, str], None]] = None
@@ -388,7 +367,6 @@ class WebDriverDownloader:
except Exception:
return None
def _cleanup(
self,
driver_file: Path
@@ -401,6 +379,29 @@ class WebDriverDownloader:
else:
item.unlink()
def download(
self,
progress_callback: Optional[Callable[[float, int, float, str], None]] = None,
cancel_event: Optional[threading.Event] = None
) -> Optional[Path]:
try:
# downlaod file : 0% - 98%
if not self._download(progress_callback, cancel_event=cancel_event):
return None
# verify file : 98% - 99%
if not self._verify(progress_callback):
progress_callback(0, 100, 0.0, "验证失败")
return None
# extract file : 99% - 100%
driver_path = self._extract(progress_callback)
if not driver_path:
progress_callback(0, 100, 0.0, "解压失败")
return None
return driver_path
except Exception as e:
raise e
class ChromeDriverDownloader(WebDriverDownloader):
"""
-16
View File
@@ -81,7 +81,6 @@ class WebDriverManager:
self.initialize()
def initialize(
self
):
@@ -93,7 +92,6 @@ class WebDriverManager:
self._checkDriverStatus()
self.__initialized = True
def _detectBrowsers(
self
):
@@ -105,7 +103,6 @@ class WebDriverManager:
for info in browser_infos
]
def _checkDriverStatus(
self
):
@@ -117,7 +114,6 @@ class WebDriverManager:
driver_info.driver_path = driver_path
driver_info.driver_status = WebDriverStatus.INSTALLED
def _mapWebBrowserTypeToDriver(
self,
browser_type: WebBrowserType
@@ -132,7 +128,6 @@ class WebDriverManager:
else:
raise ValueError(f"不支持的 Web 浏览器类型 : {browser_type}")
def _mapWebBrowserArchToDriver(
self,
browser_type: WebBrowserType,
@@ -199,7 +194,6 @@ class WebDriverManager:
else:
raise ValueError(f"不支持的 Web 浏览器类型 : {browser_type}")
def _mapFirefoxDriverVersion(
self,
version: str
@@ -240,7 +234,6 @@ class WebDriverManager:
except Exception as e:
raise ValueError(f"无效的 Firefox 版本格式 : {version}") from e
def _getDriverInfo(
self,
browser_info: WebBrowserInfo
@@ -256,7 +249,6 @@ class WebDriverManager:
driver_info.browser_version = browser_info.browser_version
return driver_info
def _getDriverPath(
self,
driver_info: WebDriverInfo
@@ -286,7 +278,6 @@ class WebDriverManager:
driver_path = driver_dir/exe_name
return driver_path
def refresh(
self
):
@@ -294,7 +285,6 @@ class WebDriverManager:
self._detectBrowsers()
self._checkDriverStatus()
def getDriverInfos(
self
) -> list[WebDriverInfo]:
@@ -302,7 +292,6 @@ class WebDriverManager:
with self.__lock:
return self.__driver_infos.copy()
def getDriverInfo(
self,
driver_type: WebDriverType
@@ -315,7 +304,6 @@ class WebDriverManager:
if info.driver_type == driver_type
]
def getDriverPath(
self,
driver_info: WebDriverInfo
@@ -325,7 +313,6 @@ class WebDriverManager:
return driver_info.driver_path
return None
def installDriver(
self,
driver_info: WebDriverInfo,
@@ -390,7 +377,6 @@ class WebDriverManager:
driver_info.driver_status = WebDriverStatus.ERROR
raise e
def cancelDriverDownload(
self,
driver_info: WebDriverInfo
@@ -411,7 +397,6 @@ class WebDriverManager:
except Exception:
return False
def uninstallDriver(
self,
driver_info: WebDriverInfo,
@@ -441,7 +426,6 @@ class WebDriverManager:
driver_info.driver_status = WebDriverStatus.ERROR
raise
def driverDir(
self
) -> str:
+2 -5
View File
@@ -89,7 +89,6 @@ class LogManager:
self.initialize()
def initialize(
self
):
@@ -139,7 +138,6 @@ class LogManager:
self.__initialized = True
def getLogger(
self,
name: Optional[str] = None
@@ -149,7 +147,6 @@ class LogManager:
return self.__logger.getChild(name)
return self.__logger
def setLevel(
self,
level: int
@@ -158,7 +155,6 @@ class LogManager:
if self.__logger:
self.__logger.setLevel(level)
def logDir(
self
) -> str:
@@ -171,6 +167,7 @@ _log_manager_instance = None
# Singleton instance lock.
_instance_lock = threading.Lock()
def instance(
log_dir: str = ""
) -> LogManager:
@@ -186,7 +183,7 @@ def instance(
raise ValueError("LogManager 的实例已初始化, 不能使用不同的日志目录")
return _log_manager_instance
# export function to get logger
def getLogger(
name: Optional[str] = None
) -> logging.Logger:
+1 -8
View File
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
"""
Copyright (c) 2025 - 2026 KenanZhu.
Copyright (c) 2025 KenanZhu.
All rights reserved.
This software is provided "as is", without any warranty of any kind.
@@ -49,7 +49,6 @@ class AutoLib(MsgBase):
raise Exception("浏览器驱动URL初始化失败 !")
self.__initLibOperators()
def __initBrowserDriver(
self
) -> bool:
@@ -142,7 +141,6 @@ class AutoLib(MsgBase):
self._showTrace(f"浏览器驱动已初始化, 类型: {self.__driver_type}, 路径: {self.__driver_path}")
return True
def __initLibOperators(
self
):
@@ -157,7 +155,6 @@ class AutoLib(MsgBase):
self.__lib_checkin = LibCheckin(self._input_queue, self._output_queue, self.__driver)
self.__lib_renew = LibRenew(self._input_queue, self._output_queue, self.__driver)
def __waitResponseLoad(
self
) -> bool:
@@ -184,7 +181,6 @@ class AutoLib(MsgBase):
self._showTrace(f"登录页面加载失败 !", self.TraceLevel.ERROR)
return False
def __initDriverUrl(
self,
) -> bool:
@@ -207,7 +203,6 @@ class AutoLib(MsgBase):
return False
return True
def __run(
self,
username: str,
@@ -292,7 +287,6 @@ class AutoLib(MsgBase):
return -1
return result
def run(
self,
user_config: dict
@@ -339,7 +333,6 @@ class AutoLib(MsgBase):
)
return
def close(
self
) -> bool:
+1 -13
View File
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
"""
Copyright (c) 2025 - 2026 KenanZhu.
Copyright (c) 2025 KenanZhu.
All rights reserved.
This software is provided "as is", without any warranty of any kind.
@@ -33,7 +33,6 @@ class LibChecker(LibOperator):
self.__driver = driver
def _waitResponseLoad(
self
) -> bool:
@@ -50,7 +49,6 @@ class LibChecker(LibOperator):
seconds = int(seconds%60)
return f"{hours}{minutes}{seconds}"
def __navigateToReserveRecordPage(
self
) -> bool:
@@ -67,7 +65,6 @@ class LibChecker(LibOperator):
return False
return True
def __decodeReserveTime(
self,
time_element
@@ -105,7 +102,6 @@ class LibChecker(LibOperator):
}
}
def __decodeReserveInfo(
self,
info_elements
@@ -133,7 +129,6 @@ class LibChecker(LibOperator):
"status": status,
}
def __decodeReserveRecord(
self,
reservation
@@ -160,7 +155,6 @@ class LibChecker(LibOperator):
"info": info
}
def __loadReserveRecords(
self
) -> list:
@@ -177,7 +171,6 @@ class LibChecker(LibOperator):
self._showTrace("加载预约记录失败 !", self.TraceLevel.ERROR)
return None
def __showMoreReserveRecords(
self
) -> bool:
@@ -203,7 +196,6 @@ class LibChecker(LibOperator):
self._showTrace("加载更多预约记录失败 !", self.TraceLevel.ERROR)
return False
def __getReserveRecord(
self,
wanted_date: str,
@@ -253,7 +245,6 @@ class LibChecker(LibOperator):
break
return None
def canReserve(
self,
date: str
@@ -270,7 +261,6 @@ class LibChecker(LibOperator):
self._showTrace(f"用户在 {date} 已存在有效预约, 无法预约")
return False
def canCheckin(
self
) -> bool:
@@ -307,7 +297,6 @@ class LibChecker(LibOperator):
self._showTrace(f"用户在 {date} 没有有效预约记录, 无法签到")
return False
def canRenew(
self
) -> tuple[bool, dict]:
@@ -335,7 +324,6 @@ class LibChecker(LibOperator):
self._showTrace(f"用户在 {date} 没有有效预约记录, 无法续约")
return False, None
def postRenewCheck(
self,
record: dict
+1 -4
View File
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
"""
Copyright (c) 2025 - 2026 KenanZhu.
Copyright (c) 2025 KenanZhu.
All rights reserved.
This software is provided "as is", without any warranty of any kind.
@@ -31,7 +31,6 @@ class LibCheckin(LibOperator):
self.__driver = driver
def _waitResponseLoad(
self
) -> bool:
@@ -87,7 +86,6 @@ class LibCheckin(LibOperator):
ok_btn.click()
return False
def __enableCheckinBtn(
self
) -> bool:
@@ -112,7 +110,6 @@ class LibCheckin(LibOperator):
self._showTrace("签到按钮启用失败", self.TraceLevel.WARNING)
return result
def checkin(
self,
username: str
+1 -2
View File
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
"""
Copyright (c) 2025 - 2026 KenanZhu.
Copyright (c) 2025 KenanZhu.
All rights reserved.
This software is provided "as is", without any warranty of any kind.
@@ -33,7 +33,6 @@ class LibCheckout(LibOperator):
self.__driver = driver
def _waitResponseLoad(
self
) -> bool:
+1 -9
View File
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
"""
Copyright (c) 2025 - 2026 KenanZhu.
Copyright (c) 2025 KenanZhu.
All rights reserved.
This software is provided "as is", without any warranty of any kind.
@@ -34,7 +34,6 @@ class LibLogin(LibOperator):
self.__driver = driver
self.__ddddocr = ddddocr.DdddOcr()
def _waitResponseLoad(
self
) -> bool:
@@ -58,7 +57,6 @@ class LibLogin(LibOperator):
)
return False
def __fillLogInElements(
self,
username: str,
@@ -78,7 +76,6 @@ class LibLogin(LibOperator):
return False
return True
def __autoRecognizeCaptcha(
self
) -> str:
@@ -100,7 +97,6 @@ class LibLogin(LibOperator):
self._showTrace(f"验证码识别失败 ! : {e}", self.TraceLevel.ERROR)
return ""
def __manualRecognizeCaptcha(
self
) -> str:
@@ -118,7 +114,6 @@ class LibLogin(LibOperator):
self._showTrace(f"输入验证码失败 ! : {e}", self.TraceLevel.ERROR)
return ""
def __refreshCaptcha(
self
):
@@ -134,7 +129,6 @@ class LibLogin(LibOperator):
self._showTrace(f"刷新验证码失败 ! : {e}", self.TraceLevel.ERROR)
return False
def __solveCaptcha(
self,
auto_captcha: bool = True
@@ -158,7 +152,6 @@ class LibLogin(LibOperator):
)
return ""
def __fillCaptchaElement(
self,
captcha_text: str
@@ -173,7 +166,6 @@ class LibLogin(LibOperator):
self._showTrace(f"验证码填写失败 ! : {e}", self.TraceLevel.ERROR)
return False
def login(
self,
username: str,
+1 -3
View File
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
"""
Copyright (c) 2025 - 2026 KenanZhu.
Copyright (c) 2025 KenanZhu.
All rights reserved.
This software is provided "as is", without any warranty of any kind.
@@ -28,14 +28,12 @@ class LibLogout(LibOperator):
self.__driver = driver
def _waitResponseLoad(
self
) -> bool:
return True
def logout(
self,
username: str
+2 -8
View File
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
"""
Copyright (c) 2025 - 2026 KenanZhu.
Copyright (c) 2025 KenanZhu.
All rights reserved.
This software is provided "as is", without any warranty of any kind.
@@ -14,7 +14,7 @@ from selenium.webdriver.chrome.webdriver import WebDriver
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from base.LibTimeSelector import LibTimeSelector
from operators.abs.LibTimeSelector import LibTimeSelector
class LibRenew(LibTimeSelector):
@@ -30,7 +30,6 @@ class LibRenew(LibTimeSelector):
self.__driver = driver
def _waitResponseLoad(
self
) -> bool:
@@ -38,7 +37,6 @@ class LibRenew(LibTimeSelector):
self.__driver.refresh()
return True
def __waitRenewDialog(
self
) -> bool:
@@ -77,7 +75,6 @@ class LibRenew(LibTimeSelector):
return False
return True
def __selectNearestTime(
self,
record: dict,
@@ -116,7 +113,6 @@ class LibRenew(LibTimeSelector):
self._showTrace(f"当前可供续约的时间有: {free_times}")
return False
def __validateAndAdjustRenewTime(
self,
end_time: str,
@@ -139,7 +135,6 @@ class LibRenew(LibTimeSelector):
return True
return True
def __confirmRenewal(
self,
best_opt,
@@ -167,7 +162,6 @@ class LibRenew(LibTimeSelector):
self._showTrace("确认续约时发生错误 !", self.TraceLevel.ERROR)
return False
def renew(
self,
username: str,
+4 -23
View File
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
"""
Copyright (c) 2025 - 2026 KenanZhu.
Copyright (c) 2025 KenanZhu.
All rights reserved.
This software is provided "as is", without any warranty of any kind.
@@ -15,7 +15,7 @@ from selenium.webdriver.chrome.webdriver import WebDriver
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from base.LibTimeSelector import LibTimeSelector
from operators.abs.LibTimeSelector import LibTimeSelector
class LibReserve(LibTimeSelector):
@@ -48,7 +48,6 @@ class LibReserve(LibTimeSelector):
"8": "五层考研"
}
def _waitResponseLoad(
self,
) -> bool:
@@ -99,7 +98,6 @@ class LibReserve(LibTimeSelector):
self._showTrace(f"预约结果加载失败 !", self.TraceLevel.ERROR)
return False
def __containRequiredInfo(
self,
reserve_info: dict
@@ -135,7 +133,6 @@ class LibReserve(LibTimeSelector):
)
return False
def __isValidDate(
self,
reserve_info: dict
@@ -157,7 +154,6 @@ class LibReserve(LibTimeSelector):
reserve_info["date"] = cur_date_str
return True
def __isValidBeginTime(
self,
reserve_info: dict
@@ -177,7 +173,6 @@ class LibReserve(LibTimeSelector):
self._showTrace(f"是否优先选择更早开始时间未指定, 自动设置为 True")
return True
def __isValidExpectDuration(
self,
reserve_info: dict
@@ -192,7 +187,6 @@ class LibReserve(LibTimeSelector):
self._showTrace("需要满足预约持续时间, 但未指定, 使用默认时长为 4 小时")
return True
def __isValidEndTime(
self,
reserve_info: dict
@@ -222,7 +216,6 @@ class LibReserve(LibTimeSelector):
self._showTrace(f"是否优先选择较晚结束时间未指定, 自动设置为 True")
return True
def __finalCheck(
self,
reserve_info: dict
@@ -275,7 +268,6 @@ class LibReserve(LibTimeSelector):
reserve_info["end_time"]["time"] = self._minsToTimeStr(begin_mins + 8*60)
return True
def __checkReserveInfo(
self,
reserve_info: dict
@@ -305,7 +297,6 @@ class LibReserve(LibTimeSelector):
)
return True
def __clickElement(
self,
trigger_locator: tuple,
@@ -330,7 +321,6 @@ class LibReserve(LibTimeSelector):
self._showTrace(fail_msg)
return False
def __clickElementByJS(
self,
trigger_locator_id: str,
@@ -364,7 +354,6 @@ class LibReserve(LibTimeSelector):
self._showTrace(fail_msg)
return result
def __selectDate(
self,
date_str: str
@@ -384,7 +373,6 @@ class LibReserve(LibTimeSelector):
fail_msg=f"选择日期失败 ! : {date_str} 不可用"
)
def __selectPlace(
self,
place: str
@@ -406,7 +394,6 @@ class LibReserve(LibTimeSelector):
fail_msg=f"选择预约场所失败 ! : {display_place} 不可用"
)
def __selectFloor(
self,
floor: str
@@ -427,7 +414,6 @@ class LibReserve(LibTimeSelector):
fail_msg=f"选择楼层失败 ! : {display_floor} 不可用"
)
def __selectRoom(
self,
room: str
@@ -453,7 +439,6 @@ class LibReserve(LibTimeSelector):
self._showTrace(f"选择房间失败 ! : {display_room} 不可用", self.TraceLevel.ERROR)
return False
def __selectSeat(
self,
seat_id: str
@@ -492,7 +477,6 @@ class LibReserve(LibTimeSelector):
self._showTrace(f"座位选择失败 !", self.TraceLevel.ERROR)
return False
def __selectNearestTime(
self,
time_id: str,
@@ -547,7 +531,6 @@ class LibReserve(LibTimeSelector):
self._showTrace(f"当前可供预约的 {time_type} 有: {free_times}")
return -1
def __selectSeatTime(
self,
begin_time: dict,
@@ -583,7 +566,7 @@ class LibReserve(LibTimeSelector):
# If 'satisfy_duration' is True, select end time based on actual begin time
if satisfy_duration:
exp_end_mins = int(self.validateAndAdjustEndTime(act_beg_mins, expect_duration))
exp_end_mins = int(self.__validateAndAdjustEndTime(act_beg_mins, expect_duration))
exp_end_tm_str = self._minsToTimeStr(exp_end_mins)
self._showTrace(
f"需要满足期望预约持续时间: {expect_duration} 小时, "
@@ -607,8 +590,7 @@ class LibReserve(LibTimeSelector):
)
return True
def validateAndAdjustEndTime(
def __validateAndAdjustEndTime(
self,
begin_mins: int,
duration: int
@@ -627,7 +609,6 @@ class LibReserve(LibTimeSelector):
)
return expect_end_mins
def reserve(
self,
username: str,
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
"""
Copyright (c) 2025 - 2026 KenanZhu.
Copyright (c) 2026 KenanZhu.
All rights reserved.
This software is provided "as is", without any warranty of any kind.
@@ -16,7 +16,7 @@ from base.LibOperator import LibOperator
class LibTimeSelector(LibOperator):
"""
Base class for time selection operations.
Abstract base class for time selection operations.
This class provides common time selection logic for reservation and renewal
operations, including time conversion utilities and best time option finding.
@@ -60,7 +60,6 @@ class LibTimeSelector(LibOperator):
hour, minute = divmod(int(mins), 60)
return f"{hour:02d}:{minute:02d}"
def _formatTimeRelation(
self,
abs_diff: int,
@@ -78,7 +77,6 @@ class LibTimeSelector(LibOperator):
else:
return f"正好等于 {time_type}"
def _findBestTimeOption(
self,
time_options: list,
+6
View File
@@ -0,0 +1,6 @@
"""
Abstract layer class of the LibOperator
Here are the classes and modules in this package:
- LibTimeSelector: Abstract base class for time selection operations.
"""
-4
View File
@@ -42,7 +42,6 @@ class JSONReader:
self.__json_data = None
self.__read()
def __read(
self
):
@@ -59,7 +58,6 @@ class JSONReader:
except Exception as e:
raise Exception(f"读取文件时发生未知错误: {e}") from e
def read(
self
) -> bool:
@@ -70,14 +68,12 @@ class JSONReader:
return False
return True
def data(
self
) -> dict:
return self.__json_data.copy()
def path(
self
) -> str:
-3
View File
@@ -46,7 +46,6 @@ class JSONWriter:
self.__json_data = json_data.copy() if json_data is not None else {}
self.__write()
def __write(
self
):
@@ -63,7 +62,6 @@ class JSONWriter:
except Exception as e:
raise Exception(f"写入文件时发生未知错误: {e}") from e
def write(
self
) -> bool:
@@ -74,7 +72,6 @@ class JSONWriter:
return False
return True
def path(
self
) -> str:
+42 -36
View File
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
"""
Copyright (c) 2025 - 2026 KenanZhu.
Copyright (c) 2026 KenanZhu.
All rights reserved.
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
def calculateNextRepeatTime(
repeat_days: list,
hour: int,
minute: int,
second: int
) -> datetime:
class TimerUtils:
"""
Calculate the next repeat time based on repeat days and target time.
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.
Timer utilities class.
"""
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
@staticmethod
def getNextTimerRepeatTime(
repeat_days: list[int],
hour: int,
minute: int,
second: int
) -> datetime:
"""
Calculate the next repeat time based on repeat days and target time.
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[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
+1 -1
View File
@@ -5,4 +5,4 @@
- TimerUtils: Timer utils class for the AutoLibrary project.
- JSONReader: JSON reader class for the AutoLibrary project.
- JSONWriter: JSON writer class for the AutoLibrary project.
"""
"""