From 4d0d7a952cc417978e847eec81f83dd69880d494 Mon Sep 17 00:00:00 2001 From: Gogs Date: Fri, 8 May 2026 15:23:24 +0800 Subject: [PATCH 1/4] =?UTF-8?q?feat(preproc):=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E9=80=82=E7=94=A8=E4=BA=8E=E9=87=8D=E5=A4=8D=E6=80=A7=E5=AE=9A?= =?UTF-8?q?=E6=97=B6=E4=BB=BB=E5=8A=A1=E7=9A=84=E9=A2=84=E5=A4=84=E7=90=86?= =?UTF-8?q?=E8=84=9A=E6=9C=AC=E4=BB=A5=E5=8F=8A=E5=8F=AF=E8=A7=86=E5=8C=96?= =?UTF-8?q?=E7=BC=96=E6=8E=92=E5=AF=B9=E8=AF=9D=E6=A1=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/gui/ALMainWorkers.py | 89 +- src/gui/ALPreProcPrevDialog.py | 226 +++++ src/gui/ALPreprocOrchDialog.py | 864 +++++++++++++++++++ src/gui/ALTimerTaskAddDialog.py | 60 +- src/gui/__init__.py | 1 + src/gui/resources/ui/ALTimerTaskAddDialog.ui | 2 +- src/utils/PreprocEngine.py | 328 +++++++ 7 files changed, 1560 insertions(+), 10 deletions(-) create mode 100644 src/gui/ALPreProcPrevDialog.py create mode 100644 src/gui/ALPreprocOrchDialog.py create mode 100644 src/utils/PreprocEngine.py diff --git a/src/gui/ALMainWorkers.py b/src/gui/ALMainWorkers.py index c1503d1..3c9033b 100644 --- a/src/gui/ALMainWorkers.py +++ b/src/gui/ALMainWorkers.py @@ -18,6 +18,7 @@ from PySide6.QtCore import ( from base.MsgBase import MsgBase from operators.AutoLib import AutoLib from utils.JSONReader import JSONReader +from utils.PreprocEngine import PreprocEngine class AutoLibWorker(MsgBase, QThread): @@ -76,25 +77,28 @@ 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 @@ -115,9 +119,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 +166,76 @@ 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._applyRepeatPreproc() + 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 _applyRepeatPreproc( + self + ): + + preproc_script = self.__timer_task.get("repeat_preproc", "") + if not preproc_script or not preproc_script.strip(): + return + self._showTrace( + f"检测到重复定时任务预处理脚本, 开始执行...", + 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: + PreprocEngine.execute(preproc_script, user) + affected_count += 1 + except ValueError as e: + self._showTrace( + f"预处理脚本执行错误 (用户 {user['username']}): {e}", + self.TraceLevel.ERROR + ) + self._showLog( + f"预处理脚本执行完毕, " + f"影响 {affected_count} 个用户", + self.TraceLevel.INFO + ) @Slot() def onTimerTaskFinishedWithError( diff --git a/src/gui/ALPreProcPrevDialog.py b/src/gui/ALPreProcPrevDialog.py new file mode 100644 index 0000000..8ed6f63 --- /dev/null +++ b/src/gui/ALPreProcPrevDialog.py @@ -0,0 +1,226 @@ +# -*- coding: utf-8 -*- +""" +Copyright (c) 2026 KenanZhu. +All rights reserved. + +This software is provided "as is", without any warranty of any kind. +You may use, modify, and distribute this file under the terms of the MIT License. +See the LICENSE file for details. +""" + +from PySide6.QtGui import ( + QSyntaxHighlighter, QTextCharFormat, QColor, QFont, QIcon +) +from PySide6.QtWidgets import ( + QDialog, QVBoxLayout, QHBoxLayout, QPlainTextEdit, + QDialogButtonBox, QPushButton, QLabel, QApplication, QStyle +) + + +class ALScriptHighlighter(QSyntaxHighlighter): + + def __init__( + self, + parent=None + ): + + super().__init__(parent) + self._rules = [] + + keywordFmt = QTextCharFormat() + keywordFmt.setForeground(QColor("#316BFF")) + keywordFmt.setFontWeight(QFont.Weight.Bold) + for kw in ["IF", "ELSE IF", "ELSE", "ENDIF", "END IF", + "SET", "PASS", "THEN", ".TRUE.", ".FALSE."]: + pattern = r"\b" + kw.replace(" ", r"\s+") + r"\b" + self._rules.append((pattern, keywordFmt)) + + opFmt = QTextCharFormat() + opFmt.setForeground(QColor("#9C27B0")) + for op in [r"\.EQ\.", r"\.NEQ\.", r"\.BGT\.", r"\.BLT\.", + r"\.BGE\.", r"\.BLE\.", r"\.ADD\.", r"\.SUB\."]: + self._rules.append((op, opFmt)) + + varFmt = QTextCharFormat() + varFmt.setForeground(QColor("#E65100")) + for var in ["RESERVE_BEGIN_TIME", "RESERVE_END_TIME", + "RESERVE_DATE", "USERNAME", "USER_ENABLE", + "PRIORITY", "CURRENT_TIME", "CURRENT_DATE"]: + self._rules.append((r"\b" + var + r"\b", varFmt)) + + funcFmt = QTextCharFormat() + funcFmt.setForeground(QColor("#2E7D32")) + self._rules.append((r"\bTIME\([^)]+\)", funcFmt)) + self._rules.append((r"\bDATE\([^)]+\)", funcFmt)) + + strFmt = QTextCharFormat() + strFmt.setForeground(QColor("#388E3C")) + self._rules.append((r"'[^']*'", strFmt)) + + numFmt = QTextCharFormat() + numFmt.setForeground(QColor("#D32F2F")) + self._rules.append((r"\b\d+\b", numFmt)) + + commentFmt = QTextCharFormat() + commentFmt.setForeground(QColor("#999999")) + commentFmt.setFontItalic(True) + self._rules.append((r"//[^\n]*", commentFmt)) + + def highlightBlock( + self, + text + ): + + import re + for pattern, fmt in self._rules: + for match in re.finditer(pattern, text, re.IGNORECASE): + start = match.start() + length = match.end() - match.start() + self.setFormat(start, length, fmt) + + +class ALScriptPreviewDialog(QDialog): + + def __init__( + self, + parent=None, + script: str = "" + ): + + super().__init__(parent) + + self.__fontSize = 13 + + self.modifyUi() + self.connectSignals() + + self._textEdit.setPlainText(script) + self._highlighter = ALScriptHighlighter( + self._textEdit.document() + ) + + + def modifyUi( + self + ): + + self.setWindowTitle("预处理脚本预览 - AutoLibrary") + self.setMinimumSize(520, 360) + + layout = QVBoxLayout(self) + toolbarLayout = QHBoxLayout() + self._zoomInBtn = QPushButton("+") + self._zoomInBtn.setFixedSize(30, 25) + self._zoomOutBtn = QPushButton("-") + self._zoomOutBtn.setFixedSize(30, 25) + self._zoomResetBtn = QPushButton( + QApplication.style().standardIcon( + QStyle.StandardPixmap.SP_BrowserReload + ), "" + ) + self._zoomResetBtn.setFixedSize(30, 25) + self._zoomResetBtn.setToolTip("重置缩放") + self._zoomLabel = QLabel(f"{self.__fontSize}px") + self._zoomLabel.setFixedHeight(25) + toolbarLayout.addWidget(self._zoomInBtn) + toolbarLayout.addWidget(self._zoomOutBtn) + toolbarLayout.addWidget(self._zoomResetBtn) + toolbarLayout.addWidget(self._zoomLabel) + toolbarLayout.addStretch() + self._copyBtn = QPushButton( + QApplication.style().standardIcon( + QStyle.StandardPixmap.SP_FileDialogDetailedView + ), "" + ) + self._copyBtn.setFixedSize(30, 25) + self._copyBtn.setToolTip("复制脚本") + toolbarLayout.addWidget(self._copyBtn) + layout.addLayout(toolbarLayout) + + self._textEdit = QPlainTextEdit(self) + self._textEdit.setReadOnly(True) + self._textEdit.setLineWrapMode( + QPlainTextEdit.LineWrapMode.NoWrap + ) + self._textEdit.setStyleSheet( + "QPlainTextEdit {" + " font-family: 'Courier New', 'Consolas', monospace;" + " font-size: 13px;" + "}" + ) + layout.addWidget(self._textEdit) + + self._btnBox = QDialogButtonBox( + QDialogButtonBox.StandardButton.Close + ) + self._btnBox.button( + QDialogButtonBox.StandardButton.Close + ).setText("关闭") + layout.addWidget(self._btnBox) + + + def connectSignals( + self + ): + + self._btnBox.rejected.connect(self.reject) + self._zoomInBtn.clicked.connect(self._onZoomIn) + self._zoomOutBtn.clicked.connect(self._onZoomOut) + self._zoomResetBtn.clicked.connect(self._onZoomReset) + self._copyBtn.clicked.connect(self._onCopy) + + + def _onZoomIn( + self + ): + + self.__fontSize = min(self.__fontSize + 2, 40) + self._updateFontSize() + + + def _onZoomOut( + self + ): + + self.__fontSize = max(self.__fontSize - 2, 8) + self._updateFontSize() + + + def _onZoomReset( + self + ): + + self.__fontSize = 13 + self._updateFontSize() + + + def _onCopy( + self + ): + + clipboard = QApplication.clipboard() + clipboard.setText(self._textEdit.toPlainText()) + original = self._copyBtn.text() + self._copyBtn.setText("已复制") + self._copyBtn.setEnabled(False) + from PySide6.QtCore import QTimer + QTimer.singleShot(2000, lambda: ( + self._copyBtn.setText(original), + self._copyBtn.setEnabled(True) + )) + + + def _updateFontSize( + self + ): + + font = self._textEdit.font() + font.setPointSize(self.__fontSize) + self._textEdit.setFont(font) + self._textEdit.setStyleSheet( + "QPlainTextEdit {" + " font-family: 'Courier New', 'Consolas', monospace;" + f" font-size: {self.__fontSize}px;" + "}" + ) + self._zoomLabel.setText(f"{self.__fontSize}px") diff --git a/src/gui/ALPreprocOrchDialog.py b/src/gui/ALPreprocOrchDialog.py new file mode 100644 index 0000000..20509c4 --- /dev/null +++ b/src/gui/ALPreprocOrchDialog.py @@ -0,0 +1,864 @@ +# -*- coding: utf-8 -*- +""" +Copyright (c) 2026 KenanZhu. +All rights reserved. + +This software is provided "as is", without any warranty of any kind. +You may use, modify, and distribute this file under the terms of the MIT License. +See the LICENSE file for details. +""" + +from PySide6.QtCore import QTime, QDate +from PySide6.QtWidgets import ( + QDialog, QWidget, QVBoxLayout, QHBoxLayout, QLabel, + QComboBox, QPushButton, QScrollArea, QTimeEdit, + QDateEdit, QLineEdit, QSpinBox, QDoubleSpinBox, + QStackedWidget, QFrame, QDialogButtonBox, + QGroupBox, QSizePolicy +) + +from utils.PreprocEngine import PreprocEngine + + +VARIABLE_META = PreprocEngine.VARIABLE_META + +_VAR_COMBO_ITEMS = [ + (display, varname, vartype) + for display, (varname, vartype) in VARIABLE_META.items() +] + +_VAR_COMBO_ITEMS_SET = [ + (display, varname, vartype) + for display, (varname, vartype) in VARIABLE_META.items() + if not varname.startswith("CURRENT_") +] + +OP_ITEMS = [ + ("等于", ".EQ."), + ("不等于", ".NEQ."), + ("大于", ".BGT."), + ("小于", ".BLT."), + ("大于等于", ".BGE."), + ("小于等于", ".BLE."), +] + + +def _makeVarCombo( +) -> QComboBox: + + cb = QComboBox() + for display, varname, vartype in _VAR_COMBO_ITEMS: + cb.addItem(display, (varname, vartype)) + cb.setMinimumWidth(120) + cb.setFixedHeight(25) + return cb + + +def _makeSetVarCombo( +) -> QComboBox: + + cb = QComboBox() + for display, varname, vartype in _VAR_COMBO_ITEMS_SET: + cb.addItem(display, (varname, vartype)) + cb.setMinimumWidth(120) + cb.setFixedHeight(25) + return cb + + +def _makeOpCombo( +) -> QComboBox: + + cb = QComboBox() + for display, op in OP_ITEMS: + cb.addItem(display, op) + cb.setMinimumWidth(80) + cb.setFixedHeight(25) + return cb + + +def _makeValueWidget( + data_type: str +) -> QWidget: + + if data_type == "Time": + w = QTimeEdit() + w.setDisplayFormat("HH:mm") + w.setMinimumWidth(100) + w.setFixedHeight(25) + elif data_type == "Date": + w = QDateEdit() + w.setDisplayFormat("yyyy-MM-dd") + w.setCalendarPopup(True) + w.setMinimumWidth(130) + w.setFixedHeight(25) + elif data_type == "Integer": + w = QSpinBox() + w.setMinimum(-999999) + w.setMaximum(999999) + w.setMinimumWidth(100) + w.setFixedHeight(25) + elif data_type == "Float": + w = QDoubleSpinBox() + w.setMinimum(-999999) + w.setMaximum(999999) + w.setDecimals(2) + w.setMinimumWidth(100) + w.setFixedHeight(25) + elif data_type == "Boolean": + w = QComboBox() + w.addItem(".TRUE.", ".TRUE.") + w.addItem(".FALSE.", ".FALSE.") + w.setMinimumWidth(100) + w.setFixedHeight(25) + else: + w = QLineEdit() + w.setPlaceholderText("输入值") + w.setMinimumWidth(120) + w.setFixedHeight(25) + return w + + +def _makeActionValueWidget( + data_type: str +) -> QWidget: + + if data_type == "Date": + w = QComboBox() + w.addItem("今天", "today") + w.addItem("明天", "tomorrow") + w.setFixedHeight(25) + w.setMinimumWidth(100) + w._is_date_action = True + return w + + if data_type == "Time": + container = QWidget() + layout = QHBoxLayout(container) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(2) + modeCombo = QComboBox() + modeCombo.addItem("固定时间", "fixed") + modeCombo.addItem("相对当前", "relative") + modeCombo.setFixedHeight(25) + stack = QStackedWidget() + timeEdit = QTimeEdit() + timeEdit.setDisplayFormat("HH:mm") + timeEdit.setFixedHeight(25) + spinBox = QSpinBox() + spinBox.setRange(0, 23) + spinBox.setSuffix("小时") + spinBox.setFixedHeight(25) + stack.addWidget(timeEdit) + stack.addWidget(spinBox) + modeCombo.currentIndexChanged.connect( + lambda i: stack.setCurrentIndex(i) + ) + layout.addWidget(modeCombo) + layout.addWidget(stack) + container._modeCombo = modeCombo + container._timeEdit = timeEdit + container._spinBox = spinBox + container._isActionTime = True + return container + + return _makeValueWidget(data_type) + + +def _getValueFromWidget( + w: QWidget +) -> str: + + if getattr(w, '_isActionTime', False): + if w._modeCombo.currentData() == "fixed": + return w._timeEdit.time().toString("HH:mm") + else: + return f"+{w._spinBox.value()}" + if isinstance(w, QTimeEdit): + return w.time().toString("HH:mm") + if isinstance(w, QDateEdit): + return w.date().toString("yyyy-MM-dd") + if isinstance(w, QComboBox): + return w.currentText() + if isinstance(w, QSpinBox): + return str(w.value()) + if isinstance(w, QDoubleSpinBox): + return str(w.value()) + if isinstance(w, QLineEdit): + return w.text() + return "" + + +def _encodeValueStr( + raw_value: str, + data_type: str +) -> str: + + if data_type == "Time": + if raw_value.startswith("+"): + return raw_value + return f"TIME({raw_value})" + elif data_type == "Date": + if raw_value == "今天": + return "CURRENT_DATE" + elif raw_value == "明天": + return "CURRENT_DATE + 1" + return f"DATE({raw_value})" + elif data_type == "Boolean": + return raw_value + elif data_type == "String": + escaped = raw_value.replace("'", "''") + return f"'{escaped}'" + else: + return raw_value + + +def _setWidgetValue( + w: QWidget, + vartype: str, + expr: str +): + + import re + s = expr.strip() + + if getattr(w, '_isActionTime', False): + timeMatch = re.match(r"TIME\((\d{1,2}:\d{2})\)", s, re.IGNORECASE) + if timeMatch: + w._modeCombo.setCurrentIndex(0) + parts = timeMatch.group(1).split(":") + w._timeEdit.setTime(QTime(int(parts[0]), int(parts[1]))) + return + relMatch = re.match(r"^\+(\d+)$", s) + if relMatch: + w._modeCombo.setCurrentIndex(1) + w._spinBox.setValue(int(relMatch.group(1))) + return + return + if getattr(w, '_is_date_action', False) and isinstance(w, QComboBox): + if s.upper() in ("CURRENT_DATE", "TODAY"): + w.setCurrentIndex(0) + elif s.upper() in ("CURRENT_DATE + 1", "TOMORROW"): + w.setCurrentIndex(1) + else: + dateMatch = re.match( + r"DATE\((\d{4}-\d{2}-\d{2})\)", s, re.IGNORECASE + ) + if dateMatch: + from datetime import datetime, timedelta + dateStr = dateMatch.group(1) + today = datetime.now().strftime("%Y-%m-%d") + tomorrow = ( + datetime.now() + timedelta(days=1) + ).strftime("%Y-%m-%d") + if dateStr == today: + w.setCurrentIndex(0) + elif dateStr == tomorrow: + w.setCurrentIndex(1) + return + if vartype == "Time": + m = re.match(r"TIME\((\d{1,2}:\d{2})\)", s, re.IGNORECASE) + if m and isinstance(w, QTimeEdit): + parts = m.group(1).split(":") + w.setTime(QTime(int(parts[0]), int(parts[1]))) + elif vartype == "Date": + m = re.match(r"DATE\((\d{4}-\d{2}-\d{2})\)", s, re.IGNORECASE) + if m and isinstance(w, QDateEdit): + parts = m.group(1).split("-") + w.setDate(QDate(int(parts[0]), int(parts[1]), int(parts[2]))) + elif vartype == "Boolean" and isinstance(w, QComboBox): + for i in range(w.count()): + if w.itemData(i) == s.upper(): + w.setCurrentIndex(i) + break + elif vartype == "Integer" and isinstance(w, QSpinBox): + try: + w.setValue(int(s)) + except ValueError: + pass + elif vartype == "Float" and isinstance(w, QDoubleSpinBox): + try: + w.setValue(float(s)) + except ValueError: + pass + elif isinstance(w, QLineEdit): + inner = s + if (inner.startswith("'") and inner.endswith("'")) or \ + (inner.startswith('"') and inner.endswith('"')): + inner = inner[1:-1].replace("''", "'") + w.setText(inner) + + +class ActionStepFrame(QFrame): + + def __init__( + self, + parent=None + ): + super().__init__(parent) + + self.setupUi() + self.connectSignals() + self._onTargetChanged(0) + + + def setupUi( + self + ): + + self.setFrameShape(QFrame.Shape.StyledPanel) + self.setFrameShadow(QFrame.Shadow.Raised) + self.setFixedHeight(35) + + layout = QHBoxLayout(self) + layout.setContentsMargins(2, 2, 2, 2) + layout.setSpacing(4) + + self.targetCombo = _makeSetVarCombo() + self.valueWidgetStack = QStackedWidget() + self.valueWidgetStack.setFixedHeight(25) + self._initValueStack() + + setLabel = QLabel("设置") + setLabel.setFixedHeight(25) + layout.addWidget(setLabel) + layout.addWidget(self.targetCombo) + toLabel = QLabel("为") + toLabel.setFixedHeight(25) + layout.addWidget(toLabel) + layout.addWidget(self.valueWidgetStack) + + self.deleteBtn = QPushButton("×") + self.deleteBtn.setFixedSize(24, 25) + self.deleteBtn.setStyleSheet("color: red; font-weight: bold;") + layout.addWidget(self.deleteBtn) + + + def connectSignals( + self + ): + + self.targetCombo.currentIndexChanged.connect(self._onTargetChanged) + + def _initValueStack( + self + ): + + self._valueWidgets = {} + for _, _, vartype in _VAR_COMBO_ITEMS: + if vartype not in self._valueWidgets: + w = _makeActionValueWidget(vartype) + self._valueWidgets[vartype] = w + self.valueWidgetStack.addWidget(w) + self.valueWidgetStack.setCurrentWidget( + self._valueWidgets.get("String", self.valueWidgetStack.widget(0)) + ) + + def _onTargetChanged( + self, + idx + ): + if idx < 0: + return + data = self.targetCombo.itemData(idx) + if data: + _, vartype = data + w = self._valueWidgets.get(vartype) + if w: + self.valueWidgetStack.setCurrentWidget(w) + + def getTarget( + self + ) -> str: + + data = self.targetCombo.currentData() + return data[0] if data else "" + + def getTargetType( + self + ) -> str: + + data = self.targetCombo.currentData() + return data[1] if data else "String" + + def getValueRaw( + self + ) -> str: + + currentType = self.getTargetType() + w = self._valueWidgets.get(currentType) + if w: + return _getValueFromWidget(w) + return "" + + def toScriptLine( + self + ) -> str: + + target = self.getTarget() + if not target: + return "" + rawVal = self.getValueRaw() + targetType = self.getTargetType() + encoded = _encodeValueStr(rawVal, targetType) + if targetType == "Time" and rawVal.startswith("+"): + hours = rawVal[1:] + return f" {target} .ADD. {hours}" + return f" SET {target} = {encoded}" + + def loadFromScript( + self, + targetVar: str, + valueExpr: str + ): + + for idx in range(self.targetCombo.count()): + data = self.targetCombo.itemData(idx) + if data and data[0] == targetVar: + self.targetCombo.setCurrentIndex(idx) + break + self._setValueFromExpr(valueExpr) + + def _setValueFromExpr( + self, + expr: str + ): + + targetType = self.getTargetType() + w = self._valueWidgets.get(targetType) + if not w: + return + _setWidgetValue(w, targetType, expr) + + +class ConditionalBlock(QGroupBox): + + def __init__( + self, + blockIndex: int, + parent=None + ): + super().__init__(parent) + + self.blockIndex = blockIndex + self._actionWidgets = [] + + self.setupUi() + self.connectSignals() + self._onOperandChanged(0) + + + def setupUi( + self + ): + + self.setStyleSheet( + "QGroupBox { font-weight: bold; border: 1px solid #ccc; " + "margin-top: 8px; padding-top: 8px; }" + ) + self.setSizePolicy( + QSizePolicy.Policy.Preferred, + QSizePolicy.Policy.Fixed + ) + + mainLayout = QVBoxLayout(self) + mainLayout.setSpacing(4) + mainLayout.setContentsMargins(5, 5, 5, 5) + + headerLayout = QHBoxLayout() + self.typeCombo = QComboBox() + self.typeCombo.addItem("IF", "IF") + self.typeCombo.addItem("ELSE IF", "ELSE IF") + self.typeCombo.addItem("ELSE", "ELSE") + if self.blockIndex == 0: + self.typeCombo.setEnabled(False) + typeLabel = QLabel("类型:") + typeLabel.setFixedHeight(25) + headerLayout.addWidget(typeLabel) + headerLayout.addWidget(self.typeCombo) + headerLayout.addStretch() + self.deleteBlockBtn = QPushButton("删除此块") + self.deleteBlockBtn.setStyleSheet("color: red;") + self.deleteBlockBtn.setFixedHeight(25) + headerLayout.addWidget(self.deleteBlockBtn) + mainLayout.addLayout(headerLayout) + + self.conditionWidget = QWidget() + self.conditionWidget.setFixedHeight(60) + condLayout = QHBoxLayout(self.conditionWidget) + condLayout.setContentsMargins(0, 0, 0, 0) + ifLabel = QLabel("如果") + ifLabel.setFixedHeight(25) + condLayout.addWidget(ifLabel) + self.operandCombo = _makeVarCombo() + condLayout.addWidget(self.operandCombo) + self.opCombo = _makeOpCombo() + condLayout.addWidget(self.opCombo) + + self.condValueStack = QStackedWidget() + self.condValueStack.setFixedHeight(25) + self._condValueWidgets = {} + for vartype in ["Time", "Date", "String", "Integer", "Float", "Boolean"]: + w = _makeValueWidget(vartype) + self._condValueWidgets[vartype] = w + self.condValueStack.addWidget(w) + self.condValueStack.setCurrentWidget(self._condValueWidgets.get("String")) + condLayout.addWidget(self.condValueStack) + mainLayout.addWidget(self.conditionWidget) + + self.actionLabel = QLabel("执行步骤:") + self.actionLabel.setFixedHeight(25) + mainLayout.addWidget(self.actionLabel) + + self.actionsLayout = QVBoxLayout() + self.actionsLayout.setSpacing(2) + mainLayout.addLayout(self.actionsLayout) + + self.addActionBtn = QPushButton("+ 添加执行步骤") + self.addActionBtn.setFixedHeight(25) + mainLayout.addWidget(self.addActionBtn) + + + def connectSignals( + self + ): + + self.operandCombo.currentIndexChanged.connect(self._onOperandChanged) + self.typeCombo.currentIndexChanged.connect(self._onTypeChanged) + self.addActionBtn.clicked.connect(self._addActionStep) + + def _onOperandChanged( + self, + idx + ): + if idx < 0: + return + data = self.operandCombo.itemData(idx) + if data: + _, vartype = data + w = self._condValueWidgets.get(vartype) + if w: + self.condValueStack.setCurrentWidget(w) + + def _onTypeChanged( + self, + idx + ): + isCond = self.typeCombo.currentData() in ("IF", "ELSE IF") + self.conditionWidget.setVisible(isCond) + self.actionLabel.setText("执行步骤:" if isCond else "ELSE 执行步骤:") + + def _addActionStep( + self + ): + + step = ActionStepFrame(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 toScriptLines( + self + ) -> list: + + blockType = self.getBlockType() + lines = [] + + if blockType in ("IF", "ELSE IF"): + operand = self.operandCombo.currentData() + operandName = operand[0] if operand else "" + operandType = operand[1] if operand else "String" + opSym = self.opCombo.currentData() + rawVal = _getValueFromWidget( + self._condValueWidgets.get(operandType, QLineEdit()) + ) + encodedVal = _encodeValueStr(rawVal, operandType) + if blockType == "IF": + lines.append(f"IF({operandName} {opSym} {encodedVal}) THEN") + else: + lines.append(f"ELSE IF({operandName} {opSym} {encodedVal}) THEN") + else: + lines.append("ELSE") + for step in self._actionWidgets: + scriptLine = step.toScriptLine() + if scriptLine: + lines.append(scriptLine) + + return lines + + def getConditionSummary( + self + ) -> str: + + bt = self.getBlockType() + if bt == "ELSE": + return "ELSE" + operandData = self.operandCombo.currentData() + if not operandData: + return bt + operandDisplay = self.operandCombo.currentText() + opDisplay = self.opCombo.currentText() + rawVal = self.getConditionRawValuePreview() + return f"{bt} ({operandDisplay} {opDisplay} {rawVal})" + + def getConditionRawValuePreview( + self + ) -> str: + + data = self.operandCombo.currentData() + if not data: + return "" + _, vartype = data + w = self._condValueWidgets.get(vartype) + if w: + return _getValueFromWidget(w) + return "" + + def countActionSteps( + self + ) -> int: + + return len(self._actionWidgets) + + +class ALPreprocOrchDialog(QDialog): + + def __init__( + self, + parent=None, + existingScript: str = "" + ): + super().__init__(parent) + self._blocks: list[ConditionalBlock] = [] + + self.modifyUi() + self.connectSignals() + + if existingScript and existingScript.strip(): + self._loadFromScript(existingScript) + else: + self._addBlock() + self._scrollLayout.addStretch() + + + def modifyUi( + self + ): + + self.setWindowTitle("预处理指令编排 - AutoLibrary") + self.setMinimumSize(420, 400) + self.setModal(True) + mainLayout = QVBoxLayout(self) + scroll = QScrollArea() + scroll.setWidgetResizable(True) + scroll.setFrameShape(QFrame.Shape.NoFrame) + scrollContent = QWidget() + self._scrollLayout = QVBoxLayout(scrollContent) + self._scrollLayout.setSpacing(5) + scroll.setWidget(scrollContent) + mainLayout.addWidget(scroll) + addBlockLayout = QHBoxLayout() + self.addBlockBtn = QPushButton("+ 添加判断块") + self.addBlockBtn.setFixedHeight(25) + addBlockLayout.addStretch() + addBlockLayout.addWidget(self.addBlockBtn) + addBlockLayout.addStretch() + mainLayout.addLayout(addBlockLayout) + self.btnBox = QDialogButtonBox( + QDialogButtonBox.StandardButton.Ok | + QDialogButtonBox.StandardButton.Cancel + ) + self.btnBox.button(QDialogButtonBox.StandardButton.Ok).setText("确定") + self.btnBox.button(QDialogButtonBox.StandardButton.Cancel).setText("取消") + mainLayout.addWidget(self.btnBox) + + + def connectSignals( + self + ): + + self.btnBox.accepted.connect(self.accept) + self.btnBox.rejected.connect(self.reject) + self.addBlockBtn.clicked.connect(self._addBlock) + + def _addBlock( + self + ): + + block = ConditionalBlock(len(self._blocks), self) + block.deleteBlockBtn.clicked.connect(lambda: self._removeBlock(block)) + self._blocks.append(block) + block._addActionStep() + if self._scrollLayout.count() > 0: + lastItem = self._scrollLayout.itemAt( + self._scrollLayout.count() - 1 + ) + if lastItem and lastItem.spacerItem(): + self._scrollLayout.insertWidget( + self._scrollLayout.count() - 1, block + ) + return + self._scrollLayout.addWidget(block) + + def _removeBlock( + self, + block: ConditionalBlock + ): + + if block in self._blocks: + self._blocks.remove(block) + self._scrollLayout.removeWidget(block) + block.hide() + block.deleteLater() + + def getScript( + self + ) -> str: + + parts = [] + for i, block in enumerate(self._blocks): + blockType = block.getBlockType() + if blockType == "IF" and i > 0: + parts.append("ENDIF") + lines = block.toScriptLines() + parts.extend(lines) + if self._blocks and self._blocks[0].getBlockType() == "IF": + parts.append("ENDIF") + return "\n".join(parts) + + def getScriptPreview( + self + ) -> str: + + s = self.getScript() + if len(s) > 10: + return s[:7] + "..." + return s + + def _loadFromScript( + self, + script: str + ): + + import re + lines = [l.strip() for l in script.split("\n") if l.strip()] + if not lines: + self._addBlock() + return + + currentBlock = None + currentBlockType = None + actionsBuffer = [] + + def flushBlock(): + nonlocal currentBlock, currentBlockType, actionsBuffer + if currentBlock is None: + return + typeIdxMap = {"IF": 0, "ELSE IF": 1, "ELSE": 2} + idx = typeIdxMap.get(currentBlockType, 0) + currentBlock.typeCombo.setCurrentIndex(idx) + currentBlock._onTypeChanged(idx) + for oldStep in list(currentBlock._actionWidgets): + currentBlock._removeActionStep(oldStep) + for target, valueExpr in actionsBuffer: + currentBlock._addActionStep() + step = currentBlock._actionWidgets[-1] + step.loadFromScript(target, valueExpr) + self._blocks.clear() + while self._scrollLayout.count(): + item = self._scrollLayout.takeAt(0) + if item.widget(): + item.widget().deleteLater() + for line in lines: + upper = line.upper() + ifMatch = re.match(r"^IF\((.+)\)\s*THEN\s*$", upper) + if ifMatch: + flushBlock() + currentBlockType = "IF" + actionsBuffer = [] + self._addBlock() + currentBlock = self._blocks[-1] + self._parseConditionToBlock(currentBlock, ifMatch.group(1)) + continue + elifIfMatch = re.match(r"^ELSE\s+IF\((.+)\)\s*THEN\s*$", upper) + if elifIfMatch: + flushBlock() + currentBlockType = "ELSE IF" + actionsBuffer = [] + self._addBlock() + currentBlock = self._blocks[-1] + self._parseConditionToBlock(currentBlock, elifIfMatch.group(1)) + continue + if upper == "ELSE": + flushBlock() + currentBlockType = "ELSE" + actionsBuffer = [] + self._addBlock() + currentBlock = self._blocks[-1] + currentBlock.conditionWidget.setVisible(False) + continue + setMatch = re.match(r"^SET\s+(\w+)\s*=\s*(.+)$", line, re.IGNORECASE) + if setMatch: + target = setMatch.group(1).strip() + valueExpr = setMatch.group(2).strip() + actionsBuffer.append((target, valueExpr)) + continue + addMatch = re.match(r"^(\w+)\s+\.ADD\.\s+(\d+)$", line, re.IGNORECASE) + if addMatch: + target = addMatch.group(1).strip() + hours = addMatch.group(2).strip() + actionsBuffer.append((target, f"+{hours}")) + continue + if upper in ("ENDIF", "END IF"): + flushBlock() + currentBlock = None + currentBlockType = None + actionsBuffer = [] + continue + flushBlock() + if not self._blocks: + self._addBlock() + + def _parseConditionToBlock( + self, + block: ConditionalBlock, + condStr: str + ): + + condStr = condStr.strip() + for _, opSym in OP_ITEMS: + idx = condStr.upper().find(opSym) + if idx >= 0: + leftPart = condStr[:idx].strip() + rightPart = condStr[idx + len(opSym):].strip() + for ci in range(block.operandCombo.count()): + data = block.operandCombo.itemData(ci) + if data and data[0] == leftPart: + block.operandCombo.setCurrentIndex(ci) + break + for oi in range(block.opCombo.count()): + if block.opCombo.itemData(oi) == opSym: + block.opCombo.setCurrentIndex(oi) + break + opData = block.operandCombo.currentData() + vartype = opData[1] if opData else "String" + w = block._condValueWidgets.get(vartype) + if w: + _setWidgetValue(w, vartype, rightPart) + return diff --git a/src/gui/ALTimerTaskAddDialog.py b/src/gui/ALTimerTaskAddDialog.py index 9eb2f25..5f0b281 100644 --- a/src/gui/ALTimerTaskAddDialog.py +++ b/src/gui/ALTimerTaskAddDialog.py @@ -13,9 +13,10 @@ 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.QtWidgets import QLabel, QDialog, QWidget, QSpinBox, QHBoxLayout, QVBoxLayout, QGridLayout, QDateTimeEdit, QGroupBox, QPushButton from gui.resources.ui.Ui_ALTimerTaskAddDialog import Ui_ALTimerTaskAddDialog +from gui.ALPreprocOrchDialog import ALPreprocOrchDialog from utils.TimerUtils import TimerUtils @@ -86,6 +87,33 @@ class ALTimerTaskAddDialog(QDialog, Ui_ALTimerTaskAddDialog): self.TimerConfigLayout.addWidget(self.RelativeTimerWidget) self.RelativeTimerWidget.setVisible(False) + self.PreprocGroupBox = QGroupBox("预处理脚本") + self.PreprocLayout = QVBoxLayout(self.PreprocGroupBox) + self.PreprocLayout.setContentsMargins(3, 3, 3, 3) + self.PreprocLayout.setSpacing(3) + + preproc_btn_layout = QHBoxLayout() + self.PreprocSetButton = QPushButton("设置预处理指令") + self.PreprocSetButton.setMinimumHeight(25) + self.PreprocSetButton.setFixedWidth(130) + preproc_btn_layout.addWidget(self.PreprocSetButton) + self.PreprocPreviewButton = QPushButton("预览") + self.PreprocPreviewButton.setMinimumHeight(25) + self.PreprocPreviewButton.setFixedWidth(60) + self.PreprocPreviewButton.setEnabled(False) + preproc_btn_layout.addWidget(self.PreprocPreviewButton) + preproc_btn_layout.addStretch() + self.PreprocStatusLabel = QLabel("未设置") + self.PreprocStatusLabel.setStyleSheet("color: #969696;") + self.PreprocStatusLabel.setFixedHeight(25) + preproc_btn_layout.addWidget(self.PreprocStatusLabel) + self.PreprocLayout.addLayout(preproc_btn_layout) + self.ALAddTimerTaskLayout.insertWidget( + self.ALAddTimerTaskLayout.indexOf(self.TaskConfigGroupBox) + 1, + self.PreprocGroupBox + ) + self.__repeat_preproc_script = "" + def connectSignals( self @@ -95,6 +123,35 @@ class ALTimerTaskAddDialog(QDialog, Ui_ALTimerTaskAddDialog): self.ConfirmButton.clicked.connect(self.accept) self.TimerTypeComboBox.currentIndexChanged.connect(self.onTimerTypeComboBoxIndexChanged) self.RepeatCheckBox.toggled.connect(self.onRepeatCheckBoxToggled) + self.PreprocSetButton.clicked.connect(self._onSetPreproc) + self.PreprocPreviewButton.clicked.connect(self._onPreviewPreproc) + + + @Slot() + def _onSetPreproc(self): + dlg = ALPreprocOrchDialog(self, existingScript=self.__repeat_preproc_script) + if dlg.exec() == QDialog.DialogCode.Accepted: + script = dlg.getScript() + self.__repeat_preproc_script = script + if script: + self.PreprocStatusLabel.setText("已设置") + self.PreprocStatusLabel.setStyleSheet("color: #4CAF50;") + self.PreprocPreviewButton.setEnabled(True) + else: + self.PreprocStatusLabel.setText("未设置") + self.PreprocStatusLabel.setStyleSheet("color: #969696;") + self.PreprocPreviewButton.setEnabled(False) + dlg.deleteLater() + + + @Slot() + def _onPreviewPreproc(self): + if not self.__repeat_preproc_script: + return + from gui.ALPreProcPrevDialog import ALScriptPreviewDialog + dlg = ALScriptPreviewDialog(self, self.__repeat_preproc_script) + dlg.exec() + dlg.deleteLater() def getTimerTask( @@ -129,6 +186,7 @@ class ALTimerTaskAddDialog(QDialog, Ui_ALTimerTaskAddDialog): "status": ALTimerTaskStatus.PENDING, "executed": False, "repeat": self.RepeatCheckBox.isChecked(), + "repeat_preproc": self.__repeat_preproc_script, } if task_data["repeat"]: task_data["history"] = [] # repeat history diff --git a/src/gui/__init__.py b/src/gui/__init__.py index c0cf4b0..d5ddc5f 100644 --- a/src/gui/__init__.py +++ b/src/gui/__init__.py @@ -10,6 +10,7 @@ - ALSeatMapTable: Seat map table class. - ALSeatMapSelectDialog: Seat map select dialog class. - ALTimerTaskAddDialog: Timer task add dialog class. + - ALPreprocOrchDialog: Preprocessing script orchestration dialog class. - ALTimerTaskHistoryDialog: Timer task history dialog class. - ALTimerTaskManageWidget: Timer task manage widget class. - ALUserTreeWidget: User tree widget class. diff --git a/src/gui/resources/ui/ALTimerTaskAddDialog.ui b/src/gui/resources/ui/ALTimerTaskAddDialog.ui index 1df3808..e2fc6c4 100644 --- a/src/gui/resources/ui/ALTimerTaskAddDialog.ui +++ b/src/gui/resources/ui/ALTimerTaskAddDialog.ui @@ -13,7 +13,7 @@ 350 - 400 + 460 diff --git a/src/utils/PreprocEngine.py b/src/utils/PreprocEngine.py new file mode 100644 index 0000000..4475105 --- /dev/null +++ b/src/utils/PreprocEngine.py @@ -0,0 +1,328 @@ +# -*- coding: utf-8 -*- +""" +Copyright (c) 2026 KenanZhu. +All rights reserved. + +This software is provided "as is", without any warranty of any kind. +You may use, modify, and distribute this file under the terms of the MIT License. +See the LICENSE file for details. +""" +import re +from datetime import datetime, timedelta + + +class PreprocEngine: + + COMPARE_OPS = { + ".EQ.": lambda a, b: a == b, + ".NEQ.": lambda a, b: a != b, + ".BGT.": lambda a, b: a > b, + ".BLT.": lambda a, b: a < b, + ".BGE.": lambda a, b: a >= b, + ".BLE.": lambda a, b: a <= b, + } + + VARIABLE_META = { + "预约开始时间": ("RESERVE_BEGIN_TIME", "Time"), + "预约结束时间": ("RESERVE_END_TIME", "Time"), + "预约日期": ("RESERVE_DATE", "Date"), + "用户名": ("USERNAME", "String"), + "用户启用": ("USER_ENABLE", "Boolean"), + "当前时间": ("CURRENT_TIME", "Time"), + "当前日期": ("CURRENT_DATE", "Date"), + } + + @staticmethod + def execute( + script_text: str, + user_data: dict + ): + + if not script_text or not script_text.strip(): + return + lines = [l.strip() for l in script_text.split("\n") if l.strip()] + if not lines: + return + + if_stack = [] + + for line in lines: + upper_line = line.upper().strip() + if upper_line.startswith("IF("): + cond_end = _findConditionEnd(upper_line) + if cond_end < 0: + raise ValueError("语法错误: IF 缺少右括号") + condition_str = line[3:cond_end].strip() + matched = PreprocEngine._evaluateCondition( + condition_str, user_data + ) + if_stack.append([matched, matched]) + elif upper_line.startswith("ELSE IF("): + if not if_stack: + raise ValueError("语法错误: ELSE IF 前缺少 IF") + cond_end = _findConditionEnd(upper_line) + if cond_end < 0: + raise ValueError("语法错误: ELSE IF 缺少右括号") + condition_str = line[8:cond_end].strip() + _, has_matched = if_stack[-1] + if not has_matched: + matched = PreprocEngine._evaluateCondition( + condition_str, user_data + ) + if_stack[-1] = [matched, matched] + else: + if_stack[-1][0] = False + elif upper_line == "ELSE": + if not if_stack: + raise ValueError("语法错误: ELSE 前缺少 IF") + _, has_matched = if_stack[-1] + if not has_matched: + if_stack[-1] = [True, True] + else: + if_stack[-1][0] = False + elif upper_line in ("ENDIF", "END IF"): + if not if_stack: + raise ValueError("语法错误: ENDIF/END IF 前缺少 IF") + if_stack.pop() + elif upper_line.startswith("SET "): + should_execute = ( + all(ctx[0] for ctx in if_stack) if if_stack else True + ) + if should_execute: + PreprocEngine._executeSet(line, user_data) + elif upper_line == "PASS": + continue + else: + should_execute = ( + all(ctx[0] for ctx in if_stack) if if_stack else True + ) + if should_execute: + PreprocEngine._executeOperation(line, user_data) + if if_stack: + raise ValueError("语法错误: IF 与 ENDIF/END IF 不匹配") + + @staticmethod + def _resolveValue( + value_str: str, + user_data: dict + ) -> str: + + s = value_str.strip() + time_match = re.match(r"^TIME\((\d{1,2}):(\d{2})\)$", s, re.IGNORECASE) + if time_match: + h, m = time_match.group(1), time_match.group(2) + return f"{int(h):02d}:{int(m):02d}" + date_match = re.match(r"^DATE\((\d{4})-(\d{2})-(\d{2})\)$", s, re.IGNORECASE) + if date_match: + y, mo, d = date_match.group(1), date_match.group(2), date_match.group(3) + return f"{int(y):04d}-{int(mo):02d}-{int(d):02d}" + if s.upper() == ".TRUE.": + return "True" + if s.upper() == ".FALSE.": + return "False" + if s.startswith("'") and s.endswith("'"): + inner = s[1:-1].replace("''", "'") + return inner + if s.startswith('"') and s.endswith('"'): + return s[1:-1] + relDate = re.match(r"^CURRENT_DATE\s*\+\s*(\d+)$", s, re.IGNORECASE) + if relDate: + days = int(relDate.group(1)) + return (datetime.now() + timedelta(days=days)).strftime("%Y-%m-%d") + relTime = re.match(r"^CURRENT_TIME\s*\+\s*(\d+)$", s, re.IGNORECASE) + if relTime: + hours = int(relTime.group(1)) + return (datetime.now() + timedelta(hours=hours)).strftime("%H:%M") + try: + float(s) + return s + except ValueError: + pass + resolved = PreprocEngine._resolveField(s, user_data) + return resolved + + @staticmethod + def _resolveField( + field_name: str, + user_data: dict + ) -> str: + + upper_name = field_name.upper().strip() + if upper_name == "CURRENT_DATE": + return datetime.now().strftime("%Y-%m-%d") + elif upper_name == "CURRENT_TIME": + return datetime.now().strftime("%H:%M") + elif upper_name == "USERNAME": + return user_data.get("username", "") + elif upper_name == "USER_ENABLE": + return str(user_data.get("enabled", "False")) + elif upper_name == "RESERVE_DATE": + return user_data.get("reserve_info", {}).get("date", "") + elif upper_name == "RESERVE_BEGIN_TIME": + return ( + user_data + .get("reserve_info", {}) + .get("begin_time", {}) + .get("time", "") + ) + elif upper_name == "RESERVE_END_TIME": + return ( + user_data + .get("reserve_info", {}) + .get("end_time", {}) + .get("time", "") + ) + return "" + + @staticmethod + def _setField( + field_name: str, + value: str, + user_data: dict + ): + upper_name = field_name.upper().strip() + if upper_name == "RESERVE_DATE": + user_data.setdefault("reserve_info", {})["date"] = value + elif upper_name == "RESERVE_BEGIN_TIME": + ri = user_data.setdefault("reserve_info", {}) + ri.setdefault("begin_time", {})["time"] = value + elif upper_name == "RESERVE_END_TIME": + ri = user_data.setdefault("reserve_info", {}) + ri.setdefault("end_time", {})["time"] = value + elif upper_name == "USERNAME": + user_data["username"] = value + elif upper_name == "USER_ENABLE": + user_data["enabled"] = value.upper() == "TRUE" + + @staticmethod + def _evaluateCondition( + condition_str: str, + user_data: dict + ) -> bool: + + for op, cmp_func in PreprocEngine.COMPARE_OPS.items(): + if op not in condition_str.upper(): + continue + idx = condition_str.upper().find(op) + parts = [condition_str[:idx], condition_str[idx + len(op):]] + if len(parts) != 2: + continue + field_name = parts[0].strip() + value_str = parts[1].strip() + left_val = PreprocEngine._resolveField(field_name, user_data) + right_val = PreprocEngine._resolveValue(value_str, user_data) + return cmp_func(left_val, right_val) + return False + + @staticmethod + def _executeSet( + line: str, + user_data: dict + ): + rest = line[3:].strip() + eq_idx = rest.find("=") + if eq_idx < 0: + return + field_name = rest[:eq_idx].strip() + value_str = rest[eq_idx + 1:].strip() + if not field_name: + return + resolved = PreprocEngine._resolveValue(value_str, user_data) + PreprocEngine._setField(field_name, resolved, user_data) + + @staticmethod + def _executeOperation( + line: str, + user_data: dict + ): + + parts = line.split() + if len(parts) < 3: + return + field_name = parts[0].upper().strip() + op = parts[1].upper().strip() + raw_value = parts[2].strip() + try: + num_value = float(raw_value) if "." in raw_value else int(raw_value) + except (ValueError, TypeError): + return + if field_name == "RESERVE_DATE": + date_str = user_data.get("reserve_info", {}).get("date", "") + if not date_str: + return + try: + date_obj = datetime.strptime(date_str, "%Y-%m-%d") + except (ValueError, TypeError): + return + if op == ".ADD.": + date_obj += timedelta(days=num_value) + elif op == ".SUB.": + date_obj -= timedelta(days=num_value) + else: + return + user_data.setdefault("reserve_info", {})["date"] = \ + date_obj.strftime("%Y-%m-%d") + elif field_name == "RESERVE_BEGIN_TIME": + time_str = ( + user_data + .get("reserve_info", {}) + .get("begin_time", {}) + .get("time", "") + ) + if not time_str: + return + try: + time_obj = datetime.strptime(time_str, "%H:%M") + except (ValueError, TypeError): + return + if op == ".ADD.": + time_obj += timedelta(hours=num_value) + elif op == ".SUB.": + time_obj -= timedelta(hours=num_value) + else: + return + ri = user_data.setdefault("reserve_info", {}) + ri.setdefault("begin_time", {})["time"] = \ + time_obj.strftime("%H:%M") + elif field_name == "RESERVE_END_TIME": + time_str = ( + user_data + .get("reserve_info", {}) + .get("end_time", {}) + .get("time", "") + ) + if not time_str: + return + try: + time_obj = datetime.strptime(time_str, "%H:%M") + except (ValueError, TypeError): + return + if op == ".ADD.": + time_obj += timedelta(hours=num_value) + elif op == ".SUB.": + time_obj -= timedelta(hours=num_value) + else: + return + ri = user_data.setdefault("reserve_info", {}) + ri.setdefault("end_time", {})["time"] = \ + time_obj.strftime("%H:%M") + + +def _findConditionEnd( + upper_line: str +) -> int: + + line = upper_line.rstrip() + if line.endswith(" THEN"): + line = line[:-5].rstrip() + paren_depth = 0 + start_found = False + for i, ch in enumerate(line): + if ch == "(": + paren_depth += 1 + start_found = True + elif ch == ")": + paren_depth -= 1 + if start_found and paren_depth == 0: + return i + return -1 From 46b3447d1e244c83021c51f3c5f179166315eec7 Mon Sep 17 00:00:00 2001 From: KenanZhu <3471685733@qq.com> Date: Fri, 8 May 2026 20:46:54 +0800 Subject: [PATCH 2/4] =?UTF-8?q?feat(autoscript):=20=E5=B0=86=E9=A2=84?= =?UTF-8?q?=E5=A4=84=E7=90=86=E8=84=9A=E6=9C=AC=E9=87=8D=E6=9E=84=E4=B8=BA?= =?UTF-8?q?=20AutoScript=20DSL=EF=BC=8C=E6=96=B0=E5=A2=9E=E5=8F=AF?= =?UTF-8?q?=E8=A7=86=E5=8C=96=E7=BC=96=E6=8E=92=E4=B8=8E=E9=A2=84=E8=A7=88?= =?UTF-8?q?=E5=AF=B9=E8=AF=9D=E6=A1=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...rchDialog.py => ALAutoScriptOrchDialog.py} | 8 +- ...revDialog.py => ALAutoScriptPrevDialog.py} | 20 +- src/gui/ALMainWorkers.py | 19 +- src/gui/ALTimerTaskAddDialog.py | 117 +++++--- src/gui/__init__.py | 2 +- .../{PreprocEngine.py => AutoScriptEngine.py} | 264 +++++++++++------- src/utils/__init__.py | 2 + 7 files changed, 258 insertions(+), 174 deletions(-) rename src/gui/{ALPreprocOrchDialog.py => ALAutoScriptOrchDialog.py} (99%) rename src/gui/{ALPreProcPrevDialog.py => ALAutoScriptPrevDialog.py} (93%) rename src/utils/{PreprocEngine.py => AutoScriptEngine.py} (60%) diff --git a/src/gui/ALPreprocOrchDialog.py b/src/gui/ALAutoScriptOrchDialog.py similarity index 99% rename from src/gui/ALPreprocOrchDialog.py rename to src/gui/ALAutoScriptOrchDialog.py index 20509c4..3ffa63d 100644 --- a/src/gui/ALPreprocOrchDialog.py +++ b/src/gui/ALAutoScriptOrchDialog.py @@ -17,10 +17,10 @@ from PySide6.QtWidgets import ( QGroupBox, QSizePolicy ) -from utils.PreprocEngine import PreprocEngine +from utils.AutoScriptEngine import AutoScriptEngine -VARIABLE_META = PreprocEngine.VARIABLE_META +VARIABLE_META = AutoScriptEngine.VARIABLE_META _VAR_COMBO_ITEMS = [ (display, varname, vartype) @@ -637,7 +637,7 @@ class ConditionalBlock(QGroupBox): return len(self._actionWidgets) -class ALPreprocOrchDialog(QDialog): +class ALAutoScriptOrchDialog(QDialog): def __init__( self, @@ -661,7 +661,7 @@ class ALPreprocOrchDialog(QDialog): self ): - self.setWindowTitle("预处理指令编排 - AutoLibrary") + self.setWindowTitle("AutoScript 指令编排 - AutoLibrary") self.setMinimumSize(420, 400) self.setModal(True) mainLayout = QVBoxLayout(self) diff --git a/src/gui/ALPreProcPrevDialog.py b/src/gui/ALAutoScriptPrevDialog.py similarity index 93% rename from src/gui/ALPreProcPrevDialog.py rename to src/gui/ALAutoScriptPrevDialog.py index 8ed6f63..78bd7ec 100644 --- a/src/gui/ALPreProcPrevDialog.py +++ b/src/gui/ALAutoScriptPrevDialog.py @@ -31,41 +31,41 @@ class ALScriptHighlighter(QSyntaxHighlighter): keywordFmt.setForeground(QColor("#316BFF")) keywordFmt.setFontWeight(QFont.Weight.Bold) for kw in ["IF", "ELSE IF", "ELSE", "ENDIF", "END IF", - "SET", "PASS", "THEN", ".TRUE.", ".FALSE."]: + "SET", "PASS", "THEN"]: pattern = r"\b" + kw.replace(" ", r"\s+") + r"\b" self._rules.append((pattern, keywordFmt)) - + literalFmt = QTextCharFormat() + literalFmt.setForeground(QColor("#C2185B")) + literalFmt.setFontWeight(QFont.Weight.Bold) + for lit in [".TRUE.", ".FALSE."]: + self._rules.append((r"\b" + lit.replace(".", r"\.") + r"\b", literalFmt)) opFmt = QTextCharFormat() opFmt.setForeground(QColor("#9C27B0")) for op in [r"\.EQ\.", r"\.NEQ\.", r"\.BGT\.", r"\.BLT\.", r"\.BGE\.", r"\.BLE\.", r"\.ADD\.", r"\.SUB\."]: self._rules.append((op, opFmt)) - varFmt = QTextCharFormat() varFmt.setForeground(QColor("#E65100")) for var in ["RESERVE_BEGIN_TIME", "RESERVE_END_TIME", "RESERVE_DATE", "USERNAME", "USER_ENABLE", "PRIORITY", "CURRENT_TIME", "CURRENT_DATE"]: self._rules.append((r"\b" + var + r"\b", varFmt)) - funcFmt = QTextCharFormat() funcFmt.setForeground(QColor("#2E7D32")) self._rules.append((r"\bTIME\([^)]+\)", funcFmt)) self._rules.append((r"\bDATE\([^)]+\)", funcFmt)) - strFmt = QTextCharFormat() strFmt.setForeground(QColor("#388E3C")) self._rules.append((r"'[^']*'", strFmt)) - numFmt = QTextCharFormat() numFmt.setForeground(QColor("#D32F2F")) self._rules.append((r"\b\d+\b", numFmt)) - commentFmt = QTextCharFormat() commentFmt.setForeground(QColor("#999999")) commentFmt.setFontItalic(True) self._rules.append((r"//[^\n]*", commentFmt)) + def highlightBlock( self, text @@ -79,7 +79,7 @@ class ALScriptHighlighter(QSyntaxHighlighter): self.setFormat(start, length, fmt) -class ALScriptPreviewDialog(QDialog): +class ALAutoScriptPreviewDialog(QDialog): def __init__( self, @@ -88,7 +88,6 @@ class ALScriptPreviewDialog(QDialog): ): super().__init__(parent) - self.__fontSize = 13 self.modifyUi() @@ -104,7 +103,7 @@ class ALScriptPreviewDialog(QDialog): self ): - self.setWindowTitle("预处理脚本预览 - AutoLibrary") + self.setWindowTitle("AutoScript 预览 - AutoLibrary") self.setMinimumSize(520, 360) layout = QVBoxLayout(self) @@ -136,7 +135,6 @@ class ALScriptPreviewDialog(QDialog): self._copyBtn.setToolTip("复制脚本") toolbarLayout.addWidget(self._copyBtn) layout.addLayout(toolbarLayout) - self._textEdit = QPlainTextEdit(self) self._textEdit.setReadOnly(True) self._textEdit.setLineWrapMode( diff --git a/src/gui/ALMainWorkers.py b/src/gui/ALMainWorkers.py index 3c9033b..4bb736f 100644 --- a/src/gui/ALMainWorkers.py +++ b/src/gui/ALMainWorkers.py @@ -18,7 +18,7 @@ from PySide6.QtCore import ( from base.MsgBase import MsgBase from operators.AutoLib import AutoLib from utils.JSONReader import JSONReader -from utils.PreprocEngine import PreprocEngine +from utils.AutoScriptEngine import AutoScriptEngine class AutoLibWorker(MsgBase, QThread): @@ -161,6 +161,7 @@ class TimerTaskWorker(AutoLibWorker): self.autoLibWorkerIsFinished.connect(self.onTimerTaskIsFinished) self.autoLibWorkerFinishedWithError.connect(self.onTimerTaskFinishedWithError) + def run( self ): @@ -173,7 +174,7 @@ class TimerTaskWorker(AutoLibWorker): try: if not self.loadConfigs(): raise Exception("配置文件加载失败") - self._applyRepeatPreproc() + self._applyRepeatAutoScript() auto_lib = AutoLib( self._input_queue, self._output_queue, @@ -206,15 +207,15 @@ class TimerTaskWorker(AutoLibWorker): self.timerTaskWorkerIsFinished.emit(False, self.__timer_task) - def _applyRepeatPreproc( + def _applyRepeatAutoScript( self ): - preproc_script = self.__timer_task.get("repeat_preproc", "") - if not preproc_script or not preproc_script.strip(): + auto_script = self.__timer_task.get("repeat_auto_script", "") + if not auto_script or not auto_script.strip(): return self._showTrace( - f"检测到重复定时任务预处理脚本, 开始执行...", + f"检测到重复定时任务 AutoScript, 开始执行...", no_log=True ) groups = self._user_config.get("groups", []) @@ -224,15 +225,15 @@ class TimerTaskWorker(AutoLibWorker): continue for user in group.get("users", []): try: - PreprocEngine.execute(preproc_script, user) + AutoScriptEngine.execute(auto_script, user) affected_count += 1 except ValueError as e: self._showTrace( - f"预处理脚本执行错误 (用户 {user['username']}): {e}", + f"AutoScript 执行错误 (用户 {user['username']}): {e}", self.TraceLevel.ERROR ) self._showLog( - f"预处理脚本执行完毕, " + f"AutoScript 执行完毕, " f"影响 {affected_count} 个用户", self.TraceLevel.INFO ) diff --git a/src/gui/ALTimerTaskAddDialog.py b/src/gui/ALTimerTaskAddDialog.py index 5f0b281..cd4e8ac 100644 --- a/src/gui/ALTimerTaskAddDialog.py +++ b/src/gui/ALTimerTaskAddDialog.py @@ -12,11 +12,12 @@ import uuid from enum import Enum from datetime import datetime, timedelta -from PySide6.QtCore import Slot, QDateTime +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 -from gui.ALPreprocOrchDialog import ALPreprocOrchDialog +from gui.ALAutoScriptOrchDialog import ALAutoScriptOrchDialog from utils.TimerUtils import TimerUtils @@ -87,32 +88,46 @@ class ALTimerTaskAddDialog(QDialog, Ui_ALTimerTaskAddDialog): self.TimerConfigLayout.addWidget(self.RelativeTimerWidget) self.RelativeTimerWidget.setVisible(False) - self.PreprocGroupBox = QGroupBox("预处理脚本") - self.PreprocLayout = QVBoxLayout(self.PreprocGroupBox) - self.PreprocLayout.setContentsMargins(3, 3, 3, 3) - self.PreprocLayout.setSpacing(3) - - preproc_btn_layout = QHBoxLayout() - self.PreprocSetButton = QPushButton("设置预处理指令") - self.PreprocSetButton.setMinimumHeight(25) - self.PreprocSetButton.setFixedWidth(130) - preproc_btn_layout.addWidget(self.PreprocSetButton) - self.PreprocPreviewButton = QPushButton("预览") - self.PreprocPreviewButton.setMinimumHeight(25) - self.PreprocPreviewButton.setFixedWidth(60) - self.PreprocPreviewButton.setEnabled(False) - preproc_btn_layout.addWidget(self.PreprocPreviewButton) - preproc_btn_layout.addStretch() - self.PreprocStatusLabel = QLabel("未设置") - self.PreprocStatusLabel.setStyleSheet("color: #969696;") - self.PreprocStatusLabel.setFixedHeight(25) - preproc_btn_layout.addWidget(self.PreprocStatusLabel) - self.PreprocLayout.addLayout(preproc_btn_layout) + self.AutoScriptGroupBox = QGroupBox("AutoScript 指令") + self.AutoScriptLayout = QVBoxLayout(self.AutoScriptGroupBox) + self.AutoScriptLayout.setContentsMargins(3, 3, 3, 3) + self.AutoScriptLayout.setSpacing(3) + autoScriptBtnLayout = QHBoxLayout() + self.AutoScriptSetButton = QPushButton("设置指令") + self.AutoScriptSetButton.setMinimumHeight(25) + self.AutoScriptSetButton.setFixedWidth(130) + autoScriptBtnLayout.addWidget(self.AutoScriptSetButton) + self.AutoScriptPreviewButton = QPushButton("预览") + self.AutoScriptPreviewButton.setMinimumHeight(25) + self.AutoScriptPreviewButton.setFixedWidth(60) + self.AutoScriptPreviewButton.setEnabled(False) + autoScriptBtnLayout.addWidget(self.AutoScriptPreviewButton) + autoScriptBtnLayout.addStretch() + self.AutoScriptHelpButton = QPushButton("?") + self.AutoScriptHelpButton.setFixedSize(20, 20) + self.AutoScriptHelpButton.setToolTip( + "AutoScript 是一种轻量级 DSL\n" + "用于在重复定时任务执行前,对用户的预约数据进行预处理\n" + "\n" + "点击查看完整在线文档" + ) + self.AutoScriptHelpButton.setStyleSheet( + "QPushButton { border-radius: 10px; border: 1px solid #999; " + "font-weight: bold; color: #555; }" + "QPushButton:hover { background-color: #E0E0E0; }" + ) + autoScriptBtnLayout.addWidget(self.AutoScriptHelpButton) + self.AutoScriptStatusLabel = QLabel("未设置") + self.AutoScriptStatusLabel.setStyleSheet("color: #969696;") + self.AutoScriptStatusLabel.setFixedHeight(25) + autoScriptBtnLayout.addWidget(self.AutoScriptStatusLabel) + self.AutoScriptLayout.addLayout(autoScriptBtnLayout) self.ALAddTimerTaskLayout.insertWidget( self.ALAddTimerTaskLayout.indexOf(self.TaskConfigGroupBox) + 1, - self.PreprocGroupBox + self.AutoScriptGroupBox ) - self.__repeat_preproc_script = "" + self.AutoScriptGroupBox.setVisible(False) + self.__auto_script = "" def connectSignals( @@ -123,35 +138,44 @@ class ALTimerTaskAddDialog(QDialog, Ui_ALTimerTaskAddDialog): self.ConfirmButton.clicked.connect(self.accept) self.TimerTypeComboBox.currentIndexChanged.connect(self.onTimerTypeComboBoxIndexChanged) self.RepeatCheckBox.toggled.connect(self.onRepeatCheckBoxToggled) - self.PreprocSetButton.clicked.connect(self._onSetPreproc) - self.PreprocPreviewButton.clicked.connect(self._onPreviewPreproc) - + self.AutoScriptSetButton.clicked.connect(self._onSetAutoScript) + self.AutoScriptPreviewButton.clicked.connect(self._onPreviewAutoScript) + self.AutoScriptHelpButton.clicked.connect(self._onAutoScriptHelp) @Slot() - def _onSetPreproc(self): - dlg = ALPreprocOrchDialog(self, existingScript=self.__repeat_preproc_script) + def _onSetAutoScript(self): + dlg = ALAutoScriptOrchDialog(self, existingScript=self.__auto_script) if dlg.exec() == QDialog.DialogCode.Accepted: script = dlg.getScript() - self.__repeat_preproc_script = script + self.__auto_script = script if script: - self.PreprocStatusLabel.setText("已设置") - self.PreprocStatusLabel.setStyleSheet("color: #4CAF50;") - self.PreprocPreviewButton.setEnabled(True) + self.AutoScriptStatusLabel.setText("已设置") + self.AutoScriptStatusLabel.setStyleSheet("color: #4CAF50;") + self.AutoScriptPreviewButton.setEnabled(True) else: - self.PreprocStatusLabel.setText("未设置") - self.PreprocStatusLabel.setStyleSheet("color: #969696;") - self.PreprocPreviewButton.setEnabled(False) + self.AutoScriptStatusLabel.setText("未设置") + self.AutoScriptStatusLabel.setStyleSheet("color: #969696;") + self.AutoScriptPreviewButton.setEnabled(False) + dlg.deleteLater() + + @Slot() + def _onPreviewAutoScript(self): + if not self.__auto_script: + return + from gui.ALAutoScriptPrevDialog import ALAutoScriptPreviewDialog + dlg = ALAutoScriptPreviewDialog(self, self.__auto_script) + dlg.exec() dlg.deleteLater() @Slot() - def _onPreviewPreproc(self): - if not self.__repeat_preproc_script: - return - from gui.ALPreProcPrevDialog import ALScriptPreviewDialog - dlg = ALScriptPreviewDialog(self, self.__repeat_preproc_script) - dlg.exec() - dlg.deleteLater() + def _onAutoScriptHelp( + self + ): + + QDesktopServices.openUrl( + QUrl("https://www.autolibrary.kenanzhu.com/manuals/autoscript") + ) def getTimerTask( @@ -186,7 +210,7 @@ class ALTimerTaskAddDialog(QDialog, Ui_ALTimerTaskAddDialog): "status": ALTimerTaskStatus.PENDING, "executed": False, "repeat": self.RepeatCheckBox.isChecked(), - "repeat_preproc": self.__repeat_preproc_script, + "repeat_auto_script": self.__auto_script, } if task_data["repeat"]: task_data["history"] = [] # repeat history @@ -240,4 +264,5 @@ class ALTimerTaskAddDialog(QDialog, Ui_ALTimerTaskAddDialog): self.ThuCheckBox.setEnabled(checked) self.FriCheckBox.setEnabled(checked) self.SatCheckBox.setEnabled(checked) - self.SunCheckBox.setEnabled(checked) \ No newline at end of file + self.SunCheckBox.setEnabled(checked) + self.AutoScriptGroupBox.setVisible(checked) \ No newline at end of file diff --git a/src/gui/__init__.py b/src/gui/__init__.py index d5ddc5f..6e687ba 100644 --- a/src/gui/__init__.py +++ b/src/gui/__init__.py @@ -10,7 +10,7 @@ - ALSeatMapTable: Seat map table class. - ALSeatMapSelectDialog: Seat map select dialog class. - ALTimerTaskAddDialog: Timer task add dialog class. - - ALPreprocOrchDialog: Preprocessing script orchestration dialog class. + - ALAutoScriptOrchDialog: AutoScript orchestration dialog class. - ALTimerTaskHistoryDialog: Timer task history dialog class. - ALTimerTaskManageWidget: Timer task manage widget class. - ALUserTreeWidget: User tree widget class. diff --git a/src/utils/PreprocEngine.py b/src/utils/AutoScriptEngine.py similarity index 60% rename from src/utils/PreprocEngine.py rename to src/utils/AutoScriptEngine.py index 4475105..7504ea6 100644 --- a/src/utils/PreprocEngine.py +++ b/src/utils/AutoScriptEngine.py @@ -11,39 +11,78 @@ import re from datetime import datetime, timedelta -class PreprocEngine: +class AutoScriptEngine: + """ + AutoScript script engine. - COMPARE_OPS = { - ".EQ.": lambda a, b: a == b, + Parses and executes AutoScript — a lightweight scripting DSL + used in repeatable timer tasks to preprocess user reservation + data before automation runs. + + Supports IF/ELSE IF/ELSE/END IF control flow, SET assignments, + .ADD./.SUB. operations on Date/Time fields, and rich comparison + operators (.EQ. .NEQ. .BGT. .BLT. .BGE. .BLE.). + + Examples: + >>> engine = AutoScriptEngine + >>> user = { + ... "username": "test", + ... "enabled": True, + ... "reserve_info": {"date": "2026-05-07"} + ... } + >>> engine.execute( + ... 'IF(CURRENT_TIME .BGT. TIME(19:00))\\n' + ... ' RESERVE_DATE .ADD. 1\\n' + ... 'END IF', + ... user + ... ) + """ + COMPARE_OPS = { # compare operators + ".EQ." : lambda a, b: a == b, ".NEQ.": lambda a, b: a != b, ".BGT.": lambda a, b: a > b, ".BLT.": lambda a, b: a < b, ".BGE.": lambda a, b: a >= b, ".BLE.": lambda a, b: a <= b, } - - VARIABLE_META = { + VARIABLE_META = { # variable metadata "预约开始时间": ("RESERVE_BEGIN_TIME", "Time"), "预约结束时间": ("RESERVE_END_TIME", "Time"), "预约日期": ("RESERVE_DATE", "Date"), - "用户名": ("USERNAME", "String"), + "用户名": ("USERNAME", "String"), "用户启用": ("USER_ENABLE", "Boolean"), "当前时间": ("CURRENT_TIME", "Time"), "当前日期": ("CURRENT_DATE", "Date"), } + _FIELD_TYPE_MAP = {meta[0]: meta[1] for meta in VARIABLE_META.values()} @staticmethod def execute( script_text: str, user_data: dict ): + """ + Execute an AutoScript against the given user data. + + The script is parsed line-by-line. All modifications are + applied directly to ``user_data`` in-place. + + Args: + script_text (str): Raw AutoScript source code. + user_data (dict): User data dictionary to read from and + write to. Must conform to the standard user profile + structure (username, enabled, reserve_info, etc.). + + Raises: + ValueError: On any syntax or type error encountered + during parsing or execution. + """ if not script_text or not script_text.strip(): return lines = [l.strip() for l in script_text.split("\n") if l.strip()] if not lines: return - if_stack = [] for line in lines: @@ -51,22 +90,22 @@ class PreprocEngine: if upper_line.startswith("IF("): cond_end = _findConditionEnd(upper_line) if cond_end < 0: - raise ValueError("语法错误: IF 缺少右括号") + raise ValueError("AutoScript 语法错误: IF 缺少右括号") condition_str = line[3:cond_end].strip() - matched = PreprocEngine._evaluateCondition( + matched = AutoScriptEngine._evaluateCondition( condition_str, user_data ) if_stack.append([matched, matched]) elif upper_line.startswith("ELSE IF("): if not if_stack: - raise ValueError("语法错误: ELSE IF 前缺少 IF") + raise ValueError("AutoScript 语法错误: ELSE IF 前缺少 IF") cond_end = _findConditionEnd(upper_line) if cond_end < 0: - raise ValueError("语法错误: ELSE IF 缺少右括号") + raise ValueError("AutoScript 语法错误: ELSE IF 缺少右括号") condition_str = line[8:cond_end].strip() _, has_matched = if_stack[-1] if not has_matched: - matched = PreprocEngine._evaluateCondition( + matched = AutoScriptEngine._evaluateCondition( condition_str, user_data ) if_stack[-1] = [matched, matched] @@ -74,7 +113,7 @@ class PreprocEngine: if_stack[-1][0] = False elif upper_line == "ELSE": if not if_stack: - raise ValueError("语法错误: ELSE 前缺少 IF") + raise ValueError("AutoScript 语法错误: ELSE 前缺少 IF") _, has_matched = if_stack[-1] if not has_matched: if_stack[-1] = [True, True] @@ -82,14 +121,14 @@ class PreprocEngine: if_stack[-1][0] = False elif upper_line in ("ENDIF", "END IF"): if not if_stack: - raise ValueError("语法错误: ENDIF/END IF 前缺少 IF") + raise ValueError("AutoScript 语法错误: ENDIF/END IF 前缺少 IF") if_stack.pop() elif upper_line.startswith("SET "): should_execute = ( all(ctx[0] for ctx in if_stack) if if_stack else True ) if should_execute: - PreprocEngine._executeSet(line, user_data) + AutoScriptEngine._executeSet(line, user_data) elif upper_line == "PASS": continue else: @@ -97,55 +136,15 @@ class PreprocEngine: all(ctx[0] for ctx in if_stack) if if_stack else True ) if should_execute: - PreprocEngine._executeOperation(line, user_data) + AutoScriptEngine._executeOperation(line, user_data) if if_stack: - raise ValueError("语法错误: IF 与 ENDIF/END IF 不匹配") - - @staticmethod - def _resolveValue( - value_str: str, - user_data: dict - ) -> str: - - s = value_str.strip() - time_match = re.match(r"^TIME\((\d{1,2}):(\d{2})\)$", s, re.IGNORECASE) - if time_match: - h, m = time_match.group(1), time_match.group(2) - return f"{int(h):02d}:{int(m):02d}" - date_match = re.match(r"^DATE\((\d{4})-(\d{2})-(\d{2})\)$", s, re.IGNORECASE) - if date_match: - y, mo, d = date_match.group(1), date_match.group(2), date_match.group(3) - return f"{int(y):04d}-{int(mo):02d}-{int(d):02d}" - if s.upper() == ".TRUE.": - return "True" - if s.upper() == ".FALSE.": - return "False" - if s.startswith("'") and s.endswith("'"): - inner = s[1:-1].replace("''", "'") - return inner - if s.startswith('"') and s.endswith('"'): - return s[1:-1] - relDate = re.match(r"^CURRENT_DATE\s*\+\s*(\d+)$", s, re.IGNORECASE) - if relDate: - days = int(relDate.group(1)) - return (datetime.now() + timedelta(days=days)).strftime("%Y-%m-%d") - relTime = re.match(r"^CURRENT_TIME\s*\+\s*(\d+)$", s, re.IGNORECASE) - if relTime: - hours = int(relTime.group(1)) - return (datetime.now() + timedelta(hours=hours)).strftime("%H:%M") - try: - float(s) - return s - except ValueError: - pass - resolved = PreprocEngine._resolveField(s, user_data) - return resolved + raise ValueError("AutoScript 语法错误: IF 与 ENDIF/END IF 不匹配") @staticmethod def _resolveField( field_name: str, user_data: dict - ) -> str: + ): upper_name = field_name.upper().strip() if upper_name == "CURRENT_DATE": @@ -155,7 +154,7 @@ class PreprocEngine: elif upper_name == "USERNAME": return user_data.get("username", "") elif upper_name == "USER_ENABLE": - return str(user_data.get("enabled", "False")) + return user_data.get("enabled", False) elif upper_name == "RESERVE_DATE": return user_data.get("reserve_info", {}).get("date", "") elif upper_name == "RESERVE_BEGIN_TIME": @@ -174,6 +173,49 @@ class PreprocEngine: ) return "" + @staticmethod + def _resolveValue( + value_str: str, + user_data: dict + ): + + s = value_str.strip() + time_match = re.match(r"^TIME\((\d{1,2}):(\d{2})\)$", s, re.IGNORECASE) + if time_match: + h, m = time_match.group(1), time_match.group(2) + return f"{int(h):02d}:{int(m):02d}" + date_match = re.match(r"^DATE\((\d{4})-(\d{2})-(\d{2})\)$", s, re.IGNORECASE) + if date_match: + y, mo, d = date_match.group(1), date_match.group(2), date_match.group(3) + return f"{int(y):04d}-{int(mo):02d}-{int(d):02d}" + if s.upper() == ".TRUE.": + return True + if s.upper() == ".FALSE.": + return False + if s.startswith("'") and s.endswith("'"): + inner = s[1:-1].replace("''", "'") + return inner + if s.startswith('"') and s.endswith('"'): + return s[1:-1] + relDate = re.match(r"^CURRENT_DATE\s*\+\s*(\d+)$", s, re.IGNORECASE) + if relDate: + days = int(relDate.group(1)) + return (datetime.now() + timedelta(days=days)).strftime("%Y-%m-%d") + relTime = re.match(r"^CURRENT_TIME\s*\+\s*(\d+)$", s, re.IGNORECASE) + if relTime: + hours = int(relTime.group(1)) + return (datetime.now() + timedelta(hours=hours)).strftime("%H:%M") + try: + return int(s) + except ValueError: + pass + try: + return float(s) + except ValueError: + pass + resolved = AutoScriptEngine._resolveField(s, user_data) + return resolved + @staticmethod def _setField( field_name: str, @@ -192,7 +234,10 @@ class PreprocEngine: elif upper_name == "USERNAME": user_data["username"] = value elif upper_name == "USER_ENABLE": - user_data["enabled"] = value.upper() == "TRUE" + if isinstance(value, bool): + user_data["enabled"] = value + else: + user_data["enabled"] = (str(value).upper() == "TRUE") @staticmethod def _evaluateCondition( @@ -200,7 +245,7 @@ class PreprocEngine: user_data: dict ) -> bool: - for op, cmp_func in PreprocEngine.COMPARE_OPS.items(): + for op, cmp_func in AutoScriptEngine.COMPARE_OPS.items(): if op not in condition_str.upper(): continue idx = condition_str.upper().find(op) @@ -209,9 +254,16 @@ class PreprocEngine: continue field_name = parts[0].strip() value_str = parts[1].strip() - left_val = PreprocEngine._resolveField(field_name, user_data) - right_val = PreprocEngine._resolveValue(value_str, user_data) - return cmp_func(left_val, right_val) + left_val = AutoScriptEngine._resolveField(field_name, user_data) + right_val = AutoScriptEngine._resolveValue(value_str, user_data) + try: + return cmp_func(left_val, right_val) + except TypeError: + raise ValueError( + f"AutoScript 语法错误: 无法比较 " + f"'{field_name}' ({type(left_val).__name__}) " + f"与 '{value_str}' ({type(right_val).__name__})" + ) return False @staticmethod @@ -227,8 +279,8 @@ class PreprocEngine: value_str = rest[eq_idx + 1:].strip() if not field_name: return - resolved = PreprocEngine._resolveValue(value_str, user_data) - PreprocEngine._setField(field_name, resolved, user_data) + resolved = AutoScriptEngine._resolveValue(value_str, user_data) + AutoScriptEngine._setField(field_name, resolved, user_data) @staticmethod def _executeOperation( @@ -242,12 +294,19 @@ class PreprocEngine: field_name = parts[0].upper().strip() op = parts[1].upper().strip() raw_value = parts[2].strip() + field_type = AutoScriptEngine._FIELD_TYPE_MAP.get(field_name) + if not field_type: + raise ValueError( + f"AutoScript 语法错误: 未知字段 '{field_name}'" + ) try: num_value = float(raw_value) if "." in raw_value else int(raw_value) except (ValueError, TypeError): - return - if field_name == "RESERVE_DATE": - date_str = user_data.get("reserve_info", {}).get("date", "") + raise ValueError( + f"AutoScript 语法错误: 无效操作数 '{raw_value}'" + ) + if field_type == "Date": + date_str = AutoScriptEngine._resolveField(field_name, user_data) if not date_str: return try: @@ -259,16 +318,14 @@ class PreprocEngine: elif op == ".SUB.": date_obj -= timedelta(days=num_value) else: - return - user_data.setdefault("reserve_info", {})["date"] = \ - date_obj.strftime("%Y-%m-%d") - elif field_name == "RESERVE_BEGIN_TIME": - time_str = ( - user_data - .get("reserve_info", {}) - .get("begin_time", {}) - .get("time", "") + raise ValueError( + f"AutoScript 语法错误: Date 类型不支持操作 '{op}'" + ) + AutoScriptEngine._setField( + field_name, date_obj.strftime("%Y-%m-%d"), user_data ) + elif field_type == "Time": + time_str = AutoScriptEngine._resolveField(field_name, user_data) if not time_str: return try: @@ -280,37 +337,38 @@ class PreprocEngine: elif op == ".SUB.": time_obj -= timedelta(hours=num_value) else: - return - ri = user_data.setdefault("reserve_info", {}) - ri.setdefault("begin_time", {})["time"] = \ - time_obj.strftime("%H:%M") - elif field_name == "RESERVE_END_TIME": - time_str = ( - user_data - .get("reserve_info", {}) - .get("end_time", {}) - .get("time", "") + raise ValueError( + f"AutoScript 语法错误: Time 类型不支持操作 '{op}'" + ) + AutoScriptEngine._setField( + field_name, time_obj.strftime("%H:%M"), user_data + ) + elif field_type in ("String", "Boolean"): + raise ValueError( + f"AutoScript 语法错误: '{field_type}' 类型字段不支持操作运算" + ) + else: + raise ValueError( + f"AutoScript 语法错误: 未知字段类型 '{field_type}'" ) - if not time_str: - return - try: - time_obj = datetime.strptime(time_str, "%H:%M") - except (ValueError, TypeError): - return - if op == ".ADD.": - time_obj += timedelta(hours=num_value) - elif op == ".SUB.": - time_obj -= timedelta(hours=num_value) - else: - return - ri = user_data.setdefault("reserve_info", {}) - ri.setdefault("end_time", {})["time"] = \ - time_obj.strftime("%H:%M") def _findConditionEnd( upper_line: str ) -> int: + """ + Find the index of the closing parenthesis that matches the + opening parenthesis in a condition expression, handling nested + parentheses and optional ``THEN`` keyword. + + Args: + upper_line (str): The uppercased line text containing the + condition, e.g. ``"IF(A .BGT. B) THEN"``. + + Returns: + int: Index of the matching ``)``, or ``-1`` if no match + is found. + """ line = upper_line.rstrip() if line.endswith(" THEN"): diff --git a/src/utils/__init__.py b/src/utils/__init__.py index 4d0a056..8c4cdf0 100644 --- a/src/utils/__init__.py +++ b/src/utils/__init__.py @@ -5,4 +5,6 @@ - TimerUtils: Timer utils class for the AutoLibrary project. - JSONReader: JSON reader class for the AutoLibrary project. - JSONWriter: JSON writer class for the AutoLibrary project. + - ConfigUtils: Config utils class for the AutoLibrary project. + - AutoScriptEngine: AutoScript script engine class for the AutoLibrary project. """ \ No newline at end of file From 27250dba2f1f67bee2a2c54563f2492af0cc6406 Mon Sep 17 00:00:00 2001 From: KenanZhu <3471685733@qq.com> Date: Sat, 9 May 2026 10:07:25 +0800 Subject: [PATCH 3/4] =?UTF-8?q?feat(ALTimerTask*):=20=E5=AE=9E=E7=8E=B0?= =?UTF-8?q?=E5=AE=9A=E6=97=B6=E4=BB=BB=E5=8A=A1=E7=BC=96=E8=BE=91=E5=8A=9F?= =?UTF-8?q?=E8=83=BD=EF=BC=8C=E7=BB=9F=E4=B8=80=E4=BB=A3=E7=A0=81=E8=A7=84?= =?UTF-8?q?=E8=8C=83=E5=B9=B6=E9=87=8D=E5=91=BD=E5=90=8D=E9=87=8D=E5=A4=8D?= =?UTF-8?q?=E4=BB=BB=E5=8A=A1=E5=8E=86=E5=8F=B2=E5=AD=97=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/gui/ALAutoScriptOrchDialog.py | 202 +++++++++++++++------------- src/gui/ALAutoScriptPrevDialog.py | 92 ++++++------- src/gui/ALMainWorkers.py | 20 +-- src/gui/ALTimerTaskAddDialog.py | 167 +++++++++++++++-------- src/gui/ALTimerTaskHistoryDialog.py | 18 +-- src/gui/ALTimerTaskManageWidget.py | 68 ++++++++-- 6 files changed, 344 insertions(+), 223 deletions(-) diff --git a/src/gui/ALAutoScriptOrchDialog.py b/src/gui/ALAutoScriptOrchDialog.py index 3ffa63d..d4b7e78 100644 --- a/src/gui/ALAutoScriptOrchDialog.py +++ b/src/gui/ALAutoScriptOrchDialog.py @@ -8,7 +8,7 @@ You may use, modify, and distribute this file under the terms of the MIT License See the LICENSE file for details. """ -from PySide6.QtCore import QTime, QDate +from PySide6.QtCore import QTime, QDate, Slot from PySide6.QtWidgets import ( QDialog, QWidget, QVBoxLayout, QHBoxLayout, QLabel, QComboBox, QPushButton, QScrollArea, QTimeEdit, @@ -21,18 +21,15 @@ from utils.AutoScriptEngine import AutoScriptEngine VARIABLE_META = AutoScriptEngine.VARIABLE_META - _VAR_COMBO_ITEMS = [ (display, varname, vartype) for display, (varname, vartype) in VARIABLE_META.items() ] - _VAR_COMBO_ITEMS_SET = [ (display, varname, vartype) for display, (varname, vartype) in VARIABLE_META.items() if not varname.startswith("CURRENT_") ] - OP_ITEMS = [ ("等于", ".EQ."), ("不等于", ".NEQ."), @@ -298,7 +295,7 @@ class ActionStepFrame(QFrame): self.setupUi() self.connectSignals() - self._onTargetChanged(0) + self.onTargetChanged(0) def setupUi( @@ -316,7 +313,7 @@ class ActionStepFrame(QFrame): self.targetCombo = _makeSetVarCombo() self.valueWidgetStack = QStackedWidget() self.valueWidgetStack.setFixedHeight(25) - self._initValueStack() + self.initValueStack() setLabel = QLabel("设置") setLabel.setFixedHeight(25) @@ -337,9 +334,10 @@ class ActionStepFrame(QFrame): self ): - self.targetCombo.currentIndexChanged.connect(self._onTargetChanged) + self.targetCombo.currentIndexChanged.connect(self.onTargetChanged) - def _initValueStack( + + def initValueStack( self ): @@ -353,18 +351,6 @@ class ActionStepFrame(QFrame): self._valueWidgets.get("String", self.valueWidgetStack.widget(0)) ) - def _onTargetChanged( - self, - idx - ): - if idx < 0: - return - data = self.targetCombo.itemData(idx) - if data: - _, vartype = data - w = self._valueWidgets.get(vartype) - if w: - self.valueWidgetStack.setCurrentWidget(w) def getTarget( self @@ -373,6 +359,7 @@ class ActionStepFrame(QFrame): data = self.targetCombo.currentData() return data[0] if data else "" + def getTargetType( self ) -> str: @@ -380,6 +367,7 @@ class ActionStepFrame(QFrame): data = self.targetCombo.currentData() return data[1] if data else "String" + def getValueRaw( self ) -> str: @@ -390,6 +378,7 @@ class ActionStepFrame(QFrame): return _getValueFromWidget(w) return "" + def toScriptLine( self ) -> str: @@ -405,6 +394,7 @@ class ActionStepFrame(QFrame): return f" {target} .ADD. {hours}" return f" SET {target} = {encoded}" + def loadFromScript( self, targetVar: str, @@ -416,9 +406,10 @@ class ActionStepFrame(QFrame): if data and data[0] == targetVar: self.targetCombo.setCurrentIndex(idx) break - self._setValueFromExpr(valueExpr) + self.setValueFromExpr(valueExpr) - def _setValueFromExpr( + + def setValueFromExpr( self, expr: str ): @@ -429,6 +420,20 @@ class ActionStepFrame(QFrame): return _setWidgetValue(w, targetType, expr) + @Slot(int) + def onTargetChanged( + self, + idx + ): + if idx < 0: + return + data = self.targetCombo.itemData(idx) + if data: + _, vartype = data + w = self._valueWidgets.get(vartype) + if w: + self.valueWidgetStack.setCurrentWidget(w) + class ConditionalBlock(QGroupBox): @@ -444,7 +449,7 @@ class ConditionalBlock(QGroupBox): self.setupUi() self.connectSignals() - self._onOperandChanged(0) + self.onOperandChanged(0) def setupUi( @@ -453,7 +458,7 @@ class ConditionalBlock(QGroupBox): self.setStyleSheet( "QGroupBox { font-weight: bold; border: 1px solid #ccc; " - "margin-top: 8px; padding-top: 8px; }" + "margin-top: 5px; padding-top: 5px; }" ) self.setSizePolicy( QSizePolicy.Policy.Preferred, @@ -522,41 +527,12 @@ class ConditionalBlock(QGroupBox): self ): - self.operandCombo.currentIndexChanged.connect(self._onOperandChanged) - self.typeCombo.currentIndexChanged.connect(self._onTypeChanged) - self.addActionBtn.clicked.connect(self._addActionStep) + self.operandCombo.currentIndexChanged.connect(self.onOperandChanged) + self.typeCombo.currentIndexChanged.connect(self.onTypeChanged) + self.addActionBtn.clicked.connect(self.addActionStep) - def _onOperandChanged( - self, - idx - ): - if idx < 0: - return - data = self.operandCombo.itemData(idx) - if data: - _, vartype = data - w = self._condValueWidgets.get(vartype) - if w: - self.condValueStack.setCurrentWidget(w) - def _onTypeChanged( - self, - idx - ): - isCond = self.typeCombo.currentData() in ("IF", "ELSE IF") - self.conditionWidget.setVisible(isCond) - self.actionLabel.setText("执行步骤:" if isCond else "ELSE 执行步骤:") - - def _addActionStep( - self - ): - - step = ActionStepFrame(self) - step.deleteBtn.clicked.connect(lambda: self._removeActionStep(step)) - self._actionWidgets.append(step) - self.actionsLayout.addWidget(step) - - def _removeActionStep( + def removeActionStep( self, step: ActionStepFrame ): @@ -567,12 +543,14 @@ class ConditionalBlock(QGroupBox): step.hide() step.deleteLater() + def getBlockType( self ) -> str: return self.typeCombo.currentData() + def toScriptLines( self ) -> list: @@ -602,6 +580,7 @@ class ConditionalBlock(QGroupBox): return lines + def getConditionSummary( self ) -> str: @@ -617,6 +596,7 @@ class ConditionalBlock(QGroupBox): rawVal = self.getConditionRawValuePreview() return f"{bt} ({operandDisplay} {opDisplay} {rawVal})" + def getConditionRawValuePreview( self ) -> str: @@ -630,12 +610,46 @@ class ConditionalBlock(QGroupBox): return _getValueFromWidget(w) return "" + def countActionSteps( self ) -> int: return len(self._actionWidgets) + @Slot(int) + def onOperandChanged( + self, + idx + ): + if idx < 0: + return + data = self.operandCombo.itemData(idx) + if data: + _, vartype = data + w = self._condValueWidgets.get(vartype) + if w: + self.condValueStack.setCurrentWidget(w) + + @Slot(int) + def onTypeChanged( + self, + idx + ): + isCond = self.typeCombo.currentData() in ("IF", "ELSE IF") + self.conditionWidget.setVisible(isCond) + self.actionLabel.setText("执行步骤:" if isCond else "ELSE 执行步骤:") + + @Slot() + def addActionStep( + self + ): + + step = ActionStepFrame(self) + step.deleteBtn.clicked.connect(lambda: self.removeActionStep(step)) + self._actionWidgets.append(step) + self.actionsLayout.addWidget(step) + class ALAutoScriptOrchDialog(QDialog): @@ -651,9 +665,9 @@ class ALAutoScriptOrchDialog(QDialog): self.connectSignals() if existingScript and existingScript.strip(): - self._loadFromScript(existingScript) + self.loadFromScript(existingScript) else: - self._addBlock() + self.addBlock() self._scrollLayout.addStretch() @@ -695,28 +709,10 @@ class ALAutoScriptOrchDialog(QDialog): self.btnBox.accepted.connect(self.accept) self.btnBox.rejected.connect(self.reject) - self.addBlockBtn.clicked.connect(self._addBlock) + self.addBlockBtn.clicked.connect(self.addBlock) - def _addBlock( - self - ): - block = ConditionalBlock(len(self._blocks), self) - block.deleteBlockBtn.clicked.connect(lambda: self._removeBlock(block)) - self._blocks.append(block) - block._addActionStep() - if self._scrollLayout.count() > 0: - lastItem = self._scrollLayout.itemAt( - self._scrollLayout.count() - 1 - ) - if lastItem and lastItem.spacerItem(): - self._scrollLayout.insertWidget( - self._scrollLayout.count() - 1, block - ) - return - self._scrollLayout.addWidget(block) - - def _removeBlock( + def removeBlock( self, block: ConditionalBlock ): @@ -727,6 +723,7 @@ class ALAutoScriptOrchDialog(QDialog): block.hide() block.deleteLater() + def getScript( self ) -> str: @@ -742,6 +739,7 @@ class ALAutoScriptOrchDialog(QDialog): parts.append("ENDIF") return "\n".join(parts) + def getScriptPreview( self ) -> str: @@ -751,7 +749,8 @@ class ALAutoScriptOrchDialog(QDialog): return s[:7] + "..." return s - def _loadFromScript( + + def loadFromScript( self, script: str ): @@ -759,7 +758,7 @@ class ALAutoScriptOrchDialog(QDialog): import re lines = [l.strip() for l in script.split("\n") if l.strip()] if not lines: - self._addBlock() + self.addBlock() return currentBlock = None @@ -773,11 +772,11 @@ class ALAutoScriptOrchDialog(QDialog): typeIdxMap = {"IF": 0, "ELSE IF": 1, "ELSE": 2} idx = typeIdxMap.get(currentBlockType, 0) currentBlock.typeCombo.setCurrentIndex(idx) - currentBlock._onTypeChanged(idx) + currentBlock.onTypeChanged(idx) for oldStep in list(currentBlock._actionWidgets): - currentBlock._removeActionStep(oldStep) + currentBlock.removeActionStep(oldStep) for target, valueExpr in actionsBuffer: - currentBlock._addActionStep() + currentBlock.addActionStep() step = currentBlock._actionWidgets[-1] step.loadFromScript(target, valueExpr) self._blocks.clear() @@ -792,24 +791,24 @@ class ALAutoScriptOrchDialog(QDialog): flushBlock() currentBlockType = "IF" actionsBuffer = [] - self._addBlock() + self.addBlock() currentBlock = self._blocks[-1] - self._parseConditionToBlock(currentBlock, ifMatch.group(1)) + self.parseConditionToBlock(currentBlock, ifMatch.group(1)) continue elifIfMatch = re.match(r"^ELSE\s+IF\((.+)\)\s*THEN\s*$", upper) if elifIfMatch: flushBlock() currentBlockType = "ELSE IF" actionsBuffer = [] - self._addBlock() + self.addBlock() currentBlock = self._blocks[-1] - self._parseConditionToBlock(currentBlock, elifIfMatch.group(1)) + self.parseConditionToBlock(currentBlock, elifIfMatch.group(1)) continue if upper == "ELSE": flushBlock() currentBlockType = "ELSE" actionsBuffer = [] - self._addBlock() + self.addBlock() currentBlock = self._blocks[-1] currentBlock.conditionWidget.setVisible(False) continue @@ -833,9 +832,10 @@ class ALAutoScriptOrchDialog(QDialog): continue flushBlock() if not self._blocks: - self._addBlock() + self.addBlock() - def _parseConditionToBlock( + + def parseConditionToBlock( self, block: ConditionalBlock, condStr: str @@ -862,3 +862,23 @@ class ALAutoScriptOrchDialog(QDialog): if w: _setWidgetValue(w, vartype, rightPart) return + + @Slot() + def addBlock( + self + ): + + block = ConditionalBlock(len(self._blocks), self) + block.deleteBlockBtn.clicked.connect(lambda: self.removeBlock(block)) + self._blocks.append(block) + block.addActionStep() + if self._scrollLayout.count() > 0: + lastItem = self._scrollLayout.itemAt( + self._scrollLayout.count() - 1 + ) + if lastItem and lastItem.spacerItem(): + self._scrollLayout.insertWidget( + self._scrollLayout.count() - 1, block + ) + return + self._scrollLayout.addWidget(block) \ No newline at end of file diff --git a/src/gui/ALAutoScriptPrevDialog.py b/src/gui/ALAutoScriptPrevDialog.py index 78bd7ec..4becb08 100644 --- a/src/gui/ALAutoScriptPrevDialog.py +++ b/src/gui/ALAutoScriptPrevDialog.py @@ -8,6 +8,8 @@ You may use, modify, and distribute this file under the terms of the MIT License See the LICENSE file for details. """ +from PySide6.QtCore import Slot + from PySide6.QtGui import ( QSyntaxHighlighter, QTextCharFormat, QColor, QFont, QIcon ) @@ -162,53 +164,13 @@ class ALAutoScriptPreviewDialog(QDialog): ): self._btnBox.rejected.connect(self.reject) - self._zoomInBtn.clicked.connect(self._onZoomIn) - self._zoomOutBtn.clicked.connect(self._onZoomOut) - self._zoomResetBtn.clicked.connect(self._onZoomReset) - self._copyBtn.clicked.connect(self._onCopy) + 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 _onZoomIn( - self - ): - - self.__fontSize = min(self.__fontSize + 2, 40) - self._updateFontSize() - - - def _onZoomOut( - self - ): - - self.__fontSize = max(self.__fontSize - 2, 8) - self._updateFontSize() - - - def _onZoomReset( - self - ): - - self.__fontSize = 13 - self._updateFontSize() - - - def _onCopy( - self - ): - - clipboard = QApplication.clipboard() - clipboard.setText(self._textEdit.toPlainText()) - original = self._copyBtn.text() - self._copyBtn.setText("已复制") - self._copyBtn.setEnabled(False) - from PySide6.QtCore import QTimer - QTimer.singleShot(2000, lambda: ( - self._copyBtn.setText(original), - self._copyBtn.setEnabled(True) - )) - - - def _updateFontSize( + def updateFontSize( self ): @@ -222,3 +184,43 @@ class ALAutoScriptPreviewDialog(QDialog): "}" ) self._zoomLabel.setText(f"{self.__fontSize}px") + + @Slot() + def onZoomIn( + self + ): + + self.__fontSize = min(self.__fontSize + 2, 40) + self.updateFontSize() + + @Slot() + def onZoomOut( + self + ): + + self.__fontSize = max(self.__fontSize - 2, 8) + self.updateFontSize() + + @Slot() + def onZoomReset( + self + ): + + self.__fontSize = 13 + self.updateFontSize() + + @Slot() + def onCopy( + self + ): + + clipboard = QApplication.clipboard() + clipboard.setText(self._textEdit.toPlainText()) + original = self._copyBtn.text() + self._copyBtn.setText("已复制") + self._copyBtn.setEnabled(False) + from PySide6.QtCore import QTimer + QTimer.singleShot(2000, lambda: ( + self._copyBtn.setText(original), + self._copyBtn.setEnabled(True) + )) diff --git a/src/gui/ALMainWorkers.py b/src/gui/ALMainWorkers.py index 4bb736f..9bc1c9f 100644 --- a/src/gui/ALMainWorkers.py +++ b/src/gui/ALMainWorkers.py @@ -174,7 +174,7 @@ class TimerTaskWorker(AutoLibWorker): try: if not self.loadConfigs(): raise Exception("配置文件加载失败") - self._applyRepeatAutoScript() + self.applyRepeatAutoScript() auto_lib = AutoLib( self._input_queue, self._output_queue, @@ -207,7 +207,7 @@ class TimerTaskWorker(AutoLibWorker): self.timerTaskWorkerIsFinished.emit(False, self.__timer_task) - def _applyRepeatAutoScript( + def applyRepeatAutoScript( self ): @@ -238,6 +238,14 @@ class TimerTaskWorker(AutoLibWorker): self.TraceLevel.INFO ) + @Slot() + def onTimerTaskIsFinished( + self + ): + + self._showTrace(f"定时任务 {self.__timer_task['name']} 运行结束") + self.timerTaskWorkerIsFinished.emit(False, self.__timer_task) + @Slot() def onTimerTaskFinishedWithError( self @@ -248,11 +256,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) diff --git a/src/gui/ALTimerTaskAddDialog.py b/src/gui/ALTimerTaskAddDialog.py index cd4e8ac..dab9139 100644 --- a/src/gui/ALTimerTaskAddDialog.py +++ b/src/gui/ALTimerTaskAddDialog.py @@ -36,15 +36,20 @@ 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 @@ -130,6 +135,45 @@ class ALTimerTaskAddDialog(QDialog, Ui_ALTimerTaskAddDialog): self.__auto_script = "" + def loadTask( + self, + task: dict + ): + + self.TaskNameLineEdit.setText(task.get("name", "")) + time_type = task.get("time_type", "特定时间") + self.TimerTypeComboBox.setCurrentText(time_type) + self.SpecificDateTimeEdit.setDateTime( + QDateTime(task["execute_time"]) + ) + self.RelativeDaySpinBox.setValue(0) + self.RelativeHourSpinBox.setValue(0) + self.RelativeMinuteSpinBox.setValue(0) + self.RelativeSecondSpinBox.setValue(0) + if task.get("silent", False): + self.SilentlyRunRadioButton.setChecked(True) + else: + self.ShowBeforeRunRadioButton.setChecked(True) + repeat = task.get("repeat", False) + self.RepeatCheckBox.setChecked(repeat) + if repeat: + repeat_days = task.get("repeat_days", []) + self.MonCheckBox.setChecked(0 in repeat_days) + self.TueCheckBox.setChecked(1 in repeat_days) + self.WedCheckBox.setChecked(2 in repeat_days) + self.ThuCheckBox.setChecked(3 in repeat_days) + self.FriCheckBox.setChecked(4 in repeat_days) + self.SatCheckBox.setChecked(5 in repeat_days) + self.SunCheckBox.setChecked(6 in repeat_days) + auto_script = task.get("repeat_auto_script", "") + if auto_script: + self.__auto_script = auto_script + self.AutoScriptStatusLabel.setText("已设置") + self.AutoScriptStatusLabel.setStyleSheet("color: #4CAF50;") + self.AutoScriptPreviewButton.setEnabled(True) + self.ConfirmButton.setText("保存") + + def connectSignals( self ): @@ -138,44 +182,9 @@ class ALTimerTaskAddDialog(QDialog, Ui_ALTimerTaskAddDialog): self.ConfirmButton.clicked.connect(self.accept) self.TimerTypeComboBox.currentIndexChanged.connect(self.onTimerTypeComboBoxIndexChanged) self.RepeatCheckBox.toggled.connect(self.onRepeatCheckBoxToggled) - self.AutoScriptSetButton.clicked.connect(self._onSetAutoScript) - self.AutoScriptPreviewButton.clicked.connect(self._onPreviewAutoScript) - self.AutoScriptHelpButton.clicked.connect(self._onAutoScriptHelp) - - @Slot() - def _onSetAutoScript(self): - dlg = ALAutoScriptOrchDialog(self, existingScript=self.__auto_script) - if dlg.exec() == QDialog.DialogCode.Accepted: - script = dlg.getScript() - self.__auto_script = script - if script: - self.AutoScriptStatusLabel.setText("已设置") - self.AutoScriptStatusLabel.setStyleSheet("color: #4CAF50;") - self.AutoScriptPreviewButton.setEnabled(True) - else: - self.AutoScriptStatusLabel.setText("未设置") - self.AutoScriptStatusLabel.setStyleSheet("color: #969696;") - self.AutoScriptPreviewButton.setEnabled(False) - dlg.deleteLater() - - @Slot() - def _onPreviewAutoScript(self): - if not self.__auto_script: - return - from gui.ALAutoScriptPrevDialog import ALAutoScriptPreviewDialog - dlg = ALAutoScriptPreviewDialog(self, self.__auto_script) - dlg.exec() - dlg.deleteLater() - - - @Slot() - def _onAutoScriptHelp( - self - ): - - QDesktopServices.openUrl( - QUrl("https://www.autolibrary.kenanzhu.com/manuals/autoscript") - ) + self.AutoScriptSetButton.clicked.connect(self.onSetAutoScript) + self.AutoScriptPreviewButton.clicked.connect(self.onPreviewAutoScript) + self.AutoScriptHelpButton.clicked.connect(self.onAutoScriptHelp) def getTimerTask( @@ -200,20 +209,34 @@ 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(), - "repeat_auto_script": self.__auto_script, - } - if task_data["repeat"]: - task_data["history"] = [] # repeat history + + 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 + 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, + } + + 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) @@ -265,4 +288,40 @@ class ALTimerTaskAddDialog(QDialog, Ui_ALTimerTaskAddDialog): self.FriCheckBox.setEnabled(checked) self.SatCheckBox.setEnabled(checked) self.SunCheckBox.setEnabled(checked) - self.AutoScriptGroupBox.setVisible(checked) \ No newline at end of file + self.AutoScriptGroupBox.setVisible(checked) + + @Slot() + def onSetAutoScript(self): + dlg = ALAutoScriptOrchDialog(self, existingScript=self.__auto_script) + if dlg.exec() == QDialog.DialogCode.Accepted: + script = dlg.getScript() + self.__auto_script = script + if script: + self.AutoScriptStatusLabel.setText("已设置") + self.AutoScriptStatusLabel.setStyleSheet("color: #4CAF50;") + self.AutoScriptPreviewButton.setEnabled(True) + else: + self.AutoScriptStatusLabel.setText("未设置") + self.AutoScriptStatusLabel.setStyleSheet("color: #969696;") + self.AutoScriptPreviewButton.setEnabled(False) + dlg.deleteLater() + + @Slot() + def onPreviewAutoScript(self): + if not self.__auto_script: + return + from gui.ALAutoScriptPrevDialog import ALAutoScriptPreviewDialog + dlg = ALAutoScriptPreviewDialog(self, self.__auto_script) + dlg.exec() + dlg.deleteLater() + + @Slot() + def onAutoScriptHelp( + self + ): + + QDesktopServices.openUrl( + QUrl("https://www.autolibrary.kenanzhu.com/manuals/autoscript") + ) + + diff --git a/src/gui/ALTimerTaskHistoryDialog.py b/src/gui/ALTimerTaskHistoryDialog.py index f054b15..8500c96 100644 --- a/src/gui/ALTimerTaskHistoryDialog.py +++ b/src/gui/ALTimerTaskHistoryDialog.py @@ -30,7 +30,7 @@ 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.connectSignals() @@ -130,6 +130,13 @@ class ALTimerTaskHistoryDialog(QDialog): self.HistoryTableWidget.setItem(row, 2, DurationItem) self.HistoryTableWidget.setRowHeight(row, 25) + + def getHistory( + self + ) -> list: + + return self.__history + @Slot() def onClearHistoryButtonClicked( self @@ -137,11 +144,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 diff --git a/src/gui/ALTimerTaskManageWidget.py b/src/gui/ALTimerTaskManageWidget.py index 9dcf792..da1646b 100644 --- a/src/gui/ALTimerTaskManageWidget.py +++ b/src/gui/ALTimerTaskManageWidget.py @@ -19,10 +19,10 @@ from PySide6.QtCore import ( ) from PySide6.QtWidgets import ( QDialog, QWidget, QListWidgetItem, QMessageBox, - QHBoxLayout, QVBoxLayout, QLabel, QPushButton + QHBoxLayout, QVBoxLayout, QLabel, QPushButton, QMenu ) from PySide6.QtGui import ( - QCloseEvent + QCloseEvent, QAction ) import managers.config.ConfigManager as ConfigManager @@ -35,6 +35,8 @@ from gui.ALTimerTaskHistoryDialog import ALTimerTaskHistoryDialog class ALTimerTaskItemWidget(QWidget): + editRequested = Signal(dict) + def __init__( self, parent = None, @@ -45,6 +47,8 @@ class ALTimerTaskItemWidget(QWidget): self.__timer_task = timer_task self.modifyUi() + self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) + self.customContextMenuRequested.connect(self.showContextMenu) def modifyUi( @@ -145,6 +149,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.parent().deleteTask(self.__timer_task) + ) + menu.addAction(delete_action) + menu.exec(self.mapToGlobal(pos)) + class ALTimerTaskManageWidget(QWidget, Ui_ALTimerTaskManageWidget): @@ -224,8 +249,8 @@ class ALTimerTaskManageWidget(QWidget, Ui_ALTimerTaskManageWidget): 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("定时任务配置文件格式错误") @@ -248,8 +273,8 @@ 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 }) return True @@ -363,6 +388,7 @@ 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) @@ -378,15 +404,30 @@ 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 "history" not in timer_task: + if "repeat_history" not in timer_task: history = [] else: - history = timer_task["history"] + history = timer_task["repeat_history"] history_count = len(history) return ( f"任务名称:{timer_task["name"]}\n" @@ -395,7 +436,7 @@ class ALTimerTaskManageWidget(QWidget, Ui_ALTimerTaskManageWidget): f"下次执行时间:{datetime.strftime(timer_task["execute_time"], "%Y-%m-%d %H:%M:%S")}\n" f"已记录次数:{history_count}" ) - + def deleteTask( self, @@ -559,7 +600,6 @@ class ALTimerTaskManageWidget(QWidget, Ui_ALTimerTaskManageWidget): self.updateTimerTaskList() self.updateStat() - @Slot(dict) def onTimerTaskIsRunning( self, @@ -584,12 +624,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, @@ -603,7 +643,7 @@ 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, From 967ede4b0498584961ccf178df60a93ce9a1ee3c Mon Sep 17 00:00:00 2001 From: KenanZhu <3471685733@qq.com> Date: Sat, 9 May 2026 12:59:23 +0800 Subject: [PATCH 4/4] =?UTF-8?q?fix(ALTimerTaskManageWidget):=20=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E5=8F=B3=E9=94=AE=E8=8F=9C=E5=8D=95=E5=88=A0=E9=99=A4?= =?UTF-8?q?=E4=BB=BB=E5=8A=A1=E6=97=B6=20parent()=20=E7=B1=BB=E5=9E=8B?= =?UTF-8?q?=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/gui/ALTimerTaskManageWidget.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/gui/ALTimerTaskManageWidget.py b/src/gui/ALTimerTaskManageWidget.py index da1646b..c603903 100644 --- a/src/gui/ALTimerTaskManageWidget.py +++ b/src/gui/ALTimerTaskManageWidget.py @@ -45,6 +45,7 @@ class ALTimerTaskItemWidget(QWidget): super().__init__(parent) self.__timer_task = timer_task + self.__manage_widget = parent self.modifyUi() self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) @@ -165,7 +166,7 @@ class ALTimerTaskItemWidget(QWidget): and self.__timer_task["status"] != ALTimerTaskStatus.READY: delete_action = QAction("删除", self) delete_action.triggered.connect( - lambda: self.parent().deleteTask(self.__timer_task) + lambda: self.__manage_widget.deleteTask(self.__timer_task) ) menu.addAction(delete_action) menu.exec(self.mapToGlobal(pos))