Compare commits
157 Commits
v1.0.5
...
0bad34d7a8
| Author | SHA1 | Date | |
|---|---|---|---|
| 0bad34d7a8 | |||
| 61d1b44402 | |||
| 72301c63fd | |||
| 609850ab60 | |||
| 8e14d45b71 | |||
| f6ef9af39d | |||
| 72dce83c55 | |||
| c337904010 | |||
| 779aad13b8 | |||
| f3360423e5 | |||
| bea12d5f0c | |||
| b24f39456e | |||
| bb63ee6f03 | |||
| 3ebebe015f | |||
| 02b3a62868 | |||
| d7e19dcd52 | |||
| 59c06b3a19 | |||
| b78fd2d1e4 | |||
| 2aace40a26 | |||
| df7ad92f7f | |||
| 910e3e3224 | |||
| f7167c13f4 | |||
| eb8da498a2 | |||
| b279b51b42 | |||
| 43336f98d2 | |||
| e77c561685 | |||
| 345cb95b98 | |||
| caa563e770 | |||
| 280028259f | |||
| a6bc103c73 | |||
| 2226e8ac90 | |||
| 106463b9e5 | |||
| 5e898180c7 | |||
| a03ab38279 | |||
| 4761cade26 | |||
| 531b05651e | |||
| 3cea7df736 | |||
| a0fd03f12f | |||
| 9b47886e5b | |||
| 82738be99a | |||
| e097b5afc9 | |||
| fe7453fe02 | |||
| 1d4b03d162 | |||
| 4642916fd5 | |||
| 5800437ba2 | |||
| 23467c1d3d | |||
| b8c0a29c59 | |||
| 87787ad3dc | |||
| e800f6ece1 | |||
| 600a304ab8 | |||
| c038c8005d | |||
| 6cf182c8c8 | |||
| 33c0f4414c | |||
| 2843300cf9 | |||
| 9bdc9a3de9 | |||
| 500ddd41c5 | |||
| 14c6db3384 | |||
| bbd97970a6 | |||
| 22d3c3462c | |||
| dc287f3aa5 | |||
| 7886379875 | |||
| 967ede4b04 | |||
| 27250dba2f | |||
| 46b3447d1e | |||
| 4d0d7a952c | |||
| e11f696b76 | |||
| ffae43d5bd | |||
| baa4f23136 | |||
| 1c88d3db7b | |||
| 3880f90916 | |||
| d3d146b1b3 | |||
| 0f74a3b0ec | |||
| 9305c559cd | |||
| f56945f29e | |||
| 37132de4fc | |||
| ac5385bcfe | |||
| f984217bda | |||
| 4e7780fe70 | |||
| 7149cb2b7d | |||
| 2c90008fcd | |||
| 5c393595d7 | |||
| 4924f4b031 | |||
| 62c1ecdb07 | |||
| aef28b6d5e | |||
| afa1d39051 | |||
| 84cff6acc3 | |||
| e40c7f4f3e | |||
| c8e202dc8c | |||
| 9a3abc365c | |||
| 6b2bf4863e | |||
| 95aa2bb518 | |||
| 571af554d2 | |||
| 706fc889f9 | |||
| bf93cc2cbc | |||
| 1cfe261324 | |||
| e5dea7bcc5 | |||
| 30b36b68dd | |||
| 595f43d852 | |||
| 02463f087e | |||
| e481824344 | |||
| 160d6a2428 | |||
| ec683cf154 | |||
| 2d0782c368 | |||
| 824b9b8869 | |||
| c26f19b6b3 | |||
| 1d99ca92f2 | |||
| 50ebeb0fab | |||
| faa26b489a | |||
| c03eed1d51 | |||
| 2f5680c547 | |||
| 1cd39ec84c | |||
| 73aab7b957 | |||
| 0a94c344d5 | |||
| 68e002ba8e | |||
| 94dc22819f | |||
| d55d2075cb | |||
| 82744e3a2d | |||
| 67493349dd | |||
| 0aea9b1540 | |||
| c02c6ddbe3 | |||
| c679a1c79e | |||
| b73242be00 | |||
| 9accf5ddc1 | |||
| 883859d1f9 | |||
| f37bcf836b | |||
| b0d1c0e99e | |||
| 5af6120be8 | |||
| 60e055f6bb | |||
| 01e8100774 | |||
| cf8493565e | |||
| 24bb76d039 | |||
| 7111411115 | |||
| 7df6a9157d | |||
| ebe3910df5 | |||
| 84367e4abe | |||
| 3a50991860 | |||
| e4482b01da | |||
| c06e0e05da | |||
| ff083884b6 | |||
| 9ae89b61a4 | |||
| 2152cc46a3 | |||
| 95a3ae2a24 | |||
| 896242a1e3 | |||
| fd96fc235e | |||
| 25aab588a8 | |||
| 6e1b8e6b10 | |||
| 5f2327cf61 | |||
| 96e7adabb0 | |||
| 42afbbe694 | |||
| 3777970332 | |||
| 9fb28e1368 | |||
| 4aeca08ce8 | |||
| a1ff85256a | |||
| 169de92d5b | |||
| 5ca4a14a14 | |||
| 155b3fe3ca | |||
| 99d454a566 |
@@ -0,0 +1,491 @@
|
||||
name: Build Test
|
||||
|
||||
# This workflow builds the application for testing purposes.
|
||||
# It is triggered when a pull request is opened, synchronized, or reopened against the main branch.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
types:
|
||||
- opened
|
||||
- synchronize
|
||||
- reopened
|
||||
workflow_dispatch:
|
||||
|
||||
#
|
||||
# Build Windows
|
||||
#
|
||||
|
||||
jobs:
|
||||
build-windows:
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ github.ref }}
|
||||
|
||||
- name: Get version info
|
||||
id: get_version
|
||||
run: |
|
||||
$version = "pr-test"
|
||||
$tagName = "pr-test"
|
||||
|
||||
Write-Host "✓ Mode: Pull Request Test Build"
|
||||
Write-Host "✓ Tag: $tagName"
|
||||
Write-Host "✓ Version: $version"
|
||||
|
||||
"VERSION=$version" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append
|
||||
"TAG_NAME=$tagName" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append
|
||||
shell: pwsh
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.13'
|
||||
cache: 'pip'
|
||||
cache-dependency-path: requirements.txt
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirements.txt
|
||||
|
||||
- name: Solve ddddocr compatibility and copy model files
|
||||
run: |
|
||||
$ddddocrPath = python -c "import ddddocr, os; print(os.path.dirname(ddddocr.__file__))"
|
||||
Write-Host "ddddocr package location: $ddddocrPath"
|
||||
|
||||
$initFile = Join-Path $ddddocrPath "__init__.py"
|
||||
if (Test-Path $initFile) {
|
||||
Write-Host "Fixing ddddocr compatibility in: $initFile"
|
||||
(Get-Content $initFile) -replace 'Image\.ANTIALIAS', 'Image.Resampling.LANCZOS' | Set-Content $initFile
|
||||
Write-Host "✓ Fixed: Image.ANTIALIAS -> Image.Resampling.LANCZOS"
|
||||
} else {
|
||||
Write-Error "✗ ddddocr __init__.py not found"
|
||||
exit 1
|
||||
}
|
||||
|
||||
if (-not (Test-Path "models")) {
|
||||
New-Item -ItemType Directory -Path "models" | Out-Null
|
||||
Write-Host "✓ Created models directory"
|
||||
}
|
||||
|
||||
$onnxSource = Join-Path $ddddocrPath "common.onnx"
|
||||
$onnxDest = "models/common.onnx"
|
||||
if (Test-Path $onnxSource) {
|
||||
Copy-Item $onnxSource $onnxDest -Force
|
||||
Write-Host "✓ Copied ONNX model from: $onnxSource"
|
||||
Write-Host "✓ ONNX model copied to: $onnxDest"
|
||||
} else {
|
||||
Write-Error "✗ ONNX model not found in ddddocr package: $onnxSource"
|
||||
exit 1
|
||||
}
|
||||
|
||||
if (Test-Path $onnxDest) {
|
||||
$fileSize = (Get-Item $onnxDest).Length / 1MB
|
||||
Write-Host "✓ Model file verified: $onnxDest (Size: $([math]::Round($fileSize, 2)) MB)"
|
||||
} else {
|
||||
Write-Error "✗ Failed to copy model file"
|
||||
exit 1
|
||||
}
|
||||
shell: pwsh
|
||||
|
||||
- name: Compile Qt Resource files
|
||||
run: |
|
||||
cd batchs
|
||||
./compile_rc.bat
|
||||
shell: cmd
|
||||
|
||||
- name: Compile Qt UI files
|
||||
run: |
|
||||
cd batchs
|
||||
./compile_ui.bat
|
||||
shell: cmd
|
||||
|
||||
- name: Generate 'Main.spec'
|
||||
run: |
|
||||
$version = "${{ steps.get_version.outputs.VERSION }}"
|
||||
$exeName = "AutoLibrary-$version"
|
||||
|
||||
Write-Host "Generating Main.spec for version: $version"
|
||||
Write-Host "Executable name: $exeName"
|
||||
|
||||
$specLines = @(
|
||||
"# -*- mode: python ; coding: utf-8 -*-"
|
||||
""
|
||||
""
|
||||
"a = Analysis("
|
||||
" ['src\\Main.py'],"
|
||||
" pathex=[],"
|
||||
" binaries=[],"
|
||||
" datas=["
|
||||
" ('models\\common.onnx', 'ddddocr'),"
|
||||
" ('src\\gui\\resources\\icons\\AutoLibrary_Logo_64.ico', 'gui\\resources\\icons'),"
|
||||
" ],"
|
||||
" hiddenimports=[],"
|
||||
" hookspath=[],"
|
||||
" hooksconfig={},"
|
||||
" runtime_hooks=[],"
|
||||
" excludes=[],"
|
||||
" noarchive=False,"
|
||||
" optimize=0,"
|
||||
")"
|
||||
"pyz = PYZ(a.pure)"
|
||||
""
|
||||
"exe = EXE("
|
||||
" pyz,"
|
||||
" a.scripts,"
|
||||
" name='AutoLibrary',"
|
||||
" debug=False,"
|
||||
" bootloader_ignore_signals=False,"
|
||||
" strip=False,"
|
||||
" upx=True,"
|
||||
" upx_exclude=[],"
|
||||
" runtime_tmpdir=None,"
|
||||
" console=False,"
|
||||
" disable_windowed_traceback=False,"
|
||||
" argv_emulation=False,"
|
||||
" target_arch=None,"
|
||||
" codesign_identity=None,"
|
||||
" entitlements_file=None,"
|
||||
" icon=['src\\gui\\resources\\icons\\AutoLibrary_Logo_64.ico'],"
|
||||
")"
|
||||
""
|
||||
"coll = COLLECT("
|
||||
" exe,"
|
||||
" a.binaries,"
|
||||
" a.datas,"
|
||||
" strip=False,"
|
||||
" upx=True,"
|
||||
" upx_exclude=[],"
|
||||
" name='$exeName'"
|
||||
")"
|
||||
)
|
||||
$specLines | Out-File -FilePath "Main.spec" -Encoding UTF8
|
||||
|
||||
Write-Host "✓ Main.spec (non-single file) generated successfully"
|
||||
Write-Host "`n========================================"
|
||||
Write-Host "Generated Main.spec"
|
||||
Write-Host "========================================"
|
||||
Get-Content "Main.spec" | Write-Host
|
||||
Write-Host "========================================`n"
|
||||
shell: pwsh
|
||||
|
||||
- name: Build with PyInstaller
|
||||
run: |
|
||||
pyinstaller Main.spec
|
||||
|
||||
- name: Zip windows release
|
||||
id: zip_release
|
||||
run: |
|
||||
$tagName = "${{ steps.get_version.outputs.TAG_NAME }}"
|
||||
$version = "${{ steps.get_version.outputs.VERSION }}"
|
||||
$distDir = "dist/AutoLibrary-$version"
|
||||
$zipName = "AutoLibrary.$tagName-windows-x86_64.zip"
|
||||
|
||||
"ZIP_NAME=$zipName" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append
|
||||
|
||||
Write-Host "Looking for distribution directory: $distDir"
|
||||
if (Test-Path $distDir) {
|
||||
Compress-Archive -Path "$distDir/*" -DestinationPath $zipName
|
||||
Write-Host "✓ Created release archive (directory mode): $zipName"
|
||||
} else {
|
||||
Write-Error "✗ Distribution directory not found: $distDir"
|
||||
Write-Host "Files in dist directory:"
|
||||
Get-ChildItem "dist" | ForEach-Object { Write-Host " - $($_.Name)" }
|
||||
exit 1
|
||||
}
|
||||
shell: pwsh
|
||||
|
||||
- name: Archive artifacts
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: AutoLibrary.${{ steps.get_version.outputs.TAG_NAME }}-windows-x86_64
|
||||
path: |
|
||||
${{ steps.zip_release.outputs.ZIP_NAME }}
|
||||
retention-days: 7
|
||||
|
||||
- name: Upload build summary
|
||||
run: |
|
||||
"## Build Test Summary" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8
|
||||
"" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
|
||||
"========================================" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
|
||||
"✓ Pull request build test completed successfully!" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
|
||||
"- Version: ${{ steps.get_version.outputs.VERSION }}" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
|
||||
"- Tag: ${{ steps.get_version.outputs.TAG_NAME }}" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
|
||||
"- Pull Request #${{ github.event.pull_request.number || 'N/A' }}" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
|
||||
"- Branch: ${{ github.event.pull_request.head.ref || github.ref }}" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
|
||||
"- Event: ${{ github.event_name }}" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
|
||||
shell: pwsh
|
||||
|
||||
#
|
||||
# Build macOS
|
||||
#
|
||||
|
||||
build-macos:
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ github.ref }}
|
||||
|
||||
- name: Get version info
|
||||
id: get_version
|
||||
run: |
|
||||
version="pr-test"
|
||||
tag_name="pr-test"
|
||||
echo "✓ Mode: Pull Request Test Build"
|
||||
echo "✓ Tag: $tag_name"
|
||||
echo "✓ Version: $version"
|
||||
echo "VERSION=$version" >> $GITHUB_OUTPUT
|
||||
echo "TAG_NAME=$tag_name" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.13'
|
||||
cache: 'pip'
|
||||
cache-dependency-path: requirements.txt
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirements.txt
|
||||
|
||||
- name: Solve ddddocr compatibility and copy model files
|
||||
run: |
|
||||
ddddocr_path=$(python -c "import ddddocr, os; print(os.path.dirname(ddddocr.__file__))")
|
||||
echo "ddddocr package location: $ddddocr_path"
|
||||
|
||||
init_file="$ddddocr_path/__init__.py"
|
||||
if [ -f "$init_file" ]; then
|
||||
echo "Fixing ddddocr compatibility in: $init_file"
|
||||
# macOS 使用 BSD sed,要求 -i 后跟备份后缀名(空字符串 = 不备份)
|
||||
sed -i '' 's/Image\.ANTIALIAS/Image.Resampling.LANCZOS/g' "$init_file"
|
||||
echo "✓ Fixed: Image.ANTIALIAS -> Image.Resampling.LANCZOS"
|
||||
else
|
||||
echo "✗ ddddocr __init__.py not found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p models
|
||||
echo "✓ Created models directory"
|
||||
|
||||
# 多路径 fallback 搜索 ONNX 模型,提升 ddddocr 版本兼容性
|
||||
onnx_source=""
|
||||
onnx_dest="models/common.onnx"
|
||||
candidate_paths=(
|
||||
"$ddddocr_path/common.onnx"
|
||||
"$ddddocr_path/models/common.onnx"
|
||||
"$ddddocr_path/ddddocr/common.onnx"
|
||||
)
|
||||
for candidate in "${candidate_paths[@]}"; do
|
||||
if [ -f "$candidate" ]; then
|
||||
onnx_source="$candidate"
|
||||
break
|
||||
fi
|
||||
done
|
||||
# 若以上候选均未命中,回退到全包搜索
|
||||
if [ -z "$onnx_source" ]; then
|
||||
echo "⚠ 未在已知路径找到 ONNX 模型,回退到全包搜索..."
|
||||
onnx_source=$(find "$ddddocr_path" -name "*.onnx" -type f 2>/dev/null | head -1)
|
||||
fi
|
||||
|
||||
if [ -n "$onnx_source" ] && [ -f "$onnx_source" ]; then
|
||||
cp "$onnx_source" "$onnx_dest"
|
||||
echo "✓ ONNX model copied: $onnx_source -> $onnx_dest"
|
||||
else
|
||||
echo "✗ ONNX model not found in ddddocr package"
|
||||
echo " Searched directory: $ddddocr_path"
|
||||
ls -la "$ddddocr_path/" || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -f "$onnx_dest" ]; then
|
||||
file_size=$(du -h "$onnx_dest" | cut -f1)
|
||||
echo "✓ Model file verified: $onnx_dest (Size: $file_size)"
|
||||
else
|
||||
echo "✗ Failed to copy model file"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Compile Qt Resource files
|
||||
run: |
|
||||
cd batchs
|
||||
bash compile_rc.sh
|
||||
|
||||
- name: Compile Qt UI files
|
||||
run: |
|
||||
cd batchs
|
||||
bash compile_ui.sh
|
||||
|
||||
- name: Generate 'Main.spec'
|
||||
env:
|
||||
APP_VERSION: ${{ steps.get_version.outputs.VERSION }}
|
||||
run: |
|
||||
version="$APP_VERSION"
|
||||
exe_name="AutoLibrary-$version"
|
||||
echo "Generating Main.spec for version: $version"
|
||||
echo "App name: $exe_name"
|
||||
|
||||
printf '%s\n' \
|
||||
'# -*- mode: python ; coding: utf-8 -*-' \
|
||||
'' \
|
||||
'a = Analysis(' \
|
||||
" ['src/Main.py']," \
|
||||
' pathex=[],' \
|
||||
' binaries=[],' \
|
||||
' datas=[' \
|
||||
" ('models/common.onnx', 'ddddocr')," \
|
||||
" ('src/gui/resources/icons/AutoLibrary_Logo_64.ico', 'gui/resources/icons')," \
|
||||
' ],' \
|
||||
' hiddenimports=[],' \
|
||||
' hookspath=[],' \
|
||||
' hooksconfig={},' \
|
||||
' runtime_hooks=[],' \
|
||||
' excludes=[],' \
|
||||
' noarchive=False,' \
|
||||
' optimize=0,' \
|
||||
')' \
|
||||
'pyz = PYZ(a.pure)' \
|
||||
'' \
|
||||
'exe = EXE(' \
|
||||
' pyz,' \
|
||||
' a.scripts,' \
|
||||
' [],' \
|
||||
' exclude_binaries=True,' \
|
||||
" name='AutoLibrary'," \
|
||||
' debug=False,' \
|
||||
' bootloader_ignore_signals=False,' \
|
||||
' strip=False,' \
|
||||
' upx=True,' \
|
||||
' upx_exclude=[],' \
|
||||
' runtime_tmpdir=None,' \
|
||||
' console=False,' \
|
||||
' disable_windowed_traceback=False,' \
|
||||
' argv_emulation=False,' \
|
||||
' target_arch=None,' \
|
||||
' codesign_identity=None,' \
|
||||
' entitlements_file=None,' \
|
||||
" icon=['src/gui/resources/icons/AutoLibrary_Logo_64.ico']," \
|
||||
')' \
|
||||
'' \
|
||||
'coll = COLLECT(' \
|
||||
' exe,' \
|
||||
' a.binaries,' \
|
||||
' a.zipfiles,' \
|
||||
' a.datas,' \
|
||||
' strip=False,' \
|
||||
' upx=True,' \
|
||||
' upx_exclude=[],' \
|
||||
" name='${exe_name}'," \
|
||||
')' \
|
||||
'' \
|
||||
'app = BUNDLE(' \
|
||||
' coll,' \
|
||||
" name='AutoLibrary.app'," \
|
||||
" icon='src/gui/resources/icons/AutoLibrary_Logo.icns'," \
|
||||
" bundle_identifier='com.kenanzhu.autolibrary'," \
|
||||
' info_plist={' \
|
||||
" 'NSHighResolutionCapable': 'True'," \
|
||||
" 'CFBundleName': 'AutoLibrary'," \
|
||||
" 'CFBundleDisplayName': 'AutoLibrary'," \
|
||||
" 'CFBundleShortVersionString': '${version}'," \
|
||||
" 'CFBundleVersion': '${version}'," \
|
||||
" 'CFBundleExecutable': 'AutoLibrary'," \
|
||||
" 'NSPrincipalClass': 'NSApplication'," \
|
||||
" 'LSMinimumSystemVersion': '11.0'," \
|
||||
" 'NSRequiresAquaSystemAppearance': 'False'," \
|
||||
" 'NSHumanReadableCopyright': 'Copyright 2025 - 2026 KenanZhu. All rights reserved.'," \
|
||||
' },' \
|
||||
')' \
|
||||
> Main.spec
|
||||
|
||||
echo "✓ Main.spec generated successfully"
|
||||
echo ""
|
||||
echo "========================================"
|
||||
echo "Generated Main.spec"
|
||||
echo "========================================"
|
||||
cat Main.spec
|
||||
echo "========================================"
|
||||
|
||||
- name: Build with PyInstaller
|
||||
run: |
|
||||
python -m PyInstaller Main.spec
|
||||
|
||||
- name: Create DMG
|
||||
run: |
|
||||
tag_name="${{ steps.get_version.outputs.TAG_NAME }}"
|
||||
dmg_name="AutoLibrary.$tag_name-macos-arm64.dmg"
|
||||
app_path="dist/AutoLibrary.app"
|
||||
|
||||
if [ ! -d "$app_path" ]; then
|
||||
echo "✗ App bundle not found: $app_path"
|
||||
echo "Files in dist directory:"
|
||||
ls -la dist/
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Creating DMG from: $app_path"
|
||||
xattr -cr "$app_path" 2>/dev/null || true
|
||||
|
||||
tmp_dmg_dir=$(mktemp -d)
|
||||
cp -R "$app_path" "$tmp_dmg_dir/"
|
||||
ln -s /Applications "$tmp_dmg_dir/Applications"
|
||||
|
||||
hdiutil create \
|
||||
-volname "AutoLibrary $tag_name" \
|
||||
-srcfolder "$tmp_dmg_dir" \
|
||||
-ov \
|
||||
-format UDZO \
|
||||
"$dmg_name"
|
||||
|
||||
rm -rf "$tmp_dmg_dir"
|
||||
|
||||
if [ -f "$dmg_name" ]; then
|
||||
dmg_size=$(du -h "$dmg_name" | cut -f1)
|
||||
echo "✓ DMG created: $dmg_name (Size: $dmg_size)"
|
||||
else
|
||||
echo "✗ Failed to create DMG"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✓ Artifacts ready:"
|
||||
echo " - $dmg_name"
|
||||
echo " - $app_path"
|
||||
|
||||
- name: Archive artifacts
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: AutoLibrary.${{ steps.get_version.outputs.TAG_NAME }}-macos-arm64
|
||||
path: |
|
||||
AutoLibrary.${{ steps.get_version.outputs.TAG_NAME }}-macos-arm64.dmg
|
||||
dist/AutoLibrary.app/**
|
||||
retention-days: 7
|
||||
|
||||
- name: Upload build summary
|
||||
run: |
|
||||
tag_name="${{ steps.get_version.outputs.TAG_NAME }}"
|
||||
version="${{ steps.get_version.outputs.VERSION }}"
|
||||
dmg_name="AutoLibrary.$tag_name-macos-arm64.dmg"
|
||||
|
||||
{
|
||||
echo "## Build Test Summary (macOS)"
|
||||
echo ""
|
||||
echo "========================================"
|
||||
echo "✓ Pull request build test completed successfully!"
|
||||
echo "- Version: $version"
|
||||
echo "- Tag: $tag_name"
|
||||
echo "- Pull Request #${{ github.event.pull_request.number || 'N/A' }}"
|
||||
echo "- Branch: ${{ github.event.pull_request.head.ref || github.ref }}"
|
||||
echo "- Event: ${{ github.event_name }}"
|
||||
echo "- DMG: $dmg_name"
|
||||
echo ""
|
||||
echo "### How to test on macOS"
|
||||
echo '1. Download the DMG artifact'
|
||||
echo '2. Open the DMG and drag AutoLibrary.app to /Applications'
|
||||
echo '3. Right-click → Open the app (to bypass Gatekeeper)'
|
||||
} >> $GITHUB_STEP_SUMMARY
|
||||
@@ -1,28 +1,29 @@
|
||||
name: Build
|
||||
|
||||
# This workflow compiles the application for Windows platform using PyInstaller, and
|
||||
# archives the built artifacts as 'AutoLibrary.<tag_name>-windows-x86_64.zip'.
|
||||
# This workflow compiles the application for Windows and macOS platforms using
|
||||
# PyInstaller, and archives the built artifacts.
|
||||
#
|
||||
# - Windows: AutoLibrary.<tag_name>-windows-x86_64.zip
|
||||
# - macOS: AutoLibrary.<tag_name>-macos-arm64.dmg
|
||||
#
|
||||
# It is triggered when called by the release workflow.
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
version:
|
||||
description: 'Version number'
|
||||
required: true
|
||||
type: string
|
||||
tag_name:
|
||||
description: 'Tag name'
|
||||
required: true
|
||||
required: false
|
||||
type: string
|
||||
outputs:
|
||||
version:
|
||||
description: 'The version number'
|
||||
value: ${{ jobs.build-windows.outputs.version }}
|
||||
tag_name:
|
||||
description: 'The tag name'
|
||||
value: ${{ jobs.build-windows.outputs.tag_name }}
|
||||
description: 'Version number'
|
||||
required: false
|
||||
type: string
|
||||
is_test:
|
||||
description: 'Whether this is a test build (not a release)'
|
||||
required: false
|
||||
type: string
|
||||
default: 'true'
|
||||
|
||||
#
|
||||
# Build Windows
|
||||
@@ -32,18 +33,18 @@ jobs:
|
||||
build-windows:
|
||||
runs-on: windows-latest
|
||||
outputs:
|
||||
version: ${{ steps.get_version.outputs.VERSION }}
|
||||
tag_name: ${{ steps.get_version.outputs.TAG_NAME }}
|
||||
version: ${{ steps.get_version.outputs.VERSION }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: main
|
||||
ref: ${{ github.ref }}
|
||||
|
||||
# here we download the build version of ALVersionInfo.py from artifacts
|
||||
# and replace the committed version
|
||||
- name: Download build version of ALVersionInfo.py
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v6
|
||||
with:
|
||||
name: updated-version-info-for-build
|
||||
path: src/gui/
|
||||
@@ -51,33 +52,49 @@ jobs:
|
||||
- name: Get version info
|
||||
id: get_version
|
||||
run: |
|
||||
$version = "${{ inputs.version }}"
|
||||
$tagName = "${{ inputs.tag_name }}"
|
||||
$isTest = "${{ inputs.is_test }}"
|
||||
if ($isTest -eq "true") {
|
||||
$version = "test"
|
||||
$tagName = "test"
|
||||
Write-Host "✓ Mode: Test Build"
|
||||
} else {
|
||||
$version = "${{ inputs.version }}"
|
||||
$tagName = "${{ inputs.tag_name }}"
|
||||
|
||||
if ([string]::IsNullOrEmpty($version)) {
|
||||
$version = "test"
|
||||
$tagName = "test"
|
||||
Write-Host "✓ Mode: Independent Build (No inputs provided)"
|
||||
}
|
||||
}
|
||||
|
||||
echo "TAG_NAME=$tagName" >> $env:GITHUB_OUTPUT
|
||||
echo "VERSION=$version" >> $env:GITHUB_OUTPUT
|
||||
Write-Host "✓ Tag: $tagName"
|
||||
Write-Host "✓ Version: $version"
|
||||
|
||||
"VERSION=$version" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append
|
||||
"TAG_NAME=$tagName" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append
|
||||
shell: pwsh
|
||||
|
||||
- name: Verify 'ALVersionInfo.py' was updated
|
||||
run: |
|
||||
$versionInfoFile = "src/gui/ALVersionInfo.py"
|
||||
Write-Host "Verifying $versionInfoFile content:"
|
||||
Write-Host "=================================="
|
||||
Write-Host "========================================"
|
||||
Get-Content $versionInfoFile | Write-Host
|
||||
Write-Host "=================================="
|
||||
Write-Host "========================================"
|
||||
shell: pwsh
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v4
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.13'
|
||||
cache: 'pip'
|
||||
cache-dependency-path: requirements.txt
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirement.txt
|
||||
pip install -r requirements.txt
|
||||
|
||||
- name: Solve ddddocr compatibility and copy model files
|
||||
run: |
|
||||
@@ -149,7 +166,7 @@ jobs:
|
||||
" binaries=[],"
|
||||
" datas=["
|
||||
" ('models\\common.onnx', 'ddddocr'),"
|
||||
" ('src\\gui\\resources\\icons\\AutoLibrary_32x32.ico', 'gui\\resources\\icons'),"
|
||||
" ('src\\gui\\resources\\icons\\AutoLibrary_Logo_64.ico', 'gui\\resources\\icons'),"
|
||||
" ],"
|
||||
" hiddenimports=[],"
|
||||
" hookspath=[],"
|
||||
@@ -164,10 +181,7 @@ jobs:
|
||||
"exe = EXE("
|
||||
" pyz,"
|
||||
" a.scripts,"
|
||||
" a.binaries,"
|
||||
" a.datas,"
|
||||
" [],"
|
||||
" name='$exeName',"
|
||||
" name='AutoLibrary',"
|
||||
" debug=False,"
|
||||
" bootloader_ignore_signals=False,"
|
||||
" strip=False,"
|
||||
@@ -180,15 +194,27 @@ jobs:
|
||||
" target_arch=None,"
|
||||
" codesign_identity=None,"
|
||||
" entitlements_file=None,"
|
||||
" icon=['src\\gui\\resources\\icons\\AutoLibrary_32x32.ico'],"
|
||||
" icon=['src\\gui\\resources\\icons\\AutoLibrary_Logo_64.ico'],"
|
||||
")"
|
||||
""
|
||||
"coll = COLLECT("
|
||||
" exe,"
|
||||
" a.binaries,"
|
||||
" a.datas,"
|
||||
" strip=False,"
|
||||
" upx=True,"
|
||||
" upx_exclude=[],"
|
||||
" name='$exeName'"
|
||||
")"
|
||||
)
|
||||
$specLines | Out-File -FilePath "Main.spec" -Encoding UTF8
|
||||
|
||||
Write-Host "✓ Main.spec generated successfully"
|
||||
Write-Host "`nGenerated Main.spec ============"
|
||||
Write-Host "✓ Main.spec (non-single file) generated successfully"
|
||||
Write-Host "`n========================================"
|
||||
Write-Host "Generated Main.spec"
|
||||
Write-Host "========================================"
|
||||
Get-Content "Main.spec" | Write-Host
|
||||
Write-Host "==================================`n"
|
||||
Write-Host "========================================`n"
|
||||
shell: pwsh
|
||||
|
||||
- name: Build with PyInstaller
|
||||
@@ -200,17 +226,17 @@ jobs:
|
||||
run: |
|
||||
$tagName = "${{ steps.get_version.outputs.TAG_NAME }}"
|
||||
$version = "${{ steps.get_version.outputs.VERSION }}"
|
||||
$exeName = "AutoLibrary-$version.exe"
|
||||
$distDir = "dist/AutoLibrary-$version"
|
||||
$zipName = "AutoLibrary.$tagName-windows-x86_64.zip"
|
||||
|
||||
echo "ZIP_PATH=$zipName" >> $env:GITHUB_OUTPUT
|
||||
"ZIP_NAME=$zipName" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append
|
||||
|
||||
Write-Host "Looking for executable: dist/$exeName"
|
||||
if (Test-Path "dist/$exeName") {
|
||||
Compress-Archive -Path "dist/$exeName" -DestinationPath $zipName
|
||||
Write-Host "✓ Created release archive: $zipName"
|
||||
Write-Host "Looking for distribution directory: $distDir"
|
||||
if (Test-Path $distDir) {
|
||||
Compress-Archive -Path "$distDir/*" -DestinationPath $zipName
|
||||
Write-Host "✓ Created release archive (directory mode): $zipName"
|
||||
} else {
|
||||
Write-Error "✗ Executable not found: dist/$exeName"
|
||||
Write-Error "✗ Distribution directory not found: $distDir"
|
||||
Write-Host "Files in dist directory:"
|
||||
Get-ChildItem "dist" | ForEach-Object { Write-Host " - $($_.Name)" }
|
||||
exit 1
|
||||
@@ -218,8 +244,320 @@ jobs:
|
||||
shell: pwsh
|
||||
|
||||
- name: Archive artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: AutoLibrary.${{ steps.get_version.outputs.TAG_NAME }}-windows-x86_64
|
||||
path: |
|
||||
${{ steps.zip_release.outputs.ZIP_PATH }}
|
||||
${{ steps.zip_release.outputs.ZIP_NAME }}
|
||||
retention-days: ${{ inputs.is_test == 'true' && 7 || 90 }}
|
||||
|
||||
- name: Upload build summary
|
||||
if: ${{ inputs.is_test == 'true' }}
|
||||
run: |
|
||||
"## Build Summary" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8
|
||||
"" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
|
||||
"========================================" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
|
||||
"✓ Build test completed successfully!" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
|
||||
"- Version: ${{ steps.get_version.outputs.VERSION }}" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
|
||||
"- Tag: ${{ steps.get_version.outputs.TAG_NAME }}" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
|
||||
"- Event: ${{ github.event_name }}" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
|
||||
"- Ref: ${{ github.ref }}" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
|
||||
shell: pwsh
|
||||
|
||||
#
|
||||
# Build macOS
|
||||
#
|
||||
|
||||
build-macos:
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ github.ref }}
|
||||
|
||||
- name: Download build version of ALVersionInfo.py
|
||||
uses: actions/download-artifact@v6
|
||||
with:
|
||||
name: updated-version-info-for-build
|
||||
path: src/gui/
|
||||
|
||||
- name: Get version info
|
||||
id: get_version
|
||||
run: |
|
||||
is_test="${{ inputs.is_test }}"
|
||||
if [ "$is_test" = "true" ]; then
|
||||
version="test"
|
||||
tag_name="test"
|
||||
echo "✓ Mode: Test Build"
|
||||
else
|
||||
version="${{ inputs.version }}"
|
||||
tag_name="${{ inputs.tag_name }}"
|
||||
|
||||
if [ -z "$version" ]; then
|
||||
version="test"
|
||||
tag_name="test"
|
||||
echo "✓ Mode: Independent Build (No inputs provided)"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "✓ Tag: $tag_name"
|
||||
echo "✓ Version: $version"
|
||||
|
||||
echo "VERSION=$version" >> $GITHUB_OUTPUT
|
||||
echo "TAG_NAME=$tag_name" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Verify 'ALVersionInfo.py' was updated
|
||||
run: |
|
||||
version_info_file="src/gui/ALVersionInfo.py"
|
||||
echo "Verifying $version_info_file content:"
|
||||
echo "========================================"
|
||||
cat "$version_info_file"
|
||||
echo "========================================"
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.13'
|
||||
cache: 'pip'
|
||||
cache-dependency-path: requirements.txt
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirements.txt
|
||||
|
||||
- name: Solve ddddocr compatibility and copy model files
|
||||
run: |
|
||||
ddddocr_path=$(python -c "import ddddocr, os; print(os.path.dirname(ddddocr.__file__))")
|
||||
echo "ddddocr package location: $ddddocr_path"
|
||||
|
||||
init_file="$ddddocr_path/__init__.py"
|
||||
if [ -f "$init_file" ]; then
|
||||
echo "Fixing ddddocr compatibility in: $init_file"
|
||||
# macOS 使用 BSD sed,要求 -i 后跟备份后缀名(空字符串 = 不备份)
|
||||
sed -i '' 's/Image\.ANTIALIAS/Image.Resampling.LANCZOS/g' "$init_file"
|
||||
echo "✓ Fixed: Image.ANTIALIAS -> Image.Resampling.LANCZOS"
|
||||
else
|
||||
echo "✗ ddddocr __init__.py not found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p models
|
||||
echo "✓ Created models directory"
|
||||
|
||||
# 多路径 fallback 搜索 ONNX 模型,提升 ddddocr 版本兼容性
|
||||
onnx_source=""
|
||||
onnx_dest="models/common.onnx"
|
||||
candidate_paths=(
|
||||
"$ddddocr_path/common.onnx"
|
||||
"$ddddocr_path/models/common.onnx"
|
||||
"$ddddocr_path/ddddocr/common.onnx"
|
||||
)
|
||||
for candidate in "${candidate_paths[@]}"; do
|
||||
if [ -f "$candidate" ]; then
|
||||
onnx_source="$candidate"
|
||||
break
|
||||
fi
|
||||
done
|
||||
# 若以上候选均未命中,回退到全包搜索
|
||||
if [ -z "$onnx_source" ]; then
|
||||
echo "⚠ 未在已知路径找到 ONNX 模型,回退到全包搜索..."
|
||||
onnx_source=$(find "$ddddocr_path" -name "*.onnx" -type f 2>/dev/null | head -1)
|
||||
fi
|
||||
|
||||
if [ -n "$onnx_source" ] && [ -f "$onnx_source" ]; then
|
||||
cp "$onnx_source" "$onnx_dest"
|
||||
echo "✓ ONNX model copied: $onnx_source -> $onnx_dest"
|
||||
else
|
||||
echo "✗ ONNX model not found in ddddocr package"
|
||||
echo " Searched directory: $ddddocr_path"
|
||||
ls -la "$ddddocr_path/" || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -f "$onnx_dest" ]; then
|
||||
file_size=$(du -h "$onnx_dest" | cut -f1)
|
||||
echo "✓ Model file verified: $onnx_dest (Size: $file_size)"
|
||||
else
|
||||
echo "✗ Failed to copy model file"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Compile Qt Resource files
|
||||
run: |
|
||||
cd batchs
|
||||
bash compile_rc.sh
|
||||
|
||||
- name: Compile Qt UI files
|
||||
run: |
|
||||
cd batchs
|
||||
bash compile_ui.sh
|
||||
|
||||
- name: Generate 'Main.spec'
|
||||
env:
|
||||
APP_VERSION: ${{ steps.get_version.outputs.VERSION }}
|
||||
run: |
|
||||
version="$APP_VERSION"
|
||||
exe_name="AutoLibrary-$version"
|
||||
echo "Generating Main.spec for version: $version"
|
||||
echo "App name: $exe_name"
|
||||
|
||||
printf '%s\n' \
|
||||
'# -*- mode: python ; coding: utf-8 -*-' \
|
||||
'' \
|
||||
'a = Analysis(' \
|
||||
" ['src/Main.py']," \
|
||||
' pathex=[],' \
|
||||
' binaries=[],' \
|
||||
' datas=[' \
|
||||
" ('models/common.onnx', 'ddddocr')," \
|
||||
" ('src/gui/resources/icons/AutoLibrary_Logo_64.ico', 'gui/resources/icons')," \
|
||||
' ],' \
|
||||
' hiddenimports=[],' \
|
||||
' hookspath=[],' \
|
||||
' hooksconfig={},' \
|
||||
' runtime_hooks=[],' \
|
||||
' excludes=[],' \
|
||||
' noarchive=False,' \
|
||||
' optimize=0,' \
|
||||
')' \
|
||||
'pyz = PYZ(a.pure)' \
|
||||
'' \
|
||||
'exe = EXE(' \
|
||||
' pyz,' \
|
||||
' a.scripts,' \
|
||||
' [],' \
|
||||
' exclude_binaries=True,' \
|
||||
" name='AutoLibrary'," \
|
||||
' debug=False,' \
|
||||
' bootloader_ignore_signals=False,' \
|
||||
' strip=False,' \
|
||||
' upx=True,' \
|
||||
' upx_exclude=[],' \
|
||||
' runtime_tmpdir=None,' \
|
||||
' console=False,' \
|
||||
' disable_windowed_traceback=False,' \
|
||||
' argv_emulation=False,' \
|
||||
' target_arch=None,' \
|
||||
' codesign_identity=None,' \
|
||||
' entitlements_file=None,' \
|
||||
" icon=['src/gui/resources/icons/AutoLibrary_Logo_64.ico']," \
|
||||
')' \
|
||||
'' \
|
||||
'coll = COLLECT(' \
|
||||
' exe,' \
|
||||
' a.binaries,' \
|
||||
' a.zipfiles,' \
|
||||
' a.datas,' \
|
||||
' strip=False,' \
|
||||
' upx=True,' \
|
||||
' upx_exclude=[],' \
|
||||
" name='${exe_name}'," \
|
||||
')' \
|
||||
'' \
|
||||
'app = BUNDLE(' \
|
||||
' coll,' \
|
||||
" name='AutoLibrary.app'," \
|
||||
" icon='src/gui/resources/icons/AutoLibrary_Logo.icns'," \
|
||||
" bundle_identifier='com.kenanzhu.autolibrary'," \
|
||||
' info_plist={' \
|
||||
" 'NSHighResolutionCapable': 'True'," \
|
||||
" 'CFBundleName': 'AutoLibrary'," \
|
||||
" 'CFBundleDisplayName': 'AutoLibrary'," \
|
||||
" 'CFBundleShortVersionString': '${version}'," \
|
||||
" 'CFBundleVersion': '${version}'," \
|
||||
" 'CFBundleExecutable': 'AutoLibrary'," \
|
||||
" 'NSPrincipalClass': 'NSApplication'," \
|
||||
" 'LSMinimumSystemVersion': '11.0'," \
|
||||
" 'NSRequiresAquaSystemAppearance': 'False'," \
|
||||
" 'NSHumanReadableCopyright': 'Copyright 2025 - 2026 KenanZhu. All rights reserved.'," \
|
||||
' },' \
|
||||
')' \
|
||||
> Main.spec
|
||||
|
||||
echo "✓ Main.spec generated successfully"
|
||||
echo ""
|
||||
echo "========================================"
|
||||
echo "Generated Main.spec"
|
||||
echo "========================================"
|
||||
cat Main.spec
|
||||
echo "========================================"
|
||||
|
||||
- name: Build with PyInstaller
|
||||
run: |
|
||||
python -m PyInstaller Main.spec
|
||||
|
||||
- name: Create DMG
|
||||
run: |
|
||||
tag_name="${{ steps.get_version.outputs.TAG_NAME }}"
|
||||
dmg_name="AutoLibrary.$tag_name-macos-arm64.dmg"
|
||||
app_path="dist/AutoLibrary.app"
|
||||
|
||||
if [ ! -d "$app_path" ]; then
|
||||
echo "✗ App bundle not found: $app_path"
|
||||
echo "Files in dist directory:"
|
||||
ls -la dist/
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Creating DMG from: $app_path"
|
||||
xattr -cr "$app_path" 2>/dev/null || true
|
||||
|
||||
tmp_dmg_dir=$(mktemp -d)
|
||||
cp -R "$app_path" "$tmp_dmg_dir/"
|
||||
ln -s /Applications "$tmp_dmg_dir/Applications"
|
||||
|
||||
hdiutil create \
|
||||
-volname "AutoLibrary $tag_name" \
|
||||
-srcfolder "$tmp_dmg_dir" \
|
||||
-ov \
|
||||
-format UDZO \
|
||||
"$dmg_name"
|
||||
|
||||
rm -rf "$tmp_dmg_dir"
|
||||
|
||||
if [ -f "$dmg_name" ]; then
|
||||
dmg_size=$(du -h "$dmg_name" | cut -f1)
|
||||
echo "✓ DMG created: $dmg_name (Size: $dmg_size)"
|
||||
else
|
||||
echo "✗ Failed to create DMG"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✓ Artifacts ready:"
|
||||
echo " - $dmg_name"
|
||||
echo " - $app_path"
|
||||
|
||||
- name: Archive artifacts
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: AutoLibrary.${{ steps.get_version.outputs.TAG_NAME }}-macos-arm64
|
||||
path: |
|
||||
AutoLibrary.${{ steps.get_version.outputs.TAG_NAME }}-macos-arm64.dmg
|
||||
dist/AutoLibrary.app/**
|
||||
retention-days: ${{ inputs.is_test == 'true' && 7 || 90 }}
|
||||
|
||||
- name: Upload build summary
|
||||
if: ${{ inputs.is_test == 'true' }}
|
||||
run: |
|
||||
tag_name="${{ steps.get_version.outputs.TAG_NAME }}"
|
||||
version="${{ steps.get_version.outputs.VERSION }}"
|
||||
dmg_name="AutoLibrary.$tag_name-macos-arm64.dmg"
|
||||
|
||||
{
|
||||
echo "## Build Summary (macOS)"
|
||||
echo ""
|
||||
echo "========================================"
|
||||
echo "✓ Build test completed successfully!"
|
||||
echo "- Version: $version"
|
||||
echo "- Tag: $tag_name"
|
||||
echo "- Event: ${{ github.event_name }}"
|
||||
echo "- Ref: ${{ github.ref }}"
|
||||
echo "- DMG: $dmg_name"
|
||||
echo ""
|
||||
echo "### How to test on macOS"
|
||||
echo '1. Download the DMG artifact'
|
||||
echo '2. Open the DMG and drag AutoLibrary.app to /Applications'
|
||||
echo '3. Right-click → Open the app (to bypass Gatekeeper)'
|
||||
} >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
name: Commit Release
|
||||
|
||||
# This workflow commits version changes in 'ALVersionInfo.py' (get from artifacts) and
|
||||
# moves the release tag to this new release commit.
|
||||
# creates/moves the release tag to this new release commit.
|
||||
#
|
||||
# It is triggered when called by the release workflow.
|
||||
|
||||
@@ -9,7 +9,7 @@ on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
tag_name:
|
||||
description: 'Tag name to move (e.g., v1.0.0)'
|
||||
description: 'Tag name to create/move (e.g., v1.0.0 or v1.0.0-rc1)'
|
||||
required: true
|
||||
type: string
|
||||
version:
|
||||
@@ -20,10 +20,33 @@ on:
|
||||
description: 'File path to commit'
|
||||
required: true
|
||||
type: string
|
||||
create_tag:
|
||||
description: 'Whether to create new tag (true) or move existing tag (false)'
|
||||
required: false
|
||||
type: string
|
||||
default: 'false'
|
||||
is_rc:
|
||||
description: 'Whether this is a release candidate (pre-release)'
|
||||
required: false
|
||||
type: string
|
||||
default: 'false'
|
||||
ref:
|
||||
description: 'Git ref to checkout (release branch)'
|
||||
required: true
|
||||
type: string
|
||||
outputs:
|
||||
tag_name:
|
||||
description: 'The tag name created/moved'
|
||||
value: ${{ inputs.tag_name }}
|
||||
version:
|
||||
description: 'Version number for commit message'
|
||||
value: ${{ inputs.version }}
|
||||
new_commit_sha:
|
||||
description: 'The new commit SHA after moving the tag'
|
||||
description: 'The new commit SHA after creating/moving the tag'
|
||||
value: ${{ jobs.commit-release.outputs.new_commit_sha }}
|
||||
branch_name:
|
||||
description: 'The branch name where the commit was made'
|
||||
value: ${{ jobs.commit-release.outputs.branch_name }}
|
||||
|
||||
jobs:
|
||||
commit-release:
|
||||
@@ -32,18 +55,19 @@ jobs:
|
||||
contents: write
|
||||
outputs:
|
||||
new_commit_sha: ${{ steps.commit_info.outputs.commit_sha }}
|
||||
branch_name: ${{ steps.push_release.outputs.branch_name }}
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: main
|
||||
ref: ${{ inputs.ref }}
|
||||
fetch-depth: 0
|
||||
|
||||
# here we download the commit version of ALVersionInfo.py from artifacts
|
||||
# and replace the original file with it.
|
||||
- name: Download commit version of ALVersionInfo.py
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v6
|
||||
with:
|
||||
name: updated-version-info-for-commit
|
||||
path: downloaded-file/
|
||||
@@ -59,7 +83,9 @@ jobs:
|
||||
|
||||
echo "✓ File replaced: $FILE_PATH"
|
||||
echo ""
|
||||
echo "Updated file content ==================="
|
||||
echo "========================================"
|
||||
echo "Updated file content"
|
||||
echo "========================================"
|
||||
cat "$FILE_PATH"
|
||||
echo "========================================"
|
||||
|
||||
@@ -81,18 +107,38 @@ jobs:
|
||||
git commit -m "chore(release): v${VERSION} [auto release commit]"
|
||||
echo "✓ Changes committed"
|
||||
|
||||
- name: Push to main branch
|
||||
- name: Push to release branch
|
||||
id: push_release
|
||||
run: |
|
||||
MAIN_BRANCH=$(git remote show origin | grep 'HEAD branch' | cut -d' ' -f5)
|
||||
if [ -z "$MAIN_BRANCH" ]; then
|
||||
MAIN_BRANCH="main"
|
||||
# Get the release branch name from the input ref
|
||||
BRANCH_NAME=$(echo "${{ inputs.ref }}" | sed 's|refs/heads/||')
|
||||
|
||||
if [ -z "$BRANCH_NAME" ]; then
|
||||
echo "✗ Error: Could not determine branch name from ref: ${{ inputs.ref }}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Pushing to branch: ${MAIN_BRANCH}"
|
||||
git push origin HEAD:${MAIN_BRANCH}
|
||||
echo "✓ Changes pushed to ${MAIN_BRANCH}"
|
||||
echo "Pushing to branch: ${BRANCH_NAME}"
|
||||
git push origin HEAD:${BRANCH_NAME}
|
||||
echo "✓ Changes pushed to ${BRANCH_NAME}"
|
||||
|
||||
# Output branch name for downstream jobs
|
||||
echo "branch_name=$BRANCH_NAME" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Create tag for release
|
||||
if: ${{ inputs.create_tag == 'true' }}
|
||||
run: |
|
||||
TAG_NAME="${{ inputs.tag_name }}"
|
||||
IS_RC="${{ inputs.is_rc }}"
|
||||
|
||||
echo "Creating new tag ${TAG_NAME} at this commit..."
|
||||
echo "Release type: $([ "$IS_RC" = "true" ] && echo "Release Candidate (Pre-release)" || echo "Stable Release")"
|
||||
git tag ${TAG_NAME}
|
||||
git push origin ${TAG_NAME}
|
||||
echo "✓ Tag ${TAG_NAME} created at commit $(git rev-parse --short HEAD)"
|
||||
|
||||
- name: Move tag to new release commit
|
||||
if: ${{ inputs.create_tag != 'true' }}
|
||||
run: |
|
||||
TAG_NAME="${{ inputs.tag_name }}"
|
||||
|
||||
@@ -107,3 +153,9 @@ jobs:
|
||||
COMMIT_SHA=$(git rev-parse --short HEAD)
|
||||
echo "commit_sha=$COMMIT_SHA" >> $GITHUB_OUTPUT
|
||||
echo "✓ New commit SHA: $COMMIT_SHA"
|
||||
echo "## Commit Release Summary" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "========================================" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- Version: ${{ inputs.version }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- Tag: ${{ inputs.tag_name }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- Commit SHA: $COMMIT_SHA" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
@@ -1,41 +1,55 @@
|
||||
name: Release
|
||||
|
||||
# This workflow automates the complete release process for AutoLibrary application
|
||||
# It is triggered when a new version tag (vX.Y.Z) is pushed to the repository
|
||||
# It is triggered when a new release branch is created (release/vX.Y.Z or release/vX.Y.Z-rc*)
|
||||
#
|
||||
# Workflow Steps:
|
||||
# START >
|
||||
|
||||
# 1. Update Version:
|
||||
# 1. Extract Version:
|
||||
# Extracts version number from branch name:
|
||||
# - release/v1.0.0 -> v1.0.0 (stable release)
|
||||
# - release/v1.0.0-rc1 -> v1.0.0 (release candidate)
|
||||
|
||||
# 2. Update Version:
|
||||
# Updates version information in 'ALVersionInfo.py' with build metadata and archives
|
||||
# the updated version file as an artifact.
|
||||
#
|
||||
# for more information, please refer to the comment in the workflow 'update-version.yml'
|
||||
|
||||
# 2. Commit Release:
|
||||
# Commits version changes and moves the release tag to this new release commit.
|
||||
# 3. Commit Release:
|
||||
# Commits version changes to release branch and creates the release tag.
|
||||
|
||||
# 3. Build:
|
||||
# Compiles the application for Windows platform using PyInstaller, and
|
||||
# archives the built artifacts as 'AutoLibrary.<tag_name>-windows-x86_64.zip'.
|
||||
# 4. Build:
|
||||
# Compiles the application for Windows and macOS platforms using PyInstaller, and
|
||||
# archives the built artifacts.
|
||||
# - Windows: AutoLibrary.<tag_name>-windows-x86_64.zip
|
||||
# - macOS: AutoLibrary.<tag_name>-macos-arm64.dmg
|
||||
|
||||
# 4. Release:
|
||||
# 5. Release:
|
||||
# Creates GitHub release with generated artifacts and release notes
|
||||
|
||||
# < END
|
||||
#
|
||||
# 6. Merge back:
|
||||
# Merges release branch back to main branch, and clean/delete the release branch
|
||||
#
|
||||
# The workflow ensures version consistency between source code, built artifacts, and GitHub releases
|
||||
# while maintaining proper commit history and tag management.
|
||||
#
|
||||
# IMPORTANT: This workflow only triggers on branch CREATION, not on pushes to release branches.
|
||||
# If you need to fix issues on a release branch, delete the tag, merge fixes to main,
|
||||
# and create a new release branch.
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v[0-9]+.[0-9]+.[0-9]+'
|
||||
branches:
|
||||
- 'release/v*'
|
||||
|
||||
jobs:
|
||||
#
|
||||
# Start :
|
||||
# virtual job that indacates the start of the release process
|
||||
# virtual job that indicates the start of the release process
|
||||
#
|
||||
|
||||
start:
|
||||
@@ -44,6 +58,62 @@ jobs:
|
||||
- name: Start release
|
||||
run: |
|
||||
echo "✓ Starting release"
|
||||
echo "Branch: ${{ github.ref_name }}"
|
||||
echo "Ref: ${{ github.ref }}"
|
||||
|
||||
#
|
||||
# Extract version :
|
||||
# this job extracts version from branch name
|
||||
#
|
||||
|
||||
extract-version:
|
||||
needs:
|
||||
- start
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
tag_name: ${{ steps.extract.outputs.tag_name }}
|
||||
version: ${{ steps.extract.outputs.version }}
|
||||
is_rc: ${{ steps.extract.outputs.is_rc }}
|
||||
steps:
|
||||
- name: Extract version from branch name
|
||||
id: extract
|
||||
run: |
|
||||
BRANCH_NAME="${{ github.ref_name }}"
|
||||
|
||||
# Validate branch name starts with 'release/v'
|
||||
if ! echo "$BRANCH_NAME" | grep -qE '^release/v'; then
|
||||
echo "✗ Error: Branch '$BRANCH_NAME' does not start with 'release/v'"
|
||||
echo "✗ This workflow should only be triggered by release branches"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Extract version from branch name:
|
||||
# - release/v1.0.0 -> v1.0.0 (stable)
|
||||
# - release/v1.0.0-rc1 -> v1.0.0 (release candidate)
|
||||
# - release/v1.0.0-alpha.1 -> v1.0.0-alpha.1 (pre-release)
|
||||
if echo "$BRANCH_NAME" | grep -qE '^release/v[0-9]+\.[0-9]+\.[0-9]+$'; then
|
||||
# Stable release: release/v1.0.0 -> v1.0.0
|
||||
TAG_NAME=$(echo "$BRANCH_NAME" | sed -E 's|^release/(v[0-9]+\.[0-9]+\.[0-9]+)$|\1|')
|
||||
IS_RC=false
|
||||
elif echo "$BRANCH_NAME" | grep -qE '^release/v[0-9]+\.[0-9]+\.[0-9]+-'; then
|
||||
# Pre-release: release/v1.0.0-rc1 -> v1.0.0-rc1
|
||||
TAG_NAME=$(echo "$BRANCH_NAME" | sed -E 's|^release/(v[0-9]+\.[0-9]+\.[0-9]+-.*)$|\1|')
|
||||
IS_RC=true
|
||||
else
|
||||
echo "✗ Error: Branch '$BRANCH_NAME' does not match expected format"
|
||||
echo "✗ Expected format: release/vX.Y.Z or release/vX.Y.Z-rcX"
|
||||
exit 1
|
||||
fi
|
||||
VERSION="${TAG_NAME#v}"
|
||||
|
||||
echo "TAG_NAME=$TAG_NAME" >> $GITHUB_OUTPUT
|
||||
echo "VERSION=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "IS_RC=$IS_RC" >> $GITHUB_OUTPUT
|
||||
|
||||
echo "✓ Branch: $BRANCH_NAME"
|
||||
echo "✓ Tag: $TAG_NAME"
|
||||
echo "✓ Version: $VERSION"
|
||||
echo "✓ Is RC: $IS_RC"
|
||||
|
||||
#
|
||||
# Update version :
|
||||
@@ -52,31 +122,35 @@ jobs:
|
||||
|
||||
update-version:
|
||||
needs:
|
||||
- start
|
||||
- extract-version
|
||||
uses: ./.github/workflows/update-version.yml
|
||||
permissions:
|
||||
contents: write
|
||||
with:
|
||||
tag_name: ${{ github.ref_name }}
|
||||
tag_name: ${{ needs.extract-version.outputs.tag_name }}
|
||||
ref: ${{ github.ref }}
|
||||
|
||||
#
|
||||
# Commit release :
|
||||
# this job commits the updated version file and move the release
|
||||
# tag to this new commit
|
||||
# this job commits the updated version file to main and creates
|
||||
# the release tag (not moving an existing tag)
|
||||
#
|
||||
|
||||
commit-release:
|
||||
needs:
|
||||
- extract-version
|
||||
- update-version
|
||||
if: ${{ needs.update-version.outputs.has_changes == 'true' }}
|
||||
uses: ./.github/workflows/commit-release.yml
|
||||
permissions:
|
||||
contents: write
|
||||
with:
|
||||
tag_name: ${{ needs.update-version.outputs.tag_name }}
|
||||
version: ${{ needs.update-version.outputs.version }}
|
||||
tag_name: ${{ needs.extract-version.outputs.tag_name }}
|
||||
version: ${{ needs.extract-version.outputs.version }}
|
||||
file_path: src/gui/ALVersionInfo.py
|
||||
create_tag: 'true'
|
||||
is_rc: ${{ needs.extract-version.outputs.is_rc }}
|
||||
ref: ${{ github.ref }}
|
||||
|
||||
#
|
||||
# Build :
|
||||
@@ -86,13 +160,14 @@ jobs:
|
||||
needs:
|
||||
- update-version
|
||||
- commit-release
|
||||
if: always() && needs.update-version.result == 'success' && needs.commit-release.result == 'success'
|
||||
if: always() && needs.update-version.result == 'success' && (needs.commit-release.result == 'success' || needs.commit-release.result == 'skipped')
|
||||
uses: ./.github/workflows/build.yml
|
||||
permissions:
|
||||
contents: write
|
||||
with:
|
||||
version: ${{ needs.update-version.outputs.version }}
|
||||
tag_name: ${{ needs.update-version.outputs.tag_name }}
|
||||
version: ${{ needs.update-version.outputs.version }}
|
||||
is_test: 'false'
|
||||
|
||||
#
|
||||
# Release :
|
||||
@@ -102,48 +177,44 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- build
|
||||
- extract-version
|
||||
if: always() && needs.build.result == 'success'
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- name: Download artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
- name: Download Windows artifacts
|
||||
uses: actions/download-artifact@v6
|
||||
with:
|
||||
name: AutoLibrary.${{ needs.build.outputs.tag_name }}-windows-x86_64
|
||||
name: AutoLibrary.${{ needs.extract-version.outputs.tag_name }}-windows-x86_64
|
||||
path: artifacts/
|
||||
|
||||
- name: Download macOS artifacts
|
||||
uses: actions/download-artifact@v6
|
||||
with:
|
||||
name: AutoLibrary.${{ needs.extract-version.outputs.tag_name }}-macos-arm64
|
||||
path: artifacts/
|
||||
|
||||
- name: Create release
|
||||
id: create_release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: ${{ needs.build.outputs.tag_name }}
|
||||
name: AutoLibrary ${{ needs.build.outputs.tag_name }}
|
||||
tag_name: ${{ needs.extract-version.outputs.tag_name }}
|
||||
name: AutoLibrary ${{ needs.extract-version.outputs.tag_name }}
|
||||
files: |
|
||||
artifacts/AutoLibrary.${{ needs.build.outputs.tag_name }}-windows-x86_64.zip
|
||||
artifacts/AutoLibrary.${{ needs.extract-version.outputs.tag_name }}-windows-x86_64.zip
|
||||
artifacts/AutoLibrary.${{ needs.extract-version.outputs.tag_name }}-macos-arm64.dmg
|
||||
draft: false
|
||||
prerelease: false
|
||||
prerelease: ${{ needs.extract-version.outputs.is_rc == 'true' }}
|
||||
generate_release_notes: true
|
||||
body: |
|
||||
### 下载获取
|
||||
- **Windows x86_64**: `AutoLibrary.${{ needs.build.outputs.tag_name }}-windows-x86_64.zip`
|
||||
|
||||
### 如何使用
|
||||
1. 下载 `AutoLibrary.${{ needs.build.outputs.tag_name }}-windows-x86_64.zip` 文件
|
||||
2. 解压到任意目录
|
||||
3. 下载对应浏览器的驱动文件
|
||||
4. 运行 `AutoLibrary-${{ needs.build.outputs.version }}.exe` (首次运行会初始化配置文件)
|
||||
5. 按照提示操作即可
|
||||
|
||||
更多详情请访问 [AutoLibrary 网站](http://www.autolibrary.top) 和查看 [帮助手册](https://www.autolibrary.top/manuals)
|
||||
|
||||
---
|
||||
**完整更新日志见下方自动生成的 Release Notes**
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# End :
|
||||
# virtual job that indacates the end of the release process
|
||||
# virtual job that indicates the end of the release process
|
||||
#
|
||||
|
||||
end:
|
||||
@@ -154,3 +225,114 @@ jobs:
|
||||
- name: End release
|
||||
run: |
|
||||
echo "✓ Ending release"
|
||||
|
||||
#
|
||||
# Merge Back :
|
||||
# this job merges the release branch to main after successful release
|
||||
#
|
||||
|
||||
merge-back:
|
||||
needs:
|
||||
- release
|
||||
- extract-version
|
||||
- commit-release
|
||||
if: ${{ needs.release.result == 'success' && (needs.commit-release.result == 'success' || needs.commit-release.result == 'skipped') }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Merge release branch to main
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
# Use the release branch name from the original trigger
|
||||
BRANCH_NAME="${{ needs.extract-version.outputs.tag_name }}"
|
||||
# Extract branch name: v1.0.0 -> release/v1.0.0
|
||||
if [[ ! "$BRANCH_NAME" =~ ^release/ ]]; then
|
||||
BRANCH_NAME="release/${BRANCH_NAME}"
|
||||
fi
|
||||
|
||||
MAIN_BRANCH=$(git remote show origin | grep 'HEAD branch' | cut -d' ' -f5)
|
||||
|
||||
if [ -z "$MAIN_BRANCH" ]; then
|
||||
MAIN_BRANCH="main"
|
||||
fi
|
||||
|
||||
echo "Merging ${BRANCH_NAME} to ${MAIN_BRANCH}..."
|
||||
echo "Current commit info:"
|
||||
git log --oneline -3
|
||||
|
||||
# Fetch all branches including the release branch
|
||||
git fetch origin ${BRANCH_NAME}
|
||||
git fetch origin ${MAIN_BRANCH}
|
||||
|
||||
# Checkout main branch
|
||||
git checkout ${MAIN_BRANCH}
|
||||
|
||||
# Show branch status before merge
|
||||
echo "========================================"
|
||||
echo "Branch status before merge"
|
||||
echo "========================================"
|
||||
git log --oneline --graph --all -5
|
||||
echo "========================================"
|
||||
echo "Diff: ${MAIN_BRANCH} vs origin/${BRANCH_NAME}"
|
||||
echo "========================================"
|
||||
git diff ${MAIN_BRANCH} origin/${BRANCH_NAME} --stat || echo "No differences found"
|
||||
|
||||
# Force create a merge commit even if there are no changes
|
||||
# This ensures the release history is properly recorded
|
||||
git merge origin/${BRANCH_NAME} \
|
||||
--no-ff \
|
||||
-m "chore(release): merge ${BRANCH_NAME} to ${MAIN_BRANCH} [auto release commit]"
|
||||
|
||||
# Show merge result
|
||||
echo "========================================"
|
||||
echo "Merge result"
|
||||
echo "========================================"
|
||||
git log --oneline --graph -3
|
||||
|
||||
# Push to main
|
||||
git push origin ${MAIN_BRANCH}
|
||||
|
||||
echo "✓ Successfully merged ${BRANCH_NAME} to ${MAIN_BRANCH}"
|
||||
|
||||
- name: Delete release branch
|
||||
run: |
|
||||
BRANCH_NAME="${{ needs.extract-version.outputs.tag_name }}"
|
||||
# Extract branch name: v1.0.0 -> release/v1.0.0
|
||||
if [[ ! "$BRANCH_NAME" =~ ^release/ ]]; then
|
||||
BRANCH_NAME="release/${BRANCH_NAME}"
|
||||
fi
|
||||
|
||||
echo "Deleting release branch: ${BRANCH_NAME}"
|
||||
git push origin --delete ${BRANCH_NAME}
|
||||
echo "✓ Deleted branch ${BRANCH_NAME}"
|
||||
|
||||
- name: Release cleanup summary
|
||||
run: |
|
||||
BRANCH_NAME="${{ github.ref_name }}"
|
||||
TAG_NAME="${{ needs.extract-version.outputs.tag_name }}"
|
||||
MAIN_BRANCH=$(git remote show origin | grep 'HEAD branch' | cut -d' ' -f5)
|
||||
if [ -z "$MAIN_BRANCH" ]; then
|
||||
MAIN_BRANCH="main"
|
||||
fi
|
||||
|
||||
echo "## Release Cleanup Summary" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "========================================" >> $GITHUB_STEP_SUMMARY
|
||||
echo "✓ Release completed successfully!" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "### Actions Performed:" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- Merged \`${BRANCH_NAME}\` to \`${MAIN_BRANCH}\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- Deleted release branch \`${BRANCH_NAME}\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "### Release Details:" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- Tag: \`${TAG_NAME}\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- Version: \`${{ needs.extract-version.outputs.version }}\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- Release Type: $([ "${{ needs.extract-version.outputs.is_rc }}" = "true" ] && echo "Release Candidate" || echo "Stable Release")" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
@@ -42,7 +42,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ inputs.ref }}
|
||||
fetch-depth: 0
|
||||
@@ -128,10 +128,15 @@ jobs:
|
||||
echo "Build version file location: $VER_INFO_BUILDFILE"
|
||||
echo "Commit version file location: $VER_INFO_COMMITFILE"
|
||||
echo ""
|
||||
echo "Build version ALVersionInfo.py content ="
|
||||
echo "========================================"
|
||||
echo "Build version ALVersionInfo.py"
|
||||
echo "========================================"
|
||||
cat "$VER_INFO_BUILDFILE"
|
||||
echo "========================================"
|
||||
echo ""
|
||||
echo "Commit version ALVersionInfo.py content "
|
||||
echo "========================================"
|
||||
echo "Commit version ALVersionInfo.py"
|
||||
echo "========================================"
|
||||
cat "$VER_INFO_COMMITFILE"
|
||||
echo "========================================"
|
||||
|
||||
@@ -140,15 +145,20 @@ jobs:
|
||||
run: |
|
||||
if git diff --quiet src/gui/ALVersionInfo.py; then
|
||||
echo "has_changes=false" >> $GITHUB_OUTPUT
|
||||
echo "! No changes detected in ALVersionInfo.py"
|
||||
echo "⚠ No changes detected in ALVersionInfo.py"
|
||||
else
|
||||
echo "has_changes=true" >> $GITHUB_OUTPUT
|
||||
echo "✓ ALVersionInfo.py has been modified"
|
||||
fi
|
||||
echo "## Update Version Summary" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "========================================" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- Version: ${{ steps.get_version.outputs.VERSION }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- Tag: ${{ steps.get_version.outputs.TAG_NAME }}" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
- name: Upload modified ALVersionInfo.py ready for build
|
||||
if: steps.check_changes.outputs.has_changes == 'true'
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: updated-version-info-for-build
|
||||
path: src/gui/temp/ALVersionInfo.py
|
||||
@@ -156,7 +166,7 @@ jobs:
|
||||
|
||||
- name: Upload modified ALVersionInfo.py ready for commit
|
||||
if: steps.check_changes.outputs.has_changes == 'true'
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: updated-version-info-for-commit
|
||||
path: src/gui/ALVersionInfo.py
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
[[source]]
|
||||
url = "https://pypi.org/simple"
|
||||
verify_ssl = true
|
||||
name = "pypi"
|
||||
|
||||
[packages]
|
||||
ddddocr = "*"
|
||||
selenium = "*"
|
||||
pyinstaller = "*"
|
||||
pyside6 = "*"
|
||||
|
||||
[dev-packages]
|
||||
|
||||
[requires]
|
||||
python_version = "3.13"
|
||||
@@ -1,630 +0,0 @@
|
||||
{
|
||||
"_meta": {
|
||||
"hash": {
|
||||
"sha256": "26dffc26812d5328611959b95713a7ed65e20c08c60089b54283b0f406dd08e4"
|
||||
},
|
||||
"pipfile-spec": 6,
|
||||
"requires": {
|
||||
"python_version": "3.13"
|
||||
},
|
||||
"sources": [
|
||||
{
|
||||
"name": "pypi",
|
||||
"url": "https://pypi.org/simple",
|
||||
"verify_ssl": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"default": {
|
||||
"altgraph": {
|
||||
"hashes": [
|
||||
"sha256:1b5afbb98f6c4dcadb2e2ae6ab9fa994bbb8c1d75f4fa96d340f9437ae454406",
|
||||
"sha256:642743b4750de17e655e6711601b077bc6598dbfa3ba5fa2b2a35ce12b508dff"
|
||||
],
|
||||
"version": "==0.17.4"
|
||||
},
|
||||
"attrs": {
|
||||
"hashes": [
|
||||
"sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11",
|
||||
"sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373"
|
||||
],
|
||||
"markers": "python_version >= '3.9'",
|
||||
"version": "==25.4.0"
|
||||
},
|
||||
"certifi": {
|
||||
"hashes": [
|
||||
"sha256:0f212c2744a9bb6de0c56639a6f68afe01ecd92d91f14ae897c4fe7bbeeef0de",
|
||||
"sha256:47c09d31ccf2acf0be3f701ea53595ee7e0b8fa08801c6624be771df09ae7b43"
|
||||
],
|
||||
"markers": "python_version >= '3.7'",
|
||||
"version": "==2025.10.5"
|
||||
},
|
||||
"cffi": {
|
||||
"hashes": [
|
||||
"sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb",
|
||||
"sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b",
|
||||
"sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f",
|
||||
"sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9",
|
||||
"sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44",
|
||||
"sha256:0f6084a0ea23d05d20c3edcda20c3d006f9b6f3fefeac38f59262e10cef47ee2",
|
||||
"sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c",
|
||||
"sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75",
|
||||
"sha256:1cd13c99ce269b3ed80b417dcd591415d3372bcac067009b6e0f59c7d4015e65",
|
||||
"sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e",
|
||||
"sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a",
|
||||
"sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e",
|
||||
"sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25",
|
||||
"sha256:2081580ebb843f759b9f617314a24ed5738c51d2aee65d31e02f6f7a2b97707a",
|
||||
"sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe",
|
||||
"sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b",
|
||||
"sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91",
|
||||
"sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592",
|
||||
"sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187",
|
||||
"sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c",
|
||||
"sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1",
|
||||
"sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94",
|
||||
"sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba",
|
||||
"sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb",
|
||||
"sha256:3f4d46d8b35698056ec29bca21546e1551a205058ae1a181d871e278b0b28165",
|
||||
"sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529",
|
||||
"sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca",
|
||||
"sha256:4647afc2f90d1ddd33441e5b0e85b16b12ddec4fca55f0d9671fef036ecca27c",
|
||||
"sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6",
|
||||
"sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c",
|
||||
"sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0",
|
||||
"sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743",
|
||||
"sha256:61d028e90346df14fedc3d1e5441df818d095f3b87d286825dfcbd6459b7ef63",
|
||||
"sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5",
|
||||
"sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5",
|
||||
"sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4",
|
||||
"sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d",
|
||||
"sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b",
|
||||
"sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93",
|
||||
"sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205",
|
||||
"sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27",
|
||||
"sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512",
|
||||
"sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d",
|
||||
"sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c",
|
||||
"sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037",
|
||||
"sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26",
|
||||
"sha256:89472c9762729b5ae1ad974b777416bfda4ac5642423fa93bd57a09204712322",
|
||||
"sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb",
|
||||
"sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c",
|
||||
"sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8",
|
||||
"sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4",
|
||||
"sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414",
|
||||
"sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9",
|
||||
"sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664",
|
||||
"sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9",
|
||||
"sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775",
|
||||
"sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739",
|
||||
"sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc",
|
||||
"sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062",
|
||||
"sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe",
|
||||
"sha256:b882b3df248017dba09d6b16defe9b5c407fe32fc7c65a9c69798e6175601be9",
|
||||
"sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92",
|
||||
"sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5",
|
||||
"sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13",
|
||||
"sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d",
|
||||
"sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26",
|
||||
"sha256:cb527a79772e5ef98fb1d700678fe031e353e765d1ca2d409c92263c6d43e09f",
|
||||
"sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495",
|
||||
"sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b",
|
||||
"sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6",
|
||||
"sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c",
|
||||
"sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef",
|
||||
"sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5",
|
||||
"sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18",
|
||||
"sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad",
|
||||
"sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3",
|
||||
"sha256:de8dad4425a6ca6e4e5e297b27b5c824ecc7581910bf9aee86cb6835e6812aa7",
|
||||
"sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5",
|
||||
"sha256:e6e73b9e02893c764e7e8d5bb5ce277f1a009cd5243f8228f75f842bf937c534",
|
||||
"sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49",
|
||||
"sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2",
|
||||
"sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5",
|
||||
"sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453",
|
||||
"sha256:fe562eb1a64e67dd297ccc4f5addea2501664954f2692b69a76449ec7913ecbf"
|
||||
],
|
||||
"markers": "python_version >= '3.9'",
|
||||
"version": "==2.0.0"
|
||||
},
|
||||
"coloredlogs": {
|
||||
"hashes": [
|
||||
"sha256:612ee75c546f53e92e70049c9dbfcc18c935a2b9a53b66085ce9ef6a6e5c0934",
|
||||
"sha256:7c991aa71a4577af2f82600d8f8f3a89f936baeaf9b50a9c197da014e5bf16b0"
|
||||
],
|
||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
|
||||
"version": "==15.0.1"
|
||||
},
|
||||
"ddddocr": {
|
||||
"hashes": [
|
||||
"sha256:5991594d481d33ba0b136022e910f578d6d5b0ca536b44886591359622ab0c70",
|
||||
"sha256:7c44b58ba7d7566d785c65b8526ec5b78efacd121e993dea4fda5f7966897428"
|
||||
],
|
||||
"index": "pypi",
|
||||
"markers": "python_version >= '3.8'",
|
||||
"version": "==1.0.6"
|
||||
},
|
||||
"flatbuffers": {
|
||||
"hashes": [
|
||||
"sha256:255538574d6cb6d0a79a17ec8bc0d30985913b87513a01cce8bcdb6b4c44d0e2",
|
||||
"sha256:676f9fa62750bb50cf531b42a0a2a118ad8f7f797a511eda12881c016f093b12"
|
||||
],
|
||||
"version": "==25.9.23"
|
||||
},
|
||||
"h11": {
|
||||
"hashes": [
|
||||
"sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1",
|
||||
"sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"
|
||||
],
|
||||
"markers": "python_version >= '3.8'",
|
||||
"version": "==0.16.0"
|
||||
},
|
||||
"humanfriendly": {
|
||||
"hashes": [
|
||||
"sha256:1697e1a8a8f550fd43c2865cd84542fc175a61dcb779b6fee18cf6b6ccba1477",
|
||||
"sha256:6b0b831ce8f15f7300721aa49829fc4e83921a9a301cc7f606be6686a2288ddc"
|
||||
],
|
||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
|
||||
"version": "==10.0"
|
||||
},
|
||||
"idna": {
|
||||
"hashes": [
|
||||
"sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea",
|
||||
"sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902"
|
||||
],
|
||||
"markers": "python_version >= '3.8'",
|
||||
"version": "==3.11"
|
||||
},
|
||||
"mpmath": {
|
||||
"hashes": [
|
||||
"sha256:7a28eb2a9774d00c7bc92411c19a89209d5da7c4c9a9e227be8330a23a25b91f",
|
||||
"sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c"
|
||||
],
|
||||
"version": "==1.3.0"
|
||||
},
|
||||
"numpy": {
|
||||
"hashes": [
|
||||
"sha256:035796aaaddfe2f9664b9a9372f089cfc88bd795a67bd1bfe15e6e770934cf64",
|
||||
"sha256:043885b4f7e6e232d7df4f51ffdef8c36320ee9d5f227b380ea636722c7ed12e",
|
||||
"sha256:04a69abe45b49c5955923cf2c407843d1c85013b424ae8a560bba16c92fe44a0",
|
||||
"sha256:0f2bcc76f1e05e5ab58893407c63d90b2029908fa41f9f1cc51eecce936c3365",
|
||||
"sha256:13b9062e4f5c7ee5c7e5be96f29ba71bc5a37fed3d1d77c37390ae00724d296d",
|
||||
"sha256:15eea9f306b98e0be91eb344a94c0e630689ef302e10c2ce5f7e11905c704f9c",
|
||||
"sha256:15fb27364ed84114438fff8aaf998c9e19adbeba08c0b75409f8c452a8692c52",
|
||||
"sha256:1b219560ae2c1de48ead517d085bc2d05b9433f8e49d0955c82e8cd37bd7bf36",
|
||||
"sha256:22758999b256b595cf0b1d102b133bb61866ba5ceecf15f759623b64c020c9ec",
|
||||
"sha256:2ec646892819370cf3558f518797f16597b4e4669894a2ba712caccc9da53f1f",
|
||||
"sha256:3634093d0b428e6c32c3a69b78e554f0cd20ee420dcad5a9f3b2a63762ce4197",
|
||||
"sha256:36dc13af226aeab72b7abad501d370d606326a0029b9f435eacb3b8c94b8a8b7",
|
||||
"sha256:3da3491cee49cf16157e70f607c03a217ea6647b1cea4819c4f48e53d49139b9",
|
||||
"sha256:40cc556d5abbc54aabe2b1ae287042d7bdb80c08edede19f0c0afb36ae586f37",
|
||||
"sha256:4121c5beb58a7f9e6dfdee612cb24f4df5cd4db6e8261d7f4d7450a997a65d6a",
|
||||
"sha256:4635239814149e06e2cb9db3dd584b2fa64316c96f10656983b8026a82e6e4db",
|
||||
"sha256:4c01835e718bcebe80394fd0ac66c07cbb90147ebbdad3dcecd3f25de2ae7e2c",
|
||||
"sha256:4ee6a571d1e4f0ea6d5f22d6e5fbd6ed1dc2b18542848e1e7301bd190500c9d7",
|
||||
"sha256:56209416e81a7893036eea03abcb91c130643eb14233b2515c90dcac963fe99d",
|
||||
"sha256:5e199c087e2aa71c8f9ce1cb7a8e10677dc12457e7cc1be4798632da37c3e86e",
|
||||
"sha256:62b2198c438058a20b6704351b35a1d7db881812d8512d67a69c9de1f18ca05f",
|
||||
"sha256:64c5825affc76942973a70acf438a8ab618dbd692b84cd5ec40a0a0509edc09a",
|
||||
"sha256:65611ecbb00ac9846efe04db15cbe6186f562f6bb7e5e05f077e53a599225d16",
|
||||
"sha256:6d34ed9db9e6395bb6cd33286035f73a59b058169733a9db9f85e650b88df37e",
|
||||
"sha256:6d9cd732068e8288dbe2717177320723ccec4fb064123f0caf9bbd90ab5be868",
|
||||
"sha256:6e274603039f924c0fe5cb73438fa9246699c78a6df1bd3decef9ae592ae1c05",
|
||||
"sha256:77b84453f3adcb994ddbd0d1c5d11db2d6bda1a2b7fd5ac5bd4649d6f5dc682e",
|
||||
"sha256:7c26b0b2bf58009ed1f38a641f3db4be8d960a417ca96d14e5b06df1506d41ff",
|
||||
"sha256:7fd09cc5d65bda1e79432859c40978010622112e9194e581e3415a3eccc7f43f",
|
||||
"sha256:817e719a868f0dacde4abdfc5c1910b301877970195db9ab6a5e2c4bd5b121f7",
|
||||
"sha256:81b3a59793523e552c4a96109dde028aa4448ae06ccac5a76ff6532a85558a7f",
|
||||
"sha256:81c3e6d8c97295a7360d367f9f8553973651b76907988bb6066376bc2252f24e",
|
||||
"sha256:838f045478638b26c375ee96ea89464d38428c69170360b23a1a50fa4baa3562",
|
||||
"sha256:84f01a4d18b2cc4ade1814a08e5f3c907b079c847051d720fad15ce37aa930b6",
|
||||
"sha256:85597b2d25ddf655495e2363fe044b0ae999b75bc4d630dc0d886484b03a5eb0",
|
||||
"sha256:85d9fb2d8cd998c84d13a79a09cc0c1091648e848e4e6249b0ccd7f6b487fa26",
|
||||
"sha256:85e071da78d92a214212cacea81c6da557cab307f2c34b5f85b628e94803f9c0",
|
||||
"sha256:863e3b5f4d9915aaf1b8ec79ae560ad21f0b8d5e3adc31e73126491bb86dee1d",
|
||||
"sha256:86966db35c4040fdca64f0816a1c1dd8dbd027d90fca5a57e00e1ca4cd41b879",
|
||||
"sha256:8ab1c5f5ee40d6e01cbe96de5863e39b215a4d24e7d007cad56c7184fdf4aeef",
|
||||
"sha256:8b5a9a39c45d852b62693d9b3f3e0fe052541f804296ff401a72a1b60edafb29",
|
||||
"sha256:8dc20bde86802df2ed8397a08d793da0ad7a5fd4ea3ac85d757bf5dd4ad7c252",
|
||||
"sha256:957e92defe6c08211eb77902253b14fe5b480ebc5112bc741fd5e9cd0608f847",
|
||||
"sha256:962064de37b9aef801d33bc579690f8bfe6c5e70e29b61783f60bcba838a14d6",
|
||||
"sha256:985f1e46358f06c2a09921e8921e2c98168ed4ae12ccd6e5e87a4f1857923f32",
|
||||
"sha256:9984bd645a8db6ca15d850ff996856d8762c51a2239225288f08f9050ca240a0",
|
||||
"sha256:9cb177bc55b010b19798dc5497d540dea67fd13a8d9e882b2dae71de0cf09eb3",
|
||||
"sha256:9d729d60f8d53a7361707f4b68a9663c968882dd4f09e0d58c044c8bf5faee7b",
|
||||
"sha256:a13fc473b6db0be619e45f11f9e81260f7302f8d180c49a22b6e6120022596b3",
|
||||
"sha256:a49d797192a8d950ca59ee2d0337a4d804f713bb5c3c50e8db26d49666e351dc",
|
||||
"sha256:a700a4031bc0fd6936e78a752eefb79092cecad2599ea9c8039c548bc097f9bc",
|
||||
"sha256:a7b2f9a18b5ff9824a6af80de4f37f4ec3c2aab05ef08f51c77a093f5b89adda",
|
||||
"sha256:a7d018bfedb375a8d979ac758b120ba846a7fe764911a64465fd87b8729f4a6a",
|
||||
"sha256:b6c231c9c2fadbae4011ca5e7e83e12dc4a5072f1a1d85a0a7b3ed754d145a40",
|
||||
"sha256:bafa7d87d4c99752d07815ed7a2c0964f8ab311eb8168f41b910bd01d15b6032",
|
||||
"sha256:bd0c630cf256b0a7fd9d0a11c9413b42fef5101219ce6ed5a09624f5a65392c7",
|
||||
"sha256:c090d4860032b857d94144d1a9976b8e36709e40386db289aaf6672de2a81966",
|
||||
"sha256:c2f91f496a87235c6aaf6d3f3d89b17dba64996abadccb289f48456cff931ca9",
|
||||
"sha256:d149aee5c72176d9ddbc6803aef9c0f6d2ceeea7626574fc68518da5476fa346",
|
||||
"sha256:d5e081bc082825f8b139f9e9fe42942cb4054524598aaeb177ff476cc76d09d2",
|
||||
"sha256:d7315ed1dab0286adca467377c8381cd748f3dc92235f22a7dfc42745644a96a",
|
||||
"sha256:dabc42f9c6577bcc13001b8810d300fe814b4cfbe8a92c873f269484594f9786",
|
||||
"sha256:e1708fac43ef8b419c975926ce1eaf793b0c13b7356cfab6ab0dc34c0a02ac0f",
|
||||
"sha256:e73d63fd04e3a9d6bc187f5455d81abfad05660b212c8804bf3b407e984cd2bc",
|
||||
"sha256:e78aecd2800b32e8347ce49316d3eaf04aed849cd5b38e0af39f829a4e59f5eb",
|
||||
"sha256:e8370eb6925bb8c1c4264fec52b0384b44f675f191df91cbe0140ec9f0955646",
|
||||
"sha256:ecb63014bb7f4ce653f8be7f1df8cbc6093a5a2811211770f6606cc92b5a78fd",
|
||||
"sha256:ed759bf7a70342f7817d88376eb7142fab9fef8320d6019ef87fae05a99874e1",
|
||||
"sha256:ef1b5a3e808bc40827b5fa2c8196151a4c5abe110e1726949d7abddfe5c7ae11",
|
||||
"sha256:f77e5b3d3da652b474cc80a14084927a5e86a5eccf54ca8ca5cbd697bf7f2667",
|
||||
"sha256:faba246fb30ea2a526c2e9645f61612341de1a83fb1e0c5edf4ddda5a9c10996",
|
||||
"sha256:fc8a63918b04b8571789688b2780ab2b4a33ab44bfe8ccea36d3eba51228c953",
|
||||
"sha256:fdebe771ca06bb8d6abce84e51dca9f7921fe6ad34a0c914541b063e9a68928b",
|
||||
"sha256:fea80f4f4cf83b54c3a051f2f727870ee51e22f0248d3114b8e755d160b38cfb"
|
||||
],
|
||||
"markers": "python_version >= '3.11'",
|
||||
"version": "==2.3.4"
|
||||
},
|
||||
"onnxruntime": {
|
||||
"hashes": [
|
||||
"sha256:0be6a37a45e6719db5120e9986fcd30ea205ac8103fd1fb74b6c33348327a0cc",
|
||||
"sha256:0f9b4ae77f8e3c9bee50c27bc1beede83f786fe1d52e99ac85aa8d65a01e9b77",
|
||||
"sha256:162f4ca894ec3de1a6fd53589e511e06ecdc3ff646849b62a9da7489dee9ce95",
|
||||
"sha256:1f9cc0a55349c584f083c1c076e611a7c35d5b867d5d6e6d6c823bf821978088",
|
||||
"sha256:218295a8acae83905f6f1aed8cacb8e3eb3bd7513a13fe4ba3b2664a19fc4a6b",
|
||||
"sha256:25de5214923ce941a3523739d34a520aac30f21e631de53bba9174dc9c004435",
|
||||
"sha256:2ff531ad8496281b4297f32b83b01cdd719617e2351ffe0dba5684fb283afa1f",
|
||||
"sha256:45d127d6e1e9b99d1ebeae9bcd8f98617a812f53f46699eafeb976275744826b",
|
||||
"sha256:4ca88747e708e5c67337b0f65eed4b7d0dd70d22ac332038c9fc4635760018f7",
|
||||
"sha256:6f91d2c9b0965e86827a5ba01531d5b669770b01775b23199565d6c1f136616c",
|
||||
"sha256:76ff670550dc23e58ea9bc53b5149b99a44e63b34b524f7b8547469aaa0dcb8c",
|
||||
"sha256:87d8b6eaf0fbeb6835a60a4265fde7a3b60157cf1b2764773ac47237b4d48612",
|
||||
"sha256:8bace4e0d46480fbeeb7bbe1ffe1f080e6663a42d1086ff95c1551f2d39e7872",
|
||||
"sha256:8f7d1fe034090a1e371b7f3ca9d3ccae2fabae8c1d8844fb7371d1ea38e8e8d2",
|
||||
"sha256:902c756d8b633ce0dedd889b7c08459433fbcf35e9c38d1c03ddc020f0648c6e",
|
||||
"sha256:9d2385e774f46ac38f02b3a91a91e30263d41b2f1f4f26ae34805b2a9ddef466",
|
||||
"sha256:a7730122afe186a784660f6ec5807138bf9d792fa1df76556b27307ea9ebcbe3",
|
||||
"sha256:b28740f4ecef1738ea8f807461dd541b8287d5650b5be33bca7b474e3cbd1f36",
|
||||
"sha256:b8f029a6b98d3cf5be564d52802bb50a8489ab73409fa9db0bf583eabb7c2321",
|
||||
"sha256:bbfd2fca76c855317568c1b36a885ddea2272c13cb0e395002c402f2360429a6",
|
||||
"sha256:da44b99206e77734c5819aa2142c69e64f3b46edc3bd314f6a45a932defc0b3e",
|
||||
"sha256:e2b9233c4947907fd1818d0e581c049c41ccc39b2856cc942ff6d26317cee145"
|
||||
],
|
||||
"markers": "python_version >= '3.10'",
|
||||
"version": "==1.23.2"
|
||||
},
|
||||
"outcome": {
|
||||
"hashes": [
|
||||
"sha256:9dcf02e65f2971b80047b377468e72a268e15c0af3cf1238e6ff14f7f91143b8",
|
||||
"sha256:e771c5ce06d1415e356078d3bdd68523f284b4ce5419828922b6871e65eda82b"
|
||||
],
|
||||
"markers": "python_version >= '3.7'",
|
||||
"version": "==1.3.0.post0"
|
||||
},
|
||||
"packaging": {
|
||||
"hashes": [
|
||||
"sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484",
|
||||
"sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"
|
||||
],
|
||||
"markers": "python_version >= '3.8'",
|
||||
"version": "==25.0"
|
||||
},
|
||||
"pefile": {
|
||||
"hashes": [
|
||||
"sha256:82e6114004b3d6911c77c3953e3838654b04511b8b66e8583db70c65998017dc",
|
||||
"sha256:da185cd2af68c08a6cd4481f7325ed600a88f6a813bad9dea07ab3ef73d8d8d6"
|
||||
],
|
||||
"markers": "python_full_version >= '3.6.0'",
|
||||
"version": "==2023.2.7"
|
||||
},
|
||||
"pillow": {
|
||||
"hashes": [
|
||||
"sha256:0869154a2d0546545cde61d1789a6524319fc1897d9ee31218eae7a60ccc5643",
|
||||
"sha256:09f2d0abef9e4e2f349305a4f8cc784a8a6c2f58a8c4892eea13b10a943bd26e",
|
||||
"sha256:0b817e7035ea7f6b942c13aa03bb554fc44fea70838ea21f8eb31c638326584e",
|
||||
"sha256:0fd00cac9c03256c8b2ff58f162ebcd2587ad3e1f2e397eab718c47e24d231cc",
|
||||
"sha256:110486b79f2d112cf6add83b28b627e369219388f64ef2f960fef9ebaf54c642",
|
||||
"sha256:1979f4566bb96c1e50a62d9831e2ea2d1211761e5662afc545fa766f996632f6",
|
||||
"sha256:1ac11e8ea4f611c3c0147424eae514028b5e9077dd99ab91e1bd7bc33ff145e1",
|
||||
"sha256:1b1b133e6e16105f524a8dec491e0586d072948ce15c9b914e41cdadd209052b",
|
||||
"sha256:1ee80a59f6ce048ae13cda1abf7fbd2a34ab9ee7d401c46be3ca685d1999a399",
|
||||
"sha256:21f241bdd5080a15bc86d3466a9f6074a9c2c2b314100dd896ac81ee6db2f1ba",
|
||||
"sha256:266cd5f2b63ff316d5a1bba46268e603c9caf5606d44f38c2873c380950576ad",
|
||||
"sha256:26d9f7d2b604cd23aba3e9faf795787456ac25634d82cd060556998e39c6fa47",
|
||||
"sha256:27f95b12453d165099c84f8a8bfdfd46b9e4bda9e0e4b65f0635430027f55739",
|
||||
"sha256:2c54c1a783d6d60595d3514f0efe9b37c8808746a66920315bfd34a938d7994b",
|
||||
"sha256:2fa5f0b6716fc88f11380b88b31fe591a06c6315e955c096c35715788b339e3f",
|
||||
"sha256:32ed80ea8a90ee3e6fa08c21e2e091bba6eda8eccc83dbc34c95169507a91f10",
|
||||
"sha256:3830c769decf88f1289680a59d4f4c46c72573446352e2befec9a8512104fa52",
|
||||
"sha256:38df9b4bfd3db902c9c2bd369bcacaf9d935b2fff73709429d95cc41554f7b3d",
|
||||
"sha256:3adfb466bbc544b926d50fe8f4a4e6abd8c6bffd28a26177594e6e9b2b76572b",
|
||||
"sha256:3e42edad50b6909089750e65c91aa09aaf1e0a71310d383f11321b27c224ed8a",
|
||||
"sha256:4078242472387600b2ce8d93ade8899c12bf33fa89e55ec89fe126e9d6d5d9e9",
|
||||
"sha256:455247ac8a4cfb7b9bc45b7e432d10421aea9fc2e74d285ba4072688a74c2e9d",
|
||||
"sha256:4cc6b3b2efff105c6a1656cfe59da4fdde2cda9af1c5e0b58529b24525d0a098",
|
||||
"sha256:4cf7fed4b4580601c4345ceb5d4cbf5a980d030fd5ad07c4d2ec589f95f09905",
|
||||
"sha256:5193fde9a5f23c331ea26d0cf171fbf67e3f247585f50c08b3e205c7aeb4589b",
|
||||
"sha256:5269cc1caeedb67e6f7269a42014f381f45e2e7cd42d834ede3c703a1d915fe3",
|
||||
"sha256:53561a4ddc36facb432fae7a9d8afbfaf94795414f5cdc5fc52f28c1dca90371",
|
||||
"sha256:55f818bd74fe2f11d4d7cbc65880a843c4075e0ac7226bc1a23261dbea531953",
|
||||
"sha256:58eea5ebe51504057dd95c5b77d21700b77615ab0243d8152793dc00eb4faf01",
|
||||
"sha256:5d5c411a8eaa2299322b647cd932586b1427367fd3184ffbb8f7a219ea2041ca",
|
||||
"sha256:6846bd2d116ff42cba6b646edf5bf61d37e5cbd256425fa089fee4ff5c07a99e",
|
||||
"sha256:6ace95230bfb7cd79ef66caa064bbe2f2a1e63d93471c3a2e1f1348d9f22d6b7",
|
||||
"sha256:6e51b71417049ad6ab14c49608b4a24d8fb3fe605e5dfabfe523b58064dc3d27",
|
||||
"sha256:71db6b4c1653045dacc1585c1b0d184004f0d7e694c7b34ac165ca70c0838082",
|
||||
"sha256:7438839e9e053ef79f7112c881cef684013855016f928b168b81ed5835f3e75e",
|
||||
"sha256:759de84a33be3b178a64c8ba28ad5c135900359e85fb662bc6e403ad4407791d",
|
||||
"sha256:792a2c0be4dcc18af9d4a2dfd8a11a17d5e25274a1062b0ec1c2d79c76f3e7f8",
|
||||
"sha256:7d87ef5795da03d742bf49439f9ca4d027cde49c82c5371ba52464aee266699a",
|
||||
"sha256:7dfb439562f234f7d57b1ac6bc8fe7f838a4bd49c79230e0f6a1da93e82f1fad",
|
||||
"sha256:7fa22993bac7b77b78cae22bad1e2a987ddf0d9015c63358032f84a53f23cdc3",
|
||||
"sha256:805ebf596939e48dbb2e4922a1d3852cfc25c38160751ce02da93058b48d252a",
|
||||
"sha256:82240051c6ca513c616f7f9da06e871f61bfd7805f566275841af15015b8f98d",
|
||||
"sha256:87d4f8125c9988bfbed67af47dd7a953e2fc7b0cc1e7800ec6d2080d490bb353",
|
||||
"sha256:8d8ca2b210ada074d57fcee40c30446c9562e542fc46aedc19baf758a93532ee",
|
||||
"sha256:8dc232e39d409036af549c86f24aed8273a40ffa459981146829a324e0848b4b",
|
||||
"sha256:90387104ee8400a7b4598253b4c406f8958f59fcf983a6cea2b50d59f7d63d0b",
|
||||
"sha256:905b0365b210c73afb0ebe9101a32572152dfd1c144c7e28968a331b9217b94a",
|
||||
"sha256:99353a06902c2e43b43e8ff74ee65a7d90307d82370604746738a1e0661ccca7",
|
||||
"sha256:99a7f72fb6249302aa62245680754862a44179b545ded638cf1fef59befb57ef",
|
||||
"sha256:9f0b04c6b8584c2c193babcccc908b38ed29524b29dd464bc8801bf10d746a3a",
|
||||
"sha256:9fe611163f6303d1619bbcb653540a4d60f9e55e622d60a3108be0d5b441017a",
|
||||
"sha256:a3475b96f5908b3b16c47533daaa87380c491357d197564e0ba34ae75c0f3257",
|
||||
"sha256:a6597ff2b61d121172f5844b53f21467f7082f5fb385a9a29c01414463f93b07",
|
||||
"sha256:a7921c5a6d31b3d756ec980f2f47c0cfdbce0fc48c22a39347a895f41f4a6ea4",
|
||||
"sha256:aa5129de4e174daccbc59d0a3b6d20eaf24417d59851c07ebb37aeb02947987c",
|
||||
"sha256:aeaefa96c768fc66818730b952a862235d68825c178f1b3ffd4efd7ad2edcb7c",
|
||||
"sha256:afbefa430092f71a9593a99ab6a4e7538bc9eabbf7bf94f91510d3503943edc4",
|
||||
"sha256:aff9e4d82d082ff9513bdd6acd4f5bd359f5b2c870907d2b0a9c5e10d40c88fe",
|
||||
"sha256:b22bd8c974942477156be55a768f7aa37c46904c175be4e158b6a86e3a6b7ca8",
|
||||
"sha256:b290fd8aa38422444d4b50d579de197557f182ef1068b75f5aa8558638b8d0a5",
|
||||
"sha256:b2e4b27a6e15b04832fe9bf292b94b5ca156016bbc1ea9c2c20098a0320d6cf6",
|
||||
"sha256:b583dc9070312190192631373c6c8ed277254aa6e6084b74bdd0a6d3b221608e",
|
||||
"sha256:b87843e225e74576437fd5b6a4c2205d422754f84a06942cfaf1dc32243e45a8",
|
||||
"sha256:bc91a56697869546d1b8f0a3ff35224557ae7f881050e99f615e0119bf934b4e",
|
||||
"sha256:bd87e140e45399c818fac4247880b9ce719e4783d767e030a883a970be632275",
|
||||
"sha256:bde737cff1a975b70652b62d626f7785e0480918dece11e8fef3c0cf057351c3",
|
||||
"sha256:bdee52571a343d721fb2eb3b090a82d959ff37fc631e3f70422e0c2e029f3e76",
|
||||
"sha256:bee2a6db3a7242ea309aa7ee8e2780726fed67ff4e5b40169f2c940e7eb09227",
|
||||
"sha256:beeae3f27f62308f1ddbcfb0690bf44b10732f2ef43758f169d5e9303165d3f9",
|
||||
"sha256:c50f36a62a22d350c96e49ad02d0da41dbd17ddc2e29750dbdba4323f85eb4a5",
|
||||
"sha256:c607c90ba67533e1b2355b821fef6764d1dd2cbe26b8c1005ae84f7aea25ff79",
|
||||
"sha256:c7b2a63fd6d5246349f3d3f37b14430d73ee7e8173154461785e43036ffa96ca",
|
||||
"sha256:c828a1ae702fc712978bda0320ba1b9893d99be0badf2647f693cc01cf0f04fa",
|
||||
"sha256:c85de1136429c524e55cfa4e033b4a7940ac5c8ee4d9401cc2d1bf48154bbc7b",
|
||||
"sha256:c98fa880d695de164b4135a52fd2e9cd7b7c90a9d8ac5e9e443a24a95ef9248e",
|
||||
"sha256:cae81479f77420d217def5f54b5b9d279804d17e982e0f2fa19b1d1e14ab5197",
|
||||
"sha256:d034140032870024e6b9892c692fe2968493790dd57208b2c37e3fb35f6df3ab",
|
||||
"sha256:d120c38a42c234dc9a8c5de7ceaaf899cf33561956acb4941653f8bdc657aa79",
|
||||
"sha256:d4827615da15cd59784ce39d3388275ec093ae3ee8d7f0c089b76fa87af756c2",
|
||||
"sha256:d49e2314c373f4c2b39446fb1a45ed333c850e09d0c59ac79b72eb3b95397363",
|
||||
"sha256:d52610d51e265a51518692045e372a4c363056130d922a7351429ac9f27e70b0",
|
||||
"sha256:d64317d2587c70324b79861babb9c09f71fbb780bad212018874b2c013d8600e",
|
||||
"sha256:d77153e14b709fd8b8af6f66a3afbb9ed6e9fc5ccf0b6b7e1ced7b036a228782",
|
||||
"sha256:d7e091d464ac59d2c7ad8e7e08105eaf9dafbc3883fd7265ffccc2baad6ac925",
|
||||
"sha256:dd333073e0cacdc3089525c7df7d39b211bcdf31fc2824e49d01c6b6187b07d0",
|
||||
"sha256:e5d8efac84c9afcb40914ab49ba063d94f5dbdf5066db4482c66a992f47a3a3b",
|
||||
"sha256:f135c702ac42262573fe9714dfe99c944b4ba307af5eb507abef1667e2cbbced",
|
||||
"sha256:f13711b1a5ba512d647a0e4ba79280d3a9a045aaf7e0cc6fbe96b91d4cdf6b0c",
|
||||
"sha256:f4f1231b7dec408e8670264ce63e9c71409d9583dd21d32c163e25213ee2a344",
|
||||
"sha256:fa3ed2a29a9e9d2d488b4da81dcb54720ac3104a20bf0bd273f1e4648aff5af9",
|
||||
"sha256:fb3096c30df99fd01c7bf8e544f392103d0795b9f98ba71a8054bcbf56b255f1"
|
||||
],
|
||||
"markers": "python_version >= '3.10'",
|
||||
"version": "==12.0.0"
|
||||
},
|
||||
"protobuf": {
|
||||
"hashes": [
|
||||
"sha256:140303d5c8d2037730c548f8c7b93b20bb1dc301be280c378b82b8894589c954",
|
||||
"sha256:25c9e1963c6734448ea2d308cfa610e692b801304ba0908d7bfa564ac5132995",
|
||||
"sha256:35be49fd3f4fefa4e6e2aacc35e8b837d6703c37a2168a55ac21e9b1bc7559ef",
|
||||
"sha256:905b07a65f1a4b72412314082c7dbfae91a9e8b68a0cc1577515f8df58ecf455",
|
||||
"sha256:9a031d10f703f03768f2743a1c403af050b6ae1f3480e9c140f39c45f81b13ee",
|
||||
"sha256:c963e86c3655af3a917962c9619e1a6b9670540351d7af9439d06064e3317cc9",
|
||||
"sha256:cd33a8e38ea3e39df66e1bbc462b076d6e5ba3a4ebbde58219d777223a7873d3",
|
||||
"sha256:d6101ded078042a8f17959eccd9236fb7a9ca20d3b0098bbcb91533a5680d035",
|
||||
"sha256:e0697ece353e6239b90ee43a9231318302ad8353c70e6e45499fa52396debf90",
|
||||
"sha256:e0a1715e4f27355afd9570f3ea369735afc853a6c3951a6afe1f80d8569ad298"
|
||||
],
|
||||
"markers": "python_version >= '3.9'",
|
||||
"version": "==6.33.0"
|
||||
},
|
||||
"pycparser": {
|
||||
"hashes": [
|
||||
"sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2",
|
||||
"sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934"
|
||||
],
|
||||
"markers": "python_version >= '3.8'",
|
||||
"version": "==2.23"
|
||||
},
|
||||
"pyinstaller": {
|
||||
"hashes": [
|
||||
"sha256:0a48f55b85ff60f83169e10050f2759019cf1d06773ad1c4da3a411cd8751058",
|
||||
"sha256:53559fe1e041a234f2b4dcc3288ea8bdd57f7cad8a6644e422c27bb407f3edef",
|
||||
"sha256:6d5f8617f3650ff9ef893e2ab4ddbf3c0d23d0c602ef74b5df8fbef4607840c8",
|
||||
"sha256:73ba72e04fcece92e32518bbb1e1fb5ac2892677943dfdff38e01a06e8742851",
|
||||
"sha256:7fd1c785219a87ca747c21fa92f561b0d2926a7edc06d0a0fe37f3736e00bd7a",
|
||||
"sha256:b1752488248f7899281b17ca3238eefb5410521291371a686a4f5830f29f52b3",
|
||||
"sha256:b756ddb9007b8141c5476b553351f9d97559b8af5d07f9460869bfae02be26b0",
|
||||
"sha256:ba618a61627ee674d6d68e5de084ba17c707b59a4f2a856084b3999bdffbd3f0",
|
||||
"sha256:bc10eb1a787f99fea613509f55b902fbd2d8b73ff5f51ff245ea29a481d97d41",
|
||||
"sha256:c8b7ef536711617e12fef4673806198872033fa06fa92326ad7fd1d84a9fa454",
|
||||
"sha256:d0af8a401de792c233c32c44b16d065ca9ab8262ee0c906835c12bdebc992a64",
|
||||
"sha256:d1ebf84d02c51fed19b82a8abb4df536923abd55bb684d694e1356e4ae2a0ce5"
|
||||
],
|
||||
"index": "pypi",
|
||||
"markers": "python_version < '3.15' and python_version >= '3.8'",
|
||||
"version": "==6.16.0"
|
||||
},
|
||||
"pyinstaller-hooks-contrib": {
|
||||
"hashes": [
|
||||
"sha256:56e972bdaad4e9af767ed47d132362d162112260cbe488c9da7fee01f228a5a6",
|
||||
"sha256:ccbfaa49399ef6b18486a165810155e5a8d4c59b41f20dc5da81af7482aaf038"
|
||||
],
|
||||
"markers": "python_version >= '3.8'",
|
||||
"version": "==2025.9"
|
||||
},
|
||||
"pyreadline3": {
|
||||
"hashes": [
|
||||
"sha256:8d57d53039a1c75adba8e50dd3d992b28143480816187ea5efbd5c78e6c885b7",
|
||||
"sha256:eaf8e6cc3c49bcccf145fc6067ba8643d1df34d604a1ec0eccbf7a18e6d3fae6"
|
||||
],
|
||||
"markers": "python_version >= '3.8'",
|
||||
"version": "==3.5.4"
|
||||
},
|
||||
"pyside6": {
|
||||
"hashes": [
|
||||
"sha256:4b709bdeeb89d386059343a5a706fc185cee37b517bda44c7d6b64d5fdaf3339",
|
||||
"sha256:70a8bcc73ea8d6baab70bba311eac77b9a1d31f658d0b418e15eb6ea36c97e6f",
|
||||
"sha256:9f402f883e640048fab246d36e298a5e16df9b18ba2e8c519877e472d3602820",
|
||||
"sha256:ae8c3c8339cd7c3c9faa7cc5c52670dcc8662ccf4b63a6fed61c6345b90c4c01",
|
||||
"sha256:c2cbc5dc2a164e3c7c51b3435e24203e90e5edd518c865466afccbd2e5872bb0"
|
||||
],
|
||||
"index": "pypi",
|
||||
"markers": "python_version < '3.14' and python_version >= '3.9'",
|
||||
"version": "==6.10.0"
|
||||
},
|
||||
"pyside6-addons": {
|
||||
"hashes": [
|
||||
"sha256:08d4ed46c4c9a353a9eb84134678f8fdd4ce17fb8cce2b3686172a7575025464",
|
||||
"sha256:15d32229d681be0bba1b936c4a300da43d01e1917ada5b57f9e03a387c245ab0",
|
||||
"sha256:88e61e21ee4643cdd9efb39ec52f4dc1ac74c0b45c5b7fa453d03c094f0a8a5c",
|
||||
"sha256:92536427413f3b6557cf53f1a515cd766725ee46a170aff57ad2ff1dfce0ffb1",
|
||||
"sha256:99d93a32c17c5f6d797c3b90dd58f2a8bae13abde81e85802c34ceafaee11859"
|
||||
],
|
||||
"markers": "python_version < '3.14' and python_version >= '3.9'",
|
||||
"version": "==6.10.0"
|
||||
},
|
||||
"pyside6-essentials": {
|
||||
"hashes": [
|
||||
"sha256:003e871effe1f3e5b876bde715c15a780d876682005a6e989d89f48b8b93e93a",
|
||||
"sha256:1d5e013a8698e37ab8ef360e6960794eb5ef20832a8d562e649b8c5a0574b2d8",
|
||||
"sha256:6dd0936394cb14da2fd8e869899f5e0925a738b1c8d74c2f22503720ea363fb1",
|
||||
"sha256:b1dd0864f0577a448fb44426b91cafff7ee7cccd1782ba66491e1c668033f998",
|
||||
"sha256:fc167eb211dd1580e20ba90d299e74898e7a5a1306d832421e879641fc03b6fe"
|
||||
],
|
||||
"markers": "python_version < '3.14' and python_version >= '3.9'",
|
||||
"version": "==6.10.0"
|
||||
},
|
||||
"pysocks": {
|
||||
"hashes": [
|
||||
"sha256:08e69f092cc6dbe92a0fdd16eeb9b9ffbc13cadfe5ca4c7bd92ffb078b293299",
|
||||
"sha256:2725bd0a9925919b9b51739eea5f9e2bae91e83288108a9ad338b2e3a4435ee5",
|
||||
"sha256:3f8804571ebe159c380ac6de37643bb4685970655d3bba243530d6558b799aa0"
|
||||
],
|
||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
|
||||
"version": "==1.7.1"
|
||||
},
|
||||
"pywin32-ctypes": {
|
||||
"hashes": [
|
||||
"sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8",
|
||||
"sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755"
|
||||
],
|
||||
"markers": "python_version >= '3.6'",
|
||||
"version": "==0.2.3"
|
||||
},
|
||||
"selenium": {
|
||||
"hashes": [
|
||||
"sha256:c117af6727859d50f622d6d0785b945c5db3e28a45ec12ad85cee2e7cc84fc4c",
|
||||
"sha256:ed47563f188130a6fd486b327ca7ba48c5b11fb900e07d6457befdde320e35fd"
|
||||
],
|
||||
"index": "pypi",
|
||||
"markers": "python_version >= '3.10'",
|
||||
"version": "==4.38.0"
|
||||
},
|
||||
"setuptools": {
|
||||
"hashes": [
|
||||
"sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922",
|
||||
"sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c"
|
||||
],
|
||||
"markers": "python_version >= '3.9'",
|
||||
"version": "==80.9.0"
|
||||
},
|
||||
"shiboken6": {
|
||||
"hashes": [
|
||||
"sha256:0bc5631c1bf150cbef768a17f5f289aae1cb4db6c6b0c19b2421394e27783717",
|
||||
"sha256:7a5f5f400ebfb3a13616030815708289c2154e701a60b9db7833b843e0bee543",
|
||||
"sha256:b01377e68d14132360efb0f4b7233006d26aa8ae9bb50edf00960c2a5f52d148",
|
||||
"sha256:dfc4beab5fec7dbbebbb418f3bf99af865d6953aa0795435563d4cbb82093b61",
|
||||
"sha256:e612734da515d683696980107cdc0396a3ae0f07b059f0f422ec8a2333810234"
|
||||
],
|
||||
"markers": "python_version < '3.14' and python_version >= '3.9'",
|
||||
"version": "==6.10.0"
|
||||
},
|
||||
"sniffio": {
|
||||
"hashes": [
|
||||
"sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2",
|
||||
"sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"
|
||||
],
|
||||
"markers": "python_version >= '3.7'",
|
||||
"version": "==1.3.1"
|
||||
},
|
||||
"sortedcontainers": {
|
||||
"hashes": [
|
||||
"sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88",
|
||||
"sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0"
|
||||
],
|
||||
"version": "==2.4.0"
|
||||
},
|
||||
"sympy": {
|
||||
"hashes": [
|
||||
"sha256:d3d3fe8df1e5a0b42f0e7bdf50541697dbe7d23746e894990c030e2b05e72517",
|
||||
"sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5"
|
||||
],
|
||||
"markers": "python_version >= '3.9'",
|
||||
"version": "==1.14.0"
|
||||
},
|
||||
"trio": {
|
||||
"hashes": [
|
||||
"sha256:150f29ec923bcd51231e1d4c71c7006e65247d68759dd1c19af4ea815a25806b",
|
||||
"sha256:4ab65984ef8370b79a76659ec87aa3a30c5c7c83ff250b4de88c29a8ab6123c5"
|
||||
],
|
||||
"markers": "python_version >= '3.10'",
|
||||
"version": "==0.32.0"
|
||||
},
|
||||
"trio-websocket": {
|
||||
"hashes": [
|
||||
"sha256:22c72c436f3d1e264d0910a3951934798dcc5b00ae56fc4ee079d46c7cf20fae",
|
||||
"sha256:df605665f1db533f4a386c94525870851096a223adcb97f72a07e8b4beba45b6"
|
||||
],
|
||||
"markers": "python_version >= '3.8'",
|
||||
"version": "==0.12.2"
|
||||
},
|
||||
"typing-extensions": {
|
||||
"hashes": [
|
||||
"sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466",
|
||||
"sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"
|
||||
],
|
||||
"markers": "python_version >= '3.9'",
|
||||
"version": "==4.15.0"
|
||||
},
|
||||
"urllib3": {
|
||||
"extras": [
|
||||
"socks"
|
||||
],
|
||||
"hashes": [
|
||||
"sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760",
|
||||
"sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc"
|
||||
],
|
||||
"markers": "python_version >= '3.9'",
|
||||
"version": "==2.5.0"
|
||||
},
|
||||
"websocket-client": {
|
||||
"hashes": [
|
||||
"sha256:9e813624b6eb619999a97dc7958469217c3176312b3a16a4bd1bc7e08a46ec98",
|
||||
"sha256:af248a825037ef591efbf6ed20cc5faa03d3b47b9e5a2230a529eeee1c1fc3ef"
|
||||
],
|
||||
"markers": "python_version >= '3.9'",
|
||||
"version": "==1.9.0"
|
||||
},
|
||||
"wsproto": {
|
||||
"hashes": [
|
||||
"sha256:ad565f26ecb92588a3e43bc3d96164de84cd9902482b130d0ddbaa9664a85065",
|
||||
"sha256:b9acddd652b585d75b20477888c56642fdade28bdfd3579aa24a4d2c037dd736"
|
||||
],
|
||||
"markers": "python_full_version >= '3.7.0'",
|
||||
"version": "==1.2.0"
|
||||
}
|
||||
},
|
||||
"develop": {}
|
||||
}
|
||||
@@ -2,15 +2,16 @@
|
||||
# AutoLibrary
|
||||
---
|
||||
|
||||

|
||||

|
||||
|
||||
[](https://github.com/KenanZhu/AutoLibrary)
|
||||

|
||||
[](https://github.com/KenanZhu/AutoLibrary/actions/workflows/release.yml)
|
||||
[](https://github.com/KenanZhu/AutoLibrary/releases)
|
||||

|
||||

|
||||
[](https://github.com/KenanZhu/AutoLibrary/actions/workflows/build-test.yml)
|
||||
[](https://github.com/KenanZhu/AutoLibrary/actions/workflows/release.yml)
|
||||
[](https://github.com/KenanZhu/AutoLibrary/releases/latest)
|
||||

|
||||
|
||||
了解更多请访问 [_AutoLibrary 网站_](http://www.autolibrary.top)
|
||||
了解更多请访问 [_AutoLibrary 网站_](http://www.autolibrary.kenanzhu.com)
|
||||
|
||||
---
|
||||
|
||||
@@ -19,32 +20,34 @@
|
||||
1. 自动预约 - 支持自动预约
|
||||
2. 自动续约 - 支持自动续约
|
||||
3. 自动签到 - 支持自动签到
|
||||
4. 批量操作 - 支持同时预约多个用户,可以指定当前需要跳过的用户,并将用户分成多个组
|
||||
5. 定时任务 - 使用内置定时任务管理,添加定时任务,指定时间后按当前预约信息自动运行
|
||||
4. 远程签到 - 支持远程签到,无需在图书馆网络环境下即可签到
|
||||
5. 批量操作 - 支持同时预约多个用户,可以指定当前需要跳过的用户,并将用户分成多个组
|
||||
6. 定时任务 - 使用内置定时任务管理,添加定时任务,指定时间后按当前预约信息自动运行,支持设置重复任务
|
||||
7. 驱动管理 - 内置浏览器驱动自动管理,支持自动检测浏览器版本并下载对应驱动,无需手动下载
|
||||
|
||||
*1,2,3 的具体操作方法和注意事项请访问我们的 [帮助手册](https://www.autolibrary.top/manuals)*
|
||||
*具体操作方法和注意事项请访问我们的 [帮助手册](https://manuals.autolibrary.kenanzhu.com)*
|
||||
|
||||
### 如何使用
|
||||
|
||||
1. 下载最新版本的 [AutoLibrary 压缩包](https://github.com/KenanZhu/AutoLibrary/releases)。
|
||||
2. 解压下载的文件到任意目录。
|
||||
3. 下载对应浏览器的驱动文件,并在配置界面的运行配置选项卡对应位置选择你下载好的浏览器驱动
|
||||
4. 运行 `AutoLibrary.exe` 文件。
|
||||
5. 按照提示操作即可。
|
||||
1. 下载最新版本的 [AutoLibrary 安装程序](https://github.com/KenanZhu/AutoLibrary/releases/latest) 或 [压缩包](https://github.com/KenanZhu/AutoLibrary/releases/latest) 。
|
||||
2. 双击运行安装程序进行安装,或将压缩包解压到任意目录。
|
||||
3. 运行 `AutoLibrary`,即可打开主界面。
|
||||
4. 点击 [配置] 按钮,在配置界面填写好预约信息和运行配置后,点击 [确认] 按钮。
|
||||
5. 点击 [启动脚本] 按钮,即可开始自动预约、续约、签到等操作。
|
||||
|
||||
*注意 1*: 关于浏览器驱动的下载和其它相关问题,请参考我们的 [帮助手册](https://www.autolibrary.top/manuals) 中对应软件版本的内容。
|
||||
*注意 1*: 工具内置浏览器驱动自动管理功能,会自动检测本地浏览器版本并下载对应的驱动文件。如果自动下载失败,也可以手动下载驱动文件并在配置界面的运行配置选项卡对应位置选择驱动文件路径。
|
||||
|
||||
#### 平台支持 & 编译步骤
|
||||
|
||||
本工具目前仅支持 Windows 平台,由于使用 PySide6 库开发,理论上是可以自行编译并在 Linux 和 macOS 上运行,这里提供简单的编译步骤:
|
||||
|
||||
1. 确保系统安装了 Python 3.13 版本 (推荐,过低或高版本会导致兼容问题)。
|
||||
2. 安装 pyside6 selenium ddddocr 库,命令为 `pip install pyside6 selenium ddddocr`。
|
||||
2. 安装所有依赖库,命令为 `pip install -r requirements.txt` (建议在虚拟环境下操作)。
|
||||
3. 在 `batchs` 目录下运行 `compile_ui.bat` (linux 和 macOS 系统使用 `compile_ui.sh`) 文件来编译 Qt 的 UI 文件。
|
||||
4. 在上一步相同目录内运行 `compile_rc.bat` (linux 和 macOS 系统使用 `compile_rc.sh`) 文件来编译 Qt 的资源文件。
|
||||
5. 待上述步骤完成后,运行 `src/Main.py` 文件即可。
|
||||
|
||||
*注意 1*:如果 python 使用的是虚拟环境,请在虚拟环境安装依赖后,在激活的虚拟环境终端中使用 `cd src/gui/batchs` 命令切换到 `batchs` 目录下,再运行编译脚本。否则会提示缺少必要的 Qt PySide 依赖库。
|
||||
*注意 1*:如果 python 使用的是虚拟环境,请在虚拟环境安装依赖后,在激活的虚拟环境终端中使用 `cd batchs` 命令切换到 `batchs` 目录下,再运行编译脚本。否则会提示缺少必要的 Qt PySide 依赖库。
|
||||
|
||||
*注意 2*:由于 ddddocr 的代码版本问题,其中 `__init__.py` 文件中的函数 `def classification(self, img: bytes):` 中的 `image.resize` 方法传入了不符合当前 pillow 版本的 `resample` 参数 `Image.ANTIALIAS`,该重采样常量已经在 10.0.0 版中删除 [1](@ref)。请将 `image.resize` 方法中的参数替换为 `resample=Image.Resampling.LANCZOS`,具体函数如下:
|
||||
```python
|
||||
@@ -98,11 +101,11 @@ def classification(self, img: bytes):
|
||||
|
||||
#### 后续会有哪些功能?
|
||||
|
||||
当前版本的功能对于正常使用已经足够,不过后续会着重考虑完善 2-4 人预约时的使用体验,暂时有以下构想:
|
||||
当前版本的功能对于正常使用已经足够,不过后续会着重完善预约时的使用体验,暂时有以下构想:
|
||||
|
||||
1. 2-4 人一起预约时,往往会偏向于预约并排或对面的整个空座位,这时候工具会按照一定策略查询搜索符合条件的座位,并预约并排或对面的整个座位,而不是各自独立预约。
|
||||
2. 预约时会考虑到组内用户的预约时间是否冲突,若冲突则会提示用户是否继续预约,若用户选择继续预约,则会按需要调整预约时间,再进行预约。
|
||||
3. 对于比较固定的用户,会考虑在定时任务管理中添加如 ‘每日任务’ ‘每周任务’ 等选项,用户可以根据需要设置定时任务重复的日期范围,自动完成预约,签到,续约等操作。
|
||||
- 引入交互预约面板功能,预约时直接在座位分布图中选择可用座位,并按用户分配,无需事先配置预约信息。
|
||||
- ~~优化定时任务管理功能,用户可以在定时任务管理界面设置重复运行的定时任务,如每日预约、每周预约等。~~ (已完成)
|
||||
- 软件的自动更新以及公告栏功能,用户可以自动更新最新版本并获取最新公告事项。
|
||||
|
||||
不过由于本人的时间和能力有限,也需要考虑到图书馆的正常运行,所以后续功能会有所取舍,但也许会进行一些小的功能验证。
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
This folder is used to store the manuals.
|
||||
|
||||
Our manuals are available at https://manuals.autolibrary.kenanzhu.com
|
||||
@@ -1,3 +0,0 @@
|
||||
This folder is used to store the manuals.
|
||||
|
||||
Our manuals are available at https://www.autolibrary.top/manuals
|
||||
@@ -0,0 +1,39 @@
|
||||
attrs==26.1.0
|
||||
certifi==2026.2.25
|
||||
cffi==2.0.0
|
||||
charset-normalizer==3.4.6
|
||||
ddddocr==1.0.6
|
||||
flatbuffers==25.12.19
|
||||
h11==0.16.0
|
||||
idna==3.11
|
||||
lupa==2.8
|
||||
mpmath==1.3.0
|
||||
numpy==2.4.3
|
||||
onnxruntime==1.24.4
|
||||
outcome==1.3.0.post0
|
||||
packaging==26.0
|
||||
pefile==2024.8.26
|
||||
pillow==12.1.1
|
||||
protobuf==7.34.0
|
||||
pybrowsers==1.3.2
|
||||
pycparser==3.0
|
||||
pyinstaller==6.19.0
|
||||
pyinstaller-hooks-contrib==2026.3
|
||||
PySide6==6.10.2
|
||||
PySide6_Addons==6.10.2
|
||||
PySide6_Essentials==6.10.2
|
||||
PySocks==1.7.1
|
||||
pywin32-ctypes==0.2.3
|
||||
requests==2.32.5
|
||||
selenium==4.38.0
|
||||
setuptools==82.0.1
|
||||
shiboken6==6.10.2
|
||||
sniffio==1.3.1
|
||||
sortedcontainers==2.4.0
|
||||
sympy==1.14.0
|
||||
trio==0.33.0
|
||||
trio-websocket==0.12.2
|
||||
typing_extensions==4.15.0
|
||||
urllib3==2.6.3
|
||||
websocket-client==1.9.0
|
||||
wsproto==1.3.2
|
||||
@@ -1,6 +1,6 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Copyright (c) 2025 - 2026 KenanZhu.
|
||||
Copyright (c) 2025 KenanZhu.
|
||||
All rights reserved.
|
||||
|
||||
This software is provided "as is", without any warranty of any kind.
|
||||
@@ -15,18 +15,22 @@ from PySide6.QtWidgets import QApplication
|
||||
from gui.ALMainWindow import ALMainWindow
|
||||
from gui.resources import ALResource
|
||||
|
||||
from boot.AppInitializer import initializeApp
|
||||
|
||||
|
||||
def main():
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
translator = QTranslator()
|
||||
if translator.load(":/res/trans/translators/qtbase_zh_CN.ts"):
|
||||
if translator.load(":/res/translators/qtbase_zh_CN.ts"):
|
||||
app.installTranslator(translator)
|
||||
app.setStyle('Fusion')
|
||||
app.setStyle("Fusion")
|
||||
app.setApplicationName("AutoLibrary")
|
||||
if not initializeApp():
|
||||
sys.exit(-1)
|
||||
window = ALMainWindow()
|
||||
window.show()
|
||||
sys.exit(app.exec_())
|
||||
|
||||
sys.exit(app.exec())
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Copyright (c) 2026 KenanZhu.
|
||||
All rights reserved.
|
||||
|
||||
This software is provided "as is", without any warranty of any kind.
|
||||
You may use, modify, and distribute this file under the terms of the MIT License.
|
||||
See the LICENSE file for details.
|
||||
"""
|
||||
from datetime import (
|
||||
date,
|
||||
datetime,
|
||||
)
|
||||
|
||||
from lupa import LuaRuntime as _LuaRuntime
|
||||
|
||||
from autoscript._helpers import (
|
||||
_TYPE_DEFAULT_VAR,
|
||||
_assignPath,
|
||||
_checkDateFormat,
|
||||
_checkTimeFormat,
|
||||
_checkType,
|
||||
_cleanLuaError,
|
||||
_navigatePath,
|
||||
)
|
||||
|
||||
try:
|
||||
from lupa.lua55 import LuaError as _LuaError, LuaSyntaxError as _LuaSyntaxError
|
||||
except ImportError:
|
||||
try:
|
||||
from lupa.lua54 import LuaError as _LuaError, LuaSyntaxError as _LuaSyntaxError
|
||||
except ImportError:
|
||||
_LuaError = Exception
|
||||
_LuaSyntaxError = Exception
|
||||
|
||||
|
||||
__all__ = ["ASEngine"]
|
||||
|
||||
|
||||
class ASEngine:
|
||||
|
||||
@staticmethod
|
||||
def getCurrentDate(
|
||||
) -> str:
|
||||
|
||||
return date.today().isoformat()
|
||||
|
||||
@staticmethod
|
||||
def getCurrentTime(
|
||||
) -> str:
|
||||
|
||||
return datetime.now().strftime("%H:%M")
|
||||
|
||||
@staticmethod
|
||||
def _sandbox(
|
||||
lua,
|
||||
) -> None:
|
||||
|
||||
lua.execute("""
|
||||
io = nil
|
||||
require = nil
|
||||
dofile = nil
|
||||
loadfile = nil
|
||||
load = nil
|
||||
package = nil
|
||||
rawget = nil
|
||||
rawset = nil
|
||||
rawequal = nil
|
||||
getfenv = nil
|
||||
setfenv = nil
|
||||
debug = nil
|
||||
if os then
|
||||
os.execute = nil
|
||||
os.exit = nil
|
||||
os.getenv = nil
|
||||
os.remove = nil
|
||||
os.rename = nil
|
||||
os.tmpname = nil
|
||||
os.setlocale = nil
|
||||
end
|
||||
""")
|
||||
|
||||
@staticmethod
|
||||
def _registerHelpers(
|
||||
lua,
|
||||
) -> None:
|
||||
|
||||
lua.execute("""
|
||||
function date(y, m, d)
|
||||
return os.time({year = y, month = m, day = d})
|
||||
end
|
||||
|
||||
function time(h, m)
|
||||
return h * 60 + m
|
||||
end
|
||||
|
||||
function datenow()
|
||||
local now = os.date("*t")
|
||||
return os.time({year = now.year, month = now.month, day = now.day})
|
||||
end
|
||||
|
||||
function timenow()
|
||||
local now = os.date("*t")
|
||||
return now.hour * 60 + now.min
|
||||
end
|
||||
|
||||
function dateadd(date_val, n)
|
||||
return date_val + n * 86400
|
||||
end
|
||||
|
||||
function timeadd(time_val, n)
|
||||
return (time_val + n * 60) % 1440
|
||||
end
|
||||
|
||||
function strtodate(iso_str)
|
||||
local y, m, d = iso_str:match("(%d+)-(%d+)-(%d+)")
|
||||
return os.time({year = y, month = m, day = d})
|
||||
end
|
||||
|
||||
function strtotime(hm_str)
|
||||
local h, m = hm_str:match("(%d+):(%d+)")
|
||||
return h * 60 + m
|
||||
end
|
||||
|
||||
function datetostr(ts)
|
||||
return os.date("%Y-%m-%d", ts)
|
||||
end
|
||||
|
||||
function timetostr(m)
|
||||
return string.format("%02d:%02d", math.floor(m / 60), m % 60)
|
||||
end
|
||||
""")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
targetVars: list[tuple] = None,
|
||||
):
|
||||
|
||||
self._targetVars: dict[str, dict] = {}
|
||||
self._lua = None
|
||||
|
||||
if targetVars:
|
||||
for item in targetVars:
|
||||
name, varType, keyPath = item[0], item[1], item[2]
|
||||
self.addTargetVar(name, varType, keyPath)
|
||||
|
||||
def _getLua(
|
||||
self,
|
||||
):
|
||||
|
||||
if self._lua is None:
|
||||
self._lua = _LuaRuntime(unpack_returned_tuples=True)
|
||||
self._sandbox(self._lua)
|
||||
self._registerHelpers(self._lua)
|
||||
return self._lua
|
||||
|
||||
def _push(
|
||||
self,
|
||||
targetData: dict,
|
||||
) -> None:
|
||||
|
||||
lua = self._getLua()
|
||||
g = lua.globals()
|
||||
strToDate = g["strtodate"]
|
||||
strToTime = g["strtotime"]
|
||||
|
||||
for varName, info in self._targetVars.items():
|
||||
keyPath = info["keyPath"]
|
||||
vt = info["type"]
|
||||
raw = _navigatePath(targetData, keyPath)
|
||||
if vt == "Date":
|
||||
if not isinstance(raw, str) or not raw.strip():
|
||||
raise ValueError(
|
||||
f"Date 类型变量 '{varName}' 对应的数据为空或不是字符串类型,"
|
||||
f"请检查路径 {keyPath} 的值是否为合法的日期字符串 (YYYY-MM-DD)"
|
||||
)
|
||||
raw = raw.strip()
|
||||
_checkDateFormat(raw, varName)
|
||||
g[varName] = strToDate(raw)
|
||||
elif vt == "Time":
|
||||
if not isinstance(raw, str) or not raw.strip():
|
||||
raise ValueError(
|
||||
f"Time 类型变量 '{varName}' 对应的数据为空或不是字符串类型,"
|
||||
f"请检查路径 {keyPath} 的值是否为合法的时间字符串 (HH:MM)"
|
||||
)
|
||||
raw = raw.strip()
|
||||
_checkTimeFormat(raw, varName)
|
||||
g[varName] = strToTime(raw)
|
||||
else:
|
||||
if raw is None:
|
||||
raw = _TYPE_DEFAULT_VAR.get(vt, False)
|
||||
g[varName] = raw
|
||||
|
||||
def _pull(
|
||||
self,
|
||||
targetData: dict,
|
||||
) -> None:
|
||||
|
||||
lua = self._getLua()
|
||||
g = lua.globals()
|
||||
dateToStr = g["datetostr"]
|
||||
timeToStr = g["timetostr"]
|
||||
|
||||
for varName, info in self._targetVars.items():
|
||||
try:
|
||||
luaVal = g[varName]
|
||||
except KeyError:
|
||||
continue
|
||||
vt = info["type"]
|
||||
if vt == "Date":
|
||||
luaVal = dateToStr(luaVal)
|
||||
elif vt == "Time":
|
||||
luaVal = timeToStr(luaVal)
|
||||
elif vt == "Float" and isinstance(luaVal, int) and not isinstance(luaVal, bool):
|
||||
luaVal = float(luaVal)
|
||||
_checkType(varName, vt, luaVal)
|
||||
_assignPath(targetData, info["keyPath"], luaVal)
|
||||
|
||||
def addTargetVar(
|
||||
self,
|
||||
name: str,
|
||||
varType: str,
|
||||
keyPath: list,
|
||||
) -> None:
|
||||
|
||||
upperName = name.upper().strip()
|
||||
self._targetVars[upperName] = {
|
||||
"type": varType,
|
||||
"keyPath": keyPath,
|
||||
}
|
||||
|
||||
def execute(
|
||||
self,
|
||||
scriptText: str,
|
||||
targetData: dict,
|
||||
) -> None:
|
||||
|
||||
if not scriptText or not scriptText.strip():
|
||||
return
|
||||
try:
|
||||
self._push(targetData)
|
||||
self._getLua().execute(scriptText)
|
||||
self._pull(targetData)
|
||||
except _LuaSyntaxError as e:
|
||||
raise ValueError(
|
||||
f"AutoScript 语法错误: {_cleanLuaError(str(e))}"
|
||||
)
|
||||
except _LuaError as e:
|
||||
raise ValueError(
|
||||
f"AutoScript 运行时错误: {_cleanLuaError(str(e))}"
|
||||
)
|
||||
except ValueError as e:
|
||||
raise ValueError(f"AutoScript 数据错误: {e}")
|
||||
except Exception as e:
|
||||
raise ValueError(f"AutoScript 未知错误: {e}")
|
||||
|
||||
def reset(
|
||||
self,
|
||||
) -> None:
|
||||
|
||||
self._targetVars = {}
|
||||
self._lua = None
|
||||
@@ -0,0 +1,58 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Copyright (c) 2026 KenanZhu.
|
||||
All rights reserved.
|
||||
|
||||
This software is provided "as is", without any warranty of any kind.
|
||||
You may use, modify, and distribute this file under the terms of the MIT License.
|
||||
See the LICENSE file for details.
|
||||
"""
|
||||
from .ASEngine import ASEngine
|
||||
|
||||
__version__ = "1.0.0" # autoscript version
|
||||
|
||||
_TARGET_VAR_DEFS = [
|
||||
("USERNAME", "String", ["username"], "用户名"),
|
||||
("USER_ENABLE", "Boolean", ["enabled"], "用户启用"),
|
||||
("RESERVE_DATE", "Date", ["reserve_info", "date"], "预约日期"),
|
||||
("RESERVE_BEGIN_TIME", "Time", ["reserve_info", "begin_time", "time"], "预约开始时间"),
|
||||
("RESERVE_END_TIME", "Time", ["reserve_info", "end_time", "time"], "预约结束时间"),
|
||||
]
|
||||
_MOCK_TYPE_VALUES = {
|
||||
"String": "__mock__",
|
||||
"Boolean": True,
|
||||
"Date": "2099-01-01",
|
||||
"Time": "00:00",
|
||||
"Int": 0,
|
||||
"Float": 0.0,
|
||||
}
|
||||
|
||||
|
||||
def createAllVariablesTable(
|
||||
) -> dict:
|
||||
|
||||
return {
|
||||
displayName: (name, varType)
|
||||
for name, varType, _, displayName in _TARGET_VAR_DEFS
|
||||
}
|
||||
|
||||
def createTargetVarDefs(
|
||||
) -> list:
|
||||
|
||||
return list(_TARGET_VAR_DEFS)
|
||||
|
||||
def createMockTargetData(
|
||||
) -> dict:
|
||||
|
||||
data = {}
|
||||
for _, varType, keyPath, _ in _TARGET_VAR_DEFS:
|
||||
d = data
|
||||
for key in keyPath[:-1]:
|
||||
d = d.setdefault(key, {})
|
||||
d[keyPath[-1]] = _MOCK_TYPE_VALUES.get(varType, "")
|
||||
return data
|
||||
|
||||
def createEngine(
|
||||
) -> ASEngine:
|
||||
|
||||
return ASEngine(_TARGET_VAR_DEFS)
|
||||
@@ -0,0 +1,153 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Copyright (c) 2026 KenanZhu.
|
||||
All rights reserved.
|
||||
|
||||
This software is provided "as is", without any warranty of any kind.
|
||||
You may use, modify, and distribute this file under the terms of the MIT License.
|
||||
See the LICENSE file for details.
|
||||
"""
|
||||
from datetime import (
|
||||
date,
|
||||
datetime,
|
||||
)
|
||||
|
||||
|
||||
_TYPE_DEFAULT_VAR: dict[str, str | int | float | bool] = {
|
||||
"String": "",
|
||||
"Int": 0,
|
||||
"Float": 0.0,
|
||||
"Boolean": False,
|
||||
}
|
||||
|
||||
|
||||
def _navigatePath(
|
||||
data: dict,
|
||||
keyPath: list,
|
||||
default=None,
|
||||
):
|
||||
|
||||
d = data
|
||||
for key in keyPath[:-1]:
|
||||
d = d.get(key, {})
|
||||
if not isinstance(d, dict):
|
||||
return default
|
||||
return d.get(keyPath[-1], default)
|
||||
|
||||
def _assignPath(
|
||||
data: dict,
|
||||
keyPath: list,
|
||||
value,
|
||||
) -> None:
|
||||
|
||||
d = data
|
||||
for key in keyPath[:-1]:
|
||||
d = d.setdefault(key, {})
|
||||
d[keyPath[-1]] = value
|
||||
|
||||
def _checkDateFormat(
|
||||
dateStr: str,
|
||||
varName: str = "",
|
||||
) -> None:
|
||||
|
||||
prefix = f"Date 类型变量 '{varName}' 的" if varName else ""
|
||||
try:
|
||||
date.fromisoformat(dateStr)
|
||||
except ValueError:
|
||||
raise ValueError(
|
||||
f"{prefix}值 '{dateStr}' 不是合法的日期格式,"
|
||||
f"应为 YYYY-MM-DD"
|
||||
)
|
||||
|
||||
def _checkTimeFormat(
|
||||
timeStr: str,
|
||||
varName: str = "",
|
||||
) -> None:
|
||||
|
||||
prefix = f"Time 类型变量 '{varName}' 的" if varName else ""
|
||||
try:
|
||||
datetime.strptime(timeStr, "%H:%M")
|
||||
except ValueError:
|
||||
raise ValueError(
|
||||
f"{prefix}值 '{timeStr}' 不是合法的时间格式,"
|
||||
f"应为 HH:MM"
|
||||
)
|
||||
|
||||
def _checkType(
|
||||
varName: str,
|
||||
varType: str,
|
||||
value,
|
||||
) -> None:
|
||||
|
||||
if varType == "Date":
|
||||
if not isinstance(value, str):
|
||||
raise ValueError(
|
||||
f"Date 类型变量 '{varName}' 只能接受日期字符串,"
|
||||
f"不能接受 {_pyTypeToASType(value)} 类型"
|
||||
)
|
||||
_checkDateFormat(value, varName)
|
||||
return
|
||||
if varType == "Time":
|
||||
if not isinstance(value, str):
|
||||
raise ValueError(
|
||||
f"Time 类型变量 '{varName}' 只能接受时间字符串,"
|
||||
f"不能接受 {_pyTypeToASType(value)} 类型"
|
||||
)
|
||||
_checkTimeFormat(value, varName)
|
||||
return
|
||||
if varType == "Int":
|
||||
if isinstance(value, bool):
|
||||
raise ValueError(
|
||||
f"Int 类型变量 '{varName}' 不能接受 Boolean 类型的值"
|
||||
)
|
||||
if not isinstance(value, int) and not (isinstance(value, float) and value == int(value)):
|
||||
raise ValueError(
|
||||
f"Int 类型变量 '{varName}' 不能接受 {_pyTypeToASType(value)} 类型的值"
|
||||
)
|
||||
return
|
||||
if varType == "Float":
|
||||
if isinstance(value, bool):
|
||||
raise ValueError(
|
||||
f"Float 类型变量 '{varName}' 不能接受 Boolean 类型的值"
|
||||
)
|
||||
if not isinstance(value, (int, float)):
|
||||
raise ValueError(
|
||||
f"Float 类型变量 '{varName}' 不能接受 {_pyTypeToASType(value)} 类型的值"
|
||||
)
|
||||
return
|
||||
if varType == "Boolean":
|
||||
if not isinstance(value, bool):
|
||||
raise ValueError(
|
||||
f"Boolean 类型变量 '{varName}' 不能接受 {_pyTypeToASType(value)} 类型的值"
|
||||
)
|
||||
return
|
||||
if varType == "String":
|
||||
if not isinstance(value, str):
|
||||
raise ValueError(
|
||||
f"String 类型变量 '{varName}' 不能接受 {_pyTypeToASType(value)} 类型的值"
|
||||
)
|
||||
return
|
||||
|
||||
def _pyTypeToASType(
|
||||
value,
|
||||
) -> str:
|
||||
|
||||
if isinstance(value, bool):
|
||||
return "Boolean"
|
||||
if isinstance(value, int):
|
||||
return "Int"
|
||||
if isinstance(value, float):
|
||||
return "Float"
|
||||
if isinstance(value, str):
|
||||
return "String"
|
||||
return "Unknown"
|
||||
|
||||
def _cleanLuaError(
|
||||
rawMsg: str,
|
||||
) -> str:
|
||||
|
||||
msg = rawMsg.replace('[string "<python>"]:', "").strip()
|
||||
stackIdx = msg.find("stack traceback:")
|
||||
if stackIdx != -1:
|
||||
msg = msg[:stackIdx].strip()
|
||||
return msg
|
||||
@@ -1,37 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Copyright (c) 2025 - 2026 KenanZhu.
|
||||
All rights reserved.
|
||||
|
||||
This software is provided "as is", without any warranty of any kind.
|
||||
You may use, modify, and distribute this file under the terms of the MIT License.
|
||||
See the LICENSE file for details.
|
||||
"""
|
||||
import queue
|
||||
|
||||
from base.MsgBase import MsgBase
|
||||
|
||||
|
||||
class LibOperator(MsgBase):
|
||||
"""
|
||||
Base abstract class for library operation.
|
||||
|
||||
This class provides the foundation for library-related operations, inheriting
|
||||
message handling and tracing abilities from MsgBase. It serves as an abstract
|
||||
base class that must be subclassed to implement specific library functionality.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
input_queue: queue.Queue,
|
||||
output_queue: queue.Queue
|
||||
):
|
||||
|
||||
super().__init__(input_queue, output_queue)
|
||||
|
||||
|
||||
def _waitResponseLoad(
|
||||
self
|
||||
) -> bool:
|
||||
|
||||
pass
|
||||
@@ -1,15 +1,18 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Copyright (c) 2025 - 2026 KenanZhu.
|
||||
Copyright (c) 2025 KenanZhu.
|
||||
All rights reserved.
|
||||
|
||||
This software is provided "as is", without any warranty of any kind.
|
||||
You may use, modify, and distribute this file under the terms of the MIT License.
|
||||
See the LICENSE file for details.
|
||||
"""
|
||||
import logging
|
||||
import queue
|
||||
import datetime
|
||||
|
||||
import managers.log.LogManager as LogManager
|
||||
|
||||
|
||||
class MsgBase:
|
||||
"""
|
||||
@@ -29,6 +32,18 @@ class MsgBase:
|
||||
implement queue polling to retrieve and process messages.
|
||||
"""
|
||||
|
||||
class TraceLevel:
|
||||
"""
|
||||
Enum class for trace levels.
|
||||
|
||||
This class provides the trace levels for the logger.
|
||||
"""
|
||||
DEBUG = logging.DEBUG
|
||||
INFO = logging.INFO
|
||||
WARNING = logging.WARNING
|
||||
ERROR = logging.ERROR
|
||||
CRITICAL = logging.CRITICAL
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
input_queue: queue.Queue,
|
||||
@@ -38,7 +53,10 @@ class MsgBase:
|
||||
self._class_name = self.__class__.__name__
|
||||
self._input_queue = input_queue
|
||||
self._output_queue = output_queue
|
||||
|
||||
try:
|
||||
self._logger = LogManager.getLogger(self._class_name)
|
||||
except RuntimeError:
|
||||
self._logger = None
|
||||
|
||||
def _showMsg(
|
||||
self,
|
||||
@@ -47,15 +65,26 @@ class MsgBase:
|
||||
|
||||
self._output_queue.put(f"[{self._class_name:<15}] >>> : {msg}")
|
||||
|
||||
|
||||
def _showTrace(
|
||||
self,
|
||||
msg: str
|
||||
msg: str,
|
||||
level: int = logging.INFO,
|
||||
no_log: bool = False
|
||||
):
|
||||
|
||||
timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")[:-3]
|
||||
self._output_queue.put(f"{timestamp}-[{self._class_name:<15}] : {msg}")
|
||||
if self._logger and not no_log:
|
||||
self._logger.log(level, msg)
|
||||
|
||||
def _showLog(
|
||||
self,
|
||||
msg: str,
|
||||
level: int = logging.INFO
|
||||
):
|
||||
|
||||
if self._logger:
|
||||
self._logger.log(level, msg)
|
||||
|
||||
def _waitMsg(
|
||||
self,
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Base module for the AutoLibrary project.
|
||||
Copyright (c) 2026 KenanZhu.
|
||||
All rights reserved.
|
||||
|
||||
Here are the classes and modules in this package:
|
||||
- MsgBase: Base class for messages.\
|
||||
- LibOperator: Base class for library operators.
|
||||
"""
|
||||
This software is provided "as is", without any warranty of any kind.
|
||||
You may use, modify, and distribute this file under the terms of the MIT License.
|
||||
See the LICENSE file for details.
|
||||
"""
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Copyright (c) 2026 KenanZhu.
|
||||
All rights reserved.
|
||||
|
||||
This software is provided "as is", without any warranty of any kind.
|
||||
You may use, modify, and distribute this file under the terms of the MIT License.
|
||||
See the LICENSE file for details.
|
||||
"""
|
||||
import os
|
||||
|
||||
from PySide6.QtCore import QStandardPaths, QDir
|
||||
|
||||
from managers.log.LogManager import instance as logInstance
|
||||
from managers.config.ConfigManager import instance as configInstance
|
||||
from managers.driver.WebDriverManager import instance as webdriverInstance
|
||||
|
||||
|
||||
def _initializeLogManager(
|
||||
) -> bool:
|
||||
|
||||
app_dir = QStandardPaths.writableLocation(QStandardPaths.StandardLocation.AppDataLocation)
|
||||
log_dir = os.path.join(app_dir, "logs")
|
||||
if not QDir(log_dir).exists():
|
||||
if not QDir().mkpath(log_dir):
|
||||
return False
|
||||
logInstance(log_dir)
|
||||
return True
|
||||
|
||||
def _initializeConfigManager(
|
||||
) -> bool:
|
||||
|
||||
logger = logInstance().getLogger("AppInitializer")
|
||||
|
||||
app_dir = QStandardPaths.writableLocation(QStandardPaths.StandardLocation.AppDataLocation)
|
||||
old_config_dir = os.path.join(app_dir, "config")
|
||||
new_config_dir = os.path.join(app_dir, "configs")
|
||||
if QDir(old_config_dir).exists(): # old config dir exists
|
||||
#we rename it to compatible with new version
|
||||
logger.info("存在旧配置目录 %s,将其重命名为 %s", old_config_dir, new_config_dir)
|
||||
if not QDir().rename(old_config_dir, new_config_dir):
|
||||
logger.error("重命名旧配置目录 %s 到 %s 失败", old_config_dir, new_config_dir)
|
||||
return False
|
||||
elif not QDir(new_config_dir).exists():
|
||||
logger.info("初始化配置目录 %s", new_config_dir)
|
||||
if not QDir().mkpath(new_config_dir):
|
||||
logger.error("创建配置目录 %s 失败", new_config_dir)
|
||||
return False
|
||||
configInstance(new_config_dir)
|
||||
return True
|
||||
|
||||
def _initializeWebDriverManager(
|
||||
) -> bool:
|
||||
|
||||
logger = logInstance().getLogger("AppInitializer")
|
||||
|
||||
app_dir = QStandardPaths.writableLocation(QStandardPaths.StandardLocation.AppDataLocation)
|
||||
driver_dir = os.path.join(app_dir, "drivers")
|
||||
if not QDir(driver_dir).exists():
|
||||
logger.info("初始化驱动目录 %s", driver_dir)
|
||||
if not QDir().mkpath(driver_dir):
|
||||
logger.error("创建驱动目录 %s 失败", driver_dir)
|
||||
return False
|
||||
webdriverInstance(driver_dir)
|
||||
return True
|
||||
|
||||
def initializeApp(
|
||||
) -> bool:
|
||||
"""
|
||||
Initialize the application components
|
||||
|
||||
Order:
|
||||
LogManager -> ConfigManager -> WebDriverManager
|
||||
"""
|
||||
|
||||
if not _initializeLogManager():
|
||||
return False
|
||||
if not _initializeConfigManager():
|
||||
return False
|
||||
if not _initializeWebDriverManager():
|
||||
return False
|
||||
return True
|
||||
@@ -0,0 +1,9 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Copyright (c) 2026 KenanZhu.
|
||||
All rights reserved.
|
||||
|
||||
This software is provided "as is", without any warranty of any kind.
|
||||
You may use, modify, and distribute this file under the terms of the MIT License.
|
||||
See the LICENSE file for details.
|
||||
"""
|
||||
@@ -1,6 +1,6 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Copyright (c) 2025 - 2026 KenanZhu.
|
||||
Copyright (c) 2025 KenanZhu.
|
||||
All rights reserved.
|
||||
|
||||
This software is provided "as is", without any warranty of any kind.
|
||||
@@ -9,21 +9,25 @@ See the LICENSE file for details.
|
||||
"""
|
||||
import platform
|
||||
|
||||
from PySide6.QtGui import (
|
||||
QIcon, QFont
|
||||
)
|
||||
from PySide6.QtWidgets import (
|
||||
QDialog, QApplication
|
||||
)
|
||||
from PySide6.QtCore import (
|
||||
QTimer, Qt
|
||||
Qt,
|
||||
QTimer
|
||||
)
|
||||
from PySide6.QtGui import QIcon
|
||||
from PySide6.QtWidgets import (
|
||||
QApplication,
|
||||
QDialog,
|
||||
QTabWidget,
|
||||
QTextBrowser
|
||||
)
|
||||
|
||||
from gui.ALVersionInfo import (
|
||||
AL_VERSION, AL_COMMIT_SHA, AL_COMMIT_DATE, AL_BUILD_DATE
|
||||
AL_VERSION,
|
||||
AL_COMMIT_SHA,
|
||||
AL_COMMIT_DATE,
|
||||
AL_BUILD_DATE
|
||||
)
|
||||
from gui.resources.ui.Ui_ALAboutDialog import Ui_ALAboutDialog
|
||||
|
||||
from gui.resources import ALResource
|
||||
|
||||
|
||||
@@ -39,19 +43,28 @@ class ALAboutDialog(QDialog, Ui_ALAboutDialog):
|
||||
self.modifyUi()
|
||||
self.connectSignals()
|
||||
|
||||
|
||||
def modifyUi(
|
||||
self
|
||||
):
|
||||
|
||||
self.LogoIconLabel.setPixmap(QIcon(":/res/icon/icons/AutoLibrary_32x32.ico").pixmap(48, 48))
|
||||
info_text = self.generateAboutText()
|
||||
self.AboutInfoBrowser.setHtml(info_text)
|
||||
browser_font = self.AboutInfoBrowser.font()
|
||||
browser_font.setFamily("Courier New")
|
||||
self.AboutInfoBrowser.setFont(browser_font)
|
||||
self.AboutInfoBrowser.setTextInteractionFlags(Qt.TextBrowserInteraction)
|
||||
|
||||
self.LogoIconLabel.setPixmap(QIcon(":/res/icons/AutoLibrary_Logo_64.svg").pixmap(48, 48))
|
||||
self.TabWidget = QTabWidget()
|
||||
self.TabWidget.setDocumentMode(True)
|
||||
AboutBrowser = QTextBrowser()
|
||||
AboutBrowser.setHtml(self.generateAboutText())
|
||||
AboutBrowser.setOpenExternalLinks(True)
|
||||
AboutBrowser.setLineWrapMode(QTextBrowser.LineWrapMode.NoWrap)
|
||||
AboutBrowser.setTextInteractionFlags(Qt.TextBrowserInteraction)
|
||||
BrowserFont = AboutBrowser.font()
|
||||
BrowserFont.setFamilies(["Courier New", "Consolas", "Menlo", "DejaVu Sans Mono", "monospace"])
|
||||
AboutBrowser.setFont(BrowserFont)
|
||||
self.TabWidget.addTab(AboutBrowser, "关于")
|
||||
LicenseBrowser = QTextBrowser()
|
||||
LicenseBrowser.setHtml(self.generateLicenseText())
|
||||
LicenseBrowser.setOpenExternalLinks(True)
|
||||
LicenseBrowser.setTextInteractionFlags(Qt.TextBrowserInteraction)
|
||||
self.TabWidget.addTab(LicenseBrowser, "许可证")
|
||||
self.AboutInfoLayout.addWidget(self.TabWidget)
|
||||
|
||||
def connectSignals(
|
||||
self
|
||||
@@ -59,39 +72,61 @@ class ALAboutDialog(QDialog, Ui_ALAboutDialog):
|
||||
|
||||
self.CopyButton.clicked.connect(self.copyAboutInfo)
|
||||
|
||||
|
||||
def generateAboutText(
|
||||
self
|
||||
) -> str:
|
||||
|
||||
os_info = self.getOSInfo()
|
||||
run_on = f"{os_info['system']} {os_info['version']} {os_info['architecture']}"
|
||||
selenium_ver = self.getSeleniumVersion()
|
||||
about_text = f"""
|
||||
<h4>Version Information:</h4>
|
||||
Version: {AL_VERSION}<br>
|
||||
<b style="font-size:14px;">VERSION: {AL_VERSION}</b><br>
|
||||
Commit SHA: {AL_COMMIT_SHA}<br>
|
||||
Commit date: {AL_COMMIT_DATE}<br>
|
||||
Build date: {AL_BUILD_DATE}<br>
|
||||
Python version: {platform.python_version()}<br>
|
||||
Qt version: {self.getQtVersion()}<br>
|
||||
|
||||
<h4>System Information:</h4>
|
||||
<br>
|
||||
<b style="font-size:14px;">SYSTEM</b><br>
|
||||
Running on: {run_on}<br>
|
||||
Processor: {platform.processor()}<br>
|
||||
Operating system: {os_info['system']}<br>
|
||||
System version: {os_info['version']}<br>
|
||||
System architecture: {os_info['architecture']}<br>
|
||||
|
||||
<h4>Project Information:</h4>
|
||||
License: MIT License<br>
|
||||
Project repository: <a href="https://www.github.com/KenanZhu/AutoLibrary" style="text-decoration: none;">https://www.github.com/KenanZhu/AutoLibrary</a><br>
|
||||
Project website: <a href="https://www.autolibrary.top" style="text-decoration: none;">https://www.autolibrary.top</a><br>
|
||||
|
||||
<h4>Author Information:</h4>
|
||||
Developer: KenanZhu<br>
|
||||
Contact: nanoki_zh@163.com<br>
|
||||
GitHub: <a href="https://www.github.com/KenanZhu" style="text-decoration: none;">https://www.github.com/KenanZhu</a><br>
|
||||
<br>
|
||||
<b style="font-size:14px;">DEPENDENCIES</b><br>
|
||||
Python: {platform.python_version()}<br>
|
||||
Qt(PySide6): {self.getQtVersion()}<br>
|
||||
Selenium: {selenium_ver}<br>
|
||||
<br>
|
||||
<b style="font-size:14px;">PROJECT</b><br>
|
||||
Website: <a href="https://www.autolibrary.kenanzhu.com" style="text-decoration:none;">https://www.autolibrary.kenanzhu.com</a><br>
|
||||
Repository: <a href="https://www.github.com/KenanZhu/AutoLibrary" style="text-decoration:none;">https://www.github.com/KenanZhu/AutoLibrary</a><br>
|
||||
<br>
|
||||
<b style="font-size:14px;">AUTHOR</b><br>
|
||||
Developer/Maintainer: KenanZhu<br>
|
||||
Contact: <a href="mailto:nanoki_zh@163.com">nanoki_zh@163.com</a><br>
|
||||
GitHub: <a href="https://www.github.com/KenanZhu" style="text-decoration:none;">https://www.github.com/KenanZhu</a><br>
|
||||
"""
|
||||
return about_text
|
||||
|
||||
def generateLicenseText(
|
||||
self
|
||||
) -> str:
|
||||
|
||||
return """
|
||||
<b>MIT License</b>
|
||||
<p>Copyright © 2025 - 2026 KenanZhu</p>
|
||||
<p>Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:</p>
|
||||
<p>The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.</p>
|
||||
<p>THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.</p>"""
|
||||
|
||||
def getOSInfo(
|
||||
self
|
||||
@@ -124,7 +159,6 @@ GitHub: <a href="https://www.github.com/KenanZhu" style="text-decoration: none;"
|
||||
'architecture': architecture
|
||||
}
|
||||
|
||||
|
||||
def getQtVersion(
|
||||
self
|
||||
):
|
||||
@@ -135,14 +169,23 @@ GitHub: <a href="https://www.github.com/KenanZhu" style="text-decoration: none;"
|
||||
except:
|
||||
return "Unknown"
|
||||
|
||||
def getSeleniumVersion(
|
||||
self
|
||||
):
|
||||
|
||||
try:
|
||||
import selenium
|
||||
return selenium.__version__
|
||||
except Exception:
|
||||
return "Unknown"
|
||||
|
||||
def copyAboutInfo(
|
||||
self
|
||||
):
|
||||
|
||||
about_text = self.AboutInfoBrowser.toPlainText()
|
||||
clipboard = QApplication.clipboard()
|
||||
clipboard.setText(about_text)
|
||||
about_text = self.TabWidget.currentWidget().toPlainText()
|
||||
Clipboard = QApplication.clipboard()
|
||||
Clipboard.setText(about_text)
|
||||
original_text = self.CopyButton.text()
|
||||
self.CopyButton.setText("已复制")
|
||||
QTimer.singleShot(2000, lambda: self.CopyButton.setText(original_text))
|
||||
QTimer.singleShot(2000, lambda: self.CopyButton.setText(original_text))
|
||||
|
||||
@@ -0,0 +1,669 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Copyright (c) 2026 KenanZhu.
|
||||
All rights reserved.
|
||||
|
||||
This software is provided "as is", without any warranty of any kind.
|
||||
You may use, modify, and distribute this file under the terms of the MIT License.
|
||||
See the LICENSE file for details.
|
||||
"""
|
||||
from copy import deepcopy
|
||||
|
||||
from PySide6.QtCore import (
|
||||
QDate,
|
||||
QSize,
|
||||
Qt,
|
||||
QTime,
|
||||
QTimer,
|
||||
Slot
|
||||
)
|
||||
from PySide6.QtGui import (
|
||||
QColor,
|
||||
QFont,
|
||||
QIcon,
|
||||
QSyntaxHighlighter,
|
||||
QTextCharFormat,
|
||||
)
|
||||
from PySide6.QtWidgets import (
|
||||
QApplication,
|
||||
QComboBox,
|
||||
QDateEdit,
|
||||
QDialog,
|
||||
QDialogButtonBox,
|
||||
QDoubleSpinBox,
|
||||
QFormLayout,
|
||||
QFrame,
|
||||
QGridLayout,
|
||||
QGroupBox,
|
||||
QHBoxLayout,
|
||||
QHeaderView,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QMessageBox,
|
||||
QPlainTextEdit,
|
||||
QPushButton,
|
||||
QSpinBox,
|
||||
QSplitter,
|
||||
QStyle,
|
||||
QTabWidget,
|
||||
QTableWidget,
|
||||
QTableWidgetItem,
|
||||
QTimeEdit,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from autoscript import (
|
||||
createAllVariablesTable,
|
||||
createMockTargetData,
|
||||
createTargetVarDefs,
|
||||
createEngine,
|
||||
)
|
||||
|
||||
|
||||
class ALScriptHighlighter(QSyntaxHighlighter):
|
||||
"""
|
||||
Syntax highlighter for Lua-based AutoScript.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent = None
|
||||
):
|
||||
|
||||
super().__init__(parent)
|
||||
self._rules = []
|
||||
|
||||
KeywordFmt = QTextCharFormat()
|
||||
KeywordFmt.setForeground(QColor("#569CD6"))
|
||||
KeywordFmt.setFontWeight(QFont.Weight.Bold)
|
||||
for kw in [
|
||||
"if", "elseif", "else", "end", "then",
|
||||
"and", "or", "not",
|
||||
"local", "function", "return", "nil",
|
||||
]:
|
||||
self._rules.append((r"\b" + kw + r"\b", KeywordFmt))
|
||||
BoolFmt = QTextCharFormat()
|
||||
BoolFmt.setForeground(QColor("#4FC1FF"))
|
||||
BoolFmt.setFontWeight(QFont.Weight.Bold)
|
||||
self._rules.append((r"\btrue\b", BoolFmt))
|
||||
self._rules.append((r"\bfalse\b", BoolFmt))
|
||||
CmpFmt = QTextCharFormat()
|
||||
CmpFmt.setForeground(QColor("#C586C0"))
|
||||
CmpFmt.setFontWeight(QFont.Weight.Normal)
|
||||
for op in [r"==", r"~=", r">=", r"<=", r">", r"<"]:
|
||||
self._rules.append((op, CmpFmt))
|
||||
ArithFmt = QTextCharFormat()
|
||||
ArithFmt.setForeground(QColor("#C586C0"))
|
||||
ArithFmt.setFontWeight(QFont.Weight.Normal)
|
||||
for op in [r"\+", r"-", r"\*", r"/", r"\.\."]:
|
||||
self._rules.append((op, ArithFmt))
|
||||
FuncFmt = QTextCharFormat()
|
||||
FuncFmt.setForeground(QColor("#DCDCAA"))
|
||||
FuncFmt.setFontWeight(QFont.Weight.Normal)
|
||||
for fn in [ "time", "date", "datenow", "timenow", "dateadd", "timeadd"]:
|
||||
self._rules.append((r"\b" + fn + r"\b", FuncFmt))
|
||||
VarFmt = QTextCharFormat()
|
||||
VarFmt.setForeground(QColor("#9CDCFE"))
|
||||
VarFmt.setFontWeight(QFont.Weight.Normal)
|
||||
var_names = [name for _, (name, _) in createAllVariablesTable().items()]
|
||||
for var in var_names:
|
||||
self._rules.append((r"\b" + var + r"\b", VarFmt))
|
||||
StrFmt = QTextCharFormat()
|
||||
StrFmt.setForeground(QColor("#CE9178"))
|
||||
StrFmt.setFontWeight(QFont.Weight.Normal)
|
||||
self._rules.append((r'"[^"]*"', StrFmt))
|
||||
self._rules.append((r"'[^']*'", StrFmt))
|
||||
NumFmt = QTextCharFormat()
|
||||
NumFmt.setForeground(QColor("#B5CEA8"))
|
||||
NumFmt.setFontWeight(QFont.Weight.Normal)
|
||||
self._rules.append((r"\b\d+(?:\.\d+)?\b", NumFmt))
|
||||
CommentFmt = QTextCharFormat()
|
||||
CommentFmt.setForeground(QColor("#6A9955"))
|
||||
CommentFmt.setFontItalic(True)
|
||||
self._rules.append((r"--[^\n]*", CommentFmt))
|
||||
|
||||
def highlightBlock(
|
||||
self,
|
||||
text
|
||||
):
|
||||
|
||||
import re
|
||||
for pattern, fmt in self._rules:
|
||||
for match in re.finditer(pattern, text, re.IGNORECASE):
|
||||
start = match.start()
|
||||
length = match.end() - match.start()
|
||||
self.setFormat(start, length, fmt)
|
||||
|
||||
|
||||
class _DebugResultDialog(QDialog):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
changes: list,
|
||||
parent = None
|
||||
):
|
||||
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("调试运行结果 - AutoLibrary")
|
||||
self.setMinimumSize(600, 200)
|
||||
DbgLayout = QVBoxLayout(self)
|
||||
DbgTable = QTableWidget(len(changes), 3)
|
||||
DbgTable.setHorizontalHeaderLabels(["目标变量", "原始数据", "运行后数据"])
|
||||
DbgTable.horizontalHeader().setStretchLastSection(True)
|
||||
DbgTable.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch)
|
||||
DbgTable.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers)
|
||||
for row, (display_name, name, var_type, before_val, after_val) in enumerate(changes):
|
||||
label = f"{display_name}: {name}({var_type})"
|
||||
DbgTable.setItem(row, 0, QTableWidgetItem(label))
|
||||
DbgTable.setItem(row, 1, QTableWidgetItem(str(before_val)))
|
||||
DbgTable.setItem(row, 2, QTableWidgetItem(str(after_val)))
|
||||
DbgLayout.addWidget(DbgTable)
|
||||
DbgBtnBox = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok)
|
||||
DbgBtnBox.button(QDialogButtonBox.StandardButton.Ok).setText("确定")
|
||||
DbgBtnBox.accepted.connect(self.accept)
|
||||
DbgLayout.addWidget(DbgBtnBox)
|
||||
|
||||
|
||||
class _TabToSpacesEditor(QPlainTextEdit):
|
||||
|
||||
def keyPressEvent(
|
||||
self,
|
||||
event
|
||||
):
|
||||
|
||||
if event.key() == Qt.Key.Key_Tab:
|
||||
self.insertPlainText(" ")
|
||||
return
|
||||
super().keyPressEvent(event)
|
||||
|
||||
|
||||
class ALAutoScriptEditDialog(QDialog):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent = None,
|
||||
script: str = "",
|
||||
mockData: dict = None
|
||||
):
|
||||
|
||||
super().__init__(parent)
|
||||
self._fontSize = 21
|
||||
self._mockWidgets = {}
|
||||
|
||||
self.setupUi()
|
||||
self.connectSignals()
|
||||
self.TextEdit.setPlainText(script)
|
||||
self._Highlighter = ALScriptHighlighter(
|
||||
self.TextEdit.document()
|
||||
)
|
||||
if mockData:
|
||||
self.setMockData(mockData)
|
||||
|
||||
def setupUi(
|
||||
self
|
||||
):
|
||||
|
||||
self.setWindowTitle("AutoScript 编辑 - AutoLibrary")
|
||||
self.setMinimumSize(660, 600)
|
||||
Layout = QVBoxLayout(self)
|
||||
Layout.setSpacing(3)
|
||||
Layout.setContentsMargins(3, 3, 3, 3)
|
||||
ToolbarLayout = QHBoxLayout()
|
||||
self.ZoomInBtn = QPushButton("+")
|
||||
self.ZoomInBtn.setFixedSize(25, 25)
|
||||
self.ZoomOutBtn = QPushButton("-")
|
||||
self.ZoomOutBtn.setFixedSize(25, 25)
|
||||
self.ZoomResetBtn = QPushButton("")
|
||||
self.ZoomResetBtn.setIcon(QIcon(":/res/icons/Reset.svg"))
|
||||
self.ZoomResetBtn.setIconSize(QSize(20, 20))
|
||||
self.ZoomResetBtn.setFixedSize(25, 25)
|
||||
self.ZoomResetBtn.setToolTip("重置缩放")
|
||||
self.ZoomLabel = QLabel(f"{self._fontSize}px")
|
||||
self.ZoomLabel.setFixedHeight(25)
|
||||
self.OrchBtn = QPushButton("编排")
|
||||
self.OrchBtn.setFixedHeight(25)
|
||||
self.OrchBtn.setToolTip("可视化生成 AutoScript 代码并插入到光标位置")
|
||||
ToolbarLayout.addWidget(self.OrchBtn)
|
||||
self.DebugBtn = QPushButton("▶ 调试运行")
|
||||
self.DebugBtn.setFixedHeight(25)
|
||||
self.DebugBtn.setToolTip("使用右侧模拟数据执行脚本,查看目标变量变化")
|
||||
ToolbarLayout.addWidget(self.DebugBtn)
|
||||
Sep = QFrame()
|
||||
Sep.setFrameShape(QFrame.Shape.VLine)
|
||||
Sep.setFrameShadow(QFrame.Shadow.Sunken)
|
||||
Sep.setFixedWidth(1)
|
||||
ToolbarLayout.addWidget(Sep)
|
||||
ToolbarLayout.addWidget(self.ZoomInBtn)
|
||||
ToolbarLayout.addWidget(self.ZoomOutBtn)
|
||||
ToolbarLayout.addWidget(self.ZoomResetBtn)
|
||||
ToolbarLayout.addWidget(self.ZoomLabel)
|
||||
ToolbarLayout.addStretch()
|
||||
self.CopyBtn = QPushButton("")
|
||||
self.CopyBtn.setIcon(QIcon(":/res/icons/Copy.svg"))
|
||||
self.CopyBtn.setIconSize(QSize(20, 20))
|
||||
self.CopyBtn.setFixedSize(25, 25)
|
||||
self.CopyBtn.setToolTip("复制脚本")
|
||||
ToolbarLayout.addWidget(self.CopyBtn)
|
||||
Layout.addLayout(ToolbarLayout)
|
||||
self.TextEdit = _TabToSpacesEditor(self)
|
||||
self.TextEdit.setTabStopDistance(40)
|
||||
self.TextEdit.setLineWrapMode(
|
||||
QPlainTextEdit.LineWrapMode.NoWrap
|
||||
)
|
||||
self.TextEdit.setStyleSheet(
|
||||
"QPlainTextEdit {"
|
||||
" font-family: 'Courier New', 'Consolas', monospace;"
|
||||
f" font-size: {self._fontSize}px;"
|
||||
"}"
|
||||
)
|
||||
Layout.addWidget(self.TextEdit)
|
||||
self.createButtonPanel(Layout)
|
||||
self.BtnBox = QDialogButtonBox(
|
||||
QDialogButtonBox.StandardButton.Ok |
|
||||
QDialogButtonBox.StandardButton.Cancel
|
||||
)
|
||||
self.BtnBox.button(QDialogButtonBox.StandardButton.Ok).setText("确定")
|
||||
self.BtnBox.button(QDialogButtonBox.StandardButton.Cancel).setText("取消")
|
||||
Layout.addWidget(self.BtnBox)
|
||||
|
||||
def createButtonPanel(
|
||||
self,
|
||||
ParentLayout
|
||||
):
|
||||
|
||||
Splitter = QSplitter(Qt.Orientation.Horizontal)
|
||||
TabWidget = QTabWidget()
|
||||
TabWidget.setMaximumHeight(150)
|
||||
BasicWidget = QWidget()
|
||||
BasicLayout = QGridLayout(BasicWidget)
|
||||
BasicLayout.setSpacing(4)
|
||||
BasicLayout.setContentsMargins(4, 4, 4, 4)
|
||||
BasicLayout.setAlignment(Qt.AlignLeft | Qt.AlignTop)
|
||||
controlButtons = [
|
||||
("如果 (if...)", "if then\n \nend"),
|
||||
("再如果 (elseif...)", "elseif then\n "),
|
||||
("否则 (else)", "else"),
|
||||
("结束 (end)", "end"),
|
||||
("跳过 (pass)", "-- pass"),
|
||||
]
|
||||
self.addButtonsToGrid(BasicLayout, controlButtons, 0, 0, 3)
|
||||
assignButtons = [
|
||||
("赋值 (=)", " = "),
|
||||
]
|
||||
self.addButtonsToGrid(BasicLayout, assignButtons, 1, 2, 3)
|
||||
TabWidget.addTab(BasicWidget, "基本语法")
|
||||
OperatorWidget = QWidget()
|
||||
OperatorLayout = QGridLayout(OperatorWidget)
|
||||
OperatorLayout.setSpacing(4)
|
||||
OperatorLayout.setContentsMargins(4, 4, 4, 4)
|
||||
OperatorLayout.setAlignment(Qt.AlignLeft | Qt.AlignTop)
|
||||
arithmeticButtons = [
|
||||
("加 (+)", " + "),
|
||||
("减 (-)", " - "),
|
||||
]
|
||||
self.addButtonsToGrid(OperatorLayout, arithmeticButtons, 0, 0, 3)
|
||||
compareButtons = [
|
||||
("等于 (==)", " == "),
|
||||
("不等于 (~=)", " ~= "),
|
||||
("大于 (>)", " > "),
|
||||
("小于 (<)", " < "),
|
||||
("大于等于 (>=)", " >= "),
|
||||
("小于等于 (<=)", " <= "),
|
||||
]
|
||||
self.addButtonsToGrid(OperatorLayout, compareButtons, 1, 0, 3)
|
||||
logic_buttons = [
|
||||
("且 (and)", " and "),
|
||||
("或 (or)", " or "),
|
||||
]
|
||||
self.addButtonsToGrid(OperatorLayout, logic_buttons, 2, 0, 3)
|
||||
TabWidget.addTab(OperatorWidget, "运算符")
|
||||
LiteralWidget = QWidget()
|
||||
LiteralLayout = QGridLayout(LiteralWidget)
|
||||
LiteralLayout.setSpacing(4)
|
||||
LiteralLayout.setContentsMargins(4, 4, 4, 4)
|
||||
LiteralLayout.setAlignment(Qt.AlignLeft | Qt.AlignTop)
|
||||
bool_buttons = [
|
||||
("真 (true)", "true"),
|
||||
("假 (false)", "false"),
|
||||
]
|
||||
self.addButtonsToGrid(LiteralLayout, bool_buttons, 0, 0, 3)
|
||||
dateTimeButtons = [
|
||||
("日期", 'date(2026, 1, 1)'),
|
||||
("时间", 'time(0, 0)'),
|
||||
]
|
||||
self.addButtonsToGrid(LiteralLayout, dateTimeButtons, 1, 0, 3)
|
||||
hintButtons = [
|
||||
("字符串", '"请输入文本"'),
|
||||
("数字", "123"),
|
||||
("注释", "-- 请输入注释"),
|
||||
]
|
||||
self.addButtonsToGrid(LiteralLayout, hintButtons, 2, 0, 3)
|
||||
TabWidget.addTab(LiteralWidget, "字面量")
|
||||
VarWidget = QWidget()
|
||||
VarLayout = QGridLayout(VarWidget)
|
||||
VarLayout.setSpacing(4)
|
||||
VarLayout.setContentsMargins(4, 4, 4, 4)
|
||||
VarLayout.setAlignment(Qt.AlignLeft | Qt.AlignTop)
|
||||
varButtons = [
|
||||
(display_name, name) for display_name, (name, _) in createAllVariablesTable().items()
|
||||
]
|
||||
self.addButtonsToGrid(VarLayout, varButtons, 0, 0, 3)
|
||||
TabWidget.addTab(VarWidget, "变量")
|
||||
FuncWidget = QWidget()
|
||||
FuncLayout = QGridLayout(FuncWidget)
|
||||
FuncLayout.setSpacing(4)
|
||||
FuncLayout.setContentsMargins(4, 4, 4, 4)
|
||||
FuncLayout.setAlignment(Qt.AlignLeft | Qt.AlignTop)
|
||||
funcButtons = [
|
||||
("datenow()", "datenow()", "返回当前日期的 Unix 时间戳"),
|
||||
("timenow()", "timenow()", "返回当前时间在一天中的分钟数"),
|
||||
("dateadd(day, n)", "dateadd(, )", "日期偏移: dateadd(日期时间戳, 天数)"),
|
||||
("timeadd(time, n)", "timeadd(, )", "时间偏移: timeadd(分钟数, 分钟数)"),
|
||||
]
|
||||
for i, (text, template, tooltip) in enumerate(funcButtons):
|
||||
Btn = QPushButton(text)
|
||||
Btn.setProperty("template", template)
|
||||
Btn.clicked.connect(self.insertTemplate)
|
||||
Btn.setFixedWidth(100)
|
||||
Btn.setFixedHeight(25)
|
||||
Btn.setToolTip(tooltip)
|
||||
FuncLayout.addWidget(Btn, i // 2, i % 2)
|
||||
TabWidget.addTab(FuncWidget, "工具函数")
|
||||
MockPanel = self.createMockPanel()
|
||||
MockPanel.setMinimumWidth(260)
|
||||
Splitter.addWidget(TabWidget)
|
||||
Splitter.addWidget(MockPanel)
|
||||
Splitter.setStretchFactor(0, 1)
|
||||
Splitter.setStretchFactor(1, 1)
|
||||
Splitter.setSizes([530, 530])
|
||||
ParentLayout.addWidget(Splitter)
|
||||
|
||||
def addButtonsToGrid(
|
||||
self,
|
||||
grid_layout,
|
||||
buttons,
|
||||
start_row,
|
||||
start_col,
|
||||
max_columns
|
||||
):
|
||||
|
||||
col = start_col
|
||||
row = start_row
|
||||
|
||||
for btn_text, template in buttons:
|
||||
Btn = QPushButton(btn_text)
|
||||
Btn.setProperty("template", template)
|
||||
Btn.clicked.connect(self.insertTemplate)
|
||||
Btn.setFixedWidth(100)
|
||||
Btn.setFixedHeight(25)
|
||||
Btn.setToolTip(f"插入: {template}")
|
||||
grid_layout.addWidget(Btn, row, col)
|
||||
col += 1
|
||||
if col >= start_col + max_columns:
|
||||
col = start_col
|
||||
row += 1
|
||||
|
||||
def createMockPanel(
|
||||
self
|
||||
) -> QGroupBox:
|
||||
|
||||
Group = QGroupBox("模拟目标数据")
|
||||
Form = QFormLayout(Group)
|
||||
Form.setSpacing(4)
|
||||
Form.setContentsMargins(5, 10, 5, 5)
|
||||
self._mockWidgets = {}
|
||||
mockData = createMockTargetData()
|
||||
for name, var_type, key_path, display_name in createTargetVarDefs():
|
||||
d = mockData
|
||||
for key in key_path:
|
||||
d = d[key]
|
||||
default = d
|
||||
Widget = self.makeMockInput(var_type, default)
|
||||
Label = QLabel(f"{display_name}: {name}({var_type})")
|
||||
Form.addRow(Label, Widget)
|
||||
self._mockWidgets[name] = (Widget, var_type, key_path)
|
||||
return Group
|
||||
|
||||
def makeMockInput(
|
||||
self,
|
||||
var_type: str,
|
||||
default
|
||||
) -> QWidget:
|
||||
|
||||
if var_type == "String":
|
||||
W = QLineEdit()
|
||||
W.setText(str(default))
|
||||
return W
|
||||
if var_type == "Boolean":
|
||||
W = QComboBox()
|
||||
W.addItems(["是", "否"])
|
||||
W.setCurrentIndex(0 if default else 1)
|
||||
return W
|
||||
if var_type == "Date":
|
||||
W = QDateEdit()
|
||||
W.setCalendarPopup(True)
|
||||
W.setDisplayFormat("yyyy-MM-dd")
|
||||
W.setDate(QDate.fromString(str(default), "yyyy-MM-dd"))
|
||||
return W
|
||||
if var_type == "Time":
|
||||
W = QTimeEdit()
|
||||
W.setDisplayFormat("HH:mm")
|
||||
W.setTime(QTime.fromString(str(default), "HH:mm"))
|
||||
return W
|
||||
if var_type == "Int":
|
||||
W = QSpinBox()
|
||||
W.setMinimum(-999999)
|
||||
W.setMaximum(999999)
|
||||
W.setValue(int(default) if default else 0)
|
||||
return W
|
||||
if var_type == "Float":
|
||||
W = QDoubleSpinBox()
|
||||
W.setMinimum(-999999.0)
|
||||
W.setMaximum(999999.0)
|
||||
W.setDecimals(2)
|
||||
W.setValue(float(default) if default else 0.0)
|
||||
return W
|
||||
W = QLineEdit()
|
||||
W.setText(str(default))
|
||||
return W
|
||||
|
||||
def getMockData(
|
||||
self
|
||||
) -> dict:
|
||||
|
||||
data = {}
|
||||
for name, var_type, key_path, display_name in createTargetVarDefs():
|
||||
widget, _, _ = self._mockWidgets[name]
|
||||
value = self.getMockValue(widget, var_type)
|
||||
d = data
|
||||
for key in key_path[:-1]:
|
||||
d = d.setdefault(key, {})
|
||||
d[key_path[-1]] = value
|
||||
return data
|
||||
|
||||
def setMockData(
|
||||
self,
|
||||
data: dict
|
||||
):
|
||||
|
||||
if not data:
|
||||
return
|
||||
for name, var_type, key_path, display_name in createTargetVarDefs():
|
||||
d = data
|
||||
try:
|
||||
for key in key_path:
|
||||
d = d[key]
|
||||
except (KeyError, TypeError):
|
||||
continue
|
||||
widget, _, _ = self._mockWidgets[name]
|
||||
self.setMockValue(widget, var_type, d)
|
||||
|
||||
def getMockValue(
|
||||
self,
|
||||
widget: QWidget,
|
||||
var_type: str
|
||||
):
|
||||
|
||||
if var_type == "Boolean":
|
||||
return widget.currentIndex() == 0
|
||||
if var_type == "Date":
|
||||
return widget.date().toString("yyyy-MM-dd")
|
||||
if var_type == "Time":
|
||||
return widget.time().toString("HH:mm")
|
||||
if var_type == "Int":
|
||||
return widget.value()
|
||||
if var_type == "Float":
|
||||
return widget.value()
|
||||
return widget.text()
|
||||
|
||||
def setMockValue(
|
||||
self,
|
||||
widget: QWidget,
|
||||
var_type: str,
|
||||
value
|
||||
):
|
||||
|
||||
if var_type == "Boolean":
|
||||
widget.setCurrentIndex(0 if value else 1)
|
||||
elif var_type == "Date":
|
||||
widget.setDate(QDate.fromString(str(value), "yyyy-MM-dd"))
|
||||
elif var_type == "Time":
|
||||
widget.setTime(QTime.fromString(str(value), "HH:mm"))
|
||||
elif var_type == "Int":
|
||||
widget.setValue(int(value))
|
||||
elif var_type == "Float":
|
||||
widget.setValue(float(value))
|
||||
else:
|
||||
widget.setText(str(value))
|
||||
|
||||
def connectSignals(
|
||||
self
|
||||
):
|
||||
|
||||
self.BtnBox.accepted.connect(self.accept)
|
||||
self.BtnBox.rejected.connect(self.reject)
|
||||
self.OrchBtn.clicked.connect(self.onOpenOrchDialog)
|
||||
self.DebugBtn.clicked.connect(self.onDebugRun)
|
||||
self.ZoomInBtn.clicked.connect(self.onZoomIn)
|
||||
self.ZoomOutBtn.clicked.connect(self.onZoomOut)
|
||||
self.ZoomResetBtn.clicked.connect(self.onZoomReset)
|
||||
self.CopyBtn.clicked.connect(self.onCopy)
|
||||
|
||||
def getScript(
|
||||
self
|
||||
) -> str:
|
||||
|
||||
return self.TextEdit.toPlainText()
|
||||
|
||||
def updateFontSize(
|
||||
self
|
||||
):
|
||||
|
||||
self.TextEdit.setStyleSheet(
|
||||
"QPlainTextEdit {"
|
||||
" font-family: 'Courier New', 'Consolas', monospace;"
|
||||
f" font-size: {self._fontSize}px;"
|
||||
"}"
|
||||
)
|
||||
self.ZoomLabel.setText(f"{self._fontSize}px")
|
||||
|
||||
@Slot()
|
||||
def insertTemplate(
|
||||
self
|
||||
):
|
||||
|
||||
Btn = self.sender()
|
||||
if not isinstance(Btn, QPushButton):
|
||||
return
|
||||
template = Btn.property("template")
|
||||
if not template:
|
||||
return
|
||||
cursor = self.TextEdit.textCursor()
|
||||
cursor.insertText(template)
|
||||
|
||||
@Slot()
|
||||
def onZoomIn(
|
||||
self
|
||||
):
|
||||
|
||||
self._fontSize = min(self._fontSize + 2, 40)
|
||||
self.updateFontSize()
|
||||
|
||||
@Slot()
|
||||
def onZoomOut(
|
||||
self
|
||||
):
|
||||
|
||||
self._fontSize = max(self._fontSize - 2, 8)
|
||||
self.updateFontSize()
|
||||
|
||||
@Slot()
|
||||
def onZoomReset(
|
||||
self
|
||||
):
|
||||
|
||||
self._fontSize = 21
|
||||
self.updateFontSize()
|
||||
|
||||
@Slot()
|
||||
def onCopy(
|
||||
self
|
||||
):
|
||||
|
||||
Clipboard = QApplication.clipboard()
|
||||
Clipboard.setText(self.TextEdit.toPlainText())
|
||||
self.CopyBtn.setEnabled(False)
|
||||
QTimer.singleShot(2000, lambda: (
|
||||
self.CopyBtn.setEnabled(True)
|
||||
))
|
||||
|
||||
@Slot()
|
||||
def onOpenOrchDialog(
|
||||
self
|
||||
):
|
||||
|
||||
from gui.ALAutoScriptOrchDialog import ALAutoScriptOrchDialog
|
||||
Dlg = ALAutoScriptOrchDialog(self)
|
||||
if Dlg.exec() == QDialog.DialogCode.Accepted:
|
||||
script = Dlg.getScript()
|
||||
if script:
|
||||
cursor = self.TextEdit.textCursor()
|
||||
cursor.insertText(script)
|
||||
Dlg.deleteLater()
|
||||
|
||||
@Slot()
|
||||
def onDebugRun(
|
||||
self
|
||||
):
|
||||
|
||||
script = self.TextEdit.toPlainText().strip()
|
||||
if not script:
|
||||
QMessageBox.warning(self, "提示", "脚本内容为空。")
|
||||
return
|
||||
target_data = self.getMockData()
|
||||
before = deepcopy(target_data)
|
||||
try:
|
||||
engine = createEngine()
|
||||
engine.execute(script, target_data)
|
||||
except ValueError as e:
|
||||
QMessageBox.warning(self, "运行错误", str(e))
|
||||
return
|
||||
changes = []
|
||||
for name, var_type, key_path, display_name in createTargetVarDefs():
|
||||
before_val = before
|
||||
after_val = target_data
|
||||
try:
|
||||
for key in key_path:
|
||||
before_val = before_val[key]
|
||||
after_val = after_val[key]
|
||||
except (KeyError, TypeError):
|
||||
continue
|
||||
if before_val != after_val:
|
||||
changes.append((display_name, name, var_type, before_val, after_val))
|
||||
if not changes:
|
||||
QMessageBox.information(self, "调试运行", "目标变量未发生变化。")
|
||||
return
|
||||
Dlg = _DebugResultDialog(changes, self)
|
||||
Dlg.exec()
|
||||
Dlg.deleteLater()
|
||||
@@ -0,0 +1,10 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Copyright (c) 2026 KenanZhu.
|
||||
All rights reserved.
|
||||
|
||||
This software is provided "as is", without any warranty of any kind.
|
||||
You may use, modify, and distribute this file under the terms of the MIT License.
|
||||
See the LICENSE file for details.
|
||||
"""
|
||||
from ._dialog import ALAutoScriptOrchDialog
|
||||
@@ -0,0 +1,265 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Copyright (c) 2026 KenanZhu.
|
||||
All rights reserved.
|
||||
|
||||
This software is provided "as is", without any warranty of any kind.
|
||||
You may use, modify, and distribute this file under the terms of the MIT License.
|
||||
See the LICENSE file for details.
|
||||
"""
|
||||
"""
|
||||
Conditional block widget for the AutoScript orchestration dialog.
|
||||
"""
|
||||
from PySide6.QtCore import Slot
|
||||
from PySide6.QtWidgets import (
|
||||
QComboBox,
|
||||
QGroupBox,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QPushButton,
|
||||
QSizePolicy,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from gui.ALAutoScriptOrchDialog._widgets import (
|
||||
ActionStepFrame,
|
||||
ConditionRowFrame,
|
||||
)
|
||||
|
||||
|
||||
class ConditionalBlock(QGroupBox):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
blockIndex: int,
|
||||
varMgr = None,
|
||||
parent = None
|
||||
):
|
||||
|
||||
super().__init__(parent)
|
||||
self.blockIndex = blockIndex
|
||||
self._varMgr = varMgr
|
||||
self._actionWidgets = []
|
||||
self._conditionRows = []
|
||||
|
||||
self.setupUi()
|
||||
self.connectSignals()
|
||||
self.addInitialConditionRow()
|
||||
|
||||
def setupUi(
|
||||
self
|
||||
):
|
||||
|
||||
self.setUpdatesEnabled(False)
|
||||
self.setStyleSheet(
|
||||
"QGroupBox { font-weight: bold; border: 1px solid #ccc; "
|
||||
"margin-top: 5px; padding-top: 5px; }"
|
||||
)
|
||||
self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
|
||||
MainLayout = QVBoxLayout(self)
|
||||
MainLayout.setSpacing(6)
|
||||
MainLayout.setContentsMargins(8, 8, 8, 8)
|
||||
HeaderLayout = QHBoxLayout()
|
||||
HeaderLayout.setSpacing(8)
|
||||
self.TypeCombo = QComboBox(self)
|
||||
self.TypeCombo.addItem("IF", "IF")
|
||||
self.TypeCombo.addItem("ELSE IF", "ELSE IF")
|
||||
self.TypeCombo.addItem("ELSE", "ELSE")
|
||||
self.TypeCombo.setFixedHeight(25)
|
||||
if self.blockIndex == 0:
|
||||
self.TypeCombo.setEnabled(False)
|
||||
HeaderLayout.addWidget(QLabel("类型:", self))
|
||||
HeaderLayout.addWidget(self.TypeCombo)
|
||||
HeaderLayout.addStretch()
|
||||
self.DeleteBlockBtn = QPushButton("删除此块", self)
|
||||
self.DeleteBlockBtn.setStyleSheet("color: red;")
|
||||
self.DeleteBlockBtn.setFixedHeight(25)
|
||||
HeaderLayout.addWidget(self.DeleteBlockBtn)
|
||||
MainLayout.addLayout(HeaderLayout)
|
||||
self.ConditionWidget = QWidget(self)
|
||||
self.ConditionWidget.setSizePolicy(
|
||||
QSizePolicy.Preferred, QSizePolicy.Preferred
|
||||
)
|
||||
CondLayout = QVBoxLayout(self.ConditionWidget)
|
||||
CondLayout.setContentsMargins(4, 4, 4, 4)
|
||||
CondLayout.setSpacing(6)
|
||||
self.CondRowsLayout = QVBoxLayout()
|
||||
self.CondRowsLayout.setSpacing(4)
|
||||
CondLayout.addLayout(self.CondRowsLayout)
|
||||
self.AddCondBtn = QPushButton("+ 添加条件", self.ConditionWidget)
|
||||
self.AddCondBtn.setFixedHeight(25)
|
||||
CondLayout.addWidget(self.AddCondBtn)
|
||||
MainLayout.addWidget(self.ConditionWidget)
|
||||
self.ActionLabel = QLabel("执行步骤:", self)
|
||||
self.ActionLabel.setFixedHeight(25)
|
||||
MainLayout.addWidget(self.ActionLabel)
|
||||
self.ActionsLayout = QVBoxLayout()
|
||||
self.ActionsLayout.setSpacing(4)
|
||||
MainLayout.addLayout(self.ActionsLayout)
|
||||
self.AddActionBtn = QPushButton("+ 添加执行步骤", self)
|
||||
self.AddActionBtn.setFixedHeight(25)
|
||||
MainLayout.addWidget(self.AddActionBtn)
|
||||
self.setUpdatesEnabled(True)
|
||||
|
||||
def connectSignals(
|
||||
self
|
||||
):
|
||||
|
||||
self.TypeCombo.currentIndexChanged.connect(self.onTypeChanged)
|
||||
self.AddCondBtn.clicked.connect(self.addConditionRow)
|
||||
self.AddActionBtn.clicked.connect(self.addActionStep)
|
||||
|
||||
def addInitialConditionRow(
|
||||
self
|
||||
):
|
||||
|
||||
Row = ConditionRowFrame(
|
||||
self._varMgr, self.blockIndex,
|
||||
isFirst=True, parent=self
|
||||
)
|
||||
self._conditionRows.append(Row)
|
||||
self.CondRowsLayout.addWidget(Row)
|
||||
|
||||
def addConditionRow(
|
||||
self
|
||||
):
|
||||
|
||||
Row = ConditionRowFrame(
|
||||
self._varMgr, self.blockIndex,
|
||||
isFirst=False, parent=self
|
||||
)
|
||||
Row.DeleteBtn.clicked.connect(lambda: self.removeConditionRow(Row))
|
||||
self._conditionRows.append(Row)
|
||||
self.CondRowsLayout.addWidget(Row)
|
||||
|
||||
def removeConditionRow(
|
||||
self,
|
||||
row: ConditionRowFrame
|
||||
):
|
||||
|
||||
if row in self._conditionRows and len(self._conditionRows) > 1:
|
||||
self._conditionRows.remove(row)
|
||||
self.CondRowsLayout.removeWidget(row)
|
||||
row.hide()
|
||||
row.deleteLater()
|
||||
|
||||
def addActionStep(
|
||||
self
|
||||
):
|
||||
|
||||
Step = ActionStepFrame(self._varMgr, self.blockIndex, parent=self)
|
||||
Step.DeleteBtn.clicked.connect(lambda: self.removeActionStep(Step))
|
||||
self._actionWidgets.append(Step)
|
||||
self.ActionsLayout.addWidget(Step)
|
||||
|
||||
def removeActionStep(
|
||||
self,
|
||||
step: ActionStepFrame
|
||||
):
|
||||
|
||||
if step in self._actionWidgets:
|
||||
self._actionWidgets.remove(step)
|
||||
self.ActionsLayout.removeWidget(step)
|
||||
step.hide()
|
||||
step.deleteLater()
|
||||
|
||||
def getBlockType(
|
||||
self
|
||||
) -> str:
|
||||
|
||||
return self.TypeCombo.currentData()
|
||||
|
||||
def getConditionRows(
|
||||
self
|
||||
):
|
||||
|
||||
return list(self._conditionRows)
|
||||
|
||||
def getActionSteps(
|
||||
self
|
||||
):
|
||||
|
||||
return list(self._actionWidgets)
|
||||
|
||||
def countActionSteps(
|
||||
self
|
||||
) -> int:
|
||||
|
||||
return len(self._actionWidgets)
|
||||
|
||||
def toScript(
|
||||
self
|
||||
) -> list:
|
||||
"""
|
||||
Generate Lua script lines for this conditional block.
|
||||
"""
|
||||
|
||||
blockType = self.getBlockType()
|
||||
lines = []
|
||||
if blockType in ("IF", "ELSE IF"):
|
||||
condTexts = [
|
||||
r.toScript() for r in self._conditionRows if r.toScript()
|
||||
]
|
||||
if not condTexts:
|
||||
condTexts = ["true"]
|
||||
if len(condTexts) == 1:
|
||||
combined = condTexts[0]
|
||||
else:
|
||||
parts = []
|
||||
for i, ct in enumerate(condTexts):
|
||||
if i > 0:
|
||||
logic = self._conditionRows[i].getLogic() or "and"
|
||||
parts.append(f" {logic} ")
|
||||
parts.append(f"({ct})")
|
||||
combined = "".join(parts)
|
||||
if blockType == "IF":
|
||||
lines.append(f"if {combined} then")
|
||||
else:
|
||||
lines.append(f"elseif {combined} then")
|
||||
else:
|
||||
lines.append("else")
|
||||
for step in self._actionWidgets:
|
||||
scriptLine = step.toScript()
|
||||
if scriptLine:
|
||||
lines.append(scriptLine)
|
||||
return lines
|
||||
|
||||
def refreshVarCombos(
|
||||
self
|
||||
):
|
||||
|
||||
for row in self._conditionRows:
|
||||
row.refreshVarCombos()
|
||||
for step in self._actionWidgets:
|
||||
step.refreshVarCombos()
|
||||
|
||||
def setPrevBlockType(
|
||||
self,
|
||||
prevType: str | None
|
||||
):
|
||||
|
||||
model = self.TypeCombo.model()
|
||||
if model is None:
|
||||
return
|
||||
for data in ("ELSE IF", "ELSE"):
|
||||
idx = self.TypeCombo.findData(data)
|
||||
if idx < 0:
|
||||
continue
|
||||
item = model.item(idx)
|
||||
shouldEnable = prevType != "ELSE"
|
||||
item.setEnabled(shouldEnable)
|
||||
if prevType == "ELSE" and self.TypeCombo.currentData() in ("ELSE IF", "ELSE"):
|
||||
self.TypeCombo.setCurrentIndex(0)
|
||||
|
||||
@Slot(int)
|
||||
def onTypeChanged(
|
||||
self,
|
||||
_idx
|
||||
):
|
||||
|
||||
isCond = self.TypeCombo.currentData() in ("IF", "ELSE IF")
|
||||
self.ConditionWidget.setVisible(isCond)
|
||||
self.ActionLabel.setText(
|
||||
"执行步骤:" if isCond else "ELSE 执行步骤:"
|
||||
)
|
||||
@@ -0,0 +1,164 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Copyright (c) 2026 KenanZhu.
|
||||
All rights reserved.
|
||||
|
||||
This software is provided "as is", without any warranty of any kind.
|
||||
You may use, modify, and distribute this file under the terms of the MIT License.
|
||||
See the LICENSE file for details.
|
||||
"""
|
||||
"""
|
||||
Orchestration dialog for visually composing AutoScript scripts.
|
||||
"""
|
||||
from PySide6.QtCore import Slot
|
||||
from PySide6.QtWidgets import (
|
||||
QDialog,
|
||||
QDialogButtonBox,
|
||||
QFrame,
|
||||
QMessageBox,
|
||||
QPushButton,
|
||||
QScrollArea,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from gui.ALAutoScriptOrchDialog._helpers import VariableManager
|
||||
from gui.ALAutoScriptOrchDialog._blocks import ConditionalBlock
|
||||
|
||||
|
||||
class ALAutoScriptOrchDialog(QDialog):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent = None
|
||||
):
|
||||
|
||||
super().__init__(parent)
|
||||
self._blocks = []
|
||||
self._varMgr = VariableManager(self)
|
||||
|
||||
self.setupUi()
|
||||
self.connectSignals()
|
||||
self.addBlock()
|
||||
self.ScrollLayout.addStretch()
|
||||
|
||||
def setupUi(
|
||||
self
|
||||
):
|
||||
|
||||
self.setWindowTitle("AutoScript 指令编排 - AutoLibrary")
|
||||
self.setMinimumSize(640, 600)
|
||||
self.setModal(True)
|
||||
MainLayout = QVBoxLayout(self)
|
||||
Scroll = QScrollArea()
|
||||
Scroll.setWidgetResizable(True)
|
||||
Scroll.setFrameShape(QFrame.NoFrame)
|
||||
ScrollContent = QWidget()
|
||||
self.ScrollLayout = QVBoxLayout(ScrollContent)
|
||||
self.ScrollLayout.setSpacing(5)
|
||||
Scroll.setWidget(ScrollContent)
|
||||
MainLayout.addWidget(Scroll)
|
||||
self.AddBlockBtn = QPushButton("+ 添加判断块")
|
||||
self.AddBlockBtn.setFixedHeight(25)
|
||||
MainLayout.addWidget(self.AddBlockBtn)
|
||||
self.BtnBox = QDialogButtonBox(
|
||||
QDialogButtonBox.StandardButton.Ok |
|
||||
QDialogButtonBox.StandardButton.Cancel
|
||||
)
|
||||
self.BtnBox.button(QDialogButtonBox.StandardButton.Ok).setText("确定")
|
||||
self.BtnBox.button(QDialogButtonBox.StandardButton.Cancel).setText("取消")
|
||||
MainLayout.addWidget(self.BtnBox)
|
||||
|
||||
def connectSignals(
|
||||
self
|
||||
):
|
||||
|
||||
self.BtnBox.accepted.connect(self.onAccept)
|
||||
self.BtnBox.rejected.connect(self.reject)
|
||||
self.AddBlockBtn.clicked.connect(self.addBlock)
|
||||
|
||||
def updateBlockTypeRestrictions(
|
||||
self
|
||||
):
|
||||
|
||||
prevType = None
|
||||
for block in self._blocks:
|
||||
block.setPrevBlockType(prevType)
|
||||
prevType = block.getBlockType()
|
||||
|
||||
def addBlock(
|
||||
self
|
||||
):
|
||||
|
||||
Block = ConditionalBlock(
|
||||
len(self._blocks), self._varMgr, parent=self
|
||||
)
|
||||
Block.DeleteBlockBtn.clicked.connect(lambda: self.removeBlock(Block))
|
||||
Block.TypeCombo.currentIndexChanged.connect(self.updateBlockTypeRestrictions)
|
||||
Block.addActionStep()
|
||||
self._blocks.append(Block)
|
||||
self.updateBlockTypeRestrictions()
|
||||
if self.ScrollLayout.count() > 0:
|
||||
lastItem = self.ScrollLayout.itemAt(
|
||||
self.ScrollLayout.count() - 1
|
||||
)
|
||||
if lastItem and lastItem.spacerItem():
|
||||
self.ScrollLayout.insertWidget(
|
||||
self.ScrollLayout.count() - 1, Block
|
||||
)
|
||||
return
|
||||
self.ScrollLayout.addWidget(Block)
|
||||
|
||||
def removeBlock(
|
||||
self,
|
||||
block: ConditionalBlock
|
||||
):
|
||||
|
||||
if len(self._blocks) <= 1:
|
||||
QMessageBox.information(self, "提示", "至少保留一个判断块。")
|
||||
return
|
||||
if block in self._blocks:
|
||||
self._blocks.remove(block)
|
||||
self.ScrollLayout.removeWidget(block)
|
||||
block.hide()
|
||||
block.deleteLater()
|
||||
for i, blk in enumerate(self._blocks):
|
||||
blk.blockIndex = i
|
||||
if i == 0:
|
||||
blk.TypeCombo.setEnabled(False)
|
||||
blk.TypeCombo.setCurrentIndex(0)
|
||||
else:
|
||||
blk.TypeCombo.setEnabled(True)
|
||||
blk.refreshVarCombos()
|
||||
self.updateBlockTypeRestrictions()
|
||||
|
||||
def getScript(
|
||||
self
|
||||
) -> str:
|
||||
"""
|
||||
Generate the complete Lua script from all blocks.
|
||||
"""
|
||||
|
||||
parts = []
|
||||
prevType = None
|
||||
for block in self._blocks:
|
||||
blockType = block.getBlockType()
|
||||
if blockType == "IF" and prevType is not None:
|
||||
parts.append("end")
|
||||
lines = block.toScript()
|
||||
parts.extend(lines)
|
||||
prevType = blockType
|
||||
if self._blocks and self._blocks[0].getBlockType() == "IF":
|
||||
parts.append("end")
|
||||
return "\n".join(parts)
|
||||
|
||||
@Slot()
|
||||
def onAccept(
|
||||
self
|
||||
):
|
||||
|
||||
script = self.getScript().strip()
|
||||
if not script:
|
||||
QMessageBox.warning(self, "提示", "脚本内容为空,请添加至少一个操作步骤。")
|
||||
return
|
||||
self.accept()
|
||||
@@ -0,0 +1,516 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Copyright (c) 2026 KenanZhu.
|
||||
All rights reserved.
|
||||
|
||||
This software is provided "as is", without any warranty of any kind.
|
||||
You may use, modify, and distribute this file under the terms of the MIT License.
|
||||
See the LICENSE file for details.
|
||||
"""
|
||||
"""
|
||||
Helper utilities and constants for the AutoScript orchestration dialog.
|
||||
"""
|
||||
import re
|
||||
|
||||
from PySide6.QtCore import QObject
|
||||
from PySide6.QtWidgets import (
|
||||
QComboBox,
|
||||
QDateEdit,
|
||||
QDoubleSpinBox,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QSizePolicy,
|
||||
QSpinBox,
|
||||
QStackedWidget,
|
||||
QTimeEdit,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from autoscript import createAllVariablesTable
|
||||
|
||||
VARTYPE_INFOS = [
|
||||
# varType, isArithType
|
||||
("String", False),
|
||||
("Int", True),
|
||||
("Float", True),
|
||||
("Boolean", False),
|
||||
("Date", True),
|
||||
("Time", True),
|
||||
]
|
||||
|
||||
|
||||
def getTypeOrder(
|
||||
) -> list:
|
||||
|
||||
return [t for t, _ in VARTYPE_INFOS]
|
||||
|
||||
def getArithType(
|
||||
varType: str
|
||||
) -> bool:
|
||||
|
||||
for t, a in VARTYPE_INFOS:
|
||||
if t == varType:
|
||||
return a
|
||||
|
||||
def getPresetVars(
|
||||
) -> list:
|
||||
|
||||
return [
|
||||
{"name": name.upper(), "type": vtype, "display": display}
|
||||
for display, (name, vtype) in createAllVariablesTable().items()
|
||||
]
|
||||
|
||||
|
||||
COMPARE_OPTIONS = [
|
||||
("等于", "=="),
|
||||
("不等于", "~="),
|
||||
("大于", ">"),
|
||||
("小于", "<"),
|
||||
("大于等于", ">="),
|
||||
("小于等于", "<="),
|
||||
]
|
||||
LOGIC_OPTIONS = [
|
||||
("并且 (and)", "and"),
|
||||
("或者 (or)", "or"),
|
||||
]
|
||||
ACTION_OPTIONS = [
|
||||
("设置为", "set"),
|
||||
("增加", "add"),
|
||||
("减少", "sub"),
|
||||
]
|
||||
DATE_OPTIONS = [
|
||||
("前天", "day_before_yesterday"),
|
||||
("昨天", "yesterday"),
|
||||
("今天", "today"),
|
||||
("明天", "tomorrow"),
|
||||
("后天", "day_after_tomorrow")
|
||||
]
|
||||
DATE_OFFSET_OPTIONS = [
|
||||
("天", "days"),
|
||||
("周", "weeks"),
|
||||
# NOTE: "月" and "年" use fixed day counts (30 / 365), not calendar months/years,
|
||||
# because dateadd() works with second-level offsets (n * 86400).
|
||||
("月", "months"),
|
||||
("年", "years"),
|
||||
]
|
||||
|
||||
|
||||
class _DateInputContainer(QWidget):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent = None
|
||||
):
|
||||
|
||||
super().__init__(parent)
|
||||
self.setupUi()
|
||||
|
||||
def setupUi(
|
||||
self
|
||||
):
|
||||
|
||||
Layout = QHBoxLayout(self)
|
||||
Layout.setContentsMargins(0, 0, 0, 0)
|
||||
Layout.setSpacing(4)
|
||||
self._ModeCombo = QComboBox(self)
|
||||
self._ModeCombo.addItem("相对日期", "relative")
|
||||
self._ModeCombo.addItem("绝对日期", "absolute")
|
||||
self._ModeCombo.setFixedHeight(25)
|
||||
self._Stack = QStackedWidget(self)
|
||||
self._RelCombo = QComboBox(self)
|
||||
for display, data in DATE_OPTIONS:
|
||||
self._RelCombo.addItem(display, data)
|
||||
self._RelCombo.setFixedHeight(25)
|
||||
self._Stack.addWidget(self._RelCombo)
|
||||
self._DateEdit = QDateEdit(self)
|
||||
self._DateEdit.setDisplayFormat("yyyy-MM-dd")
|
||||
self._DateEdit.setCalendarPopup(True)
|
||||
self._DateEdit.setFixedHeight(25)
|
||||
self._Stack.addWidget(self._DateEdit)
|
||||
self._ModeCombo.currentIndexChanged.connect(
|
||||
lambda i: self._Stack.setCurrentIndex(i)
|
||||
)
|
||||
Layout.addWidget(self._ModeCombo)
|
||||
Layout.addWidget(self._Stack)
|
||||
Layout.addStretch()
|
||||
|
||||
def getValue(
|
||||
self
|
||||
) -> str:
|
||||
|
||||
mode = self._ModeCombo.currentData()
|
||||
if mode == "relative":
|
||||
return self._RelCombo.currentText()
|
||||
return self._DateEdit.date().toString("yyyy-MM-dd")
|
||||
|
||||
|
||||
class _TimeInputContainer(QWidget):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent = None
|
||||
):
|
||||
|
||||
super().__init__(parent)
|
||||
self._TimeEdit = QTimeEdit(self)
|
||||
self._TimeEdit.setDisplayFormat("HH:mm")
|
||||
self._TimeEdit.setFixedHeight(25)
|
||||
|
||||
Layout = QHBoxLayout(self)
|
||||
Layout.setContentsMargins(0, 0, 0, 0)
|
||||
Layout.addWidget(self._TimeEdit)
|
||||
|
||||
def getValue(
|
||||
self
|
||||
) -> str:
|
||||
|
||||
return self._TimeEdit.time().toString("HH:mm")
|
||||
|
||||
|
||||
class _DateOffsetContainer(QWidget):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent = None
|
||||
):
|
||||
|
||||
super().__init__(parent)
|
||||
self._SpinBox = QSpinBox(self)
|
||||
self._SpinBox.setRange(0, 99999)
|
||||
self._SpinBox.setFixedHeight(25)
|
||||
self._UnitCombo = QComboBox(self)
|
||||
for display, data in DATE_OFFSET_OPTIONS:
|
||||
self._UnitCombo.addItem(display, data)
|
||||
self._UnitCombo.setFixedHeight(25)
|
||||
|
||||
Layout = QHBoxLayout(self)
|
||||
Layout.setContentsMargins(0, 0, 0, 0)
|
||||
Layout.setSpacing(4)
|
||||
Layout.addWidget(self._SpinBox)
|
||||
Layout.addWidget(self._UnitCombo)
|
||||
Layout.addStretch()
|
||||
|
||||
def getValue(
|
||||
self
|
||||
) -> str:
|
||||
|
||||
return str(self.getOffsetDays())
|
||||
|
||||
def getOffsetDays(
|
||||
self
|
||||
) -> int:
|
||||
|
||||
val = self._SpinBox.value()
|
||||
unit = self._UnitCombo.currentData()
|
||||
if unit == "weeks":
|
||||
return val*7
|
||||
if unit == "months":
|
||||
return val*30
|
||||
if unit == "years":
|
||||
return val*365
|
||||
return val
|
||||
|
||||
|
||||
class _TimeOffsetContainer(QWidget):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent = None
|
||||
):
|
||||
|
||||
super().__init__(parent)
|
||||
self._SpinBox = QSpinBox(self)
|
||||
self._SpinBox.setRange(0, 99999)
|
||||
self._SpinBox.setSuffix(" 小时")
|
||||
self._SpinBox.setFixedHeight(25)
|
||||
|
||||
Layout = QHBoxLayout(self)
|
||||
Layout.setContentsMargins(0, 0, 0, 0)
|
||||
Layout.addWidget(self._SpinBox)
|
||||
|
||||
def getValue(
|
||||
self
|
||||
) -> str:
|
||||
|
||||
return str(self.getOffsetHours())
|
||||
|
||||
def getOffsetHours(
|
||||
self
|
||||
) -> int:
|
||||
|
||||
return self._SpinBox.value()
|
||||
|
||||
|
||||
class VariableManager(QObject):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent = None
|
||||
):
|
||||
|
||||
super().__init__(parent)
|
||||
self._vars = []
|
||||
self._nameMap = {}
|
||||
|
||||
self.initPresetVars()
|
||||
|
||||
def initPresetVars(
|
||||
self
|
||||
):
|
||||
|
||||
for p in getPresetVars():
|
||||
entry = {"name": p["name"], "type": p["type"], "display": p["display"]}
|
||||
self._vars.append(entry)
|
||||
self._nameMap[p["name"]] = entry
|
||||
|
||||
def getInfoByName(
|
||||
self,
|
||||
name: str
|
||||
):
|
||||
|
||||
return self._nameMap.get(name.upper().strip())
|
||||
|
||||
def populateCombo(
|
||||
self,
|
||||
combo: QComboBox
|
||||
):
|
||||
|
||||
currentData = combo.currentData()
|
||||
combo.blockSignals(True)
|
||||
combo.clear()
|
||||
for entry in self._vars:
|
||||
combo.addItem(
|
||||
entry["display"],
|
||||
(entry["name"], entry["type"])
|
||||
)
|
||||
if currentData:
|
||||
for i in range(combo.count()):
|
||||
d = combo.itemData(i)
|
||||
if d and d[0] == currentData[0]:
|
||||
combo.setCurrentIndex(i)
|
||||
break
|
||||
combo.blockSignals(False)
|
||||
|
||||
|
||||
def makeValueWidget(
|
||||
var_type: str,
|
||||
parent: QWidget = None
|
||||
) -> QWidget:
|
||||
|
||||
if var_type == "Int":
|
||||
w = QSpinBox(parent)
|
||||
w.setRange(-999999, 999999)
|
||||
w.setFixedHeight(25)
|
||||
w.setMinimumWidth(100)
|
||||
return w
|
||||
if var_type == "Float":
|
||||
w = QDoubleSpinBox(parent)
|
||||
w.setRange(-999999.0, 999999.0)
|
||||
w.setDecimals(2)
|
||||
w.setFixedHeight(25)
|
||||
w.setMinimumWidth(100)
|
||||
return w
|
||||
if var_type == "String":
|
||||
w = QLineEdit(parent)
|
||||
w.setPlaceholderText("输入值")
|
||||
w.setFixedHeight(25)
|
||||
w.setMinimumWidth(120)
|
||||
return w
|
||||
if var_type == "Boolean":
|
||||
w = QComboBox(parent)
|
||||
w.addItem("是 (true)", "true")
|
||||
w.addItem("否 (false)", "false")
|
||||
w.setFixedHeight(25)
|
||||
w.setMinimumWidth(100)
|
||||
return w
|
||||
if var_type == "Date":
|
||||
return _DateInputContainer(parent)
|
||||
if var_type == "Time":
|
||||
return _TimeInputContainer(parent)
|
||||
w = QLineEdit(parent)
|
||||
w.setPlaceholderText("输入值")
|
||||
w.setFixedHeight(25)
|
||||
w.setMinimumWidth(120)
|
||||
return w
|
||||
|
||||
def makeOffsetWidget(
|
||||
var_type: str,
|
||||
parent: QWidget = None
|
||||
) -> QWidget:
|
||||
|
||||
if var_type == "Int":
|
||||
w = QSpinBox(parent)
|
||||
w.setRange(-999999, 999999)
|
||||
w.setFixedHeight(25)
|
||||
w.setMinimumWidth(100)
|
||||
return w
|
||||
if var_type == "Float":
|
||||
w = QDoubleSpinBox(parent)
|
||||
w.setRange(-999999.0, 999999.0)
|
||||
w.setDecimals(2)
|
||||
w.setFixedHeight(25)
|
||||
w.setMinimumWidth(100)
|
||||
return w
|
||||
if var_type == "Date":
|
||||
return _DateOffsetContainer(parent)
|
||||
if var_type == "Time":
|
||||
return _TimeOffsetContainer(parent)
|
||||
w = QLabel("(不支持该操作)", parent)
|
||||
w.setFixedHeight(25)
|
||||
return w
|
||||
|
||||
def makeVarRefCombo(
|
||||
parent: QWidget = None
|
||||
) -> QComboBox:
|
||||
|
||||
Cb = QComboBox(parent)
|
||||
Cb.setFixedHeight(25)
|
||||
Cb.setMinimumWidth(120)
|
||||
Cb.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
|
||||
return Cb
|
||||
|
||||
def makeComboWidget(
|
||||
items,
|
||||
min_width: int = 80,
|
||||
parent: QWidget = None
|
||||
) -> QComboBox:
|
||||
|
||||
Cb = QComboBox(parent)
|
||||
for display, data in items:
|
||||
Cb.addItem(display, data)
|
||||
Cb.setFixedHeight(25)
|
||||
Cb.setMinimumWidth(min_width)
|
||||
return Cb
|
||||
|
||||
def makeLabel(
|
||||
text: str,
|
||||
parent: QWidget = None,
|
||||
width: int = None
|
||||
) -> QLabel:
|
||||
|
||||
Lbl = QLabel(text, parent)
|
||||
Lbl.setFixedHeight(25)
|
||||
if width:
|
||||
Lbl.setFixedWidth(width)
|
||||
return Lbl
|
||||
|
||||
def getValueFromWidget(
|
||||
w: QWidget
|
||||
) -> str:
|
||||
|
||||
if hasattr(w, "getValue"):
|
||||
return w.getValue()
|
||||
if isinstance(w, QTimeEdit):
|
||||
return w.time().toString("HH:mm")
|
||||
if isinstance(w, QDateEdit):
|
||||
return w.date().toString("yyyy-MM-dd")
|
||||
if isinstance(w, QComboBox):
|
||||
return w.currentData() or w.currentText()
|
||||
if isinstance(w, QSpinBox):
|
||||
return str(w.value())
|
||||
if isinstance(w, QDoubleSpinBox):
|
||||
return str(w.value())
|
||||
if isinstance(w, QLineEdit):
|
||||
return w.text()
|
||||
return ""
|
||||
|
||||
def encodeValueStr(
|
||||
raw_value: str,
|
||||
var_type: str
|
||||
) -> str:
|
||||
"""
|
||||
Encode a raw widget value as a Lua expression.
|
||||
|
||||
Arithmetic expressions (A + B) are passed through for numeric types;
|
||||
Date/Time arithmetic is translated to ``dateadd()`` / ``timeadd()`` calls.
|
||||
"""
|
||||
|
||||
if var_type in ("Date", "Time"):
|
||||
return encodeDateOrTime(str(raw_value), var_type)
|
||||
if isinstance(raw_value, bool):
|
||||
return "true" if raw_value else "false"
|
||||
s = str(raw_value)
|
||||
if isArithExpr(s):
|
||||
return s
|
||||
if var_type == "Boolean":
|
||||
up = s.upper().strip()
|
||||
if up in ("TRUE", "FALSE"):
|
||||
return up.lower()
|
||||
return "true" if raw_value else "false"
|
||||
if var_type == "String":
|
||||
escaped = s.replace("\\", "\\\\").replace('"', '\\"')
|
||||
return f'"{escaped}"'
|
||||
return s
|
||||
|
||||
def encodeDateOrTime(
|
||||
raw_value: str,
|
||||
var_type: str
|
||||
) -> str:
|
||||
"""
|
||||
Translate a date/time widget value into a Lua expression.
|
||||
"""
|
||||
|
||||
s = raw_value.strip()
|
||||
up = s.upper()
|
||||
# Input comes from widget values — single binary expressions only (e.g. "A + 3",
|
||||
# "CURRENT_DATE + 5"). Multi-operator expressions are not produced by the UI.
|
||||
m_arith_spaced = re.match(r'^(.+?)\s+([+-])\s+(.+)$', s)
|
||||
m_arith_nospace = re.match(r'^([A-Za-z_]\w*)([+-])(\d+|[A-Za-z_]\w*)$', s)
|
||||
m_arith = m_arith_spaced or m_arith_nospace
|
||||
if m_arith:
|
||||
left = m_arith.group(1).strip().upper()
|
||||
sign = m_arith.group(2)
|
||||
right = m_arith.group(3).strip()
|
||||
operand = right if sign == "+" else f"-{right}"
|
||||
if left == "CURRENT_DATE":
|
||||
return f"dateadd(datenow(), {operand})"
|
||||
if left == "CURRENT_TIME":
|
||||
return f"timeadd(timenow(), {operand})"
|
||||
if var_type == "Date":
|
||||
return f"dateadd({left}, {operand})"
|
||||
if var_type == "Time":
|
||||
return f"timeadd({left}, {operand})"
|
||||
return f"{left} {sign} {right}"
|
||||
if up == "CURRENT_DATE":
|
||||
return "datenow()"
|
||||
if up == "CURRENT_TIME":
|
||||
return "timenow()"
|
||||
_REL_MAP = {
|
||||
"前天": "dateadd(datenow(), -2)",
|
||||
"昨天": "dateadd(datenow(), -1)",
|
||||
"今天": "datenow()",
|
||||
"明天": "dateadd(datenow(), 1)",
|
||||
"后天": "dateadd(datenow(), 2)",
|
||||
}
|
||||
if s in _REL_MAP:
|
||||
return _REL_MAP[s]
|
||||
if var_type == "Date":
|
||||
m_date = re.match(r"^(\d{4})-(\d{2})-(\d{2})$", s)
|
||||
if m_date:
|
||||
y, m, d = int(m_date.group(1)), int(m_date.group(2)), int(m_date.group(3))
|
||||
return f"date({y}, {m}, {d})"
|
||||
if var_type == "Time":
|
||||
m_time = re.match(r"^(\d{1,2}):(\d{2})$", s)
|
||||
if m_time:
|
||||
h, m = int(m_time.group(1)), int(m_time.group(2))
|
||||
return f"time({h}, {m})"
|
||||
if re.match(r"^[+-]?\d+$", s):
|
||||
return s
|
||||
if re.match(r"^[A-Za-z_]\w*$", s):
|
||||
return s
|
||||
return f'"{s}"'
|
||||
|
||||
# Pre-compiled patterns for detecting arithmetic expressions (A + B / A - B)
|
||||
_RE_ARITH_SPACED = re.compile(r'^(.+?)\s+([+-])\s+(.+)$')
|
||||
_RE_ARITH_NOSPACE = re.compile(r'^([A-Za-z_]\w*)([+-])(\d+|[A-Za-z_]\w*)$')
|
||||
|
||||
def isArithExpr(
|
||||
expr: str
|
||||
) -> bool:
|
||||
"""
|
||||
Return True if expr looks like a two-operand arithmetic expression (A ± B).
|
||||
"""
|
||||
|
||||
s = expr.strip()
|
||||
return bool(_RE_ARITH_SPACED.match(s) or _RE_ARITH_NOSPACE.match(s))
|
||||
@@ -0,0 +1,468 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Copyright (c) 2026 KenanZhu.
|
||||
All rights reserved.
|
||||
|
||||
This software is provided "as is", without any warranty of any kind.
|
||||
You may use, modify, and distribute this file under the terms of the MIT License.
|
||||
See the LICENSE file for details.
|
||||
"""
|
||||
"""
|
||||
Widget components for the AutoScript orchestration dialog.
|
||||
"""
|
||||
from PySide6.QtCore import Slot
|
||||
from PySide6.QtWidgets import (
|
||||
QComboBox,
|
||||
QFrame,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QPushButton,
|
||||
QSizePolicy,
|
||||
QStackedWidget
|
||||
)
|
||||
|
||||
from gui.ALAutoScriptOrchDialog._helpers import (
|
||||
ACTION_OPTIONS,
|
||||
COMPARE_OPTIONS,
|
||||
LOGIC_OPTIONS,
|
||||
encodeValueStr,
|
||||
getPresetVars,
|
||||
getTypeOrder,
|
||||
getValueFromWidget,
|
||||
getArithType,
|
||||
makeComboWidget,
|
||||
makeLabel,
|
||||
makeOffsetWidget,
|
||||
makeValueWidget,
|
||||
makeVarRefCombo,
|
||||
)
|
||||
|
||||
|
||||
class ConditionRowFrame(QFrame):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
varMgr,
|
||||
parentBlockIndex: int = 0,
|
||||
isFirst: bool = False,
|
||||
parent = None
|
||||
):
|
||||
|
||||
super().__init__(parent)
|
||||
self._varMgr = varMgr
|
||||
self._blockIndex = parentBlockIndex
|
||||
self._isFirst = isFirst
|
||||
self._isBoolMode = False
|
||||
self._rawRhsExpr = ""
|
||||
|
||||
self.setupUi()
|
||||
self.connectSignals()
|
||||
|
||||
def setupUi(
|
||||
self
|
||||
):
|
||||
|
||||
self.setUpdatesEnabled(False)
|
||||
self.setFrameShape(QFrame.StyledPanel)
|
||||
self.setFrameShadow(QFrame.Raised)
|
||||
self.setFixedHeight(32)
|
||||
Layout = QHBoxLayout(self)
|
||||
Layout.setContentsMargins(2, 2, 2, 2)
|
||||
Layout.setSpacing(4)
|
||||
if self._isFirst:
|
||||
self.LogicCombo = None
|
||||
else:
|
||||
self.LogicCombo = makeComboWidget(LOGIC_OPTIONS, min_width=110, parent=self)
|
||||
Layout.addWidget(self.LogicCombo)
|
||||
self.LeftVarCombo = QComboBox(self)
|
||||
self.LeftVarCombo.setFixedHeight(25)
|
||||
self.LeftVarCombo.setMinimumWidth(120)
|
||||
self.LeftVarCombo.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
|
||||
self.populateLeftVarCombo()
|
||||
Layout.addWidget(self.LeftVarCombo)
|
||||
self.OpCombo = makeComboWidget(COMPARE_OPTIONS, min_width=80, parent=self)
|
||||
Layout.addWidget(self.OpCombo)
|
||||
self._CompTypeCombo = makeComboWidget([
|
||||
("特定值", "literal"),
|
||||
("变量", "variable"),
|
||||
], min_width=70, parent=self)
|
||||
Layout.addWidget(self._CompTypeCombo)
|
||||
self.RhsStack = QStackedWidget(self)
|
||||
self.RhsStack.setFixedHeight(25)
|
||||
self.initLiteralStack()
|
||||
self.RhsVarCombo = makeVarRefCombo(self)
|
||||
self.RhsStack.addWidget(self.RhsVarCombo)
|
||||
self.RhsStack.setCurrentIndex(0)
|
||||
Layout.addWidget(self.RhsStack)
|
||||
if not self._isFirst:
|
||||
self.DeleteBtn = QPushButton("×", self)
|
||||
self.DeleteBtn.setFixedSize(25, 25)
|
||||
self.DeleteBtn.setStyleSheet("color: red; font-weight: bold;")
|
||||
Layout.addWidget(self.DeleteBtn)
|
||||
else:
|
||||
self.DeleteBtn = None
|
||||
Layout.addStretch()
|
||||
self.setUpdatesEnabled(True)
|
||||
|
||||
def populateLeftVarCombo(
|
||||
self
|
||||
):
|
||||
|
||||
wasBool = self._isBoolMode
|
||||
boolName = None
|
||||
if wasBool:
|
||||
data = self.LeftVarCombo.currentData()
|
||||
if data:
|
||||
boolName = data[0]
|
||||
self._varMgr.populateCombo(self.LeftVarCombo)
|
||||
# Append boolean literal sentinels at the end
|
||||
self.LeftVarCombo.insertSeparator(self.LeftVarCombo.count())
|
||||
self.LeftVarCombo.addItem("true", ("true", "Boolean"))
|
||||
self.LeftVarCombo.addItem("false", ("false", "Boolean"))
|
||||
if wasBool and boolName:
|
||||
for ci in range(self.LeftVarCombo.count()):
|
||||
d = self.LeftVarCombo.itemData(ci)
|
||||
if d and d[0] == boolName:
|
||||
self.LeftVarCombo.setCurrentIndex(ci)
|
||||
break
|
||||
|
||||
def populateRHSVarCombo(
|
||||
self
|
||||
):
|
||||
|
||||
self._varMgr.populateCombo(self.RhsVarCombo)
|
||||
|
||||
def initLiteralStack(
|
||||
self
|
||||
):
|
||||
|
||||
self.LiteralStack = QStackedWidget(self)
|
||||
self.LiteralStack.setFixedHeight(25)
|
||||
self._literalWidgets = {}
|
||||
for vt in getTypeOrder():
|
||||
W = makeValueWidget(vt, self.LiteralStack)
|
||||
self._literalWidgets[vt] = W
|
||||
self.LiteralStack.addWidget(W)
|
||||
self.LiteralStack.setCurrentWidget(self._literalWidgets.get("String"))
|
||||
self.RhsStack.addWidget(self.LiteralStack)
|
||||
|
||||
def connectSignals(
|
||||
self
|
||||
):
|
||||
|
||||
self.LeftVarCombo.currentIndexChanged.connect(self.onLeftVarChanged)
|
||||
self._CompTypeCombo.currentIndexChanged.connect(self.onCompTypeChanged)
|
||||
|
||||
def getLogic(
|
||||
self
|
||||
) -> str:
|
||||
|
||||
return self.LogicCombo.currentData() if self.LogicCombo else ""
|
||||
|
||||
def updateRHSLiteralWidget(
|
||||
self,
|
||||
vartype: str
|
||||
):
|
||||
|
||||
if vartype not in self._literalWidgets:
|
||||
vartype = "String"
|
||||
self.LiteralStack.setCurrentWidget(self._literalWidgets[vartype])
|
||||
|
||||
def toScript(
|
||||
self
|
||||
) -> str:
|
||||
|
||||
data = self.LeftVarCombo.currentData()
|
||||
if self._isBoolMode and data:
|
||||
return data[0]
|
||||
if not data:
|
||||
return ""
|
||||
name, vartype = data
|
||||
# CURRENT_DATE / CURRENT_TIME map to datenow() / timenow()
|
||||
if name == "CURRENT_DATE":
|
||||
name = "datenow()"
|
||||
elif name == "CURRENT_TIME":
|
||||
name = "timenow()"
|
||||
opSym = self.OpCombo.currentData()
|
||||
if self._rawRhsExpr:
|
||||
return f"{name} {opSym} {self._rawRhsExpr}"
|
||||
isVarRef = (self._CompTypeCombo.currentData() == "variable")
|
||||
if isVarRef:
|
||||
rd = self.RhsVarCombo.currentData()
|
||||
if rd:
|
||||
rhsName = rd[0]
|
||||
if rhsName == "CURRENT_DATE":
|
||||
rhsName = "datenow()"
|
||||
elif rhsName == "CURRENT_TIME":
|
||||
rhsName = "timenow()"
|
||||
return f"{name} {opSym} {rhsName}"
|
||||
rhsText = self.RhsVarCombo.currentText().strip()
|
||||
if rhsText:
|
||||
return f"{name} {opSym} {rhsText}"
|
||||
return ""
|
||||
w = self._literalWidgets.get(vartype)
|
||||
if w:
|
||||
rawVal = getValueFromWidget(w)
|
||||
encoded = encodeValueStr(rawVal, vartype)
|
||||
return f"{name} {opSym} {encoded}"
|
||||
return ""
|
||||
|
||||
def refreshVarCombos(
|
||||
self
|
||||
):
|
||||
|
||||
self.populateLeftVarCombo()
|
||||
self.populateRHSVarCombo()
|
||||
|
||||
@Slot(int)
|
||||
def onLeftVarChanged(
|
||||
self,
|
||||
idx
|
||||
):
|
||||
|
||||
self._rawRhsExpr = ""
|
||||
if idx < 0:
|
||||
return
|
||||
data = self.LeftVarCombo.itemData(idx)
|
||||
if not data:
|
||||
return
|
||||
name, vartype = data
|
||||
isBool = name in ("true", "false")
|
||||
self._isBoolMode = isBool
|
||||
self.OpCombo.setVisible(not isBool)
|
||||
self._CompTypeCombo.setVisible(not isBool)
|
||||
self.RhsStack.setVisible(not isBool)
|
||||
if not isBool:
|
||||
self.updateRHSLiteralWidget(vartype)
|
||||
|
||||
@Slot(int)
|
||||
def onCompTypeChanged(
|
||||
self,
|
||||
idx
|
||||
):
|
||||
|
||||
self._rawRhsExpr = ""
|
||||
isVar = (self._CompTypeCombo.currentData() == "variable")
|
||||
self.RhsStack.setCurrentIndex(1 if isVar else 0)
|
||||
if isVar:
|
||||
self.populateRHSVarCombo()
|
||||
|
||||
|
||||
class ActionStepFrame(QFrame):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
varMgr,
|
||||
parentBlockIndex: int = 0,
|
||||
parent = None
|
||||
):
|
||||
|
||||
super().__init__(parent)
|
||||
self._varMgr = varMgr
|
||||
self._blockIndex = parentBlockIndex
|
||||
self._currentTargetType = "String"
|
||||
|
||||
self.setupUi()
|
||||
self.connectSignals()
|
||||
|
||||
def setupUi(
|
||||
self
|
||||
):
|
||||
|
||||
self.setUpdatesEnabled(False)
|
||||
self.setFrameShape(QFrame.StyledPanel)
|
||||
self.setFrameShadow(QFrame.Raised)
|
||||
self.setFixedHeight(35)
|
||||
Layout = QHBoxLayout(self)
|
||||
Layout.setContentsMargins(2, 2, 2, 2)
|
||||
Layout.setSpacing(4)
|
||||
self.OpTypeCombo = makeComboWidget(ACTION_OPTIONS, min_width=70, parent=self)
|
||||
Layout.addWidget(self.OpTypeCombo)
|
||||
Layout.addWidget(makeLabel("设置", self))
|
||||
self.TargetCombo = QComboBox(self)
|
||||
self.TargetCombo.setFixedHeight(25)
|
||||
self.TargetCombo.setMinimumWidth(120)
|
||||
self.populateTargetCombo()
|
||||
Layout.addWidget(self.TargetCombo)
|
||||
Layout.addWidget(makeLabel("为", self))
|
||||
self.ValueSrcCombo = makeComboWidget([
|
||||
("特定值", "literal"),
|
||||
("变量", "variable"),
|
||||
], min_width=70, parent=self)
|
||||
Layout.addWidget(self.ValueSrcCombo)
|
||||
self.ValueStack = QStackedWidget(self)
|
||||
self.ValueStack.setFixedHeight(25)
|
||||
self.initValueStacks()
|
||||
Layout.addWidget(self.ValueStack)
|
||||
self.ExistingVarCombo = makeVarRefCombo(self)
|
||||
self.ExistingVarCombo.setVisible(False)
|
||||
Layout.addWidget(self.ExistingVarCombo)
|
||||
self.DeleteBtn = QPushButton("×", self)
|
||||
self.DeleteBtn.setFixedSize(25, 25)
|
||||
self.DeleteBtn.setStyleSheet("color: red; font-weight: bold;")
|
||||
Layout.addWidget(self.DeleteBtn)
|
||||
self.setUpdatesEnabled(True)
|
||||
|
||||
def populateTargetCombo(
|
||||
self
|
||||
):
|
||||
|
||||
self.TargetCombo.blockSignals(True)
|
||||
self.TargetCombo.clear()
|
||||
for p in getPresetVars():
|
||||
if p["name"] in ("CURRENT_TIME", "CURRENT_DATE"):
|
||||
continue
|
||||
info = self._varMgr.getInfoByName(p["name"])
|
||||
if info:
|
||||
self.TargetCombo.addItem(
|
||||
info["display"],
|
||||
(info["name"], info["type"])
|
||||
)
|
||||
self.TargetCombo.blockSignals(False)
|
||||
|
||||
def initValueStacks(
|
||||
self
|
||||
):
|
||||
|
||||
self._literalWidgets = {}
|
||||
self._offsetWidgets = {}
|
||||
for vt in getTypeOrder():
|
||||
self._literalWidgets[vt] = makeValueWidget(vt, self.ValueStack)
|
||||
self.ValueStack.addWidget(self._literalWidgets[vt])
|
||||
if getArithType(vt):
|
||||
self._offsetWidgets[vt] = makeOffsetWidget(vt, self.ValueStack)
|
||||
self.ValueStack.addWidget(self._offsetWidgets[vt])
|
||||
else:
|
||||
Lbl = QLabel("(不支持该操作)", self.ValueStack)
|
||||
Lbl.setFixedHeight(25)
|
||||
self._offsetWidgets[vt] = Lbl
|
||||
self.ValueStack.addWidget(Lbl)
|
||||
|
||||
def connectSignals(
|
||||
self
|
||||
):
|
||||
|
||||
self.OpTypeCombo.currentIndexChanged.connect(self.onOpTypeChanged)
|
||||
self.TargetCombo.currentIndexChanged.connect(self.onTargetChanged)
|
||||
self.ValueSrcCombo.currentIndexChanged.connect(self.onValueSrcChanged)
|
||||
|
||||
def getTargetName(
|
||||
self
|
||||
) -> str:
|
||||
|
||||
data = self.TargetCombo.currentData()
|
||||
return data[0] if data else ""
|
||||
|
||||
def updateValueWidget(
|
||||
self
|
||||
):
|
||||
|
||||
op = self.OpTypeCombo.currentData()
|
||||
isArith = (op in ("add", "sub"))
|
||||
actualType = self._currentTargetType
|
||||
if isArith and actualType in self._offsetWidgets:
|
||||
self.ValueStack.setCurrentWidget(self._offsetWidgets[actualType])
|
||||
elif actualType in self._literalWidgets:
|
||||
self.ValueStack.setCurrentWidget(self._literalWidgets[actualType])
|
||||
else:
|
||||
self.ValueStack.setCurrentWidget(self._literalWidgets.get("String"))
|
||||
|
||||
def toScript(
|
||||
self
|
||||
) -> str:
|
||||
"""
|
||||
Generate a single line of Lua script from the current widget state.
|
||||
"""
|
||||
|
||||
target = self.getTargetName()
|
||||
op = self.OpTypeCombo.currentData()
|
||||
if op == "pass":
|
||||
return " -- pass"
|
||||
if not target:
|
||||
return ""
|
||||
rawVal = self.getValueRaw()
|
||||
vartype = self._currentTargetType
|
||||
if op == "set":
|
||||
encoded = encodeValueStr(rawVal, vartype)
|
||||
return f" {target} = {encoded}"
|
||||
elif op == "add":
|
||||
if vartype == "Date" and hasattr(self.ValueStack.currentWidget(), "getOffsetDays"):
|
||||
days = self.ValueStack.currentWidget().getOffsetDays()
|
||||
return f" {target} = dateadd({target}, {days})"
|
||||
if vartype == "Time" and hasattr(self.ValueStack.currentWidget(), "getOffsetHours"):
|
||||
hours = self.ValueStack.currentWidget().getOffsetHours()
|
||||
return f" {target} = timeadd({target}, {hours})"
|
||||
return f" {target} = {target} + {rawVal}"
|
||||
elif op == "sub":
|
||||
if vartype == "Date" and hasattr(self.ValueStack.currentWidget(), "getOffsetDays"):
|
||||
days = self.ValueStack.currentWidget().getOffsetDays()
|
||||
return f" {target} = dateadd({target}, -{days})"
|
||||
if vartype == "Time" and hasattr(self.ValueStack.currentWidget(), "getOffsetHours"):
|
||||
hours = self.ValueStack.currentWidget().getOffsetHours()
|
||||
return f" {target} = timeadd({target}, -{hours})"
|
||||
return f" {target} = {target} - {rawVal}"
|
||||
return ""
|
||||
|
||||
def getValueRaw(
|
||||
self
|
||||
) -> str:
|
||||
|
||||
if self.ValueSrcCombo.currentData() == "variable":
|
||||
data = self.ExistingVarCombo.currentData()
|
||||
return data[0] if data else ""
|
||||
w = self.ValueStack.currentWidget()
|
||||
if w:
|
||||
return getValueFromWidget(w)
|
||||
return ""
|
||||
|
||||
def refreshVarCombos(
|
||||
self
|
||||
):
|
||||
|
||||
currentData = self.TargetCombo.currentData()
|
||||
self.populateTargetCombo()
|
||||
if currentData:
|
||||
for i in range(self.TargetCombo.count()):
|
||||
d = self.TargetCombo.itemData(i)
|
||||
if d and d[0] == currentData[0]:
|
||||
self.TargetCombo.setCurrentIndex(i)
|
||||
break
|
||||
self._varMgr.populateCombo(self.ExistingVarCombo)
|
||||
|
||||
@Slot(int)
|
||||
def onTargetChanged(
|
||||
self,
|
||||
idx
|
||||
):
|
||||
|
||||
if idx < 0:
|
||||
return
|
||||
data = self.TargetCombo.itemData(idx)
|
||||
if not data:
|
||||
return
|
||||
_, vartype = data
|
||||
self._currentTargetType = vartype
|
||||
self.updateValueWidget()
|
||||
self.onValueSrcChanged(self.ValueSrcCombo.currentIndex())
|
||||
|
||||
@Slot(int)
|
||||
def onOpTypeChanged(
|
||||
self,
|
||||
idx
|
||||
):
|
||||
|
||||
self.updateValueWidget()
|
||||
|
||||
@Slot(int)
|
||||
def onValueSrcChanged(
|
||||
self,
|
||||
idx
|
||||
):
|
||||
|
||||
isVar = (self.ValueSrcCombo.currentData() == "variable")
|
||||
self.ValueStack.setVisible(not isVar)
|
||||
self.ExistingVarCombo.setVisible(isVar)
|
||||
if isVar:
|
||||
self._varMgr.populateCombo(self.ExistingVarCombo)
|
||||
else:
|
||||
self.updateValueWidget()
|
||||
@@ -1,39 +1,51 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Copyright (c) 2025 - 2026 KenanZhu.
|
||||
Copyright (c) 2025 KenanZhu.
|
||||
All rights reserved.
|
||||
|
||||
This software is provided "as is", without any warranty of any kind.
|
||||
You may use, modify, and distribute this file under the terms of the MIT License.
|
||||
See the LICENSE file for details.
|
||||
"""
|
||||
import sys
|
||||
import time
|
||||
import queue
|
||||
|
||||
from PySide6.QtCore import (
|
||||
Qt, Signal, Slot, QDir, QFileInfo, QTimer, QUrl,
|
||||
)
|
||||
from PySide6.QtWidgets import (
|
||||
QMainWindow, QMenu, QSystemTrayIcon
|
||||
QTimer,
|
||||
QUrl,
|
||||
Qt,
|
||||
Signal,
|
||||
Slot
|
||||
)
|
||||
from PySide6.QtGui import (
|
||||
QTextCursor, QCloseEvent, QFont, QIcon, QDesktopServices
|
||||
QCloseEvent,
|
||||
QDesktopServices,
|
||||
QFont,
|
||||
QIcon,
|
||||
QTextCursor
|
||||
)
|
||||
from PySide6.QtWidgets import (
|
||||
QMainWindow,
|
||||
QMenu,
|
||||
QMessageBox,
|
||||
QSystemTrayIcon
|
||||
)
|
||||
|
||||
from base.MsgBase import MsgBase
|
||||
|
||||
from gui.resources.ui.Ui_ALMainWindow import Ui_ALMainWindow
|
||||
from gui.ALConfigWidget import ALConfigWidget
|
||||
from gui.ALTimerTaskManageWidget import ALTimerTaskManageWidget
|
||||
from gui.ALAboutDialog import ALAboutDialog
|
||||
from gui.ALMainWorkers import TimerTaskWorker, AutoLibWorker
|
||||
|
||||
from gui.ALConfigWidget import ALConfigWidget
|
||||
from gui.ALMainWorkers import (
|
||||
AutoLibWorker,
|
||||
TimerTaskWorker
|
||||
)
|
||||
from gui.ALTimerTaskManageWidget import ALTimerTaskManageWidget
|
||||
from gui.resources import ALResource
|
||||
from gui.resources.ui.Ui_ALMainWindow import Ui_ALMainWindow
|
||||
from managers.config.ConfigUtils import ConfigUtils
|
||||
|
||||
|
||||
class ALMainWindow(MsgBase, QMainWindow, Ui_ALMainWindow):
|
||||
|
||||
# signal : timer task
|
||||
timerTaskIsRunning = Signal(dict)
|
||||
timerTaskIsExecuted = Signal(dict)
|
||||
timerTaskIsError = Signal(dict)
|
||||
@@ -45,14 +57,8 @@ class ALMainWindow(MsgBase, QMainWindow, Ui_ALMainWindow):
|
||||
MsgBase.__init__(self, queue.Queue(), queue.Queue())
|
||||
QMainWindow.__init__(self)
|
||||
self.__timer_task_queue = queue.Queue()
|
||||
script_path = sys.executable
|
||||
script_dir = QFileInfo(script_path).absoluteDir()
|
||||
self.__config_paths = {
|
||||
"run": QDir.toNativeSeparators(script_dir.absoluteFilePath("run.json")),
|
||||
"user": QDir.toNativeSeparators(script_dir.absoluteFilePath("user.json")),
|
||||
"timer_task": QDir.toNativeSeparators(script_dir.absoluteFilePath("timer_task.json")),
|
||||
}
|
||||
self.__alTimerTaskWidget = None
|
||||
self.__config_paths = ConfigUtils.getAutomationConfigPaths()
|
||||
self.__alTimerTaskManageWidget = None
|
||||
self.__alConfigWidget = None
|
||||
self.__auto_lib_thread = None
|
||||
self.__current_timer_task_thread = None
|
||||
@@ -64,69 +70,73 @@ class ALMainWindow(MsgBase, QMainWindow, Ui_ALMainWindow):
|
||||
self.connectSignals()
|
||||
self.startMsgPolling()
|
||||
self.startTimerTaskPolling()
|
||||
|
||||
self._showLog("主窗口初始化完成")
|
||||
|
||||
def modifyUi(
|
||||
self
|
||||
):
|
||||
|
||||
self.icon = QIcon(":/res/icon/icons/AutoLibrary_32x32.ico")
|
||||
self.setWindowIcon(self.icon)
|
||||
self.Icon = QIcon(":/res/icons/AutoLibrary_Logo_64.svg")
|
||||
self.setWindowIcon(self.Icon)
|
||||
self.MessageIOTextEdit.setFont(QFont("Courier New", 10))
|
||||
self.ManualAction.triggered.connect(self.onManualActionTriggered)
|
||||
self.AboutAction.triggered.connect(self.onAboutActionTriggered)
|
||||
|
||||
# initialize timer task widget, but not show it
|
||||
self.__alTimerTaskWidget = ALTimerTaskManageWidget(self, self.__config_paths["timer_task"])
|
||||
self.timerTaskIsRunning.connect(self.__alTimerTaskWidget.onTimerTaskIsRunning)
|
||||
self.timerTaskIsExecuted.connect(self.__alTimerTaskWidget.onTimerTaskIsExecuted)
|
||||
self.timerTaskIsError.connect(self.__alTimerTaskWidget.onTimerTaskIsError)
|
||||
self.__alTimerTaskWidget.timerTaskIsReady.connect(self.onTimerTaskIsReady)
|
||||
self.__alTimerTaskWidget.timerTaskManageWidgetClosed.connect(self.onTimerTaskWidgetClosed)
|
||||
self.__alTimerTaskWidget.setWindowFlags(Qt.WindowType.Window|Qt.WindowType.WindowCloseButtonHint)
|
||||
|
||||
try:
|
||||
self.__alTimerTaskManageWidget = ALTimerTaskManageWidget(self)
|
||||
except Exception as e:
|
||||
QMessageBox.critical(
|
||||
self,
|
||||
"错误 - AutoLibrary",
|
||||
f"初始化定时任务功能失败: \n{e}"
|
||||
)
|
||||
self.__alTimerTaskManageWidget = None
|
||||
self.TimerTaskManageWidgetButton.setEnabled(False)
|
||||
self.TimerTaskManageWidgetButton.setToolTip("定时任务功能初始化失败, 请检查配置文件。")
|
||||
return
|
||||
self.timerTaskIsRunning.connect(self.__alTimerTaskManageWidget.onTimerTaskIsRunning)
|
||||
self.timerTaskIsExecuted.connect(self.__alTimerTaskManageWidget.onTimerTaskIsExecuted)
|
||||
self.timerTaskIsError.connect(self.__alTimerTaskManageWidget.onTimerTaskIsError)
|
||||
self.__alTimerTaskManageWidget.timerTaskIsReady.connect(self.onTimerTaskIsReady)
|
||||
self.__alTimerTaskManageWidget.timerTaskManageWidgetIsClosed.connect(self.onTimerTaskManageWidgetClosed)
|
||||
self.__alTimerTaskManageWidget.setWindowFlags(Qt.WindowType.Window|Qt.WindowType.WindowCloseButtonHint)
|
||||
|
||||
def onAboutActionTriggered(
|
||||
self
|
||||
):
|
||||
|
||||
about_dialog = ALAboutDialog(self)
|
||||
about_dialog.exec()
|
||||
|
||||
AboutDialog = ALAboutDialog(self)
|
||||
AboutDialog.exec()
|
||||
|
||||
def onManualActionTriggered(
|
||||
self
|
||||
):
|
||||
|
||||
url = QUrl("https://www.autolibrary.top/manuals")
|
||||
QDesktopServices.openUrl(url)
|
||||
|
||||
Url = QUrl("https://www.autolibrary.kenanzhu.com/manuals")
|
||||
QDesktopServices.openUrl(Url)
|
||||
|
||||
def setupTray(
|
||||
self
|
||||
):
|
||||
|
||||
if not QSystemTrayIcon.isSystemTrayAvailable():
|
||||
self.showTraceSignal.emit(
|
||||
"系统不支持系统托盘功能, 无法创建系统托盘图标。"
|
||||
)
|
||||
self._showTrace("操作系统不支持系统托盘功能, 无法创建系统托盘图标", self.TraceLevel.WARNING)
|
||||
return
|
||||
self.TrayIcon = QSystemTrayIcon(self.icon, self)
|
||||
self.TrayIcon = QSystemTrayIcon(self.Icon, self)
|
||||
self.TrayIcon.setToolTip("AutoLibrary")
|
||||
|
||||
self.TrayMenu = QMenu()
|
||||
self.TrayMenu.addAction("显示主窗口", self.showNormal)
|
||||
self.TrayMenu.addAction("显示定时窗口", self.onTimerTaskWidgetButtonClicked)
|
||||
self.TrayMenu.addAction("显示定时窗口", self.onTimerTaskManageWidgetButtonClicked)
|
||||
self.TrayMenu.addAction("最小化到托盘", self.hideToTray)
|
||||
self.TrayMenu.addSeparator()
|
||||
self.TrayMenu.addAction("退出", self.close)
|
||||
self.TrayIcon.setContextMenu(self.TrayMenu)
|
||||
|
||||
self.TrayIcon.setContextMenu(self.TrayMenu)
|
||||
self.TrayIcon.activated.connect(self.onTrayIconActivated)
|
||||
self.TrayIcon.show()
|
||||
|
||||
|
||||
def hideToTray(
|
||||
self
|
||||
):
|
||||
@@ -139,7 +149,6 @@ class ALMainWindow(MsgBase, QMainWindow, Ui_ALMainWindow):
|
||||
2000
|
||||
)
|
||||
|
||||
|
||||
def onTrayIconActivated(
|
||||
self,
|
||||
reason: QSystemTrayIcon.ActivationReason
|
||||
@@ -148,24 +157,26 @@ class ALMainWindow(MsgBase, QMainWindow, Ui_ALMainWindow):
|
||||
if reason == QSystemTrayIcon.DoubleClick:
|
||||
self.showNormal()
|
||||
|
||||
|
||||
def connectSignals(
|
||||
self
|
||||
):
|
||||
|
||||
self.ConfigButton.clicked.connect(self.onConfigButtonClicked)
|
||||
self.TimerTaskWidgetButton.clicked.connect(self.onTimerTaskWidgetButtonClicked)
|
||||
self.TimerTaskManageWidgetButton.clicked.connect(self.onTimerTaskManageWidgetButtonClicked)
|
||||
self.StartButton.clicked.connect(self.onStartButtonClicked)
|
||||
self.StopButton.clicked.connect(self.onStopButtonClicked)
|
||||
self.SendButton.clicked.connect(self.onSendButtonClicked)
|
||||
self.MessageEdit.returnPressed.connect(self.onSendButtonClicked)
|
||||
|
||||
|
||||
def closeEvent(
|
||||
self,
|
||||
event: QCloseEvent
|
||||
):
|
||||
|
||||
if not self.isVisible():
|
||||
self.showNormal()
|
||||
event.ignore()
|
||||
return
|
||||
if self.__msg_queue_timer and self.__msg_queue_timer.isActive():
|
||||
self.__msg_queue_timer.stop()
|
||||
if self.__timer_task_timer and self.__timer_task_timer.isActive():
|
||||
@@ -173,14 +184,14 @@ class ALMainWindow(MsgBase, QMainWindow, Ui_ALMainWindow):
|
||||
if self.__is_running_timer_task:
|
||||
self.__current_timer_task_thread.wait(2000)
|
||||
self.__current_timer_task_thread.deleteLater()
|
||||
if self.__alTimerTaskWidget:
|
||||
self.__alTimerTaskWidget.close()
|
||||
self.__alTimerTaskWidget.deleteLater()
|
||||
if self.__alTimerTaskManageWidget:
|
||||
self.__alTimerTaskManageWidget.close()
|
||||
self.__alTimerTaskManageWidget.deleteLater()
|
||||
if self.__alConfigWidget:
|
||||
self.__alConfigWidget.close()
|
||||
# the config widget is already deleted in the 'self.onConfigWidgetClosed'
|
||||
super().closeEvent(event)
|
||||
|
||||
self._showLog("主窗口关闭")
|
||||
QMainWindow.closeEvent(self, event)
|
||||
|
||||
def appendToTextEdit(
|
||||
self,
|
||||
@@ -195,7 +206,6 @@ class ALMainWindow(MsgBase, QMainWindow, Ui_ALMainWindow):
|
||||
scrollbar = self.MessageIOTextEdit.verticalScrollBar()
|
||||
scrollbar.setValue(scrollbar.maximum())
|
||||
|
||||
|
||||
def startMsgPolling(
|
||||
self
|
||||
):
|
||||
@@ -204,7 +214,6 @@ class ALMainWindow(MsgBase, QMainWindow, Ui_ALMainWindow):
|
||||
self.__msg_queue_timer.timeout.connect(self.pollMsgQueue)
|
||||
self.__msg_queue_timer.start(100)
|
||||
|
||||
|
||||
def startTimerTaskPolling(
|
||||
self
|
||||
):
|
||||
@@ -213,7 +222,6 @@ class ALMainWindow(MsgBase, QMainWindow, Ui_ALMainWindow):
|
||||
self.__timer_task_timer.timeout.connect(self.pollTimerTaskQueue)
|
||||
self.__timer_task_timer.start(500)
|
||||
|
||||
|
||||
def pollTimerTaskQueue(
|
||||
self
|
||||
):
|
||||
@@ -241,13 +249,12 @@ class ALMainWindow(MsgBase, QMainWindow, Ui_ALMainWindow):
|
||||
self._output_queue,
|
||||
self.__config_paths
|
||||
)
|
||||
self.__current_timer_task_thread.TimerTaskWorkerIsFinished.connect(self.onTimerTaskFinished)
|
||||
self.__current_timer_task_thread.timerTaskWorkerIsFinished.connect(self.onTimerTaskFinished)
|
||||
self.__current_timer_task_thread.start()
|
||||
except queue.Empty:
|
||||
self.__is_running_timer_task = False
|
||||
pass
|
||||
|
||||
|
||||
def setControlButtons(
|
||||
self,
|
||||
config_button_enabled: bool,
|
||||
@@ -276,24 +283,24 @@ class ALMainWindow(MsgBase, QMainWindow, Ui_ALMainWindow):
|
||||
pass
|
||||
|
||||
@Slot()
|
||||
def onTimerTaskWidgetClosed(
|
||||
def onTimerTaskManageWidgetClosed(
|
||||
self
|
||||
):
|
||||
|
||||
self.TimerTaskWidgetButton.setEnabled(True)
|
||||
self.TimerTaskManageWidgetButton.setEnabled(True)
|
||||
|
||||
@Slot(dict)
|
||||
def onConfigWidgetClosed(
|
||||
self,
|
||||
config_paths: dict
|
||||
self
|
||||
):
|
||||
|
||||
if self.__alConfigWidget:
|
||||
self.__alConfigWidget.configWidgetIsClosed.disconnect(self.onConfigWidgetClosed)
|
||||
self.__alConfigWidget.deleteLater()
|
||||
self.__alConfigWidget = None
|
||||
self.__config_paths = ConfigUtils.getAutomationConfigPaths()
|
||||
self.setControlButtons(True, None, None)
|
||||
self.__config_paths = config_paths
|
||||
self._showLog("配置窗口已关闭,配置文件路径已更新")
|
||||
|
||||
@Slot(dict)
|
||||
def onTimerTaskIsReady(
|
||||
@@ -311,7 +318,7 @@ class ALMainWindow(MsgBase, QMainWindow, Ui_ALMainWindow):
|
||||
):
|
||||
|
||||
self.__current_timer_task_thread.wait(1000)
|
||||
self.__current_timer_task_thread.TimerTaskWorkerIsFinished.disconnect(self.onTimerTaskFinished)
|
||||
self.__current_timer_task_thread.timerTaskWorkerIsFinished.disconnect(self.onTimerTaskFinished)
|
||||
self.__current_timer_task_thread.deleteLater()
|
||||
self.__current_timer_task_thread = None
|
||||
self.setControlButtons(None, False, True)
|
||||
@@ -321,11 +328,11 @@ class ALMainWindow(MsgBase, QMainWindow, Ui_ALMainWindow):
|
||||
self.TrayIcon.showMessage(
|
||||
"定时任务 - AutoLibrary",
|
||||
f"\n定时任务 '{timer_task['name']}' 执行{'失败' if is_error else '完成'}",
|
||||
QSystemTrayIcon.MessageIcon.Information,
|
||||
QSystemTrayIcon.MessageIcon.Warning if is_error else QSystemTrayIcon.MessageIcon.Information,
|
||||
1000
|
||||
)
|
||||
self._showTrace(
|
||||
f"定时任务 {timer_task['name']} 执行{'失败' if is_error else '完成'}, uuid: {timer_task['task_uuid']}"
|
||||
f"定时任务 {timer_task['name']} 执行{'失败' if is_error else '完成'}, uuid: {timer_task['uuid']}"
|
||||
)
|
||||
if not is_error:
|
||||
self.timerTaskIsExecuted.emit(timer_task)
|
||||
@@ -333,14 +340,15 @@ class ALMainWindow(MsgBase, QMainWindow, Ui_ALMainWindow):
|
||||
self.timerTaskIsError.emit(timer_task)
|
||||
|
||||
@Slot()
|
||||
def onTimerTaskWidgetButtonClicked(
|
||||
def onTimerTaskManageWidgetButtonClicked(
|
||||
self
|
||||
):
|
||||
|
||||
self.__alTimerTaskWidget.show()
|
||||
self.__alTimerTaskWidget.raise_()
|
||||
self.__alTimerTaskWidget.activateWindow()
|
||||
self.TimerTaskWidgetButton.setEnabled(False)
|
||||
self.__alTimerTaskManageWidget.show()
|
||||
self.__alTimerTaskManageWidget.raise_()
|
||||
self.__alTimerTaskManageWidget.activateWindow()
|
||||
self.TimerTaskManageWidgetButton.setEnabled(False)
|
||||
self._showLog("打开定时任务管理窗口")
|
||||
|
||||
@Slot()
|
||||
def onConfigButtonClicked(
|
||||
@@ -348,15 +356,13 @@ class ALMainWindow(MsgBase, QMainWindow, Ui_ALMainWindow):
|
||||
):
|
||||
|
||||
if self.__alConfigWidget is None:
|
||||
self.__alConfigWidget = ALConfigWidget(
|
||||
self,
|
||||
self.__config_paths
|
||||
)
|
||||
self.__alConfigWidget = ALConfigWidget(self)
|
||||
self.__alConfigWidget.configWidgetIsClosed.connect(self.onConfigWidgetClosed)
|
||||
self.__alConfigWidget.show()
|
||||
self.__alConfigWidget.raise_()
|
||||
self.__alConfigWidget.activateWindow()
|
||||
self.ConfigButton.setEnabled(False)
|
||||
self._showLog("打开配置窗口")
|
||||
|
||||
@Slot()
|
||||
def onStartButtonClicked(
|
||||
@@ -370,9 +376,10 @@ class ALMainWindow(MsgBase, QMainWindow, Ui_ALMainWindow):
|
||||
self._output_queue,
|
||||
self.__config_paths
|
||||
)
|
||||
self.__auto_lib_thread.AutoLibWorkerIsFinished.connect(self.onStopButtonClicked)
|
||||
self.__auto_lib_thread.AutoLibWorkerFinishedWithError.connect(self.onStopButtonClicked)
|
||||
self.__auto_lib_thread.autoLibWorkerIsFinished.connect(self.onStopButtonClicked)
|
||||
self.__auto_lib_thread.autoLibWorkerFinishedWithError.connect(self.onStopButtonClicked)
|
||||
self.__auto_lib_thread.start()
|
||||
self._showLog("开始手动执行任务")
|
||||
|
||||
@Slot()
|
||||
def onStopButtonClicked(
|
||||
@@ -380,14 +387,15 @@ class ALMainWindow(MsgBase, QMainWindow, Ui_ALMainWindow):
|
||||
):
|
||||
|
||||
if self.__auto_lib_thread:
|
||||
self._showTrace("正在停止操作......")
|
||||
self._showTrace("正在停止操作......", no_log=True)
|
||||
self.__auto_lib_thread.wait(2000)
|
||||
self._showTrace("操作已停止")
|
||||
self.__auto_lib_thread.AutoLibWorkerIsFinished.disconnect(self.onStopButtonClicked)
|
||||
self.__auto_lib_thread.AutoLibWorkerFinishedWithError.disconnect(self.onStopButtonClicked)
|
||||
self._showTrace("操作已停止", no_log=True)
|
||||
self.__auto_lib_thread.autoLibWorkerIsFinished.disconnect(self.onStopButtonClicked)
|
||||
self.__auto_lib_thread.autoLibWorkerFinishedWithError.disconnect(self.onStopButtonClicked)
|
||||
self.__auto_lib_thread.deleteLater()
|
||||
self.__auto_lib_thread = None
|
||||
self.setControlButtons(None, False, True)
|
||||
self._showLog("任务已停止")
|
||||
|
||||
@Slot()
|
||||
def onSendButtonClicked(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Copyright (c) 2025 - 2026 KenanZhu.
|
||||
Copyright (c) 2025 KenanZhu.
|
||||
All rights reserved.
|
||||
|
||||
This software is provided "as is", without any warranty of any kind.
|
||||
@@ -12,31 +12,32 @@ import time
|
||||
import queue
|
||||
|
||||
from PySide6.QtCore import (
|
||||
Slot, Signal, QThread
|
||||
Signal,
|
||||
QThread,
|
||||
)
|
||||
|
||||
from base.MsgBase import MsgBase
|
||||
from operators.AutoLib import AutoLib
|
||||
from utils.ConfigReader import ConfigReader
|
||||
from pages.AutoLib import AutoLib
|
||||
from utils.JSONReader import JSONReader
|
||||
from autoscript import createEngine
|
||||
|
||||
|
||||
class AutoLibWorker(MsgBase, QThread):
|
||||
|
||||
AutoLibWorkerIsFinished = Signal()
|
||||
AutoLibWorkerFinishedWithError = Signal()
|
||||
autoLibWorkerIsFinished = Signal()
|
||||
autoLibWorkerFinishedWithError = Signal()
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
input_queue: queue.Queue,
|
||||
output_queue: queue.Queue,
|
||||
config_paths: dict
|
||||
config_paths: dict,
|
||||
):
|
||||
|
||||
MsgBase.__init__(self, input_queue, output_queue)
|
||||
QThread.__init__(self)
|
||||
self.__config_paths = config_paths
|
||||
|
||||
|
||||
def checkTimeAvailable(
|
||||
self,
|
||||
) -> bool:
|
||||
@@ -44,12 +45,13 @@ class AutoLibWorker(MsgBase, QThread):
|
||||
current_time = time.strftime("%H:%M", time.localtime())
|
||||
if current_time >= "23:30" or current_time <= "07:30":
|
||||
self._showTrace(
|
||||
"当前时间不在图书馆开放时间内, 请在 07:30 - 23:30 之间尝试"
|
||||
"当前时间不在图书馆开放时间内, 请在 07:30 - 23:30 之间尝试",
|
||||
self.TraceLevel.WARNING,
|
||||
)
|
||||
return False
|
||||
self._showLog(f"时间检查通过, 当前时间: {current_time}", self.TraceLevel.INFO)
|
||||
return True
|
||||
|
||||
|
||||
def checkConfigPaths(
|
||||
self,
|
||||
) -> bool:
|
||||
@@ -57,110 +59,190 @@ class AutoLibWorker(MsgBase, QThread):
|
||||
if not all(
|
||||
os.path.exists(path) for path in self.__config_paths.values()
|
||||
):
|
||||
self._showTrace("配置文件路径不存在, 请检查配置文件路径是否正确")
|
||||
self._showTrace(
|
||||
"配置文件路径不存在, 请检查配置文件路径是否正确",
|
||||
self.TraceLevel.ERROR,
|
||||
)
|
||||
return False
|
||||
self._showLog(
|
||||
f"配置文件路径检查通过, 路径: {self.__config_paths}",
|
||||
self.TraceLevel.INFO,
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def loadConfigs(
|
||||
self
|
||||
self,
|
||||
) -> bool:
|
||||
|
||||
self._showTrace(
|
||||
f"正在加载配置文件, 运行配置文件路径: {self.__config_paths["run"]}"
|
||||
f"正在加载配置文件, 运行配置文件路径: {self.__config_paths["run"]}",
|
||||
no_log=True,
|
||||
)
|
||||
self.__run_config = ConfigReader(self.__config_paths["run"]).getConfigs()
|
||||
self._run_config = JSONReader(self.__config_paths["run"]).data()
|
||||
self._showTrace(
|
||||
f"正在加载配置文件, 用户配置文件路径: {self.__config_paths["user"]}"
|
||||
f"正在加载配置文件, 用户配置文件路径: {self.__config_paths["user"]}",
|
||||
no_log=True,
|
||||
)
|
||||
self.__user_config = ConfigReader(self.__config_paths["user"]).getConfigs()
|
||||
if self.__run_config is None or self.__user_config is None:
|
||||
self._showTrace("配置文件加载失败, 请检查配置文件是否正确")
|
||||
self._showTrace("配置文件加载失败, 请检查配置文件是否正确")
|
||||
return False
|
||||
if not self.__user_config.get("groups"):
|
||||
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"):
|
||||
self._showTrace(
|
||||
"用户配置文件中无有效任务组, 请检查用户配置文件是否正确",
|
||||
self.TraceLevel.WARNING,
|
||||
)
|
||||
return False
|
||||
self._showLog(
|
||||
f"配置文件加载成功, 任务组数量: {len(self._user_config.get("groups"))}",
|
||||
self.TraceLevel.INFO,
|
||||
)
|
||||
return True
|
||||
|
||||
def _runName(
|
||||
self,
|
||||
) -> str:
|
||||
|
||||
return "常规任务"
|
||||
|
||||
def _beforeCreateAutoLib(
|
||||
self,
|
||||
):
|
||||
|
||||
return
|
||||
|
||||
def _onChecksFailed(
|
||||
self,
|
||||
) -> bool:
|
||||
|
||||
return True
|
||||
|
||||
def _onFinished(
|
||||
self,
|
||||
):
|
||||
|
||||
self.autoLibWorkerIsFinished.emit()
|
||||
|
||||
def _onError(
|
||||
self,
|
||||
error_msg: str,
|
||||
):
|
||||
|
||||
self._showTrace(error_msg, self.TraceLevel.ERROR)
|
||||
self.autoLibWorkerFinishedWithError.emit()
|
||||
|
||||
def run(
|
||||
self
|
||||
self,
|
||||
):
|
||||
|
||||
auto_lib = None
|
||||
self._showTrace("AutoLibrary 开始运行")
|
||||
if not self.checkTimeAvailable()\
|
||||
or not self.checkConfigPaths():
|
||||
# time or config existence check failed, skip and finish
|
||||
pass
|
||||
self._showTrace(f"{self._runName()} 开始运行")
|
||||
|
||||
if not self.checkTimeAvailable() or not self.checkConfigPaths():
|
||||
if not self._onChecksFailed():
|
||||
return
|
||||
else:
|
||||
try:
|
||||
if not self.loadConfigs():
|
||||
raise Exception("配置文件加载失败")
|
||||
self._beforeCreateAutoLib()
|
||||
auto_lib = AutoLib(
|
||||
self._input_queue,
|
||||
self._output_queue,
|
||||
self.__run_config
|
||||
self._run_config,
|
||||
)
|
||||
groups = self.__user_config.get("groups")
|
||||
groups = self._user_config.get("groups")
|
||||
for group in groups:
|
||||
if not group["enabled"]:
|
||||
self._showTrace(f"任务组 {group["name"]} 已跳过")
|
||||
if not group.get("enabled", False):
|
||||
self._showTrace(f"任务组 {group.get("name", "未知")} 已跳过", no_log=True)
|
||||
continue
|
||||
self._showTrace(f"正在运行任务组 {group["name"]}")
|
||||
auto_lib.run(
|
||||
{ "users": group.get("users", []) }
|
||||
)
|
||||
self._showTrace(f"正在运行任务组 {group.get("name", "未知")}", no_log=True)
|
||||
auto_lib.run({"users": group.get("users", [])})
|
||||
except Exception as e:
|
||||
self._showTrace(f"AutoLibrary 运行时发生异常 : {e}")
|
||||
self.AutoLibWorkerFinishedWithError.emit()
|
||||
self._onError(f"{self._runName()} 运行时发生异常 : {e}")
|
||||
return
|
||||
if auto_lib:
|
||||
auto_lib.close()
|
||||
self._showTrace("AutoLibrary 运行结束")
|
||||
self.AutoLibWorkerIsFinished.emit()
|
||||
self._showTrace(f"{self._runName()} 运行结束")
|
||||
self._onFinished()
|
||||
|
||||
|
||||
class TimerTaskWorker(AutoLibWorker):
|
||||
|
||||
TimerTaskWorkerIsFinished = Signal(bool, dict)
|
||||
timerTaskWorkerIsFinished = Signal(bool, dict)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
timer_task: dict,
|
||||
input_queue: queue.Queue,
|
||||
output_queue: queue.Queue,
|
||||
config_paths: dict
|
||||
config_paths: dict,
|
||||
):
|
||||
|
||||
super().__init__(input_queue, output_queue, config_paths)
|
||||
|
||||
self.__timer_task = timer_task
|
||||
self.AutoLibWorkerIsFinished.connect(self.onTimerTaskIsFinished)
|
||||
self.AutoLibWorkerFinishedWithError.connect(self.onTimerTaskIsError)
|
||||
|
||||
def run(
|
||||
self
|
||||
def _runName(
|
||||
self,
|
||||
) -> str:
|
||||
|
||||
return f"定时任务 '{self.__timer_task.get("name", "未知")}'"
|
||||
|
||||
def _beforeCreateAutoLib(
|
||||
self,
|
||||
):
|
||||
|
||||
self._showTrace(f"定时任务 {self.__timer_task['name']} 开始运行")
|
||||
super().run()
|
||||
self.applyRepeatAutoScript()
|
||||
|
||||
@Slot()
|
||||
def onTimerTaskIsError(
|
||||
self
|
||||
def _onChecksFailed(
|
||||
self,
|
||||
) -> bool:
|
||||
|
||||
self._showTrace("定时任务跳过执行: 时间或配置文件检查未通过")
|
||||
self.timerTaskWorkerIsFinished.emit(False, self.__timer_task)
|
||||
return False
|
||||
|
||||
def _onFinished(
|
||||
self,
|
||||
):
|
||||
|
||||
self._showTrace(f"定时任务 {self.__timer_task['name']} 运行时发生异常")
|
||||
self.TimerTaskWorkerIsFinished.emit(True, self.__timer_task)
|
||||
self.timerTaskWorkerIsFinished.emit(False, self.__timer_task)
|
||||
|
||||
@Slot()
|
||||
def onTimerTaskIsFinished(
|
||||
self
|
||||
def _onError(
|
||||
self,
|
||||
error_msg: str,
|
||||
):
|
||||
|
||||
self._showTrace(f"定时任务 {self.__timer_task['name']} 运行结束")
|
||||
self.TimerTaskWorkerIsFinished.emit(False, self.__timer_task)
|
||||
self._showTrace(error_msg, self.TraceLevel.ERROR)
|
||||
self.timerTaskWorkerIsFinished.emit(True, self.__timer_task)
|
||||
|
||||
def applyRepeatAutoScript(
|
||||
self,
|
||||
):
|
||||
|
||||
auto_script = self.__timer_task.get("repeat_auto_script", "")
|
||||
if not auto_script or not auto_script.strip():
|
||||
return
|
||||
self._showTrace("检测到重复定时任务 AutoScript, 开始执行...", no_log=True)
|
||||
groups = self._user_config.get("groups", [])
|
||||
affected_count = 0
|
||||
for group in groups:
|
||||
if not group.get("enabled", False):
|
||||
continue
|
||||
for user in group.get("users", []):
|
||||
try:
|
||||
engine = createEngine()
|
||||
engine.execute(auto_script, user)
|
||||
affected_count += 1
|
||||
except ValueError as e:
|
||||
self._showTrace(
|
||||
f"AutoScript 执行错误 (用户 {user.get("username", "未知")}): {e}",
|
||||
self.TraceLevel.ERROR,
|
||||
)
|
||||
self._showLog(
|
||||
f"AutoScript 执行完毕, 影响 {affected_count} 个用户",
|
||||
self.TraceLevel.INFO,
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Copyright (c) 2025 - 2026 KenanZhu.
|
||||
Copyright (c) 2025 KenanZhu.
|
||||
All rights reserved.
|
||||
|
||||
This software is provided "as is", without any warranty of any kind.
|
||||
@@ -63,14 +63,12 @@ class ALSeatFrame(QFrame):
|
||||
self.toggleSelection()
|
||||
self.clicked.emit(self.__seat_number)
|
||||
|
||||
|
||||
def isSelected(
|
||||
self
|
||||
):
|
||||
|
||||
return self.__is_selected
|
||||
|
||||
|
||||
def toggleSelection(self):
|
||||
|
||||
self.__is_selected = not self.__is_selected
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Copyright (c) 2025 - 2026 KenanZhu.
|
||||
Copyright (c) 2026 KenanZhu.
|
||||
All rights reserved.
|
||||
|
||||
This software is provided "as is", without any warranty of any kind.
|
||||
@@ -8,21 +8,27 @@ You may use, modify, and distribute this file under the terms of the MIT License
|
||||
See the LICENSE file for details.
|
||||
"""
|
||||
from PySide6.QtCore import (
|
||||
Qt, Slot, Signal
|
||||
)
|
||||
from PySide6.QtWidgets import (
|
||||
QDialog, QLabel, QHBoxLayout, QVBoxLayout,
|
||||
QPushButton,
|
||||
Qt,
|
||||
Signal,
|
||||
Slot
|
||||
)
|
||||
from PySide6.QtGui import (
|
||||
QCloseEvent
|
||||
)
|
||||
from PySide6.QtWidgets import (
|
||||
QDialog,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QPushButton,
|
||||
QVBoxLayout
|
||||
)
|
||||
|
||||
from gui.ALSeatMapView import ALSeatMapView
|
||||
|
||||
|
||||
class ALSeatMapSelectDialog(QDialog):
|
||||
|
||||
seatMapSelectDialogClosed = Signal(list)
|
||||
seatMapSelectDialogIsClosed = Signal(list)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -41,7 +47,6 @@ class ALSeatMapSelectDialog(QDialog):
|
||||
self.setupUi()
|
||||
self.connectSignals()
|
||||
|
||||
|
||||
def setupUi(
|
||||
self
|
||||
):
|
||||
@@ -84,7 +89,6 @@ class ALSeatMapSelectDialog(QDialog):
|
||||
self.SeatMapWidgetControlLayout.addWidget(self.ConfirmButton)
|
||||
self.SeatMapWidgetMainLayout.addLayout(self.SeatMapWidgetControlLayout)
|
||||
|
||||
|
||||
def connectSignals(
|
||||
self
|
||||
):
|
||||
@@ -92,7 +96,6 @@ class ALSeatMapSelectDialog(QDialog):
|
||||
self.ConfirmButton.clicked.connect(self.onConfirmButtonClicked)
|
||||
self.CancelButton.clicked.connect(self.onCancelButtonClicked)
|
||||
|
||||
|
||||
def showEvent(
|
||||
self,
|
||||
event
|
||||
@@ -116,7 +119,6 @@ class ALSeatMapSelectDialog(QDialog):
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def closeEvent(
|
||||
self,
|
||||
event: QCloseEvent
|
||||
@@ -127,10 +129,9 @@ class ALSeatMapSelectDialog(QDialog):
|
||||
self.reject()
|
||||
else:
|
||||
self.accept()
|
||||
self.seatMapSelectDialogClosed.emit(self.getSelectedSeats())
|
||||
self.seatMapSelectDialogIsClosed.emit(self.getSelectedSeats())
|
||||
super().closeEvent(event)
|
||||
|
||||
|
||||
def selectSeat(
|
||||
self,
|
||||
seat_number: str
|
||||
@@ -138,7 +139,6 @@ class ALSeatMapSelectDialog(QDialog):
|
||||
|
||||
self.SeatMapGraphicsView.selectSeat(seat_number)
|
||||
|
||||
|
||||
def selectSeats(
|
||||
self,
|
||||
seat_numbers: list[str]
|
||||
@@ -146,14 +146,12 @@ class ALSeatMapSelectDialog(QDialog):
|
||||
|
||||
return self.SeatMapGraphicsView.selectSeats(seat_numbers)
|
||||
|
||||
|
||||
def getSelectedSeats(
|
||||
self
|
||||
) -> list[str]:
|
||||
|
||||
return self.SeatMapGraphicsView.getSelectedSeats()
|
||||
|
||||
|
||||
def clearSelections(
|
||||
self
|
||||
):
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Copyright (c) 2025 - 2026 KenanZhu.
|
||||
Copyright (c) 2026 KenanZhu.
|
||||
All rights reserved.
|
||||
|
||||
This software is provided "as is", without any warranty of any kind.
|
||||
@@ -8,15 +8,23 @@ You may use, modify, and distribute this file under the terms of the MIT License
|
||||
See the LICENSE file for details.
|
||||
"""
|
||||
from PySide6.QtCore import (
|
||||
Qt, Slot, QEvent
|
||||
Qt,
|
||||
Slot,
|
||||
QEvent
|
||||
)
|
||||
from PySide6.QtWidgets import (
|
||||
QFrame, QWidget,
|
||||
QGridLayout, QGraphicsView, QGraphicsScene, QGraphicsItem
|
||||
QFrame,
|
||||
QWidget,
|
||||
QGridLayout,
|
||||
QGraphicsView,
|
||||
QGraphicsScene,
|
||||
QGraphicsItem
|
||||
)
|
||||
from PySide6.QtGui import (
|
||||
QPainter, QWheelEvent
|
||||
QPainter,
|
||||
QWheelEvent
|
||||
)
|
||||
|
||||
from gui.ALSeatFrame import ALSeatFrame
|
||||
|
||||
|
||||
@@ -34,18 +42,6 @@ class ALSeatMapView(QGraphicsView):
|
||||
|
||||
self.setupUi()
|
||||
|
||||
@staticmethod
|
||||
def formatSeatNumber(
|
||||
seat_number: str
|
||||
) -> str:
|
||||
|
||||
if seat_number and not seat_number[-1].isdigit():
|
||||
digits = seat_number[:-1]
|
||||
letter = seat_number[-1]
|
||||
return digits.zfill(3) + letter
|
||||
return seat_number.zfill(3)
|
||||
|
||||
|
||||
def eventFilter(
|
||||
self,
|
||||
watched,
|
||||
@@ -60,7 +56,6 @@ class ALSeatMapView(QGraphicsView):
|
||||
return True
|
||||
return super().eventFilter(watched, event)
|
||||
|
||||
|
||||
def zoomGraphicsView(
|
||||
self,
|
||||
event: QWheelEvent
|
||||
@@ -79,7 +74,6 @@ class ALSeatMapView(QGraphicsView):
|
||||
self.setTransformationAnchor(QGraphicsView.ViewportAnchor.AnchorUnderMouse)
|
||||
self.scale(zoom_factor, zoom_factor)
|
||||
|
||||
|
||||
def setupUi(
|
||||
self
|
||||
):
|
||||
@@ -99,7 +93,6 @@ class ALSeatMapView(QGraphicsView):
|
||||
self.ContainerProxy = self.SeatMapGraphicsScene.addWidget(self.SeatsContainerWidget)
|
||||
self.ContainerProxy.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsSelectable, False)
|
||||
|
||||
|
||||
def setupSeatMap(
|
||||
self
|
||||
):
|
||||
@@ -110,21 +103,20 @@ class ALSeatMapView(QGraphicsView):
|
||||
seats_number = [seat.strip() for seat in row.split(",")]
|
||||
for seat_number in seats_number:
|
||||
if seat_number:
|
||||
seat_widget = ALSeatFrame(seat_number)
|
||||
seat_widget.clicked.connect(self.onSeatClicked)
|
||||
self.SeatsContainerLayout.addWidget(seat_widget, row_idx, col_idx)
|
||||
self.__seat_frames[seat_number] = seat_widget
|
||||
SeatWidget = ALSeatFrame(seat_number)
|
||||
SeatWidget.clicked.connect(self.onSeatClicked)
|
||||
self.SeatsContainerLayout.addWidget(SeatWidget, row_idx, col_idx)
|
||||
self.__seat_frames[seat_number] = SeatWidget
|
||||
else:
|
||||
spacer = QFrame()
|
||||
spacer.setFixedSize(20, 30)
|
||||
spacer.setStyleSheet("background-color: transparent; border: none;")
|
||||
self.SeatsContainerLayout.addWidget(spacer, row_idx, col_idx)
|
||||
Spacer = QFrame()
|
||||
Spacer.setFixedSize(20, 30)
|
||||
Spacer.setStyleSheet("background-color: transparent; border: none;")
|
||||
self.SeatsContainerLayout.addWidget(Spacer, row_idx, col_idx)
|
||||
col_idx += 1
|
||||
self.SeatsContainerLayout.setSpacing(20)
|
||||
self.SeatsContainerLayout.setContentsMargins(20, 20, 20, 20)
|
||||
self.SeatsContainerWidget.adjustSize()
|
||||
|
||||
|
||||
def selectSeat(
|
||||
self,
|
||||
seat_number: str
|
||||
@@ -141,7 +133,6 @@ class ALSeatMapView(QGraphicsView):
|
||||
widget.toggleSelection()
|
||||
self.__selected_seats.append(seat_number)
|
||||
|
||||
|
||||
def selectSeats(
|
||||
self,
|
||||
selected_seats: list
|
||||
@@ -151,14 +142,12 @@ class ALSeatMapView(QGraphicsView):
|
||||
for seat_number in selected_seats:
|
||||
self.selectSeat(seat_number)
|
||||
|
||||
|
||||
def getSelectedSeats(
|
||||
self
|
||||
) -> list[str]:
|
||||
|
||||
return self.__selected_seats
|
||||
|
||||
|
||||
def clearSelections(
|
||||
self
|
||||
):
|
||||
@@ -184,4 +173,15 @@ class ALSeatMapView(QGraphicsView):
|
||||
if len(self.__selected_seats) < 1:
|
||||
self.__selected_seats.append(seat_number)
|
||||
else:
|
||||
self.__seat_frames[seat_number].toggleSelection()
|
||||
self.__seat_frames[seat_number].toggleSelection()
|
||||
|
||||
@staticmethod
|
||||
def formatSeatNumber(
|
||||
seat_number: str
|
||||
) -> str:
|
||||
|
||||
if seat_number and not seat_number[-1].isdigit():
|
||||
digits = seat_number[:-1]
|
||||
letter = seat_number[-1]
|
||||
return digits.zfill(3) + letter
|
||||
return seat_number.zfill(3)
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Copyright (c) 2026 KenanZhu.
|
||||
All rights reserved.
|
||||
|
||||
This software is provided "as is", without any warranty of any kind.
|
||||
You may use, modify, and distribute this file under the terms of the MIT License.
|
||||
See the LICENSE file for details.
|
||||
"""
|
||||
from enum import Enum
|
||||
|
||||
from PySide6.QtCore import (
|
||||
Property,
|
||||
QEasingCurve,
|
||||
QPropertyAnimation,
|
||||
Qt
|
||||
)
|
||||
from PySide6.QtGui import (
|
||||
QColor,
|
||||
QConicalGradient,
|
||||
QPainter,
|
||||
QPalette
|
||||
)
|
||||
from PySide6.QtWidgets import (
|
||||
QLabel
|
||||
)
|
||||
|
||||
|
||||
class ALStatusLabel(QLabel):
|
||||
|
||||
class Status(Enum):
|
||||
"""
|
||||
Enum class for representing the status of ALStatusLabel.
|
||||
"""
|
||||
|
||||
WAITING = 0
|
||||
RUNNING = 1
|
||||
SUCCESS = 2
|
||||
WARNING = 3
|
||||
FAILURE = 4
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent = None
|
||||
):
|
||||
|
||||
super().__init__(parent)
|
||||
self.__status = self.Status.WAITING
|
||||
self.__icon_angle = 0
|
||||
|
||||
self.setupUi()
|
||||
|
||||
def setupUi(
|
||||
self
|
||||
):
|
||||
|
||||
self.setFixedSize(36, 36)
|
||||
self.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self.RunningAnimation = QPropertyAnimation(self, b"iconAngle")
|
||||
self.RunningAnimation.setDuration(1000)
|
||||
self.RunningAnimation.setStartValue(0)
|
||||
self.RunningAnimation.setEndValue(-360)
|
||||
self.RunningAnimation.setLoopCount(-1)
|
||||
self.RunningAnimation.setEasingCurve(QEasingCurve.Type.Linear)
|
||||
|
||||
def isDarkMode(
|
||||
self
|
||||
) -> bool:
|
||||
|
||||
return self.palette().color(QPalette.ColorRole.Window).value() < 128
|
||||
|
||||
def getMarkColor(
|
||||
self
|
||||
) -> QColor:
|
||||
|
||||
return QColor("#FFFFFF") if self.isDarkMode() else QColor("#454545")
|
||||
|
||||
@Property(Status)
|
||||
def status(
|
||||
self
|
||||
) -> Status:
|
||||
|
||||
return self.__status
|
||||
|
||||
@Property(int)
|
||||
def iconAngle(
|
||||
self
|
||||
) -> int:
|
||||
|
||||
return self.__icon_angle
|
||||
|
||||
@status.setter
|
||||
def status(
|
||||
self,
|
||||
status: Status
|
||||
):
|
||||
|
||||
if status not in self.Status:
|
||||
raise ValueError(f"Invalid (class)Status[enum.Enum] value: {status}")
|
||||
self.__status = status
|
||||
if self.__status == self.Status.RUNNING:
|
||||
self.RunningAnimation.start()
|
||||
else:
|
||||
self.RunningAnimation.stop()
|
||||
self.update()
|
||||
|
||||
@iconAngle.setter
|
||||
def iconAngle(
|
||||
self,
|
||||
value: int
|
||||
):
|
||||
|
||||
self.__icon_angle = value
|
||||
self.update()
|
||||
|
||||
def paintEvent(
|
||||
self,
|
||||
event
|
||||
):
|
||||
|
||||
Painter = QPainter(self)
|
||||
Painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
center_x = self.width()/2
|
||||
center_y = self.height()/2
|
||||
radius = min(center_x, center_y) - 3
|
||||
match self.__status:
|
||||
case self.Status.WAITING:
|
||||
Pen = Painter.pen()
|
||||
Pen.setWidth(2)
|
||||
Pen.setBrush(Qt.BrushStyle.NoBrush)
|
||||
Pen.setCapStyle(Qt.PenCapStyle.RoundCap)
|
||||
Pen.setColor(QColor("#969696")) # grey
|
||||
Painter.setPen(Pen)
|
||||
Painter.drawEllipse(
|
||||
int(center_x - radius),
|
||||
int(center_y - radius),
|
||||
int(radius*2),
|
||||
int(radius*2)
|
||||
)
|
||||
case self.Status.RUNNING:
|
||||
Gradient = QConicalGradient(center_x, center_y, self.__icon_angle)
|
||||
Gradient.setColorAt(0.0, QColor("#2294FF" if self.isDarkMode() else "#0094FF"))
|
||||
Gradient.setColorAt(1.0, QColor("#2294FF00"))
|
||||
Pen = Painter.pen()
|
||||
Pen.setWidth(3)
|
||||
Pen.setBrush(Gradient)
|
||||
Pen.setCapStyle(Qt.PenCapStyle.RoundCap)
|
||||
Painter.setPen(Pen)
|
||||
Painter.drawEllipse(
|
||||
int(center_x - radius),
|
||||
int(center_y - radius),
|
||||
int(radius*2),
|
||||
int(radius*2)
|
||||
)
|
||||
case self.Status.SUCCESS:
|
||||
# draw the success green circle
|
||||
Pen = Painter.pen()
|
||||
Pen.setWidth(2)
|
||||
Pen.setBrush(Qt.BrushStyle.NoBrush)
|
||||
Pen.setCapStyle(Qt.PenCapStyle.RoundCap)
|
||||
Pen.setColor(QColor("#4CAF50" if self.isDarkMode() else "#00AF50")) # green
|
||||
Painter.setPen(Pen)
|
||||
Painter.drawEllipse(
|
||||
int(center_x - radius),
|
||||
int(center_y - radius),
|
||||
int(radius*2),
|
||||
int(radius*2)
|
||||
)
|
||||
# draw the success check mark '✓'
|
||||
Painter.setPen(Qt.PenStyle.SolidLine)
|
||||
Pen = Painter.pen()
|
||||
Pen.setWidth(3)
|
||||
Pen.setBrush(Qt.BrushStyle.NoBrush)
|
||||
Pen.setCapStyle(Qt.PenCapStyle.RoundCap)
|
||||
# white when dark mode, black when light mode
|
||||
Pen.setColor(self.getMarkColor())
|
||||
Painter.setPen(Pen)
|
||||
mark_size = radius/2
|
||||
mark_path = [
|
||||
(center_x - mark_size, center_y),
|
||||
(center_x - mark_size/3, center_y + mark_size/2),
|
||||
(center_x + mark_size, center_y - mark_size/2)
|
||||
]
|
||||
Painter.drawLine(
|
||||
int(mark_path[0][0]),int(mark_path[0][1]),
|
||||
int(mark_path[1][0]),int(mark_path[1][1])
|
||||
)
|
||||
Painter.drawLine(
|
||||
int(mark_path[1][0]),int(mark_path[1][1]),
|
||||
int(mark_path[2][0]),int(mark_path[2][1])
|
||||
)
|
||||
case self.Status.WARNING:
|
||||
# draw the warning orange circle
|
||||
Pen = Painter.pen()
|
||||
Pen.setWidth(2)
|
||||
Pen.setBrush(Qt.BrushStyle.NoBrush)
|
||||
Pen.setCapStyle(Qt.PenCapStyle.RoundCap)
|
||||
Pen.setColor(QColor("#FF9800")) # orange
|
||||
Painter.setPen(Pen)
|
||||
Painter.drawEllipse(
|
||||
int(center_x - radius),
|
||||
int(center_y - radius),
|
||||
int(radius*2),
|
||||
int(radius*2)
|
||||
)
|
||||
# draw the warning exclamation mark '!'
|
||||
Painter.setPen(Qt.PenStyle.SolidLine)
|
||||
Pen = Painter.pen()
|
||||
Pen.setWidth(3)
|
||||
Pen.setBrush(Qt.BrushStyle.NoBrush)
|
||||
Pen.setCapStyle(Qt.PenCapStyle.RoundCap)
|
||||
# white when dark mode, black when light mode
|
||||
Pen.setColor(self.getMarkColor())
|
||||
Painter.setPen(Pen)
|
||||
Painter.drawLine(
|
||||
int(center_x), int(center_y - radius/2),
|
||||
int(center_x), int(center_y + radius/6)
|
||||
)
|
||||
Painter.drawPoint(
|
||||
int(center_x), int(center_y + radius/2)
|
||||
)
|
||||
case self.Status.FAILURE:
|
||||
# draw the failure red circle
|
||||
Pen = Painter.pen()
|
||||
Pen.setWidth(2)
|
||||
Pen.setBrush(Qt.BrushStyle.NoBrush)
|
||||
Pen.setCapStyle(Qt.PenCapStyle.RoundCap)
|
||||
Pen.setColor(QColor("#DC0000")) # red
|
||||
Painter.setPen(Pen)
|
||||
Painter.drawEllipse(
|
||||
int(center_x - radius),
|
||||
int(center_y - radius),
|
||||
int(radius*2),
|
||||
int(radius*2)
|
||||
)
|
||||
# draw the failure cross mark '✗'
|
||||
Painter.setPen(Qt.PenStyle.SolidLine)
|
||||
Pen = Painter.pen()
|
||||
Pen.setWidth(3)
|
||||
Pen.setBrush(Qt.BrushStyle.NoBrush)
|
||||
Pen.setCapStyle(Qt.PenCapStyle.RoundCap)
|
||||
# white when dark mode, black when light mode
|
||||
Pen.setColor(self.getMarkColor())
|
||||
Painter.setPen(Pen)
|
||||
mark_size = radius/3
|
||||
Painter.drawLine(
|
||||
int(center_x - mark_size), int(center_y - mark_size),
|
||||
int(center_x + mark_size), int(center_y + mark_size)
|
||||
)
|
||||
Painter.drawLine(
|
||||
int(center_x + mark_size), int(center_y - mark_size),
|
||||
int(center_x - mark_size), int(center_y + mark_size)
|
||||
)
|
||||
Painter.end()
|
||||
super().paintEvent(event)
|
||||
@@ -1,6 +1,6 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Copyright (c) 2025 - 2026 KenanZhu.
|
||||
Copyright (c) 2025 KenanZhu.
|
||||
All rights reserved.
|
||||
|
||||
This software is provided "as is", without any warranty of any kind.
|
||||
@@ -13,14 +13,25 @@ from enum import Enum
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from PySide6.QtCore import (
|
||||
Slot, QDateTime
|
||||
Slot,
|
||||
QDateTime,
|
||||
QUrl
|
||||
)
|
||||
from PySide6.QtGui import QDesktopServices
|
||||
from PySide6.QtWidgets import (
|
||||
QLabel, QDialog, QWidget, QSpinBox,
|
||||
QHBoxLayout, QGridLayout, QDateTimeEdit
|
||||
QLabel,
|
||||
QDialog,
|
||||
QWidget,
|
||||
QSpinBox,
|
||||
QHBoxLayout,
|
||||
QVBoxLayout,
|
||||
QDateTimeEdit,
|
||||
QGroupBox,
|
||||
QPushButton
|
||||
)
|
||||
|
||||
from gui.resources.ui.Ui_ALTimerTaskAddDialog import Ui_ALTimerTaskAddDialog
|
||||
from utils.TimerUtils import TimerUtils
|
||||
|
||||
|
||||
class ALTimerTaskStatus(Enum):
|
||||
@@ -31,21 +42,26 @@ class ALTimerTaskStatus(Enum):
|
||||
EXECUTED = "已执行"
|
||||
ERROR = "执行失败"
|
||||
OUTDATED = "已过期"
|
||||
UNKNOWN = "未知"
|
||||
|
||||
|
||||
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.connectSignals()
|
||||
self.modifyUi()
|
||||
self.connectSignals()
|
||||
|
||||
if self.__edit_timer_task:
|
||||
self.loadTask(self.__edit_timer_task)
|
||||
|
||||
def modifyUi(
|
||||
self
|
||||
@@ -54,6 +70,8 @@ class ALTimerTaskAddDialog(QDialog, Ui_ALTimerTaskAddDialog):
|
||||
self.TimerTypeComboBox.setCurrentIndex(0)
|
||||
self.SpecificTimerWidget = QWidget()
|
||||
self.SpecificTimerLayout = QHBoxLayout(self.SpecificTimerWidget)
|
||||
self.SpecificTimerLayout.setContentsMargins(0, 0, 0, 0)
|
||||
self.SpecificTimerLayout.setSpacing(5)
|
||||
self.SpecificTimerLayout.addWidget(QLabel("定时时间:"))
|
||||
self.SpecificDateTimeEdit = QDateTimeEdit()
|
||||
self.SpecificDateTimeEdit.setCalendarPopup(True)
|
||||
@@ -62,33 +80,109 @@ class ALTimerTaskAddDialog(QDialog, Ui_ALTimerTaskAddDialog):
|
||||
self.SpecificDateTimeEdit.setDateTime(QDateTime.currentDateTime().addSecs(60))
|
||||
self.SpecificTimerLayout.addWidget(self.SpecificDateTimeEdit)
|
||||
self.TimerConfigLayout.addWidget(self.SpecificTimerWidget)
|
||||
|
||||
self.RelativeTimerWidget = QWidget()
|
||||
self.RelativeTimerLayout = QGridLayout(self.RelativeTimerWidget)
|
||||
self.RelativeTimerLayout.addWidget(QLabel("相对时间:"), 0, 0)
|
||||
self.RelativeTimerLayout = QHBoxLayout(self.RelativeTimerWidget)
|
||||
self.RelativeTimerLayout.setContentsMargins(0, 0, 0, 0)
|
||||
self.RelativeTimerLayout.setSpacing(5)
|
||||
self.RelativeTimerLayout.addWidget(QLabel("相对时间:"))
|
||||
self.RelativeDaySpinBox = QSpinBox()
|
||||
self.RelativeDaySpinBox.setMinimum(0)
|
||||
self.RelativeDaySpinBox.setMaximum(365)
|
||||
self.RelativeDaySpinBox.setMaximum(364)
|
||||
self.RelativeDaySpinBox.setSuffix("天")
|
||||
self.RelativeTimerLayout.addWidget(self.RelativeDaySpinBox, 1, 0)
|
||||
self.RelativeTimerLayout.addWidget(self.RelativeDaySpinBox)
|
||||
self.RelativeHourSpinBox = QSpinBox()
|
||||
self.RelativeHourSpinBox.setMinimum(0)
|
||||
self.RelativeHourSpinBox.setMaximum(23)
|
||||
self.RelativeHourSpinBox.setSuffix("时")
|
||||
self.RelativeTimerLayout.addWidget(self.RelativeHourSpinBox, 1, 1)
|
||||
self.RelativeTimerLayout.addWidget(self.RelativeHourSpinBox)
|
||||
self.RelativeMinuteSpinBox = QSpinBox()
|
||||
self.RelativeMinuteSpinBox.setMinimum(0)
|
||||
self.RelativeMinuteSpinBox.setMaximum(59)
|
||||
self.RelativeMinuteSpinBox.setSuffix("分")
|
||||
self.RelativeTimerLayout.addWidget(self.RelativeMinuteSpinBox, 1, 2)
|
||||
self.RelativeTimerLayout.addWidget(self.RelativeMinuteSpinBox)
|
||||
self.RelativeSecondSpinBox = QSpinBox()
|
||||
self.RelativeSecondSpinBox.setMinimum(0)
|
||||
self.RelativeSecondSpinBox.setMaximum(59)
|
||||
self.RelativeSecondSpinBox.setSuffix("秒")
|
||||
self.RelativeTimerLayout.addWidget(self.RelativeSecondSpinBox, 1, 3)
|
||||
self.RelativeTimerLayout.addWidget(self.RelativeSecondSpinBox)
|
||||
self.TimerConfigLayout.addWidget(self.RelativeTimerWidget)
|
||||
self.RelativeTimerWidget.setVisible(False)
|
||||
self.AutoScriptGroupBox = QGroupBox("AutoScript 指令")
|
||||
self.AutoScriptLayout = QVBoxLayout(self.AutoScriptGroupBox)
|
||||
self.AutoScriptLayout.setContentsMargins(3, 3, 3, 3)
|
||||
self.AutoScriptLayout.setSpacing(3)
|
||||
AutoScriptBtnLayout = QHBoxLayout()
|
||||
self.AutoScriptEditButton = QPushButton("编辑")
|
||||
self.AutoScriptEditButton.setMinimumHeight(25)
|
||||
self.AutoScriptEditButton.setFixedWidth(80)
|
||||
AutoScriptBtnLayout.addWidget(self.AutoScriptEditButton)
|
||||
AutoScriptBtnLayout.addStretch()
|
||||
self.AutoScriptHelpButton = QPushButton("?")
|
||||
self.AutoScriptHelpButton.setFixedSize(20, 20)
|
||||
self.AutoScriptHelpButton.setToolTip(
|
||||
"AutoScript 是一种轻量级 DSL 语言,基于 Lua 实现。\n"
|
||||
"用于在重复定时任务执行前,对用户的预约数据进行预处理\n"
|
||||
"\n"
|
||||
"点击查看完整在线文档"
|
||||
)
|
||||
self.AutoScriptHelpButton.setStyleSheet(
|
||||
"QPushButton { border-radius: 10px; border: 1px solid #999; "
|
||||
"font-weight: bold; color: #555; }"
|
||||
"QPushButton:hover { background-color: #E0E0E0; }"
|
||||
)
|
||||
AutoScriptBtnLayout.addWidget(self.AutoScriptHelpButton)
|
||||
self.AutoScriptStatusLabel = QLabel("未设置")
|
||||
self.AutoScriptStatusLabel.setStyleSheet("color: #969696;")
|
||||
self.AutoScriptStatusLabel.setFixedHeight(25)
|
||||
AutoScriptBtnLayout.addWidget(self.AutoScriptStatusLabel)
|
||||
self.AutoScriptLayout.addLayout(AutoScriptBtnLayout)
|
||||
self.ALAddTimerTaskLayout.insertWidget(
|
||||
self.ALAddTimerTaskLayout.indexOf(self.TaskConfigGroupBox) + 1,
|
||||
self.AutoScriptGroupBox
|
||||
)
|
||||
self.AutoScriptGroupBox.setVisible(False)
|
||||
self.__auto_script = ""
|
||||
self.__mock_target_data = None
|
||||
|
||||
def loadTask(
|
||||
self,
|
||||
task: dict
|
||||
):
|
||||
|
||||
self.TaskNameLineEdit.setText(task.get("name", ""))
|
||||
time_type = task.get("time_type", "特定时间")
|
||||
self.TimerTypeComboBox.setCurrentText(time_type)
|
||||
self.SpecificDateTimeEdit.setDateTime(
|
||||
QDateTime(task["execute_time"])
|
||||
)
|
||||
self.RelativeDaySpinBox.setValue(0)
|
||||
self.RelativeHourSpinBox.setValue(0)
|
||||
self.RelativeMinuteSpinBox.setValue(0)
|
||||
self.RelativeSecondSpinBox.setValue(0)
|
||||
if task.get("silent", False):
|
||||
self.SilentlyRunRadioButton.setChecked(True)
|
||||
else:
|
||||
self.ShowBeforeRunRadioButton.setChecked(True)
|
||||
repeat = task.get("repeat", False)
|
||||
self.RepeatCheckBox.setChecked(repeat)
|
||||
if repeat:
|
||||
repeat_days = task.get("repeat_days", [])
|
||||
self.MonCheckBox.setChecked(0 in repeat_days)
|
||||
self.TueCheckBox.setChecked(1 in repeat_days)
|
||||
self.WedCheckBox.setChecked(2 in repeat_days)
|
||||
self.ThuCheckBox.setChecked(3 in repeat_days)
|
||||
self.FriCheckBox.setChecked(4 in repeat_days)
|
||||
self.SatCheckBox.setChecked(5 in repeat_days)
|
||||
self.SunCheckBox.setChecked(6 in repeat_days)
|
||||
auto_script = task.get("repeat_auto_script", "")
|
||||
if auto_script:
|
||||
self.__auto_script = auto_script
|
||||
self.AutoScriptStatusLabel.setText("已设置")
|
||||
self.AutoScriptStatusLabel.setStyleSheet("color: #4CAF50;")
|
||||
mock_data = task.get("mock_target_data")
|
||||
if mock_data:
|
||||
self.__mock_target_data = mock_data
|
||||
self.ConfirmButton.setText("保存")
|
||||
|
||||
def connectSignals(
|
||||
self
|
||||
@@ -97,7 +191,9 @@ class ALTimerTaskAddDialog(QDialog, Ui_ALTimerTaskAddDialog):
|
||||
self.CancelButton.clicked.connect(self.reject)
|
||||
self.ConfirmButton.clicked.connect(self.accept)
|
||||
self.TimerTypeComboBox.currentIndexChanged.connect(self.onTimerTypeComboBoxIndexChanged)
|
||||
|
||||
self.RepeatCheckBox.toggled.connect(self.onRepeatCheckBoxToggled)
|
||||
self.AutoScriptEditButton.clicked.connect(self.onPreviewAutoScript)
|
||||
self.AutoScriptHelpButton.clicked.connect(self.onAutoScriptHelp)
|
||||
|
||||
def getTimerTask(
|
||||
self
|
||||
@@ -121,17 +217,64 @@ class ALTimerTaskAddDialog(QDialog, Ui_ALTimerTaskAddDialog):
|
||||
minutes = self.RelativeMinuteSpinBox.value(),
|
||||
seconds = self.RelativeSecondSpinBox.value()
|
||||
)
|
||||
return {
|
||||
"name": name,
|
||||
"task_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,
|
||||
"add_time": added_time,
|
||||
"status": ALTimerTaskStatus.PENDING,
|
||||
"executed": False
|
||||
}
|
||||
|
||||
if self.__edit_timer_task:
|
||||
task_data = dict(self.__edit_timer_task)
|
||||
task_data["name"] = name
|
||||
task_data["execute_time"] = execute_time
|
||||
task_data["silent"] = silent
|
||||
task_data["status"] = ALTimerTaskStatus.PENDING
|
||||
task_data["executed"] = False
|
||||
task_data["repeat_auto_script"] = self.__auto_script
|
||||
task_data["mock_target_data"] = self.__mock_target_data
|
||||
else:
|
||||
task_data = {
|
||||
"name": name,
|
||||
"uuid": uuid.uuid4().hex.upper() + f"-{added_time.strftime("%Y%m%d%H%M%S")}",
|
||||
"time_type": self.TimerTypeComboBox.currentText(),
|
||||
"execute_time": execute_time,
|
||||
"silent": silent,
|
||||
"added_time": added_time,
|
||||
"status": ALTimerTaskStatus.PENDING,
|
||||
"executed": False,
|
||||
"repeat": self.RepeatCheckBox.isChecked(),
|
||||
"repeat_auto_script": self.__auto_script,
|
||||
"mock_target_data": self.__mock_target_data,
|
||||
}
|
||||
|
||||
repeat = self.RepeatCheckBox.isChecked()
|
||||
task_data["repeat"] = repeat
|
||||
if repeat:
|
||||
if "repeat_history" not in task_data:
|
||||
task_data["repeat_history"] = []
|
||||
repeat_days = []
|
||||
if self.MonCheckBox.isChecked():
|
||||
repeat_days.append(0)
|
||||
if self.TueCheckBox.isChecked():
|
||||
repeat_days.append(1)
|
||||
if self.WedCheckBox.isChecked():
|
||||
repeat_days.append(2)
|
||||
if self.ThuCheckBox.isChecked():
|
||||
repeat_days.append(3)
|
||||
if self.FriCheckBox.isChecked():
|
||||
repeat_days.append(4)
|
||||
if self.SatCheckBox.isChecked():
|
||||
repeat_days.append(5)
|
||||
if self.SunCheckBox.isChecked():
|
||||
repeat_days.append(6)
|
||||
if not repeat_days:
|
||||
repeat_days = [0, 1, 2, 3, 4, 5, 6]
|
||||
task_data["repeat_days"] = repeat_days
|
||||
task_data["repeat_hour"] = execute_time.hour
|
||||
task_data["repeat_minute"] = execute_time.minute
|
||||
task_data["repeat_second"] = execute_time.second
|
||||
task_data["execute_time"] = TimerUtils.getNextTimerRepeatTime(
|
||||
task_data["repeat_days"],
|
||||
task_data["repeat_hour"],
|
||||
task_data["repeat_minute"],
|
||||
task_data["repeat_second"]
|
||||
)
|
||||
return task_data
|
||||
|
||||
@Slot(int)
|
||||
def onTimerTypeComboBoxIndexChanged(
|
||||
@@ -140,4 +283,44 @@ class ALTimerTaskAddDialog(QDialog, Ui_ALTimerTaskAddDialog):
|
||||
):
|
||||
|
||||
self.SpecificTimerWidget.setVisible(index == 0)
|
||||
self.RelativeTimerWidget.setVisible(index == 1)
|
||||
self.RelativeTimerWidget.setVisible(index == 1)
|
||||
|
||||
@Slot(bool)
|
||||
def onRepeatCheckBoxToggled(
|
||||
self,
|
||||
checked: bool
|
||||
):
|
||||
|
||||
self.MonCheckBox.setEnabled(checked)
|
||||
self.TueCheckBox.setEnabled(checked)
|
||||
self.WedCheckBox.setEnabled(checked)
|
||||
self.ThuCheckBox.setEnabled(checked)
|
||||
self.FriCheckBox.setEnabled(checked)
|
||||
self.SatCheckBox.setEnabled(checked)
|
||||
self.SunCheckBox.setEnabled(checked)
|
||||
self.AutoScriptGroupBox.setVisible(checked)
|
||||
|
||||
@Slot()
|
||||
def onPreviewAutoScript(self):
|
||||
from gui.ALAutoScriptEditDialog import ALAutoScriptEditDialog
|
||||
Dlg = ALAutoScriptEditDialog(self, self.__auto_script, self.__mock_target_data)
|
||||
if Dlg.exec() == QDialog.DialogCode.Accepted:
|
||||
script = Dlg.getScript()
|
||||
self.__auto_script = script
|
||||
self.__mock_target_data = Dlg.getMockData()
|
||||
if script:
|
||||
self.AutoScriptStatusLabel.setText("已设置")
|
||||
self.AutoScriptStatusLabel.setStyleSheet("color: #4CAF50;")
|
||||
else:
|
||||
self.AutoScriptStatusLabel.setText("未设置")
|
||||
self.AutoScriptStatusLabel.setStyleSheet("color: #969696;")
|
||||
Dlg.deleteLater()
|
||||
|
||||
@Slot()
|
||||
def onAutoScriptHelp(
|
||||
self
|
||||
):
|
||||
|
||||
QDesktopServices.openUrl(
|
||||
QUrl("https://www.autolibrary.kenanzhu.com/manuals/autoscript")
|
||||
)
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Copyright (c) 2026 KenanZhu.
|
||||
All rights reserved.
|
||||
|
||||
This software is provided "as is", without any warranty of any kind.
|
||||
You may use, modify, and distribute this file under the terms of the MIT License.
|
||||
See the LICENSE file for details.
|
||||
"""
|
||||
from datetime import datetime
|
||||
|
||||
from PySide6.QtCore import Slot, Qt
|
||||
from PySide6.QtWidgets import (
|
||||
QDialog, QTableWidget, QTableWidgetItem,
|
||||
QVBoxLayout, QHBoxLayout, QGridLayout,
|
||||
QPushButton, QLabel, QHeaderView
|
||||
)
|
||||
|
||||
from gui.ALTimerTaskAddDialog import ALTimerTaskStatus
|
||||
|
||||
|
||||
class ALTimerTaskHistoryDialog(QDialog):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent = None,
|
||||
task_data: dict = None
|
||||
):
|
||||
|
||||
super().__init__(parent)
|
||||
self.__task_data = task_data
|
||||
self.__history = task_data.get("repeat_history", [])
|
||||
|
||||
self.setupUi()
|
||||
self.connectSignals()
|
||||
|
||||
def setupUi(
|
||||
self
|
||||
):
|
||||
|
||||
self.setWindowTitle("定时任务执行历史 - AutoLibrary")
|
||||
self.setMinimumSize(300, 300)
|
||||
self.setMaximumSize(500, 400)
|
||||
MainLayout = QVBoxLayout(self)
|
||||
InfoLayout = QGridLayout()
|
||||
TaskNameLabel = QLabel(f"任务: {self.__task_data.get('name', '未命名')}")
|
||||
TaskNameLabel.setStyleSheet("font-weight: bold; font-size: 12px;")
|
||||
InfoLayout.addWidget(TaskNameLabel, 0, 0)
|
||||
TaskUUIDLabel = QLabel(f"UUID: {self.__task_data.get('uuid', '未命名')}")
|
||||
TaskUUIDLabel.setStyleSheet("color: #969696; font-size: 11px;")
|
||||
InfoLayout.addWidget(TaskUUIDLabel, 1, 0)
|
||||
InfoLayout.setColumnStretch(0, 1)
|
||||
if self.__task_data.get("repeat", False):
|
||||
RepeatLabel = QLabel("可重复性任务")
|
||||
RepeatLabel.setStyleSheet("color: #2294FF; font-size: 12px;")
|
||||
InfoLayout.addWidget(RepeatLabel, 0, 1)
|
||||
MainLayout.addLayout(InfoLayout)
|
||||
self.HistoryTableWidget = QTableWidget()
|
||||
self.HistoryTableWidget.setColumnCount(3)
|
||||
self.HistoryTableWidget.setHorizontalHeaderLabels(["执行时间", "结果", "耗时(秒/s)"])
|
||||
self.HistoryTableWidget.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch)
|
||||
self.HistoryTableWidget.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.ResizeToContents)
|
||||
self.HistoryTableWidget.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeMode.Stretch)
|
||||
self.HistoryTableWidget.verticalHeader().setVisible(False)
|
||||
self.HistoryTableWidget.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers)
|
||||
self.HistoryTableWidget.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows)
|
||||
self.loadHistory()
|
||||
MainLayout.addWidget(self.HistoryTableWidget)
|
||||
ButtonLayout = QHBoxLayout()
|
||||
ButtonLayout.addStretch()
|
||||
self.CloseButton = QPushButton("关闭")
|
||||
self.CloseButton.setFixedSize(80, 25)
|
||||
self.CloseButton.setDefault(True)
|
||||
self.ClearHistoryButton = QPushButton("清空历史")
|
||||
self.ClearHistoryButton.setFixedSize(80, 25)
|
||||
self.ClearHistoryButton.setStyleSheet("color: #DC0000;")
|
||||
ButtonLayout.addWidget(self.ClearHistoryButton)
|
||||
ButtonLayout.addWidget(self.CloseButton)
|
||||
MainLayout.addLayout(ButtonLayout)
|
||||
|
||||
def connectSignals(
|
||||
self
|
||||
):
|
||||
|
||||
self.CloseButton.clicked.connect(self.accept)
|
||||
self.ClearHistoryButton.clicked.connect(self.onClearHistoryButtonClicked)
|
||||
|
||||
def loadHistory(
|
||||
self
|
||||
):
|
||||
|
||||
self.HistoryTableWidget.setRowCount(len(self.__history))
|
||||
for row, record in enumerate(self.__history):
|
||||
self.addHistoryRow(row, record)
|
||||
|
||||
def getHistory(
|
||||
self
|
||||
) -> list:
|
||||
|
||||
return self.__history
|
||||
|
||||
def addHistoryRow(
|
||||
self,
|
||||
row: int,
|
||||
record: dict
|
||||
):
|
||||
|
||||
execute_time = record.get("execute_time", "")
|
||||
result = record.get("result", ALTimerTaskStatus.UNKNOWN)
|
||||
duration = record.get("duration", 0)
|
||||
ExecuteTimeItem = QTableWidgetItem(execute_time)
|
||||
ExecuteTimeItem.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self.HistoryTableWidget.setItem(row, 0, ExecuteTimeItem)
|
||||
ResultItem = QTableWidgetItem(result.value)
|
||||
ResultItem.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
match result:
|
||||
case ALTimerTaskStatus.EXECUTED:
|
||||
ResultItem.setForeground(Qt.GlobalColor.green)
|
||||
case ALTimerTaskStatus.ERROR:
|
||||
ResultItem.setForeground(Qt.GlobalColor.red)
|
||||
case ALTimerTaskStatus.OUTDATED:
|
||||
ResultItem.setForeground(Qt.GlobalColor.red)
|
||||
case _:
|
||||
ResultItem.setForeground(Qt.GlobalColor.black)
|
||||
self.HistoryTableWidget.setItem(row, 1, ResultItem)
|
||||
DurationItem = QTableWidgetItem(f"{duration:.2f}")
|
||||
DurationItem.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self.HistoryTableWidget.setItem(row, 2, DurationItem)
|
||||
self.HistoryTableWidget.setRowHeight(row, 25)
|
||||
|
||||
@Slot()
|
||||
def onClearHistoryButtonClicked(
|
||||
self
|
||||
):
|
||||
|
||||
self.__history.clear()
|
||||
self.HistoryTableWidget.setRowCount(0)
|
||||
self.__task_data["repeat_history"] = self.__history
|
||||
@@ -1,6 +1,6 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Copyright (c) 2025 - 2026 KenanZhu.
|
||||
Copyright (c) 2025 KenanZhu.
|
||||
All rights reserved.
|
||||
|
||||
This software is provided "as is", without any warranty of any kind.
|
||||
@@ -8,31 +8,53 @@ You may use, modify, and distribute this file under the terms of the MIT License
|
||||
See the LICENSE file for details.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import copy
|
||||
|
||||
from enum import Enum
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from PySide6.QtCore import (
|
||||
Qt, Signal, Slot, QTimer
|
||||
)
|
||||
from PySide6.QtWidgets import (
|
||||
QDialog, QWidget, QListWidgetItem, QMessageBox,
|
||||
QHBoxLayout, QVBoxLayout, QLabel, QPushButton
|
||||
QTimer,
|
||||
Qt,
|
||||
Signal,
|
||||
Slot
|
||||
)
|
||||
from PySide6.QtGui import (
|
||||
QAction,
|
||||
QCloseEvent
|
||||
)
|
||||
from PySide6.QtWidgets import (
|
||||
QDialog,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QListWidgetItem,
|
||||
QMenu,
|
||||
QMessageBox,
|
||||
QPushButton,
|
||||
QVBoxLayout,
|
||||
QWidget
|
||||
)
|
||||
|
||||
import managers.config.ConfigManager as ConfigManager
|
||||
|
||||
from gui.ALTimerTaskAddDialog import (
|
||||
ALTimerTaskAddDialog,
|
||||
ALTimerTaskStatus
|
||||
)
|
||||
from gui.ALTimerTaskHistoryDialog import ALTimerTaskHistoryDialog
|
||||
from gui.resources.ui.Ui_ALTimerTaskManageWidget import Ui_ALTimerTaskManageWidget
|
||||
from gui.ALTimerTaskAddDialog import ALTimerTaskAddDialog, ALTimerTaskStatus
|
||||
|
||||
from utils.ConfigReader import ConfigReader
|
||||
from utils.ConfigWriter import ConfigWriter
|
||||
from interfaces.ConfigProvider import (
|
||||
CfgKey,
|
||||
ConfigProvider
|
||||
)
|
||||
from utils.TimerUtils import TimerUtils
|
||||
|
||||
|
||||
class ALTimerTaskItemWidget(QWidget):
|
||||
|
||||
editRequested = Signal(dict)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent = None,
|
||||
@@ -41,9 +63,11 @@ class ALTimerTaskItemWidget(QWidget):
|
||||
|
||||
super().__init__(parent)
|
||||
self.__timer_task = timer_task
|
||||
self.__manage_widget = parent
|
||||
|
||||
self.modifyUi()
|
||||
|
||||
self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
|
||||
self.customContextMenuRequested.connect(self.showContextMenu)
|
||||
|
||||
def modifyUi(
|
||||
self
|
||||
@@ -61,13 +85,25 @@ class ALTimerTaskItemWidget(QWidget):
|
||||
TaskNameLabel.setFont(TaskNameLabelFont)
|
||||
TaskNameLabel.setFixedHeight(25)
|
||||
self.TaskInfoLayout.addWidget(TaskNameLabel)
|
||||
|
||||
ExecuteTimeStr = self.__timer_task["execute_time"].strftime("%Y-%m-%d %H:%M:%S")
|
||||
ExecuteTimeLabel = QLabel(f"执行时间: {ExecuteTimeStr}")
|
||||
if self.__timer_task.get("repeat", False):
|
||||
repeat_days = self.__timer_task.get("repeat_days", [])
|
||||
repeat_hour = self.__timer_task.get("repeat_hour", 0)
|
||||
repeat_minute = self.__timer_task.get("repeat_minute", 0)
|
||||
repeat_second = self.__timer_task.get("repeat_second", 0)
|
||||
if len(repeat_days) == 7:
|
||||
time_str = f"{repeat_hour:02d}:{repeat_minute:02d}:{repeat_second:02d}"
|
||||
ExecuteTimeLabel = QLabel(f"下次执行时间: {ExecuteTimeStr} (每日 {time_str})")
|
||||
else:
|
||||
day_names = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"]
|
||||
selected_days = [day_names[d] for d in repeat_days]
|
||||
time_str = f"{repeat_hour:02d}:{repeat_minute:02d}:{repeat_second:02d}"
|
||||
ExecuteTimeLabel = QLabel(f"下次执行时间: {ExecuteTimeStr} (每{','.join(selected_days)} {time_str})")
|
||||
else:
|
||||
ExecuteTimeLabel = QLabel(f"执行时间: {ExecuteTimeStr}")
|
||||
ExecuteTimeLabel.setStyleSheet("color: #969696;")
|
||||
ExecuteTimeLabel.setFixedHeight(20)
|
||||
self.TaskInfoLayout.addWidget(ExecuteTimeLabel)
|
||||
|
||||
self.ItemWidgetLayout.addLayout(self.TaskInfoLayout)
|
||||
self.ItemWidgetLayout.addStretch()
|
||||
|
||||
@@ -118,14 +154,40 @@ class ALTimerTaskItemWidget(QWidget):
|
||||
TaskModeLabel.setFixedSize(60, 25)
|
||||
self.ItemWidgetLayout.addWidget(TaskModeLabel)
|
||||
|
||||
if self.__timer_task.get("repeat", False):
|
||||
self.HistoryButton = QPushButton("历史")
|
||||
self.HistoryButton.setFixedSize(80, 25)
|
||||
self.ItemWidgetLayout.addWidget(self.HistoryButton)
|
||||
self.DeleteButton = QPushButton("删除")
|
||||
self.DeleteButton.setFixedSize(80, 25)
|
||||
self.DeleteButton.setStyleSheet("color: #DC0000;")
|
||||
self.ItemWidgetLayout.addWidget(self.DeleteButton)
|
||||
if self.__timer_task["status"] == ALTimerTaskStatus.READY\
|
||||
or self.__timer_task["status"] == ALTimerTaskStatus.RUNNING:
|
||||
self.DeleteButton.setEnabled(False)
|
||||
self.setFixedHeight(55)
|
||||
|
||||
@Slot(object)
|
||||
def showContextMenu(
|
||||
self,
|
||||
pos
|
||||
):
|
||||
|
||||
Menu = QMenu(self)
|
||||
EditAction = QAction("编辑", self)
|
||||
EditAction.triggered.connect(
|
||||
lambda: self.editRequested.emit(self.__timer_task)
|
||||
)
|
||||
Menu.addAction(EditAction)
|
||||
if self.__timer_task["status"] != ALTimerTaskStatus.RUNNING\
|
||||
and self.__timer_task["status"] != ALTimerTaskStatus.READY:
|
||||
DeleteAction = QAction("删除", self)
|
||||
DeleteAction.triggered.connect(
|
||||
lambda: self.__manage_widget.deleteTask(self.__timer_task)
|
||||
)
|
||||
Menu.addAction(DeleteAction)
|
||||
Menu.exec(self.mapToGlobal(pos))
|
||||
|
||||
|
||||
class ALTimerTaskManageWidget(QWidget, Ui_ALTimerTaskManageWidget):
|
||||
|
||||
@@ -137,20 +199,19 @@ class ALTimerTaskManageWidget(QWidget, Ui_ALTimerTaskManageWidget):
|
||||
|
||||
timerTaskIsReady = Signal(dict)
|
||||
timerTasksChanged = Signal()
|
||||
timerTaskManageWidgetClosed = Signal()
|
||||
timerTaskManageWidgetIsClosed = Signal()
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent = None,
|
||||
timer_tasks_config_path: str = ""
|
||||
parent = None
|
||||
):
|
||||
|
||||
super().__init__(parent)
|
||||
self.__cfg_mgr: ConfigProvider = ConfigManager.instance()
|
||||
self.__timer_tasks = []
|
||||
self.__check_timer = None
|
||||
self.__CheckTimer = None
|
||||
self.__sort_policy = self.SortPolicy.BY_EXECUTE_TIME
|
||||
self.__sort_order = Qt.SortOrder.AscendingOrder
|
||||
self.__timer_tasks_config_path = timer_tasks_config_path
|
||||
|
||||
self.setupUi(self)
|
||||
self.connectSignals()
|
||||
@@ -158,7 +219,6 @@ class ALTimerTaskManageWidget(QWidget, Ui_ALTimerTaskManageWidget):
|
||||
if not self.initializeTimerTasks():
|
||||
raise Exception("定时任务配置文件初始化失败 !")
|
||||
|
||||
|
||||
def connectSignals(
|
||||
self
|
||||
):
|
||||
@@ -169,81 +229,76 @@ class ALTimerTaskManageWidget(QWidget, Ui_ALTimerTaskManageWidget):
|
||||
self.TimerTaskSortOrderToggleButton.clicked.connect(self.onSortOrderToggleButtonClicked)
|
||||
self.timerTasksChanged.connect(self.onTimerTasksChanged)
|
||||
|
||||
|
||||
def setupTimer(
|
||||
self
|
||||
):
|
||||
|
||||
self.__check_timer = QTimer(self)
|
||||
self.__check_timer.timeout.connect(self.checkTasks)
|
||||
self.__check_timer.start(500)
|
||||
|
||||
self.__CheckTimer = QTimer(self)
|
||||
self.__CheckTimer.timeout.connect(self.checkTasks)
|
||||
self.__CheckTimer.start(500)
|
||||
|
||||
def initializeTimerTasks(
|
||||
self
|
||||
) -> bool:
|
||||
|
||||
timer_tasks = self.loadTimerTasks(self.__timer_tasks_config_path)
|
||||
timer_tasks = self.getTimerTasks()
|
||||
if timer_tasks is not None:
|
||||
self.__timer_tasks = timer_tasks
|
||||
self.timerTasksChanged.emit()
|
||||
return True
|
||||
timer_tasks = []
|
||||
if self.saveTimerTasks(self.__timer_tasks_config_path, copy.deepcopy(timer_tasks)):
|
||||
if self.setTimerTasks(copy.deepcopy(timer_tasks)):
|
||||
self.__timer_tasks = timer_tasks
|
||||
self.updateTimerTaskList()
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def loadTimerTasks(
|
||||
self,
|
||||
timer_tasks_config_path: str
|
||||
def getTimerTasks(
|
||||
self
|
||||
) -> list:
|
||||
|
||||
try:
|
||||
if not timer_tasks_config_path or not os.path.exists(timer_tasks_config_path):
|
||||
raise Exception("定时任务配置文件不存在")
|
||||
timer_tasks = ConfigReader(timer_tasks_config_path).getConfigs()
|
||||
timer_tasks = self.__cfg_mgr.get(CfgKey.TIMERTASK.ROOT)
|
||||
if timer_tasks and "timer_tasks" in timer_tasks:
|
||||
for task in timer_tasks["timer_tasks"]:
|
||||
task["add_time"] = datetime.strptime(task["add_time"], "%Y-%m-%d %H:%M:%S")
|
||||
task["added_time"] = datetime.strptime(task["added_time"], "%Y-%m-%d %H:%M:%S")
|
||||
task["execute_time"] = datetime.strptime(task["execute_time"], "%Y-%m-%d %H:%M:%S")
|
||||
task["status"] = ALTimerTaskStatus(task["status"])
|
||||
if "repeat_history" in task:
|
||||
for item in task["repeat_history"]:
|
||||
item["result"] = ALTimerTaskStatus(item["result"])
|
||||
return timer_tasks["timer_tasks"]
|
||||
raise Exception("定时任务配置文件格式错误")
|
||||
except Exception:
|
||||
except Exception as e:
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
"警告 - AutoLibrary",
|
||||
f"加载定时任务配置发生错误 ! : \n{e}"
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def saveTimerTasks(
|
||||
def setTimerTasks(
|
||||
self,
|
||||
timer_tasks_config_path: str,
|
||||
timer_tasks: list
|
||||
) -> bool:
|
||||
|
||||
try:
|
||||
if not timer_tasks_config_path:
|
||||
raise Exception("配置文件路径为空")
|
||||
for task in timer_tasks:
|
||||
task["add_time"] = task["add_time"].strftime("%Y-%m-%d %H:%M:%S")
|
||||
task["added_time"] = task["added_time"].strftime("%Y-%m-%d %H:%M:%S")
|
||||
task["execute_time"] = task["execute_time"].strftime("%Y-%m-%d %H:%M:%S")
|
||||
task["status"] = task["status"].value
|
||||
ConfigWriter(
|
||||
timer_tasks_config_path,
|
||||
{ "timer_tasks": timer_tasks }
|
||||
)
|
||||
if "repeat_history" in task:
|
||||
for item in task["repeat_history"]:
|
||||
item["result"] = item["result"].value
|
||||
self.__cfg_mgr.set(CfgKey.TIMERTASK.ROOT, { "timer_tasks": timer_tasks })
|
||||
return True
|
||||
except Exception as e:
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
"警告 - AutoLibrary",
|
||||
f"保存定时任务配置发生错误 ! : {e}\n"\
|
||||
f"文件路径: {timer_tasks_config_path}"
|
||||
f"保存定时任务配置发生错误 ! : \n{e}"
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
def showEvent(
|
||||
self,
|
||||
event
|
||||
@@ -267,17 +322,15 @@ class ALTimerTaskManageWidget(QWidget, Ui_ALTimerTaskManageWidget):
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def closeEvent(
|
||||
self,
|
||||
event: QCloseEvent
|
||||
):
|
||||
|
||||
self.hide()
|
||||
self.timerTaskManageWidgetClosed.emit()
|
||||
self.timerTaskManageWidgetIsClosed.emit()
|
||||
event.ignore()
|
||||
|
||||
|
||||
def sortTimerTasks(
|
||||
self,
|
||||
policy: SortPolicy = SortPolicy.BY_EXECUTE_TIME,
|
||||
@@ -291,7 +344,7 @@ class ALTimerTaskManageWidget(QWidget, Ui_ALTimerTaskManageWidget):
|
||||
)
|
||||
elif policy == self.SortPolicy.BY_ADD_TIME:
|
||||
self.__timer_tasks.sort(
|
||||
key = lambda x: x["add_time"],
|
||||
key = lambda x: x["added_time"],
|
||||
reverse = order is Qt.SortOrder.DescendingOrder
|
||||
)
|
||||
elif policy == self.SortPolicy.BY_EXECUTE_TIME:
|
||||
@@ -300,7 +353,6 @@ class ALTimerTaskManageWidget(QWidget, Ui_ALTimerTaskManageWidget):
|
||||
reverse = order is Qt.SortOrder.DescendingOrder
|
||||
)
|
||||
|
||||
|
||||
def updateStat(
|
||||
self
|
||||
):
|
||||
@@ -327,7 +379,6 @@ class ALTimerTaskManageWidget(QWidget, Ui_ALTimerTaskManageWidget):
|
||||
self.ExecutedTaskLabel.setText(f"已执行:{executed}")
|
||||
self.InvalidTaskLabel.setText(f"无效的:{invalid}")
|
||||
|
||||
|
||||
def updateTimerTaskList(
|
||||
self
|
||||
):
|
||||
@@ -335,40 +386,91 @@ class ALTimerTaskManageWidget(QWidget, Ui_ALTimerTaskManageWidget):
|
||||
self.TimerTasksListWidget.clear()
|
||||
self.sortTimerTasks(self.__sort_policy, self.__sort_order)
|
||||
for timer_task in self.__timer_tasks:
|
||||
item = QListWidgetItem()
|
||||
item.setData(Qt.UserRole, timer_task)
|
||||
widget = ALTimerTaskItemWidget(self, timer_task)
|
||||
widget.DeleteButton.clicked.connect(
|
||||
lambda _, uuid = timer_task["task_uuid"]: self.deleteTask(uuid)
|
||||
Item = QListWidgetItem()
|
||||
Item.setData(Qt.UserRole, timer_task)
|
||||
Widget = ALTimerTaskItemWidget(self, timer_task)
|
||||
Widget.DeleteButton.clicked.connect(
|
||||
lambda _, task = timer_task: self.deleteTask(task)
|
||||
)
|
||||
item.setSizeHint(widget.size())
|
||||
self.TimerTasksListWidget.addItem(item)
|
||||
self.TimerTasksListWidget.setItemWidget(item, widget)
|
||||
|
||||
if timer_task.get("repeat", False) and hasattr(Widget, "HistoryButton"):
|
||||
Widget.HistoryButton.clicked.connect(
|
||||
lambda _, task = timer_task: self.showTaskHistory(task)
|
||||
)
|
||||
Widget.editRequested.connect(self.editTask)
|
||||
Item.setSizeHint(Widget.size())
|
||||
self.TimerTasksListWidget.addItem(Item)
|
||||
self.TimerTasksListWidget.setItemWidget(Item, Widget)
|
||||
|
||||
def addTask(
|
||||
self
|
||||
):
|
||||
|
||||
dialog = ALTimerTaskAddDialog(self)
|
||||
if dialog.exec() == QDialog.DialogCode.Accepted:
|
||||
timer_task = dialog.getTimerTask()
|
||||
Dialog = ALTimerTaskAddDialog(self)
|
||||
if Dialog.exec() == QDialog.DialogCode.Accepted:
|
||||
timer_task = Dialog.getTimerTask()
|
||||
self.__timer_tasks.append(timer_task)
|
||||
self.timerTasksChanged.emit()
|
||||
|
||||
def editTask(
|
||||
self,
|
||||
timer_task: dict
|
||||
):
|
||||
|
||||
Dialog = ALTimerTaskAddDialog(self, timer_task)
|
||||
if Dialog.exec() == QDialog.DialogCode.Accepted:
|
||||
updated = Dialog.getTimerTask()
|
||||
for i, task in enumerate(self.__timer_tasks):
|
||||
if task["uuid"] == updated["uuid"]:
|
||||
self.__timer_tasks[i] = updated
|
||||
break
|
||||
self.timerTasksChanged.emit()
|
||||
|
||||
@staticmethod
|
||||
def getTimerTaskDetailMessage(
|
||||
timer_task: dict
|
||||
):
|
||||
|
||||
if "repeat_history" not in timer_task:
|
||||
history = []
|
||||
else:
|
||||
history = timer_task["repeat_history"]
|
||||
history_count = len(history)
|
||||
return (
|
||||
f"任务名称:{timer_task["name"]}\n"
|
||||
f"添加时间:{timer_task["added_time"]}\n"
|
||||
f"当前状态:{timer_task["status"].value}\n"
|
||||
f"下次执行时间:{datetime.strftime(timer_task["execute_time"], "%Y-%m-%d %H:%M:%S")}\n"
|
||||
f"已记录次数:{history_count}"
|
||||
)
|
||||
|
||||
def deleteTask(
|
||||
self,
|
||||
task_uuid: str
|
||||
timer_task: dict
|
||||
):
|
||||
|
||||
if timer_task["repeat"]: # when delete a repeat task
|
||||
MsgBox = QMessageBox(self)
|
||||
MsgBox.setIcon(QMessageBox.Icon.Question)
|
||||
MsgBox.setWindowTitle("警告 - AutoLibrary")
|
||||
MsgBox.setStandardButtons(
|
||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
|
||||
)
|
||||
MsgBox.setText("删除可重复性任务将同时删除所有已执行的记录 !\n是否继续 ?")
|
||||
MsgBox.setDetailedText(
|
||||
"以下可重复性任务将被删除:\n"\
|
||||
"\n"
|
||||
f"{self.getTimerTaskDetailMessage(timer_task)}"
|
||||
)
|
||||
result = MsgBox.exec()
|
||||
if result != QMessageBox.StandardButton.Yes:
|
||||
return
|
||||
task_uuid = timer_task["uuid"]
|
||||
self.__timer_tasks = [
|
||||
x for x in self.__timer_tasks
|
||||
if x["task_uuid"] != task_uuid
|
||||
if x["uuid"] != task_uuid
|
||||
]
|
||||
self.timerTasksChanged.emit()
|
||||
|
||||
|
||||
def clearAllTasks(
|
||||
self
|
||||
):
|
||||
@@ -381,8 +483,9 @@ class ALTimerTaskManageWidget(QWidget, Ui_ALTimerTaskManageWidget):
|
||||
"是否要清除所有定时任务 ?",
|
||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
|
||||
)
|
||||
if result is QMessageBox.StandardButton.No:
|
||||
if result == QMessageBox.StandardButton.No:
|
||||
return
|
||||
# READY and RUNNING tasks cannot be cleared
|
||||
in_queue_tasks = [
|
||||
x for x in self.__timer_tasks
|
||||
if x["status"] == ALTimerTaskStatus.READY
|
||||
@@ -393,11 +496,50 @@ class ALTimerTaskManageWidget(QWidget, Ui_ALTimerTaskManageWidget):
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
"警告 - AutoLibrary",
|
||||
"存在正在执行或已就绪的队列任务,无法清除所有定时任务 !"
|
||||
f"存在 {in_queue_count} 个正在执行或已就绪的队列任务,无法清除所有定时任务 !"
|
||||
)
|
||||
self.__timer_tasks = in_queue_tasks
|
||||
return
|
||||
# repeat tasks ask before clear
|
||||
repeat_tasks = [
|
||||
x for x in self.__timer_tasks
|
||||
if x.get("repeat", False)
|
||||
]
|
||||
repeat_tasks_count = len(repeat_tasks)
|
||||
if repeat_tasks_count > 0:
|
||||
MsgBox = QMessageBox(self)
|
||||
MsgBox.setIcon(QMessageBox.Icon.Question)
|
||||
MsgBox.setWindowTitle("警告 - AutoLibrary")
|
||||
MsgBox.setStandardButtons(
|
||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
|
||||
)
|
||||
MsgBox.setText(
|
||||
f"存在 {repeat_tasks_count} 个可重复性任务,\n"
|
||||
"删除可重复性任务将同时删除所有已执行的记录 !\n"
|
||||
"是否继续 ?"
|
||||
)
|
||||
delete_msgs = [
|
||||
self.getTimerTaskDetailMessage(x) for x in repeat_tasks
|
||||
]
|
||||
MsgBox.setDetailedText(
|
||||
"以下可重复性任务将被删除:\n"\
|
||||
"\n"
|
||||
f"{"\n\n".join(delete_msgs)}"
|
||||
)
|
||||
result = MsgBox.exec()
|
||||
if result != QMessageBox.StandardButton.Yes:
|
||||
return
|
||||
# clear all tasks
|
||||
self.__timer_tasks.clear()
|
||||
self.timerTasksChanged.emit()
|
||||
|
||||
def showTaskHistory(
|
||||
self,
|
||||
task: dict
|
||||
):
|
||||
|
||||
Dialog = ALTimerTaskHistoryDialog(self, task)
|
||||
if Dialog.exec() == QDialog.DialogCode.Accepted:
|
||||
self.timerTasksChanged.emit()
|
||||
|
||||
def checkTasks(
|
||||
self
|
||||
@@ -412,7 +554,10 @@ class ALTimerTaskManageWidget(QWidget, Ui_ALTimerTaskManageWidget):
|
||||
if timer_task["status"] is not ALTimerTaskStatus.PENDING:
|
||||
continue
|
||||
if timer_task["execute_time"] <= now + timedelta(seconds = -5):
|
||||
timer_task["status"] = ALTimerTaskStatus.OUTDATED
|
||||
if timer_task.get("repeat", False):
|
||||
self.onRepeatTimerTaskIs(ALTimerTaskStatus.OUTDATED, timer_task)
|
||||
else:
|
||||
timer_task["status"] = ALTimerTaskStatus.OUTDATED
|
||||
need_update = True
|
||||
else:
|
||||
timer_task["status"] = ALTimerTaskStatus.READY
|
||||
@@ -453,11 +598,10 @@ class ALTimerTaskManageWidget(QWidget, Ui_ALTimerTaskManageWidget):
|
||||
self
|
||||
):
|
||||
|
||||
self.saveTimerTasks(self.__timer_tasks_config_path, copy.deepcopy(self.__timer_tasks))
|
||||
self.setTimerTasks(copy.deepcopy(self.__timer_tasks))
|
||||
self.updateTimerTaskList()
|
||||
self.updateStat()
|
||||
|
||||
|
||||
@Slot(dict)
|
||||
def onTimerTaskIsRunning(
|
||||
self,
|
||||
@@ -465,10 +609,61 @@ class ALTimerTaskManageWidget(QWidget, Ui_ALTimerTaskManageWidget):
|
||||
):
|
||||
|
||||
for task in self.__timer_tasks:
|
||||
if task["task_uuid"] == timer_task["task_uuid"]:
|
||||
if task["uuid"] == timer_task["uuid"]:
|
||||
task["status"] = ALTimerTaskStatus.RUNNING
|
||||
break
|
||||
self.timerTasksChanged.emit()
|
||||
|
||||
def onRepeatTimerTaskIs(
|
||||
self,
|
||||
status: ALTimerTaskStatus,
|
||||
timer_task: dict
|
||||
) -> dict:
|
||||
|
||||
# only these status are valid
|
||||
valid_statuses = {ALTimerTaskStatus.EXECUTED, ALTimerTaskStatus.ERROR,
|
||||
ALTimerTaskStatus.OUTDATED}
|
||||
if status not in valid_statuses:
|
||||
return timer_task
|
||||
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["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,
|
||||
"duration": duration,
|
||||
"uuid": timer_task["uuid"]
|
||||
})
|
||||
else:
|
||||
current_time = datetime.now()
|
||||
execute_time = timer_task["execute_time"]
|
||||
execute_weekday = execute_time.weekday()
|
||||
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["repeat_history"].append({
|
||||
"execute_time": (execute_time + timedelta(days=i)).strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"executed_time": (execute_time + timedelta(days=i)).strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"result": status,
|
||||
"duration": 0,
|
||||
"uuid": timer_task["uuid"]
|
||||
})
|
||||
next_time = TimerUtils.getNextTimerRepeatTime(
|
||||
timer_task["repeat_days"],
|
||||
timer_task["repeat_hour"],
|
||||
timer_task["repeat_minute"],
|
||||
timer_task["repeat_second"]
|
||||
)
|
||||
if next_time:
|
||||
timer_task["execute_time"] = next_time
|
||||
timer_task["status"] = ALTimerTaskStatus.PENDING
|
||||
timer_task["executed"] = False
|
||||
else:
|
||||
timer_task["status"] = status
|
||||
return timer_task
|
||||
|
||||
@Slot(dict)
|
||||
def onTimerTaskIsExecuted(
|
||||
@@ -477,8 +672,12 @@ class ALTimerTaskManageWidget(QWidget, Ui_ALTimerTaskManageWidget):
|
||||
):
|
||||
|
||||
for task in self.__timer_tasks:
|
||||
if task["task_uuid"] == timer_task["task_uuid"]:
|
||||
task["status"] = ALTimerTaskStatus.EXECUTED
|
||||
if task["uuid"] == timer_task["uuid"]:
|
||||
if task.get("repeat", False):
|
||||
self.onRepeatTimerTaskIs(ALTimerTaskStatus.EXECUTED, task)
|
||||
else:
|
||||
task["status"] = ALTimerTaskStatus.EXECUTED
|
||||
break
|
||||
self.timerTasksChanged.emit()
|
||||
|
||||
@Slot(dict)
|
||||
@@ -488,6 +687,10 @@ class ALTimerTaskManageWidget(QWidget, Ui_ALTimerTaskManageWidget):
|
||||
):
|
||||
|
||||
for task in self.__timer_tasks:
|
||||
if task["task_uuid"] == timer_task["task_uuid"]:
|
||||
task["status"] = ALTimerTaskStatus.ERROR
|
||||
if task["uuid"] == timer_task["uuid"]:
|
||||
if task.get("repeat", False):
|
||||
self.onRepeatTimerTaskIs(ALTimerTaskStatus.ERROR, task)
|
||||
else:
|
||||
task["status"] = ALTimerTaskStatus.ERROR
|
||||
break
|
||||
self.timerTasksChanged.emit()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Copyright (c) 2025 - 2026 KenanZhu.
|
||||
Copyright (c) 2025 KenanZhu.
|
||||
All rights reserved.
|
||||
|
||||
This software is provided "as is", without any warranty of any kind.
|
||||
@@ -10,14 +10,22 @@ See the LICENSE file for details.
|
||||
from enum import Enum
|
||||
|
||||
from PySide6.QtCore import (
|
||||
Qt, QSize, QCoreApplication, QRect, QPoint
|
||||
Qt,
|
||||
QSize,
|
||||
QCoreApplication,
|
||||
QRect,
|
||||
QPoint
|
||||
)
|
||||
from PySide6.QtWidgets import (
|
||||
QAbstractScrollArea, QAbstractItemView,
|
||||
QTreeWidget, QTreeWidgetItem
|
||||
QAbstractScrollArea,
|
||||
QAbstractItemView,
|
||||
QTreeWidget,
|
||||
QTreeWidgetItem
|
||||
)
|
||||
from PySide6.QtGui import (
|
||||
QDragEnterEvent, QDragMoveEvent, QDropEvent
|
||||
QDragEnterEvent,
|
||||
QDragMoveEvent,
|
||||
QDropEvent
|
||||
)
|
||||
|
||||
|
||||
@@ -39,14 +47,13 @@ class ALUserTreeWidget(QTreeWidget):
|
||||
self.setupUi()
|
||||
self.translateUi()
|
||||
|
||||
|
||||
def setupUi(
|
||||
self
|
||||
):
|
||||
|
||||
__qtreewidgetitem = QTreeWidgetItem()
|
||||
__qtreewidgetitem.setText(0, u"\u5206\u7ec4/\u7528\u6237");
|
||||
self.setHeaderItem(__qtreewidgetitem)
|
||||
__QTreeWidgetItem = QTreeWidgetItem()
|
||||
__QTreeWidgetItem.setText(0, u"\u5206\u7ec4/\u7528\u6237");
|
||||
self.setHeaderItem(__QTreeWidgetItem)
|
||||
self.setObjectName(u"UserTreeWidget")
|
||||
self.setMinimumSize(QSize(230, 0))
|
||||
self.setMaximumSize(QSize(250, 16777215))
|
||||
@@ -70,14 +77,12 @@ class ALUserTreeWidget(QTreeWidget):
|
||||
self.header().setHighlightSections(False)
|
||||
self.header().setProperty(u"showSortIndicator", True)
|
||||
|
||||
|
||||
def translateUi(
|
||||
self
|
||||
):
|
||||
|
||||
___qtreewidgetitem = self.headerItem()
|
||||
___qtreewidgetitem.setText(1, QCoreApplication.translate("ALConfigWidget", u"\u72b6\u6001", None));
|
||||
|
||||
___QTreeWidgetItem = self.headerItem()
|
||||
___QTreeWidgetItem.setText(1, QCoreApplication.translate("ALConfigWidget", u"\u72b6\u6001", None));
|
||||
|
||||
@staticmethod
|
||||
def isDragPositionValid(
|
||||
@@ -90,7 +95,6 @@ class ALUserTreeWidget(QTreeWidget):
|
||||
y_offset < target_rect.height()*0.8)
|
||||
return valid
|
||||
|
||||
|
||||
def dragEnterEvent(
|
||||
self,
|
||||
event: QDragEnterEvent
|
||||
@@ -98,7 +102,6 @@ class ALUserTreeWidget(QTreeWidget):
|
||||
|
||||
super().dragEnterEvent(event)
|
||||
|
||||
|
||||
def dragMoveEvent(
|
||||
self,
|
||||
event: QDragMoveEvent
|
||||
@@ -106,27 +109,27 @@ class ALUserTreeWidget(QTreeWidget):
|
||||
|
||||
super().dragMoveEvent(event)
|
||||
|
||||
source_item = self.currentItem()
|
||||
target_item = self.itemAt(event.position().toPoint())
|
||||
if source_item is None:
|
||||
SourceItem = self.currentItem()
|
||||
TargetItem = self.itemAt(event.position().toPoint())
|
||||
if SourceItem is None:
|
||||
event.ignore()
|
||||
return
|
||||
if source_item.type() == ALUserTreeItemType.GROUP.value:
|
||||
if target_item is not None:
|
||||
if SourceItem.type() == ALUserTreeItemType.GROUP.value:
|
||||
if TargetItem is not None:
|
||||
event.ignore()
|
||||
return
|
||||
elif source_item.type() == ALUserTreeItemType.USER.value:
|
||||
if target_item is None:
|
||||
elif SourceItem.type() == ALUserTreeItemType.USER.value:
|
||||
if TargetItem is None:
|
||||
event.ignore()
|
||||
return
|
||||
if target_item.type() != ALUserTreeItemType.GROUP.value:
|
||||
if TargetItem.type() != ALUserTreeItemType.GROUP.value:
|
||||
event.ignore()
|
||||
return
|
||||
if target_item.checkState(1) == Qt.CheckState.Unchecked:
|
||||
if TargetItem.checkState(1) == Qt.CheckState.Unchecked:
|
||||
event.ignore()
|
||||
return
|
||||
if not self.isDragPositionValid(
|
||||
self.visualItemRect(target_item),
|
||||
self.visualItemRect(TargetItem),
|
||||
event.position().toPoint()
|
||||
):
|
||||
event.ignore()
|
||||
@@ -136,7 +139,6 @@ class ALUserTreeWidget(QTreeWidget):
|
||||
return
|
||||
event.acceptProposedAction()
|
||||
|
||||
|
||||
def dropEvent(
|
||||
self,
|
||||
event: QDropEvent
|
||||
|
||||
@@ -5,11 +5,11 @@
|
||||
workflow process. Do not edit manually.
|
||||
|
||||
This file is auto-generated during the workflow process.
|
||||
Last updated: 2026-02-16 07:04:48 UTC
|
||||
Last updated: 2026-05-09 06:05:13 UTC
|
||||
"""
|
||||
|
||||
AL_VERSION = "1.0.5"
|
||||
AL_TAG = "v1.0.5"
|
||||
AL_VERSION = "1.3.0"
|
||||
AL_TAG = "v1.3.0"
|
||||
AL_COMMIT_SHA = "local"
|
||||
AL_COMMIT_DATE = "null" # time zone : UTC
|
||||
AL_BUILD_DATE = "null" # time zone : UTC
|
||||
|
||||
@@ -0,0 +1,567 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Copyright (c) 2026 KenanZhu.
|
||||
All rights reserved.
|
||||
|
||||
This software is provided "as is", without any warranty of any kind.
|
||||
You may use, modify, and distribute this file under the terms of the MIT License.
|
||||
See the LICENSE file for details.
|
||||
"""
|
||||
import threading
|
||||
from typing import Optional
|
||||
|
||||
from PySide6.QtCore import (
|
||||
Qt,
|
||||
Slot,
|
||||
QThread,
|
||||
Signal
|
||||
)
|
||||
from PySide6.QtWidgets import (
|
||||
QDialog,
|
||||
QLabel,
|
||||
QComboBox,
|
||||
QProgressBar,
|
||||
QPushButton,
|
||||
QVBoxLayout,
|
||||
QHBoxLayout,
|
||||
QMessageBox,
|
||||
QFrame,
|
||||
QLineEdit
|
||||
)
|
||||
from PySide6.QtGui import QCloseEvent
|
||||
|
||||
from managers.driver.WebDriverManager import (
|
||||
instance as webdriverInstance,
|
||||
WebDriverManager,
|
||||
WebDriverInfo,
|
||||
WebDriverType,
|
||||
WebDriverStatus
|
||||
)
|
||||
from gui.ALStatusLabel import ALStatusLabel
|
||||
|
||||
|
||||
class DownloadWorker(QThread):
|
||||
"""
|
||||
Worker thread for downloading web drivers.
|
||||
"""
|
||||
|
||||
progress = Signal(float, int, float, str)
|
||||
downloadFinished = Signal(object, str)
|
||||
downloadError = Signal(str)
|
||||
downloadCancelled = Signal()
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
driver_manager: WebDriverManager,
|
||||
driver_info: WebDriverInfo
|
||||
):
|
||||
super().__init__()
|
||||
self.__driver_manager = driver_manager
|
||||
self.__driver_info = driver_info
|
||||
self.__driver_path = None
|
||||
self.__cancelled = False
|
||||
self.__cancel_event = threading.Event()
|
||||
|
||||
def cancel(
|
||||
self
|
||||
):
|
||||
"""
|
||||
Cancel the download operation.
|
||||
"""
|
||||
|
||||
self.__cancelled = True
|
||||
self.__cancel_event.set()
|
||||
|
||||
def run(
|
||||
self
|
||||
):
|
||||
try:
|
||||
if self.__cancelled:
|
||||
self.downloadCancelled.emit()
|
||||
return
|
||||
self.__driver_path = self.__driver_manager.installDriver(
|
||||
self.__driver_info,
|
||||
progress_callback=self.onProgress,
|
||||
cancel_event=self.__cancel_event
|
||||
)
|
||||
if self.__cancelled:
|
||||
self.downloadCancelled.emit()
|
||||
return
|
||||
if self.__driver_path:
|
||||
self.downloadFinished.emit(self.__driver_path, "")
|
||||
else:
|
||||
self.downloadError.emit("下载失败: 未返回有效路径")
|
||||
except Exception as e:
|
||||
if not self.__cancelled:
|
||||
self.downloadError.emit(f"下载失败: {str(e)}")
|
||||
|
||||
def onProgress(
|
||||
self,
|
||||
downloaded: float,
|
||||
total: int,
|
||||
speed: float,
|
||||
message: str
|
||||
):
|
||||
|
||||
if self.__cancel_event.is_set():
|
||||
self.__cancelled = True
|
||||
if not self.__cancelled:
|
||||
self.progress.emit(downloaded, total, speed, message)
|
||||
|
||||
def stop(
|
||||
self
|
||||
):
|
||||
"""
|
||||
Cancel and wait for the thread to finish.
|
||||
Must only be called from the main thread.
|
||||
"""
|
||||
|
||||
self.cancel()
|
||||
if not self.isFinished():
|
||||
if not self.wait(5000):
|
||||
self.terminate()
|
||||
self.wait()
|
||||
|
||||
|
||||
class ALWebDriverDownloadDialog(QDialog):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent: Optional[QDialog] = None,
|
||||
driver_dir: str = ""
|
||||
):
|
||||
"""
|
||||
Web driver download dialog.
|
||||
|
||||
Args:
|
||||
parent: Parent widget.
|
||||
driver_dir: Driver directory path.
|
||||
"""
|
||||
|
||||
super().__init__(parent)
|
||||
|
||||
self.__driver_dir = driver_dir
|
||||
self.__driver_manager: Optional[WebDriverManager] = None
|
||||
self.__confirmed = False
|
||||
self.__selected_driver_info: Optional[WebDriverInfo] = None
|
||||
self.__driver_infos: list[WebDriverInfo] = []
|
||||
self.__download_thread: Optional[DownloadWorker] = None
|
||||
|
||||
self.setupUi()
|
||||
self.connectSignals()
|
||||
self.initializeDriverManager()
|
||||
self.refreshDriverList()
|
||||
|
||||
def showEvent(
|
||||
self,
|
||||
event
|
||||
):
|
||||
|
||||
result = super().showEvent(event)
|
||||
if self.parent():
|
||||
screen_rect = self.screen().geometry()
|
||||
target_pos = self.parent().geometry().center()
|
||||
target_pos.setX(target_pos.x() - self.width()//2)
|
||||
target_pos.setY(target_pos.y() - self.height()//2)
|
||||
if target_pos.x() < 0:
|
||||
target_pos.setX(0)
|
||||
if target_pos.x() + self.width() > screen_rect.width():
|
||||
target_pos.setX(screen_rect.width() - self.width())
|
||||
if target_pos.y() < 0:
|
||||
target_pos.setY(0)
|
||||
if target_pos.y() + self.height() > screen_rect.height():
|
||||
target_pos.setY(screen_rect.height() - self.height())
|
||||
self.move(target_pos)
|
||||
return result
|
||||
|
||||
def setupUi(
|
||||
self
|
||||
):
|
||||
|
||||
self.setModal(True)
|
||||
self.setMaximumHeight(240)
|
||||
self.setMinimumHeight(240)
|
||||
self.setWindowTitle("浏览器驱动下载 - AutoLibrary")
|
||||
self.MainLayout = QVBoxLayout(self)
|
||||
self.MainLayout.setContentsMargins(5, 5, 5, 5)
|
||||
self.MainLayout.setSpacing(5)
|
||||
self.BrowserCountLabel = QLabel("检测到 0 个可用浏览器:")
|
||||
self.MainLayout.addWidget(self.BrowserCountLabel)
|
||||
self.DriverInfoLayout = QHBoxLayout()
|
||||
self.DriverInfoLayout.setSpacing(5)
|
||||
self.DriverComboBox = QComboBox()
|
||||
self.DriverInfoLayout.addWidget(self.DriverComboBox)
|
||||
self.StatusLabel = ALStatusLabel()
|
||||
self.StatusLabel.setFixedSize(32, 32)
|
||||
self.DriverInfoLayout.addWidget(self.StatusLabel)
|
||||
self.MainLayout.addLayout(self.DriverInfoLayout)
|
||||
self.DetailLayout = QVBoxLayout()
|
||||
self.DetailLayout.setSpacing(5)
|
||||
self.DetailLayout.setContentsMargins(5, 5, 5, 5)
|
||||
self.BrowserTypeLabel = QLabel("类型:")
|
||||
self.DetailLayout.addWidget(self.BrowserTypeLabel)
|
||||
self.VersionLabel = QLabel("版本:")
|
||||
self.DetailLayout.addWidget(self.VersionLabel)
|
||||
self.PathLabel = QLineEdit()
|
||||
self.PathLabel.setReadOnly(True)
|
||||
self.PathLabel.setText("路径:未安装")
|
||||
self.DetailLayout.addWidget(self.PathLabel)
|
||||
self.MainLayout.addLayout(self.DetailLayout)
|
||||
self.Line = QFrame()
|
||||
self.Line.setFrameShape(QFrame.Shape.HLine)
|
||||
self.Line.setFrameShadow(QFrame.Shadow.Sunken)
|
||||
self.MainLayout.addWidget(self.Line)
|
||||
self.ProgressBar = QProgressBar()
|
||||
self.ProgressBar.setValue(0)
|
||||
self.ProgressBar.setTextVisible(False)
|
||||
self.MainLayout.addWidget(self.ProgressBar)
|
||||
self.ProgressText = QLabel("")
|
||||
self.ProgressText.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self.MainLayout.addWidget(self.ProgressText)
|
||||
self.ControlLayout = QHBoxLayout()
|
||||
self.ControlLayout.setSpacing(8)
|
||||
self.ControlLayout.setAlignment(Qt.AlignmentFlag.AlignRight)
|
||||
self.RefreshButton = QPushButton("刷新")
|
||||
self.RefreshButton.setFixedSize(80, 25)
|
||||
self.DownloadButton = QPushButton("下载驱动")
|
||||
self.DownloadButton.setFixedSize(80, 25)
|
||||
self.DeleteButton = QPushButton("删除驱动")
|
||||
self.DeleteButton.setFixedSize(80, 25)
|
||||
self.CancelButton = QPushButton("取消")
|
||||
self.CancelButton.setFixedSize(80, 25)
|
||||
self.ConfirmButton = QPushButton("确认")
|
||||
self.ConfirmButton.setFixedSize(80, 25)
|
||||
self.ConfirmButton.setEnabled(False)
|
||||
self.ControlLayout.addWidget(self.RefreshButton)
|
||||
self.ControlLayout.addWidget(self.DownloadButton)
|
||||
self.ControlLayout.addWidget(self.DeleteButton)
|
||||
self.ControlLayout.addWidget(self.CancelButton)
|
||||
self.ControlLayout.addWidget(self.ConfirmButton)
|
||||
self.MainLayout.addLayout(self.ControlLayout)
|
||||
|
||||
def connectSignals(
|
||||
self
|
||||
):
|
||||
|
||||
self.RefreshButton.clicked.connect(self.onRefreshButtonClicked)
|
||||
self.DownloadButton.clicked.connect(self.onDownloadButtonClicked)
|
||||
self.DeleteButton.clicked.connect(self.onDeleteButtonClicked)
|
||||
self.CancelButton.clicked.connect(self.onCancelButtonClicked)
|
||||
self.ConfirmButton.clicked.connect(self.onConfirmButtonClicked)
|
||||
self.DriverComboBox.currentIndexChanged.connect(self.onDriverComboBoxChanged)
|
||||
|
||||
def initializeDriverManager(
|
||||
self
|
||||
):
|
||||
|
||||
try:
|
||||
self.__driver_manager = webdriverInstance(self.__driver_dir)
|
||||
except ValueError as e:
|
||||
QMessageBox.warning(self, "初始化失败", f"WebDriverManager 初始化失败:\n{str(e)}")
|
||||
self.reject()
|
||||
|
||||
def refreshDriverList(
|
||||
self
|
||||
):
|
||||
|
||||
if not self.__driver_manager:
|
||||
return
|
||||
self.__driver_manager.refresh()
|
||||
self.__driver_infos = self.__driver_manager.getDriverInfos()
|
||||
self.DriverComboBox.clear()
|
||||
installed = 0
|
||||
installed_idx = 0
|
||||
for i, driver_info in enumerate(self.__driver_infos):
|
||||
display_text = f"{driver_info.driver_type.value} - {driver_info.browser_version}"
|
||||
if driver_info.driver_status == WebDriverStatus.INSTALLED:
|
||||
installed += 1
|
||||
installed_idx = i # get the installed driver index
|
||||
display_text += " : 已安装"
|
||||
self.DriverComboBox.addItem(display_text)
|
||||
count = len(self.__driver_infos)
|
||||
self.BrowserCountLabel.setText(f"检测到 {count} 个可用浏览器,{installed} 个已安装驱动:")
|
||||
if self.__driver_infos:
|
||||
self.DriverComboBox.setCurrentIndex(installed_idx)
|
||||
|
||||
def onDriverComboBoxChanged(
|
||||
self,
|
||||
index: int
|
||||
):
|
||||
|
||||
if not self.__driver_infos or index < 0 or index >= len(self.__driver_infos):
|
||||
return
|
||||
driver_info = self.__driver_infos[index]
|
||||
self.updateDriverInfoDisplay(driver_info)
|
||||
self.updateProgressBarStates(driver_info)
|
||||
self.updateButtonStates(driver_info)
|
||||
|
||||
def closeEvent(
|
||||
self,
|
||||
event: QCloseEvent
|
||||
):
|
||||
|
||||
if self.__download_thread and self.__download_thread.isRunning():
|
||||
reply = QMessageBox.question(
|
||||
self,
|
||||
"确认关闭 - AutoLibrary",
|
||||
"驱动正在下载中, 确定要取消并关闭对话框吗 ?",
|
||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
|
||||
)
|
||||
if reply == QMessageBox.StandardButton.No:
|
||||
event.ignore()
|
||||
return
|
||||
self.__download_thread.stop()
|
||||
if not self.__confirmed:
|
||||
self.__selected_driver_info = None
|
||||
event.accept()
|
||||
super().closeEvent(event)
|
||||
|
||||
def onThreadFinished(
|
||||
self
|
||||
):
|
||||
|
||||
if self.__download_thread:
|
||||
self.__download_thread.deleteLater()
|
||||
self.__download_thread = None
|
||||
|
||||
def getSelectedDriverInfo(
|
||||
self
|
||||
) -> Optional[WebDriverInfo]:
|
||||
|
||||
return self.__selected_driver_info
|
||||
|
||||
def updateDriverInfoDisplay(
|
||||
self,
|
||||
driver_info: WebDriverInfo
|
||||
):
|
||||
|
||||
if driver_info.driver_type == WebDriverType.CHROME:
|
||||
driver_type = "Google Chrome"
|
||||
elif driver_info.driver_type == WebDriverType.FIREFOX:
|
||||
driver_type = "Mozilla Firefox"
|
||||
elif driver_info.driver_type == WebDriverType.EDGE:
|
||||
driver_type = "Microsoft Edge"
|
||||
else:
|
||||
driver_type = "未知"
|
||||
self.BrowserTypeLabel.setText(f"类型:{driver_type}")
|
||||
self.VersionLabel.setText(f"版本:{driver_info.driver_version}")
|
||||
if driver_info.driver_path:
|
||||
self.PathLabel.setText(str(driver_info.driver_path))
|
||||
else:
|
||||
self.PathLabel.setText("未安装")
|
||||
match driver_info.driver_status:
|
||||
case WebDriverStatus.NOT_INSTALLED:
|
||||
self.StatusLabel.status = ALStatusLabel.Status.WAITING
|
||||
case WebDriverStatus.INSTALLED:
|
||||
self.StatusLabel.status = ALStatusLabel.Status.SUCCESS
|
||||
case WebDriverStatus.DOWNLOADING:
|
||||
self.StatusLabel.status = ALStatusLabel.Status.RUNNING
|
||||
case WebDriverStatus.ERROR:
|
||||
self.StatusLabel.status = ALStatusLabel.Status.FAILURE
|
||||
|
||||
def updateProgressBarStates(
|
||||
self,
|
||||
driver_info: WebDriverInfo
|
||||
):
|
||||
|
||||
if driver_info.driver_status == WebDriverStatus.NOT_INSTALLED:
|
||||
self.ProgressBar.setValue(0)
|
||||
self.ProgressText.setText("未安装")
|
||||
elif driver_info.driver_status == WebDriverStatus.INSTALLED:
|
||||
self.ProgressBar.setValue(100)
|
||||
self.ProgressText.setText("已安装")
|
||||
elif driver_info.driver_status == WebDriverStatus.DOWNLOADING:
|
||||
pass # update by worker thread
|
||||
elif driver_info.driver_status == WebDriverStatus.ERROR:
|
||||
self.ProgressBar.setValue(0)
|
||||
self.ProgressText.setText("下载失败")
|
||||
|
||||
def updateButtonStates(
|
||||
self,
|
||||
driver_info: WebDriverInfo
|
||||
):
|
||||
|
||||
if driver_info.driver_status == WebDriverStatus.NOT_INSTALLED:
|
||||
self.RefreshButton.setEnabled(True)
|
||||
self.DeleteButton.setEnabled(False)
|
||||
self.DownloadButton.setEnabled(True)
|
||||
self.CancelButton.setEnabled(True)
|
||||
self.ConfirmButton.setEnabled(False)
|
||||
elif driver_info.driver_status == WebDriverStatus.INSTALLED:
|
||||
self.RefreshButton.setEnabled(True)
|
||||
self.DownloadButton.setEnabled(False)
|
||||
self.DeleteButton.setEnabled(True)
|
||||
self.CancelButton.setEnabled(True)
|
||||
self.ConfirmButton.setEnabled(True)
|
||||
elif driver_info.driver_status == WebDriverStatus.DOWNLOADING:
|
||||
self.RefreshButton.setEnabled(False)
|
||||
self.DownloadButton.setEnabled(False)
|
||||
self.DeleteButton.setEnabled(False)
|
||||
self.CancelButton.setEnabled(True)
|
||||
self.ConfirmButton.setEnabled(False)
|
||||
elif driver_info.driver_status == WebDriverStatus.ERROR:
|
||||
self.RefreshButton.setEnabled(True)
|
||||
self.DownloadButton.setEnabled(True)
|
||||
self.DeleteButton.setEnabled(False)
|
||||
self.CancelButton.setEnabled(True)
|
||||
self.ConfirmButton.setEnabled(False)
|
||||
|
||||
@Slot()
|
||||
def onRefreshButtonClicked(
|
||||
self
|
||||
):
|
||||
|
||||
self.refreshDriverList()
|
||||
|
||||
@Slot()
|
||||
def onDeleteButtonClicked(
|
||||
self
|
||||
):
|
||||
|
||||
index = self.DriverComboBox.currentIndex()
|
||||
if index < 0 or index >= len(self.__driver_infos):
|
||||
return
|
||||
driver_info = self.__driver_infos[index]
|
||||
if driver_info.driver_status.name != "INSTALLED":
|
||||
QMessageBox.information(self, "提示 - AutoLibrary", "该驱动未安装, 无需删除")
|
||||
return
|
||||
reply = QMessageBox.question(
|
||||
self,
|
||||
"确认删除 - AutoLibrary",
|
||||
f"确定要删除 {driver_info.driver_type.value} 驱动吗 ?",
|
||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
|
||||
)
|
||||
if reply == QMessageBox.StandardButton.No:
|
||||
return
|
||||
try:
|
||||
self.__driver_manager.uninstallDriver(driver_info)
|
||||
self.refreshDriverList()
|
||||
QMessageBox.information(self, "删除成功 - AutoLibrary", "驱动已成功删除")
|
||||
except Exception as e:
|
||||
QMessageBox.critical(self, "删除失败 - AutoLibrary", f"删除驱动时出错:\n{str(e)}")
|
||||
|
||||
@Slot()
|
||||
def onDownloadButtonClicked(
|
||||
self
|
||||
):
|
||||
|
||||
self.DriverComboBox.setEnabled(False)
|
||||
index = self.DriverComboBox.currentIndex()
|
||||
if index < 0 or index >= len(self.__driver_infos):
|
||||
return
|
||||
driver_info = self.__driver_infos[index]
|
||||
if driver_info.driver_status == WebDriverStatus.INSTALLED:
|
||||
return
|
||||
driver_info.driver_status = WebDriverStatus.DOWNLOADING # we set this only to update
|
||||
# the display, and we will set to not installed in the download thread
|
||||
self.updateDriverInfoDisplay(driver_info)
|
||||
self.updateProgressBarStates(driver_info)
|
||||
self.ProgressText.setText("准备开始下载...")
|
||||
self.updateButtonStates(driver_info)
|
||||
# set to not installed
|
||||
driver_info.driver_status = WebDriverStatus.NOT_INSTALLED
|
||||
self.__download_thread = DownloadWorker(self.__driver_manager, driver_info)
|
||||
self.__download_thread.progress.connect(self.onDownloadProgress)
|
||||
self.__download_thread.downloadFinished.connect(self.onDownloadFinished)
|
||||
self.__download_thread.downloadError.connect(self.onDownloadError)
|
||||
self.__download_thread.downloadCancelled.connect(self.onDownloadCancelled)
|
||||
self.__download_thread.finished.connect(self.onThreadFinished)
|
||||
self.__download_thread.start()
|
||||
|
||||
@Slot()
|
||||
def onDownloadProgress(
|
||||
self,
|
||||
downloaded: float,
|
||||
total: int,
|
||||
speed: float,
|
||||
message: str
|
||||
):
|
||||
|
||||
progress = downloaded
|
||||
self.ProgressBar.setValue(progress)
|
||||
if speed >= 1024:
|
||||
speed_text = f"{speed/1024:.1f} MB/s"
|
||||
else:
|
||||
speed_text = f"{speed:.1f} KB/s"
|
||||
progress_text = f"{message}... {downloaded:.1f}% - {speed_text}"
|
||||
self.ProgressText.setText(progress_text)
|
||||
|
||||
@Slot()
|
||||
def onDownloadFinished(
|
||||
self
|
||||
):
|
||||
|
||||
self.DriverComboBox.setEnabled(True)
|
||||
index = self.DriverComboBox.currentIndex()
|
||||
if 0 <= index < len(self.__driver_infos):
|
||||
driver_info = self.__driver_infos[index]
|
||||
driver_info.driver_status = WebDriverStatus.INSTALLED
|
||||
self.updateDriverInfoDisplay(driver_info)
|
||||
self.updateProgressBarStates(driver_info)
|
||||
self.updateButtonStates(driver_info)
|
||||
|
||||
@Slot()
|
||||
def onDownloadError(
|
||||
self,
|
||||
error_message: str
|
||||
):
|
||||
|
||||
self.DriverComboBox.setEnabled(True)
|
||||
index = self.DriverComboBox.currentIndex()
|
||||
if 0 <= index < len(self.__driver_infos):
|
||||
driver_info = self.__driver_infos[index]
|
||||
driver_info.driver_status = WebDriverStatus.ERROR
|
||||
self.updateDriverInfoDisplay(driver_info)
|
||||
self.updateProgressBarStates(driver_info)
|
||||
self.updateButtonStates(driver_info)
|
||||
QMessageBox.critical(self, "下载失败 - AutoLibrary", error_message)
|
||||
|
||||
@Slot()
|
||||
def onDownloadCancelled(
|
||||
self
|
||||
):
|
||||
|
||||
self.DriverComboBox.setEnabled(True)
|
||||
index = self.DriverComboBox.currentIndex()
|
||||
if 0 <= index < len(self.__driver_infos):
|
||||
driver_info = self.__driver_infos[index]
|
||||
self.__driver_manager.cancelDriverDownload(driver_info)
|
||||
driver_info.driver_status = WebDriverStatus.NOT_INSTALLED
|
||||
self.updateDriverInfoDisplay(driver_info)
|
||||
self.updateProgressBarStates(driver_info)
|
||||
self.updateButtonStates(driver_info)
|
||||
self.ProgressText.setText("下载已取消")
|
||||
|
||||
@Slot()
|
||||
def onConfirmButtonClicked(
|
||||
self
|
||||
):
|
||||
|
||||
index = self.DriverComboBox.currentIndex()
|
||||
if index < 0 or index >= len(self.__driver_infos):
|
||||
return
|
||||
driver_info = self.__driver_infos[index]
|
||||
if driver_info.driver_status != WebDriverStatus.INSTALLED:
|
||||
return
|
||||
self.__selected_driver_info = driver_info
|
||||
self.__confirmed = True
|
||||
self.accept()
|
||||
|
||||
@Slot()
|
||||
def onCancelButtonClicked(
|
||||
self
|
||||
):
|
||||
|
||||
if self.__download_thread:
|
||||
reply = QMessageBox.question(
|
||||
self,
|
||||
"确认取消 - AutoLibrary",
|
||||
"正在下载中, 确定要取消下载吗 ?",
|
||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
|
||||
)
|
||||
if reply == QMessageBox.StandardButton.Yes:
|
||||
self.__download_thread.cancel()
|
||||
else:
|
||||
self.__confirmed = False
|
||||
self.__selected_driver_info = None
|
||||
self.reject()
|
||||
@@ -0,0 +1,9 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Copyright (c) 2026 KenanZhu.
|
||||
All rights reserved.
|
||||
|
||||
This software is provided "as is", without any warranty of any kind.
|
||||
You may use, modify, and distribute this file under the terms of the MIT License.
|
||||
See the LICENSE file for details.
|
||||
"""
|
||||
@@ -1,8 +1,11 @@
|
||||
<RCC>
|
||||
<qresource prefix="/res/icon">
|
||||
<file>icons/AutoLibrary_32x32.ico</file>
|
||||
</qresource>
|
||||
<qresource prefix="/res/trans">
|
||||
<qresource prefix="/res">
|
||||
<file>icons/AutoLibrary_Logo_64.svg</file>
|
||||
<file>icons/AutoLibrary_Logo_128.svg</file>
|
||||
|
||||
<file>icons/Copy.svg</file>
|
||||
<file>icons/Reset.svg</file>
|
||||
|
||||
<file>translators/qtbase_zh_CN.qm</file>
|
||||
</qresource>
|
||||
</RCC>
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Copyright (c) 2026 KenanZhu.
|
||||
All rights reserved.
|
||||
|
||||
This software is provided "as is", without any warranty of any kind.
|
||||
You may use, modify, and distribute this file under the terms of the MIT License.
|
||||
See the LICENSE file for details.
|
||||
"""
|
||||
|
Before Width: | Height: | Size: 785 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 173 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 178 KiB |
|
After Width: | Height: | Size: 30 KiB |
@@ -0,0 +1,6 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M13 7H7V5H13V7Z" fill="currentColor"/>
|
||||
<path d="M13 11H7V9H13V11Z" fill="currentColor"/>
|
||||
<path d="M7 15H13V13H7V15Z" fill="currentColor"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M3 19V1H17V5H21V23H7V19H3ZM15 17V3H5V17H15ZM17 7V19H9V21H19V7H17Z" fill="currentColor"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 396 B |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M20.817 11.186a8.94 8.94 0 0 0-1.355-3.219 9.053 9.053 0 0 0-2.43-2.43 8.95 8.95 0 0 0-3.219-1.355 9.028 9.028 0 0 0-1.838-.18V2L8 5l3.975 3V6.002c.484-.002.968.044 1.435.14a6.961 6.961 0 0 1 2.502 1.053 7.005 7.005 0 0 1 1.892 1.892A6.967 6.967 0 0 1 19 13a7.032 7.032 0 0 1-.55 2.725 7.11 7.11 0 0 1-.644 1.188 7.2 7.2 0 0 1-.858 1.039 7.028 7.028 0 0 1-3.536 1.907 7.13 7.13 0 0 1-2.822 0 6.961 6.961 0 0 1-2.503-1.054 7.002 7.002 0 0 1-1.89-1.89A6.996 6.996 0 0 1 5 13H3a9.02 9.02 0 0 0 1.539 5.034 9.096 9.096 0 0 0 2.428 2.428A8.95 8.95 0 0 0 12 22a9.09 9.09 0 0 0 1.814-.183 9.014 9.014 0 0 0 3.218-1.355 8.886 8.886 0 0 0 1.331-1.099 9.228 9.228 0 0 0 1.1-1.332A8.952 8.952 0 0 0 21 13a9.09 9.09 0 0 0-.183-1.814z" fill="currentColor"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 866 B |
@@ -19,7 +19,7 @@
|
||||
<property name="maximumSize">
|
||||
<size>
|
||||
<width>800</width>
|
||||
<height>400</height>
|
||||
<height>600</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="windowTitle">
|
||||
@@ -103,53 +103,6 @@
|
||||
<property name="spacing">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<item>
|
||||
<widget class="QFrame" name="AboutInfoSpaceFrame">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>56</width>
|
||||
<height>0</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="maximumSize">
|
||||
<size>
|
||||
<width>56</width>
|
||||
<height>16777215</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="frameShape">
|
||||
<enum>QFrame::Shape::NoFrame</enum>
|
||||
</property>
|
||||
<property name="frameShadow">
|
||||
<enum>QFrame::Shadow::Plain</enum>
|
||||
</property>
|
||||
<property name="lineWidth">
|
||||
<number>0</number>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QTextBrowser" name="AboutInfoBrowser">
|
||||
<property name="frameShadow">
|
||||
<enum>QFrame::Shadow::Plain</enum>
|
||||
</property>
|
||||
<property name="verticalScrollBarPolicy">
|
||||
<enum>Qt::ScrollBarPolicy::ScrollBarAlwaysOff</enum>
|
||||
</property>
|
||||
<property name="horizontalScrollBarPolicy">
|
||||
<enum>Qt::ScrollBarPolicy::ScrollBarAlwaysOff</enum>
|
||||
</property>
|
||||
<property name="lineWrapMode">
|
||||
<enum>QTextEdit::LineWrapMode::NoWrap</enum>
|
||||
</property>
|
||||
<property name="openExternalLinks">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
<property name="openLinks">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
<item>
|
||||
|
||||
@@ -195,6 +195,11 @@
|
||||
<height>25</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="styleSheet">
|
||||
<string notr="true">QPushButton {
|
||||
color: #DC0000;
|
||||
}</string>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>删除用户</string>
|
||||
</property>
|
||||
@@ -1228,12 +1233,31 @@
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="0">
|
||||
<widget class="QGroupBox" name="BrowserConfigGroupBox">
|
||||
<property name="title">
|
||||
<string>浏览器设置</string>
|
||||
<item row="2" column="0">
|
||||
<widget class="QFrame" name="SystemConfigSpaceFrame">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>0</width>
|
||||
<height>270</height>
|
||||
</size>
|
||||
</property>
|
||||
<layout class="QVBoxLayout" name="BrowserConfigLayout">
|
||||
<property name="frameShape">
|
||||
<enum>QFrame::Shape::NoFrame</enum>
|
||||
</property>
|
||||
<property name="frameShadow">
|
||||
<enum>QFrame::Shadow::Plain</enum>
|
||||
</property>
|
||||
<property name="lineWidth">
|
||||
<number>0</number>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="1" colspan="2">
|
||||
<widget class="QGroupBox" name="RunModeConfigGroupBox">
|
||||
<property name="title">
|
||||
<string>运行模式</string>
|
||||
</property>
|
||||
<layout class="QVBoxLayout" name="RunModeConfigLayout">
|
||||
<property name="spacing">
|
||||
<number>5</number>
|
||||
</property>
|
||||
@@ -1250,162 +1274,59 @@
|
||||
<number>3</number>
|
||||
</property>
|
||||
<item>
|
||||
<widget class="QLabel" name="BrowserTypeLabel">
|
||||
<widget class="QCheckBox" name="AutoReserveCheckBox">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>80</width>
|
||||
<width>100</width>
|
||||
<height>25</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="maximumSize">
|
||||
<size>
|
||||
<width>80</width>
|
||||
<width>100</width>
|
||||
<height>25</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>浏览器类型:</string>
|
||||
<string>自动预约</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QComboBox" name="BrowserTypeComboBox">
|
||||
<widget class="QCheckBox" name="AutoCheckinCheckBox">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>80</width>
|
||||
<width>100</width>
|
||||
<height>25</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="maximumSize">
|
||||
<size>
|
||||
<width>80</width>
|
||||
<height>25</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="toolTip">
|
||||
<string><html><head/><body><p>脚本运行使用的浏览器类型</p></body></html></string>
|
||||
</property>
|
||||
<property name="whatsThis">
|
||||
<string><html><head/><body><p><br/></p></body></html></string>
|
||||
</property>
|
||||
<property name="currentText">
|
||||
<string>edge</string>
|
||||
</property>
|
||||
<property name="currentIndex">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<property name="maxVisibleItems">
|
||||
<number>3</number>
|
||||
</property>
|
||||
<property name="maxCount">
|
||||
<number>3</number>
|
||||
</property>
|
||||
<item>
|
||||
<property name="text">
|
||||
<string>edge</string>
|
||||
</property>
|
||||
</item>
|
||||
<item>
|
||||
<property name="text">
|
||||
<string>chrome</string>
|
||||
</property>
|
||||
</item>
|
||||
<item>
|
||||
<property name="text">
|
||||
<string>firefox</string>
|
||||
</property>
|
||||
</item>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QLabel" name="BrowserDriverLabel">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>80</width>
|
||||
<height>25</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="maximumSize">
|
||||
<size>
|
||||
<width>80</width>
|
||||
<width>100</width>
|
||||
<height>25</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>驱动路径:</string>
|
||||
<string>自动签到</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<layout class="QHBoxLayout" name="BrowserDriverLayout">
|
||||
<property name="spacing">
|
||||
<number>5</number>
|
||||
</property>
|
||||
<item>
|
||||
<widget class="QLineEdit" name="BrowseBrowserDriverEdit">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>250</width>
|
||||
<height>25</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="maximumSize">
|
||||
<size>
|
||||
<width>300</width>
|
||||
<height>25</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="toolTip">
|
||||
<string><html><head/><body><p>详情请参阅 <a href="https://www.autolibrary.top/manuals"><span style=" text-decoration: underline; color:#69fcff;">用户手册</span></a></p></body></html></string>
|
||||
</property>
|
||||
<property name="whatsThis">
|
||||
<string><html><head/><body><p><br/></p></body></html></string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QPushButton" name="BrowseBrowserDriverButton">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>35</width>
|
||||
<height>25</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="maximumSize">
|
||||
<size>
|
||||
<width>35</width>
|
||||
<height>25</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>...</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QCheckBox" name="HeadlessCheckBox">
|
||||
<widget class="QCheckBox" name="AutoRenewalCheckBox">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>0</width>
|
||||
<width>100</width>
|
||||
<height>25</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="maximumSize">
|
||||
<size>
|
||||
<width>16777215</width>
|
||||
<width>100</width>
|
||||
<height>25</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="toolTip">
|
||||
<string><html><head/><body><p>运行时不显示浏览器</p></body></html></string>
|
||||
</property>
|
||||
<property name="whatsThis">
|
||||
<string><html><head/><body><p><br/></p></body></html></string>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>无头模式</string>
|
||||
<string>自动续约</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
@@ -1524,15 +1445,12 @@
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="1" colspan="2">
|
||||
<widget class="QGroupBox" name="RunModeConfigGroupBox">
|
||||
<item row="1" column="0">
|
||||
<widget class="QGroupBox" name="BrowserConfigGroupBox">
|
||||
<property name="title">
|
||||
<string>运行模式</string>
|
||||
<string>浏览器设置</string>
|
||||
</property>
|
||||
<layout class="QVBoxLayout" name="RunModeConfigLayout">
|
||||
<property name="spacing">
|
||||
<number>5</number>
|
||||
</property>
|
||||
<layout class="QGridLayout" name="BrowserConfigLayout">
|
||||
<property name="leftMargin">
|
||||
<number>3</number>
|
||||
</property>
|
||||
@@ -1545,85 +1463,203 @@
|
||||
<property name="bottomMargin">
|
||||
<number>3</number>
|
||||
</property>
|
||||
<item>
|
||||
<widget class="QCheckBox" name="AutoReserveCheckBox">
|
||||
<property name="spacing">
|
||||
<number>5</number>
|
||||
</property>
|
||||
<item row="0" column="0">
|
||||
<widget class="QLabel" name="BrowserTypeLabel">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>100</width>
|
||||
<width>80</width>
|
||||
<height>25</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="maximumSize">
|
||||
<size>
|
||||
<width>100</width>
|
||||
<width>80</width>
|
||||
<height>25</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>自动预约</string>
|
||||
<string>浏览器类型:</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QCheckBox" name="AutoCheckinCheckBox">
|
||||
<item row="1" column="0">
|
||||
<widget class="QComboBox" name="BrowserTypeComboBox">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>100</width>
|
||||
<width>80</width>
|
||||
<height>25</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="maximumSize">
|
||||
<size>
|
||||
<width>100</width>
|
||||
<width>80</width>
|
||||
<height>25</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="toolTip">
|
||||
<string><html><head/><body><p>脚本运行使用的浏览器类型</p></body></html></string>
|
||||
</property>
|
||||
<property name="whatsThis">
|
||||
<string><html><head/><body><p><br/></p></body></html></string>
|
||||
</property>
|
||||
<property name="currentText">
|
||||
<string>edge</string>
|
||||
</property>
|
||||
<property name="currentIndex">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<property name="maxVisibleItems">
|
||||
<number>3</number>
|
||||
</property>
|
||||
<property name="maxCount">
|
||||
<number>3</number>
|
||||
</property>
|
||||
<item>
|
||||
<property name="text">
|
||||
<string>edge</string>
|
||||
</property>
|
||||
</item>
|
||||
<item>
|
||||
<property name="text">
|
||||
<string>chrome</string>
|
||||
</property>
|
||||
</item>
|
||||
<item>
|
||||
<property name="text">
|
||||
<string>firefox</string>
|
||||
</property>
|
||||
</item>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="0">
|
||||
<widget class="QLabel" name="BrowserDriverLabel">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>175</width>
|
||||
<height>25</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="maximumSize">
|
||||
<size>
|
||||
<width>175</width>
|
||||
<height>25</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>自动签到</string>
|
||||
<string>驱动路径:</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QCheckBox" name="AutoRenewalCheckBox">
|
||||
<item row="3" column="0" colspan="2">
|
||||
<layout class="QHBoxLayout" name="BrowserDriverLayout">
|
||||
<property name="spacing">
|
||||
<number>5</number>
|
||||
</property>
|
||||
<item>
|
||||
<widget class="QLineEdit" name="BrowseBrowserDriverEdit">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>250</width>
|
||||
<height>25</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="maximumSize">
|
||||
<size>
|
||||
<width>300</width>
|
||||
<height>25</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="toolTip">
|
||||
<string><html><head/><body><p>详情请参阅 <a href="https://www.autolibrary.kenanzhu.com/manuals"><span style=" text-decoration: underline; color:#69fcff;">用户手册</span></a></p></body></html></string>
|
||||
</property>
|
||||
<property name="whatsThis">
|
||||
<string><html><head/><body><p><br/></p></body></html></string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QPushButton" name="BrowseBrowserDriverButton">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>35</width>
|
||||
<height>25</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="maximumSize">
|
||||
<size>
|
||||
<width>35</width>
|
||||
<height>25</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>...</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
<item row="4" column="0">
|
||||
<widget class="QCheckBox" name="HeadlessCheckBox">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>100</width>
|
||||
<width>0</width>
|
||||
<height>25</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="maximumSize">
|
||||
<size>
|
||||
<width>100</width>
|
||||
<width>16777215</width>
|
||||
<height>25</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="toolTip">
|
||||
<string><html><head/><body><p>运行时不显示浏览器</p></body></html></string>
|
||||
</property>
|
||||
<property name="whatsThis">
|
||||
<string><html><head/><body><p><br/></p></body></html></string>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>自动续约</string>
|
||||
<string>无头模式</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="1">
|
||||
<widget class="QPushButton" name="AutoDownloadWebDriverButton">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Minimum" vsizetype="Fixed">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>120</width>
|
||||
<height>25</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="maximumSize">
|
||||
<size>
|
||||
<width>120</width>
|
||||
<height>25</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="layoutDirection">
|
||||
<enum>Qt::LayoutDirection::LeftToRight</enum>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>自动下载驱动</string>
|
||||
</property>
|
||||
<property name="icon">
|
||||
<iconset theme="document-properties"/>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="0">
|
||||
<widget class="QFrame" name="SystemConfigSpaceFrame">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>0</width>
|
||||
<height>270</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="frameShape">
|
||||
<enum>QFrame::Shape::NoFrame</enum>
|
||||
</property>
|
||||
<property name="frameShadow">
|
||||
<enum>QFrame::Shadow::Plain</enum>
|
||||
</property>
|
||||
<property name="lineWidth">
|
||||
<number>0</number>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<widget class="QWidget" name="OtherConfigWidget">
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
<number>5</number>
|
||||
</property>
|
||||
<item>
|
||||
<widget class="QPushButton" name="TimerTaskWidgetButton">
|
||||
<widget class="QPushButton" name="TimerTaskManageWidgetButton">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>25</width>
|
||||
|
||||
@@ -6,20 +6,20 @@
|
||||
<rect>
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>300</width>
|
||||
<height>300</height>
|
||||
<width>350</width>
|
||||
<height>400</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>0</width>
|
||||
<height>300</height>
|
||||
<width>350</width>
|
||||
<height>460</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="maximumSize">
|
||||
<size>
|
||||
<width>500</width>
|
||||
<height>300</height>
|
||||
<width>350</width>
|
||||
<height>500</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="windowTitle">
|
||||
@@ -149,8 +149,20 @@
|
||||
<property name="spacing">
|
||||
<number>5</number>
|
||||
</property>
|
||||
<item row="0" column="0">
|
||||
<item row="1" column="0">
|
||||
<widget class="QRadioButton" name="SilentlyRunRadioButton">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>0</width>
|
||||
<height>25</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="maximumSize">
|
||||
<size>
|
||||
<width>16777215</width>
|
||||
<height>25</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>静默运行</string>
|
||||
</property>
|
||||
@@ -168,13 +180,248 @@
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="0">
|
||||
<item row="2" column="0">
|
||||
<widget class="QRadioButton" name="ShowBeforeRunRadioButton">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>0</width>
|
||||
<height>25</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="maximumSize">
|
||||
<size>
|
||||
<width>16777215</width>
|
||||
<height>25</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>运行前提示</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="0">
|
||||
<widget class="QGroupBox" name="RepeatConfigGroupBox">
|
||||
<property name="title">
|
||||
<string>重复运行</string>
|
||||
</property>
|
||||
<layout class="QVBoxLayout" name="RepeatConfigLayout" stretch="1,1,1">
|
||||
<property name="spacing">
|
||||
<number>5</number>
|
||||
</property>
|
||||
<property name="leftMargin">
|
||||
<number>3</number>
|
||||
</property>
|
||||
<property name="topMargin">
|
||||
<number>3</number>
|
||||
</property>
|
||||
<property name="rightMargin">
|
||||
<number>3</number>
|
||||
</property>
|
||||
<property name="bottomMargin">
|
||||
<number>3</number>
|
||||
</property>
|
||||
<item>
|
||||
<widget class="QCheckBox" name="RepeatCheckBox">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>0</width>
|
||||
<height>25</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="maximumSize">
|
||||
<size>
|
||||
<width>16777215</width>
|
||||
<height>25</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>启用重复执行</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QLabel" name="label">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>0</width>
|
||||
<height>25</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="maximumSize">
|
||||
<size>
|
||||
<width>16777215</width>
|
||||
<height>25</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>重复周期(全选或全不选都为每日运行):</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<layout class="QGridLayout" name="RepeatCheckBoxLayout" rowstretch="10,10" columnstretch="0,0,0,0" rowminimumheight="25,25">
|
||||
<property name="spacing">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<item row="0" column="3">
|
||||
<widget class="QCheckBox" name="ThuCheckBox">
|
||||
<property name="enabled">
|
||||
<bool>false</bool>
|
||||
</property>
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>50</width>
|
||||
<height>25</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="maximumSize">
|
||||
<size>
|
||||
<width>50</width>
|
||||
<height>25</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>周四</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="2">
|
||||
<widget class="QCheckBox" name="WedCheckBox">
|
||||
<property name="enabled">
|
||||
<bool>false</bool>
|
||||
</property>
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>50</width>
|
||||
<height>25</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="maximumSize">
|
||||
<size>
|
||||
<width>50</width>
|
||||
<height>25</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>周三</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="0">
|
||||
<widget class="QCheckBox" name="MonCheckBox">
|
||||
<property name="enabled">
|
||||
<bool>false</bool>
|
||||
</property>
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>50</width>
|
||||
<height>25</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="maximumSize">
|
||||
<size>
|
||||
<width>50</width>
|
||||
<height>25</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>周一</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="1">
|
||||
<widget class="QCheckBox" name="TueCheckBox">
|
||||
<property name="enabled">
|
||||
<bool>false</bool>
|
||||
</property>
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>50</width>
|
||||
<height>25</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="maximumSize">
|
||||
<size>
|
||||
<width>50</width>
|
||||
<height>25</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>周二</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="0">
|
||||
<widget class="QCheckBox" name="FriCheckBox">
|
||||
<property name="enabled">
|
||||
<bool>false</bool>
|
||||
</property>
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>50</width>
|
||||
<height>25</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="maximumSize">
|
||||
<size>
|
||||
<width>50</width>
|
||||
<height>25</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>周五</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="1">
|
||||
<widget class="QCheckBox" name="SatCheckBox">
|
||||
<property name="enabled">
|
||||
<bool>false</bool>
|
||||
</property>
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>50</width>
|
||||
<height>25</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="maximumSize">
|
||||
<size>
|
||||
<width>50</width>
|
||||
<height>25</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>周六</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="2">
|
||||
<widget class="QCheckBox" name="SunCheckBox">
|
||||
<property name="enabled">
|
||||
<bool>false</bool>
|
||||
</property>
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>50</width>
|
||||
<height>25</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="maximumSize">
|
||||
<size>
|
||||
<width>50</width>
|
||||
<height>25</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>周日</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
|
||||
@@ -6,26 +6,26 @@
|
||||
<rect>
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>400</width>
|
||||
<width>500</width>
|
||||
<height>400</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>400</width>
|
||||
<width>500</width>
|
||||
<height>400</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="maximumSize">
|
||||
<size>
|
||||
<width>600</width>
|
||||
<width>800</width>
|
||||
<height>400</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="windowTitle">
|
||||
<string>定时任务管理 - AutoLibrary</string>
|
||||
</property>
|
||||
<layout class="QVBoxLayout" name="ALTimerTaskWidgetLayout">
|
||||
<layout class="QVBoxLayout" name="ALTimerTaskManageWidgetLayout">
|
||||
<property name="spacing">
|
||||
<number>5</number>
|
||||
</property>
|
||||
@@ -306,6 +306,11 @@
|
||||
<height>25</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="styleSheet">
|
||||
<string notr="true">QPushButton {
|
||||
color: #DC0000;
|
||||
}</string>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>清除全部</string>
|
||||
</property>
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Copyright (c) 2026 KenanZhu.
|
||||
All rights reserved.
|
||||
|
||||
This software is provided "as is", without any warranty of any kind.
|
||||
You may use, modify, and distribute this file under the terms of the MIT License.
|
||||
See the LICENSE file for details.
|
||||
"""
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import Any, Optional, Protocol
|
||||
|
||||
|
||||
class ConfigType(Enum):
|
||||
"""
|
||||
Config type enum. Values represent the default filename.
|
||||
"""
|
||||
|
||||
GLOBAL = "autolibrary.json"
|
||||
BULLETIN = "bulletin.json"
|
||||
TIMERTASK = "timer_task.json"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ConfigPath:
|
||||
"""
|
||||
A typed configuration path that carries both the config file
|
||||
and the dot-separated key in a single object.
|
||||
|
||||
Consumers pass this directly to ConfigProvider.get/set,
|
||||
eliminating the need to import ConfigType separately.
|
||||
"""
|
||||
|
||||
config_type: ConfigType
|
||||
key: str = ""
|
||||
|
||||
|
||||
class CfgKey:
|
||||
"""
|
||||
Type-safe hierarchical configuration key constants.
|
||||
|
||||
Each leaf is a ConfigPath that can be passed directly to
|
||||
``ConfigProvider.get()`` or ``ConfigProvider.set()``.
|
||||
|
||||
Usage::
|
||||
|
||||
CfgKey.GLOBAL.AUTOMATION.RUN_PATH.PATHS
|
||||
# -> ConfigPath(ConfigType.GLOBAL, "automation.run_path.paths")
|
||||
|
||||
config.get(CfgKey.GLOBAL.AUTOMATION.RUN_PATH.PATHS, [])
|
||||
config.set(CfgKey.GLOBAL.AUTOMATION.RUN_PATH.PATHS, value)
|
||||
"""
|
||||
|
||||
class GLOBAL:
|
||||
class AUTOMATION:
|
||||
ROOT = ConfigPath(ConfigType.GLOBAL, "automation")
|
||||
|
||||
class RUN_PATH:
|
||||
ROOT = ConfigPath(ConfigType.GLOBAL, "automation.run_path")
|
||||
CURRENT = ConfigPath(ConfigType.GLOBAL, "automation.run_path.current")
|
||||
PATHS = ConfigPath(ConfigType.GLOBAL, "automation.run_path.paths")
|
||||
|
||||
class USER_PATH:
|
||||
ROOT = ConfigPath(ConfigType.GLOBAL, "automation.user_path")
|
||||
CURRENT = ConfigPath(ConfigType.GLOBAL, "automation.user_path.current")
|
||||
PATHS = ConfigPath(ConfigType.GLOBAL, "automation.user_path.paths")
|
||||
|
||||
class TIMERTASK:
|
||||
ROOT = ConfigPath(ConfigType.TIMERTASK, "")
|
||||
TIMER_TASKS = ConfigPath(ConfigType.TIMERTASK, "timer_tasks")
|
||||
|
||||
class BULLETIN:
|
||||
ROOT = ConfigPath(ConfigType.BULLETIN, "")
|
||||
BULLETIN = ConfigPath(ConfigType.BULLETIN, "bulletin")
|
||||
LAST_SYNC_TIME = ConfigPath(ConfigType.BULLETIN, "last_sync_time")
|
||||
|
||||
|
||||
class ConfigProvider(Protocol):
|
||||
"""
|
||||
Abstract interface for configuration storage access.
|
||||
|
||||
Concrete implementations (e.g. ConfigManager) conform to
|
||||
this protocol structurally rather than through explicit
|
||||
inheritance.
|
||||
"""
|
||||
|
||||
def get(
|
||||
self,
|
||||
key: ConfigPath,
|
||||
default: Optional[Any] = None
|
||||
) -> Any:
|
||||
"""
|
||||
Retrieve a configuration value.
|
||||
|
||||
Args:
|
||||
key: A ConfigPath object specifying which config file
|
||||
and key to read from.
|
||||
default: Fallback value if the key is not found.
|
||||
|
||||
Returns:
|
||||
The configuration value at the given key path.
|
||||
"""
|
||||
...
|
||||
|
||||
def set(
|
||||
self,
|
||||
key: ConfigPath,
|
||||
value: Any = None
|
||||
) -> None:
|
||||
"""
|
||||
Set a configuration value and persist to disk.
|
||||
|
||||
Args:
|
||||
key: A ConfigPath object specifying which config file
|
||||
and key to write to.
|
||||
value: The value to store.
|
||||
"""
|
||||
...
|
||||
@@ -0,0 +1,9 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Copyright (c) 2026 KenanZhu.
|
||||
All rights reserved.
|
||||
|
||||
This software is provided "as is", without any warranty of any kind.
|
||||
You may use, modify, and distribute this file under the terms of the MIT License.
|
||||
See the LICENSE file for details.
|
||||
"""
|
||||
@@ -0,0 +1,9 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Copyright (c) 2026 KenanZhu.
|
||||
All rights reserved.
|
||||
|
||||
This software is provided "as is", without any warranty of any kind.
|
||||
You may use, modify, and distribute this file under the terms of the MIT License.
|
||||
See the LICENSE file for details.
|
||||
"""
|
||||
@@ -0,0 +1,186 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Copyright (c) 2026 KenanZhu.
|
||||
All rights reserved.
|
||||
|
||||
This software is provided "as is", without any warranty of any kind.
|
||||
You may use, modify, and distribute this file under the terms of the MIT License.
|
||||
See the LICENSE file for details.
|
||||
"""
|
||||
import os
|
||||
import threading
|
||||
|
||||
from typing import Any, Optional
|
||||
|
||||
from utils.JSONReader import JSONReader
|
||||
from utils.JSONWriter import JSONWriter
|
||||
from interfaces.ConfigProvider import ConfigType, ConfigPath
|
||||
|
||||
|
||||
# This config manager class only responsible for global and other
|
||||
# unconfigurable config files.
|
||||
|
||||
|
||||
class ConfigTemplate:
|
||||
"""
|
||||
Config template class.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config_type: ConfigType
|
||||
):
|
||||
|
||||
self.__config_type = config_type
|
||||
|
||||
def template(
|
||||
self
|
||||
) -> dict:
|
||||
"""
|
||||
Get config template.
|
||||
|
||||
Returns:
|
||||
dict: Config template.
|
||||
"""
|
||||
match self.__config_type:
|
||||
case ConfigType.GLOBAL:
|
||||
return {
|
||||
"automation": {
|
||||
"run_path": {
|
||||
"current": 0,
|
||||
"paths": []
|
||||
},
|
||||
"user_path": {
|
||||
"current": 0,
|
||||
"paths": []
|
||||
}
|
||||
}
|
||||
}
|
||||
case ConfigType.BULLETIN:
|
||||
return {
|
||||
"bulletin": [],
|
||||
"last_sync_time": None
|
||||
}
|
||||
case ConfigType.TIMERTASK:
|
||||
return {
|
||||
"timer_tasks": []
|
||||
}
|
||||
case _:
|
||||
return {}
|
||||
|
||||
|
||||
class ConfigManager:
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config_dir: str
|
||||
):
|
||||
|
||||
self.__config_dir = os.path.abspath(config_dir)
|
||||
self.__config_lock = threading.Lock()
|
||||
self.__config_data = {}
|
||||
|
||||
self.initialize()
|
||||
|
||||
def initialize(
|
||||
self
|
||||
):
|
||||
|
||||
for config_type in ConfigType:
|
||||
self.load(config_type)
|
||||
|
||||
def load(
|
||||
self,
|
||||
config_type: ConfigType
|
||||
):
|
||||
|
||||
config_path = os.path.join(self.__config_dir, config_type.value)
|
||||
if os.path.exists(config_path):
|
||||
try:
|
||||
config_data = JSONReader(config_path).data()
|
||||
self.__config_data[config_type.value] = config_data
|
||||
return
|
||||
except:
|
||||
pass
|
||||
self.__config_data[config_type.value] = ConfigTemplate(config_type).template()
|
||||
JSONWriter(config_path, self.__config_data[config_type.value])
|
||||
|
||||
def get(
|
||||
self,
|
||||
key: ConfigPath,
|
||||
default: Optional[Any] = None
|
||||
) -> Any:
|
||||
|
||||
with self.__config_lock:
|
||||
config_data = self.__config_data[key.config_type.value]
|
||||
if key.key == "":
|
||||
return config_data
|
||||
keys = key.key.split('.')
|
||||
for k in keys[:-1]:
|
||||
config_data = config_data.get(k, None)
|
||||
if config_data is None:
|
||||
return default
|
||||
return config_data.get(keys[-1], default)
|
||||
|
||||
def set(
|
||||
self,
|
||||
key: ConfigPath,
|
||||
value: Any = None
|
||||
):
|
||||
|
||||
with self.__config_lock:
|
||||
root_data = self.__config_data[key.config_type.value]
|
||||
if key.key == "":
|
||||
self.__config_data[key.config_type.value] = value
|
||||
else:
|
||||
keys = key.key.split('.')
|
||||
config_data = root_data
|
||||
for k in keys[:-1]:
|
||||
if k not in config_data:
|
||||
config_data[k] = {}
|
||||
config_data = config_data[k]
|
||||
config_data[keys[-1]] = value
|
||||
self.save(key.config_type)
|
||||
|
||||
def save(
|
||||
self,
|
||||
config_type: ConfigType
|
||||
):
|
||||
|
||||
config_path = os.path.join(self.__config_dir, config_type.value)
|
||||
JSONWriter(config_path, self.__config_data[config_type.value])
|
||||
|
||||
def configDir(
|
||||
self
|
||||
) -> str:
|
||||
|
||||
return self.__config_dir
|
||||
|
||||
|
||||
# ConfigManager singleton instance.
|
||||
_config_manager_instance : ConfigManager | None = None
|
||||
|
||||
# Singleton instance of ConfigManager.
|
||||
_instance_lock = threading.Lock()
|
||||
|
||||
def instance(
|
||||
config_dir: str = ""
|
||||
) -> ConfigManager:
|
||||
"""
|
||||
Initialize ConfigManager singleton instance.
|
||||
|
||||
Args:
|
||||
config_dir (str): Config directory.
|
||||
"""
|
||||
global _config_manager_instance
|
||||
with _instance_lock:
|
||||
if _config_manager_instance is None:
|
||||
if not config_dir:
|
||||
raise ValueError("ConfigManager 需要配置目录参数")
|
||||
_config_manager_instance = ConfigManager(config_dir)
|
||||
else:
|
||||
if config_dir == "":
|
||||
return _config_manager_instance
|
||||
if _config_manager_instance.configDir() != config_dir:
|
||||
raise ValueError("ConfigManager 的实例已初始化,不能使用不同的配置目录。")
|
||||
return _config_manager_instance
|
||||
@@ -0,0 +1,48 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Copyright (c) 2026 KenanZhu.
|
||||
All rights reserved.
|
||||
|
||||
This software is provided "as is", without any warranty of any kind.
|
||||
You may use, modify, and distribute this file under the terms of the MIT License.
|
||||
See the LICENSE file for details.
|
||||
"""
|
||||
import os
|
||||
|
||||
import managers.config.ConfigManager as ConfigManager
|
||||
|
||||
from interfaces.ConfigProvider import CfgKey
|
||||
|
||||
class ConfigUtils:
|
||||
"""
|
||||
Config utilities class.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def getAutomationConfigPaths(
|
||||
) -> dict[str]:
|
||||
"""
|
||||
Get validated automation config paths from ConfigManager instance.
|
||||
These function will validate the config paths and return the validated paths in a dict.
|
||||
|
||||
Returns:
|
||||
dict[str]: Validated automation config paths (include user and run config paths).
|
||||
"""
|
||||
cfg_mgr = ConfigManager.instance() # config manager instance
|
||||
|
||||
config_paths = {"run": "", "user": ""}
|
||||
auto_config = cfg_mgr.get(CfgKey.GLOBAL.AUTOMATION.ROOT, {})
|
||||
for cfg_type in ["run", "user"]:
|
||||
paths = auto_config.get(f"{cfg_type}_path", {}).get("paths", [])
|
||||
index = auto_config.get(f"{cfg_type}_path", {}).get("current", 0)
|
||||
if paths == []:
|
||||
paths.append(os.path.join(cfg_mgr.configDir(), f"{cfg_type}.json"))
|
||||
if index < 0:
|
||||
index = 0
|
||||
if index >= len(paths):
|
||||
index = len(paths) - 1
|
||||
config_paths[cfg_type] = paths[index]
|
||||
data = {"current": index, "paths": paths}
|
||||
auto_config[f"{cfg_type}_path"] = data
|
||||
cfg_mgr.set(CfgKey.GLOBAL.AUTOMATION.ROOT, auto_config)
|
||||
return config_paths
|
||||
@@ -0,0 +1,9 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Copyright (c) 2026 KenanZhu.
|
||||
All rights reserved.
|
||||
|
||||
This software is provided "as is", without any warranty of any kind.
|
||||
You may use, modify, and distribute this file under the terms of the MIT License.
|
||||
See the LICENSE file for details.
|
||||
"""
|
||||
@@ -0,0 +1,183 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Copyright (c) 2026 KenanZhu.
|
||||
All rights reserved.
|
||||
|
||||
This software is provided "as is", without any warranty of any kind.
|
||||
You may use, modify, and distribute this file under the terms of the MIT License.
|
||||
See the LICENSE file for details.
|
||||
"""
|
||||
import platform
|
||||
import browsers
|
||||
|
||||
from pathlib import Path
|
||||
from enum import Enum
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
class WebBrowserType(Enum):
|
||||
"""
|
||||
Web browser type
|
||||
"""
|
||||
|
||||
CHROME = "chrome"
|
||||
FIREFOX = "firefox"
|
||||
EDGE = "edge"
|
||||
|
||||
|
||||
class WebBrowserArch(Enum):
|
||||
"""
|
||||
Web browser architecture
|
||||
"""
|
||||
|
||||
WINX86_32 = 0
|
||||
WINX86_64 = 1
|
||||
WINARM = 2
|
||||
|
||||
LINUXX86_32 = 3
|
||||
LINUXX86_64 = 4
|
||||
LINUXARM = 5
|
||||
|
||||
MACX86_64 = 6
|
||||
MACARM = 7
|
||||
|
||||
|
||||
@dataclass
|
||||
class WebBrowserInfo:
|
||||
"""
|
||||
Web browser information
|
||||
|
||||
Attributes:
|
||||
browser_arch (WebBrowserArch): Web browser architecture
|
||||
browser_type (WebBrowserType): Web browser type
|
||||
browser_version (str): Web browser version
|
||||
browser_path (Path): Web browser executable file path
|
||||
"""
|
||||
|
||||
browser_arch: WebBrowserArch
|
||||
browser_type: WebBrowserType
|
||||
browser_version: str
|
||||
browser_path: Path
|
||||
|
||||
|
||||
class WebBrowserArchDetector:
|
||||
"""
|
||||
Web browser architecture detector
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self
|
||||
):
|
||||
|
||||
pass
|
||||
|
||||
def detect(
|
||||
self
|
||||
) -> WebBrowserArch:
|
||||
"""
|
||||
Detect system architecture
|
||||
|
||||
Returns:
|
||||
WebBrowserArch: System architecture
|
||||
"""
|
||||
|
||||
system = platform.system()
|
||||
machine = platform.machine().lower()
|
||||
if system == "Windows":
|
||||
if machine in ["amd64", "x86_64"]:
|
||||
return WebBrowserArch.WINX86_64
|
||||
elif machine in ["i386", "i686", "x86"]:
|
||||
return WebBrowserArch.WINX86_32
|
||||
elif machine in ["arm64", "aarch64"]:
|
||||
return WebBrowserArch.WINARM
|
||||
else:
|
||||
return WebBrowserArch.WINX86_64
|
||||
elif system == "Darwin":
|
||||
if machine in ["arm64", "aarch64"]:
|
||||
return WebBrowserArch.MACARM
|
||||
else:
|
||||
return WebBrowserArch.MACX86_64
|
||||
elif system == "Linux":
|
||||
if machine in ["amd64", "x86_64"]:
|
||||
return WebBrowserArch.LINUXX86_64
|
||||
elif machine in ["i386", "i686", "x86"]:
|
||||
return WebBrowserArch.LINUXX86_32
|
||||
elif machine in ["arm64", "aarch64"]:
|
||||
return WebBrowserArch.LINUXARM
|
||||
elif machine.startswith("arm"):
|
||||
return WebBrowserArch.LINUXARM
|
||||
else:
|
||||
return WebBrowserArch.LINUXX86_64
|
||||
raise ValueError(f"不支持的系统架构 : {system} {machine}")
|
||||
|
||||
|
||||
class WebBrowserDetector:
|
||||
"""
|
||||
Web browser detector
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self
|
||||
):
|
||||
|
||||
self.browser_arch = WebBrowserArchDetector().detect()
|
||||
self.browser_infos : list[WebBrowserInfo] = []
|
||||
|
||||
def detect(
|
||||
self
|
||||
) -> list[WebBrowserInfo]:
|
||||
|
||||
"""
|
||||
Detect installed web browsers on the system.
|
||||
|
||||
Returns:
|
||||
list[WebBrowserInfo]: List of detected browser information objects.
|
||||
"""
|
||||
|
||||
self.browser_infos = []
|
||||
try:
|
||||
all_browsers = list(browsers.browsers())
|
||||
except Exception as e:
|
||||
self.browser_infos = []
|
||||
return self.browser_infos
|
||||
|
||||
# Mapping from internal library name to our enum
|
||||
type_map = {
|
||||
'chrome': WebBrowserType.CHROME,
|
||||
'firefox': WebBrowserType.FIREFOX,
|
||||
'msedge': WebBrowserType.EDGE,
|
||||
}
|
||||
for browser in all_browsers:
|
||||
internal_name = browser.get("browser_type", "").lower()
|
||||
if internal_name not in type_map:
|
||||
continue # Not one of the browsers we care about
|
||||
version = browser.get("version", "")
|
||||
if not version:
|
||||
# Skip browsers with no version info (unlikely, but defensive)
|
||||
continue
|
||||
exe_path = browser.get("path", "")
|
||||
if not exe_path:
|
||||
continue
|
||||
try:
|
||||
path = Path(exe_path)
|
||||
if not path.is_file():
|
||||
continue
|
||||
except Exception:
|
||||
continue # Invalid path
|
||||
info = WebBrowserInfo(
|
||||
browser_arch=self.browser_arch, # Use system architecture as fallback
|
||||
browser_type=type_map[internal_name],
|
||||
browser_version=version,
|
||||
browser_path=path,
|
||||
)
|
||||
self.browser_infos.append(info)
|
||||
# Deduplicate: keep only one entry per (type, version)
|
||||
seen = set()
|
||||
unique = []
|
||||
for info in self.browser_infos:
|
||||
key = (info.browser_type, info.browser_version)
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
unique.append(info)
|
||||
self.browser_infos = unique
|
||||
return self.browser_infos
|
||||
@@ -0,0 +1,453 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Copyright (c) 2026 KenanZhu.
|
||||
All rights reserved.
|
||||
|
||||
This software is provided "as is", without any warranty of any kind.
|
||||
You may use, modify, and distribute this file under the terms of the MIT License.
|
||||
See the LICENSE file for details.
|
||||
"""
|
||||
import os
|
||||
import time
|
||||
import shutil
|
||||
import threading
|
||||
import requests
|
||||
import zipfile
|
||||
import tarfile
|
||||
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Optional, Callable
|
||||
|
||||
|
||||
class WebDriverType(Enum):
|
||||
"""
|
||||
Web driver type
|
||||
"""
|
||||
|
||||
CHROME = "chrome"
|
||||
FIREFOX = "firefox"
|
||||
EDGE = "edge"
|
||||
|
||||
|
||||
class WebDriverArch(Enum):
|
||||
"""
|
||||
Web driver architecture
|
||||
"""
|
||||
|
||||
class Chrome(Enum):
|
||||
"""
|
||||
Chrome web driver architecture
|
||||
"""
|
||||
|
||||
WINX86_32 = "win32"
|
||||
WINX86_64 = "win64"
|
||||
|
||||
# LINUX86_32 : no support for linux 32bit
|
||||
LINUX86_64 = "linux64"
|
||||
# LINUXARM : no support for linux arm64
|
||||
|
||||
MACX86_64 = "mac-x64"
|
||||
MACARM = "mac-arm64"
|
||||
|
||||
class Firefox(Enum):
|
||||
"""
|
||||
Firefox web driver architecture
|
||||
"""
|
||||
|
||||
WINX86_32 = "win32"
|
||||
WINX86_64 = "win64"
|
||||
WINARM = "win-aarch64"
|
||||
|
||||
LINUXX86_32 = "linux32"
|
||||
LINUXX86_64 = "linux64"
|
||||
LINUXARM = "linux-aarch64"
|
||||
|
||||
MACX86_64 = "macos"
|
||||
MACARM = "macos-aarch64"
|
||||
|
||||
class Edge(Enum):
|
||||
"""
|
||||
Edge web driver architecture
|
||||
"""
|
||||
|
||||
WINX86_32 = "win32"
|
||||
WINX86_64 = "win64"
|
||||
WINARM = "arm64"
|
||||
|
||||
# LINUX86_32 : no support for linux 32bit
|
||||
LINUXX86_64 = "linux64"
|
||||
# LINUXARM : no support for linux arm64
|
||||
|
||||
MACX86_64 = "mac64"
|
||||
MACARM = "mac64_m1"
|
||||
|
||||
|
||||
class WebDriverName:
|
||||
"""
|
||||
Web driver name
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
driver_type: WebDriverType
|
||||
):
|
||||
|
||||
self.driver_type = driver_type
|
||||
|
||||
def __str__(
|
||||
self
|
||||
) -> str:
|
||||
|
||||
match self.driver_type:
|
||||
case WebDriverType.CHROME:
|
||||
return "chromedriver"
|
||||
case WebDriverType.FIREFOX:
|
||||
return "geckodriver"
|
||||
case WebDriverType.EDGE:
|
||||
return "msedgedriver"
|
||||
case _:
|
||||
raise ValueError(f"不受支持的 web driver 类型 : {self.driver_type}")
|
||||
|
||||
|
||||
class WebDriverExecName:
|
||||
"""
|
||||
Web driver executable file name
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
driver_type: WebDriverType,
|
||||
arch: WebDriverArch
|
||||
):
|
||||
|
||||
self.driver_type = driver_type
|
||||
self.arch = arch
|
||||
|
||||
def __str__(
|
||||
self
|
||||
) -> str:
|
||||
|
||||
is_win = True if self.arch is WebDriverArch.Chrome.WINX86_32 or\
|
||||
self.arch is WebDriverArch.Chrome.WINX86_64 or\
|
||||
self.arch is WebDriverArch.Firefox.WINX86_32 or\
|
||||
self.arch is WebDriverArch.Firefox.WINX86_64 or\
|
||||
self.arch is WebDriverArch.Edge.WINX86_32 or\
|
||||
self.arch is WebDriverArch.Edge.WINX86_64 else False
|
||||
match self.driver_type:
|
||||
case WebDriverType.CHROME:
|
||||
return f"{WebDriverName(self.driver_type)}" + (".exe" if is_win else "")
|
||||
case WebDriverType.FIREFOX:
|
||||
return f"{WebDriverName(self.driver_type)}" + (".exe" if is_win else "")
|
||||
case WebDriverType.EDGE:
|
||||
return f"{WebDriverName(self.driver_type)}" + (".exe" if is_win else "")
|
||||
case _:
|
||||
raise ValueError(f"不受支持的 web driver 类型 : {self.driver_type}")
|
||||
|
||||
|
||||
class WebDriverFileName:
|
||||
"""\
|
||||
Web driver compressed file name
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
version: str,
|
||||
driver_type: WebDriverType,
|
||||
arch: WebDriverArch
|
||||
):
|
||||
|
||||
self.version = version
|
||||
self.driver_type = driver_type
|
||||
self.arch = arch
|
||||
|
||||
def __str__(
|
||||
self
|
||||
) -> str:
|
||||
|
||||
match self.driver_type:
|
||||
case WebDriverType.CHROME:
|
||||
return f"{WebDriverName(self.driver_type)}-{self.arch.value}.zip"
|
||||
case WebDriverType.FIREFOX:
|
||||
if self.arch is WebDriverArch.Firefox.WINX86_32 or\
|
||||
self.arch is WebDriverArch.Firefox.WINX86_64:
|
||||
suffix = "zip"
|
||||
else:
|
||||
suffix = "tar.gz"
|
||||
return f"{WebDriverName(self.driver_type)}-v{self.version}-{self.arch.value}.{suffix}"
|
||||
case WebDriverType.EDGE:
|
||||
return f"edgedriver_{self.arch.value}.zip" # Edge web driver file name is different
|
||||
case _:
|
||||
raise ValueError(f"不受支持的 web driver 类型 : {self.driver_type}")
|
||||
|
||||
|
||||
class WebDriverURL:
|
||||
"""
|
||||
Web driver download URL
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
version: str,
|
||||
driver_type: WebDriverType,
|
||||
arch: WebDriverArch
|
||||
):
|
||||
|
||||
self.version = version
|
||||
self.driver_type = driver_type
|
||||
self.arch = arch
|
||||
self.file_name = str(WebDriverFileName(self.version, self.driver_type, self.arch))
|
||||
|
||||
def __str__(
|
||||
self
|
||||
) -> str:
|
||||
|
||||
match self.driver_type:
|
||||
case WebDriverType.CHROME:
|
||||
return f"https://storage.googleapis.com/chrome-for-testing-public/"\
|
||||
f"{self.version}/"\
|
||||
f"{self.arch.value}/"\
|
||||
f"{self.file_name}"
|
||||
case WebDriverType.FIREFOX:
|
||||
return f"https://github.com/mozilla/geckodriver/releases/download/"\
|
||||
f"v{self.version}/"\
|
||||
f"{self.file_name}"
|
||||
case WebDriverType.EDGE:
|
||||
return f"https://msedgedriver.microsoft.com/"\
|
||||
f"{self.version}/"\
|
||||
f"{self.file_name}"
|
||||
case _:
|
||||
raise ValueError(f"不受支持的 web driver 类型 : {self.driver_type}")
|
||||
|
||||
|
||||
class WebDriverDownloader:
|
||||
"""
|
||||
Base class for WebDriver downloaders
|
||||
|
||||
Args:
|
||||
driver_type (WebDriverType): Web driver type
|
||||
version (str): WebDriver version
|
||||
arch (WebDriverArch): WebDriver architecture
|
||||
download_dir (str): Download directory
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
driver_type: WebDriverType,
|
||||
driver_version: str,
|
||||
driver_arch: WebDriverArch,
|
||||
download_dir: str
|
||||
):
|
||||
|
||||
self.driver_type = driver_type
|
||||
self.arch = driver_arch
|
||||
self.version = driver_version
|
||||
self.download_url = str(WebDriverURL(self.version, self.driver_type, self.arch))
|
||||
self.download_dir = Path(download_dir)/self.driver_type.value/self.version/self.arch.value
|
||||
self.download_dir.mkdir(mode=0o0755, parents=True, exist_ok=True)
|
||||
self.download_path = self.download_dir/str(WebDriverFileName(self.version, self.driver_type, self.arch))
|
||||
|
||||
def _download(
|
||||
self,
|
||||
progress_callback: Optional[Callable[[float, int, float, str], None]] = None,
|
||||
max_retries: int = 3,
|
||||
cancel_event: Optional[threading.Event] = None
|
||||
) -> bool:
|
||||
|
||||
CHUNK_SIZE = 8192*8 # 64KB chunk
|
||||
headers = {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
|
||||
'Accept-Encoding': 'gzip, deflate'
|
||||
}
|
||||
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
if cancel_event and cancel_event.is_set():
|
||||
return False
|
||||
# resume download if file exists
|
||||
if self.download_path.exists():
|
||||
downloaded_size = self.download_path.stat().st_size
|
||||
headers_ = headers.copy()
|
||||
headers_['Range'] = f"bytes={downloaded_size}-"
|
||||
mode = 'ab'
|
||||
else:
|
||||
downloaded_size = 0
|
||||
headers_ = headers
|
||||
mode = 'wb'
|
||||
# get response
|
||||
response = requests.get(str(self.download_url), headers=headers_, stream=True, timeout=10)
|
||||
if response.status_code not in [200, 206]:
|
||||
if self.download_path.exists():
|
||||
self.download_path.unlink()
|
||||
downloaded_size = 0
|
||||
mode = 'wb'
|
||||
response = requests.get(str(self.download_url), headers=headers, stream=True)
|
||||
response.raise_for_status()
|
||||
# get total size
|
||||
total_size = int(response.headers.get('Content-Length', 0))
|
||||
if response.status_code == 206: # Partial Content - server supports Range
|
||||
total_size += downloaded_size
|
||||
last_callback_time = time.time()
|
||||
last_callback_size = downloaded_size
|
||||
callback_interval = 0.1
|
||||
with open(self.download_path, mode) as f:
|
||||
for chunk in response.iter_content(CHUNK_SIZE):
|
||||
current_time = time.time()
|
||||
if cancel_event and cancel_event.is_set():
|
||||
response.close()
|
||||
return False
|
||||
if not chunk:
|
||||
continue
|
||||
f.write(chunk)
|
||||
downloaded_size += len(chunk)
|
||||
if not progress_callback or total_size <= 0:
|
||||
continue
|
||||
current_progress = (downloaded_size/total_size)*98.0
|
||||
if current_time - last_callback_time >= callback_interval or current_progress >= 98.0:
|
||||
elapsed = current_time - last_callback_time
|
||||
if elapsed > 0:
|
||||
speed = (downloaded_size - last_callback_size)/(elapsed*1024.0)
|
||||
else:
|
||||
speed = 0.0
|
||||
progress_callback(current_progress, 100, speed, "下载中...")
|
||||
last_callback_time = current_time
|
||||
last_callback_size = downloaded_size
|
||||
if total_size > 0 and self.download_path.stat().st_size < total_size:
|
||||
raise Exception(f"下载不完整 : {self.download_path.stat().st_size}/{total_size} 字节")
|
||||
return True
|
||||
except Exception as e:
|
||||
if cancel_event and cancel_event.is_set():
|
||||
return False
|
||||
if attempt < max_retries - 1:
|
||||
progress_callback(0, 100, 0.0, f"第 {attempt+1} 次重试...")
|
||||
time.sleep(1)
|
||||
continue
|
||||
raise e
|
||||
|
||||
def _verify(
|
||||
self,
|
||||
progress_callback: Optional[Callable[[float, int, float, str], None]] = None
|
||||
) -> bool:
|
||||
|
||||
progress_callback(98, 100, 0.0, "验证完成")
|
||||
return True
|
||||
|
||||
def _extract(
|
||||
self,
|
||||
progress_callback: Optional[Callable[[float, int, float, str], None]] = None
|
||||
) -> Optional[Path]:
|
||||
|
||||
try:
|
||||
progress_callback(98, 100, 0.0, "解压中...")
|
||||
file_path_str = str(self.download_path)
|
||||
if file_path_str.endswith('.tar.gz'):
|
||||
with tarfile.open(self.download_path, 'r:gz') as tar_ref:
|
||||
tar_ref.extractall(self.download_dir)
|
||||
else:
|
||||
with zipfile.ZipFile(self.download_path, 'r') as zip_ref:
|
||||
zip_ref.extractall(self.download_dir)
|
||||
driver_file = None
|
||||
for root, _, files in os.walk(self.download_dir):
|
||||
for file in files:
|
||||
expected_name = str(WebDriverExecName(self.driver_type, self.arch))
|
||||
if file == str(expected_name):
|
||||
src_path = Path(root, file)
|
||||
dst_path = self.download_dir/file
|
||||
src_path.rename(dst_path)
|
||||
driver_file = dst_path
|
||||
break
|
||||
if driver_file:
|
||||
break
|
||||
if not driver_file:
|
||||
raise FileNotFoundError(f"未找到 web driver 文件 : {expected_name}")
|
||||
progress_callback(100, 100, 0.0, "解压完成")
|
||||
self.download_path.unlink()
|
||||
self._cleanup(driver_file)
|
||||
return driver_file
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def _cleanup(
|
||||
self,
|
||||
driver_file: Path
|
||||
) -> None:
|
||||
|
||||
for item in self.download_dir.iterdir():
|
||||
if item != driver_file:
|
||||
if item.is_dir():
|
||||
shutil.rmtree(item)
|
||||
else:
|
||||
item.unlink()
|
||||
|
||||
def download(
|
||||
self,
|
||||
progress_callback: Optional[Callable[[float, int, float, str], None]] = None,
|
||||
cancel_event: Optional[threading.Event] = None
|
||||
) -> Optional[Path]:
|
||||
|
||||
try:
|
||||
# downlaod file : 0% - 98%
|
||||
if not self._download(progress_callback, cancel_event=cancel_event):
|
||||
return None
|
||||
# verify file : 98% - 99%
|
||||
if not self._verify(progress_callback):
|
||||
progress_callback(0, 100, 0.0, "验证失败")
|
||||
return None
|
||||
# extract file : 99% - 100%
|
||||
driver_path = self._extract(progress_callback)
|
||||
if not driver_path:
|
||||
progress_callback(0, 100, 0.0, "解压失败")
|
||||
return None
|
||||
return driver_path
|
||||
except Exception as e:
|
||||
raise e
|
||||
|
||||
|
||||
class ChromeDriverDownloader(WebDriverDownloader):
|
||||
"""
|
||||
Chrome web driver downloader
|
||||
|
||||
Only support version higher than 114
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
version: str,
|
||||
arch: WebDriverArch,
|
||||
download_dir: str
|
||||
):
|
||||
|
||||
super().__init__(WebDriverType.CHROME, version, arch, download_dir)
|
||||
|
||||
|
||||
class FirefoxDriverDownloader(WebDriverDownloader):
|
||||
"""
|
||||
Firefox web driver downloader
|
||||
|
||||
This class do not resolve version mapping,
|
||||
only support driver version higher than 0.17.0
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
version: str,
|
||||
arch: WebDriverArch,
|
||||
download_dir: str
|
||||
):
|
||||
|
||||
super().__init__(WebDriverType.FIREFOX, version, arch, download_dir)
|
||||
|
||||
|
||||
class EdgeDriverDownloader(WebDriverDownloader):
|
||||
"""
|
||||
Edge web driver downloader
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
version: str,
|
||||
arch: WebDriverArch,
|
||||
download_dir: str
|
||||
):
|
||||
|
||||
super().__init__(WebDriverType.EDGE, version, arch, download_dir)
|
||||
@@ -0,0 +1,455 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Copyright (c) 2026 KenanZhu.
|
||||
All rights reserved.
|
||||
|
||||
This software is provided "as is", without any warranty of any kind.
|
||||
You may use, modify, and distribute this file under the terms of the MIT License.
|
||||
See the LICENSE file for details.
|
||||
"""
|
||||
import os
|
||||
import threading
|
||||
import packaging.version as ver
|
||||
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Optional, Callable
|
||||
|
||||
from managers.driver.WebBrowserDetector import (
|
||||
WebBrowserType, WebBrowserArch, WebBrowserInfo, WebBrowserDetector
|
||||
)
|
||||
from managers.driver.WebDriverDownloader import (
|
||||
WebDriverArch, WebDriverType,
|
||||
ChromeDriverDownloader, FirefoxDriverDownloader, EdgeDriverDownloader
|
||||
)
|
||||
|
||||
|
||||
class WebDriverStatus(Enum):
|
||||
"""
|
||||
Web driver status.
|
||||
"""
|
||||
|
||||
NOT_INSTALLED = 0
|
||||
INSTALLED = 1
|
||||
DOWNLOADING = 2
|
||||
ERROR = 3
|
||||
|
||||
|
||||
class WebDriverInfo:
|
||||
"""
|
||||
Web driver information.
|
||||
|
||||
Attributes:
|
||||
driver_type (WebDriverType): Web driver type
|
||||
driver_arch (WebDriverArch): Web driver architecture
|
||||
driver_version (str): Web driver version
|
||||
browser_version (str): Web browser version
|
||||
driver_path (Optional[Path]): Web driver executable file path
|
||||
driver_status (DriverStatus): Web driver status
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self
|
||||
):
|
||||
|
||||
self.driver_type = None
|
||||
self.driver_arch = None
|
||||
self.driver_version = ""
|
||||
self.browser_version = ""
|
||||
self.driver_path: Optional[Path] = None
|
||||
self.driver_status = WebDriverStatus.NOT_INSTALLED
|
||||
|
||||
|
||||
class WebDriverManager:
|
||||
"""
|
||||
Web Driver Manager Singleton Class
|
||||
|
||||
Args:
|
||||
driver_dir (str): The directory to store web drivers.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
driver_dir: str
|
||||
):
|
||||
|
||||
self.__driver_dir = os.path.abspath(driver_dir)
|
||||
self.__browser_detector = WebBrowserDetector()
|
||||
self.__driver_infos: list[WebDriverInfo] = []
|
||||
self.__initialized = False
|
||||
self.__lock = threading.Lock()
|
||||
|
||||
self.initialize()
|
||||
|
||||
def initialize(
|
||||
self
|
||||
):
|
||||
|
||||
if self.__initialized:
|
||||
return
|
||||
os.makedirs(self.__driver_dir, exist_ok=True)
|
||||
self._detectBrowsers()
|
||||
self._checkDriverStatus()
|
||||
self.__initialized = True
|
||||
|
||||
def _detectBrowsers(
|
||||
self
|
||||
):
|
||||
|
||||
with self.__lock:
|
||||
browser_infos = self.__browser_detector.detect()
|
||||
self.__driver_infos = [
|
||||
self._getDriverInfo(info)
|
||||
for info in browser_infos
|
||||
]
|
||||
|
||||
def _checkDriverStatus(
|
||||
self
|
||||
):
|
||||
|
||||
with self.__lock:
|
||||
for driver_info in self.__driver_infos:
|
||||
driver_path = self._getDriverPath(driver_info)
|
||||
if driver_path and driver_path.exists() and driver_path.is_file():
|
||||
driver_info.driver_path = driver_path
|
||||
driver_info.driver_status = WebDriverStatus.INSTALLED
|
||||
|
||||
def _mapWebBrowserTypeToDriver(
|
||||
self,
|
||||
browser_type: WebBrowserType
|
||||
) -> WebDriverType:
|
||||
|
||||
if browser_type == WebBrowserType.CHROME:
|
||||
return WebDriverType.CHROME
|
||||
elif browser_type == WebBrowserType.FIREFOX:
|
||||
return WebDriverType.FIREFOX
|
||||
elif browser_type == WebBrowserType.EDGE:
|
||||
return WebDriverType.EDGE
|
||||
else:
|
||||
raise ValueError(f"不支持的 Web 浏览器类型 : {browser_type}")
|
||||
|
||||
def _mapWebBrowserArchToDriver(
|
||||
self,
|
||||
browser_type: WebBrowserType,
|
||||
browser_arch: WebBrowserArch
|
||||
) -> WebDriverArch:
|
||||
|
||||
if browser_type == WebBrowserType.CHROME:
|
||||
if browser_arch == WebBrowserArch.WINX86_32:
|
||||
return WebDriverArch.Chrome.WINX86_32
|
||||
elif browser_arch == WebBrowserArch.WINX86_64:
|
||||
return WebDriverArch.Chrome.WINX86_64
|
||||
elif browser_arch == WebBrowserArch.WINARM:
|
||||
raise ValueError("Chrome 不支持 Windows ARM 架构")
|
||||
elif browser_arch == WebBrowserArch.LINUXX86_32:
|
||||
raise ValueError("Chrome 不支持 Linux x86_32 架构")
|
||||
elif browser_arch == WebBrowserArch.LINUXX86_64:
|
||||
return WebDriverArch.Chrome.LINUXX86_64
|
||||
elif browser_arch == WebBrowserArch.LINUXARM:
|
||||
raise ValueError("Chrome 不支持 Linux ARM 架构")
|
||||
elif browser_arch == WebBrowserArch.MACX86_64:
|
||||
return WebDriverArch.Chrome.MACX86_64
|
||||
elif browser_arch == WebBrowserArch.MACARM:
|
||||
return WebDriverArch.Chrome.MACARM
|
||||
else:
|
||||
raise ValueError(f"不支持的 Chrome 浏览器架构 : {browser_arch}")
|
||||
elif browser_type == WebBrowserType.FIREFOX:
|
||||
if browser_arch == WebBrowserArch.WINX86_32:
|
||||
return WebDriverArch.Firefox.WINX86_32
|
||||
elif browser_arch == WebBrowserArch.WINX86_64:
|
||||
return WebDriverArch.Firefox.WINX86_64
|
||||
elif browser_arch == WebBrowserArch.WINARM:
|
||||
return WebDriverArch.Firefox.WINARM
|
||||
elif browser_arch == WebBrowserArch.LINUXX86_32:
|
||||
return WebDriverArch.Firefox.LINUXX86_32
|
||||
elif browser_arch == WebBrowserArch.LINUXX86_64:
|
||||
return WebDriverArch.Firefox.LINUXX86_64
|
||||
elif browser_arch == WebBrowserArch.LINUXARM:
|
||||
return WebDriverArch.Firefox.LINUXARM
|
||||
elif browser_arch == WebBrowserArch.MACX86_64:
|
||||
return WebDriverArch.Firefox.MACX86_64
|
||||
elif browser_arch == WebBrowserArch.MACARM:
|
||||
return WebDriverArch.Firefox.MACARM
|
||||
else:
|
||||
raise ValueError(f"不支持的 Firefox 浏览器架构 : {browser_arch}")
|
||||
elif browser_type == WebBrowserType.EDGE:
|
||||
if browser_arch == WebBrowserArch.WINX86_32:
|
||||
return WebDriverArch.Edge.WINX86_32
|
||||
elif browser_arch == WebBrowserArch.WINX86_64:
|
||||
return WebDriverArch.Edge.WINX86_64
|
||||
elif browser_arch == WebBrowserArch.WINARM:
|
||||
return WebDriverArch.Edge.WINARM
|
||||
elif browser_arch == WebBrowserArch.LINUXX86_32:
|
||||
raise ValueError("Edge 不支持 Linux x86_32 架构")
|
||||
elif browser_arch == WebBrowserArch.LINUXX86_64:
|
||||
return WebDriverArch.Edge.LINUXX86_64
|
||||
elif browser_arch == WebBrowserArch.LINUXARM:
|
||||
raise ValueError("Edge 不支持 Linux ARM 架构")
|
||||
elif browser_arch == WebBrowserArch.MACX86_64:
|
||||
return WebDriverArch.Edge.MACX86_64
|
||||
elif browser_arch == WebBrowserArch.MACARM:
|
||||
return WebDriverArch.Edge.MACARM
|
||||
else:
|
||||
raise ValueError(f"不支持的 Edge 浏览器架构 : {browser_arch}")
|
||||
else:
|
||||
raise ValueError(f"不支持的 Web 浏览器类型 : {browser_type}")
|
||||
|
||||
def _mapFirefoxDriverVersion(
|
||||
self,
|
||||
version: str
|
||||
) -> str:
|
||||
|
||||
version_mapping = [
|
||||
(ver.Version("128.0"), ver.Version("999.0"), "0.36.0"),
|
||||
(ver.Version("115.0"), ver.Version("127.0"), "0.35.0"),
|
||||
(ver.Version("91.0"), ver.Version("114.0"), "0.34.0"),
|
||||
(ver.Version("91.0"), ver.Version("120.0"), "0.33.0"),
|
||||
(ver.Version("91.0"), ver.Version("120.0"), "0.32.0"),
|
||||
(ver.Version("91.0"), ver.Version("120.0"), "0.31.0"),
|
||||
(ver.Version("78.0"), ver.Version("90.0"), "0.30.0"),
|
||||
(ver.Version("60.0"), ver.Version("90.0"), "0.29.0"),
|
||||
(ver.Version("60.0"), ver.Version("90.0"), "0.28.0"),
|
||||
(ver.Version("60.0"), ver.Version("90.0"), "0.27.0"),
|
||||
(ver.Version("57.0"), ver.Version("90.0"), "0.26.0"),
|
||||
(ver.Version("55.0"), ver.Version("62.0"), "0.25.0"),
|
||||
(ver.Version("55.0"), ver.Version("62.0"), "0.24.0"),
|
||||
(ver.Version("57.0"), ver.Version("79.0"), "0.23.0"),
|
||||
(ver.Version("57.0"), ver.Version("79.0"), "0.22.0"),
|
||||
(ver.Version("57.0"), ver.Version("79.0"), "0.21.0"),
|
||||
(ver.Version("55.0"), ver.Version("62.0"), "0.20.0"),
|
||||
(ver.Version("55.0"), ver.Version("62.0"), "0.19.0"),
|
||||
(ver.Version("53.0"), ver.Version("62.0"), "0.18.0"),
|
||||
(ver.Version("52.0"), ver.Version("62.0"), "0.17.0"),
|
||||
]
|
||||
|
||||
try:
|
||||
firefox_version = ver.Version(version)
|
||||
for min_ver, max_ver, gecko_ver in version_mapping:
|
||||
if min_ver <= firefox_version <= max_ver:
|
||||
return gecko_ver
|
||||
raise ValueError(
|
||||
f"不支持的 Firefox 版本 : {version}"
|
||||
f"Firefox 版本 52 及以上受支持"
|
||||
)
|
||||
except Exception as e:
|
||||
raise ValueError(f"无效的 Firefox 版本格式 : {version}") from e
|
||||
|
||||
def _getDriverInfo(
|
||||
self,
|
||||
browser_info: WebBrowserInfo
|
||||
) -> WebDriverInfo:
|
||||
|
||||
driver_info = WebDriverInfo()
|
||||
driver_info.driver_type = self._mapWebBrowserTypeToDriver(browser_info.browser_type)
|
||||
driver_info.driver_arch = self._mapWebBrowserArchToDriver(browser_info.browser_type, browser_info.browser_arch)
|
||||
if browser_info.browser_type == WebBrowserType.FIREFOX:
|
||||
driver_info.driver_version = self._mapFirefoxDriverVersion(browser_info.browser_version)
|
||||
else:
|
||||
driver_info.driver_version = browser_info.browser_version
|
||||
driver_info.browser_version = browser_info.browser_version
|
||||
return driver_info
|
||||
|
||||
def _getDriverPath(
|
||||
self,
|
||||
driver_info: WebDriverInfo
|
||||
) -> Optional[Path]:
|
||||
|
||||
driver_type = driver_info.driver_type
|
||||
driver_arch = driver_info.driver_arch
|
||||
driver_version = driver_info.driver_version
|
||||
if driver_type == WebDriverType.CHROME:
|
||||
driver_name = "chromedriver"
|
||||
elif driver_type == WebDriverType.FIREFOX:
|
||||
driver_name = "geckodriver"
|
||||
elif driver_type == WebDriverType.EDGE:
|
||||
driver_name = "msedgedriver"
|
||||
else:
|
||||
return None
|
||||
is_win = driver_arch in [
|
||||
WebDriverArch.Chrome.WINX86_32,
|
||||
WebDriverArch.Chrome.WINX86_64,
|
||||
WebDriverArch.Firefox.WINX86_32,
|
||||
WebDriverArch.Firefox.WINX86_64,
|
||||
WebDriverArch.Edge.WINX86_32,
|
||||
WebDriverArch.Edge.WINX86_64,
|
||||
]
|
||||
exe_name = f"{driver_name}.exe" if is_win else driver_name
|
||||
driver_dir = Path(self.__driver_dir)/driver_type.value/driver_version/driver_arch.value
|
||||
driver_path = driver_dir/exe_name
|
||||
return driver_path
|
||||
|
||||
def refresh(
|
||||
self
|
||||
):
|
||||
|
||||
self._detectBrowsers()
|
||||
self._checkDriverStatus()
|
||||
|
||||
def getDriverInfos(
|
||||
self
|
||||
) -> list[WebDriverInfo]:
|
||||
|
||||
with self.__lock:
|
||||
return self.__driver_infos.copy()
|
||||
|
||||
def getDriverInfo(
|
||||
self,
|
||||
driver_type: WebDriverType
|
||||
) -> list[WebDriverInfo]:
|
||||
|
||||
with self.__lock:
|
||||
return [
|
||||
info
|
||||
for info in self.__driver_infos
|
||||
if info.driver_type == driver_type
|
||||
]
|
||||
|
||||
def getDriverPath(
|
||||
self,
|
||||
driver_info: WebDriverInfo
|
||||
) -> Optional[Path]:
|
||||
|
||||
if driver_info and driver_info.driver_status == WebDriverStatus.INSTALLED:
|
||||
return driver_info.driver_path
|
||||
return None
|
||||
|
||||
def installDriver(
|
||||
self,
|
||||
driver_info: WebDriverInfo,
|
||||
progress_callback: Optional[Callable[[float, int, float, str], None]] = None,
|
||||
cancel_event: Optional[threading.Event] = None
|
||||
) -> Optional[Path]:
|
||||
|
||||
with self.__lock:
|
||||
if not driver_info:
|
||||
if progress_callback:
|
||||
progress_callback(0, 0, 0, "未找到浏览器信息")
|
||||
else:
|
||||
raise ValueError("未找到浏览器信息")
|
||||
if driver_info and driver_info.driver_status == WebDriverStatus.DOWNLOADING:
|
||||
if progress_callback:
|
||||
progress_callback(0, 0, 0, f"{driver_info.driver_type} 驱动正在下载中")
|
||||
else:
|
||||
raise ValueError(f"{driver_info.driver_type} 驱动正在下载中")
|
||||
try:
|
||||
if not driver_info:
|
||||
raise ValueError("未找到浏览器信息")
|
||||
driver_arch = driver_info.driver_arch
|
||||
driver_type = driver_info.driver_type
|
||||
driver_version = driver_info.driver_version
|
||||
downloader = None
|
||||
if driver_type == WebDriverType.CHROME:
|
||||
downloader = ChromeDriverDownloader(
|
||||
version=driver_version,
|
||||
arch=driver_arch,
|
||||
download_dir=self.__driver_dir
|
||||
)
|
||||
elif driver_type == WebDriverType.FIREFOX:
|
||||
downloader = FirefoxDriverDownloader(
|
||||
version=driver_version,
|
||||
arch=driver_arch,
|
||||
download_dir=self.__driver_dir
|
||||
)
|
||||
elif driver_type == WebDriverType.EDGE:
|
||||
downloader = EdgeDriverDownloader(
|
||||
version=driver_version,
|
||||
arch=driver_arch,
|
||||
download_dir=self.__driver_dir
|
||||
)
|
||||
if downloader is None:
|
||||
if progress_callback:
|
||||
progress_callback(0, 0, 0, f"不支持的 Web Driver 类型")
|
||||
else:
|
||||
raise ValueError(f"不支持的 Web Driver 类型")
|
||||
with self.__lock:
|
||||
driver_info.driver_status = WebDriverStatus.DOWNLOADING
|
||||
driver_path = downloader.download(progress_callback=progress_callback, cancel_event=cancel_event)
|
||||
with self.__lock:
|
||||
if driver_path:
|
||||
driver_info.driver_path = driver_path
|
||||
driver_info.driver_version = driver_version
|
||||
driver_info.driver_status = WebDriverStatus.INSTALLED
|
||||
else:
|
||||
driver_info.driver_status = WebDriverStatus.ERROR
|
||||
return driver_path
|
||||
except Exception as e:
|
||||
with self.__lock:
|
||||
driver_info.driver_status = WebDriverStatus.ERROR
|
||||
raise e
|
||||
|
||||
def cancelDriverDownload(
|
||||
self,
|
||||
driver_info: WebDriverInfo
|
||||
) -> bool:
|
||||
|
||||
import shutil
|
||||
|
||||
try:
|
||||
driver_path = self._getDriverPath(driver_info)
|
||||
if driver_path:
|
||||
download_dir = driver_path.parent
|
||||
if download_dir.exists():
|
||||
shutil.rmtree(download_dir, ignore_errors=True)
|
||||
with self.__lock:
|
||||
driver_info.driver_path = None
|
||||
driver_info.driver_status = WebDriverStatus.NOT_INSTALLED
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def uninstallDriver(
|
||||
self,
|
||||
driver_info: WebDriverInfo,
|
||||
progress_callback: Optional[Callable[[int, int, float, str], None]] = None
|
||||
) -> bool:
|
||||
|
||||
with self.__lock:
|
||||
if not driver_info:
|
||||
if progress_callback:
|
||||
progress_callback(0, 0, 0, "未找到浏览器信息")
|
||||
else:
|
||||
raise ValueError("未找到浏览器信息")
|
||||
if driver_info.driver_status != WebDriverStatus.INSTALLED:
|
||||
if progress_callback:
|
||||
progress_callback(0, 0, 0, f"{driver_info.driver_type} 驱动未安装")
|
||||
else:
|
||||
raise ValueError(f"{driver_info.driver_type} 驱动未安装")
|
||||
try:
|
||||
driver_path = driver_info.driver_path
|
||||
driver_path.unlink()
|
||||
with self.__lock:
|
||||
driver_info.driver_path = None
|
||||
driver_info.driver_status = WebDriverStatus.NOT_INSTALLED
|
||||
return True
|
||||
except Exception:
|
||||
with self.__lock:
|
||||
driver_info.driver_status = WebDriverStatus.ERROR
|
||||
raise
|
||||
|
||||
def driverDir(
|
||||
self
|
||||
) -> str:
|
||||
|
||||
return self.__driver_dir
|
||||
|
||||
|
||||
# WebDriverManager singleton instance.
|
||||
_webdriver_manager_instance = None
|
||||
|
||||
# Singleton instance lock.
|
||||
_instance_lock = threading.Lock()
|
||||
|
||||
def instance(
|
||||
driver_dir: str = ""
|
||||
) -> WebDriverManager:
|
||||
|
||||
global _webdriver_manager_instance
|
||||
with _instance_lock:
|
||||
if _webdriver_manager_instance is None:
|
||||
if not driver_dir:
|
||||
raise ValueError("WebDriverManager 需要驱动目录参数")
|
||||
_webdriver_manager_instance = WebDriverManager(driver_dir)
|
||||
else:
|
||||
if driver_dir and _webdriver_manager_instance.driverDir() != os.path.abspath(driver_dir):
|
||||
raise ValueError("WebDriverManager 的实例已初始化, 不能使用不同的驱动目录")
|
||||
return _webdriver_manager_instance
|
||||
@@ -0,0 +1,9 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Copyright (c) 2026 KenanZhu.
|
||||
All rights reserved.
|
||||
|
||||
This software is provided "as is", without any warranty of any kind.
|
||||
You may use, modify, and distribute this file under the terms of the MIT License.
|
||||
See the LICENSE file for details.
|
||||
"""
|
||||
@@ -0,0 +1,193 @@
|
||||
# -*- 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 logging
|
||||
import os
|
||||
import threading
|
||||
|
||||
from logging.handlers import TimedRotatingFileHandler
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class CallerInfoFormatter(logging.Formatter):
|
||||
"""
|
||||
Custom formatter to extract real caller information.
|
||||
Skips MsgBase._showTrace to show the actual calling location.
|
||||
|
||||
Format:
|
||||
- Logger name: left-aligned, max 15 chars
|
||||
- Level name: left-aligned, max 8 chars
|
||||
- Filename: left-aligned, max 20 chars
|
||||
- Line number: left-aligned, max 4 digits
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
fmt=None,
|
||||
datefmt=None,
|
||||
style='%'
|
||||
):
|
||||
|
||||
super().__init__(fmt, datefmt, style)
|
||||
self.basefmt = fmt
|
||||
|
||||
def format(
|
||||
self,
|
||||
record
|
||||
):
|
||||
|
||||
depth = 0
|
||||
while depth < 10:
|
||||
record.filename = os.path.basename(record.pathname)
|
||||
if 'MsgBase.py' not in record.filename and record.funcName != '_showTrace':
|
||||
break
|
||||
if not hasattr(record, 'stack'):
|
||||
record.stack = True
|
||||
import traceback
|
||||
record.stack_list = traceback.extract_stack()
|
||||
depth += 1
|
||||
if depth < len(record.stack_list):
|
||||
frame = record.stack_list[-depth-1]
|
||||
record.filename = os.path.basename(frame.filename)
|
||||
record.lineno = int(frame.lineno)
|
||||
record.funcName = frame.name
|
||||
record.name = record.name[-15:].ljust(15)
|
||||
record.levelname = record.levelname.ljust(8)
|
||||
record.filename = record.filename[-20:].ljust(20)
|
||||
# Ensure lineno is always integer before formatting
|
||||
try:
|
||||
lineno_int = int(record.lineno)
|
||||
except (ValueError, TypeError):
|
||||
lineno_int = 0
|
||||
record.lineno = f"{lineno_int:04d}"
|
||||
|
||||
return super().format(record)
|
||||
|
||||
|
||||
class LogManager:
|
||||
"""
|
||||
Log Manager Singleton Class
|
||||
|
||||
Args:
|
||||
log_dir (str): The directory to store log files.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
log_dir: str
|
||||
):
|
||||
|
||||
self.__log_dir = os.path.abspath(log_dir)
|
||||
self.__logger = None
|
||||
self.__initialized = False
|
||||
|
||||
self.initialize()
|
||||
|
||||
def initialize(
|
||||
self
|
||||
):
|
||||
|
||||
if self.__initialized:
|
||||
return
|
||||
os.makedirs(self.__log_dir, exist_ok=True)
|
||||
self.__logger = logging.getLogger("AutoLibrary")
|
||||
self.__logger.setLevel(logging.DEBUG)
|
||||
self.__logger.handlers.clear()
|
||||
|
||||
formatter = CallerInfoFormatter(
|
||||
'[%(asctime)s] - [%(name)s] - [%(levelname)s] - [%(filename)s:%(lineno)s] - %(message)s',
|
||||
datefmt='%Y-%m-%d %H:%M:%S'
|
||||
)
|
||||
|
||||
console_handler = logging.StreamHandler()
|
||||
console_handler.setLevel(logging.INFO)
|
||||
console_handler.setFormatter(formatter)
|
||||
self.__logger.addHandler(console_handler)
|
||||
|
||||
all_log_file = os.path.join(self.__log_dir, "all.log")
|
||||
file_handler_all = TimedRotatingFileHandler(
|
||||
all_log_file,
|
||||
when='midnight',
|
||||
interval=1,
|
||||
backupCount=7,
|
||||
encoding='utf-8'
|
||||
)
|
||||
file_handler_all.suffix = "%Y-%m-%d.log"
|
||||
file_handler_all.setLevel(logging.DEBUG)
|
||||
file_handler_all.setFormatter(formatter)
|
||||
self.__logger.addHandler(file_handler_all)
|
||||
|
||||
error_log_file = os.path.join(self.__log_dir, "error.log")
|
||||
file_handler_error = TimedRotatingFileHandler(
|
||||
error_log_file,
|
||||
when='midnight',
|
||||
interval=1,
|
||||
backupCount=14,
|
||||
encoding='utf-8'
|
||||
)
|
||||
file_handler_error.suffix = "%Y-%m-%d.log"
|
||||
file_handler_error.setLevel(logging.ERROR)
|
||||
file_handler_error.setFormatter(formatter)
|
||||
self.__logger.addHandler(file_handler_error)
|
||||
|
||||
self.__initialized = True
|
||||
|
||||
def getLogger(
|
||||
self,
|
||||
name: Optional[str] = None
|
||||
) -> logging.Logger:
|
||||
|
||||
if name:
|
||||
return self.__logger.getChild(name)
|
||||
return self.__logger
|
||||
|
||||
def setLevel(
|
||||
self,
|
||||
level: int
|
||||
):
|
||||
|
||||
if self.__logger:
|
||||
self.__logger.setLevel(level)
|
||||
|
||||
def logDir(
|
||||
self
|
||||
) -> str:
|
||||
|
||||
return self.__log_dir
|
||||
|
||||
|
||||
# LogManager singleton instance.
|
||||
_log_manager_instance = None
|
||||
|
||||
# Singleton instance lock.
|
||||
_instance_lock = threading.Lock()
|
||||
|
||||
def instance(
|
||||
log_dir: str = ""
|
||||
) -> LogManager:
|
||||
|
||||
global _log_manager_instance
|
||||
with _instance_lock:
|
||||
if _log_manager_instance is None:
|
||||
if not log_dir:
|
||||
raise ValueError("LogManager 需要日志目录参数")
|
||||
_log_manager_instance = LogManager(log_dir)
|
||||
else:
|
||||
if log_dir and _log_manager_instance.logDir() != os.path.abspath(log_dir):
|
||||
raise ValueError("LogManager 的实例已初始化, 不能使用不同的日志目录")
|
||||
return _log_manager_instance
|
||||
|
||||
# export function to get logger
|
||||
def getLogger(
|
||||
name: Optional[str] = None
|
||||
) -> logging.Logger:
|
||||
|
||||
if _log_manager_instance is None:
|
||||
raise RuntimeError("LogManager 未初始化, 请先调用 LogManager.instance(log_dir) 初始化")
|
||||
return _log_manager_instance.getLogger(name)
|
||||
@@ -0,0 +1,9 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Copyright (c) 2026 KenanZhu.
|
||||
All rights reserved.
|
||||
|
||||
This software is provided "as is", without any warranty of any kind.
|
||||
You may use, modify, and distribute this file under the terms of the MIT License.
|
||||
See the LICENSE file for details.
|
||||
"""
|
||||
@@ -1,335 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Copyright (c) 2025 - 2026 KenanZhu.
|
||||
All rights reserved.
|
||||
|
||||
This software is provided "as is", without any warranty of any kind.
|
||||
You may use, modify, and distribute this file under the terms of the MIT License.
|
||||
See the LICENSE file for details.
|
||||
"""
|
||||
import os
|
||||
import queue
|
||||
|
||||
from selenium import webdriver
|
||||
from selenium.common.exceptions import TimeoutException
|
||||
from selenium.webdriver.common.by import By
|
||||
from selenium.webdriver.support.ui import WebDriverWait
|
||||
from selenium.webdriver.support import expected_conditions as EC
|
||||
from selenium.webdriver.edge.service import Service as EdgeService
|
||||
from selenium.webdriver.chrome.service import Service as ChromeService
|
||||
from selenium.webdriver.firefox.service import Service as FirefoxService
|
||||
|
||||
from base.MsgBase import MsgBase
|
||||
from operators.LibChecker import LibChecker
|
||||
from operators.LibLogin import LibLogin
|
||||
from operators.LibLogout import LibLogout
|
||||
from operators.LibReserve import LibReserve
|
||||
from operators.LibCheckin import LibCheckin
|
||||
from operators.LibRenew import LibRenew
|
||||
|
||||
|
||||
class AutoLib(MsgBase):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
input_queue: queue.Queue,
|
||||
output_queue: queue.Queue,
|
||||
run_config: dict
|
||||
):
|
||||
super().__init__(input_queue, output_queue)
|
||||
|
||||
self.__run_config = run_config
|
||||
self.__user_config = None
|
||||
self.__driver = None
|
||||
if not self.__initBrowserDriver():
|
||||
raise Exception("浏览器驱动初始化失败 !")
|
||||
else:
|
||||
if not self.__initDriverUrl():
|
||||
self.close()
|
||||
raise Exception("浏览器驱动URL初始化失败 !")
|
||||
self.__initLibOperators()
|
||||
|
||||
|
||||
def __initBrowserDriver(
|
||||
self
|
||||
) -> bool:
|
||||
|
||||
self._showTrace("正在初始化浏览器驱动......")
|
||||
|
||||
web_driver_config = self.__run_config.get("web_driver", None)
|
||||
self.__driver_type = web_driver_config.get("driver_type")
|
||||
match self.__driver_type.lower():
|
||||
case "edge":
|
||||
driver_options = webdriver.EdgeOptions()
|
||||
case "chrome":
|
||||
driver_options = webdriver.ChromeOptions()
|
||||
case "firefox":
|
||||
driver_options = webdriver.FirefoxOptions()
|
||||
case _:
|
||||
self._showTrace(f"不支持的浏览器驱动类型: {self.__driver_type} !")
|
||||
return False
|
||||
|
||||
if not web_driver_config:
|
||||
self._showTrace("未配置浏览器驱动参数 !")
|
||||
return False
|
||||
if web_driver_config.get("headless"):
|
||||
driver_options.add_argument("--headless")
|
||||
driver_options.add_argument("--disable-gpu")
|
||||
driver_options.add_argument("--no-sandbox")
|
||||
driver_options.add_argument("--disable-dev-shm-usage")
|
||||
|
||||
# must be 1920x1080, otherwise the page will cause some elements not accessible
|
||||
driver_options.add_argument("--window-size=1920,1080")
|
||||
|
||||
# omit ssl errors and verbose log level
|
||||
driver_options.add_argument("--ignore-certificate-errors")
|
||||
driver_options.add_argument("--ignore-ssl-errors")
|
||||
driver_options.add_argument("--log-level=OFF")
|
||||
driver_options.add_argument("--silent")
|
||||
|
||||
# set options for chrome and edge
|
||||
if self.__driver_type.lower() in ["edge", "chrome"]:
|
||||
driver_options.add_argument("--remote-allow-origins=*")
|
||||
driver_options.add_experimental_option("excludeSwitches", ["enable-automation"])
|
||||
driver_options.add_experimental_option("useAutomationExtension", False)
|
||||
driver_options.add_argument("--disable-blink-features=AutomationControlled")
|
||||
user_agent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "\
|
||||
"AppleWebKit/537.36 (KHTML, like Gecko) "\
|
||||
"Chrome/120.0.0.0 "\
|
||||
"Safari/537.36"
|
||||
if self.__driver_type.lower() == "edge":
|
||||
user_agent += " Edg/120.0.0.0"
|
||||
# set options for firefox
|
||||
elif self.__driver_type.lower() == "firefox":
|
||||
driver_options.set_preference("dom.webdriver.enabled", False)
|
||||
driver_options.set_preference("useAutomationExtension", False)
|
||||
user_agent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:120.0) "\
|
||||
"Gecko/20100101 Firefox/120.0"
|
||||
driver_options.add_argument(f"user-agent={user_agent}")
|
||||
|
||||
# init browser driver
|
||||
self.__driver_path = web_driver_config.get("driver_path")
|
||||
if not self.__driver_path:
|
||||
self._showTrace("未配置浏览器驱动路径 !")
|
||||
return False
|
||||
self.__driver_path = os.path.abspath(self.__driver_path)
|
||||
try:
|
||||
service = None
|
||||
match self.__driver_type.lower():
|
||||
case "edge":
|
||||
service = EdgeService(executable_path=self.__driver_path)
|
||||
self.__driver = webdriver.Edge(service=service, options=driver_options)
|
||||
case "chrome":
|
||||
service = ChromeService(executable_path=self.__driver_path)
|
||||
self.__driver = webdriver.Chrome(service=service, options=driver_options)
|
||||
case "firefox":
|
||||
self._showTrace(f"Firefox 浏览器驱动初始化略慢, 请耐心等待...")
|
||||
service = FirefoxService(executable_path=self.__driver_path)
|
||||
self.__driver = webdriver.Firefox(service=service, options=driver_options)
|
||||
case _: # actually will not happen, beacuse we have checked it at the initlization
|
||||
# of 'driver_options'
|
||||
raise Exception(f"不支持的浏览器驱动类型: {self.__driver_type}")
|
||||
self.__driver.implicitly_wait(1)
|
||||
self.__driver.execute_script(
|
||||
"Object.defineProperty(navigator, 'webdriver', {get: () => undefined})"
|
||||
)
|
||||
except Exception as e:
|
||||
self._showTrace(f"浏览器驱动初始化失败: {e}")
|
||||
return False
|
||||
self._showTrace(f"浏览器驱动已初始化, 类型: {self.__driver_type}, 路径: {self.__driver_path}")
|
||||
return True
|
||||
|
||||
|
||||
def __initLibOperators(
|
||||
self
|
||||
):
|
||||
|
||||
if not self.__driver:
|
||||
self._showTrace(f"浏览器驱动未初始化, 请先初始化浏览器驱动 !")
|
||||
return
|
||||
self.__lib_checker = LibChecker(self._input_queue, self._output_queue, self.__driver)
|
||||
self.__lib_login = LibLogin(self._input_queue, self._output_queue, self.__driver)
|
||||
self.__lib_logout = LibLogout(self._input_queue, self._output_queue, self.__driver)
|
||||
self.__lib_reserve = LibReserve(self._input_queue, self._output_queue, self.__driver)
|
||||
self.__lib_checkin = LibCheckin(self._input_queue, self._output_queue, self.__driver)
|
||||
self.__lib_renew = LibRenew(self._input_queue, self._output_queue, self.__driver)
|
||||
|
||||
|
||||
def __waitResponseLoad(
|
||||
self
|
||||
) -> bool:
|
||||
|
||||
# wait for page load
|
||||
try:
|
||||
WebDriverWait(self.__driver, 2).until( # title contains "首页"
|
||||
EC.title_contains("首页")
|
||||
)
|
||||
WebDriverWait(self.__driver, 2).until( # username field presence
|
||||
EC.presence_of_element_located((By.NAME, "username"))
|
||||
)
|
||||
WebDriverWait(self.__driver, 2).until( # password field presence
|
||||
EC.presence_of_element_located((By.NAME, "password"))
|
||||
)
|
||||
WebDriverWait(self.__driver, 2).until( # captcha field presence
|
||||
EC.presence_of_element_located((By.NAME, "answer"))
|
||||
)
|
||||
WebDriverWait(self.__driver, 2).until( # captcha image presence
|
||||
EC.presence_of_element_located((By.ID, "loadImgId"))
|
||||
)
|
||||
return True
|
||||
except:
|
||||
self._showTrace(f"登录页面加载失败 !")
|
||||
return False
|
||||
|
||||
|
||||
def __initDriverUrl(
|
||||
self,
|
||||
) -> bool:
|
||||
|
||||
lib_config = self.__run_config.get("library", None)
|
||||
if not lib_config:
|
||||
self._showTrace("未配置图书馆参数 !")
|
||||
return False
|
||||
url = lib_config.get("host_url") + lib_config.get("login_url")
|
||||
self.__driver.set_page_load_timeout(5)
|
||||
try:
|
||||
self.__driver.get(url)
|
||||
except TimeoutException:
|
||||
self.__driver.execute_script("window.stop();")
|
||||
self._showTrace(f"图书馆登录页面加载超时 ! 请检查网络环境是否正常")
|
||||
return False
|
||||
if not self.__waitResponseLoad():
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def __run(
|
||||
self,
|
||||
username: str,
|
||||
password: str,
|
||||
login_config: dict,
|
||||
run_mode_config: dict,
|
||||
reserve_info: dict
|
||||
) -> int:
|
||||
|
||||
# result : -1 - terminate, 0 - success, 1 - failed, 2 - passed
|
||||
result = 2
|
||||
|
||||
# login
|
||||
if not self.__lib_login.login(
|
||||
username,
|
||||
password,
|
||||
login_config.get("max_attempt", 3),
|
||||
login_config.get("auto_captcha", True),
|
||||
):
|
||||
return 1
|
||||
# Here, we collect the run mode from the run config.
|
||||
run_mode = run_mode_config.get("run_mode", 0)
|
||||
run_mode = {
|
||||
"auto_reserve": run_mode&0x1,
|
||||
"auto_checkin": run_mode&0x2,
|
||||
"auto_renewal": run_mode&0x4,
|
||||
}
|
||||
# reserve
|
||||
if run_mode["auto_reserve"]:
|
||||
if self.__lib_checker.canReserve(reserve_info.get("date")):
|
||||
if self.__lib_reserve.reserve(username, reserve_info):
|
||||
result = 0
|
||||
else:
|
||||
result = 1
|
||||
else:
|
||||
self._showTrace(f"用户 {username} 无法预约,已跳过")
|
||||
result = 2
|
||||
# checkin
|
||||
if run_mode["auto_checkin"] and result == 2:
|
||||
if self.__lib_checker.canCheckin():
|
||||
if self.__lib_checkin.checkin(username):
|
||||
result = 0
|
||||
else:
|
||||
result = 1
|
||||
else:
|
||||
self._showTrace(f"用户 {username} 无法签到,已跳过")
|
||||
result = 2
|
||||
# renewal
|
||||
if run_mode["auto_renewal"] and result == 2:
|
||||
if record := self.__lib_checker.canRenew():
|
||||
if self.__lib_renew.renew(username, record, reserve_info):
|
||||
if self.__lib_checker.postRenewCheck(record):
|
||||
result = 0
|
||||
else:
|
||||
result = 1
|
||||
else:
|
||||
result = 1
|
||||
else:
|
||||
self._showTrace(f"用户 {username} 无法续约,已跳过")
|
||||
result = 2
|
||||
# logout
|
||||
if not self.__lib_logout.logout(
|
||||
username
|
||||
):
|
||||
# if logout is failed, we must make sure the host to be reloaded
|
||||
# otherwise, the next login may fail
|
||||
if not self.__initDriverUrl():
|
||||
return -1
|
||||
return result
|
||||
|
||||
|
||||
def run(
|
||||
self,
|
||||
user_config: dict
|
||||
):
|
||||
|
||||
self.__user_config = user_config
|
||||
|
||||
user_counter = {"current": 0, "success": 0, "failed": 0, "passed": 0}
|
||||
users = self.__user_config["users"]
|
||||
self._showTrace(f"共发现 {len(users)} 个用户")
|
||||
for user in users:
|
||||
user_counter["current"] += 1
|
||||
self._showTrace(
|
||||
f"正在处理第 {user_counter["current"]}/{len(users)} 个用户: {user["username"]}......"
|
||||
)
|
||||
if not user["enabled"]:
|
||||
self._showTrace(f"用户 {user["username"]} 已跳过")
|
||||
user_counter["passed"] += 1
|
||||
continue
|
||||
r = self.__run(
|
||||
username=user["username"],
|
||||
password=user["password"],
|
||||
login_config=self.__run_config["login"],
|
||||
run_mode_config=self.__run_config["mode"],
|
||||
reserve_info=user["reserve_info"],
|
||||
)
|
||||
if r == -1:
|
||||
self._showTrace(
|
||||
f"用户 {user["username"]} 处理过程中页面发生异常,无法继续操作, 任务已终止 !"
|
||||
)
|
||||
break
|
||||
elif r == 0:
|
||||
user_counter["success"] += 1
|
||||
elif r == 1:
|
||||
user_counter["failed"] += 1
|
||||
elif r == 2:
|
||||
user_counter["passed"] += 1
|
||||
self._showTrace(f"处理完成, 共计 {user_counter["current"]} 个用户, "\
|
||||
f"成功 {user_counter["success"]} 个用户, "\
|
||||
f"失败 {user_counter["failed"]} 个用户, "\
|
||||
f"跳过 {user_counter["passed"]} 个用户"
|
||||
)
|
||||
return
|
||||
|
||||
|
||||
def close(
|
||||
self
|
||||
) -> bool:
|
||||
|
||||
if self.__driver:
|
||||
if self.__driver_type.lower() == "firefox":
|
||||
self._showTrace(f"Firefox 浏览器驱动关闭略慢, 请耐心等待...")
|
||||
self.__driver.quit()
|
||||
self.__driver = None
|
||||
self._showTrace(f"浏览器驱动已关闭")
|
||||
return True
|
||||
else:
|
||||
self._showTrace(f"浏览器驱动未初始化, 无需关闭")
|
||||
return False
|
||||
@@ -1,375 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Copyright (c) 2025 - 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
|
||||
import time
|
||||
import queue
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from selenium.webdriver.common.by import By
|
||||
from selenium.webdriver.chrome.webdriver import WebDriver
|
||||
from selenium.webdriver.support.ui import WebDriverWait
|
||||
from selenium.webdriver.support import expected_conditions as EC
|
||||
|
||||
from base.LibOperator import LibOperator
|
||||
|
||||
|
||||
class LibChecker(LibOperator):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
input_queue: queue.Queue,
|
||||
output_queue: queue.Queue,
|
||||
driver: WebDriver
|
||||
):
|
||||
|
||||
super().__init__(input_queue, output_queue)
|
||||
|
||||
self.__driver = driver
|
||||
|
||||
|
||||
def _waitResponseLoad(
|
||||
self
|
||||
) -> bool:
|
||||
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def __formatDiffTime(
|
||||
seconds: float
|
||||
) -> str:
|
||||
|
||||
hours = int(seconds//3600)
|
||||
minutes = int(seconds%3600//60)
|
||||
seconds = int(seconds%60)
|
||||
return f"{hours} 时 {minutes} 分 {seconds} 秒"
|
||||
|
||||
|
||||
def __navigateToReserveRecordPage(
|
||||
self
|
||||
) -> bool:
|
||||
|
||||
try:
|
||||
WebDriverWait(self.__driver, 2).until(
|
||||
EC.element_to_be_clickable((By.XPATH, "//a[@href='/history?type=SEAT']"))
|
||||
).click()
|
||||
WebDriverWait(self.__driver, 2).until(
|
||||
EC.presence_of_element_located((By.CLASS_NAME, "myReserveList"))
|
||||
)
|
||||
except:
|
||||
self._showTrace("加载预约记录页面失败 !")
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def __decodeReserveTime(
|
||||
self,
|
||||
time_element
|
||||
) -> dict:
|
||||
|
||||
time_str = time_element.text.strip()
|
||||
today = datetime.now().date()
|
||||
if "明天" in time_str:
|
||||
target_date = today + timedelta(days=1)
|
||||
date = target_date.strftime("%Y-%m-%d")
|
||||
elif "今天" in time_str:
|
||||
target_date = today
|
||||
date = target_date.strftime("%Y-%m-%d")
|
||||
elif "昨天" in time_str:
|
||||
target_date = today - timedelta(days=1)
|
||||
date = target_date.strftime("%Y-%m-%d")
|
||||
else:
|
||||
date_match = re.search(r"(\d{4}-\d{1,2}-\d{1,2})", time_str)
|
||||
if date_match:
|
||||
date = date_match.group(1)
|
||||
else:
|
||||
date = ""
|
||||
time_match = re.search(r"(\d{1,2}:\d{2}) -- (\d{1,2}:\d{2})", time_str)
|
||||
if time_match:
|
||||
begin_time = time_match.group(1)
|
||||
end_time = time_match.group(2)
|
||||
else:
|
||||
begin_time = ""
|
||||
end_time = ""
|
||||
return {
|
||||
"date": date,
|
||||
"time": {
|
||||
"begin": begin_time,
|
||||
"end": end_time
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def __decodeReserveInfo(
|
||||
self,
|
||||
info_elements
|
||||
) -> str:
|
||||
|
||||
location = ""
|
||||
status = ""
|
||||
for info in info_elements:
|
||||
if "已预约" in info.text:
|
||||
status = "已预约"
|
||||
elif "使用中" in info.text:
|
||||
status = "使用中"
|
||||
elif "已完成" in info.text:
|
||||
status = "已完成"
|
||||
elif "已结束使用" in info.text:
|
||||
status = "已结束使用"
|
||||
elif "已取消" in info.text:
|
||||
status = "已取消"
|
||||
elif "失约" in info.text:
|
||||
status = "失约"
|
||||
elif "图书馆" in info.text:
|
||||
location = info.text.strip()
|
||||
return {
|
||||
"location": location,
|
||||
"status": status,
|
||||
}
|
||||
|
||||
|
||||
def __decodeReserveRecord(
|
||||
self,
|
||||
reservation
|
||||
) -> dict:
|
||||
|
||||
try:
|
||||
time_element = reservation.find_element(
|
||||
By.CSS_SELECTOR, "dt"
|
||||
)
|
||||
info_elements = reservation.find_elements(
|
||||
By.CSS_SELECTOR, "a"
|
||||
)
|
||||
except:
|
||||
return {
|
||||
"date": "",
|
||||
"time": {"begin": "", "end": ""},
|
||||
"info": {"location": "", "status": ""}
|
||||
}
|
||||
time = self.__decodeReserveTime(time_element)
|
||||
info = self.__decodeReserveInfo(info_elements)
|
||||
return {
|
||||
"date": time["date"],
|
||||
"time": time["time"],
|
||||
"info": info
|
||||
}
|
||||
|
||||
|
||||
def __loadReserveRecords(
|
||||
self
|
||||
) -> list:
|
||||
try:
|
||||
# check if there's any reservation on the date
|
||||
WebDriverWait(self.__driver, 2).until(
|
||||
EC.presence_of_element_located((By.CSS_SELECTOR, ".myReserveList > dl"))
|
||||
)
|
||||
reservations = self.__driver.find_elements(
|
||||
By.CSS_SELECTOR, ".myReserveList > dl:not(#moreBlock)"
|
||||
)
|
||||
return reservations
|
||||
except:
|
||||
self._showTrace("加载预约记录失败 !")
|
||||
return None
|
||||
|
||||
|
||||
def __showMoreReserveRecords(
|
||||
self
|
||||
) -> bool:
|
||||
|
||||
# load new reservations if still not sure
|
||||
try:
|
||||
WebDriverWait(self.__driver, 0.1).until(
|
||||
EC.element_to_be_clickable((By.ID, "moreBtn"))
|
||||
)
|
||||
except:
|
||||
# the reservation is the last one
|
||||
return False
|
||||
try:
|
||||
more_btn = self.__driver.find_element(By.ID, "moreBtn")
|
||||
if more_btn.is_displayed() and more_btn.is_enabled():
|
||||
self.__driver.execute_script("arguments[0].scrollIntoView(true);", more_btn)
|
||||
self.__driver.execute_script("arguments[0].click();", more_btn)
|
||||
return True
|
||||
else:
|
||||
self._showTrace("用户无法加载更多预约记录")
|
||||
return False
|
||||
except:
|
||||
self._showTrace("加载更多预约记录失败 !")
|
||||
return False
|
||||
|
||||
|
||||
def __getReserveRecord(
|
||||
self,
|
||||
wanted_date: str,
|
||||
wanted_status: str
|
||||
) -> dict:
|
||||
|
||||
if wanted_date is None:
|
||||
self._showTrace("日期未指定, 无法检查当前预约状态")
|
||||
return None
|
||||
self._showTrace(f"正在检查用户在 {wanted_date} 是否有预约状态为 {wanted_status} 的预约记录......")
|
||||
|
||||
checked_count = 0
|
||||
max_check_times = 6 # we only check (4*(6-1)=)20 reservations, the last time cant be checked
|
||||
|
||||
if not self.__navigateToReserveRecordPage():
|
||||
return None
|
||||
for _ in range(max_check_times):
|
||||
reservations = self.__loadReserveRecords()
|
||||
if reservations is None:
|
||||
return None
|
||||
for reservation in reservations[checked_count:]:
|
||||
record = self.__decodeReserveRecord(reservation)
|
||||
checked_count += 1
|
||||
if record is None:
|
||||
continue
|
||||
if record["date"] == "":
|
||||
continue
|
||||
if record["time"] == {"begin": "", "end": ""}:
|
||||
continue
|
||||
# record date is later than the given date, check the next one
|
||||
if datetime.strptime(record["date"], "%Y-%m-%d").date() >\
|
||||
datetime.strptime(wanted_date, "%Y-%m-%d").date():
|
||||
continue
|
||||
# record date is earlier than the given date, so there is no wanted record
|
||||
if datetime.strptime(record["date"], "%Y-%m-%d").date() <\
|
||||
datetime.strptime(wanted_date, "%Y-%m-%d").date():
|
||||
return None
|
||||
if record["info"]["status"] == wanted_status:
|
||||
self._showTrace(
|
||||
f"寻找到用户第 {checked_count} 条状态为 {wanted_status} 的预约记录, "
|
||||
f"详细信息: {record["date"]} "
|
||||
f"{record["time"]["begin"]} - {record["time"]["end"]} {record["info"]["location"]}"
|
||||
)
|
||||
return record
|
||||
if not self.__showMoreReserveRecords():
|
||||
break
|
||||
return None
|
||||
|
||||
|
||||
def canReserve(
|
||||
self,
|
||||
date: str
|
||||
) -> bool:
|
||||
|
||||
# no reserved or using record in the given date
|
||||
# then can reserve
|
||||
if self.__getReserveRecord(date, "已预约") is None:
|
||||
if self.__getReserveRecord(date, "使用中") is None:
|
||||
self._showTrace(f"用户在 {date} 可以预约")
|
||||
return True
|
||||
self._showTrace(f"用户在 {date} 有使用中的预约, 无法预约")
|
||||
return False
|
||||
self._showTrace(f"用户在 {date} 已存在有效预约, 无法预约")
|
||||
return False
|
||||
|
||||
|
||||
def canCheckin(
|
||||
self
|
||||
) -> bool:
|
||||
|
||||
# only check the current date
|
||||
date = time.strftime("%Y-%m-%d", time.localtime())
|
||||
record = self.__getReserveRecord(date, "已预约")
|
||||
if record is not None:
|
||||
begin_time = record["time"]["begin"]
|
||||
begin_time = datetime.strptime(f"{date} {begin_time}", "%Y-%m-%d %H:%M")
|
||||
time_diff = datetime.now() - begin_time
|
||||
time_diff_seconds = time_diff.total_seconds()
|
||||
# before 30 minutes, cant checkin
|
||||
if time_diff_seconds < -30*60:
|
||||
self._showTrace(
|
||||
f"用户在 {date} 的预约开始时间为 {begin_time}, "
|
||||
f"当前距离预约开始时间还有 {self.__formatDiffTime(abs(time_diff_seconds))}, 无法签到"
|
||||
)
|
||||
return False
|
||||
# before in 30 minutes, can checkin
|
||||
elif -30*60 <= time_diff_seconds < 0:
|
||||
self._showTrace(
|
||||
f"用户在 {date} 的预约开始时间为 {begin_time}, "
|
||||
f"当前距离预约开始时间还有 {self.__formatDiffTime(abs(time_diff_seconds))}, 可以签到"
|
||||
)
|
||||
return True
|
||||
# past less than 30 minutes, can checkin
|
||||
elif 0 <= time_diff_seconds < 30*60 - 5: # spare 5 seconds for the checkin process
|
||||
self._showTrace(
|
||||
f"用户在 {date} 的预约开始时间为 {begin_time}, "
|
||||
f"当前距离预约开始时间已经过去 {self.__formatDiffTime(abs(time_diff_seconds))}, 可以签到"
|
||||
)
|
||||
return True
|
||||
self._showTrace(f"用户在 {date} 没有有效预约记录, 无法签到")
|
||||
return False
|
||||
|
||||
|
||||
def canRenew(
|
||||
self
|
||||
):
|
||||
|
||||
# only check the current date
|
||||
date = time.strftime("%Y-%m-%d", time.localtime())
|
||||
record = self.__getReserveRecord(date, "使用中")
|
||||
if record is not None:
|
||||
end_time = record["time"]["end"]
|
||||
end_time = datetime.strptime(f"{date} {end_time}", "%Y-%m-%d %H:%M")
|
||||
time_diff = end_time - datetime.now()
|
||||
time_diff_seconds = time_diff.total_seconds()
|
||||
# a using record is definitely after the begin time
|
||||
trace_msg = (
|
||||
f"用户在 {date} 的预约结束时间为 {end_time}, "
|
||||
f"当前距离预约结束时间还有 {self.__formatDiffTime(abs(time_diff_seconds))}"
|
||||
)
|
||||
if abs(time_diff_seconds) < 120*60:
|
||||
self._showTrace(f"{trace_msg}, 可以续约")
|
||||
return record
|
||||
else:
|
||||
self._showTrace(f"{trace_msg}, 无法续约")
|
||||
return None
|
||||
self._showTrace(f"用户在 {date} 没有有效预约记录, 无法续约")
|
||||
return None
|
||||
|
||||
|
||||
def postRenewCheck(
|
||||
self,
|
||||
record: dict
|
||||
) -> bool:
|
||||
"""
|
||||
Check if the renew operation is successful
|
||||
|
||||
Args:
|
||||
record (dict): The expected record after renewal
|
||||
|
||||
Returns:
|
||||
bool: True if the renew operation is successful, False otherwise
|
||||
"""
|
||||
# because the special circumstance that the renew operation
|
||||
# do not show the success message or anything else,
|
||||
# we need to check the record data to make sure the renew operation is successful.
|
||||
|
||||
# only check the given record date
|
||||
date = record["date"]
|
||||
act_record = self.__getReserveRecord(date, "使用中")
|
||||
if act_record is not None:
|
||||
if act_record["time"]["begin"] == record["time"]["begin"] and\
|
||||
act_record["time"]["end"] == record["time"]["end"]:
|
||||
self._showTrace(f"\n"\
|
||||
f" 续约成功 !\n"\
|
||||
f" 日 期 :{date}\n"\
|
||||
f" 时 间 :{act_record["time"]["begin"]} - {act_record["time"]["end"]}\n"\
|
||||
f" 位 置 :{act_record["info"]["location"]}\n"
|
||||
f" 状 态 :{act_record["info"]["status"]}"
|
||||
)
|
||||
return True
|
||||
else:
|
||||
self._showTrace(f"\n"\
|
||||
f" 续约失败 !\n"\
|
||||
f" 续约后结束时间为 {act_record["time"]["end"]},与预期结束时间 {record["time"]["end"]} 不符 !"
|
||||
)
|
||||
return False
|
||||
self._showTrace(f"用户在 {date} 没有有效预约记录, 无法检查续约结果")
|
||||
return False
|
||||
@@ -1,115 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Copyright (c) 2025 - 2026 KenanZhu.
|
||||
All rights reserved.
|
||||
|
||||
This software is provided "as is", without any warranty of any kind.
|
||||
You may use, modify, and distribute this file under the terms of the MIT License.
|
||||
See the LICENSE file for details.
|
||||
"""
|
||||
import time
|
||||
import queue
|
||||
|
||||
from selenium.webdriver.common.by import By
|
||||
from selenium.webdriver.chrome.webdriver import WebDriver
|
||||
from selenium.webdriver.support.ui import WebDriverWait
|
||||
from selenium.webdriver.support import expected_conditions as EC
|
||||
|
||||
from base.LibOperator import LibOperator
|
||||
|
||||
|
||||
class LibCheckin(LibOperator):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
input_queue: queue.Queue,
|
||||
output_queue: queue.Queue,
|
||||
driver: WebDriver
|
||||
):
|
||||
|
||||
super().__init__(input_queue, output_queue)
|
||||
|
||||
self.__driver = driver
|
||||
|
||||
|
||||
def _waitResponseLoad(
|
||||
self
|
||||
) -> bool:
|
||||
|
||||
try:
|
||||
WebDriverWait(self.__driver, 2).until(
|
||||
EC.presence_of_element_located((By.CLASS_NAME, "ui_dialog"))
|
||||
)
|
||||
WebDriverWait(self.__driver, 2).until(
|
||||
EC.presence_of_element_located((By.CLASS_NAME, "resultMessage"))
|
||||
)
|
||||
WebDriverWait(self.__driver, 2).until(
|
||||
EC.element_to_be_clickable((By.CLASS_NAME, "btnOK"))
|
||||
)
|
||||
result_message_element = self.__driver.find_element(
|
||||
By.CLASS_NAME, "resultMessage"
|
||||
)
|
||||
ok_btn = self.__driver.find_element(By.CLASS_NAME, "btnOK")
|
||||
except:
|
||||
self._showTrace("签到时发生未知错误 !")
|
||||
return False
|
||||
result_message = result_message_element.text
|
||||
if "签到成功" in result_message:
|
||||
try:
|
||||
detail_elements = self.__driver.find_elements(
|
||||
By.CSS_SELECTOR, ".resultMessage dd"
|
||||
)
|
||||
except:
|
||||
pass
|
||||
if detail_elements:
|
||||
details = [element.text for element in detail_elements if element.text.strip()]
|
||||
if len(details) >= 5:
|
||||
self._showTrace(f"\n"\
|
||||
f" 签到成功 !\n"\
|
||||
f" {details[1]}\n"\
|
||||
f" {details[2]}\n"\
|
||||
f" {details[3]}\n"\
|
||||
f" {details[4]}"
|
||||
)
|
||||
else:
|
||||
self._showTrace(f"\n"\
|
||||
" 签到成功 !\n"\
|
||||
" 未获取到签到详情 !"
|
||||
)
|
||||
ok_btn.click()
|
||||
return True
|
||||
else:
|
||||
failure_reason = result_message.replace("签到失败", "").strip()
|
||||
self._showTrace(f"\n"\
|
||||
" 签到失败 !\n"\
|
||||
f" {failure_reason}"
|
||||
)
|
||||
ok_btn.click()
|
||||
return False
|
||||
|
||||
|
||||
def checkin(
|
||||
self,
|
||||
username: str
|
||||
) -> bool:
|
||||
|
||||
if self.__driver is None:
|
||||
self._showTrace("未提供有效 WebDriver 实例 !")
|
||||
return False
|
||||
try:
|
||||
checkin_btn = WebDriverWait(self.__driver, 2).until(
|
||||
EC.element_to_be_clickable((By.ID, "btnCheckIn"))
|
||||
)
|
||||
except:
|
||||
self._showTrace(f"用户 {username} 签到界面加载失败 !")
|
||||
return False
|
||||
if "disabled" in checkin_btn.get_attribute("class"):
|
||||
self._showTrace("签到按钮不可用, 可能不在场馆内, 请连接图书馆网络后重试")
|
||||
return False
|
||||
checkin_btn.click()
|
||||
if self._waitResponseLoad():
|
||||
self._showTrace(f"用户 {username} 签到成功 !")
|
||||
return True
|
||||
else:
|
||||
self._showTrace(f"用户 {username} 签到失败 !")
|
||||
return False
|
||||
@@ -1,41 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Copyright (c) 2025 - 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
|
||||
import time
|
||||
import queue
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from selenium.webdriver.common.by import By
|
||||
from selenium.webdriver.chrome.webdriver import WebDriver
|
||||
from selenium.webdriver.support.ui import WebDriverWait
|
||||
from selenium.webdriver.support import expected_conditions as EC
|
||||
|
||||
from base.LibOperator import LibOperator
|
||||
|
||||
|
||||
class LibCheckout(LibOperator):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
input_queue: queue.Queue,
|
||||
output_queue: queue.Queue,
|
||||
driver: WebDriver
|
||||
):
|
||||
|
||||
super().__init__(input_queue, output_queue)
|
||||
|
||||
self.__driver = driver
|
||||
|
||||
|
||||
def _waitResponseLoad(
|
||||
self
|
||||
) -> bool:
|
||||
|
||||
pass
|
||||
@@ -1,207 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Copyright (c) 2025 - 2026 KenanZhu.
|
||||
All rights reserved.
|
||||
|
||||
This software is provided "as is", without any warranty of any kind.
|
||||
You may use, modify, and distribute this file under the terms of the MIT License.
|
||||
See the LICENSE file for details.
|
||||
"""
|
||||
import queue
|
||||
import base64
|
||||
|
||||
import ddddocr
|
||||
|
||||
from selenium.webdriver.common.by import By
|
||||
from selenium.webdriver.chrome.webdriver import WebDriver
|
||||
from selenium.webdriver.support.ui import WebDriverWait
|
||||
from selenium.webdriver.support import expected_conditions as EC
|
||||
|
||||
from base.LibOperator import LibOperator
|
||||
|
||||
|
||||
class LibLogin(LibOperator):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
input_queue: queue.Queue,
|
||||
output_queue: queue.Queue,
|
||||
driver: WebDriver
|
||||
):
|
||||
|
||||
super().__init__(input_queue, output_queue)
|
||||
|
||||
self.__driver = driver
|
||||
self.__ddddocr = ddddocr.DdddOcr()
|
||||
|
||||
|
||||
def _waitResponseLoad(
|
||||
self
|
||||
) -> bool:
|
||||
|
||||
# wait to verify login success
|
||||
try:
|
||||
WebDriverWait(self.__driver, 2).until( # title contains "自选座位 :: 座位预约系统"
|
||||
EC.title_contains("自选座位 :: 座位预约系统")
|
||||
)
|
||||
WebDriverWait(self.__driver, 2).until( # search button presence
|
||||
EC.presence_of_element_located((By.ID, "search"))
|
||||
)
|
||||
WebDriverWait(self.__driver, 2).until( # select content presence
|
||||
EC.presence_of_element_located((By.CLASS_NAME, "selectContent"))
|
||||
)
|
||||
return True
|
||||
except:
|
||||
self._showTrace(f"登录页面加载失败 ! : 用户账号或者密码错误/验证码错误, 具体以页面提示为准")
|
||||
return False
|
||||
|
||||
|
||||
def __fillLogInElements(
|
||||
self,
|
||||
username: str,
|
||||
password: str
|
||||
) -> bool:
|
||||
|
||||
# ensure elements presence and fill them
|
||||
try:
|
||||
username_element = self.__driver.find_element(By.NAME, "username")
|
||||
username_element.clear()
|
||||
username_element.send_keys(username)
|
||||
password_element = self.__driver.find_element(By.NAME, "password")
|
||||
password_element.clear()
|
||||
password_element.send_keys(password)
|
||||
except Exception as e:
|
||||
self._showTrace(f"用户名或密码填写失败 ! : {e}")
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def __autoRecognizeCaptcha(
|
||||
self
|
||||
) -> str:
|
||||
|
||||
# auto recognize captcha
|
||||
try:
|
||||
captcha_img = self.__driver.find_element(By.ID, "loadImgId")
|
||||
img_src = captcha_img.get_attribute("src")
|
||||
base64_str = img_src.split(',', 1)[1]
|
||||
captcha_img = base64.b64decode(base64_str)
|
||||
captcha_text = self.__ddddocr.classification(captcha_img)
|
||||
captcha_text = ''.join(filter(str.isalnum, captcha_text)).lower()
|
||||
self._showTrace(f"识别到验证码为 : '{captcha_text}'")
|
||||
if len(captcha_text) != 4:
|
||||
raise Exception("识别到的验证码长度不等于 4 个字符 !")
|
||||
return captcha_text
|
||||
except Exception as e:
|
||||
self._showTrace(f"验证码识别失败 ! : {e}")
|
||||
return ""
|
||||
|
||||
|
||||
def __manualRecognizeCaptcha(
|
||||
self
|
||||
) -> str:
|
||||
|
||||
# manual recognize captcha
|
||||
try:
|
||||
self._showMsg("请输入验证码:")
|
||||
captcha_text = self._waitMsg(timeout=15)
|
||||
self._showTrace(f"输入的验证码为 : '{captcha_text}'")
|
||||
if len(captcha_text) != 4:
|
||||
raise Exception("输入的验证码长度不等于 4 个字符 !")
|
||||
return captcha_text
|
||||
except Exception as e:
|
||||
self._showTrace(f"输入验证码失败 ! : {e}")
|
||||
return ""
|
||||
|
||||
|
||||
def __refreshCaptcha(
|
||||
self
|
||||
):
|
||||
|
||||
# refresh captcha
|
||||
try:
|
||||
self._showTrace("刷新验证码......")
|
||||
self.__driver.find_element(
|
||||
By.ID, "loadImgId"
|
||||
).click()
|
||||
return True
|
||||
except Exception as e:
|
||||
self._showTrace(f"刷新验证码失败 ! : {e}")
|
||||
return False
|
||||
|
||||
|
||||
def __solveCaptcha(
|
||||
self,
|
||||
auto_captcha: bool = True
|
||||
) -> str:
|
||||
|
||||
max_attempts = 3 # the possibility of 3 times failed is less than (10%^3)
|
||||
for _ in range(max_attempts):
|
||||
if auto_captcha:
|
||||
captcha_text = self.__autoRecognizeCaptcha()
|
||||
else:
|
||||
self._showTrace(f"用户未配置自动识别验证码, 请手动输入验证码 !")
|
||||
captcha_text = self.__manualRecognizeCaptcha()
|
||||
if captcha_text:
|
||||
return captcha_text
|
||||
else:
|
||||
if not self.__refreshCaptcha():
|
||||
return ""
|
||||
self._showTrace(f"验证码识别失败 {max_attempts} 次, 达到最大尝试次数 !")
|
||||
return ""
|
||||
|
||||
|
||||
def __fillCaptchaElement(
|
||||
self,
|
||||
captcha_text: str
|
||||
) -> bool:
|
||||
|
||||
try:
|
||||
captcha_element = self.__driver.find_element(By.NAME, "answer")
|
||||
captcha_element.clear()
|
||||
captcha_element.send_keys(captcha_text)
|
||||
return True
|
||||
except Exception as e:
|
||||
self._showTrace(f"验证码填写失败 ! : {e}")
|
||||
return False
|
||||
|
||||
|
||||
def login(
|
||||
self,
|
||||
username: str,
|
||||
password: str,
|
||||
max_attempts: int = 5,
|
||||
auto_captcha: bool = True
|
||||
) -> bool:
|
||||
|
||||
if self.__driver is None:
|
||||
self._showTrace("未提供有效 WebDriver 实例 !")
|
||||
return False
|
||||
# begin login process
|
||||
for attempt in range(max_attempts):
|
||||
self._showTrace(f"用户 {username} 第 {attempt + 1} 次尝试登录......")
|
||||
if not self.__fillLogInElements(
|
||||
username,
|
||||
password,
|
||||
):
|
||||
continue
|
||||
captcha_text = self.__solveCaptcha(auto_captcha)
|
||||
if not captcha_text:
|
||||
continue
|
||||
if not self.__fillCaptchaElement(captcha_text):
|
||||
continue
|
||||
self._showTrace("尝试登录...")
|
||||
try:
|
||||
self.__driver.find_element(
|
||||
By.XPATH,
|
||||
"//input[@type='button' and @value='登录']"
|
||||
).click()
|
||||
except Exception as e:
|
||||
self._showTrace(f"尝试登录失败 ! : {e}")
|
||||
continue
|
||||
if self._waitResponseLoad():
|
||||
self._showTrace(f"用户 {username} 第 {attempt + 1} 次登录成功 !")
|
||||
return True
|
||||
else:
|
||||
self._showTrace(f"用户 {username} 第 {attempt + 1} 次登录失败 !")
|
||||
return False
|
||||
@@ -1,55 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Copyright (c) 2025 - 2026 KenanZhu.
|
||||
All rights reserved.
|
||||
|
||||
This software is provided "as is", without any warranty of any kind.
|
||||
You may use, modify, and distribute this file under the terms of the MIT License.
|
||||
See the LICENSE file for details.
|
||||
"""
|
||||
import queue
|
||||
|
||||
from selenium.webdriver.common.by import By
|
||||
from selenium.webdriver.chrome.webdriver import WebDriver
|
||||
|
||||
from base.LibOperator import LibOperator
|
||||
|
||||
|
||||
class LibLogout(LibOperator):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
input_queue: queue.Queue,
|
||||
output_queue: queue.Queue,
|
||||
driver: WebDriver
|
||||
):
|
||||
|
||||
super().__init__(input_queue, output_queue)
|
||||
|
||||
self.__driver = driver
|
||||
|
||||
|
||||
def _waitResponseLoad(
|
||||
self
|
||||
) -> bool:
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def logout(
|
||||
self,
|
||||
username: str
|
||||
) -> bool:
|
||||
|
||||
if self.__driver is None:
|
||||
self._showTrace("未提供有效 WebDriver 实例 !")
|
||||
return False
|
||||
try:
|
||||
self.__driver.find_element(
|
||||
By.XPATH, "//a[@href='/logout']"
|
||||
).click()
|
||||
self._showTrace(f"用户 {username} 注销成功 !")
|
||||
return True
|
||||
except Exception as e:
|
||||
self._showTrace(f"用户 {username} 注销失败 ! : {e}")
|
||||
return False
|
||||
@@ -1,214 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Copyright (c) 2025 - 2026 KenanZhu.
|
||||
All rights reserved.
|
||||
|
||||
This software is provided "as is", without any warranty of any kind.
|
||||
You may use, modify, and distribute this file under the terms of the MIT License.
|
||||
See the LICENSE file for details.
|
||||
"""
|
||||
import queue
|
||||
|
||||
from selenium.webdriver.common.by import By
|
||||
from selenium.webdriver.chrome.webdriver import WebDriver
|
||||
from selenium.webdriver.support.ui import WebDriverWait
|
||||
from selenium.webdriver.support import expected_conditions as EC
|
||||
|
||||
from base.LibOperator import LibOperator
|
||||
|
||||
|
||||
class LibRenew(LibOperator):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
input_queue: queue.Queue,
|
||||
output_queue: queue.Queue,
|
||||
driver: WebDriver
|
||||
):
|
||||
|
||||
super().__init__(input_queue, output_queue)
|
||||
|
||||
self.__driver = driver
|
||||
|
||||
|
||||
def _waitResponseLoad(
|
||||
self
|
||||
) -> bool:
|
||||
|
||||
self.__driver.refresh()
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def __timeToMins(
|
||||
time_str: str
|
||||
) -> int:
|
||||
|
||||
hour, minute = map(int, time_str.split(":"))
|
||||
return hour*60 + minute
|
||||
|
||||
@staticmethod
|
||||
def __minsToTime(
|
||||
mins: int
|
||||
) -> str:
|
||||
|
||||
hour, minute = divmod(mins, 60)
|
||||
return f"{hour:02d}:{minute:02d}"
|
||||
|
||||
|
||||
def __waitRenewDialog(
|
||||
self
|
||||
) -> bool:
|
||||
|
||||
try:
|
||||
WebDriverWait(self.__driver, 2).until(
|
||||
EC.visibility_of_element_located((By.ID, "extendDiv"))
|
||||
)
|
||||
head_message = WebDriverWait(self.__driver, 2).until(
|
||||
EC.presence_of_element_located((By.CSS_SELECTOR, "#extendDiv p.messageHead"))
|
||||
)
|
||||
result_message = WebDriverWait(self.__driver, 2).until(
|
||||
EC.presence_of_element_located((By.CSS_SELECTOR, "#extendDiv div.resultMessage"))
|
||||
)
|
||||
except:
|
||||
self._showTrace("续约时间选择界面加载失败 !")
|
||||
return False
|
||||
head_message = head_message.text.strip()
|
||||
if "警告" in head_message:
|
||||
result_message = result_message.text.strip()
|
||||
self._showTrace(f"\n"\
|
||||
f" 续约失败 !\n"\
|
||||
f" {result_message}")
|
||||
return False
|
||||
try:
|
||||
WebDriverWait(self.__driver, 2).until(
|
||||
EC.presence_of_all_elements_located(
|
||||
(By.CSS_SELECTOR, "#extendDiv .renewal_List li")
|
||||
)
|
||||
)
|
||||
WebDriverWait(self.__driver, 2).until(
|
||||
EC.presence_of_element_located((By.CSS_SELECTOR, "#extendDiv .btnOK"))
|
||||
)
|
||||
except:
|
||||
self._showTrace("续约时间选择界面加载失败 !")
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def __selectNearstTime(
|
||||
self,
|
||||
record: dict,
|
||||
reserve_info: dict
|
||||
) -> bool:
|
||||
|
||||
"""
|
||||
TODO : this function is too long and too ugly
|
||||
|
||||
we need to refactor it to make it more readable.
|
||||
but may be it is not a good idea to refactor it. :) who knows...
|
||||
"""
|
||||
|
||||
end_time = record["time"]["end"]
|
||||
renew_info = reserve_info["renew_time"]
|
||||
max_diff = renew_info["max_diff"]
|
||||
prefer_earlier = renew_info["prefer_early"]
|
||||
target_renew_mins = self.__timeToMins(end_time) + renew_info["expect_duration"]*60
|
||||
renew_ok_btn = self.__driver.find_element(
|
||||
By.CSS_SELECTOR, "#extendDiv .btnOK"
|
||||
)
|
||||
try:
|
||||
renew_time_opts = self.__driver.find_elements(
|
||||
By.CSS_SELECTOR, "#extendDiv .renewal_List li"
|
||||
)
|
||||
free_times = []
|
||||
best_time_diff = max_diff
|
||||
best_actual_diff = None
|
||||
best_time_opt = None
|
||||
|
||||
if not renew_time_opts:
|
||||
self._showTrace("当前未查询到可用续约时间 !")
|
||||
return False
|
||||
for time_opt in renew_time_opts:
|
||||
time_attr = time_opt.get_attribute("id")
|
||||
if time_attr and time_attr.isdigit():
|
||||
time_val = int(time_attr)
|
||||
free_times.append(time_opt.text.strip())
|
||||
else:
|
||||
continue
|
||||
actual_diff = time_val - target_renew_mins
|
||||
abs_diff = abs(actual_diff)
|
||||
if abs_diff < best_time_diff or (
|
||||
abs_diff == best_time_diff and (
|
||||
# 优先选择更早的时间
|
||||
(prefer_earlier and actual_diff <= 0) or
|
||||
# 优先选择更晚的时间
|
||||
(not prefer_earlier and actual_diff >= 0)
|
||||
)
|
||||
):
|
||||
best_time_diff = abs_diff
|
||||
best_actual_diff = actual_diff
|
||||
best_time_opt = time_opt
|
||||
|
||||
if best_time_opt is not None:
|
||||
best_time_opt.click()
|
||||
abs_time_diff = abs(best_actual_diff)
|
||||
if best_actual_diff < 0:
|
||||
time_relation = f"早了 {abs_time_diff} 分钟"
|
||||
elif best_actual_diff > 0:
|
||||
time_relation = f"晚了 {abs_time_diff} 分钟"
|
||||
else:
|
||||
time_relation = f"正好等于续约时间"
|
||||
self._showTrace(
|
||||
f"选择距离期望续约时间最近的 {best_time_opt.text}, "\
|
||||
f"与期望续约时间相比 {time_relation}"
|
||||
)
|
||||
# update the actual renew end time
|
||||
record["time"]["end"] = best_time_opt.text.strip()
|
||||
renew_ok_btn.click()
|
||||
return True
|
||||
self._showTrace(
|
||||
"无法选择最近的可用续约时间 !" \
|
||||
f"所有可选时间与目标时间相差都超过了 {max_diff} 分钟 !"
|
||||
)
|
||||
self._showTrace(
|
||||
f"当前可供续约的时间有: {free_times}"
|
||||
)
|
||||
return False
|
||||
except:
|
||||
self._showTrace("查询可用续约时间时发生未知错误 !")
|
||||
return False
|
||||
|
||||
|
||||
def renew(
|
||||
self,
|
||||
username: str,
|
||||
record: dict,
|
||||
reserve_info: dict
|
||||
) -> bool:
|
||||
|
||||
if self.__driver is None:
|
||||
self._showTrace("未提供有效 WebDriver 实例 !")
|
||||
return False
|
||||
try:
|
||||
renew_btn = WebDriverWait(self.__driver, 2).until(
|
||||
EC.element_to_be_clickable((By.ID, "btnExtend"))
|
||||
)
|
||||
except:
|
||||
self._showTrace(f"用户 {username} 续约界面加载失败 !")
|
||||
return False
|
||||
if "disabled" in renew_btn.get_attribute("class"):
|
||||
self._showTrace(f"用户 {username} 续约按钮不可用, 可能不在场馆内, 请连接图书馆网络后重试")
|
||||
return False
|
||||
renew_btn.click()
|
||||
if not self.__waitRenewDialog():
|
||||
self._showTrace(f"用户 {username} 续约失败 !")
|
||||
|
||||
# After the renewal, the webpage will display a mask overlay,
|
||||
# so we need to refresh the page for subsequent operations.
|
||||
self.__driver.refresh()
|
||||
return False
|
||||
if not self.__selectNearstTime(record, reserve_info):
|
||||
self._showTrace(f"用户 {username} 续约失败 !")
|
||||
self.__driver.refresh()
|
||||
return False
|
||||
if self._waitResponseLoad():
|
||||
return True
|
||||
@@ -1,692 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Copyright (c) 2025 - 2026 KenanZhu.
|
||||
All rights reserved.
|
||||
|
||||
This software is provided "as is", without any warranty of any kind.
|
||||
You may use, modify, and distribute this file under the terms of the MIT License.
|
||||
See the LICENSE file for details.
|
||||
"""
|
||||
import time
|
||||
import queue
|
||||
|
||||
from datetime import datetime
|
||||
from selenium.webdriver.common.by import By
|
||||
from selenium.webdriver.chrome.webdriver import WebDriver
|
||||
from selenium.webdriver.support.ui import WebDriverWait
|
||||
from selenium.webdriver.support import expected_conditions as EC
|
||||
|
||||
from base.LibOperator import LibOperator
|
||||
|
||||
|
||||
class LibReserve(LibOperator):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
input_queue: queue.Queue,
|
||||
output_queue: queue.Queue,
|
||||
driver: WebDriver
|
||||
):
|
||||
|
||||
super().__init__(input_queue, output_queue)
|
||||
|
||||
self.__driver = driver
|
||||
# library floor and room mapping in website
|
||||
self.__floor_map = {
|
||||
"2": "二层",
|
||||
"3": "三层",
|
||||
"4": "四层",
|
||||
"5": "五层"
|
||||
}
|
||||
self.__room_map = {
|
||||
"1": "二层内环",
|
||||
"2": "二层西区",
|
||||
"3": "三层内环",
|
||||
"4": "三层外环",
|
||||
"5": "四层内环",
|
||||
"6": "四层外环",
|
||||
"7": "四层期刊",
|
||||
"8": "五层考研"
|
||||
}
|
||||
|
||||
|
||||
def _waitResponseLoad(
|
||||
self,
|
||||
) -> bool:
|
||||
|
||||
try:
|
||||
WebDriverWait(self.__driver, 2).until(
|
||||
EC.presence_of_element_located((By.CLASS_NAME, "layoutSeat"))
|
||||
)
|
||||
title_elements = []
|
||||
# reserve failed without title elements, so we need to try
|
||||
try:
|
||||
WebDriverWait(self.__driver, 2).until(
|
||||
EC.presence_of_element_located((By.CSS_SELECTOR, ".layoutSeat dt"))
|
||||
)
|
||||
title_elements = self.__driver.find_elements(
|
||||
By.CSS_SELECTOR, ".layoutSeat dt"
|
||||
)
|
||||
except:
|
||||
pass
|
||||
content_elements = self.__driver.find_elements(
|
||||
By.CSS_SELECTOR, ".layoutSeat dd"
|
||||
)
|
||||
if not content_elements:
|
||||
self._showTrace("未找到预约结果")
|
||||
raise
|
||||
title = title_elements[0].text if title_elements else ""
|
||||
contents = [element.text for element in content_elements if element.text.strip()]
|
||||
for message in contents:
|
||||
if "预约失败" in message or "已有1个有效预约" in message:
|
||||
self._showTrace(f"预约失败 - {"".join(contents)}")
|
||||
raise
|
||||
if "预定好了" in title or "预约成功" in title or "操作成功" in title:
|
||||
if len(contents) >= 6:
|
||||
self._showTrace(f"\n"\
|
||||
f" 预约成功 !\n"\
|
||||
f" {contents[1]}\n"\
|
||||
f" {contents[2]}\n"\
|
||||
f" {contents[3]}\n"\
|
||||
f" 签到时间 :{contents[5]}"
|
||||
)
|
||||
else:
|
||||
self._showTrace("\n"\
|
||||
" 预约成功 !\n"\
|
||||
" 未找获取到详细信息"
|
||||
)
|
||||
return True
|
||||
except:
|
||||
self._showTrace(f"预约结果加载失败 !")
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def __timeToMins(
|
||||
time_str: str
|
||||
) -> int:
|
||||
|
||||
hour, minute = map(int, time_str.split(":"))
|
||||
return hour*60 + minute
|
||||
|
||||
@staticmethod
|
||||
def __minsToTime(
|
||||
mins: int
|
||||
) -> str:
|
||||
|
||||
hour, minute = divmod(mins, 60)
|
||||
return f"{hour:02d}:{minute:02d}"
|
||||
|
||||
|
||||
def __containRequiredInfo(
|
||||
self,
|
||||
reserve_info: dict
|
||||
) -> bool:
|
||||
|
||||
try:
|
||||
# must contain the required infomation
|
||||
if reserve_info.get("floor") is None: # if existence ?
|
||||
raise ValueError("未指定楼层")
|
||||
if reserve_info["floor"] not in self.__floor_map: # if in the mao ?
|
||||
raise ValueError(f"该楼层 '{reserve_info['floor']}' 不存在")
|
||||
if reserve_info.get("room") is None:
|
||||
raise ValueError("未指定房间")
|
||||
if reserve_info["room"] not in self.__room_map:
|
||||
raise ValueError(f"该房间 '{reserve_info['room']}' 不存在")
|
||||
if reserve_info.get("seat_id") is None:
|
||||
raise ValueError("未指定座位")
|
||||
if reserve_info["seat_id"] == "":
|
||||
raise ValueError("未指定座位号")
|
||||
return True
|
||||
except ValueError as e:
|
||||
self._showTrace(
|
||||
f"预约信息错误 ! : {e}, "\
|
||||
f"由于缺少必要的预约信息, 无法开始预约流程, 请检查预约信息是否完整"
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
def __isValidDate(
|
||||
self,
|
||||
reserve_info: dict
|
||||
) -> bool:
|
||||
|
||||
cur_date = time.strftime("%Y-%m-%d", time.localtime())
|
||||
if reserve_info.get("date") is None:
|
||||
reserve_info["date"] = cur_date
|
||||
self._showTrace(f"预约日期未指定, 自动设置为当前日期: {cur_date}")
|
||||
else:
|
||||
if reserve_info["date"] < cur_date:
|
||||
self._showTrace(
|
||||
f"预约日期错误 ! :"\
|
||||
f"{reserve_info['date']} 早于当前日期 {cur_date}, 自动设置为当前日期"
|
||||
)
|
||||
reserve_info["date"] = cur_date
|
||||
return True
|
||||
|
||||
|
||||
def __isValidBeginTime(
|
||||
self,
|
||||
reserve_info: dict
|
||||
) -> bool:
|
||||
|
||||
cur_time = time.strftime("%H:%M", time.localtime())
|
||||
if reserve_info.get("begin_time") is None:
|
||||
reserve_info["begin_time"] = {}
|
||||
if "time" not in reserve_info["begin_time"]:
|
||||
reserve_info["begin_time"]["time"] = cur_time
|
||||
self._showTrace(f"开始时间未指定, 自动设置为当前时间: {cur_time}")
|
||||
if "max_diff" not in reserve_info["begin_time"]:
|
||||
reserve_info["begin_time"]["max_diff"] = 30
|
||||
self._showTrace(f"开始时间最大时间差未指定, 自动设置为 30 分钟")
|
||||
if "prefer_early" not in reserve_info["begin_time"]:
|
||||
reserve_info["begin_time"]["prefer_early"] = True
|
||||
self._showTrace(f"是否优先选择更早开始时间未指定, 自动设置为 True")
|
||||
return True
|
||||
|
||||
|
||||
def __isValidExpectDuration(
|
||||
self,
|
||||
reserve_info: dict
|
||||
) -> bool:
|
||||
|
||||
if reserve_info.get("satisfy_duration") is None:
|
||||
reserve_info["satisfy_duration"] = True
|
||||
self._showTrace("预约满足时长要求未指定, 默认满足")
|
||||
if reserve_info["satisfy_duration"]:
|
||||
if reserve_info.get("expect_duration") is None:
|
||||
reserve_info["expect_duration"] = 4
|
||||
self._showTrace("需要满足预约持续时间, 但未指定, 使用默认时长为 4 小时")
|
||||
return True
|
||||
|
||||
|
||||
def __isValidEndTime(
|
||||
self,
|
||||
reserve_info: dict
|
||||
) -> bool:
|
||||
|
||||
if reserve_info.get("end_time") is None:
|
||||
reserve_info["end_time"] = {}
|
||||
if "time" not in reserve_info["end_time"]:
|
||||
end_mins = self.__timeToMins(reserve_info["begin_time"]["time"])
|
||||
end_mins = end_mins + int(reserve_info["expect_duration"]*60)
|
||||
reserve_info["end_time"] = {
|
||||
"time": self.__minsToTime(end_mins),
|
||||
"max_diff": 30,
|
||||
"prefer_early": False
|
||||
}
|
||||
self._showTrace(
|
||||
f"结束时间未指定, 自动设置为开始时间加上期望时长: {reserve_info['end_time']['time']}"
|
||||
)
|
||||
if "max_diff" not in reserve_info["end_time"]:
|
||||
reserve_info["end_time"]["max_diff"] = 30
|
||||
self._showTrace(f"结束时间最大时间差未指定, 自动设置为 30 分钟")
|
||||
if "prefer_early" not in reserve_info["end_time"]:
|
||||
reserve_info["end_time"]["prefer_early"] = False
|
||||
self._showTrace(f"是否优先选择较晚结束时间未指定, 自动设置为 True")
|
||||
return True
|
||||
|
||||
|
||||
def __finalCheck(
|
||||
self,
|
||||
reserve_info: dict
|
||||
):
|
||||
|
||||
begin_time, end_time = reserve_info["begin_time"], reserve_info["end_time"]
|
||||
begin_mins = self.__timeToMins(begin_time["time"])
|
||||
end_mins = self.__timeToMins(end_time["time"])
|
||||
# if end time is earlier than begin_time, exchange them
|
||||
if end_mins < begin_mins:
|
||||
self._showTrace(
|
||||
f"结束时间 {end_time['time']} 早于开始时间 {begin_time['time']}, 尝试交换时间"
|
||||
)
|
||||
reserve_info["end_time"] = begin_time
|
||||
reserve_info["begin_time"] = end_time
|
||||
begin_time, end_time = reserve_info["begin_time"], reserve_info["end_time"]
|
||||
begin_mins = self.__timeToMins(begin_time["time"])
|
||||
end_mins = self.__timeToMins(end_time["time"])
|
||||
# ensure the end time is not later than 23:30
|
||||
if end_mins > self.__timeToMins("23:30"):
|
||||
self._showTrace(
|
||||
f"结束时间 {end_time['time']} 晚于 23:30, 自动设置为 23:30"
|
||||
)
|
||||
reserve_info["end_time"]["time"] = "23:30"
|
||||
end_mins = self.__timeToMins("23:30")
|
||||
# ensure the duration is not longer than 8 hours
|
||||
if reserve_info["satisfy_duration"]:
|
||||
if reserve_info["expect_duration"] > 8:
|
||||
self._showTrace(
|
||||
f"该用户设置了优先满足时长要求, 但是预约期望持续时间 "
|
||||
f"{reserve_info['expect_duration']} 小时 "
|
||||
f"超出最大时长 8 小时, 自动设置为 8 小时"
|
||||
)
|
||||
reserve_info["expect_duration"] = 8
|
||||
else:
|
||||
if end_mins - begin_mins > 8*60:
|
||||
self._showTrace(
|
||||
f"该用户未设置优先满足时长要求, 但是检查到预约持续时间 "
|
||||
f"{float((end_mins - begin_mins)/60)} 小时 "
|
||||
f"超出最大时长 8 小时, 自动设置为 8 小时"
|
||||
)
|
||||
reserve_info["end_time"]["time"] = self.__minsToTime(begin_mins + 8*60)
|
||||
return True
|
||||
|
||||
|
||||
def __checkReserveInfo(
|
||||
self,
|
||||
reserve_info: dict
|
||||
) -> bool:
|
||||
|
||||
if not self.__containRequiredInfo(reserve_info):
|
||||
return False
|
||||
if not self.__isValidDate(reserve_info):
|
||||
return False
|
||||
if not self.__isValidBeginTime(reserve_info):
|
||||
return False
|
||||
if not self.__isValidExpectDuration(reserve_info):
|
||||
return False
|
||||
if not self.__isValidEndTime(reserve_info):
|
||||
return False
|
||||
if not self.__finalCheck(reserve_info):
|
||||
return False
|
||||
self._showTrace(
|
||||
f"预约信息检查完成, 准备预约 "
|
||||
f"{reserve_info['date']} "
|
||||
f"{reserve_info['begin_time']["time"]} - "
|
||||
f"{reserve_info['end_time']["time"]} "
|
||||
f"图书馆 "
|
||||
f"{self.__floor_map[reserve_info['floor']]} "
|
||||
f"{self.__room_map[reserve_info['room']]} "
|
||||
f"的座位 {reserve_info['seat_id']}"
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def __clickElement(
|
||||
self,
|
||||
trigger_locator: tuple,
|
||||
fail_msg: str,
|
||||
success_msg: str,
|
||||
option_locator: tuple = None
|
||||
) -> bool:
|
||||
|
||||
try:
|
||||
# click the trigger element
|
||||
WebDriverWait(self.__driver, 2).until(
|
||||
EC.element_to_be_clickable(trigger_locator)
|
||||
).click()
|
||||
if option_locator:
|
||||
# select the option element if specified
|
||||
WebDriverWait(self.__driver, 2).until(
|
||||
EC.element_to_be_clickable(option_locator)
|
||||
).click()
|
||||
self._showTrace(success_msg)
|
||||
return True
|
||||
except:
|
||||
self._showTrace(fail_msg)
|
||||
return False
|
||||
|
||||
|
||||
def __clickElementByJS(
|
||||
self,
|
||||
trigger_locator_id: str,
|
||||
option_query_selector: str,
|
||||
fail_msg: str,
|
||||
success_msg: str,
|
||||
) -> bool:
|
||||
|
||||
script = f"""
|
||||
try {{
|
||||
var trigger = document.getElementById('{trigger_locator_id}');
|
||||
if (trigger) {{
|
||||
trigger.click();
|
||||
var option = document.querySelector("{option_query_selector}");
|
||||
if (option) {{
|
||||
option.click();
|
||||
return true;
|
||||
}}
|
||||
return false;
|
||||
}}
|
||||
return false;
|
||||
}} catch (e) {{
|
||||
return false;
|
||||
}}
|
||||
"""
|
||||
result = self.__driver.execute_script(script)
|
||||
time.sleep(0.1)
|
||||
if result:
|
||||
self._showTrace(success_msg)
|
||||
else:
|
||||
self._showTrace(fail_msg)
|
||||
return result
|
||||
|
||||
|
||||
def __selectDate(
|
||||
self,
|
||||
date_str: str
|
||||
) -> bool:
|
||||
|
||||
if self.__clickElementByJS(
|
||||
trigger_locator_id="onDate_select",
|
||||
option_query_selector=f"p#options_onDate a[value='{date_str}']",
|
||||
success_msg=f"日期 {date_str} 选择成功 !",
|
||||
fail_msg=f"选择日期失败 ! : {date_str} 不可用"
|
||||
):
|
||||
return True
|
||||
return self.__clickElement(
|
||||
trigger_locator=(By.ID, "onDate_select"),
|
||||
option_locator=(By.XPATH, f"//p[@id='options_onDate']/a[@value='{date_str}']"),
|
||||
success_msg=f"日期 {date_str} 选择成功 !",
|
||||
fail_msg=f"选择日期失败 ! : {date_str} 不可用"
|
||||
)
|
||||
|
||||
|
||||
def __selectPlace(
|
||||
self,
|
||||
place: str
|
||||
) -> bool:
|
||||
|
||||
place = "1" # the library only have this place :)
|
||||
display_place = "图书馆"
|
||||
if self.__clickElementByJS(
|
||||
trigger_locator_id="display_building",
|
||||
option_query_selector=f"p#options_building a[value='{place}']",
|
||||
success_msg=f"预约场所 {display_place} 选择成功 !",
|
||||
fail_msg=f"选择预约场所失败 ! : {display_place} 不可用"
|
||||
):
|
||||
return True
|
||||
return self.__clickElement(
|
||||
trigger_locator=(By.ID, "display_building"),
|
||||
option_locator=(By.XPATH, f"//p[@id='options_building']/a[@value='{place}']"),
|
||||
success_msg=f"预约场所 {display_place} 选择成功 !",
|
||||
fail_msg=f"选择预约场所失败 ! : {display_place} 不可用"
|
||||
)
|
||||
|
||||
|
||||
def __selectFloor(
|
||||
self,
|
||||
floor: str
|
||||
) -> bool:
|
||||
|
||||
display_floor = self.__floor_map.get(floor)
|
||||
if self.__clickElementByJS(
|
||||
trigger_locator_id="floor_select",
|
||||
option_query_selector=f"p#options_floor a[value='{floor}']",
|
||||
success_msg=f"楼层 {display_floor} 选择成功 !",
|
||||
fail_msg=f"选择楼层失败 ! : {display_floor} 不可用"
|
||||
):
|
||||
return True
|
||||
return self.__clickElement(
|
||||
trigger_locator=(By.ID, "floor_select"),
|
||||
option_locator=(By.XPATH, f"//p[@id='options_floor']/a[@value='{floor}']"),
|
||||
success_msg=f"楼层 {display_floor} 选择成功 !",
|
||||
fail_msg=f"选择楼层失败 ! : {display_floor} 不可用"
|
||||
)
|
||||
|
||||
|
||||
def __selectRoom(
|
||||
self,
|
||||
room: str
|
||||
) -> bool:
|
||||
|
||||
display_room = self.__room_map.get(room)
|
||||
# find room
|
||||
try:
|
||||
WebDriverWait(self.__driver, 2).until(
|
||||
EC.element_to_be_clickable((By.ID, "findRoom"))
|
||||
).click()
|
||||
except:
|
||||
self._showTrace("加载房间/区域失败 !")
|
||||
return False
|
||||
# select room
|
||||
try:
|
||||
WebDriverWait(self.__driver, 2).until(
|
||||
EC.element_to_be_clickable((By.ID, f"room_{room}"))
|
||||
).click()
|
||||
self._showTrace(f"房间 {display_room} 选择成功 !")
|
||||
return True
|
||||
except:
|
||||
self._showTrace(f"选择房间失败 ! : {display_room} 不可用")
|
||||
return False
|
||||
|
||||
|
||||
def __selectSeat(
|
||||
self,
|
||||
seat_id: str
|
||||
) -> bool:
|
||||
|
||||
try:
|
||||
# wait fot seat layout element to load
|
||||
WebDriverWait(self.__driver, 2).until(
|
||||
EC.presence_of_element_located((By.ID, "seatLayout"))
|
||||
)
|
||||
WebDriverWait(self.__driver, 2).until(
|
||||
EC.presence_of_all_elements_located((By.CSS_SELECTOR, "li[id^='seat_']"))
|
||||
)
|
||||
except:
|
||||
self._showTrace(f"座位加载失败 !")
|
||||
return False
|
||||
try:
|
||||
all_seats = self.__driver.find_elements(
|
||||
By.CSS_SELECTOR, "li[id^='seat_']"
|
||||
)
|
||||
seat_id_upper = seat_id.lstrip('0').upper()
|
||||
for seat in all_seats:
|
||||
if not seat_id_upper == seat.text.lstrip('0'):
|
||||
continue
|
||||
seat_link = seat.find_element(By.TAG_NAME, "a")
|
||||
WebDriverWait(self.__driver, 2).until(
|
||||
EC.element_to_be_clickable(seat_link)
|
||||
)
|
||||
seat_link.click()
|
||||
seat_status = seat_link.get_attribute("title")
|
||||
self._showTrace(f"座位 {seat_id} 选择成功 ! : 当前状态 - '{seat_status}'")
|
||||
return True
|
||||
self._showTrace(f"座位 {seat_id} 在该楼层区域中不存在, 请检查座位号是否正确")
|
||||
except:
|
||||
self._showTrace(f"座位选择失败 !")
|
||||
return False
|
||||
|
||||
|
||||
def __selectNearestTime(
|
||||
self,
|
||||
time_id: str,
|
||||
time_type: str,
|
||||
target_time: int,
|
||||
max_time_diff: int = 30,
|
||||
prefer_earlier: bool = True
|
||||
) -> int:
|
||||
|
||||
try:
|
||||
WebDriverWait(self.__driver, 2).until(
|
||||
EC.presence_of_all_elements_located(
|
||||
(By.CSS_SELECTOR, f"#{time_id} ul li a")
|
||||
)
|
||||
)
|
||||
except:
|
||||
self._showTrace(f"{time_type} 选择失败 ! : 当前未查询到可用时间")
|
||||
return -1
|
||||
try:
|
||||
all_time_opts = self.__driver.find_elements(
|
||||
By.CSS_SELECTOR,
|
||||
f"#{time_id} ul li a"
|
||||
)
|
||||
free_times = []
|
||||
best_time_diff = max_time_diff
|
||||
best_actual_diff = None
|
||||
best_time_opt = None
|
||||
|
||||
if not all_time_opts:
|
||||
self._showTrace(f"{time_type} 选择失败 ! : 当前未查询到可用时间")
|
||||
return -1
|
||||
for time_opt in all_time_opts:
|
||||
time_attr = time_opt.get_attribute("time")
|
||||
if time_attr == "now":
|
||||
now = datetime.now()
|
||||
time_val = int(now.hour*60 + now.minute)
|
||||
elif time_attr and time_attr.isdigit():
|
||||
time_val = int(time_attr)
|
||||
else:
|
||||
continue
|
||||
free_times.append(self.__minsToTime(time_val))
|
||||
actual_diff = time_val - target_time
|
||||
abs_diff = abs(actual_diff)
|
||||
if abs_diff < best_time_diff or (
|
||||
abs_diff == best_time_diff and (
|
||||
# prefer earlier time
|
||||
(prefer_earlier and actual_diff <= 0) or
|
||||
# prefer later time
|
||||
(not prefer_earlier and actual_diff >= 0)
|
||||
)
|
||||
):
|
||||
best_time_diff = abs_diff
|
||||
best_actual_diff = actual_diff
|
||||
best_time_opt = time_opt
|
||||
|
||||
if best_time_opt is not None:
|
||||
best_time_opt.click()
|
||||
abs_time_diff = abs(best_actual_diff)
|
||||
if best_actual_diff < 0:
|
||||
time_relation = f"早了 {abs_time_diff} 分钟"
|
||||
elif best_actual_diff > 0:
|
||||
time_relation = f"晚了 {abs_time_diff} 分钟"
|
||||
else:
|
||||
time_relation = f"正好等于 {time_type}"
|
||||
target_time += best_actual_diff
|
||||
self._showTrace(
|
||||
f"选择距离期望 {time_type} 最近的 {best_time_opt.text}, "\
|
||||
f"与期望 {time_type} 相比 {time_relation}"
|
||||
)
|
||||
return target_time
|
||||
self._showTrace(
|
||||
f"无法选择最近的 {time_type} {self.__minsToTime(target_time)}, "\
|
||||
f"所有可选时间与目标时间相差都超过 {max_time_diff} 分钟"
|
||||
)
|
||||
self._showTrace(f"当前可供预约的 {time_type} 有: {free_times}")
|
||||
return -1
|
||||
except:
|
||||
self._showTrace(f"{time_type} {self.__minsToTime(target_time)} 选择失败 !")
|
||||
return -1
|
||||
|
||||
|
||||
def __selectSeatTime(
|
||||
self,
|
||||
begin_time: dict,
|
||||
end_time: dict,
|
||||
expct_duration: int = 4,
|
||||
satisfy_duration: bool = True
|
||||
) -> bool:
|
||||
|
||||
expect_begin_time = actual_begin_time = begin_time["time"]
|
||||
expect_end_time = actual_end_time = end_time["time"]
|
||||
expect_begin_mins = self.__timeToMins(expect_begin_time)
|
||||
actual_begin_mins = expect_begin_mins
|
||||
expect_end_mins = self.__timeToMins(expect_end_time)
|
||||
|
||||
# select the begin time
|
||||
if self.__selectNearestTime(
|
||||
time_id="startTime", # dont change into begin, this is the element in the page
|
||||
time_type="开始时间",
|
||||
target_time=expect_begin_mins,
|
||||
max_time_diff=begin_time["max_diff"],
|
||||
prefer_earlier=begin_time["prefer_early"]
|
||||
) == -1:
|
||||
return False
|
||||
else:
|
||||
actual_begin_time = self.__minsToTime(expect_begin_mins)
|
||||
actual_begin_mins = self.__timeToMins(actual_begin_time)
|
||||
# if 'satisfy_duration' is True.
|
||||
# select the end time based on the begin time
|
||||
# (because it may be changed under the 'max time diff' strategy) and expect duration.
|
||||
if satisfy_duration:
|
||||
expect_end_mins = int(actual_begin_mins + expct_duration*60)
|
||||
if expect_end_mins > self.__timeToMins("23:30"):
|
||||
expect_end_mins = self.__timeToMins("23:30")
|
||||
self._showTrace(
|
||||
f"预约持续时间 {expct_duration} 小时, 超过最大预约时间 23:30, 自动调整为 23:30"
|
||||
)
|
||||
expect_end_time = self.__minsToTime(expect_end_mins)
|
||||
self._showTrace(
|
||||
f"需要满足期望预约持续时间: {expct_duration} 小时, "\
|
||||
f"根据开始时间 {actual_begin_time} 计算结束时间: {self.__minsToTime(expect_end_mins)}"
|
||||
)
|
||||
# select the end time
|
||||
if self.__selectNearestTime(
|
||||
time_id="endTime",
|
||||
time_type="结束时间",
|
||||
target_time=expect_end_mins,
|
||||
max_time_diff=end_time["max_diff"],
|
||||
prefer_earlier=end_time["prefer_early"]
|
||||
) == -1:
|
||||
return False
|
||||
else:
|
||||
actual_end_time = self.__minsToTime(expect_end_mins)
|
||||
self._showTrace(
|
||||
f"期望预约时间段: {expect_begin_time} - {expect_end_time}, "
|
||||
f"实际预约时间段: {actual_begin_time} - {actual_end_time}"
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def reserve(
|
||||
self,
|
||||
username: str,
|
||||
reserve_info: dict
|
||||
) -> bool:
|
||||
|
||||
submit_reserve = False
|
||||
reserve_success = False
|
||||
have_hover_on_page = False
|
||||
|
||||
# reserve info
|
||||
if not self.__checkReserveInfo(reserve_info):
|
||||
return False
|
||||
# map page
|
||||
try:
|
||||
WebDriverWait(self.__driver, 2).until(
|
||||
EC.element_to_be_clickable((By.XPATH, "//a[@href='/map']"))
|
||||
).click()
|
||||
WebDriverWait(self.__driver, 2).until(
|
||||
EC.presence_of_element_located((By.ID, "seatLayout"))
|
||||
)
|
||||
except:
|
||||
self._showTrace(f"加载预约选座页面失败 !")
|
||||
return False
|
||||
# date, place, floor, room
|
||||
if not self.__selectDate(reserve_info["date"]):
|
||||
return False
|
||||
if not self.__selectPlace(reserve_info["place"]):
|
||||
return False
|
||||
if not self.__selectFloor(reserve_info["floor"]):
|
||||
return False
|
||||
if not self.__selectRoom(reserve_info["room"]):
|
||||
return False
|
||||
else:
|
||||
have_hover_on_page = True
|
||||
# seat selections
|
||||
if not self.__selectSeat(reserve_info["seat_id"]):
|
||||
pass
|
||||
elif not self.__selectSeatTime(
|
||||
begin_time=reserve_info["begin_time"],
|
||||
end_time=reserve_info["end_time"],
|
||||
expct_duration=reserve_info["expect_duration"],
|
||||
satisfy_duration=reserve_info["satisfy_duration"]
|
||||
):
|
||||
pass
|
||||
else:
|
||||
try:
|
||||
WebDriverWait(self.__driver, 2).until(
|
||||
EC.element_to_be_clickable((By.ID, "reserveBtn"))
|
||||
).click()
|
||||
submit_reserve = True
|
||||
if not self._waitResponseLoad():
|
||||
raise
|
||||
reserve_success = True
|
||||
except:
|
||||
self._showTrace(f"预约提交失败 !")
|
||||
if not submit_reserve and have_hover_on_page:
|
||||
self.__driver.refresh()
|
||||
if reserve_success:
|
||||
self._showTrace(f"用户 {username} 预约成功 !")
|
||||
else:
|
||||
self._showTrace(f"用户 {username} 预约失败 !")
|
||||
return reserve_success
|
||||
@@ -1,13 +0,0 @@
|
||||
"""
|
||||
Operators module for the AutoLibrary project.
|
||||
|
||||
Here are the classes and modules in this package:
|
||||
- AutoLib: AutoLibrary operator.
|
||||
- LibLogin: Library operator for logging in.
|
||||
- LibLogout: Library operator for logging out.
|
||||
- LibReserve: Library operator for reserving seat.
|
||||
- LibCheckin: Library operator for checking in seat.
|
||||
- LibCheckout: Library operator for checking out seat.
|
||||
- LibChecker: Library operator for checking record status.
|
||||
- LibRenew: Library operator for renewing seat.
|
||||
"""
|
||||
@@ -0,0 +1,396 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Copyright (c) 2025 KenanZhu.
|
||||
All rights reserved.
|
||||
|
||||
This software is provided "as is", without any warranty of any kind.
|
||||
You may use, modify, and distribute this file under the terms of the MIT License.
|
||||
See the LICENSE file for details.
|
||||
"""
|
||||
import os
|
||||
import queue
|
||||
from selenium import webdriver
|
||||
from selenium.webdriver.remote.webdriver import WebDriver
|
||||
from selenium.common.exceptions import (
|
||||
TimeoutException,
|
||||
WebDriverException,
|
||||
)
|
||||
from selenium.webdriver.edge.service import Service as EdgeService
|
||||
from selenium.webdriver.chrome.service import Service as ChromeService
|
||||
from selenium.webdriver.firefox.service import Service as FirefoxService
|
||||
|
||||
from base.MsgBase import MsgBase
|
||||
from pages.LoginPage import LoginPage
|
||||
from pages.MainShell import MainShell
|
||||
from pages.flows.ReserveFlow import ReserveFlow, ReserveContext
|
||||
from pages.flows.CheckinFlow import CheckinFlow
|
||||
from pages.flows.RenewFlow import RenewFlow
|
||||
from pages.services.CaptchaSolver import CaptchaSolver
|
||||
from pages.services.ReserveChecker import ReserveChecker
|
||||
from pages.services.RecordChecker import RecordChecker
|
||||
|
||||
|
||||
class AutoLib(MsgBase):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
input_queue: queue.Queue,
|
||||
output_queue: queue.Queue,
|
||||
run_config: dict,
|
||||
) -> None:
|
||||
|
||||
super().__init__(input_queue, output_queue)
|
||||
self.__run_config: dict = run_config
|
||||
self.__user_config: dict | None = None
|
||||
self.__driver: WebDriver | None = None
|
||||
self.__driver_type: str = ""
|
||||
self.__driver_path: str = ""
|
||||
self.__login_page: LoginPage = None
|
||||
self.__shell: MainShell = None
|
||||
self.__captcha_solver: CaptchaSolver = None
|
||||
self.__record_checker: RecordChecker = None
|
||||
self.__reserve_checker: ReserveChecker = None
|
||||
self.__reserve_flow: ReserveFlow = None
|
||||
self.__checkin_flow: CheckinFlow = None
|
||||
self.__renew_flow: RenewFlow = None
|
||||
|
||||
if not self.__initBrowserDriver():
|
||||
raise Exception("浏览器驱动初始化失败 !")
|
||||
else:
|
||||
if not self.__initDriverUrl():
|
||||
self.close()
|
||||
raise Exception("浏览器驱动 URL 初始化失败 !")
|
||||
self.__initPagesServices()
|
||||
self.__initPagesFlows()
|
||||
|
||||
def __initBrowserDriver(
|
||||
self,
|
||||
) -> bool:
|
||||
|
||||
self._showTrace("正在初始化浏览器驱动......", no_log=True)
|
||||
driver_config: dict = self.__run_config.get("web_driver", None)
|
||||
self.__driver_type = driver_config.get("driver_type", "none")
|
||||
self.__driver_type = self.__driver_type.lower()
|
||||
match self.__driver_type:
|
||||
case "edge":
|
||||
driver_options = webdriver.EdgeOptions()
|
||||
case "chrome":
|
||||
driver_options = webdriver.ChromeOptions()
|
||||
case "firefox":
|
||||
driver_options = webdriver.FirefoxOptions()
|
||||
case _:
|
||||
self._showTrace(
|
||||
f"不支持的浏览器驱动类型: {self.__driver_type} !",
|
||||
self.TraceLevel.WARNING,
|
||||
)
|
||||
return False
|
||||
if not driver_config:
|
||||
self._showTrace("未配置浏览器驱动参数 !", self.TraceLevel.ERROR)
|
||||
return False
|
||||
if driver_config.get("headless", False):
|
||||
driver_options.add_argument("--headless")
|
||||
driver_options.add_argument("--disable-gpu")
|
||||
driver_options.add_argument("--no-sandbox")
|
||||
driver_options.add_argument("--disable-dev-shm-usage")
|
||||
|
||||
# must be 1920x1080, otherwise the page will cause some elements not accessible
|
||||
driver_options.add_argument("--window-size=1920,1080")
|
||||
|
||||
# omit ssl errors and verbose log level
|
||||
driver_options.add_argument("--ignore-certificate-errors")
|
||||
driver_options.add_argument("--ignore-ssl-errors")
|
||||
driver_options.add_argument("--log-level=OFF")
|
||||
driver_options.add_argument("--silent")
|
||||
|
||||
# set options for chrome and edge
|
||||
if self.__driver_type.lower() in ["edge", "chrome"]:
|
||||
driver_options.add_argument("--remote-allow-origins=*")
|
||||
driver_options.add_experimental_option("excludeSwitches", ["enable-automation"])
|
||||
driver_options.add_experimental_option("useAutomationExtension", False)
|
||||
driver_options.add_argument("--disable-blink-features=AutomationControlled")
|
||||
user_agent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "\
|
||||
"AppleWebKit/537.36 (KHTML, like Gecko) "\
|
||||
"Chrome/120.0.0.0 "\
|
||||
"Safari/537.36"
|
||||
if self.__driver_type == "edge":
|
||||
user_agent += " Edg/120.0.0.0"
|
||||
|
||||
# set options for firefox
|
||||
elif self.__driver_type == "firefox":
|
||||
driver_options.set_preference("dom.webdriver.enabled", False)
|
||||
driver_options.set_preference("useAutomationExtension", False)
|
||||
user_agent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:120.0) "\
|
||||
"Gecko/20100101 Firefox/120.0"
|
||||
driver_options.add_argument(f"user-agent={user_agent}")
|
||||
|
||||
# init browser driver
|
||||
self.__driver_path = driver_config.get("driver_path", "")
|
||||
if not self.__driver_path:
|
||||
self._showTrace("未配置浏览器驱动路径 !", self.TraceLevel.WARNING)
|
||||
return False
|
||||
try:
|
||||
self.__driver_path = os.path.abspath(self.__driver_path)
|
||||
service = None
|
||||
match self.__driver_type:
|
||||
case "edge":
|
||||
service = EdgeService(executable_path=self.__driver_path)
|
||||
self.__driver = webdriver.Edge(service=service, options=driver_options)
|
||||
case "chrome":
|
||||
service = ChromeService(executable_path=self.__driver_path)
|
||||
self.__driver = webdriver.Chrome(service=service, options=driver_options)
|
||||
case "firefox":
|
||||
self._showTrace("Firefox 浏览器驱动初始化略慢, 请耐心等待...", no_log=True)
|
||||
service = FirefoxService(executable_path=self.__driver_path)
|
||||
self.__driver = webdriver.Firefox(service=service, options=driver_options)
|
||||
case _:
|
||||
raise Exception(f"不支持的浏览器驱动类型: {self.__driver_type} !")
|
||||
self.__driver.implicitly_wait(1)
|
||||
self.__driver.execute_script(
|
||||
"Object.defineProperty(navigator, 'webdriver', {get: () => undefined})"
|
||||
)
|
||||
except WebDriverException as e:
|
||||
self._showTrace(f"浏览器驱动初始化失败: {e}", self.TraceLevel.ERROR)
|
||||
return False
|
||||
self._showTrace(f"浏览器驱动已初始化, 类型: {self.__driver_type}, 路径: {self.__driver_path}")
|
||||
return True
|
||||
|
||||
def __initDriverUrl(
|
||||
self,
|
||||
) -> bool:
|
||||
|
||||
lib_config: dict = self.__run_config.get("library", None)
|
||||
if not lib_config:
|
||||
self._showTrace("未配置图书馆参数 !", self.TraceLevel.ERROR)
|
||||
return False
|
||||
url: str = lib_config.get("host_url") + lib_config.get("login_url")
|
||||
self.__login_page = LoginPage(self._input_queue, self._output_queue, self.__driver)
|
||||
self.__driver.set_page_load_timeout(5)
|
||||
try:
|
||||
self.__driver.get(url)
|
||||
except TimeoutException:
|
||||
self.__login_page.stopPageLoad()
|
||||
self._showTrace(
|
||||
"图书馆登录页面加载超时 ! 请检查网络环境是否正常", self.TraceLevel.ERROR
|
||||
)
|
||||
return False
|
||||
except WebDriverException as e:
|
||||
self._showTrace(f"图书馆页面加载失败: {e}", self.TraceLevel.ERROR)
|
||||
return False
|
||||
if not self.__login_page.waitUntilLoaded():
|
||||
return False
|
||||
return True
|
||||
|
||||
def __initPagesServices(
|
||||
self,
|
||||
) -> None:
|
||||
|
||||
if not self.__driver:
|
||||
self._showTrace("浏览器驱动未初始化, 请先初始化浏览器驱动 !", self.TraceLevel.WARNING)
|
||||
return
|
||||
self.__shell = MainShell(self.__driver)
|
||||
self.__captcha_solver = CaptchaSolver(
|
||||
input_queue=self._input_queue,
|
||||
output_queue=self._output_queue,
|
||||
)
|
||||
self.__record_checker = RecordChecker(
|
||||
input_queue=self._input_queue,
|
||||
output_queue=self._output_queue,
|
||||
)
|
||||
self.__reserve_checker = ReserveChecker(
|
||||
input_queue=self._input_queue,
|
||||
output_queue=self._output_queue,
|
||||
)
|
||||
|
||||
def __initPagesFlows(
|
||||
self,
|
||||
) -> None:
|
||||
|
||||
self.__reserve_flow = ReserveFlow(
|
||||
input_queue=self._input_queue,
|
||||
output_queue=self._output_queue,
|
||||
driver=self.__driver,
|
||||
shell=self.__shell,
|
||||
)
|
||||
self.__checkin_flow = CheckinFlow(
|
||||
input_queue=self._input_queue,
|
||||
output_queue=self._output_queue,
|
||||
driver=self.__driver,
|
||||
shell=self.__shell,
|
||||
)
|
||||
self.__renew_flow = RenewFlow(
|
||||
input_queue=self._input_queue,
|
||||
output_queue=self._output_queue,
|
||||
driver=self.__driver,
|
||||
shell=self.__shell,
|
||||
)
|
||||
|
||||
def __run(
|
||||
self,
|
||||
username: str,
|
||||
password: str,
|
||||
login_config: dict,
|
||||
run_mode_config: dict,
|
||||
reserve_info: dict,
|
||||
) -> int:
|
||||
|
||||
# result : -1 - terminate, 0 - success, 1 - failed, 2 - passed
|
||||
result: int = 2
|
||||
|
||||
# login
|
||||
auto_captcha: bool = login_config.get("auto_captcha", True)
|
||||
if not self.__login_page.login(
|
||||
username,
|
||||
password,
|
||||
captcha_solver=self.__captcha_solver.solveCaptcha,
|
||||
auto_captcha=auto_captcha,
|
||||
max_attempts=login_config.get("max_attempt", 3),
|
||||
):
|
||||
return 1
|
||||
run_mode_raw: int = run_mode_config.get("run_mode", 0)
|
||||
run_mode: dict[str, bool] = {
|
||||
"auto_reserve": run_mode_raw & 0x1,
|
||||
"auto_checkin": run_mode_raw & 0x2,
|
||||
"auto_renewal": run_mode_raw & 0x4,
|
||||
}
|
||||
|
||||
# reserve
|
||||
if run_mode["auto_reserve"]:
|
||||
if self.__reserve_checker.check(reserve_info):
|
||||
if self.__record_checker.canReserve(self.__shell, reserve_info["date"]):
|
||||
ctx = ReserveContext(
|
||||
username=username,
|
||||
date=reserve_info["date"],
|
||||
floor=reserve_info["floor"],
|
||||
room=reserve_info["room"],
|
||||
seat_id=reserve_info["seat_id"],
|
||||
begin_time=reserve_info["begin_time"]["time"],
|
||||
end_time=reserve_info["end_time"]["time"],
|
||||
begin_max_diff=reserve_info["begin_time"]["max_diff"],
|
||||
end_max_diff=reserve_info["end_time"]["max_diff"],
|
||||
begin_prefer_early=reserve_info["begin_time"]["prefer_early"],
|
||||
end_prefer_early=reserve_info["end_time"]["prefer_early"],
|
||||
expect_duration=reserve_info["expect_duration"],
|
||||
satisfy_duration=reserve_info["satisfy_duration"],
|
||||
)
|
||||
if self.__reserve_flow.execute(ctx):
|
||||
result = 0
|
||||
else:
|
||||
result = 1
|
||||
else:
|
||||
self._showTrace(f"用户 {username} 无法预约, 已跳过")
|
||||
result = 2
|
||||
else:
|
||||
result = 1
|
||||
|
||||
# checkin
|
||||
last_result: int = result
|
||||
if run_mode["auto_checkin"] and last_result != 1:
|
||||
if self.__record_checker.canCheckin(self.__shell):
|
||||
if self.__checkin_flow.execute(username):
|
||||
result = 0
|
||||
else:
|
||||
result = 1
|
||||
else:
|
||||
self._showTrace(f"用户 {username} 无法签到, 已跳过")
|
||||
result = 2
|
||||
if last_result == 0: # partly success
|
||||
result = 0
|
||||
|
||||
# renewal
|
||||
last_result = result
|
||||
if run_mode["auto_renewal"] and last_result != 1:
|
||||
can_renew, record = self.__record_checker.canRenew(self.__shell)
|
||||
if can_renew:
|
||||
renew_info: dict = reserve_info.get("renew_time", {})
|
||||
if self.__renew_flow.execute(username, record, renew_info):
|
||||
if self.__record_checker.postRenewCheck(self.__shell, record):
|
||||
self._showTrace(f"用户 {username} 续约成功 !")
|
||||
result = 0
|
||||
else:
|
||||
if result != 1: # partly success
|
||||
result = 0
|
||||
else:
|
||||
result = 1
|
||||
else:
|
||||
result = 1
|
||||
else:
|
||||
self._showTrace(f"用户 {username} 无法续约, 已跳过")
|
||||
result = 2
|
||||
if last_result == 0: # partly success
|
||||
result = 0
|
||||
|
||||
# logout
|
||||
if not self.__shell.logout():
|
||||
self._showTrace(f"用户 {username} 退出登录失败, 尝试直接重载页面")
|
||||
if not self.__initDriverUrl():
|
||||
self._showTrace(f"用户 {username} 重载页面失败, 无法继续操作, 该任务已终止 !")
|
||||
return -1
|
||||
self._showTrace(f"用户 {username} 已退出登录")
|
||||
return result
|
||||
|
||||
def run(
|
||||
self,
|
||||
user_config: dict,
|
||||
) -> None:
|
||||
|
||||
self.__user_config = user_config
|
||||
user_counter: dict[str, int] = {"current": 0, "success": 0, "failed": 0, "passed": 0}
|
||||
users: list = self.__user_config.get("users", [])
|
||||
self._showTrace(f"共发现 {len(users)} 个用户")
|
||||
for user in users:
|
||||
user_counter["current"] += 1
|
||||
self._showTrace(
|
||||
f"正在处理第 {user_counter["current"]}/{len(users)} 个用户: {user.get("username", "未知")}......",
|
||||
no_log=True,
|
||||
)
|
||||
if not user.get("enabled", False):
|
||||
self._showTrace(f"用户 {user.get("username", "未知")} 已跳过")
|
||||
user_counter["passed"] += 1
|
||||
continue
|
||||
r: int = self.__run(
|
||||
username=user.get("username", ""),
|
||||
password=user.get("password", ""),
|
||||
login_config=self.__run_config.get("login", {}),
|
||||
run_mode_config=self.__run_config.get("mode", {}),
|
||||
reserve_info=user.get("reserve_info", {}),
|
||||
)
|
||||
if r == -1:
|
||||
self._showTrace(
|
||||
f"用户 {user.get("username", "未知")} 处理过程中页面发生异常, 无法继续操作, 任务已终止 !",
|
||||
self.TraceLevel.WARNING,
|
||||
)
|
||||
break
|
||||
elif r == 0:
|
||||
user_counter["success"] += 1
|
||||
elif r == 1:
|
||||
user_counter["failed"] += 1
|
||||
elif r == 2:
|
||||
user_counter["passed"] += 1
|
||||
self._showTrace(
|
||||
f"处理完成, 共计 {user_counter["current"]} 个用户, "
|
||||
f"成功 {user_counter["success"]} 个用户, "
|
||||
f"失败 {user_counter["failed"]} 个用户, "
|
||||
f"跳过 {user_counter["passed"]} 个用户"
|
||||
)
|
||||
return
|
||||
|
||||
def close(
|
||||
self,
|
||||
) -> bool:
|
||||
|
||||
if self.__driver:
|
||||
if self.__driver_type.lower() == "firefox":
|
||||
self._showTrace(
|
||||
"Firefox 浏览器驱动关闭略慢, 请耐心等待...",
|
||||
no_log=True,
|
||||
)
|
||||
try:
|
||||
self.__driver.quit()
|
||||
except WebDriverException as e:
|
||||
self._showTrace(f"浏览器驱动关闭时发生异常: {e}", self.TraceLevel.WARNING)
|
||||
self.__driver = None
|
||||
self._showTrace("浏览器驱动已关闭")
|
||||
return True
|
||||
else:
|
||||
self._showTrace("浏览器驱动未初始化, 无需关闭", no_log=True)
|
||||
return False
|
||||
@@ -0,0 +1,205 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Copyright (c) 2026 KenanZhu.
|
||||
All rights reserved.
|
||||
|
||||
This software is provided "as is", without any warranty of any kind.
|
||||
You may use, modify, and distribute this file under the terms of the MIT License.
|
||||
See the LICENSE file for details.
|
||||
"""
|
||||
import queue
|
||||
from typing import Callable
|
||||
|
||||
from selenium.common.exceptions import (
|
||||
ElementNotInteractableException,
|
||||
NoSuchElementException,
|
||||
TimeoutException,
|
||||
)
|
||||
from selenium.webdriver.common.by import By
|
||||
from selenium.webdriver.remote.webdriver import WebDriver
|
||||
from selenium.webdriver.support.ui import WebDriverWait
|
||||
from selenium.webdriver.support import expected_conditions as EC
|
||||
|
||||
from base.MsgBase import MsgBase
|
||||
|
||||
|
||||
class LoginPage(MsgBase):
|
||||
|
||||
USERNAME_INPUT = (By.NAME, "username")
|
||||
PASSWORD_INPUT = (By.NAME, "password")
|
||||
CAPTCHA_INPUT = (By.NAME, "answer")
|
||||
CAPTCHA_IMG = (By.ID, "loadImgId")
|
||||
LOGIN_BUTTON = (By.XPATH, "//input[@type='button' and @value='登录']")
|
||||
|
||||
SUCCESS_INDICATOR_SEARCH = (By.ID, "search")
|
||||
SUCCESS_INDICATOR_CONTENT = (By.CLASS_NAME, "selectContent")
|
||||
SUCCESS_TITLE_KEYWORD = "自选座位 :: 座位预约系统"
|
||||
|
||||
PAGE_LOAD_TIMEOUT = 5
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
input_queue: queue.Queue,
|
||||
output_queue: queue.Queue,
|
||||
driver: WebDriver,
|
||||
) -> None:
|
||||
|
||||
super().__init__(input_queue, output_queue)
|
||||
self._driver: WebDriver = driver
|
||||
|
||||
def navigate(
|
||||
self,
|
||||
url: str,
|
||||
) -> bool:
|
||||
|
||||
self._driver.set_page_load_timeout(self.PAGE_LOAD_TIMEOUT)
|
||||
self._driver.get(url)
|
||||
if not self.waitUntilLoaded():
|
||||
return False
|
||||
return True
|
||||
|
||||
def waitUntilLoaded(
|
||||
self,
|
||||
) -> bool:
|
||||
|
||||
try:
|
||||
WebDriverWait(self._driver, 2).until(
|
||||
EC.title_contains("首页")
|
||||
)
|
||||
WebDriverWait(self._driver, 2).until(
|
||||
EC.presence_of_element_located(self.USERNAME_INPUT)
|
||||
)
|
||||
WebDriverWait(self._driver, 2).until(
|
||||
EC.presence_of_element_located(self.PASSWORD_INPUT)
|
||||
)
|
||||
WebDriverWait(self._driver, 2).until(
|
||||
EC.presence_of_element_located(self.CAPTCHA_INPUT)
|
||||
)
|
||||
WebDriverWait(self._driver, 2).until(
|
||||
EC.presence_of_element_located(self.CAPTCHA_IMG)
|
||||
)
|
||||
return True
|
||||
except TimeoutException:
|
||||
return False
|
||||
|
||||
def fillCredentials(
|
||||
self,
|
||||
username: str,
|
||||
password: str,
|
||||
) -> bool:
|
||||
|
||||
try:
|
||||
el = self._driver.find_element(*self.USERNAME_INPUT)
|
||||
el.clear()
|
||||
el.send_keys(username)
|
||||
el = self._driver.find_element(*self.PASSWORD_INPUT)
|
||||
el.clear()
|
||||
el.send_keys(password)
|
||||
return True
|
||||
except (NoSuchElementException, ElementNotInteractableException):
|
||||
return False
|
||||
|
||||
def getCaptchaImageSrc(
|
||||
self,
|
||||
) -> str | None:
|
||||
|
||||
# return 'None' if captcha image element is not found.
|
||||
# But the 'get_attribute("src")' also return 'None' if there's no attribute with
|
||||
# that name, which is not what we want.
|
||||
try:
|
||||
captcha_el = self._driver.find_element(*self.CAPTCHA_IMG)
|
||||
return captcha_el.get_attribute("src")
|
||||
except NoSuchElementException:
|
||||
return None
|
||||
|
||||
def refreshCaptcha(
|
||||
self,
|
||||
) -> bool:
|
||||
|
||||
try:
|
||||
self._driver.find_element(*self.CAPTCHA_IMG).click()
|
||||
return True
|
||||
except (NoSuchElementException, ElementNotInteractableException):
|
||||
return False
|
||||
|
||||
def fillCaptcha(
|
||||
self,
|
||||
captcha_text: str,
|
||||
) -> bool:
|
||||
|
||||
try:
|
||||
el = self._driver.find_element(*self.CAPTCHA_INPUT)
|
||||
el.clear()
|
||||
el.send_keys(captcha_text)
|
||||
return True
|
||||
except (NoSuchElementException, ElementNotInteractableException):
|
||||
return False
|
||||
|
||||
def clickLogin(
|
||||
self,
|
||||
) -> bool:
|
||||
|
||||
try:
|
||||
self._driver.find_element(*self.LOGIN_BUTTON).click()
|
||||
return True
|
||||
except (NoSuchElementException, ElementNotInteractableException):
|
||||
return False
|
||||
|
||||
def waitLoginSuccess(
|
||||
self,
|
||||
) -> bool:
|
||||
|
||||
try:
|
||||
WebDriverWait(self._driver, 2).until(
|
||||
EC.title_contains(self.SUCCESS_TITLE_KEYWORD)
|
||||
)
|
||||
WebDriverWait(self._driver, 2).until(
|
||||
EC.presence_of_element_located(self.SUCCESS_INDICATOR_SEARCH)
|
||||
)
|
||||
WebDriverWait(self._driver, 2).until(
|
||||
EC.presence_of_element_located(self.SUCCESS_INDICATOR_CONTENT)
|
||||
)
|
||||
return True
|
||||
except TimeoutException:
|
||||
return False
|
||||
|
||||
def stopPageLoad(
|
||||
self,
|
||||
) -> None:
|
||||
|
||||
self._driver.execute_script("window.stop();")
|
||||
|
||||
def login(
|
||||
self,
|
||||
username: str,
|
||||
password: str,
|
||||
captcha_solver: Callable[["LoginPage", bool], str],
|
||||
auto_captcha: bool,
|
||||
max_attempts: int = 5,
|
||||
) -> bool:
|
||||
|
||||
for attempt in range(max_attempts):
|
||||
self._showTrace(
|
||||
f"用户 {username} 第 {attempt + 1} 次尝试登录......",
|
||||
no_log=True,
|
||||
)
|
||||
if not self.fillCredentials(username, password):
|
||||
continue
|
||||
captcha_text = captcha_solver(self, auto_captcha)
|
||||
if not captcha_text:
|
||||
continue
|
||||
if not self.fillCaptcha(captcha_text):
|
||||
continue
|
||||
self._showTrace("尝试登录...", no_log=True)
|
||||
if not self.clickLogin():
|
||||
continue
|
||||
if self.waitLoginSuccess():
|
||||
self._showTrace(f"用户 {username} 第 {attempt + 1} 次登录成功 !")
|
||||
return True
|
||||
else:
|
||||
self._showTrace(
|
||||
"登录页面加载失败 ! : "
|
||||
"用户账号或者密码错误/验证码错误, 具体以页面提示为准",
|
||||
level=self.TraceLevel.ERROR,
|
||||
)
|
||||
return False
|
||||
@@ -0,0 +1,177 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Copyright (c) 2026 KenanZhu.
|
||||
All rights reserved.
|
||||
|
||||
This software is provided "as is", without any warranty of any kind.
|
||||
You may use, modify, and distribute this file under the terms of the MIT License.
|
||||
See the LICENSE file for details.
|
||||
"""
|
||||
import time
|
||||
|
||||
from selenium.webdriver.common.by import By
|
||||
from selenium.webdriver.support.ui import WebDriverWait
|
||||
from selenium.webdriver.support import expected_conditions as EC
|
||||
from selenium.webdriver.remote.webdriver import WebDriver
|
||||
from selenium.common.exceptions import (
|
||||
ElementNotInteractableException,
|
||||
NoSuchElementException,
|
||||
TimeoutException,
|
||||
WebDriverException,
|
||||
)
|
||||
|
||||
from pages.ReserveView import ReserveView
|
||||
from pages.RecordsView import RecordsView
|
||||
|
||||
|
||||
class MainShell:
|
||||
|
||||
TAB_RESERVE = (By.XPATH, "//a[@href='/map']")
|
||||
TAB_HISTORY = (By.XPATH, "//a[@href='/history?type=SEAT']")
|
||||
TAB_LOGOUT = (By.XPATH, "//a[@href='/logout']")
|
||||
|
||||
BTN_CHECKIN = (By.ID, "btnCheckIn")
|
||||
BTN_EXTEND = (By.ID, "btnExtend")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
driver: WebDriver,
|
||||
) -> None:
|
||||
|
||||
self._driver = driver
|
||||
|
||||
def _clickTab(
|
||||
self,
|
||||
locator: tuple,
|
||||
) -> None:
|
||||
|
||||
WebDriverWait(self._driver, 2).until(
|
||||
EC.element_to_be_clickable(locator)
|
||||
).click()
|
||||
|
||||
def gotoReserveView(
|
||||
self,
|
||||
) -> ReserveView:
|
||||
|
||||
self._clickTab(self.TAB_RESERVE)
|
||||
WebDriverWait(self._driver, 2).until(
|
||||
EC.presence_of_element_located((By.ID, "seatLayout"))
|
||||
)
|
||||
return ReserveView(self._driver)
|
||||
|
||||
def gotoRecordsView(
|
||||
self,
|
||||
) -> RecordsView:
|
||||
|
||||
self._clickTab(self.TAB_HISTORY)
|
||||
WebDriverWait(self._driver, 2).until(
|
||||
EC.presence_of_element_located((By.CLASS_NAME, "myReserveList"))
|
||||
)
|
||||
return RecordsView(self._driver)
|
||||
|
||||
def logout(
|
||||
self,
|
||||
) -> bool:
|
||||
|
||||
try:
|
||||
self._driver.find_element(*self.TAB_LOGOUT).click()
|
||||
return True
|
||||
except (NoSuchElementException, ElementNotInteractableException):
|
||||
return False
|
||||
|
||||
def waitCheckinButton(
|
||||
self,
|
||||
) -> bool:
|
||||
|
||||
try:
|
||||
WebDriverWait(self._driver, 2).until(
|
||||
EC.element_to_be_clickable(self.BTN_CHECKIN)
|
||||
)
|
||||
return True
|
||||
except TimeoutException:
|
||||
return False
|
||||
|
||||
def waitExtendButton(
|
||||
self,
|
||||
) -> bool:
|
||||
|
||||
try:
|
||||
WebDriverWait(self._driver, 2).until(
|
||||
EC.element_to_be_clickable(self.BTN_EXTEND)
|
||||
)
|
||||
return True
|
||||
except TimeoutException:
|
||||
return False
|
||||
|
||||
def isCheckinButtonDisabled(
|
||||
self,
|
||||
) -> bool:
|
||||
|
||||
try:
|
||||
btn = self._driver.find_element(*self.BTN_CHECKIN)
|
||||
return "disabled" in btn.get_attribute("class")
|
||||
except NoSuchElementException:
|
||||
return True
|
||||
|
||||
def isExtendButtonDisabled(
|
||||
self,
|
||||
) -> bool:
|
||||
|
||||
try:
|
||||
btn = self._driver.find_element(*self.BTN_EXTEND)
|
||||
return "disabled" in btn.get_attribute("class")
|
||||
except NoSuchElementException:
|
||||
return True
|
||||
|
||||
def clickCheckinButton(
|
||||
self,
|
||||
) -> None:
|
||||
|
||||
try:
|
||||
btn = WebDriverWait(self._driver, 2).until(
|
||||
EC.element_to_be_clickable(self.BTN_CHECKIN)
|
||||
)
|
||||
btn.click()
|
||||
except (TimeoutException, ElementNotInteractableException):
|
||||
return
|
||||
|
||||
def clickExtendButton(
|
||||
self,
|
||||
) -> None:
|
||||
|
||||
try:
|
||||
btn = WebDriverWait(self._driver, 2).until(
|
||||
EC.element_to_be_clickable(self.BTN_EXTEND)
|
||||
)
|
||||
btn.click()
|
||||
except (TimeoutException, ElementNotInteractableException):
|
||||
return
|
||||
|
||||
def enableCheckinButtonByJS(
|
||||
self,
|
||||
) -> bool:
|
||||
|
||||
script = """
|
||||
try {
|
||||
var checkin_btn = document.getElementById('btnCheckIn');
|
||||
if (checkin_btn) {
|
||||
checkin_btn.classList.remove('disabled');
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
"""
|
||||
result = self._driver.execute_script(script)
|
||||
time.sleep(0.1)
|
||||
return result
|
||||
|
||||
def refresh(
|
||||
self,
|
||||
) -> None:
|
||||
|
||||
try:
|
||||
self._driver.refresh()
|
||||
except (TimeoutException, WebDriverException):
|
||||
return
|
||||
@@ -0,0 +1,87 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Copyright (c) 2026 KenanZhu.
|
||||
All rights reserved.
|
||||
|
||||
This software is provided "as is", without any warranty of any kind.
|
||||
You may use, modify, and distribute this file under the terms of the MIT License.
|
||||
See the LICENSE file for details.
|
||||
"""
|
||||
from selenium.webdriver.common.by import By
|
||||
from selenium.webdriver.support.ui import WebDriverWait
|
||||
from selenium.webdriver.support import expected_conditions as EC
|
||||
from selenium.webdriver.remote.webdriver import WebDriver
|
||||
from selenium.webdriver.remote.webelement import WebElement
|
||||
from selenium.common.exceptions import (
|
||||
NoSuchElementException,
|
||||
StaleElementReferenceException,
|
||||
TimeoutException,
|
||||
)
|
||||
|
||||
|
||||
class RecordsView:
|
||||
|
||||
RECORDS_LIST = (By.CSS_SELECTOR, ".myReserveList > dl:not(#moreBlock)")
|
||||
MORE_BTN = (By.ID, "moreBtn")
|
||||
RECORD_TIME = (By.CSS_SELECTOR, "dt")
|
||||
RECORD_INFO = (By.CSS_SELECTOR, "a")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
driver: WebDriver,
|
||||
) -> None:
|
||||
|
||||
self._driver = driver
|
||||
|
||||
def loadRecords(
|
||||
self,
|
||||
) -> list | None:
|
||||
|
||||
try:
|
||||
WebDriverWait(self._driver, 2).until(
|
||||
EC.presence_of_element_located(self.RECORDS_LIST)
|
||||
)
|
||||
return self._driver.find_elements(*self.RECORDS_LIST)
|
||||
except TimeoutException:
|
||||
return None
|
||||
|
||||
def getRecordTimeElement(
|
||||
self,
|
||||
record: WebElement,
|
||||
) -> WebElement:
|
||||
|
||||
return record.find_element(*self.RECORD_TIME)
|
||||
|
||||
def getRecordInfoElements(
|
||||
self,
|
||||
record: WebElement,
|
||||
) -> list[WebElement]:
|
||||
|
||||
return record.find_elements(*self.RECORD_INFO)
|
||||
|
||||
def showMoreRecords(
|
||||
self,
|
||||
) -> bool:
|
||||
|
||||
try:
|
||||
WebDriverWait(self._driver, 2).until(
|
||||
EC.element_to_be_clickable(self.MORE_BTN)
|
||||
)
|
||||
except TimeoutException:
|
||||
return False
|
||||
try:
|
||||
more_btn = self._driver.find_element(*self.MORE_BTN)
|
||||
if more_btn.is_displayed() and more_btn.is_enabled():
|
||||
self._driver.execute_script("arguments[0].scrollIntoView(true);", more_btn)
|
||||
self._driver.execute_script("arguments[0].click();", more_btn)
|
||||
return True
|
||||
return False
|
||||
except (NoSuchElementException, StaleElementReferenceException):
|
||||
return False
|
||||
|
||||
def getRecordText(
|
||||
self,
|
||||
record: WebElement,
|
||||
) -> str:
|
||||
|
||||
return record.text.strip()
|
||||
@@ -0,0 +1,186 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Copyright (c) 2026 KenanZhu.
|
||||
All rights reserved.
|
||||
|
||||
This software is provided "as is", without any warranty of any kind.
|
||||
You may use, modify, and distribute this file under the terms of the MIT License.
|
||||
See the LICENSE file for details.
|
||||
"""
|
||||
import time
|
||||
|
||||
from selenium.webdriver.common.by import By
|
||||
from selenium.webdriver.support.ui import WebDriverWait
|
||||
from selenium.webdriver.support import expected_conditions as EC
|
||||
from selenium.webdriver.remote.webdriver import WebDriver
|
||||
from selenium.common.exceptions import (
|
||||
ElementNotInteractableException,
|
||||
TimeoutException,
|
||||
)
|
||||
|
||||
from pages.components.SeatMapDialog import SeatMapDialog
|
||||
|
||||
|
||||
class ReserveView:
|
||||
|
||||
DATE_SELECT = (By.ID, "onDate_select")
|
||||
DATE_OPTION_FMT = "p#options_onDate a[value='{value}']"
|
||||
DATE_XPATH_FMT = "//p[@id='options_onDate']/a[@value='{value}']"
|
||||
|
||||
PLACE_SELECT = (By.ID, "display_building")
|
||||
PLACE_OPTION_FMT = "p#options_building a[value='{value}']"
|
||||
PLACE_XPATH_FMT = "//p[@id='options_building']/a[@value='{value}']"
|
||||
|
||||
FLOOR_SELECT = (By.ID, "floor_select")
|
||||
FLOOR_OPTION_FMT = "p#options_floor a[value='{value}']"
|
||||
FLOOR_XPATH_FMT = "//p[@id='options_floor']/a[@value='{value}']"
|
||||
|
||||
FIND_ROOM_BTN = (By.ID, "findRoom")
|
||||
ROOM_BTN_FMT = "room_{room}"
|
||||
|
||||
RESERVE_BTN = (By.ID, "reserveBtn")
|
||||
|
||||
FLOOR_MAP = {"2": "二层", "3": "三层", "4": "四层", "5": "五层"}
|
||||
ROOM_MAP = {
|
||||
"1": "二层内环", "2": "二层西区", "3": "三层内环", "4": "三层外环",
|
||||
"5": "四层内环", "6": "四层外环", "7": "四层期刊", "8": "五层考研",
|
||||
}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
driver: WebDriver,
|
||||
) -> None:
|
||||
|
||||
self._driver = driver
|
||||
|
||||
def _clickOptionByJS(
|
||||
self,
|
||||
trigger_id: str,
|
||||
option_css: str,
|
||||
) -> bool:
|
||||
|
||||
script = f"""
|
||||
try {{
|
||||
var trigger = document.getElementById('{trigger_id}');
|
||||
if (trigger) {{
|
||||
trigger.click();
|
||||
var option = document.querySelector("{option_css}");
|
||||
if (option) {{
|
||||
option.click();
|
||||
return true;
|
||||
}}
|
||||
return false;
|
||||
}}
|
||||
return false;
|
||||
}} catch (e) {{
|
||||
return false;
|
||||
}}
|
||||
"""
|
||||
result = self._driver.execute_script(script)
|
||||
time.sleep(0.1)
|
||||
return result
|
||||
|
||||
def _clickOption(
|
||||
self,
|
||||
trigger: tuple,
|
||||
option: tuple,
|
||||
) -> bool:
|
||||
|
||||
try:
|
||||
WebDriverWait(self._driver, 2).until(
|
||||
EC.element_to_be_clickable(trigger)
|
||||
).click()
|
||||
WebDriverWait(self._driver, 2).until(
|
||||
EC.element_to_be_clickable(option)
|
||||
).click()
|
||||
return True
|
||||
except (TimeoutException, ElementNotInteractableException):
|
||||
return False
|
||||
|
||||
def selectDate(
|
||||
self,
|
||||
date_str: str,
|
||||
) -> bool:
|
||||
|
||||
if self._clickOptionByJS(
|
||||
trigger_id="onDate_select",
|
||||
option_css=self.DATE_OPTION_FMT.format(value=date_str),
|
||||
):
|
||||
return True
|
||||
return self._clickOption(
|
||||
trigger=self.DATE_SELECT,
|
||||
option=(By.XPATH, self.DATE_XPATH_FMT.format(value=date_str)),
|
||||
)
|
||||
|
||||
def selectPlace(
|
||||
self,
|
||||
place: str = "1",
|
||||
) -> bool:
|
||||
|
||||
if self._clickOptionByJS(
|
||||
trigger_id="display_building",
|
||||
option_css=self.PLACE_OPTION_FMT.format(value=place),
|
||||
):
|
||||
return True
|
||||
return self._clickOption(
|
||||
trigger=self.PLACE_SELECT,
|
||||
option=(By.XPATH, self.PLACE_XPATH_FMT.format(value=place)),
|
||||
)
|
||||
|
||||
def selectFloor(
|
||||
self,
|
||||
floor: str,
|
||||
) -> bool:
|
||||
|
||||
if self._clickOptionByJS(
|
||||
trigger_id="floor_select",
|
||||
option_css=self.FLOOR_OPTION_FMT.format(value=floor),
|
||||
):
|
||||
return True
|
||||
return self._clickOption(
|
||||
trigger=self.FLOOR_SELECT,
|
||||
option=(By.XPATH, self.FLOOR_XPATH_FMT.format(value=floor)),
|
||||
)
|
||||
|
||||
def selectRoom(
|
||||
self,
|
||||
room: str,
|
||||
) -> SeatMapDialog | None:
|
||||
|
||||
try:
|
||||
WebDriverWait(self._driver, 2).until(
|
||||
EC.element_to_be_clickable(self.FIND_ROOM_BTN)
|
||||
).click()
|
||||
except (TimeoutException, ElementNotInteractableException):
|
||||
return None
|
||||
try:
|
||||
WebDriverWait(self._driver, 2).until(
|
||||
EC.element_to_be_clickable((By.ID, self.ROOM_BTN_FMT.format(room=room)))
|
||||
).click()
|
||||
except (TimeoutException, ElementNotInteractableException):
|
||||
return None
|
||||
try:
|
||||
return SeatMapDialog(self._driver)
|
||||
except TimeoutException:
|
||||
return None
|
||||
|
||||
def submitReserve(
|
||||
self,
|
||||
) -> bool:
|
||||
|
||||
try:
|
||||
WebDriverWait(self._driver, 2).until(
|
||||
EC.element_to_be_clickable(self.RESERVE_BTN)
|
||||
).click()
|
||||
return True
|
||||
except (TimeoutException, ElementNotInteractableException):
|
||||
return False
|
||||
|
||||
def refresh(
|
||||
self,
|
||||
) -> None:
|
||||
|
||||
try:
|
||||
self._driver.refresh()
|
||||
except TimeoutException:
|
||||
return
|
||||
@@ -0,0 +1,19 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Copyright (c) 2026 KenanZhu.
|
||||
All rights reserved.
|
||||
|
||||
This software is provided "as is", without any warranty of any kind.
|
||||
You may use, modify, and distribute this file under the terms of the MIT License.
|
||||
See the LICENSE file for details.
|
||||
"""
|
||||
from .AutoLib import AutoLib
|
||||
from .LoginPage import LoginPage
|
||||
from .MainShell import MainShell
|
||||
from .ReserveView import ReserveView
|
||||
from .RecordsView import RecordsView
|
||||
from .components.SeatMapDialog import SeatMapDialog
|
||||
from .components.TimeSelectDialog import TimeSelectDialog
|
||||
from .components.ReserveResultDialog import ReserveResultDialog
|
||||
from .components.CheckinResultDialog import CheckinResultDialog
|
||||
from .components.RenewDialog import RenewDialog
|
||||
@@ -0,0 +1,70 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Copyright (c) 2026 KenanZhu.
|
||||
All rights reserved.
|
||||
|
||||
This software is provided "as is", without any warranty of any kind.
|
||||
You may use, modify, and distribute this file under the terms of the MIT License.
|
||||
See the LICENSE file for details.
|
||||
"""
|
||||
from selenium.common.exceptions import (
|
||||
ElementNotInteractableException,
|
||||
NoSuchElementException,
|
||||
StaleElementReferenceException,
|
||||
TimeoutException,
|
||||
)
|
||||
from selenium.webdriver.common.by import By
|
||||
from selenium.webdriver.remote.webdriver import WebDriver
|
||||
|
||||
from pages.components.Dialog import Dialog
|
||||
|
||||
|
||||
class CheckinResultDialog(Dialog):
|
||||
"""
|
||||
Check-in result dialog.
|
||||
"""
|
||||
|
||||
ROOT = (By.CLASS_NAME, "ui_dialog")
|
||||
|
||||
RESULT_MSG = (By.CLASS_NAME, "resultMessage")
|
||||
OK_BTN = (By.CLASS_NAME, "btnOK")
|
||||
DETAIL_DD = (By.CSS_SELECTOR, ".resultMessage dd")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
driver: WebDriver,
|
||||
) -> None:
|
||||
|
||||
super().__init__(driver, self.ROOT, auto_close_on_exit=False)
|
||||
|
||||
def getResultMessage(
|
||||
self,
|
||||
) -> str:
|
||||
|
||||
try:
|
||||
self._waitPresence(self.RESULT_MSG)
|
||||
el = self._find(*self.RESULT_MSG)
|
||||
return el.text
|
||||
except (TimeoutException, NoSuchElementException,
|
||||
StaleElementReferenceException):
|
||||
return ""
|
||||
|
||||
def getDetails(
|
||||
self,
|
||||
) -> list[str]:
|
||||
|
||||
try:
|
||||
elements = self._findAll(*self.DETAIL_DD)
|
||||
return [el.text for el in elements if el.text.strip()]
|
||||
except (NoSuchElementException, StaleElementReferenceException):
|
||||
return []
|
||||
|
||||
def clickOk(
|
||||
self,
|
||||
) -> bool:
|
||||
|
||||
try:
|
||||
self._waitClickable(self.OK_BTN).click()
|
||||
return True
|
||||
except (TimeoutException, ElementNotInteractableException):
|
||||
return False
|
||||
@@ -0,0 +1,114 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Copyright (c) 2026 KenanZhu.
|
||||
All rights reserved.
|
||||
|
||||
This software is provided "as is", without any warranty of any kind.
|
||||
You may use, modify, and distribute this file under the terms of the MIT License.
|
||||
See the LICENSE file for details.
|
||||
"""
|
||||
from selenium.webdriver.remote.webdriver import WebDriver
|
||||
from selenium.webdriver.remote.webelement import WebElement
|
||||
from selenium.webdriver.support.ui import WebDriverWait
|
||||
from selenium.webdriver.support import expected_conditions as EC
|
||||
|
||||
|
||||
class Dialog:
|
||||
"""
|
||||
Context-managed overlay / modal / dialog on a page.
|
||||
|
||||
The constructor verifies that the root element is visible — if not,
|
||||
the dialog is not on screen and a :exc:`TimeoutException` is raised.
|
||||
|
||||
Automates the lifecycle: wait for appearance on enter,
|
||||
optionally wait for disappearance on exit.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
driver: WebDriver,
|
||||
root_locator: tuple,
|
||||
auto_close_on_exit: bool = True,
|
||||
wait_timeout: float = 3.0,
|
||||
) -> None:
|
||||
|
||||
self._driver: WebDriver = driver
|
||||
self._root_locator: tuple = root_locator
|
||||
self._auto_close: bool = auto_close_on_exit
|
||||
self._timeout: float = wait_timeout
|
||||
|
||||
WebDriverWait(self._driver, self._timeout).until(
|
||||
EC.visibility_of_element_located(self._root_locator)
|
||||
)
|
||||
|
||||
def __enter__(
|
||||
self,
|
||||
) -> "Dialog":
|
||||
|
||||
return self
|
||||
|
||||
def __exit__(
|
||||
self,
|
||||
*args: object,
|
||||
) -> None:
|
||||
|
||||
if self._auto_close:
|
||||
WebDriverWait(self._driver, self._timeout).until(
|
||||
EC.invisibility_of_element_located(self._root_locator)
|
||||
)
|
||||
|
||||
def _find(
|
||||
self,
|
||||
by: str,
|
||||
value: str,
|
||||
) -> WebElement:
|
||||
|
||||
return self._driver.find_element(by, value)
|
||||
|
||||
def _findAll(
|
||||
self,
|
||||
by: str,
|
||||
value: str,
|
||||
) -> list[WebElement]:
|
||||
|
||||
return self._driver.find_elements(by, value)
|
||||
|
||||
def _waitClickable(
|
||||
self,
|
||||
locator: tuple,
|
||||
timeout: float = 2.0,
|
||||
) -> WebElement:
|
||||
|
||||
return WebDriverWait(self._driver, timeout).until(
|
||||
EC.element_to_be_clickable(locator)
|
||||
)
|
||||
|
||||
def _waitPresence(
|
||||
self,
|
||||
locator: tuple,
|
||||
timeout: float = 2.0,
|
||||
) -> WebElement:
|
||||
|
||||
return WebDriverWait(self._driver, timeout).until(
|
||||
EC.presence_of_element_located(locator)
|
||||
)
|
||||
|
||||
def _waitVisible(
|
||||
self,
|
||||
locator: tuple,
|
||||
timeout: float = 2.0,
|
||||
) -> WebElement:
|
||||
|
||||
return WebDriverWait(self._driver, timeout).until(
|
||||
EC.visibility_of_element_located(locator)
|
||||
)
|
||||
|
||||
def _waitAllPresence(
|
||||
self,
|
||||
locator: tuple,
|
||||
timeout: float = 2.0,
|
||||
) -> list[WebElement]:
|
||||
|
||||
return WebDriverWait(self._driver, timeout).until(
|
||||
EC.presence_of_all_elements_located(locator)
|
||||
)
|
||||
@@ -0,0 +1,127 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Copyright (c) 2026 KenanZhu.
|
||||
All rights reserved.
|
||||
|
||||
This software is provided "as is", without any warranty of any kind.
|
||||
You may use, modify, and distribute this file under the terms of the MIT License.
|
||||
See the LICENSE file for details.
|
||||
"""
|
||||
from selenium.common.exceptions import (
|
||||
ElementNotInteractableException,
|
||||
NoSuchElementException,
|
||||
StaleElementReferenceException,
|
||||
TimeoutException,
|
||||
)
|
||||
from selenium.webdriver.common.by import By
|
||||
from selenium.webdriver.remote.webdriver import WebDriver
|
||||
from selenium.webdriver.remote.webelement import WebElement
|
||||
|
||||
from pages.components.Dialog import Dialog
|
||||
from pages.strategies.TimeSelectMaker import (
|
||||
TimeSelectionResult,
|
||||
TimeSelectMaker,
|
||||
)
|
||||
|
||||
|
||||
class RenewDialog(Dialog):
|
||||
"""
|
||||
Renewal time selection dialog.
|
||||
"""
|
||||
|
||||
ROOT = (By.ID, "extendDiv")
|
||||
|
||||
MESSAGE_HEAD = (By.CSS_SELECTOR, "#extendDiv p.messageHead")
|
||||
RESULT_MSG = (By.CSS_SELECTOR, "#extendDiv div.resultMessage")
|
||||
TIME_OPTS = (By.CSS_SELECTOR, "#extendDiv .renewal_List li")
|
||||
OK_BTN = (By.CSS_SELECTOR, "#extendDiv .btnOK")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
driver: WebDriver,
|
||||
) -> None:
|
||||
|
||||
super().__init__(driver, self.ROOT, auto_close_on_exit=False)
|
||||
|
||||
def waitUntilReady(
|
||||
self,
|
||||
) -> bool:
|
||||
|
||||
try:
|
||||
self._waitVisible(self.ROOT)
|
||||
self._waitPresence(self.MESSAGE_HEAD)
|
||||
self._waitPresence(self.RESULT_MSG)
|
||||
except TimeoutException:
|
||||
return False
|
||||
head_msg = self._find(*self.MESSAGE_HEAD).text.strip()
|
||||
if "警告" in head_msg:
|
||||
return False
|
||||
try:
|
||||
self._waitAllPresence(self.TIME_OPTS)
|
||||
self._waitPresence(self.OK_BTN)
|
||||
except TimeoutException:
|
||||
return False
|
||||
return True
|
||||
|
||||
def getHeadMessage(
|
||||
self,
|
||||
) -> str:
|
||||
|
||||
try:
|
||||
return self._find(*self.MESSAGE_HEAD).text.strip()
|
||||
except (NoSuchElementException, StaleElementReferenceException):
|
||||
return ""
|
||||
|
||||
def getResultMessage(
|
||||
self,
|
||||
) -> str:
|
||||
|
||||
try:
|
||||
return self._find(*self.RESULT_MSG).text.strip()
|
||||
except (NoSuchElementException, StaleElementReferenceException):
|
||||
return ""
|
||||
|
||||
def getTimeOptions(
|
||||
self,
|
||||
) -> list[WebElement]:
|
||||
|
||||
return self._findAll(*self.TIME_OPTS)
|
||||
|
||||
def selectBestTime(
|
||||
self,
|
||||
target_time: int,
|
||||
max_time_diff: int,
|
||||
prefer_earlier: bool,
|
||||
) -> TimeSelectionResult:
|
||||
|
||||
all_time_opts = self.getTimeOptions()
|
||||
if not all_time_opts:
|
||||
return TimeSelectionResult()
|
||||
result = TimeSelectMaker.forRenew().decide(
|
||||
all_time_opts,
|
||||
target_time,
|
||||
max_time_diff,
|
||||
prefer_earlier,
|
||||
)
|
||||
if result.selected_index >= 0:
|
||||
try:
|
||||
all_time_opts[result.selected_index].click()
|
||||
except (ElementNotInteractableException, StaleElementReferenceException):
|
||||
return TimeSelectionResult(free_times=result.free_times)
|
||||
return result
|
||||
|
||||
def getOkButton(
|
||||
self,
|
||||
) -> WebElement:
|
||||
|
||||
return self._find(*self.OK_BTN)
|
||||
|
||||
def clickOk(
|
||||
self,
|
||||
) -> bool:
|
||||
|
||||
try:
|
||||
self._find(*self.OK_BTN).click()
|
||||
return True
|
||||
except (NoSuchElementException, ElementNotInteractableException):
|
||||
return False
|
||||
@@ -0,0 +1,77 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Copyright (c) 2026 KenanZhu.
|
||||
All rights reserved.
|
||||
|
||||
This software is provided "as is", without any warranty of any kind.
|
||||
You may use, modify, and distribute this file under the terms of the MIT License.
|
||||
See the LICENSE file for details.
|
||||
"""
|
||||
from selenium.common.exceptions import (
|
||||
NoSuchElementException,
|
||||
StaleElementReferenceException,
|
||||
)
|
||||
from selenium.webdriver.common.by import By
|
||||
from selenium.webdriver.remote.webdriver import WebDriver
|
||||
|
||||
from pages.components.Dialog import Dialog
|
||||
|
||||
|
||||
class ReserveResultDialog(Dialog):
|
||||
"""
|
||||
Reservation result dialog shown after submitting a reserve request.
|
||||
"""
|
||||
|
||||
ROOT = (By.CLASS_NAME, "layoutSeat")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
driver: WebDriver,
|
||||
) -> None:
|
||||
|
||||
super().__init__(driver, self.ROOT, auto_close_on_exit=False)
|
||||
|
||||
def _titleLocator(
|
||||
self,
|
||||
) -> tuple:
|
||||
|
||||
return (By.CSS_SELECTOR, ".layoutSeat dt")
|
||||
|
||||
def getTitle(
|
||||
self,
|
||||
) -> str:
|
||||
|
||||
try:
|
||||
return self._find(*self._titleLocator()).text
|
||||
except (NoSuchElementException, StaleElementReferenceException):
|
||||
return ""
|
||||
|
||||
def isSuccess(
|
||||
self,
|
||||
) -> bool:
|
||||
|
||||
title = self.getTitle()
|
||||
return any(
|
||||
kw in title
|
||||
for kw in ("预定好了", "预约成功", "操作成功")
|
||||
)
|
||||
|
||||
def isFailure(
|
||||
self,
|
||||
) -> bool:
|
||||
|
||||
contents = self.getDetailTexts()
|
||||
return any(
|
||||
"预约失败" in msg or "已有1个有效预约" in msg
|
||||
for msg in contents
|
||||
)
|
||||
|
||||
def getDetailTexts(
|
||||
self,
|
||||
) -> list[str]:
|
||||
|
||||
try:
|
||||
elements = self._findAll(By.CSS_SELECTOR, ".layoutSeat dd")
|
||||
return [el.text for el in elements if el.text.strip()]
|
||||
except (NoSuchElementException, StaleElementReferenceException):
|
||||
return []
|
||||
@@ -0,0 +1,74 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Copyright (c) 2026 KenanZhu.
|
||||
All rights reserved.
|
||||
|
||||
This software is provided "as is", without any warranty of any kind.
|
||||
You may use, modify, and distribute this file under the terms of the MIT License.
|
||||
See the LICENSE file for details.
|
||||
"""
|
||||
from selenium.common.exceptions import (
|
||||
ElementNotInteractableException,
|
||||
NoSuchElementException,
|
||||
StaleElementReferenceException,
|
||||
TimeoutException,
|
||||
)
|
||||
from selenium.webdriver.common.by import By
|
||||
from selenium.webdriver.remote.webdriver import WebDriver
|
||||
from selenium.webdriver.support.ui import WebDriverWait
|
||||
from selenium.webdriver.support import expected_conditions as EC
|
||||
|
||||
from pages.components.Dialog import Dialog
|
||||
|
||||
|
||||
class SeatMapDialog(Dialog):
|
||||
"""
|
||||
Seat selection overlay that opens after choosing a floor and room.
|
||||
"""
|
||||
|
||||
ROOT = (By.ID, "seatLayout")
|
||||
SEAT_ITEMS = (By.CSS_SELECTOR, "li[id^='seat_']")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
driver: WebDriver,
|
||||
) -> None:
|
||||
|
||||
super().__init__(driver, self.ROOT)
|
||||
|
||||
def selectSeat(
|
||||
self,
|
||||
seat_id: str,
|
||||
) -> str | None:
|
||||
|
||||
try:
|
||||
self._waitAllPresence(self.SEAT_ITEMS)
|
||||
except TimeoutException:
|
||||
return None
|
||||
try:
|
||||
seat_el = self._find(By.ID, f"seat_{int(seat_id):03d}")
|
||||
seat_link = seat_el.find_element(By.TAG_NAME, "a")
|
||||
WebDriverWait(self._driver, 2).until(
|
||||
EC.element_to_be_clickable(seat_link)
|
||||
)
|
||||
seat_link.click()
|
||||
return seat_link.get_attribute("title")
|
||||
except (NoSuchElementException, ValueError, TimeoutException,
|
||||
ElementNotInteractableException, StaleElementReferenceException):
|
||||
pass
|
||||
try:
|
||||
all_seats = self._findAll(*self.SEAT_ITEMS)
|
||||
seat_id_upper = seat_id.lstrip('0').upper()
|
||||
for seat in all_seats:
|
||||
if not seat_id_upper == seat.text.lstrip('0'):
|
||||
continue
|
||||
seat_link = seat.find_element(By.TAG_NAME, "a")
|
||||
WebDriverWait(self._driver, 2).until(
|
||||
EC.element_to_be_clickable(seat_link)
|
||||
)
|
||||
seat_link.click()
|
||||
return seat_link.get_attribute("title")
|
||||
return None
|
||||
except (NoSuchElementException, TimeoutException,
|
||||
ElementNotInteractableException, StaleElementReferenceException):
|
||||
return None
|
||||
@@ -0,0 +1,234 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Copyright (c) 2026 KenanZhu.
|
||||
All rights reserved.
|
||||
|
||||
This software is provided "as is", without any warranty of any kind.
|
||||
You may use, modify, and distribute this file under the terms of the MIT License.
|
||||
See the LICENSE file for details.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Callable, Optional
|
||||
|
||||
from selenium.common.exceptions import (
|
||||
ElementNotInteractableException,
|
||||
StaleElementReferenceException,
|
||||
TimeoutException,
|
||||
)
|
||||
from selenium.webdriver.common.by import By
|
||||
from selenium.webdriver.remote.webdriver import WebDriver
|
||||
from selenium.webdriver.remote.webelement import WebElement
|
||||
|
||||
from pages.components.Dialog import Dialog
|
||||
from pages.strategies.TimeSelectMaker import (
|
||||
TimeRangeResult,
|
||||
TimeSelectionResult,
|
||||
TimeSelectMaker,
|
||||
minsToTimeStr,
|
||||
timeStrToMins,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pages.flows.ReserveFlow import ReserveContext
|
||||
|
||||
|
||||
class TimeSelectDialog(Dialog):
|
||||
"""
|
||||
Time selection panel that appears after selecting a seat.
|
||||
|
||||
Contains start-time and end-time option lists.
|
||||
Does NOT auto-close — the reserve submission handles cleanup.
|
||||
"""
|
||||
|
||||
ROOT = (By.CSS_SELECTOR, "#startTime ul")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
driver: WebDriver,
|
||||
tracer: Optional[Callable[[str, int], None]] = None,
|
||||
) -> None:
|
||||
|
||||
super().__init__(driver, self.ROOT, auto_close_on_exit=False)
|
||||
self._tracer = tracer
|
||||
|
||||
def _trace(
|
||||
self,
|
||||
msg: str,
|
||||
level: int = logging.INFO,
|
||||
) -> None:
|
||||
|
||||
if self._tracer is not None:
|
||||
self._tracer(msg, level)
|
||||
|
||||
def _logTimeStep(
|
||||
self,
|
||||
time_type: str,
|
||||
target_mins: int,
|
||||
max_diff: int,
|
||||
step_result: TimeSelectionResult,
|
||||
) -> bool:
|
||||
|
||||
if step_result.selected_index >= 0:
|
||||
abs_diff = abs(step_result.actual_diff)
|
||||
if step_result.actual_diff < 0:
|
||||
relation = f"早了 {abs_diff} 分钟"
|
||||
elif step_result.actual_diff > 0:
|
||||
relation = f"晚了 {abs_diff} 分钟"
|
||||
else:
|
||||
relation = f"正好等于 {time_type}"
|
||||
self._trace(
|
||||
f"选择距离期望 {time_type} 最近的 {step_result.display_text}, "
|
||||
f"与期望 {time_type} 相比 {relation}"
|
||||
)
|
||||
return True
|
||||
if not step_result.free_times:
|
||||
self._trace(
|
||||
f"{time_type} 选择失败 ! : 当前未查询到可用时间",
|
||||
logging.ERROR,
|
||||
)
|
||||
else:
|
||||
target_str = minsToTimeStr(target_mins)
|
||||
self._trace(
|
||||
f"无法选择最近的 {time_type} {target_str}, "
|
||||
f"所有可选时间与目标时间相差都超过 {max_diff} 分钟",
|
||||
logging.WARNING,
|
||||
)
|
||||
self._trace(f"当前可供预约的 {time_type} 有: {step_result.free_times}")
|
||||
return False
|
||||
|
||||
def getTimeOptions(
|
||||
self,
|
||||
time_id: str,
|
||||
) -> list[WebElement]:
|
||||
|
||||
try:
|
||||
self._waitAllPresence(
|
||||
(By.CSS_SELECTOR, f"#{time_id} ul li a")
|
||||
)
|
||||
except TimeoutException:
|
||||
return []
|
||||
return self._findAll(
|
||||
By.CSS_SELECTOR,
|
||||
f"#{time_id} ul li a",
|
||||
)
|
||||
|
||||
def selectNearestTime(
|
||||
self,
|
||||
time_id: str,
|
||||
target_time: int,
|
||||
max_time_diff: int,
|
||||
prefer_earlier: bool,
|
||||
) -> TimeSelectionResult:
|
||||
|
||||
all_time_opts = self.getTimeOptions(time_id)
|
||||
if not all_time_opts:
|
||||
return TimeSelectionResult()
|
||||
result = TimeSelectMaker.forReserve().decide(
|
||||
all_time_opts,
|
||||
target_time,
|
||||
max_time_diff,
|
||||
prefer_earlier,
|
||||
)
|
||||
if result.selected_index >= 0:
|
||||
try:
|
||||
all_time_opts[result.selected_index].click()
|
||||
except (ElementNotInteractableException, StaleElementReferenceException):
|
||||
return TimeSelectionResult(free_times=result.free_times)
|
||||
return result
|
||||
|
||||
def selectTimeRange(
|
||||
self,
|
||||
begin_target: int,
|
||||
end_target: int,
|
||||
begin_max_diff: int = 30,
|
||||
end_max_diff: int = 30,
|
||||
begin_prefer_early: bool = True,
|
||||
end_prefer_early: bool = False,
|
||||
satisfy_duration: bool = True,
|
||||
expect_duration: int = 4,
|
||||
library_close_mins: int = TimeSelectMaker.LIBRARY_CLOSE_MINS,
|
||||
) -> TimeRangeResult:
|
||||
|
||||
begin_result = self.selectNearestTime(
|
||||
"startTime",
|
||||
begin_target,
|
||||
begin_max_diff,
|
||||
begin_prefer_early,
|
||||
)
|
||||
if begin_result.selected_index < 0:
|
||||
return TimeRangeResult(begin_result=begin_result)
|
||||
actual_begin = begin_result.selected_value
|
||||
if satisfy_duration:
|
||||
end_target = TimeSelectMaker.calcEndTime(
|
||||
actual_begin,
|
||||
expect_duration,
|
||||
library_close_mins,
|
||||
)
|
||||
end_result = self.selectNearestTime(
|
||||
"endTime",
|
||||
end_target,
|
||||
end_max_diff,
|
||||
end_prefer_early,
|
||||
)
|
||||
if end_result.selected_index < 0:
|
||||
return TimeRangeResult(
|
||||
begin_result=begin_result,
|
||||
actual_begin_mins=actual_begin,
|
||||
end_result=end_result,
|
||||
expect_end_mins=end_target,
|
||||
)
|
||||
return TimeRangeResult(
|
||||
begin_result=begin_result,
|
||||
end_result=end_result,
|
||||
actual_begin_mins=actual_begin,
|
||||
actual_end_mins=end_result.selected_value,
|
||||
expect_end_mins=end_target,
|
||||
)
|
||||
|
||||
def selectSeatTime(
|
||||
self,
|
||||
ctx: ReserveContext,
|
||||
library_close_mins: int = TimeSelectMaker.LIBRARY_CLOSE_MINS,
|
||||
) -> bool:
|
||||
|
||||
exp_beg_mins = timeStrToMins(ctx.begin_time)
|
||||
exp_end_mins = timeStrToMins(ctx.end_time)
|
||||
result = self.selectTimeRange(
|
||||
begin_target=exp_beg_mins,
|
||||
end_target=exp_end_mins,
|
||||
begin_max_diff=ctx.begin_max_diff,
|
||||
end_max_diff=ctx.end_max_diff,
|
||||
begin_prefer_early=ctx.begin_prefer_early,
|
||||
end_prefer_early=ctx.end_prefer_early,
|
||||
satisfy_duration=ctx.satisfy_duration,
|
||||
expect_duration=ctx.expect_duration,
|
||||
library_close_mins=library_close_mins,
|
||||
)
|
||||
if not self._logTimeStep("开始时间", exp_beg_mins, ctx.begin_max_diff, result.begin_result):
|
||||
return False
|
||||
if ctx.satisfy_duration:
|
||||
unclipped = result.actual_begin_mins + ctx.expect_duration*60
|
||||
if unclipped > library_close_mins:
|
||||
self._trace(
|
||||
f"预约持续时间 {ctx.expect_duration} 小时, 超过最大预约时间 {minsToTimeStr(library_close_mins)}, "
|
||||
f"自动调整为 {minsToTimeStr(library_close_mins)}",
|
||||
logging.WARNING,
|
||||
)
|
||||
act_beg_str = minsToTimeStr(result.actual_begin_mins)
|
||||
exp_end_str = minsToTimeStr(result.expect_end_mins)
|
||||
self._trace(
|
||||
f"需要满足期望预约持续时间: {ctx.expect_duration} 小时, "
|
||||
f"根据开始时间 {act_beg_str} 计算结束时间: {exp_end_str}"
|
||||
)
|
||||
if not self._logTimeStep("结束时间", result.expect_end_mins, ctx.end_max_diff, result.end_result):
|
||||
return False
|
||||
act_beg_str = minsToTimeStr(result.actual_begin_mins)
|
||||
act_end_str = minsToTimeStr(result.actual_end_mins)
|
||||
exp_end_str = minsToTimeStr(result.expect_end_mins)
|
||||
self._trace(
|
||||
f"期望预约时间段: {ctx.begin_time} - {exp_end_str}, "
|
||||
f"实际预约时间段: {act_beg_str} - {act_end_str}"
|
||||
)
|
||||
return True
|
||||
@@ -0,0 +1,14 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Copyright (c) 2026 KenanZhu.
|
||||
All rights reserved.
|
||||
|
||||
This software is provided "as is", without any warranty of any kind.
|
||||
You may use, modify, and distribute this file under the terms of the MIT License.
|
||||
See the LICENSE file for details.
|
||||
"""
|
||||
from .SeatMapDialog import SeatMapDialog
|
||||
from .TimeSelectDialog import TimeSelectDialog
|
||||
from .ReserveResultDialog import ReserveResultDialog
|
||||
from .CheckinResultDialog import CheckinResultDialog
|
||||
from .RenewDialog import RenewDialog
|
||||
@@ -0,0 +1,105 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Copyright (c) 2026 KenanZhu.
|
||||
All rights reserved.
|
||||
|
||||
This software is provided "as is", without any warranty of any kind.
|
||||
You may use, modify, and distribute this file under the terms of the MIT License.
|
||||
See the LICENSE file for details.
|
||||
"""
|
||||
import queue
|
||||
|
||||
from selenium.common.exceptions import (
|
||||
ElementNotInteractableException,
|
||||
NoSuchElementException,
|
||||
TimeoutException,
|
||||
)
|
||||
from selenium.webdriver.remote.webdriver import WebDriver
|
||||
|
||||
from base.MsgBase import MsgBase
|
||||
from pages.MainShell import MainShell
|
||||
from pages.components.CheckinResultDialog import CheckinResultDialog
|
||||
|
||||
|
||||
class CheckinFlow(MsgBase):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
input_queue: queue.Queue,
|
||||
output_queue: queue.Queue,
|
||||
driver: WebDriver,
|
||||
shell: MainShell,
|
||||
) -> None:
|
||||
|
||||
super().__init__(input_queue, output_queue)
|
||||
self._driver: WebDriver = driver
|
||||
self._shell: MainShell = shell
|
||||
|
||||
def _ensureCheckinButton(
|
||||
self,
|
||||
username: str,
|
||||
) -> bool:
|
||||
|
||||
if not self._shell.waitCheckinButton():
|
||||
self._showTrace(f"用户 {username} 签到界面加载失败 !", self.TraceLevel.ERROR)
|
||||
return False
|
||||
if self._shell.isCheckinButtonDisabled():
|
||||
self._showTrace("签到按钮不可用, 可能不在场馆内, 正在尝试启用......")
|
||||
if not self._shell.enableCheckinButtonByJS():
|
||||
self._showTrace(f"签到按钮启用失败 !", self.TraceLevel.ERROR)
|
||||
return False
|
||||
self._showTrace("签到按钮已启用")
|
||||
return True
|
||||
|
||||
def _processCheckinDialog(
|
||||
self,
|
||||
username: str,
|
||||
) -> bool:
|
||||
|
||||
try:
|
||||
with CheckinResultDialog(self._driver) as dialog:
|
||||
result_msg = dialog.getResultMessage()
|
||||
if "签到成功" in result_msg:
|
||||
details = dialog.getDetails()
|
||||
if details:
|
||||
if len(details) >= 5:
|
||||
self._showTrace(
|
||||
f"\n"
|
||||
f" 签到成功 !\n"
|
||||
f" {details[1]}\n"
|
||||
f" {details[2]}\n"
|
||||
f" {details[3]}\n"
|
||||
f" {details[4]}"
|
||||
)
|
||||
else:
|
||||
self._showTrace(
|
||||
"\n"
|
||||
" 签到成功 !\n"
|
||||
" 未获取到签到详情 !"
|
||||
)
|
||||
dialog.clickOk()
|
||||
self._showTrace(f"用户 {username} 签到成功 !")
|
||||
return True
|
||||
else:
|
||||
failure_reason = result_msg.replace("签到失败", "").strip()
|
||||
self._showTrace(
|
||||
f"\n"
|
||||
" 签到失败 !\n"
|
||||
f" {failure_reason}"
|
||||
)
|
||||
dialog.clickOk()
|
||||
self._showTrace(f"用户 {username} 签到失败 !", self.TraceLevel.ERROR)
|
||||
return False
|
||||
except (TimeoutException, NoSuchElementException, ElementNotInteractableException):
|
||||
self._showTrace("签到时发生未知错误 !", self.TraceLevel.ERROR)
|
||||
return False
|
||||
|
||||
def execute(
|
||||
self,
|
||||
username: str,
|
||||
) -> bool:
|
||||
|
||||
if not self._ensureCheckinButton(username):
|
||||
return False
|
||||
self._shell.clickCheckinButton()
|
||||
return self._processCheckinDialog(username)
|
||||
@@ -0,0 +1,167 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Copyright (c) 2026 KenanZhu.
|
||||
All rights reserved.
|
||||
|
||||
This software is provided "as is", without any warranty of any kind.
|
||||
You may use, modify, and distribute this file under the terms of the MIT License.
|
||||
See the LICENSE file for details.
|
||||
"""
|
||||
import queue
|
||||
|
||||
from selenium.common.exceptions import (
|
||||
ElementNotInteractableException,
|
||||
NoSuchElementException,
|
||||
TimeoutException,
|
||||
)
|
||||
from selenium.webdriver.remote.webdriver import WebDriver
|
||||
|
||||
from base.MsgBase import MsgBase
|
||||
from pages.MainShell import MainShell
|
||||
from pages.components.RenewDialog import RenewDialog
|
||||
from pages.flows._helpers import timeStrToMins, minsToTimeStr
|
||||
from pages.strategies.TimeSelectMaker import TimeSelectMaker
|
||||
|
||||
|
||||
class RenewFlow(MsgBase):
|
||||
|
||||
LIBRARY_CLOSE_MINS = TimeSelectMaker.LIBRARY_CLOSE_MINS
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
input_queue: queue.Queue,
|
||||
output_queue: queue.Queue,
|
||||
driver: WebDriver,
|
||||
shell: MainShell,
|
||||
) -> None:
|
||||
|
||||
super().__init__(input_queue, output_queue)
|
||||
self._driver: WebDriver = driver
|
||||
self._shell: MainShell = shell
|
||||
|
||||
def _validateRenewTime(
|
||||
self,
|
||||
end_time: str,
|
||||
target_renew_mins: int,
|
||||
) -> bool:
|
||||
|
||||
if target_renew_mins > self.LIBRARY_CLOSE_MINS:
|
||||
actual_renew_duration = self.LIBRARY_CLOSE_MINS - timeStrToMins(end_time)
|
||||
if actual_renew_duration <= 0:
|
||||
self._showTrace(
|
||||
f"当前结束时间 {end_time} 已接近闭馆时间,无法续约 !", self.TraceLevel.ERROR
|
||||
)
|
||||
return False
|
||||
self._showTrace(
|
||||
f"续约时间已调整至闭馆时间 "
|
||||
f"{minsToTimeStr(self.LIBRARY_CLOSE_MINS)},"
|
||||
f"实际续约时长为 "
|
||||
f"{actual_renew_duration // 60} 小时 "
|
||||
f"{actual_renew_duration % 60} 分钟"
|
||||
)
|
||||
return True
|
||||
|
||||
def _computeRenewTarget(
|
||||
self,
|
||||
record: dict,
|
||||
renew_info: dict,
|
||||
):
|
||||
|
||||
end_time = record["time"]["end"]
|
||||
target_renew_mins = timeStrToMins(end_time) + renew_info.get("expect_duration", 2) * 60
|
||||
if not self._validateRenewTime(end_time, target_renew_mins):
|
||||
return None
|
||||
return target_renew_mins
|
||||
|
||||
def _ensureExtendButton(
|
||||
self,
|
||||
username: str,
|
||||
) -> bool:
|
||||
|
||||
if not self._shell.waitExtendButton():
|
||||
self._showTrace(f"用户 {username} 续约界面加载失败 !", self.TraceLevel.ERROR)
|
||||
return False
|
||||
if self._shell.isExtendButtonDisabled():
|
||||
self._showTrace(
|
||||
f"用户 {username} 续约按钮不可用, 可能不在场馆内, "
|
||||
f"请连接图书馆网络后重试"
|
||||
)
|
||||
return False
|
||||
return True
|
||||
|
||||
def _processRenewDialog(
|
||||
self,
|
||||
username: str,
|
||||
record: dict,
|
||||
target_renew_mins: int,
|
||||
max_diff: int,
|
||||
prefer_earlier: bool,
|
||||
) -> bool:
|
||||
|
||||
try:
|
||||
with RenewDialog(self._driver) as dialog:
|
||||
if not dialog.waitUntilReady():
|
||||
result_msg = dialog.getResultMessage()
|
||||
self._showTrace(
|
||||
f"\n"
|
||||
f" 续约失败 !\n"
|
||||
f" {result_msg}"
|
||||
)
|
||||
self._shell.refresh()
|
||||
self._showTrace(f"用户 {username} 续约失败 !", self.TraceLevel.ERROR)
|
||||
return False
|
||||
result = dialog.selectBestTime(
|
||||
target_renew_mins,
|
||||
max_diff,
|
||||
prefer_earlier,
|
||||
)
|
||||
if result.selected_index >= 0:
|
||||
abs_diff = abs(result.actual_diff)
|
||||
if result.actual_diff < 0:
|
||||
relation = f"早了 {abs_diff} 分钟"
|
||||
elif result.actual_diff > 0:
|
||||
relation = f"晚了 {abs_diff} 分钟"
|
||||
else:
|
||||
relation = "正好等于 续约时间"
|
||||
self._showTrace(
|
||||
f"选择距离期望续约时间最近的 {result.display_text}, "
|
||||
f"与期望续约时间相比 {relation}"
|
||||
)
|
||||
record["time"]["end"] = result.display_text.strip()
|
||||
dialog.clickOk()
|
||||
self._shell.refresh()
|
||||
return True
|
||||
if not result.free_times:
|
||||
self._showTrace("当前未查询到可用续约时间 !", self.TraceLevel.WARNING)
|
||||
else:
|
||||
self._showTrace(
|
||||
"无法选择最近的可用续约时间 ! "
|
||||
f"所有可选时间与目标时间相差都超过了 {max_diff} 分钟 !",
|
||||
self.TraceLevel.WARNING,
|
||||
)
|
||||
self._showTrace(f"当前可供续约的时间有: {result.free_times}")
|
||||
self._shell.refresh()
|
||||
return False
|
||||
except (NoSuchElementException, TimeoutException, ElementNotInteractableException) as e:
|
||||
self._showTrace(f"用户 {username} 续约失败 ! : {e}", self.TraceLevel.ERROR)
|
||||
self._shell.refresh()
|
||||
return False
|
||||
|
||||
def execute(
|
||||
self,
|
||||
username: str,
|
||||
record: dict,
|
||||
renew_info: dict,
|
||||
) -> bool:
|
||||
|
||||
max_diff = renew_info.get("max_diff", 30)
|
||||
prefer_earlier = renew_info.get("prefer_early", True)
|
||||
target_renew_mins = self._computeRenewTarget(record, renew_info)
|
||||
if target_renew_mins is None:
|
||||
return False
|
||||
if not self._ensureExtendButton(username):
|
||||
return False
|
||||
self._shell.clickExtendButton()
|
||||
return self._processRenewDialog(
|
||||
username, record, target_renew_mins, max_diff, prefer_earlier,
|
||||
)
|
||||
@@ -0,0 +1,213 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Copyright (c) 2026 KenanZhu.
|
||||
All rights reserved.
|
||||
|
||||
This software is provided "as is", without any warranty of any kind.
|
||||
You may use, modify, and distribute this file under the terms of the MIT License.
|
||||
See the LICENSE file for details.
|
||||
"""
|
||||
import queue
|
||||
from dataclasses import dataclass
|
||||
|
||||
from selenium.common.exceptions import (
|
||||
ElementNotInteractableException,
|
||||
TimeoutException,
|
||||
)
|
||||
from selenium.webdriver.remote.webdriver import WebDriver
|
||||
|
||||
from base.MsgBase import MsgBase
|
||||
from pages.MainShell import MainShell
|
||||
from pages.strategies.TimeSelectMaker import TimeSelectMaker
|
||||
from pages.ReserveView import ReserveView
|
||||
from pages.components.ReserveResultDialog import ReserveResultDialog
|
||||
from pages.components.TimeSelectDialog import TimeSelectDialog
|
||||
|
||||
|
||||
@dataclass
|
||||
class ReserveContext:
|
||||
|
||||
username: str
|
||||
date: str
|
||||
floor: str
|
||||
room: str
|
||||
seat_id: str
|
||||
begin_time: str
|
||||
end_time: str
|
||||
begin_max_diff: int = 30
|
||||
end_max_diff: int = 30
|
||||
begin_prefer_early: bool = True
|
||||
end_prefer_early: bool = False
|
||||
expect_duration: int = 4
|
||||
satisfy_duration: bool = True
|
||||
|
||||
|
||||
class ReserveFlow(MsgBase):
|
||||
|
||||
LIBRARY_CLOSE_MINS = TimeSelectMaker.LIBRARY_CLOSE_MINS
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
input_queue: queue.Queue,
|
||||
output_queue: queue.Queue,
|
||||
driver: WebDriver,
|
||||
shell: MainShell,
|
||||
) -> None:
|
||||
|
||||
super().__init__(input_queue, output_queue)
|
||||
self._driver: WebDriver = driver
|
||||
self._shell: MainShell = shell
|
||||
|
||||
def _loadReserveView(
|
||||
self,
|
||||
) -> ReserveView | None:
|
||||
|
||||
try:
|
||||
return self._shell.gotoReserveView()
|
||||
except (TimeoutException, ElementNotInteractableException) as e:
|
||||
self._showTrace(f"加载预约选座页面失败 ! : {e}", self.TraceLevel.ERROR)
|
||||
return None
|
||||
|
||||
def _selectDate(
|
||||
self,
|
||||
view: ReserveView,
|
||||
ctx: ReserveContext,
|
||||
) -> bool:
|
||||
|
||||
if not view.selectDate(ctx.date):
|
||||
self._showTrace(f"选择日期失败 ! : {ctx.date} 不可用", self.TraceLevel.ERROR)
|
||||
return False
|
||||
self._showTrace(f"日期 {ctx.date} 选择成功 !")
|
||||
return True
|
||||
|
||||
def _selectPlace(
|
||||
self,
|
||||
view: ReserveView,
|
||||
) -> bool:
|
||||
|
||||
if not view.selectPlace("1"):
|
||||
self._showTrace("选择预约场所失败 ! : 图书馆 不可用", self.TraceLevel.ERROR)
|
||||
return False
|
||||
self._showTrace("预约场所 图书馆 选择成功 !")
|
||||
return True
|
||||
|
||||
def _selectFloor(
|
||||
self,
|
||||
view: ReserveView,
|
||||
ctx: ReserveContext,
|
||||
) -> bool:
|
||||
|
||||
if not view.selectFloor(ctx.floor):
|
||||
display_floor = ReserveView.FLOOR_MAP.get(ctx.floor, ctx.floor)
|
||||
self._showTrace(f"选择楼层失败 ! : {display_floor} 不可用", self.TraceLevel.ERROR)
|
||||
return False
|
||||
self._showTrace(f"楼层 {ReserveView.FLOOR_MAP.get(ctx.floor)} 选择成功 !")
|
||||
return True
|
||||
|
||||
def _selectRoom(
|
||||
self,
|
||||
view: ReserveView,
|
||||
ctx: ReserveContext,
|
||||
):
|
||||
|
||||
seat_map = view.selectRoom(ctx.room)
|
||||
if seat_map is None:
|
||||
display_room = ReserveView.ROOM_MAP.get(ctx.room, ctx.room)
|
||||
self._showTrace(f"选择房间失败 ! : {display_room} 不可用", self.TraceLevel.ERROR)
|
||||
return None
|
||||
self._showTrace(f"房间 {ReserveView.ROOM_MAP.get(ctx.room)} 选择成功 !")
|
||||
return seat_map
|
||||
|
||||
def _processReserveResult(
|
||||
self,
|
||||
) -> bool:
|
||||
|
||||
with ReserveResultDialog(self._driver) as result:
|
||||
if result.isFailure():
|
||||
self._showTrace("预约失败", self.TraceLevel.ERROR)
|
||||
elif result.isSuccess():
|
||||
details = result.getDetailTexts()
|
||||
if len(details) >= 6:
|
||||
self._showTrace(
|
||||
f"\n"
|
||||
f" 预约成功 !\n"
|
||||
f" {details[1]}\n"
|
||||
f" {details[2]}\n"
|
||||
f" {details[3]}\n"
|
||||
f" 签到时间 :{details[5]}"
|
||||
)
|
||||
else:
|
||||
self._showTrace(
|
||||
"\n"
|
||||
" 预约成功 !\n"
|
||||
" 未找获取到详细信息"
|
||||
)
|
||||
return True
|
||||
else:
|
||||
self._showTrace("预约结果加载失败 !", self.TraceLevel.ERROR)
|
||||
return False
|
||||
|
||||
def _selectSeatAndSubmit(
|
||||
self,
|
||||
view: ReserveView,
|
||||
seat_map,
|
||||
ctx: ReserveContext,
|
||||
) -> tuple[bool, bool]:
|
||||
|
||||
submit_reserve = False
|
||||
reserve_success = False
|
||||
|
||||
seat_status = seat_map.selectSeat(ctx.seat_id)
|
||||
if seat_status is None:
|
||||
self._showTrace(
|
||||
f"座位 {ctx.seat_id} 在该楼层区域中不存在, 请检查座位号是否正确",
|
||||
self.TraceLevel.WARNING,
|
||||
)
|
||||
else:
|
||||
self._showTrace(f"座位 {ctx.seat_id} 选择成功 ! : 当前状态 - '{seat_status}'")
|
||||
try:
|
||||
time_dialog = TimeSelectDialog(self._driver, tracer=self._showTrace)
|
||||
except TimeoutException:
|
||||
self._showTrace("时间选择面板未出现 !", self.TraceLevel.ERROR)
|
||||
else:
|
||||
if not time_dialog.selectSeatTime(ctx):
|
||||
self._showTrace("选择时间失败 !", self.TraceLevel.ERROR)
|
||||
else:
|
||||
try:
|
||||
view.submitReserve()
|
||||
submit_reserve = True
|
||||
reserve_success = self._processReserveResult()
|
||||
except (TimeoutException, ElementNotInteractableException):
|
||||
self._showTrace("预约提交失败 !", self.TraceLevel.ERROR)
|
||||
return submit_reserve, reserve_success
|
||||
|
||||
def execute(
|
||||
self,
|
||||
ctx: ReserveContext,
|
||||
) -> bool:
|
||||
|
||||
# reserve flow pipeline:
|
||||
# date > place > floor > room > seat (begin/end time) > submit > result
|
||||
view = self._loadReserveView()
|
||||
if view is None:
|
||||
return False
|
||||
if not self._selectDate(view, ctx):
|
||||
return False
|
||||
if not self._selectPlace(view):
|
||||
return False
|
||||
if not self._selectFloor(view, ctx):
|
||||
return False
|
||||
seat_map = self._selectRoom(view, ctx)
|
||||
if seat_map is None:
|
||||
return False
|
||||
have_hover_on_page = True
|
||||
submit_reserve, reserve_success = self._selectSeatAndSubmit(
|
||||
view, seat_map, ctx,
|
||||
)
|
||||
if not submit_reserve and have_hover_on_page:
|
||||
view.refresh()
|
||||
if reserve_success:
|
||||
self._showTrace(f"用户 {ctx.username} 预约成功 !")
|
||||
else:
|
||||
self._showTrace(f"用户 {ctx.username} 预约失败 !", self.TraceLevel.ERROR)
|
||||
return reserve_success
|
||||