mirror of
https://github.com/KenanZhu/AutoLibrary.git
synced 2026-06-18 07:23:03 +08:00
Compare commits
111 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 3963b3f2e6 | |||
| f2a05809bd | |||
| b55a0c06a5 | |||
| 2496c4e367 | |||
| de30559af1 | |||
| e1c2efc8c0 | |||
| 26a70cdceb | |||
| ce14be2555 | |||
| eda16f01f1 | |||
| 22f806bfb0 | |||
| d26852eaaf | |||
| 2ffe620532 | |||
| fe42d3cd98 | |||
| 0795939aa3 | |||
| 8b6baf9b6a | |||
| 7098d7075f | |||
| be3942ea2f | |||
| 7e3a089e21 | |||
| f3d68c40cb | |||
| 0ceff677e4 | |||
| 6f6b415bff | |||
| 735f31830d | |||
| 7be5afeae1 | |||
| 3d6978c9c2 |
@@ -0,0 +1,221 @@
|
||||
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:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
types:
|
||||
- opened
|
||||
- synchronize
|
||||
- reopened
|
||||
# Allow manual trigger for testing
|
||||
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'
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirement.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_32x32.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_32x32.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 "`nGenerated Main.spec ============"
|
||||
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"
|
||||
|
||||
echo "ZIP_NAME=$zipName" >> $env:GITHUB_OUTPUT
|
||||
|
||||
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: |
|
||||
Write-Host "## Build Test Summary" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8
|
||||
Write-Host "" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
|
||||
Write-Host "✓ Pull request build test completed successfully!" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
|
||||
Write-Host "- Version: ${{ steps.get_version.outputs.VERSION }}" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
|
||||
Write-Host "- Tag: ${{ steps.get_version.outputs.TAG_NAME }}" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
|
||||
Write-Host "- Pull Request #${{ github.event.pull_request.number }}" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
|
||||
Write-Host "- Branch: ${{ github.event.pull_request.head.ref }}" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
|
||||
Write-Host "- Event: ${{ github.event_name }}" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
|
||||
shell: pwsh
|
||||
+80
-48
@@ -8,21 +8,19 @@ name: Build
|
||||
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 +30,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,13 +49,27 @@ 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
|
||||
@@ -70,7 +82,7 @@ jobs:
|
||||
shell: pwsh
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v4
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.13'
|
||||
|
||||
@@ -94,13 +106,13 @@ jobs:
|
||||
exit 1
|
||||
}
|
||||
|
||||
if (-not (Test-Path "model")) {
|
||||
New-Item -ItemType Directory -Path "model" | Out-Null
|
||||
Write-Host "✓ Created model directory"
|
||||
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 = "model/common.onnx"
|
||||
$onnxDest = "models/common.onnx"
|
||||
if (Test-Path $onnxSource) {
|
||||
Copy-Item $onnxSource $onnxDest -Force
|
||||
Write-Host "✓ Copied ONNX model from: $onnxSource"
|
||||
@@ -119,18 +131,18 @@ jobs:
|
||||
}
|
||||
shell: pwsh
|
||||
|
||||
- name: Compile Qt UI files
|
||||
run: |
|
||||
cd src/gui/batchs
|
||||
./compile_ui.bat
|
||||
shell: cmd
|
||||
|
||||
- name: Compile Qt Resource files
|
||||
run: |
|
||||
cd src/gui/batchs
|
||||
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 }}"
|
||||
@@ -148,8 +160,8 @@ jobs:
|
||||
" pathex=[],"
|
||||
" binaries=[],"
|
||||
" datas=["
|
||||
" ('model\\common.onnx', 'ddddocr'),"
|
||||
" ('src\\gui\\icons\\AutoLibrary_32x32.ico', 'gui\\icons'),"
|
||||
" ('models\\common.onnx', 'ddddocr'),"
|
||||
" ('src\\gui\\resources\\icons\\AutoLibrary_32x32.ico', 'gui\\resources\\icons'),"
|
||||
" ],"
|
||||
" hiddenimports=[],"
|
||||
" hookspath=[],"
|
||||
@@ -164,10 +176,7 @@ jobs:
|
||||
"exe = EXE("
|
||||
" pyz,"
|
||||
" a.scripts,"
|
||||
" a.binaries,"
|
||||
" a.datas,"
|
||||
" [],"
|
||||
" name='$exeName',"
|
||||
" name='AutoLibrary',"
|
||||
" debug=False,"
|
||||
" bootloader_ignore_signals=False,"
|
||||
" strip=False,"
|
||||
@@ -180,12 +189,22 @@ jobs:
|
||||
" target_arch=None,"
|
||||
" codesign_identity=None,"
|
||||
" entitlements_file=None,"
|
||||
" icon=['src\\gui\\icons\\AutoLibrary_32x32.ico'],"
|
||||
" icon=['src\\gui\\resources\\icons\\AutoLibrary_32x32.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 "✓ Main.spec (non-single file) generated successfully"
|
||||
Write-Host "`nGenerated Main.spec ============"
|
||||
Get-Content "Main.spec" | Write-Host
|
||||
Write-Host "==================================`n"
|
||||
@@ -200,17 +219,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
|
||||
echo "ZIP_NAME=$zipName" >> $env:GITHUB_OUTPUT
|
||||
|
||||
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 +237,21 @@ 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: ${{ github.event_name != 'workflow_call' && 7 || 90 }}
|
||||
|
||||
- name: Upload build summary
|
||||
if: ${{ github.event_name != 'workflow_call' }}
|
||||
run: |
|
||||
Write-Host "## Build Summary" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8
|
||||
Write-Host "" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
|
||||
Write-Host "✓ Build test completed successfully!" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
|
||||
Write-Host "- Version: ${{ steps.get_version.outputs.VERSION }}" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
|
||||
Write-Host "- Tag: ${{ steps.get_version.outputs.TAG_NAME }}" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
|
||||
Write-Host "- Event: ${{ github.event_name }}" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
|
||||
Write-Host "- Ref: ${{ github.ref }}" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
|
||||
shell: pwsh
|
||||
|
||||
@@ -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/
|
||||
@@ -81,18 +105,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 }}"
|
||||
|
||||
|
||||
+199
-33
@@ -1,36 +1,48 @@
|
||||
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:
|
||||
# 4. Build:
|
||||
# Compiles the application for Windows platform using PyInstaller, and
|
||||
# archives the built artifacts as 'AutoLibrary.<tag_name>-windows-x86_64.zip'.
|
||||
|
||||
# 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:
|
||||
#
|
||||
@@ -44,6 +56,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 +120,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 :
|
||||
@@ -91,8 +163,9 @@ jobs:
|
||||
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,41 +175,30 @@ 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
|
||||
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: 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
|
||||
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://autolibrary.cv) 和查看 [帮助手册](https://autolibrary.cv/docs/manual_lists.html)
|
||||
|
||||
---
|
||||
**完整更新日志见下方自动生成的 Release Notes**
|
||||
env:
|
||||
@@ -154,3 +216,107 @@ 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' }}
|
||||
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 "=== Branch status before merge ==="
|
||||
git log --oneline --graph --all -5
|
||||
echo "=== Diff between ${MAIN_BRANCH} and origin/${BRANCH_NAME} ==="
|
||||
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 "=== Merge result ==="
|
||||
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 "✓ 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
|
||||
@@ -148,7 +148,7 @@ jobs:
|
||||
|
||||
- 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 +156,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
|
||||
|
||||
+12
-12
@@ -6,16 +6,16 @@
|
||||
__pycache__/
|
||||
build/
|
||||
dist/
|
||||
model/*.onnx
|
||||
driver/*.exe
|
||||
src/gui/configs/*.json
|
||||
src/gui/translators/qtbase_zh_CN.qm
|
||||
src/gui/AutoLibraryResources.py
|
||||
src/gui/AutoLibraryResource.py
|
||||
src/gui/Ui_ALMainWindow.py
|
||||
src/gui/Ui_ALConfigWidget.py
|
||||
src/gui/Ui_ALTimerTaskWidget.py
|
||||
src/gui/Ui_ALAddTimerTaskDialog.py
|
||||
src/gui/Ui_ALAboutDialog.py
|
||||
|
||||
Main.spec
|
||||
models/*.*
|
||||
drivers/*.*
|
||||
!models/*.md
|
||||
!drivers/*.md
|
||||
!templates/*.md
|
||||
!templates/configs/*.md
|
||||
|
||||
src/gui/resources/ui/Ui_*.py
|
||||
src/gui/resources/translators/qtbase_zh_CN.qm
|
||||
src/gui/resources/ALResource.py
|
||||
|
||||
Main.spec
|
||||
@@ -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"
|
||||
Generated
-630
@@ -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": {}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ chcp 65001 >nul
|
||||
setlocal enabledelayedexpansion
|
||||
|
||||
cd /d "%~dp0.."
|
||||
cd src/gui/resources
|
||||
|
||||
echo [AutoLibrary compile] 检查翻译文件...
|
||||
if exist translators (
|
||||
@@ -18,12 +19,11 @@ if exist translators (
|
||||
|
||||
pyside6-lrelease "%%f"
|
||||
if !errorlevel! equ 0 (
|
||||
echo [AutoLibrary compile] 翻译文件 "%%f" ✓ 编译成功,输出文件: "!qm_filename!"
|
||||
echo [AutoLibrary compile] 翻译文件 "%%f" 编译成功,输出文件: "!qm_filename!"
|
||||
) else (
|
||||
echo [AutoLibrary compile] 翻译文件 "%%f" ✗ 编译失败
|
||||
echo [AutoLibrary compile] 翻译文件 "%%f" 编译失败
|
||||
)
|
||||
)
|
||||
echo.
|
||||
) else (
|
||||
echo [AutoLibrary compile] 未找到任何 .ts 翻译文件
|
||||
)
|
||||
@@ -52,11 +52,10 @@ for %%f in (*.qrc) do (
|
||||
|
||||
pyside6-rcc "%%f" -o "!output_file!"
|
||||
if !errorlevel! equ 0 (
|
||||
echo [AutoLibrary compile] 文件 "%%f" ✓ 编译成功,输出文件: "!output_file!"
|
||||
echo [AutoLibrary compile] 文件 "%%f" 编译成功,输出文件: "!output_file!"
|
||||
) else (
|
||||
echo [AutoLibrary compile] 文件 "%%f" ✗ 编译失败
|
||||
echo [AutoLibrary compile] 文件 "%%f" 编译失败
|
||||
)
|
||||
echo.
|
||||
)
|
||||
|
||||
echo [AutoLibrary compile] 所有操作完成。
|
||||
Regular → Executable
+7
-11
@@ -1,8 +1,9 @@
|
||||
#!/bin/bash
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
PARENT_DIR="$(dirname "$SCRIPT_DIR")"
|
||||
cd "$PARENT_DIR"
|
||||
PRJECT_DIR="$SCRIPT_DIR/.."
|
||||
|
||||
cd "$PRJECT_DIR/src/gui/resources"
|
||||
|
||||
echo "[AutoLibrary compile] 检查翻译文件..."
|
||||
if [ -d "translators" ]; then
|
||||
@@ -10,7 +11,6 @@ if [ -d "translators" ]; then
|
||||
ts_files=(*.ts)
|
||||
ts_count=${#ts_files[@]}
|
||||
|
||||
# 如果第一个元素是"*.ts"(表示没有匹配),则数量为0
|
||||
if [ "$ts_count" -eq 1 ] && [ "${ts_files[0]}" = "*.ts" ]; then
|
||||
ts_count=0
|
||||
fi
|
||||
@@ -23,12 +23,11 @@ if [ -d "translators" ]; then
|
||||
echo "[AutoLibrary compile] 正在编译翻译文件: \"$file\" -> \"$qm_file\""
|
||||
|
||||
if pyside6-lrelease "$file"; then
|
||||
echo "[AutoLibrary compile] 翻译文件 \"$file\" ✓ 编译成功,输出文件: \"$qm_file\""
|
||||
echo "[AutoLibrary compile] 翻译文件 \"$file\" 编译成功,输出文件: \"$qm_file\""
|
||||
else
|
||||
echo "[AutoLibrary compile] 翻译文件 \"$file\" ✗ 编译失败"
|
||||
echo "[AutoLibrary compile] 翻译文件 \"$file\" 编译失败"
|
||||
fi
|
||||
done
|
||||
echo
|
||||
else
|
||||
echo "[AutoLibrary compile] 未找到任何 .ts 翻译文件"
|
||||
fi
|
||||
@@ -36,7 +35,6 @@ if [ -d "translators" ]; then
|
||||
else
|
||||
echo "[AutoLibrary compile] 未找到 translators 目录"
|
||||
fi
|
||||
echo
|
||||
|
||||
file_count=$(ls *.qrc 2>/dev/null | wc -l)
|
||||
|
||||
@@ -46,7 +44,6 @@ if [ $file_count -eq 0 ]; then
|
||||
fi
|
||||
|
||||
echo "[AutoLibrary compile] 找到 $file_count 个 .qrc 文件,开始编译..."
|
||||
echo
|
||||
|
||||
for file in *.qrc; do
|
||||
base_name=$(basename "$file" .qrc)
|
||||
@@ -54,11 +51,10 @@ for file in *.qrc; do
|
||||
echo "[AutoLibrary compile] 正在编译: \"$file\" -> \"$output_file\""
|
||||
|
||||
if pyside6-rcc "$file" -o "$output_file"; then
|
||||
echo "[AutoLibrary compile] 文件 \"$file\" ✓ 编译成功,输出文件: \"$output_file\""
|
||||
echo "[AutoLibrary compile] 文件 \"$file\" 编译成功,输出文件: \"$output_file\""
|
||||
else
|
||||
echo "[AutoLibrary compile] 文件 \"$file\" ✗ 编译失败"
|
||||
echo "[AutoLibrary compile] 文件 \"$file\" 编译失败"
|
||||
fi
|
||||
echo
|
||||
done
|
||||
|
||||
echo "[AutoLibrary compile] 所有操作完成。"
|
||||
@@ -3,6 +3,7 @@ chcp 65001 >nul
|
||||
setlocal enabledelayedexpansion
|
||||
|
||||
cd /d "%~dp0.."
|
||||
cd src/gui/resources/ui
|
||||
|
||||
set count=0
|
||||
for %%f in (*.ui) do set /a count+=1
|
||||
@@ -23,11 +24,10 @@ for %%f in (*.ui) do (
|
||||
|
||||
pyside6-uic "%%f" -o "!output_file!"
|
||||
if !errorlevel! equ 0 (
|
||||
echo [AutoLibrary compile] 文件 "%%f" ✓ 编译成功,输出文件: "!output_file!"
|
||||
echo [AutoLibrary compile] 文件 "%%f" 编译成功,输出文件: "!output_file!"
|
||||
) else (
|
||||
echo [AutoLibrary compile] 文件 "%%f" ✗ 编译失败
|
||||
echo [AutoLibrary compile] 文件 "%%f" 编译失败
|
||||
)
|
||||
echo.
|
||||
)
|
||||
|
||||
echo [AutoLibrary compile] 所有操作完成。
|
||||
Regular → Executable
+5
-6
@@ -1,8 +1,9 @@
|
||||
#!/bin/bash
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
PARENT_DIR="$(dirname "$SCRIPT_DIR")"
|
||||
cd "$PARENT_DIR"
|
||||
PRJECT_DIR="$SCRIPT_DIR/.."
|
||||
|
||||
cd "$PRJECT_DIR/src/gui/resources/ui"
|
||||
|
||||
file_count=$(ls *.ui 2>/dev/null | wc -l)
|
||||
|
||||
@@ -12,7 +13,6 @@ if [ $file_count -eq 0 ]; then
|
||||
fi
|
||||
|
||||
echo "[AutoLibrary compile] 找到 $file_count 个 .ui 文件,开始编译..."
|
||||
echo
|
||||
|
||||
for file in *.ui; do
|
||||
base_name=$(basename "$file" .ui)
|
||||
@@ -20,11 +20,10 @@ for file in *.ui; do
|
||||
echo "[AutoLibrary compile] 正在编译: \"$file\" -> \"$output_file\""
|
||||
|
||||
if pyside6-uic "$file" -o "$output_file"; then
|
||||
echo "[AutoLibrary compile] 文件 \"$file\" ✓ 编译成功,输出文件: \"$output_file\""
|
||||
echo "[AutoLibrary compile] 文件 \"$file\" 编译成功,输出文件: \"$output_file\""
|
||||
else
|
||||
echo "[AutoLibrary compile] 文件 \"$file\" ✗ 编译失败"
|
||||
echo "[AutoLibrary compile] 文件 \"$file\" 编译失败"
|
||||
fi
|
||||
echo
|
||||
done
|
||||
|
||||
echo "[AutoLibrary compile] 所有操作完成。"
|
||||
@@ -0,0 +1 @@
|
||||
This folder is used to store the batch scripts.
|
||||
@@ -1 +0,0 @@
|
||||
For more infomation, please visit our website: https://www.autolibrary.cv
|
||||
@@ -1 +0,0 @@
|
||||
This folder is used to store the browser driver using by selenium.
|
||||
@@ -0,0 +1 @@
|
||||
This folder is used to store the browser drivers using by selenium.
|
||||
@@ -0,0 +1,3 @@
|
||||
This folder is used to store the manuals.
|
||||
|
||||
Our manuals are available at https://www.autolibrary.kenanzhu.com/manuals
|
||||
@@ -1 +0,0 @@
|
||||
This folder is used to store the model using by ddddocr.
|
||||
@@ -0,0 +1 @@
|
||||
This folder is used to store the models using by ddddocr.
|
||||
@@ -2,14 +2,16 @@
|
||||
# AutoLibrary
|
||||
---
|
||||
|
||||

|
||||

|
||||
|
||||

|
||||

|
||||

|
||||

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

|
||||
[](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://autolibrary.cv)
|
||||
了解更多请访问 [_AutoLibrary 网站_](http://www.autolibrary.kenanzhu.com)
|
||||
|
||||
---
|
||||
|
||||
@@ -18,51 +20,34 @@
|
||||
1. 自动预约 - 支持自动预约
|
||||
2. 自动续约 - 支持自动续约
|
||||
3. 自动签到 - 支持自动签到
|
||||
4. 批量操作 - 支持同时预约多个用户,可以指定当前需要跳过的用户,并将用户分成多个组
|
||||
5. 定时任务 - 使用内置定时任务管理,添加定时任务,指定时间后按当前预约信息自动运行
|
||||
4. 远程签到 - 支持远程签到,无需在图书馆网络环境下即可签到
|
||||
5. 批量操作 - 支持同时预约多个用户,可以指定当前需要跳过的用户,并将用户分成多个组
|
||||
6. 定时任务 - 使用内置定时任务管理,添加定时任务,指定时间后按当前预约信息自动运行,支持设置重复任务
|
||||
7. 驱动管理 - 内置浏览器驱动自动管理,支持自动检测浏览器版本并下载对应驱动,无需手动下载
|
||||
|
||||
*1,2,3 的具体操作方法和注意事项请访问我们的 [帮助手册](https://autolibrary.cv/docs/manual_lists.html)*
|
||||
|
||||
### 特点
|
||||
|
||||
#### 关于预约等操作的注意事项
|
||||
|
||||
工具会自动处理登录过程的验证码识别过程,正常情况下单次识别准确率在 90% 以上,如遇验证码识别错误,大概率是校园网网络环境不佳导致的。
|
||||
|
||||
只要确保处于校园网网络环境内,工具都是可以正常运行的。操作处理速度基本上取决于校园网的网络环境,一般情况下在 3-4 秒(不考虑硬件差异)左右即可完成一个用户的操作,完全满足正常使用需求。
|
||||
|
||||
> [!NOTE]
|
||||
> 工具仅作为正常的预约,签到和续约的图书馆辅助工具,请勿干扰图书馆的正常运行(如故意预约多个座位,或同时预约大量的用户等,对此影响图书馆正常运行本工具概不负责,请在善用工具方便自己的情况下尽量不用影响其他同学的使用)。
|
||||
|
||||
#### 关于批量操作的注意事项
|
||||
|
||||
批量操作时,建议将需要操作的用户分成多个组,每个组的用户数量不要超过 4 人(即一整张桌子的数量),否则会影响操作效率,大量用户同时预约会一定程度上增加图书馆服务器的压力,影响正常使用。根据需要在用户管理界面中可以勾选本次操作是否跳过该用户,以提高运行效率。
|
||||
|
||||
#### 关于定时任务的注意事项
|
||||
|
||||
定时任务会在指定的时间自动运行,运行时会根据当前预约信息进行操作。一般情况下不建议设置两个运行开始时间比较接近的定时任务,否则后一个任务会等待前一个任务完成后才会运行,按照队列的顺序执行。
|
||||
*具体操作方法和注意事项请访问我们的 [帮助手册](https://www.autolibrary.kenanzhu.com/manuals)*
|
||||
|
||||
### 如何使用
|
||||
|
||||
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://autolibrary.cv/docs/manual_lists.html) 中对应软件版本的内容。
|
||||
*注意 1*: 工具内置浏览器驱动自动管理功能,会自动检测本地浏览器版本并下载对应的驱动文件。如果自动下载失败,也可以手动下载驱动文件并在配置界面的运行配置选项卡对应位置选择驱动文件路径。
|
||||
|
||||
#### 平台支持 & 编译步骤
|
||||
|
||||
本工具目前仅支持 Windows 平台,由于使用 PySide6 库开发,理论上是可以自行编译并在 Linux 和 macOS 上运行,这里提供简单的编译步骤:
|
||||
|
||||
1. 确保系统安装了 Python 3.13 版本 (推荐,过低或高版本会导致兼容问题)。
|
||||
2. 安装 pyside6 selenium ddddocr 库,命令为 `pip install pyside6 selenium ddddocr`。
|
||||
3. 在 `src/gui/batchs` 目录下运行 `compile_ui.bat` (linux 和 macOS 系统使用 `compile_ui.sh`) 文件来编译 Qt 的 UI 文件。
|
||||
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
|
||||
@@ -77,6 +62,25 @@ def classification(self, img: bytes):
|
||||
|
||||
[1](@ref):[pillow 中已经删除或已经弃用的常量](https://pillow.ac.cn/en/stable/deprecations.html#constants)
|
||||
|
||||
### 注意事项
|
||||
|
||||
#### 关于预约等操作
|
||||
|
||||
工具会自动处理登录过程的验证码识别过程,正常情况下单次识别准确率在 90% 以上,如遇验证码识别错误,大概率是校园网网络环境不佳导致的。
|
||||
|
||||
只要确保处于校园网网络环境内,工具都是可以正常运行的。操作处理速度基本上取决于校园网的网络环境,一般情况下在 3-4 秒(不考虑硬件差异)左右即可完成一个用户的操作,完全满足正常使用需求。
|
||||
|
||||
> [!NOTE]
|
||||
> 工具仅作为正常的预约,签到和续约的图书馆辅助工具,请勿干扰图书馆的正常运行(如故意预约多个座位,或同时预约大量的用户等,对此影响图书馆正常运行本工具概不负责,请在善用工具方便自己的情况下尽量不用影响其他同学的使用)。
|
||||
|
||||
#### 关于批量操作
|
||||
|
||||
批量操作时,建议将需要操作的用户分成多个组,每个组的用户数量不要超过 4 人(即一整张桌子的数量),否则会影响操作效率,大量用户同时预约会一定程度上增加图书馆服务器的压力,影响正常使用。根据需要在用户管理界面中可以勾选本次操作是否跳过该用户,以提高运行效率。
|
||||
|
||||
#### 关于定时任务
|
||||
|
||||
定时任务会在指定的时间自动运行,运行时会根据当前预约信息进行操作。一般情况下不建议设置两个运行开始时间比较接近的定时任务,否则后一个任务会等待前一个任务完成后才会运行,按照队列的顺序执行。
|
||||
|
||||
### Q&A
|
||||
|
||||
#### 为什么开发这个工具?
|
||||
@@ -97,11 +101,11 @@ def classification(self, img: bytes):
|
||||
|
||||
#### 后续会有哪些功能?
|
||||
|
||||
当前 v1.0.0 版本的功能对于正常使用已经足够,不过后续会着重考虑完善 2-4 人预约时的使用体验,暂时有以下构想:
|
||||
当前版本的功能对于正常使用已经足够,不过后续会着重完善预约时的使用体验,暂时有以下构想:
|
||||
|
||||
1. 2-4 人一起预约时,往往会偏向于预约并排或对面的整个空座位,这时候工具会按照一定策略查询搜索符合条件的座位,并预约并排或对面的整个座位,而不是各自独立预约。
|
||||
2. 预约时会考虑到组内用户的预约时间是否冲突,若冲突则会提示用户是否继续预约,若用户选择继续预约,则会按需要调整预约时间,再进行预约。
|
||||
3. 对于比较固定的用户,会考虑在定时任务管理中添加如 ‘每日任务’ ‘每周任务’ 等选项,用户可以根据需要设置定时任务重复的日期范围,自动完成预约,签到,续约等操作。
|
||||
- 引入交互预约面板功能,预约时直接在座位分布图中选择可用座位,并按用户分配,无需事先配置预约信息。
|
||||
- ~~优化定时任务管理功能,用户可以在定时任务管理界面设置重复运行的定时任务,如每日预约、每周预约等。~~ (已完成)
|
||||
- 软件的自动更新以及公告栏功能,用户可以自动更新最新版本并获取最新公告事项。
|
||||
|
||||
不过由于本人的时间和能力有限,也需要考虑到图书馆的正常运行,所以后续功能会有所取舍,但也许会进行一些小的功能验证。
|
||||
|
||||
|
||||
Binary file not shown.
+7
-3
@@ -13,7 +13,9 @@ from PySide6.QtCore import QTranslator
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
from gui.ALMainWindow import ALMainWindow
|
||||
from gui import AutoLibraryResource
|
||||
from gui.resources import ALResource
|
||||
|
||||
from boot.AppInitializer import initializeApp
|
||||
|
||||
|
||||
def main():
|
||||
@@ -23,10 +25,12 @@ def main():
|
||||
if translator.load(":/res/trans/translators/qtbase_zh_CN.ts"):
|
||||
app.installTranslator(translator)
|
||||
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,141 @@
|
||||
# -*- 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 datetime import datetime
|
||||
|
||||
from base.LibOperator import LibOperator
|
||||
|
||||
|
||||
class LibTimeSelector(LibOperator):
|
||||
"""
|
||||
Base class for time selection operations.
|
||||
|
||||
This class provides common time selection logic for reservation and renewal
|
||||
operations, including time conversion utilities and best time option finding.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
input_queue: queue.Queue,
|
||||
output_queue: queue.Queue
|
||||
):
|
||||
|
||||
super().__init__(input_queue, output_queue)
|
||||
|
||||
@staticmethod
|
||||
def _timeStrToMins(
|
||||
time_str: str
|
||||
) -> int:
|
||||
|
||||
"""
|
||||
Convert time string "HH:MM" to minutes since midnight.
|
||||
|
||||
Example:
|
||||
"10:00" -> 600
|
||||
"13:30" -> 810
|
||||
"""
|
||||
hour, minute = map(int, time_str.split(":"))
|
||||
return hour*60 + minute
|
||||
|
||||
@staticmethod
|
||||
def _minsToTimeStr(
|
||||
mins: int
|
||||
) -> str:
|
||||
|
||||
"""
|
||||
Convert minutes since midnight to time string "HH:MM".
|
||||
|
||||
Example:
|
||||
600 -> "10:00"
|
||||
810 -> "13:30"
|
||||
"""
|
||||
hour, minute = divmod(int(mins), 60)
|
||||
return f"{hour:02d}:{minute:02d}"
|
||||
|
||||
|
||||
def _formatTimeRelation(
|
||||
self,
|
||||
abs_diff: int,
|
||||
actual_diff: int,
|
||||
time_type: str
|
||||
) -> str:
|
||||
|
||||
"""
|
||||
Format time difference relation string.
|
||||
"""
|
||||
if actual_diff < 0:
|
||||
return f"早了 {abs_diff} 分钟"
|
||||
elif actual_diff > 0:
|
||||
return f"晚了 {abs_diff} 分钟"
|
||||
else:
|
||||
return f"正好等于 {time_type}"
|
||||
|
||||
|
||||
def _findBestTimeOption(
|
||||
self,
|
||||
time_options: list,
|
||||
target_time: int,
|
||||
max_time_diff: int,
|
||||
prefer_earlier: bool,
|
||||
is_reserve: bool = True
|
||||
) -> tuple:
|
||||
"""
|
||||
Find the best time option from available times.
|
||||
|
||||
Args:
|
||||
time_options: List of WebElement time options
|
||||
target_time: Target time in minutes
|
||||
max_time_diff: Maximum acceptable time difference in minutes
|
||||
prefer_earlier: If True, prefer earlier times when diffs are equal
|
||||
is_reserve: If True, parse 'time' attribute; if False, parse 'id' attribute
|
||||
|
||||
Returns:
|
||||
Tuple of (best_time_element, best_time_text, actual_diff, free_times_list)
|
||||
or (None, None, None, []) if no suitable option found
|
||||
"""
|
||||
free_times = []
|
||||
best_time_diff = max_time_diff
|
||||
best_actual_diff = None
|
||||
best_time_opt = None
|
||||
|
||||
for time_opt in time_options:
|
||||
# Parse time value based on context
|
||||
if is_reserve:
|
||||
# Reservation context: parse 'time' attribute
|
||||
time_attr = time_opt.get_attribute("time")
|
||||
if time_attr == "now":
|
||||
now = datetime.now()
|
||||
time_val = now.hour*60 + now.minute
|
||||
elif time_attr and time_attr.isdigit():
|
||||
time_val = int(time_attr)
|
||||
else:
|
||||
continue
|
||||
else:
|
||||
# Renewal context: parse 'id' attribute
|
||||
time_attr = time_opt.get_attribute("id")
|
||||
if not (time_attr and time_attr.isdigit()):
|
||||
continue
|
||||
time_val = int(time_attr)
|
||||
free_times.append(time_opt.text.strip() if not is_reserve else self._minsToTimeStr(time_val))
|
||||
actual_diff = time_val - target_time
|
||||
abs_diff = abs(actual_diff)
|
||||
|
||||
# Update best option if current is better
|
||||
if (abs_diff < best_time_diff or
|
||||
(abs_diff == best_time_diff and
|
||||
((prefer_earlier and actual_diff <= 0) or
|
||||
(not prefer_earlier and actual_diff >= 0)))):
|
||||
best_time_diff = abs_diff
|
||||
best_actual_diff = actual_diff
|
||||
best_time_opt = time_opt
|
||||
if best_time_opt is not None:
|
||||
return (best_time_opt, best_time_opt.text.strip(), best_actual_diff, free_times)
|
||||
return (None, None, None, free_times)
|
||||
+36
-15
@@ -7,8 +7,11 @@ 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 logging
|
||||
import queue
|
||||
import datetime
|
||||
|
||||
from managers.log.LogManager import getLogger
|
||||
|
||||
|
||||
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,6 +53,10 @@ class MsgBase:
|
||||
self._class_name = self.__class__.__name__
|
||||
self._input_queue = input_queue
|
||||
self._output_queue = output_queue
|
||||
try:
|
||||
self._logger = getLogger(self._class_name)
|
||||
except RuntimeError:
|
||||
self._logger = None
|
||||
|
||||
|
||||
def _showMsg(
|
||||
@@ -50,11 +69,25 @@ class MsgBase:
|
||||
|
||||
def _showTrace(
|
||||
self,
|
||||
msg: str
|
||||
msg: str,
|
||||
level: int = logging.INFO,
|
||||
no_log: bool = False
|
||||
):
|
||||
|
||||
timestamp = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())
|
||||
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(
|
||||
@@ -67,15 +100,3 @@ class MsgBase:
|
||||
return msg
|
||||
except queue.Empty:
|
||||
return None
|
||||
|
||||
|
||||
def _inputMsg(
|
||||
self,
|
||||
timeout: float = 1.0
|
||||
) -> bool:
|
||||
|
||||
try:
|
||||
self._input_queue.get(timeout=timeout)
|
||||
return True
|
||||
except queue.Empty:
|
||||
return False
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
# -*- 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
|
||||
|
||||
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:
|
||||
|
||||
if not initializeLogManager():
|
||||
return False
|
||||
if not initializeConfigManager():
|
||||
return False
|
||||
if not initializeWebDriverManager():
|
||||
return False
|
||||
return True
|
||||
@@ -0,0 +1,6 @@
|
||||
"""
|
||||
Boot module for the AutoLibrary project.
|
||||
|
||||
Here are the classes and modules in this package:
|
||||
- AppInitializer: Application initializer class.
|
||||
"""
|
||||
+11
-10
@@ -7,11 +7,10 @@ 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 platform
|
||||
|
||||
from PySide6.QtGui import (
|
||||
QIcon
|
||||
QIcon, QFont
|
||||
)
|
||||
from PySide6.QtWidgets import (
|
||||
QDialog, QApplication
|
||||
@@ -23,9 +22,8 @@ from PySide6.QtCore import (
|
||||
from gui.ALVersionInfo import (
|
||||
AL_VERSION, AL_COMMIT_SHA, AL_COMMIT_DATE, AL_BUILD_DATE
|
||||
)
|
||||
from gui.Ui_ALAboutDialog import Ui_ALAboutDialog
|
||||
|
||||
from gui import AutoLibraryResource
|
||||
from gui.resources.ui.Ui_ALAboutDialog import Ui_ALAboutDialog
|
||||
from gui.resources import ALResource
|
||||
|
||||
|
||||
class ALAboutDialog(QDialog, Ui_ALAboutDialog):
|
||||
@@ -47,8 +45,11 @@ class ALAboutDialog(QDialog, Ui_ALAboutDialog):
|
||||
|
||||
self.LogoIconLabel.setPixmap(QIcon(":/res/icon/icons/AutoLibrary_32x32.ico").pixmap(48, 48))
|
||||
info_text = self.generateAboutText()
|
||||
self.AboutInfoEdit.setHtml(info_text)
|
||||
self.AboutInfoEdit.setTextInteractionFlags(Qt.TextBrowserInteraction)
|
||||
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)
|
||||
|
||||
|
||||
def connectSignals(
|
||||
@@ -60,7 +61,7 @@ class ALAboutDialog(QDialog, Ui_ALAboutDialog):
|
||||
|
||||
def generateAboutText(
|
||||
self
|
||||
):
|
||||
) -> str:
|
||||
|
||||
os_info = self.getOSInfo()
|
||||
about_text = f"""
|
||||
@@ -81,7 +82,7 @@ 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.cv/" style="text-decoration: none;">https://www.autolibrary.cv/</a><br>
|
||||
Project website: <a href="https://www.autolibrary.kenanzhu.com" style="text-decoration: none;">https://www.autolibrary.kenanzhu.com</a><br>
|
||||
|
||||
<h4>Author Information:</h4>
|
||||
Developer: KenanZhu<br>
|
||||
@@ -138,7 +139,7 @@ GitHub: <a href="https://www.github.com/KenanZhu" style="text-decoration: none;"
|
||||
self
|
||||
):
|
||||
|
||||
about_text = self.AboutInfoEdit.toPlainText()
|
||||
about_text = self.AboutInfoBrowser.toPlainText()
|
||||
clipboard = QApplication.clipboard()
|
||||
clipboard.setText(about_text)
|
||||
original_text = self.CopyButton.text()
|
||||
|
||||
@@ -1,249 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ui version="4.0">
|
||||
<class>ALAddTimerTaskDialog</class>
|
||||
<widget class="QDialog" name="ALAddTimerTaskDialog">
|
||||
<property name="geometry">
|
||||
<rect>
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>300</width>
|
||||
<height>300</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>0</width>
|
||||
<height>300</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="maximumSize">
|
||||
<size>
|
||||
<width>500</width>
|
||||
<height>300</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="windowTitle">
|
||||
<string>添加定时任务 - AutoLibrary</string>
|
||||
</property>
|
||||
<property name="sizeGripEnabled">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
<property name="modal">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
<layout class="QVBoxLayout" name="ALAddTimerTaskLayout">
|
||||
<property name="spacing">
|
||||
<number>5</number>
|
||||
</property>
|
||||
<property name="leftMargin">
|
||||
<number>5</number>
|
||||
</property>
|
||||
<property name="topMargin">
|
||||
<number>5</number>
|
||||
</property>
|
||||
<property name="rightMargin">
|
||||
<number>5</number>
|
||||
</property>
|
||||
<property name="bottomMargin">
|
||||
<number>5</number>
|
||||
</property>
|
||||
<item>
|
||||
<layout class="QHBoxLayout" name="TaskNameLayout">
|
||||
<property name="spacing">
|
||||
<number>5</number>
|
||||
</property>
|
||||
<item>
|
||||
<widget class="QLabel" name="TaskNameLabel">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>60</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="QLineEdit" name="TaskNameLineEdit"/>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QGroupBox" name="TimerConfigGroupBox">
|
||||
<property name="title">
|
||||
<string>定时设置</string>
|
||||
</property>
|
||||
<layout class="QVBoxLayout" name="TimerConfigLayout">
|
||||
<property name="spacing">
|
||||
<number>5</number>
|
||||
</property>
|
||||
<property name="leftMargin">
|
||||
<number>5</number>
|
||||
</property>
|
||||
<property name="topMargin">
|
||||
<number>5</number>
|
||||
</property>
|
||||
<property name="rightMargin">
|
||||
<number>5</number>
|
||||
</property>
|
||||
<property name="bottomMargin">
|
||||
<number>5</number>
|
||||
</property>
|
||||
<item>
|
||||
<layout class="QHBoxLayout" name="TimerTypeSelectLayout">
|
||||
<property name="spacing">
|
||||
<number>5</number>
|
||||
</property>
|
||||
<item>
|
||||
<widget class="QLabel" name="TimerTypeLabel">
|
||||
<property name="text">
|
||||
<string>定时类型:</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QComboBox" name="TimerTypeComboBox">
|
||||
<item>
|
||||
<property name="text">
|
||||
<string>特定时间</string>
|
||||
</property>
|
||||
</item>
|
||||
<item>
|
||||
<property name="text">
|
||||
<string>相对时间</string>
|
||||
</property>
|
||||
</item>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QGroupBox" name="TaskConfigGroupBox">
|
||||
<property name="title">
|
||||
<string>运行设置</string>
|
||||
</property>
|
||||
<layout class="QGridLayout" name="TaskConfigLayout">
|
||||
<property name="leftMargin">
|
||||
<number>5</number>
|
||||
</property>
|
||||
<property name="topMargin">
|
||||
<number>5</number>
|
||||
</property>
|
||||
<property name="rightMargin">
|
||||
<number>5</number>
|
||||
</property>
|
||||
<property name="bottomMargin">
|
||||
<number>5</number>
|
||||
</property>
|
||||
<property name="spacing">
|
||||
<number>5</number>
|
||||
</property>
|
||||
<item row="0" column="0">
|
||||
<widget class="QRadioButton" name="SilentlyRunRadioButton">
|
||||
<property name="text">
|
||||
<string>静默运行</string>
|
||||
</property>
|
||||
<property name="checkable">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
<property name="checked">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
<property name="autoRepeat">
|
||||
<bool>false</bool>
|
||||
</property>
|
||||
<property name="autoExclusive">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="0">
|
||||
<widget class="QRadioButton" name="ShowBeforeRunRadioButton">
|
||||
<property name="text">
|
||||
<string>运行前提示</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<layout class="QHBoxLayout" name="ControLayout">
|
||||
<property name="spacing">
|
||||
<number>5</number>
|
||||
</property>
|
||||
<item>
|
||||
<widget class="QFrame" name="ControlSpaceFrame">
|
||||
<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="QPushButton" name="CancelButton">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>80</width>
|
||||
<height>25</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="maximumSize">
|
||||
<size>
|
||||
<width>80</width>
|
||||
<height>25</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>取消</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QPushButton" name="ConfirmButton">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>80</width>
|
||||
<height>25</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="maximumSize">
|
||||
<size>
|
||||
<width>80</width>
|
||||
<height>25</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>添加</string>
|
||||
</property>
|
||||
<property name="autoDefault">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
<property name="default">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<resources/>
|
||||
<connections/>
|
||||
</ui>
|
||||
+231
-199
@@ -8,53 +8,48 @@ 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
|
||||
|
||||
from PySide6.QtCore import (
|
||||
Qt, Signal, Slot, QTime, QDate, QDir, QFileInfo
|
||||
)
|
||||
from PySide6.QtWidgets import (
|
||||
QWidget, QLineEdit, QMessageBox, QFileDialog,
|
||||
QDialog, QWidget, QLineEdit, QMessageBox, QFileDialog,
|
||||
QTreeWidgetItem, QMenu, QInputDialog
|
||||
)
|
||||
from PySide6.QtGui import (
|
||||
QCloseEvent, QAction
|
||||
)
|
||||
|
||||
from gui.Ui_ALConfigWidget import Ui_ALConfigWidget
|
||||
from gui.ALSeatMapWidget import ALSeatMapWidget
|
||||
from gui.ALSeatMapTable import seats_maps
|
||||
from gui.ALUserTreeWidget import TreeItemType
|
||||
from gui.ALUserTreeWidget import ALUserTreeWidget
|
||||
import managers.config.ConfigManager as ConfigManager
|
||||
|
||||
from utils.ConfigReader import ConfigReader
|
||||
from utils.ConfigWriter import ConfigWriter
|
||||
from utils.JSONReader import JSONReader
|
||||
from utils.JSONWriter import JSONWriter
|
||||
|
||||
from gui.resources.ui.Ui_ALConfigWidget import Ui_ALConfigWidget
|
||||
from gui.ALSeatMapSelectDialog import ALSeatMapSelectDialog
|
||||
from gui.ALSeatMapTable import ALSeatMapTable
|
||||
from gui.ALUserTreeWidget import ALUserTreeWidget, ALUserTreeItemType
|
||||
from gui.ALWebDriverDownloadDialog import ALWebDriverDownloadDialog
|
||||
|
||||
|
||||
class ALConfigWidget(QWidget, Ui_ALConfigWidget):
|
||||
|
||||
configWidgetCloseSingal = Signal(dict)
|
||||
configWidgetIsClosed = Signal()
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent = None,
|
||||
config_paths = {
|
||||
"run": "",
|
||||
"user": ""
|
||||
}
|
||||
):
|
||||
|
||||
super().__init__(parent)
|
||||
self.__config_paths = config_paths
|
||||
self.__cfg_mgr = ConfigManager.instance()
|
||||
self.__config_paths = ConfigManager.getValidateAutomationConfigPaths()
|
||||
self.__config_data = {"run": {}, "user": {}}
|
||||
self.__seat_map_widget = None
|
||||
|
||||
self.setupUi(self)
|
||||
self.modifyUi()
|
||||
self.connectSignals()
|
||||
self.initlizeFloorRoomMap()
|
||||
self.initlizeDefaultConfigPaths()
|
||||
if not self.initlizeConfigs():
|
||||
if not self.initializeConfigs():
|
||||
self.close()
|
||||
|
||||
|
||||
@@ -70,8 +65,8 @@ class ALConfigWidget(QWidget, Ui_ALConfigWidget):
|
||||
self.UserListLayout.insertWidget(0, self.UserTreeWidget)
|
||||
self.UserTreeWidget.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
|
||||
self.UserTreeWidget.customContextMenuRequested.connect(self.onUserTreeWidgetContextMenu)
|
||||
self.initlizeFloorRoomMap()
|
||||
self.initilizeUserInfoWidget()
|
||||
self.initializeFloorRoomMap()
|
||||
self.initializeUserInfoWidget()
|
||||
|
||||
|
||||
def connectSignals(
|
||||
@@ -86,6 +81,7 @@ class ALConfigWidget(QWidget, Ui_ALConfigWidget):
|
||||
self.AddUserButton.clicked.connect(self.onAddUserButtonClicked)
|
||||
self.DelUserButton.clicked.connect(self.onDelUserButtonClicked)
|
||||
self.BrowseBrowserDriverButton.clicked.connect(self.onBrowseBrowserDriverButtonClicked)
|
||||
self.AutoDownloadWebDriverButton.clicked.connect(self.onAutoDownloadWebDriverButtonClicked)
|
||||
self.BrowseCurrentRunConfigButton.clicked.connect(self.onBrowseCurrentRunConfigButtonClicked)
|
||||
self.BrowseCurrentUserConfigButton.clicked.connect(self.onBrowseCurrentUserConfigButtonClicked)
|
||||
self.BrowseExportRunConfigButton.clicked.connect(self.onBrowseExportRunConfigButtonClicked)
|
||||
@@ -126,11 +122,11 @@ class ALConfigWidget(QWidget, Ui_ALConfigWidget):
|
||||
event: QCloseEvent
|
||||
):
|
||||
|
||||
self.configWidgetCloseSingal.emit(self.__config_paths)
|
||||
self.configWidgetIsClosed.emit()
|
||||
super().closeEvent(event)
|
||||
|
||||
|
||||
def initlizeFloorRoomMap(
|
||||
def initializeFloorRoomMap(
|
||||
self
|
||||
):
|
||||
|
||||
@@ -164,19 +160,7 @@ class ALConfigWidget(QWidget, Ui_ALConfigWidget):
|
||||
}
|
||||
|
||||
|
||||
def initlizeDefaultConfigPaths(
|
||||
self
|
||||
):
|
||||
|
||||
script_path = sys.executable
|
||||
script_dir = QFileInfo(script_path).absoluteDir()
|
||||
self.__default_config_paths = {
|
||||
"user": QDir.toNativeSeparators(script_dir.absoluteFilePath("user.json")),
|
||||
"run": QDir.toNativeSeparators(script_dir.absoluteFilePath("run.json"))
|
||||
}
|
||||
|
||||
|
||||
def initlizeConfigToWidget(
|
||||
def initializeConfigToWidget(
|
||||
self,
|
||||
which: str,
|
||||
config_data: dict
|
||||
@@ -186,23 +170,22 @@ class ALConfigWidget(QWidget, Ui_ALConfigWidget):
|
||||
self.setRunConfigToWidget(config_data)
|
||||
self.CurrentRunConfigEdit.setText(self.__config_paths["run"])
|
||||
elif which == "user":
|
||||
self.initilizeUserInfoWidget()
|
||||
self.fillUserTree(config_data)
|
||||
self.initializeUserInfoWidget()
|
||||
self.setUsersToTreeWidget(config_data)
|
||||
self.CurrentUserConfigEdit.setText(self.__config_paths["user"])
|
||||
|
||||
|
||||
def initlizeConfig(
|
||||
def initializeConfig(
|
||||
self,
|
||||
which: str
|
||||
) -> bool:
|
||||
|
||||
msg = ""
|
||||
msg = "" # no use for now
|
||||
is_success = True
|
||||
if which == "run":
|
||||
run_config_path = self.__config_paths[which]
|
||||
if not os.path.exists(run_config_path):
|
||||
self.__config_data[which] = self.defaultRunConfig()
|
||||
self.__config_paths[which] = self.__default_config_paths[which]
|
||||
if self.saveRunConfig(self.__config_paths[which], self.__config_data[which]):
|
||||
msg += f"运行配置文件已初始化, 文件路径: \n{self.__config_paths[which]}\n"
|
||||
else:
|
||||
@@ -215,7 +198,6 @@ class ALConfigWidget(QWidget, Ui_ALConfigWidget):
|
||||
user_config_path = self.__config_paths[which]
|
||||
if not os.path.exists(user_config_path):
|
||||
self.__config_data[which] = self.defaultUserConfig()
|
||||
self.__config_paths[which] = self.__default_config_paths[which]
|
||||
if self.saveUserConfig(self.__config_paths[which], self.__config_data[which]):
|
||||
msg += f"用户配置文件已初始化, 文件路径: \n{self.__config_paths[which]}\n"
|
||||
else:
|
||||
@@ -224,27 +206,19 @@ class ALConfigWidget(QWidget, Ui_ALConfigWidget):
|
||||
self.__config_data[which] = self.loadUserConfig(user_config_path)
|
||||
if self.__config_data[which] is None:
|
||||
is_success = False
|
||||
if msg:
|
||||
QMessageBox.information(
|
||||
self,
|
||||
"提示 - AutoLibrary",
|
||||
f"配置文件初始化完成: \n{msg}"
|
||||
)
|
||||
return is_success
|
||||
|
||||
|
||||
def initlizeConfigs(
|
||||
def initializeConfigs(
|
||||
self
|
||||
) -> bool:
|
||||
|
||||
is_success = True
|
||||
for which in ["run", "user"]:
|
||||
if not self.__config_paths[which]:
|
||||
self.__config_paths[which] = self.__default_config_paths[which]
|
||||
if not self.initlizeConfig(which):
|
||||
if not self.initializeConfig(which):
|
||||
is_success = False
|
||||
break
|
||||
self.initlizeConfigToWidget(which, self.__config_data[which])
|
||||
self.initializeConfigToWidget(which, self.__config_data[which])
|
||||
return is_success
|
||||
|
||||
|
||||
@@ -277,27 +251,8 @@ class ALConfigWidget(QWidget, Ui_ALConfigWidget):
|
||||
) -> dict:
|
||||
|
||||
return {
|
||||
"groups": []
|
||||
}
|
||||
|
||||
|
||||
def defaultGroup(
|
||||
self
|
||||
) -> dict:
|
||||
|
||||
return {
|
||||
"name": "默认分组",
|
||||
"enabled": True,
|
||||
"users": []
|
||||
}
|
||||
|
||||
|
||||
def defaultUsers(
|
||||
self
|
||||
) -> dict:
|
||||
|
||||
return {
|
||||
"users": []
|
||||
"groups": [
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -328,24 +283,41 @@ class ALConfigWidget(QWidget, Ui_ALConfigWidget):
|
||||
run_config: dict
|
||||
):
|
||||
|
||||
self.HostUrlEdit.setText(run_config["library"]["host_url"])
|
||||
self.LoginUrlEdit.setText(run_config["library"]["login_url"])
|
||||
self.AutoCaptchaCheckBox.setChecked(run_config["login"]["auto_captcha"])
|
||||
self.LoginAttemptSpinBox.setValue(run_config["login"]["max_attempt"])
|
||||
self.BrowserTypeComboBox.setCurrentText(run_config["web_driver"]["driver_type"])
|
||||
if run_config["web_driver"]["driver_path"]:
|
||||
driver_path = os.path.abspath(run_config["web_driver"]["driver_path"])
|
||||
else:
|
||||
driver_path = ""
|
||||
self.BrowseBrowserDriverEdit.setText(QDir.toNativeSeparators(driver_path))
|
||||
self.HeadlessCheckBox.setChecked(run_config["web_driver"]["headless"])
|
||||
run_mode = run_config["mode"]["run_mode"]
|
||||
self.AutoReserveCheckBox.setChecked(run_mode&0x01)
|
||||
self.AutoCheckinCheckBox.setChecked(run_mode&0x02)
|
||||
self.AutoRenewalCheckBox.setChecked(run_mode&0x04)
|
||||
try:
|
||||
self.HostUrlEdit.setText(run_config["library"]["host_url"])
|
||||
self.LoginUrlEdit.setText(run_config["library"]["login_url"])
|
||||
self.AutoCaptchaCheckBox.setChecked(run_config["login"]["auto_captcha"])
|
||||
self.LoginAttemptSpinBox.setValue(run_config["login"]["max_attempt"])
|
||||
self.BrowserTypeComboBox.setCurrentText(run_config["web_driver"]["driver_type"])
|
||||
if run_config["web_driver"]["driver_path"]:
|
||||
driver_path = os.path.abspath(run_config["web_driver"]["driver_path"])
|
||||
else:
|
||||
driver_path = ""
|
||||
self.BrowseBrowserDriverEdit.setText(QDir.toNativeSeparators(driver_path))
|
||||
self.HeadlessCheckBox.setChecked(run_config["web_driver"]["headless"])
|
||||
run_mode = run_config["mode"]["run_mode"]
|
||||
self.AutoReserveCheckBox.setChecked(run_mode&0x01)
|
||||
self.AutoCheckinCheckBox.setChecked(run_mode&0x02)
|
||||
self.AutoRenewalCheckBox.setChecked(run_mode&0x04)
|
||||
except KeyError as e:
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
"警告 - AutoLibrary",
|
||||
f"运行配置文件读取键 '{e}' 时发生错误 ! :\n"
|
||||
f"文件路径: {self.__config_paths['run']}\n"
|
||||
"文件可能被意外修改或已经损坏\n"
|
||||
)
|
||||
except Exception as e:
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
"警告 - AutoLibrary",
|
||||
f"运行配置文件读取键 '{e}' 时发生未知错误 ! :\n"
|
||||
f"文件路径: {self.__config_paths['run']}\n"
|
||||
"文件可能被意外修改或已经损坏\n"
|
||||
)
|
||||
|
||||
|
||||
def initilizeUserInfoWidget(
|
||||
def initializeUserInfoWidget(
|
||||
self
|
||||
):
|
||||
|
||||
@@ -370,7 +342,7 @@ class ALConfigWidget(QWidget, Ui_ALConfigWidget):
|
||||
self.PreferLateRenewTimeCheckBox.setChecked(False)
|
||||
|
||||
|
||||
def collectUserFromUserInfoWidget(
|
||||
def collectUserFromWidget(
|
||||
self
|
||||
) -> dict:
|
||||
|
||||
@@ -403,7 +375,7 @@ class ALConfigWidget(QWidget, Ui_ALConfigWidget):
|
||||
return user
|
||||
|
||||
|
||||
def collectUserConfigFromUserTreeWidget(
|
||||
def collectUsersFromTreeWidget(
|
||||
self
|
||||
) -> dict:
|
||||
|
||||
@@ -450,13 +422,64 @@ class ALConfigWidget(QWidget, Ui_ALConfigWidget):
|
||||
self.ExpectRenewDurationSpinBox.setValue(user["reserve_info"]["renew_time"]["expect_duration"])
|
||||
self.MaxRenewTimeDiffSpinBox.setValue(user["reserve_info"]["renew_time"]["max_diff"])
|
||||
self.PreferLateRenewTimeCheckBox.setChecked(not user["reserve_info"]["renew_time"]["prefer_early"])
|
||||
except:
|
||||
except KeyError as e:
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
"警告 - AutoLibrary",
|
||||
"用户配置文件读取发生错误 !\n"\
|
||||
f"用户: {user['username']} 配置文件可能已损坏"
|
||||
f"用户配置文件读取键 '{e}' 时发生错误 ! :\n"
|
||||
f"文件路径: {self.__config_paths['user']}\n"
|
||||
"文件可能被意外修改或已经损坏\n"
|
||||
)
|
||||
except Exception as e:
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
"警告 - AutoLibrary",
|
||||
f"用户配置文件读取键 '{e}' 时发生未知错误 ! :\n"
|
||||
f"文件路径: {self.__config_paths['user']}\n"
|
||||
"文件可能被意外修改或已经损坏\n"
|
||||
)
|
||||
|
||||
|
||||
def setUsersToTreeWidget(
|
||||
self,
|
||||
users: dict
|
||||
):
|
||||
|
||||
self.UserTreeWidget.clear()
|
||||
self.UserTreeWidget.itemChanged.disconnect(self.onUserTreeWidgetItemChanged)
|
||||
try:
|
||||
if "groups" in users:
|
||||
for group_config in users["groups"]:
|
||||
group_item = QTreeWidgetItem(self.UserTreeWidget, ALUserTreeItemType.GROUP.value)
|
||||
group_item.setText(0, group_config["name"])
|
||||
group_item.setFlags(group_item.flags() | Qt.ItemIsEditable)
|
||||
group_item.setCheckState(1, Qt.Checked if group_config.get("enabled", True) else Qt.Unchecked)
|
||||
for user_config in group_config["users"]:
|
||||
user_item = QTreeWidgetItem(group_item, ALUserTreeItemType.USER.value)
|
||||
user_item.setText(0, user_config["username"])
|
||||
user_item.setText(1, "" if user_config.get("enabled", True) else "跳过")
|
||||
user_item.setData(0, Qt.UserRole, user_config)
|
||||
user_item.setCheckState(1, Qt.Checked if user_config.get("enabled", True) else Qt.Unchecked)
|
||||
user_item.setDisabled(not group_config.get("enabled", True))
|
||||
group_item.setExpanded(True)
|
||||
except KeyError as e:
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
"警告 - AutoLibrary",
|
||||
f"用户配置文件读取键 '{e}' 时发生错误 ! :\n"
|
||||
f"文件路径: {self.__config_paths['user']}\n"
|
||||
"文件可能被意外修改或已经损坏\n"
|
||||
)
|
||||
except Exception as e:
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
"警告 - AutoLibrary",
|
||||
f"用户配置文件读取键 '{e}' 时发生未知错误 ! :\n"
|
||||
f"文件路径: {self.__config_paths['user']}\n"
|
||||
"文件可能被意外修改或已经损坏\n"
|
||||
)
|
||||
finally:
|
||||
self.UserTreeWidget.itemChanged.connect(self.onUserTreeWidgetItemChanged)
|
||||
|
||||
|
||||
def loadRunConfig(
|
||||
@@ -467,18 +490,18 @@ class ALConfigWidget(QWidget, Ui_ALConfigWidget):
|
||||
try:
|
||||
if not run_config_path or not os.path.exists(run_config_path):
|
||||
raise Exception("文件路径不存在")
|
||||
run_config = ConfigReader(run_config_path).getConfigs()
|
||||
run_config = JSONReader(run_config_path).data()
|
||||
if run_config and "library" in run_config\
|
||||
and "web_driver" in run_config\
|
||||
and "login" in run_config:
|
||||
return run_config
|
||||
return None
|
||||
else:
|
||||
return None
|
||||
except Exception as e:
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
"警告 - AutoLibrary",
|
||||
f"运行配置文件读取发生错误 ! : {e}\n"\
|
||||
f"文件路径: {run_config_path}"
|
||||
f"运行配置文件读取发生错误 ! :\n{e}"
|
||||
)
|
||||
return None
|
||||
|
||||
@@ -494,14 +517,13 @@ class ALConfigWidget(QWidget, Ui_ALConfigWidget):
|
||||
raise Exception("文件路径为空")
|
||||
if not run_config_data or not isinstance(run_config_data, dict):
|
||||
raise Exception("运行配置数据为空或类型错误")
|
||||
ConfigWriter(run_config_path, run_config_data)
|
||||
JSONWriter(run_config_path, run_config_data)
|
||||
return True
|
||||
except Exception as e:
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
"警告 - AutoLibrary",
|
||||
f"配置文件写入发生错误 ! : {e}\n"\
|
||||
f"文件路径: {run_config_path}"
|
||||
f"配置文件写入发生错误 ! : \n{e}"
|
||||
)
|
||||
return False
|
||||
|
||||
@@ -514,11 +536,11 @@ class ALConfigWidget(QWidget, Ui_ALConfigWidget):
|
||||
try:
|
||||
if not user_config_path or not os.path.exists(user_config_path):
|
||||
raise Exception("文件路径不存在")
|
||||
user_config = ConfigReader(user_config_path).getConfigs()
|
||||
user_config = JSONReader(user_config_path).data()
|
||||
if user_config and "groups" in user_config:
|
||||
return user_config
|
||||
# compatibility with old version config format
|
||||
if user_config and "users" in user_config:
|
||||
elif user_config and "users" in user_config:
|
||||
user_config = {
|
||||
"groups": [
|
||||
{
|
||||
@@ -529,13 +551,13 @@ class ALConfigWidget(QWidget, Ui_ALConfigWidget):
|
||||
]
|
||||
}
|
||||
return user_config
|
||||
return None
|
||||
else:
|
||||
return None
|
||||
except Exception as e:
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
"警告 - AutoLibrary",
|
||||
f"用户配置文件读取发生错误 ! : {e}\n"\
|
||||
f"文件路径: {user_config_path}"
|
||||
f"用户配置文件读取发生错误 ! :\n{e}"
|
||||
)
|
||||
return None
|
||||
|
||||
@@ -551,14 +573,13 @@ class ALConfigWidget(QWidget, Ui_ALConfigWidget):
|
||||
raise Exception("文件路径为空")
|
||||
if not user_config_data or not isinstance(user_config_data, dict):
|
||||
raise Exception("用户配置数据为空或类型错误")
|
||||
ConfigWriter(user_config_path, user_config_data)
|
||||
JSONWriter(user_config_path, user_config_data)
|
||||
return True
|
||||
except Exception as e:
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
"警告 - AutoLibrary",
|
||||
f"用户配置文件写入发生错误 ! : {e}\n"\
|
||||
f"文件路径: \n{user_config_path}"
|
||||
f"用户配置文件写入发生错误 ! :\n{e}"
|
||||
)
|
||||
return False
|
||||
|
||||
@@ -570,7 +591,7 @@ class ALConfigWidget(QWidget, Ui_ALConfigWidget):
|
||||
) -> bool:
|
||||
|
||||
if user_config_path:
|
||||
self.__config_data["user"] = self.collectUserConfigFromUserTreeWidget()
|
||||
self.__config_data["user"] = self.collectUsersFromTreeWidget()
|
||||
if not self.saveUserConfig(
|
||||
user_config_path,
|
||||
self.__config_data["user"]
|
||||
@@ -609,45 +630,19 @@ class ALConfigWidget(QWidget, Ui_ALConfigWidget):
|
||||
return True
|
||||
if user_config is not None:
|
||||
self.__config_data["user"].update(user_config)
|
||||
self.fillUserTree(self.__config_data["user"])
|
||||
self.setUsersToTreeWidget(self.__config_data["user"])
|
||||
return True
|
||||
except:
|
||||
return False
|
||||
|
||||
|
||||
def fillUserTree(
|
||||
self,
|
||||
user_config_data: dict
|
||||
):
|
||||
|
||||
self.UserTreeWidget.clear()
|
||||
self.UserTreeWidget.itemChanged.disconnect(self.onUserTreeWidgetItemChanged)
|
||||
try:
|
||||
if "groups" in user_config_data:
|
||||
for group_config in user_config_data["groups"]:
|
||||
group_item = QTreeWidgetItem(self.UserTreeWidget, TreeItemType.GROUP.value)
|
||||
group_item.setText(0, group_config["name"])
|
||||
group_item.setFlags(group_item.flags() | Qt.ItemIsEditable)
|
||||
group_item.setCheckState(1, Qt.Checked if group_config.get("enabled", True) else Qt.Unchecked)
|
||||
for user_config in group_config["users"]:
|
||||
user_item = QTreeWidgetItem(group_item, TreeItemType.USER.value)
|
||||
user_item.setText(0, user_config["username"])
|
||||
user_item.setText(1, "" if user_config.get("enabled", True) else "跳过")
|
||||
user_item.setData(0, Qt.UserRole, user_config)
|
||||
user_item.setCheckState(1, Qt.Checked if user_config.get("enabled", True) else Qt.Unchecked)
|
||||
user_item.setDisabled(not group_config.get("enabled", True))
|
||||
group_item.setExpanded(True)
|
||||
finally:
|
||||
self.UserTreeWidget.itemChanged.connect(self.onUserTreeWidgetItemChanged)
|
||||
|
||||
|
||||
def addGroup(
|
||||
self,
|
||||
group_name: str = ""
|
||||
) -> QTreeWidgetItem:
|
||||
|
||||
self.UserTreeWidget.itemChanged.disconnect(self.onUserTreeWidgetItemChanged)
|
||||
group_item = QTreeWidgetItem(self.UserTreeWidget, TreeItemType.GROUP.value)
|
||||
group_item = QTreeWidgetItem(self.UserTreeWidget, ALUserTreeItemType.GROUP.value)
|
||||
if not group_name:
|
||||
group_name = f"新分组-{self.UserTreeWidget.topLevelItemCount()}"
|
||||
group_item.setText(0, group_name)
|
||||
@@ -658,6 +653,19 @@ class ALConfigWidget(QWidget, Ui_ALConfigWidget):
|
||||
return group_item
|
||||
|
||||
|
||||
def delGroup(
|
||||
self,
|
||||
group_item: QTreeWidgetItem = None
|
||||
):
|
||||
|
||||
if group_item is None:
|
||||
return
|
||||
if group_item.type() != ALUserTreeItemType.GROUP.value:
|
||||
return
|
||||
index = self.UserTreeWidget.indexOfTopLevelItem(group_item)
|
||||
self.UserTreeWidget.takeTopLevelItem(index)
|
||||
|
||||
|
||||
def addUser(
|
||||
self,
|
||||
group_item: QTreeWidgetItem = None
|
||||
@@ -667,7 +675,7 @@ class ALConfigWidget(QWidget, Ui_ALConfigWidget):
|
||||
current_item = self.UserTreeWidget.currentItem()
|
||||
if current_item is None:
|
||||
group_item = self.addGroup()
|
||||
if group_item.type() == TreeItemType.USER.value:
|
||||
if group_item.type() == ALUserTreeItemType.USER.value:
|
||||
group_item = group_item.parent()
|
||||
if group_item.checkState(1) == Qt.CheckState.Unchecked:
|
||||
return None
|
||||
@@ -701,7 +709,7 @@ class ALConfigWidget(QWidget, Ui_ALConfigWidget):
|
||||
}
|
||||
}
|
||||
self.UserTreeWidget.itemChanged.disconnect(self.onUserTreeWidgetItemChanged)
|
||||
user_item = QTreeWidgetItem(group_item, TreeItemType.USER.value)
|
||||
user_item = QTreeWidgetItem(group_item, ALUserTreeItemType.USER.value)
|
||||
user_item.setText(0, new_user["username"])
|
||||
user_item.setText(1, "")
|
||||
user_item.setData(0, Qt.UserRole, new_user)
|
||||
@@ -720,7 +728,7 @@ class ALConfigWidget(QWidget, Ui_ALConfigWidget):
|
||||
|
||||
if user_item is None:
|
||||
return
|
||||
if user_item.type() != TreeItemType.USER.value:
|
||||
if user_item.type() != ALUserTreeItemType.USER.value:
|
||||
return
|
||||
parent_item = user_item.parent()
|
||||
index = parent_item.indexOfChild(user_item)
|
||||
@@ -729,19 +737,6 @@ class ALConfigWidget(QWidget, Ui_ALConfigWidget):
|
||||
self.UserTreeWidget.setCurrentItem(None)
|
||||
|
||||
|
||||
def delGroup(
|
||||
self,
|
||||
group_item: QTreeWidgetItem = None
|
||||
):
|
||||
|
||||
if group_item is None:
|
||||
return
|
||||
if group_item.type() != TreeItemType.GROUP.value:
|
||||
return
|
||||
index = self.UserTreeWidget.indexOfTopLevelItem(group_item)
|
||||
self.UserTreeWidget.takeTopLevelItem(index)
|
||||
|
||||
|
||||
def renameItem(
|
||||
self,
|
||||
item: QTreeWidgetItem,
|
||||
@@ -761,7 +756,7 @@ class ALConfigWidget(QWidget, Ui_ALConfigWidget):
|
||||
if not ok or not new_name:
|
||||
return
|
||||
item.setText(0, new_name)
|
||||
if item.type() == TreeItemType.GROUP.value:
|
||||
if item.type() == ALUserTreeItemType.GROUP.value:
|
||||
item.setText(0, new_name)
|
||||
else:
|
||||
user = item.data(0, Qt.UserRole)
|
||||
@@ -770,7 +765,6 @@ class ALConfigWidget(QWidget, Ui_ALConfigWidget):
|
||||
item.setData(0, Qt.UserRole, user)
|
||||
self.setUserToWidget(user)
|
||||
|
||||
|
||||
@Slot()
|
||||
def onShowPasswordCheckBoxChecked(
|
||||
self,
|
||||
@@ -792,20 +786,6 @@ class ALConfigWidget(QWidget, Ui_ALConfigWidget):
|
||||
self.RoomComboBox.addItems(self.__floor_room_map[floor])
|
||||
self.RoomComboBox.setCurrentIndex(0)
|
||||
|
||||
@Slot()
|
||||
def onSeatMapWidgetClosed(
|
||||
self,
|
||||
selected_seats: list[str]
|
||||
):
|
||||
|
||||
self.__seat_map_widget.seatMapWidgetClosed.disconnect(self.onSeatMapWidgetClosed)
|
||||
self.__seat_map_widget.deleteLater()
|
||||
self.__seat_map_widget = None
|
||||
if len(selected_seats) == 0:
|
||||
self.SeatIDEdit.clear() # no selected seat, we clear the edit
|
||||
return
|
||||
self.SeatIDEdit.setText(",".join(selected_seats))
|
||||
|
||||
@Slot()
|
||||
def onSelectSeatsButtonClicked(
|
||||
self
|
||||
@@ -815,18 +795,19 @@ class ALConfigWidget(QWidget, Ui_ALConfigWidget):
|
||||
room = self.RoomComboBox.currentText()
|
||||
floor_idx = self.__floor_rmap[floor]
|
||||
room_idx = self.__room_rmap[room]
|
||||
if self.__seat_map_widget is None:
|
||||
self.__seat_map_widget = ALSeatMapWidget(
|
||||
self,
|
||||
floor,
|
||||
room,
|
||||
seats_maps[floor_idx][room_idx]
|
||||
)
|
||||
self.__seat_map_widget.seatMapWidgetClosed.connect(self.onSeatMapWidgetClosed)
|
||||
self.__seat_map_widget.show()
|
||||
self.__seat_map_widget.raise_()
|
||||
self.__seat_map_widget.activateWindow()
|
||||
self.__seat_map_widget.selectSeats(self.SeatIDEdit.text().split(","))
|
||||
dialog = ALSeatMapSelectDialog(
|
||||
self,
|
||||
floor,
|
||||
room,
|
||||
ALSeatMapTable[floor_idx][room_idx]
|
||||
)
|
||||
dialog.selectSeats(self.SeatIDEdit.text().split(","))
|
||||
if dialog.exec() == QDialog.DialogCode.Accepted:
|
||||
selected_seats = dialog.getSelectedSeats()
|
||||
if len(selected_seats) == 0:
|
||||
self.SeatIDEdit.clear()
|
||||
return
|
||||
self.SeatIDEdit.setText(",".join(dialog.getSelectedSeats()))
|
||||
|
||||
@Slot()
|
||||
def onUserTreeWidgetCurrentItemChanged(
|
||||
@@ -838,8 +819,8 @@ class ALConfigWidget(QWidget, Ui_ALConfigWidget):
|
||||
# cant effectively update the data of each user, due to the
|
||||
# possiblity of frequency edit. we just let the QListWidget
|
||||
# help us.
|
||||
if previous and previous.type() == TreeItemType.USER.value:
|
||||
user = self.collectUserFromUserInfoWidget()
|
||||
if previous and previous.type() == ALUserTreeItemType.USER.value:
|
||||
user = self.collectUserFromWidget()
|
||||
if user:
|
||||
self.UsernameEdit.textEdited.disconnect()
|
||||
user["enabled"] = previous.checkState(1) == Qt.Checked
|
||||
@@ -847,15 +828,15 @@ class ALConfigWidget(QWidget, Ui_ALConfigWidget):
|
||||
previous.setText(1, "" if user.get("enabled", True) else "跳过")
|
||||
previous.setData(0, Qt.UserRole, user)
|
||||
if current is None:
|
||||
self.initilizeUserInfoWidget()
|
||||
self.initializeUserInfoWidget()
|
||||
return
|
||||
if current.type() == TreeItemType.USER.value:
|
||||
if current.type() == ALUserTreeItemType.USER.value:
|
||||
user = current.data(0, Qt.UserRole)
|
||||
if user:
|
||||
self.setUserToWidget(user)
|
||||
self.UsernameEdit.textEdited.connect(lambda text: current.setText(0, text))
|
||||
else:
|
||||
self.initilizeUserInfoWidget()
|
||||
self.initializeUserInfoWidget()
|
||||
|
||||
@Slot()
|
||||
def onUserTreeWidgetItemChanged(
|
||||
@@ -868,7 +849,7 @@ class ALConfigWidget(QWidget, Ui_ALConfigWidget):
|
||||
return
|
||||
if column != 1:
|
||||
return
|
||||
if item.type() == TreeItemType.GROUP.value:
|
||||
if item.type() == ALUserTreeItemType.GROUP.value:
|
||||
is_checked = item.checkState(1) == Qt.CheckState.Checked
|
||||
for i in range(item.childCount()):
|
||||
child = item.child(i)
|
||||
@@ -933,7 +914,7 @@ class ALConfigWidget(QWidget, Ui_ALConfigWidget):
|
||||
menu = QMenu(self.UserTreeWidget)
|
||||
if current_item is None:
|
||||
self.showTreeMenu(menu)
|
||||
elif current_item.type() == TreeItemType.GROUP.value:
|
||||
elif current_item.type() == ALUserTreeItemType.GROUP.value:
|
||||
self.showGroupMenu(menu, current_item)
|
||||
else:
|
||||
self.showUserMenu(menu, current_item)
|
||||
@@ -969,6 +950,21 @@ class ALConfigWidget(QWidget, Ui_ALConfigWidget):
|
||||
if browser_driver_path:
|
||||
self.BrowseBrowserDriverEdit.setText(QDir.toNativeSeparators(browser_driver_path))
|
||||
|
||||
|
||||
@Slot()
|
||||
def onAutoDownloadWebDriverButtonClicked(
|
||||
self
|
||||
):
|
||||
|
||||
dialog = ALWebDriverDownloadDialog(self)
|
||||
dialog.show()
|
||||
dialog.exec_()
|
||||
selected_driver_info = dialog.getSelectedDriverInfo()
|
||||
if selected_driver_info and selected_driver_info.driver_path:
|
||||
self.BrowserTypeComboBox.setCurrentText(selected_driver_info.driver_type.value)
|
||||
self.BrowseBrowserDriverEdit.setText(QDir.toNativeSeparators(str(selected_driver_info.driver_path)))
|
||||
|
||||
|
||||
@Slot()
|
||||
def onBrowseCurrentRunConfigButtonClicked(
|
||||
self
|
||||
@@ -982,9 +978,27 @@ class ALConfigWidget(QWidget, Ui_ALConfigWidget):
|
||||
)[0]
|
||||
if run_config_path:
|
||||
run_config_path = QDir.toNativeSeparators(run_config_path)
|
||||
if self.loadConfig(run_config_path):
|
||||
data = self.loadRunConfig(run_config_path)
|
||||
if data is not None:
|
||||
self.__config_data["run"].update(data)
|
||||
self.setRunConfigToWidget(data)
|
||||
self.__config_paths["run"] = run_config_path
|
||||
self.CurrentRunConfigEdit.setText(run_config_path)
|
||||
paths = self.__cfg_mgr.get(ConfigManager.ConfigType.GLOBAL, "automation.run_path.paths", [])
|
||||
if run_config_path not in paths:
|
||||
paths.append(run_config_path)
|
||||
index = len(paths) - 1
|
||||
else:
|
||||
index = paths.index(run_config_path)
|
||||
self.__cfg_mgr.set(ConfigManager.ConfigType.GLOBAL, "automation.run_path", {"current": index, "paths": paths})
|
||||
else:
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
"警告 - AutoLibrary",
|
||||
"运行配置文件读取发生错误 ! :\n"\
|
||||
"无法从选择的运行配置文件中加载数据 ! :\n"\
|
||||
"可能选择了错误的配置文件类型"
|
||||
)
|
||||
|
||||
@Slot()
|
||||
def onBrowseCurrentUserConfigButtonClicked(
|
||||
@@ -999,9 +1013,27 @@ class ALConfigWidget(QWidget, Ui_ALConfigWidget):
|
||||
)[0]
|
||||
if user_config_path:
|
||||
user_config_path = QDir.toNativeSeparators(user_config_path)
|
||||
if self.loadConfig(user_config_path):
|
||||
data = self.loadUserConfig(user_config_path)
|
||||
if data is not None:
|
||||
self.__config_data["user"].update(data)
|
||||
self.setUsersToTreeWidget(data)
|
||||
self.__config_paths["user"] = user_config_path
|
||||
self.CurrentUserConfigEdit.setText(user_config_path)
|
||||
paths = self.__cfg_mgr.get(ConfigManager.ConfigType.GLOBAL, "automation.user_path.paths", [])
|
||||
if user_config_path not in paths:
|
||||
paths.append(user_config_path)
|
||||
index = len(paths) - 1
|
||||
else:
|
||||
index = paths.index(user_config_path)
|
||||
self.__cfg_mgr.set(ConfigManager.ConfigType.GLOBAL, "automation.user_path", {"current": index, "paths": paths})
|
||||
else:
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
"警告 - AutoLibrary",
|
||||
"用户配置文件读取发生错误 ! :\n"\
|
||||
"无法从选择的用户配置文件中加载数据 ! :\n"\
|
||||
"可能选择了错误的配置文件类型"
|
||||
)
|
||||
|
||||
@Slot()
|
||||
def onBrowseExportRunConfigButtonClicked(
|
||||
@@ -1088,9 +1120,9 @@ class ALConfigWidget(QWidget, Ui_ALConfigWidget):
|
||||
if run_exists or user_exists:
|
||||
exist_files = []
|
||||
if run_exists:
|
||||
exist_files.append(run_config_path)
|
||||
exist_files.append(f"运行配置文件: \n{run_config_path}")
|
||||
if user_exists:
|
||||
exist_files.append(user_config_path)
|
||||
exist_files.append(f"用户配置文件: \n{user_config_path}")
|
||||
reply = QMessageBox.information(
|
||||
self,
|
||||
"提示 - AutoLibrary",
|
||||
@@ -1106,8 +1138,8 @@ class ALConfigWidget(QWidget, Ui_ALConfigWidget):
|
||||
"run": run_config_path,
|
||||
"user": user_config_path
|
||||
}
|
||||
self.initlizeConfigToWidget("run", self.__config_data["run"])
|
||||
self.initlizeConfigToWidget("user", self.__config_data["user"])
|
||||
self.initializeConfigToWidget("run", self.__config_data["run"])
|
||||
self.initializeConfigToWidget("user", self.__config_data["user"])
|
||||
|
||||
@Slot()
|
||||
def onConfirmButtonClicked(
|
||||
@@ -1115,7 +1147,7 @@ class ALConfigWidget(QWidget, Ui_ALConfigWidget):
|
||||
):
|
||||
|
||||
current_item = self.UserTreeWidget.currentItem()
|
||||
if current_item and current_item.type() == TreeItemType.USER.value:
|
||||
if current_item and current_item.type() == ALUserTreeItemType.USER.value:
|
||||
self.UserTreeWidget.setCurrentItem(None)
|
||||
if self.saveConfigs(
|
||||
self.__config_paths["run"],
|
||||
@@ -1124,7 +1156,7 @@ class ALConfigWidget(QWidget, Ui_ALConfigWidget):
|
||||
QMessageBox.information(
|
||||
self,
|
||||
"提示 - AutoLibrary",
|
||||
"配置文件保存成功 !\n"
|
||||
"配置文件保存成功 ! :\n"
|
||||
f"运行配置文件路径: \n{self.__config_paths['run']}\n"\
|
||||
f"用户配置文件路径: \n{self.__config_paths['user']}"
|
||||
)
|
||||
@@ -1132,7 +1164,7 @@ class ALConfigWidget(QWidget, Ui_ALConfigWidget):
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
"警告 - AutoLibrary",
|
||||
"配置文件保存失败, 请检查文件路径权限"
|
||||
"配置文件保存失败 !\n"
|
||||
)
|
||||
self.close()
|
||||
|
||||
|
||||
+84
-91
@@ -7,31 +7,33 @@ 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,
|
||||
Qt, Signal, Slot, QTimer, QUrl,
|
||||
)
|
||||
from PySide6.QtWidgets import (
|
||||
QMainWindow, QMenu, QSystemTrayIcon
|
||||
QMainWindow, QMenu, QSystemTrayIcon, QMessageBox
|
||||
)
|
||||
from PySide6.QtGui import (
|
||||
QTextCursor, QCloseEvent, QFont, QIcon, QDesktopServices
|
||||
)
|
||||
|
||||
from gui.Ui_ALMainWindow import Ui_ALMainWindow
|
||||
import managers.config.ConfigManager as ConfigManager
|
||||
|
||||
from base.MsgBase import MsgBase
|
||||
|
||||
from gui.resources.ui.Ui_ALMainWindow import Ui_ALMainWindow
|
||||
from gui.resources import ALResource
|
||||
from gui.ALConfigWidget import ALConfigWidget
|
||||
from gui.ALTimerTaskWidget import ALTimerTaskWidget
|
||||
from gui.ALTimerTaskManageWidget import ALTimerTaskManageWidget
|
||||
from gui.ALAboutDialog import ALAboutDialog
|
||||
from gui.ALMainWorkers import TimerTaskWorker, AutoLibWorker
|
||||
|
||||
from gui import AutoLibraryResource
|
||||
|
||||
class ALMainWindow(MsgBase, QMainWindow, Ui_ALMainWindow):
|
||||
|
||||
class ALMainWindow(QMainWindow, Ui_ALMainWindow):
|
||||
|
||||
# signal : timer task
|
||||
timerTaskIsRunning = Signal(dict)
|
||||
timerTaskIsExecuted = Signal(dict)
|
||||
timerTaskIsError = Signal(dict)
|
||||
@@ -40,19 +42,12 @@ class ALMainWindow(QMainWindow, Ui_ALMainWindow):
|
||||
self
|
||||
):
|
||||
|
||||
super().__init__()
|
||||
self.__class_name = self.__class__.__name__
|
||||
self.__input_queue = queue.Queue()
|
||||
self.__output_queue = queue.Queue()
|
||||
MsgBase.__init__(self, queue.Queue(), queue.Queue())
|
||||
QMainWindow.__init__(self)
|
||||
self.__cfg_mgr = ConfigManager.instance()
|
||||
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 = ConfigManager.getValidateAutomationConfigPaths()
|
||||
self.__alTimerTaskManageWidget = None
|
||||
self.__alConfigWidget = None
|
||||
self.__auto_lib_thread = None
|
||||
self.__current_timer_task_thread = None
|
||||
@@ -64,6 +59,7 @@ class ALMainWindow(QMainWindow, Ui_ALMainWindow):
|
||||
self.connectSignals()
|
||||
self.startMsgPolling()
|
||||
self.startTimerTaskPolling()
|
||||
self._showLog("主窗口初始化完成")
|
||||
|
||||
|
||||
def modifyUi(
|
||||
@@ -77,13 +73,24 @@ class ALMainWindow(QMainWindow, Ui_ALMainWindow):
|
||||
self.AboutAction.triggered.connect(self.onAboutActionTriggered)
|
||||
|
||||
# initialize timer task widget, but not show it
|
||||
self.__alTimerTaskWidget = ALTimerTaskWidget(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.timerTaskWidgetClosed.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(
|
||||
@@ -98,7 +105,7 @@ class ALMainWindow(QMainWindow, Ui_ALMainWindow):
|
||||
self
|
||||
):
|
||||
|
||||
url = QUrl("https://www.autolibrary.cv/docs/manual_lists.html")
|
||||
url = QUrl("https://www.autolibrary.kenanzhu.com/manuals")
|
||||
QDesktopServices.openUrl(url)
|
||||
|
||||
|
||||
@@ -107,22 +114,19 @@ class ALMainWindow(QMainWindow, Ui_ALMainWindow):
|
||||
):
|
||||
|
||||
if not QSystemTrayIcon.isSystemTrayAvailable():
|
||||
self.showTraceSignal.emit(
|
||||
"系统不支持系统托盘功能, 无法创建系统托盘图标。"
|
||||
)
|
||||
self._showTrace("操作系统不支持系统托盘功能, 无法创建系统托盘图标", self.TraceLevel.WARNING)
|
||||
return
|
||||
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()
|
||||
|
||||
@@ -154,7 +158,7 @@ class ALMainWindow(QMainWindow, Ui_ALMainWindow):
|
||||
):
|
||||
|
||||
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)
|
||||
@@ -166,6 +170,10 @@ class ALMainWindow(QMainWindow, Ui_ALMainWindow):
|
||||
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,13 +181,14 @@ class ALMainWindow(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(
|
||||
@@ -226,7 +235,7 @@ class ALMainWindow(QMainWindow, Ui_ALMainWindow):
|
||||
self.timerTaskIsRunning.emit(timer_task)
|
||||
self.__timer_task_timer.stop()
|
||||
self.__is_running_timer_task = True
|
||||
self.setControlButtons(True, True, False)
|
||||
self.setControlButtons(None, True, False)
|
||||
if not timer_task["silent"]:
|
||||
self.TrayIcon.showMessage(
|
||||
"定时任务 - AutoLibrary",
|
||||
@@ -237,11 +246,11 @@ class ALMainWindow(QMainWindow, Ui_ALMainWindow):
|
||||
self.showNormal()
|
||||
self.__current_timer_task_thread = TimerTaskWorker(
|
||||
timer_task,
|
||||
self.__input_queue,
|
||||
self.__output_queue,
|
||||
self._input_queue,
|
||||
self._output_queue,
|
||||
self.__config_paths
|
||||
)
|
||||
self.__current_timer_task_thread.finishedSignal_TimerWorker.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
|
||||
@@ -263,23 +272,6 @@ class ALMainWindow(QMainWindow, Ui_ALMainWindow):
|
||||
if start_button_enabled is not None:
|
||||
self.StartButton.setEnabled(start_button_enabled)
|
||||
|
||||
@Slot()
|
||||
def showMsg(
|
||||
self,
|
||||
msg: str
|
||||
):
|
||||
|
||||
self.__output_queue.put(f"[{self.__class_name:<15}] >>> : {msg}")
|
||||
|
||||
@Slot()
|
||||
def showTrace(
|
||||
self,
|
||||
msg: str
|
||||
):
|
||||
|
||||
timestamp = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())
|
||||
self.__output_queue.put(f"{timestamp}-[{self.__class_name:<15}] : {msg}")
|
||||
|
||||
@Slot()
|
||||
def pollMsgQueue(
|
||||
self
|
||||
@@ -287,30 +279,30 @@ class ALMainWindow(QMainWindow, Ui_ALMainWindow):
|
||||
|
||||
try:
|
||||
while True:
|
||||
msg = self.__output_queue.get_nowait()
|
||||
msg = self._output_queue.get_nowait()
|
||||
self.appendToTextEdit(msg)
|
||||
except queue.Empty:
|
||||
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.configWidgetCloseSingal.disconnect(self.onConfigWidgetClosed)
|
||||
self.__alConfigWidget.configWidgetIsClosed.disconnect(self.onConfigWidgetClosed)
|
||||
self.__alConfigWidget.deleteLater()
|
||||
self.__alConfigWidget = None
|
||||
self.__config_paths = ConfigManager.getValidateAutomationConfigPaths()
|
||||
self.setControlButtons(True, None, None)
|
||||
self.__config_paths = config_paths
|
||||
self._showLog("配置窗口已关闭,配置文件路径已更新")
|
||||
|
||||
@Slot(dict)
|
||||
def onTimerTaskIsReady(
|
||||
@@ -328,7 +320,7 @@ class ALMainWindow(QMainWindow, Ui_ALMainWindow):
|
||||
):
|
||||
|
||||
self.__current_timer_task_thread.wait(1000)
|
||||
self.__current_timer_task_thread.finishedSignal_TimerWorker.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)
|
||||
@@ -338,11 +330,11 @@ class ALMainWindow(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']}"
|
||||
self._showTrace(
|
||||
f"定时任务 {timer_task['name']} 执行{'失败' if is_error else '完成'}, uuid: {timer_task['uuid']}"
|
||||
)
|
||||
if not is_error:
|
||||
self.timerTaskIsExecuted.emit(timer_task)
|
||||
@@ -350,14 +342,15 @@ class ALMainWindow(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(
|
||||
@@ -365,15 +358,13 @@ class ALMainWindow(QMainWindow, Ui_ALMainWindow):
|
||||
):
|
||||
|
||||
if self.__alConfigWidget is None:
|
||||
self.__alConfigWidget = ALConfigWidget(
|
||||
self,
|
||||
self.__config_paths
|
||||
)
|
||||
self.__alConfigWidget.configWidgetCloseSingal.connect(self.onConfigWidgetClosed)
|
||||
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(
|
||||
@@ -383,13 +374,14 @@ class ALMainWindow(QMainWindow, Ui_ALMainWindow):
|
||||
self.setControlButtons(None, True, False)
|
||||
if self.__auto_lib_thread is None:
|
||||
self.__auto_lib_thread = AutoLibWorker(
|
||||
self.__input_queue,
|
||||
self.__output_queue,
|
||||
self._input_queue,
|
||||
self._output_queue,
|
||||
self.__config_paths
|
||||
)
|
||||
self.__auto_lib_thread.finishedSignal.connect(self.onStopButtonClicked)
|
||||
self.__auto_lib_thread.finishedWithErrorSignal.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(
|
||||
@@ -397,14 +389,15 @@ class ALMainWindow(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.finishedSignal.disconnect(self.onStopButtonClicked)
|
||||
self.__auto_lib_thread.finishedWithErrorSignal.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(
|
||||
@@ -414,6 +407,6 @@ class ALMainWindow(QMainWindow, Ui_ALMainWindow):
|
||||
msg = self.MessageEdit.text().strip()
|
||||
if not msg:
|
||||
return
|
||||
self.showMsg(msg)
|
||||
self.__input_queue.put(msg) # put message to input queue
|
||||
self._showMsg(msg)
|
||||
self._input_queue.put(msg) # put message to input queue
|
||||
self.MessageEdit.clear()
|
||||
+48
-30
@@ -17,13 +17,13 @@ from PySide6.QtCore import (
|
||||
|
||||
from base.MsgBase import MsgBase
|
||||
from operators.AutoLib import AutoLib
|
||||
from utils.ConfigReader import ConfigReader
|
||||
from utils.JSONReader import JSONReader
|
||||
|
||||
|
||||
class AutoLibWorker(QThread, MsgBase):
|
||||
class AutoLibWorker(MsgBase, QThread):
|
||||
|
||||
finishedSignal = Signal()
|
||||
finishedWithErrorSignal = Signal()
|
||||
autoLibWorkerIsFinished = Signal()
|
||||
autoLibWorkerFinishedWithError = Signal()
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -32,8 +32,8 @@ class AutoLibWorker(QThread, MsgBase):
|
||||
config_paths: dict
|
||||
):
|
||||
|
||||
super().__init__(input_queue=input_queue, output_queue=output_queue)
|
||||
|
||||
MsgBase.__init__(self, input_queue, output_queue)
|
||||
QThread.__init__(self)
|
||||
self.__config_paths = config_paths
|
||||
|
||||
|
||||
@@ -44,9 +44,11 @@ class AutoLibWorker(QThread, MsgBase):
|
||||
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
|
||||
|
||||
|
||||
@@ -57,8 +59,12 @@ class AutoLibWorker(QThread, MsgBase):
|
||||
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
|
||||
|
||||
|
||||
@@ -67,22 +73,28 @@ class AutoLibWorker(QThread, MsgBase):
|
||||
) -> 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()
|
||||
self.__user_config = JSONReader(self.__config_paths["user"]).data()
|
||||
if self.__run_config is None or self.__user_config is None:
|
||||
self._showTrace("配置文件加载失败, 请检查配置文件是否正确")
|
||||
self._showTrace("配置文件加载失败, 请检查配置文件是否正确")
|
||||
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
|
||||
|
||||
|
||||
@@ -108,25 +120,28 @@ class AutoLibWorker(QThread, MsgBase):
|
||||
groups = self.__user_config.get("groups")
|
||||
for group in groups:
|
||||
if not group["enabled"]:
|
||||
self._showTrace(f"任务组 {group["name"]} 已跳过")
|
||||
self._showTrace(f"任务组 {group["name"]} 已跳过", no_log=True)
|
||||
continue
|
||||
self._showTrace(f"正在运行任务组 {group["name"]}")
|
||||
self._showTrace(f"正在运行任务组 {group["name"]}", no_log=True)
|
||||
auto_lib.run(
|
||||
{ "users": group.get("users", []) }
|
||||
)
|
||||
except Exception as e:
|
||||
self._showTrace(f"AutoLibrary 运行时发生异常 : {e}")
|
||||
self.finishedWithErrorSignal.emit()
|
||||
self._showTrace(
|
||||
f"AutoLibrary 运行时发生异常 : {e}",
|
||||
self.TraceLevel.ERROR
|
||||
)
|
||||
self.autoLibWorkerFinishedWithError.emit()
|
||||
return
|
||||
if auto_lib:
|
||||
auto_lib.close()
|
||||
self._showTrace("AutoLibrary 运行结束")
|
||||
self.finishedSignal.emit()
|
||||
self.autoLibWorkerIsFinished.emit()
|
||||
|
||||
|
||||
class TimerTaskWorker(AutoLibWorker):
|
||||
|
||||
finishedSignal_TimerWorker = Signal(bool, dict)
|
||||
timerTaskWorkerIsFinished = Signal(bool, dict)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -137,10 +152,10 @@ class TimerTaskWorker(AutoLibWorker):
|
||||
):
|
||||
|
||||
super().__init__(input_queue, output_queue, config_paths)
|
||||
|
||||
self.__timer_task = timer_task
|
||||
self.finishedSignal.connect(self.onTimerTaskIsFinished)
|
||||
self.finishedWithErrorSignal.connect(self.onTimerTaskIsError)
|
||||
|
||||
self.autoLibWorkerIsFinished.connect(self.onTimerTaskIsFinished)
|
||||
self.autoLibWorkerFinishedWithError.connect(self.onTimerTaskFinishedWithError)
|
||||
|
||||
def run(
|
||||
self
|
||||
@@ -149,18 +164,21 @@ class TimerTaskWorker(AutoLibWorker):
|
||||
self._showTrace(f"定时任务 {self.__timer_task['name']} 开始运行")
|
||||
super().run()
|
||||
|
||||
@Slot(dict)
|
||||
def onTimerTaskIsError(
|
||||
@Slot()
|
||||
def onTimerTaskFinishedWithError(
|
||||
self
|
||||
):
|
||||
|
||||
self._showTrace(f"定时任务 {self.__timer_task['name']} 运行时发生异常")
|
||||
self.finishedSignal_TimerWorker.emit(True, self.__timer_task)
|
||||
self._showTrace(
|
||||
f"定时任务 {self.__timer_task['name']} 运行时发生异常",
|
||||
self.TraceLevel.ERROR
|
||||
)
|
||||
self.timerTaskWorkerIsFinished.emit(True, self.__timer_task)
|
||||
|
||||
@Slot(dict)
|
||||
@Slot()
|
||||
def onTimerTaskIsFinished(
|
||||
self
|
||||
):
|
||||
|
||||
self._showTrace(f"定时任务 {self.__timer_task['name']} 运行结束")
|
||||
self.finishedSignal_TimerWorker.emit(False, self.__timer_task)
|
||||
self.timerTaskWorkerIsFinished.emit(False, self.__timer_task)
|
||||
|
||||
+14
-13
@@ -22,13 +22,13 @@ class ALSeatFrame(QFrame):
|
||||
def __init__(
|
||||
self,
|
||||
seat_number,
|
||||
parent=None
|
||||
parent = None
|
||||
):
|
||||
|
||||
super().__init__(parent)
|
||||
self.__seat_number = seat_number
|
||||
self.__is_selected = False
|
||||
|
||||
|
||||
self.setupUi()
|
||||
|
||||
def setupUi(
|
||||
@@ -40,18 +40,19 @@ class ALSeatFrame(QFrame):
|
||||
self.setLineWidth(2)
|
||||
self.setStyleSheet("""
|
||||
QFrame {
|
||||
background-color: #4196EB;
|
||||
border: 2px solid #4196EB;
|
||||
background-color: #2294FF;
|
||||
border: 2px solid #2294FF;
|
||||
border-radius: 5px;
|
||||
}
|
||||
QLabel {
|
||||
color: #F0F0F0;
|
||||
color: #FFFFFF;
|
||||
font-weight: bold;
|
||||
}
|
||||
""")
|
||||
self.label = QLabel(self.__seat_number, self)
|
||||
self.label.setAlignment(Qt.AlignCenter)
|
||||
self.label.setGeometry(0, 0, 60, 40)
|
||||
self.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
self.Label = QLabel(self.__seat_number, self)
|
||||
self.Label.setAlignment(Qt.AlignCenter)
|
||||
self.Label.setGeometry(0, 0, 60, 40)
|
||||
|
||||
def mousePressEvent(
|
||||
self,
|
||||
@@ -77,24 +78,24 @@ class ALSeatFrame(QFrame):
|
||||
self.setStyleSheet("""
|
||||
QFrame {
|
||||
background-color: #4CAF50;
|
||||
border: 2px solid #388E3C;
|
||||
border: 2px solid #4CAF50;
|
||||
border-radius: 5px;
|
||||
color: white;
|
||||
}
|
||||
QLabel {
|
||||
color: #F0F0F0;
|
||||
color: #FFFFFF;
|
||||
font-weight: bold;
|
||||
}
|
||||
""")
|
||||
else:
|
||||
self.setStyleSheet("""
|
||||
QFrame {
|
||||
background-color: #4196EB;
|
||||
border: 2px solid #4196EB;
|
||||
background-color: #2294FF;
|
||||
border: 2px solid #2294FF;
|
||||
border-radius: 5px;
|
||||
}
|
||||
QLabel {
|
||||
color: #F0F0F0;
|
||||
color: #FFFFFF;
|
||||
font-weight: bold;
|
||||
}
|
||||
""")
|
||||
@@ -0,0 +1,178 @@
|
||||
# -*- 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.
|
||||
"""
|
||||
from PySide6.QtCore import (
|
||||
Qt, Slot, Signal
|
||||
)
|
||||
from PySide6.QtWidgets import (
|
||||
QDialog, QLabel, QHBoxLayout, QVBoxLayout,
|
||||
QPushButton,
|
||||
)
|
||||
from PySide6.QtGui import (
|
||||
QCloseEvent
|
||||
)
|
||||
|
||||
from gui.ALSeatMapView import ALSeatMapView
|
||||
|
||||
|
||||
class ALSeatMapSelectDialog(QDialog):
|
||||
|
||||
seatMapSelectDialogIsClosed = Signal(list)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent: QDialog = None,
|
||||
floor: str = "",
|
||||
room: str = "",
|
||||
seats_data: str = ""
|
||||
):
|
||||
|
||||
super().__init__(parent)
|
||||
self.__floor = floor
|
||||
self.__room = room
|
||||
self.__seats_data = seats_data
|
||||
self.__confirmed = False
|
||||
|
||||
self.setupUi()
|
||||
self.connectSignals()
|
||||
|
||||
|
||||
def setupUi(
|
||||
self
|
||||
):
|
||||
|
||||
self.setModal(True)
|
||||
self.setMinimumSize(800, 600)
|
||||
self.resize(800, 600)
|
||||
self.setWindowTitle(f"选择楼层座位 - AutoLibrary")
|
||||
|
||||
self.SeatMapWidgetMainLayout = QVBoxLayout(self)
|
||||
self.SeatMapWidgetMainLayout.setContentsMargins(5, 5, 5, 5)
|
||||
self.SeatMapWidgetMainLayout.setSpacing(5)
|
||||
self.TitleLabel = QLabel(f"楼层座位分布图: {self.__floor}-{self.__room}")
|
||||
self.TitleLabel.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self.TitleLabel.setStyleSheet("font-size: 16px; font-weight: bold; margin: 10px;")
|
||||
self.SeatMapWidgetMainLayout.addWidget(self.TitleLabel)
|
||||
|
||||
self.SeatMapGraphicsView = ALSeatMapView(None, self.__seats_data)
|
||||
self.SeatMapWidgetMainLayout.addWidget(self.SeatMapGraphicsView)
|
||||
|
||||
self.TipsLabel = QLabel(
|
||||
" 点击座位进行选择/取消选择, 最多选择1个座位 \n"
|
||||
" [操作方法: Ctrl+鼠标滚轮缩放 | 滚轮/拖拽/方向键 移动]"
|
||||
)
|
||||
self.TipsLabel.setAlignment(Qt.AlignmentFlag.AlignLeft)
|
||||
self.TipsLabel.setStyleSheet("color: #666; margin: 5px;")
|
||||
self.SeatMapWidgetMainLayout.addWidget(self.TipsLabel)
|
||||
|
||||
self.ConfirmButton = QPushButton("确认")
|
||||
self.ConfirmButton.setFixedSize(80, 25)
|
||||
self.ConfirmButton.setAutoDefault(True)
|
||||
self.ConfirmButton.setDefault(True)
|
||||
self.CancelButton = QPushButton("取消")
|
||||
self.CancelButton.setFixedSize(80, 25)
|
||||
self.SeatMapWidgetControlLayout = QHBoxLayout()
|
||||
self.SeatMapWidgetControlLayout.setContentsMargins(0, 0, 0, 0)
|
||||
self.SeatMapWidgetControlLayout.setSpacing(5)
|
||||
self.SeatMapWidgetControlLayout.setAlignment(Qt.AlignmentFlag.AlignRight)
|
||||
self.SeatMapWidgetControlLayout.addWidget(self.CancelButton)
|
||||
self.SeatMapWidgetControlLayout.addWidget(self.ConfirmButton)
|
||||
self.SeatMapWidgetMainLayout.addLayout(self.SeatMapWidgetControlLayout)
|
||||
|
||||
|
||||
def connectSignals(
|
||||
self
|
||||
):
|
||||
|
||||
self.ConfirmButton.clicked.connect(self.onConfirmButtonClicked)
|
||||
self.CancelButton.clicked.connect(self.onCancelButtonClicked)
|
||||
|
||||
|
||||
def showEvent(
|
||||
self,
|
||||
event
|
||||
):
|
||||
|
||||
result = super().showEvent(event)
|
||||
|
||||
screen_rect = self.screen().geometry()
|
||||
target_pos = self.parent().geometry().center()
|
||||
target_pos.setX(target_pos.x() - self.width()//2)
|
||||
target_pos.setY(target_pos.y() - self.height()//2)
|
||||
if target_pos.x() < 0:
|
||||
target_pos.setX(0)
|
||||
if target_pos.x() + self.width() > screen_rect.width():
|
||||
target_pos.setX(screen_rect.width() - self.width())
|
||||
if target_pos.y() < 0:
|
||||
target_pos.setY(0)
|
||||
if target_pos.y() + self.height() > screen_rect.height():
|
||||
target_pos.setY(screen_rect.height() - self.height())
|
||||
self.move(target_pos)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def closeEvent(
|
||||
self,
|
||||
event: QCloseEvent
|
||||
):
|
||||
|
||||
if not self.__confirmed:
|
||||
self.clearSelections()
|
||||
self.reject()
|
||||
else:
|
||||
self.accept()
|
||||
self.seatMapSelectDialogIsClosed.emit(self.getSelectedSeats())
|
||||
super().closeEvent(event)
|
||||
|
||||
|
||||
def selectSeat(
|
||||
self,
|
||||
seat_number: str
|
||||
):
|
||||
|
||||
self.SeatMapGraphicsView.selectSeat(seat_number)
|
||||
|
||||
|
||||
def selectSeats(
|
||||
self,
|
||||
seat_numbers: list[str]
|
||||
) -> bool:
|
||||
|
||||
return self.SeatMapGraphicsView.selectSeats(seat_numbers)
|
||||
|
||||
|
||||
def getSelectedSeats(
|
||||
self
|
||||
) -> list[str]:
|
||||
|
||||
return self.SeatMapGraphicsView.getSelectedSeats()
|
||||
|
||||
|
||||
def clearSelections(
|
||||
self
|
||||
):
|
||||
|
||||
self.SeatMapGraphicsView.clearSelections()
|
||||
|
||||
@Slot()
|
||||
def onConfirmButtonClicked(
|
||||
self
|
||||
):
|
||||
|
||||
self.__confirmed = True
|
||||
self.accept()
|
||||
|
||||
@Slot()
|
||||
def onCancelButtonClicked(
|
||||
self
|
||||
):
|
||||
|
||||
self.__confirmed = False
|
||||
self.reject()
|
||||
@@ -1,4 +1,4 @@
|
||||
seats_maps = {
|
||||
ALSeatMapTable = {
|
||||
"2": {
|
||||
"1": """
|
||||
,,,,,,,,,,,039A,039B,,040A,040B,,041A,041B,,042A,042B,,043A,043B,,044A,044B,,,,,,,,,
|
||||
|
||||
@@ -8,41 +8,32 @@ 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, QEvent
|
||||
Qt, Slot, QEvent
|
||||
)
|
||||
from PySide6.QtWidgets import (
|
||||
QFrame, QWidget, QLabel, QHBoxLayout, QVBoxLayout,
|
||||
QGridLayout, QGraphicsView, QGraphicsScene, QGraphicsItem,
|
||||
QPushButton,
|
||||
QFrame, QWidget,
|
||||
QGridLayout, QGraphicsView, QGraphicsScene, QGraphicsItem
|
||||
)
|
||||
from PySide6.QtGui import (
|
||||
QPainter, QWheelEvent, QCloseEvent
|
||||
QPainter, QWheelEvent
|
||||
)
|
||||
|
||||
from gui.ALSeatFrame import ALSeatFrame
|
||||
|
||||
|
||||
class ALSeatMapWidget(QWidget):
|
||||
|
||||
seatMapWidgetClosed = Signal(list)
|
||||
class ALSeatMapView(QGraphicsView):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent: QWidget = None,
|
||||
floor: str = "",
|
||||
room: str = "",
|
||||
seats_data: dict = {},
|
||||
):
|
||||
|
||||
super().__init__(parent)
|
||||
self.__floor = floor
|
||||
self.__room = room
|
||||
self.__seats_data = seats_data
|
||||
self.__selected_seats = []
|
||||
self.__seat_frames = {}
|
||||
self.__confirmed = False
|
||||
|
||||
self.setupUi()
|
||||
self.connectSignals()
|
||||
|
||||
@staticmethod
|
||||
def formatSeatNumber(
|
||||
@@ -56,108 +47,13 @@ class ALSeatMapWidget(QWidget):
|
||||
return seat_number.zfill(3)
|
||||
|
||||
|
||||
def setupUi(
|
||||
self
|
||||
):
|
||||
|
||||
self.setWindowFlags(Qt.WindowType.Window)
|
||||
self.setWindowModality(Qt.WindowModality.ApplicationModal)
|
||||
self.setMinimumSize(800, 600)
|
||||
self.resize(800, 600)
|
||||
self.setWindowTitle(f"选择楼层座位 - AutoLibrary")
|
||||
|
||||
self.SeatMapWidgetMainLayout = QVBoxLayout(self)
|
||||
self.TitleLabel = QLabel(f"楼层座位分布图: {self.__floor}-{self.__room}")
|
||||
self.TitleLabel.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self.TitleLabel.setStyleSheet("font-size: 16px; font-weight: bold; margin: 10px;")
|
||||
self.SeatMapWidgetMainLayout.addWidget(self.TitleLabel)
|
||||
|
||||
self.SeatMapGraphicsView = QGraphicsView(self)
|
||||
self.SeatMapGraphicsScene = QGraphicsScene(self)
|
||||
self.SeatMapGraphicsView.setScene(self.SeatMapGraphicsScene)
|
||||
self.SeatMapGraphicsView.setRenderHint(QPainter.RenderHint.LosslessImageRendering)
|
||||
self.SeatMapGraphicsView.setDragMode(QGraphicsView.DragMode.ScrollHandDrag)
|
||||
self.SeatMapGraphicsView.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
|
||||
self.SeatMapGraphicsView.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
|
||||
self.SeatMapGraphicsView.viewport().installEventFilter(self)
|
||||
|
||||
self.SeatsContainerWidget = QWidget()
|
||||
self.SeatsContainerLayout = QGridLayout(self.SeatsContainerWidget)
|
||||
self.createSeatMap()
|
||||
|
||||
self.ContainerProxy = self.SeatMapGraphicsScene.addWidget(self.SeatsContainerWidget)
|
||||
self.ContainerProxy.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsSelectable, False)
|
||||
self.SeatMapWidgetMainLayout.addWidget(self.SeatMapGraphicsView)
|
||||
|
||||
self.TipsLabel = QLabel(
|
||||
" 点击座位进行选择/取消选择, 最多选择1个座位 \n"
|
||||
" [操作方法: Ctrl+鼠标滚轮缩放 | 滚轮/拖拽/方向键 移动]"
|
||||
)
|
||||
self.TipsLabel.setAlignment(Qt.AlignmentFlag.AlignLeft)
|
||||
self.TipsLabel.setStyleSheet("color: #666; margin: 5px;")
|
||||
self.SeatMapWidgetMainLayout.addWidget(self.TipsLabel)
|
||||
|
||||
self.ConfirmButton = QPushButton("确认")
|
||||
self.ConfirmButton.setFixedSize(80, 25)
|
||||
self.CancelButton = QPushButton("取消")
|
||||
self.CancelButton.setFixedSize(80, 25)
|
||||
self.SeatMapWidgetControlLayout = QHBoxLayout()
|
||||
self.SeatMapWidgetControlLayout.setAlignment(Qt.AlignmentFlag.AlignRight)
|
||||
self.SeatMapWidgetControlLayout.addWidget(self.CancelButton)
|
||||
self.SeatMapWidgetControlLayout.addWidget(self.ConfirmButton)
|
||||
self.SeatMapWidgetMainLayout.addLayout(self.SeatMapWidgetControlLayout)
|
||||
|
||||
|
||||
def connectSignals(
|
||||
self
|
||||
):
|
||||
|
||||
self.ConfirmButton.clicked.connect(self.onConfirmButtonClicked)
|
||||
self.CancelButton.clicked.connect(self.onCancelButtonClicked)
|
||||
|
||||
|
||||
def showEvent(
|
||||
self,
|
||||
event
|
||||
):
|
||||
|
||||
result = super().showEvent(event)
|
||||
|
||||
screen_rect = self.screen().geometry()
|
||||
target_pos = self.parent().geometry().center()
|
||||
target_pos.setX(target_pos.x() - self.width()//2)
|
||||
target_pos.setY(target_pos.y() - self.height()//2)
|
||||
if target_pos.x() < 0:
|
||||
target_pos.setX(0)
|
||||
if target_pos.x() + self.width() > screen_rect.width():
|
||||
target_pos.setX(screen_rect.width() - self.width())
|
||||
if target_pos.y() < 0:
|
||||
target_pos.setY(0)
|
||||
if target_pos.y() + self.height() > screen_rect.height():
|
||||
target_pos.setY(screen_rect.height() - self.height())
|
||||
self.move(target_pos)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def closeEvent(
|
||||
self,
|
||||
event: QCloseEvent
|
||||
):
|
||||
|
||||
if not self.__confirmed:
|
||||
self.clearSelections()
|
||||
self.seatMapWidgetClosed.emit(self.__selected_seats)
|
||||
super().closeEvent(event)
|
||||
|
||||
|
||||
def eventFilter(
|
||||
self,
|
||||
watched,
|
||||
event
|
||||
):
|
||||
|
||||
if (watched is self.SeatMapGraphicsView.viewport() and
|
||||
if (watched is self.viewport() and
|
||||
event.type() == QEvent.Type.Wheel and
|
||||
event.modifiers() == Qt.KeyboardModifier.ControlModifier
|
||||
):
|
||||
@@ -172,12 +68,40 @@ class ALSeatMapWidget(QWidget):
|
||||
):
|
||||
|
||||
delta = event.angleDelta().y()
|
||||
min_scale = 0.1
|
||||
max_scale = 4.0
|
||||
current_scale = self.transform().m11()
|
||||
zoom_factor = 1.2 if delta > 0 else 1/1.2
|
||||
self.SeatMapGraphicsView.setTransformationAnchor(QGraphicsView.ViewportAnchor.AnchorUnderMouse)
|
||||
self.SeatMapGraphicsView.scale(zoom_factor, zoom_factor)
|
||||
target_scale = current_scale*zoom_factor
|
||||
if target_scale < min_scale and delta < 0:
|
||||
return
|
||||
if target_scale > max_scale and delta > 0:
|
||||
return
|
||||
self.setTransformationAnchor(QGraphicsView.ViewportAnchor.AnchorUnderMouse)
|
||||
self.scale(zoom_factor, zoom_factor)
|
||||
|
||||
|
||||
def createSeatMap(
|
||||
def setupUi(
|
||||
self
|
||||
):
|
||||
|
||||
self.SeatMapGraphicsScene = QGraphicsScene(self)
|
||||
self.setScene(self.SeatMapGraphicsScene)
|
||||
self.setRenderHint(QPainter.RenderHint.LosslessImageRendering)
|
||||
self.setDragMode(QGraphicsView.DragMode.ScrollHandDrag)
|
||||
self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
|
||||
self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
|
||||
self.viewport().installEventFilter(self)
|
||||
|
||||
self.SeatsContainerWidget = QWidget()
|
||||
self.SeatsContainerLayout = QGridLayout(self.SeatsContainerWidget)
|
||||
self.setupSeatMap()
|
||||
|
||||
self.ContainerProxy = self.SeatMapGraphicsScene.addWidget(self.SeatsContainerWidget)
|
||||
self.ContainerProxy.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsSelectable, False)
|
||||
|
||||
|
||||
def setupSeatMap(
|
||||
self
|
||||
):
|
||||
|
||||
@@ -261,20 +185,4 @@ class ALSeatMapWidget(QWidget):
|
||||
if len(self.__selected_seats) < 1:
|
||||
self.__selected_seats.append(seat_number)
|
||||
else:
|
||||
self.__seat_frames[seat_number].toggleSelection()
|
||||
|
||||
@Slot()
|
||||
def onConfirmButtonClicked(
|
||||
self
|
||||
):
|
||||
|
||||
self.__confirmed = True
|
||||
self.close()
|
||||
|
||||
@Slot()
|
||||
def onCancelButtonClicked(
|
||||
self
|
||||
):
|
||||
|
||||
self.__confirmed = False
|
||||
self.close()
|
||||
self.__seat_frames[seat_number].toggleSelection()
|
||||
@@ -0,0 +1,246 @@
|
||||
|
||||
from enum import Enum
|
||||
|
||||
from PySide6.QtWidgets import (
|
||||
QLabel
|
||||
)
|
||||
from PySide6.QtCore import (
|
||||
Qt, Property, QPropertyAnimation, QEasingCurve
|
||||
)
|
||||
from PySide6.QtGui import (
|
||||
QPainter, QColor, QConicalGradient, QPalette
|
||||
)
|
||||
|
||||
|
||||
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)
|
||||
@@ -12,18 +12,14 @@ import uuid
|
||||
from enum import Enum
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from PySide6.QtCore import (
|
||||
Slot, QDateTime
|
||||
)
|
||||
from PySide6.QtWidgets import (
|
||||
QLabel, QDialog, QWidget, QSpinBox,
|
||||
QHBoxLayout, QGridLayout, QDateTimeEdit
|
||||
)
|
||||
from PySide6.QtCore import Slot, QDateTime
|
||||
from PySide6.QtWidgets import QLabel, QDialog, QWidget, QSpinBox, QHBoxLayout, QGridLayout, QDateTimeEdit
|
||||
|
||||
from gui.Ui_ALAddTimerTaskDialog import Ui_ALAddTimerTaskDialog
|
||||
from gui.resources.ui.Ui_ALTimerTaskAddDialog import Ui_ALTimerTaskAddDialog
|
||||
import utils.TimerUtils as TimerUtils
|
||||
|
||||
|
||||
class TimerTaskStatus(Enum):
|
||||
class ALTimerTaskStatus(Enum):
|
||||
|
||||
PENDING = "等待中"
|
||||
READY = "已就绪"
|
||||
@@ -31,9 +27,10 @@ class TimerTaskStatus(Enum):
|
||||
EXECUTED = "已执行"
|
||||
ERROR = "执行失败"
|
||||
OUTDATED = "已过期"
|
||||
UNKNOWN = "未知"
|
||||
|
||||
|
||||
class ALAddTimerTaskWidget(QDialog, Ui_ALAddTimerTaskDialog):
|
||||
class ALTimerTaskAddDialog(QDialog, Ui_ALTimerTaskAddDialog):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -43,8 +40,8 @@ class ALAddTimerTaskWidget(QDialog, Ui_ALAddTimerTaskDialog):
|
||||
super().__init__(parent)
|
||||
|
||||
self.setupUi(self)
|
||||
self.connectSignals()
|
||||
self.modifyUi()
|
||||
self.connectSignals()
|
||||
|
||||
|
||||
def modifyUi(
|
||||
@@ -64,28 +61,28 @@ class ALAddTimerTaskWidget(QDialog, Ui_ALAddTimerTaskDialog):
|
||||
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.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)
|
||||
|
||||
@@ -97,6 +94,7 @@ class ALAddTimerTaskWidget(QDialog, Ui_ALAddTimerTaskDialog):
|
||||
self.CancelButton.clicked.connect(self.reject)
|
||||
self.ConfirmButton.clicked.connect(self.accept)
|
||||
self.TimerTypeComboBox.currentIndexChanged.connect(self.onTimerTypeComboBoxIndexChanged)
|
||||
self.RepeatCheckBox.toggled.connect(self.onRepeatCheckBoxToggled)
|
||||
|
||||
|
||||
def getTimerTask(
|
||||
@@ -121,17 +119,46 @@ class ALAddTimerTaskWidget(QDialog, Ui_ALAddTimerTaskDialog):
|
||||
minutes = self.RelativeMinuteSpinBox.value(),
|
||||
seconds = self.RelativeSecondSpinBox.value()
|
||||
)
|
||||
return {
|
||||
task_data = {
|
||||
"name": name,
|
||||
"task_uuid": uuid.uuid4().hex.upper() + f"-{added_time.strftime("%Y%m%d%H%M%S")}",
|
||||
"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": TimerTaskStatus.PENDING,
|
||||
"executed": False
|
||||
"added_time": added_time,
|
||||
"status": ALTimerTaskStatus.PENDING,
|
||||
"executed": False,
|
||||
"repeat": self.RepeatCheckBox.isChecked(),
|
||||
}
|
||||
|
||||
if task_data["repeat"]:
|
||||
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.calculateNextRepeatTime(
|
||||
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 +167,18 @@ class ALAddTimerTaskWidget(QDialog, Ui_ALAddTimerTaskDialog):
|
||||
):
|
||||
|
||||
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)
|
||||
@@ -0,0 +1,147 @@
|
||||
# -*- 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("history", [])
|
||||
|
||||
self.modifyUi()
|
||||
self.connectSignals()
|
||||
|
||||
|
||||
def modifyUi(
|
||||
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 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["history"] = self.__history
|
||||
|
||||
|
||||
def getHistory(
|
||||
self
|
||||
) -> list:
|
||||
|
||||
return self.__history
|
||||
@@ -0,0 +1,655 @@
|
||||
# -*- 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 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
|
||||
)
|
||||
from PySide6.QtGui import (
|
||||
QCloseEvent
|
||||
)
|
||||
|
||||
import managers.config.ConfigManager as ConfigManager
|
||||
import utils.TimerUtils as TimerUtils
|
||||
|
||||
from gui.resources.ui.Ui_ALTimerTaskManageWidget import Ui_ALTimerTaskManageWidget
|
||||
from gui.ALTimerTaskAddDialog import ALTimerTaskAddDialog, ALTimerTaskStatus
|
||||
from gui.ALTimerTaskHistoryDialog import ALTimerTaskHistoryDialog
|
||||
|
||||
|
||||
class ALTimerTaskItemWidget(QWidget):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent = None,
|
||||
timer_task: dict = None
|
||||
):
|
||||
|
||||
super().__init__(parent)
|
||||
self.__timer_task = timer_task
|
||||
|
||||
self.modifyUi()
|
||||
|
||||
|
||||
def modifyUi(
|
||||
self
|
||||
):
|
||||
|
||||
self.ItemWidgetLayout = QHBoxLayout(self)
|
||||
self.ItemWidgetLayout.setSpacing(10)
|
||||
self.ItemWidgetLayout.setContentsMargins(10, 5, 10, 5)
|
||||
|
||||
self.TaskInfoLayout = QVBoxLayout()
|
||||
self.TaskInfoLayout.setSpacing(5)
|
||||
TaskNameLabel = QLabel(self.__timer_task["name"])
|
||||
TaskNameLabelFont = TaskNameLabel.font()
|
||||
TaskNameLabelFont.setBold(True)
|
||||
TaskNameLabel.setFont(TaskNameLabelFont)
|
||||
TaskNameLabel.setFixedHeight(25)
|
||||
self.TaskInfoLayout.addWidget(TaskNameLabel)
|
||||
ExecuteTimeStr = self.__timer_task["execute_time"].strftime("%Y-%m-%d %H:%M:%S")
|
||||
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()
|
||||
|
||||
match self.__timer_task["status"]:
|
||||
case ALTimerTaskStatus.PENDING:
|
||||
TaskStatusText = "等待中"
|
||||
TaskStatusColor = "#FF9800"
|
||||
case ALTimerTaskStatus.READY:
|
||||
TaskStatusText = "已就绪"
|
||||
TaskStatusColor = "#316BFF"
|
||||
case ALTimerTaskStatus.RUNNING:
|
||||
TaskStatusText = "执行中"
|
||||
TaskStatusColor = "#2294FF"
|
||||
case ALTimerTaskStatus.EXECUTED:
|
||||
TaskStatusText = "已执行"
|
||||
TaskStatusColor = "#4CAF50"
|
||||
case ALTimerTaskStatus.ERROR:
|
||||
TaskStatusText = "执行失败"
|
||||
TaskStatusColor = "#DC0000"
|
||||
case ALTimerTaskStatus.OUTDATED:
|
||||
TaskStatusText = "已过期"
|
||||
TaskStatusColor = "#DC0000"
|
||||
TaskStatusLabel = QLabel(TaskStatusText)
|
||||
TaskStatusLabel.setStyleSheet(f"""
|
||||
QLabel {{
|
||||
background-color: {TaskStatusColor};
|
||||
color: #FFFFFF;
|
||||
border-radius: 5px;
|
||||
font-weight: bold;
|
||||
}}
|
||||
""")
|
||||
TaskStatusLabel.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
TaskStatusLabel.setFixedSize(80, 25)
|
||||
self.ItemWidgetLayout.addWidget(TaskStatusLabel)
|
||||
|
||||
TaskModeText = "静默" if self.__timer_task["silent"] else "显示"
|
||||
TaskModeColor = "#6325FF" if self.__timer_task["silent"] else "#2294FF"
|
||||
TaskModeLabel = QLabel(TaskModeText)
|
||||
TaskModeLabel.setStyleSheet(f"""
|
||||
QLabel {{
|
||||
background-color: {TaskModeColor};
|
||||
color: #FFFFFF;
|
||||
border-radius: 5px;
|
||||
font-weight: bold;
|
||||
}}
|
||||
""")
|
||||
TaskModeLabel.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
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)
|
||||
|
||||
|
||||
class ALTimerTaskManageWidget(QWidget, Ui_ALTimerTaskManageWidget):
|
||||
|
||||
class SortPolicy(Enum):
|
||||
|
||||
BY_NAME = "按名称"
|
||||
BY_ADD_TIME = "按添加时间"
|
||||
BY_EXECUTE_TIME = "按执行时间"
|
||||
|
||||
timerTaskIsReady = Signal(dict)
|
||||
timerTasksChanged = Signal()
|
||||
timerTaskManageWidgetIsClosed = Signal()
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent = None
|
||||
):
|
||||
|
||||
super().__init__(parent)
|
||||
self.__cfg_mgr = ConfigManager.instance()
|
||||
self.__timer_tasks = []
|
||||
self.__check_timer = None
|
||||
self.__sort_policy = self.SortPolicy.BY_EXECUTE_TIME
|
||||
self.__sort_order = Qt.SortOrder.AscendingOrder
|
||||
|
||||
self.setupUi(self)
|
||||
self.connectSignals()
|
||||
self.setupTimer()
|
||||
if not self.initializeTimerTasks():
|
||||
raise Exception("定时任务配置文件初始化失败 !")
|
||||
|
||||
|
||||
def connectSignals(
|
||||
self
|
||||
):
|
||||
|
||||
self.AddTimerTaskButton.clicked.connect(self.addTask)
|
||||
self.ClearAllTimerTasksButton.clicked.connect(self.clearAllTasks)
|
||||
self.TimerTaskSortTypeComboBox.currentIndexChanged.connect(self.onSortPolicyComboBoxChanged)
|
||||
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)
|
||||
|
||||
|
||||
def initializeTimerTasks(
|
||||
self
|
||||
) -> bool:
|
||||
|
||||
timer_tasks = self.getTimerTasks()
|
||||
if timer_tasks is not None:
|
||||
self.__timer_tasks = timer_tasks
|
||||
self.timerTasksChanged.emit()
|
||||
return True
|
||||
timer_tasks = []
|
||||
if self.setTimerTasks(copy.deepcopy(timer_tasks)):
|
||||
self.__timer_tasks = timer_tasks
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def getTimerTasks(
|
||||
self
|
||||
) -> list:
|
||||
|
||||
try:
|
||||
timer_tasks = self.__cfg_mgr.get(ConfigManager.ConfigType.TIMERTASK)
|
||||
if timer_tasks and "timer_tasks" in timer_tasks:
|
||||
for task in timer_tasks["timer_tasks"]:
|
||||
task["added_time"] = datetime.strptime(task["added_time"], "%Y-%m-%d %H:%M:%S")
|
||||
task["execute_time"] = datetime.strptime(task["execute_time"], "%Y-%m-%d %H:%M:%S")
|
||||
task["status"] = ALTimerTaskStatus(task["status"])
|
||||
if "history" in task:
|
||||
for item in task["history"]:
|
||||
item["result"] = ALTimerTaskStatus(item["result"])
|
||||
return timer_tasks["timer_tasks"]
|
||||
raise Exception("定时任务配置文件格式错误")
|
||||
except Exception as e:
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
"警告 - AutoLibrary",
|
||||
f"加载定时任务配置发生错误 ! : \n{e}"
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def setTimerTasks(
|
||||
self,
|
||||
timer_tasks: list
|
||||
) -> bool:
|
||||
|
||||
try:
|
||||
for task in timer_tasks:
|
||||
task["added_time"] = task["added_time"].strftime("%Y-%m-%d %H:%M:%S")
|
||||
task["execute_time"] = task["execute_time"].strftime("%Y-%m-%d %H:%M:%S")
|
||||
task["status"] = task["status"].value
|
||||
if "history" in task:
|
||||
for item in task["history"]:
|
||||
item["result"] = item["result"].value
|
||||
self.__cfg_mgr.set(ConfigManager.ConfigType.TIMERTASK, "", { "timer_tasks": timer_tasks })
|
||||
return True
|
||||
except Exception as e:
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
"警告 - AutoLibrary",
|
||||
f"保存定时任务配置发生错误 ! : \n{e}"
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
def showEvent(
|
||||
self,
|
||||
event
|
||||
):
|
||||
|
||||
result = super().showEvent(event)
|
||||
|
||||
screen_rect = self.screen().geometry()
|
||||
target_pos = self.parent().geometry().center()
|
||||
target_pos.setX(target_pos.x() - self.width()//2)
|
||||
target_pos.setY(target_pos.y() - self.height()//2)
|
||||
if target_pos.x() < 0:
|
||||
target_pos.setX(0)
|
||||
if target_pos.x() + self.width() > screen_rect.width():
|
||||
target_pos.setX(screen_rect.width() - self.width())
|
||||
if target_pos.y() < 0:
|
||||
target_pos.setY(0)
|
||||
if target_pos.y() + self.height() > screen_rect.height():
|
||||
target_pos.setY(screen_rect.height() - self.height())
|
||||
self.move(target_pos)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def closeEvent(
|
||||
self,
|
||||
event: QCloseEvent
|
||||
):
|
||||
|
||||
self.hide()
|
||||
self.timerTaskManageWidgetIsClosed.emit()
|
||||
event.ignore()
|
||||
|
||||
|
||||
def sortTimerTasks(
|
||||
self,
|
||||
policy: SortPolicy = SortPolicy.BY_EXECUTE_TIME,
|
||||
order: Qt.SortOrder = Qt.SortOrder.AscendingOrder
|
||||
):
|
||||
|
||||
if policy == self.SortPolicy.BY_NAME:
|
||||
self.__timer_tasks.sort(
|
||||
key = lambda x: x["name"],
|
||||
reverse = order is Qt.SortOrder.DescendingOrder
|
||||
)
|
||||
elif policy == self.SortPolicy.BY_ADD_TIME:
|
||||
self.__timer_tasks.sort(
|
||||
key = lambda x: x["added_time"],
|
||||
reverse = order is Qt.SortOrder.DescendingOrder
|
||||
)
|
||||
elif policy == self.SortPolicy.BY_EXECUTE_TIME:
|
||||
self.__timer_tasks.sort(
|
||||
key = lambda x: x["execute_time"],
|
||||
reverse = order is Qt.SortOrder.DescendingOrder
|
||||
)
|
||||
|
||||
|
||||
def updateStat(
|
||||
self
|
||||
):
|
||||
|
||||
pending = 0
|
||||
in_queue = 0
|
||||
executed = 0
|
||||
invalid = 0
|
||||
total = len(self.__timer_tasks)
|
||||
for timer_task in self.__timer_tasks:
|
||||
if timer_task["status"] == ALTimerTaskStatus.PENDING:
|
||||
pending += 1
|
||||
elif timer_task["status"] == ALTimerTaskStatus.READY\
|
||||
or timer_task["status"] == ALTimerTaskStatus.RUNNING:
|
||||
in_queue += 1
|
||||
elif timer_task["status"] == ALTimerTaskStatus.EXECUTED:
|
||||
executed += 1
|
||||
elif timer_task["status"] == ALTimerTaskStatus.ERROR\
|
||||
or timer_task["status"] == ALTimerTaskStatus.OUTDATED:
|
||||
invalid += 1
|
||||
self.TotalTaskLabel.setText(f"总任务:{total}")
|
||||
self.PendingTaskLabel.setText(f"待执行:{pending}")
|
||||
self.InQueueTaskLabel.setText(f"队列中:{in_queue}")
|
||||
self.ExecutedTaskLabel.setText(f"已执行:{executed}")
|
||||
self.InvalidTaskLabel.setText(f"无效的:{invalid}")
|
||||
|
||||
|
||||
def updateTimerTaskList(
|
||||
self
|
||||
):
|
||||
|
||||
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 _, task = timer_task: self.deleteTask(task)
|
||||
)
|
||||
if timer_task.get("repeat", False) and hasattr(widget, "HistoryButton"):
|
||||
widget.HistoryButton.clicked.connect(
|
||||
lambda _, task = timer_task: self.showTaskHistory(task)
|
||||
)
|
||||
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()
|
||||
self.__timer_tasks.append(timer_task)
|
||||
self.timerTasksChanged.emit()
|
||||
|
||||
@staticmethod
|
||||
def getTimerTaskDetailMessage(
|
||||
timer_task: dict
|
||||
):
|
||||
|
||||
if "history" not in timer_task:
|
||||
history = []
|
||||
else:
|
||||
history = timer_task["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,
|
||||
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["uuid"] != task_uuid
|
||||
]
|
||||
self.timerTasksChanged.emit()
|
||||
|
||||
|
||||
def clearAllTasks(
|
||||
self
|
||||
):
|
||||
|
||||
if not self.__timer_tasks:
|
||||
return
|
||||
result = QMessageBox.question(
|
||||
self,
|
||||
"确认 - AutoLibrary",
|
||||
"是否要清除所有定时任务 ?",
|
||||
QMessageBox.StandardButton.Yes | 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
|
||||
or x["status"] == ALTimerTaskStatus.RUNNING
|
||||
]
|
||||
in_queue_count = len(in_queue_tasks)
|
||||
if in_queue_count > 0:
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
"警告 - AutoLibrary",
|
||||
f"存在 {in_queue_count} 个正在执行或已就绪的队列任务,无法清除所有定时任务 !"
|
||||
)
|
||||
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
|
||||
):
|
||||
|
||||
need_update = False
|
||||
|
||||
now = datetime.now()
|
||||
for timer_task in self.__timer_tasks:
|
||||
if timer_task["execute_time"] > now:
|
||||
continue
|
||||
if timer_task["status"] is not ALTimerTaskStatus.PENDING:
|
||||
continue
|
||||
if timer_task["execute_time"] <= now + timedelta(seconds = -5):
|
||||
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
|
||||
self.timerTaskIsReady.emit(timer_task)
|
||||
need_update = True
|
||||
if need_update:
|
||||
self.timerTasksChanged.emit()
|
||||
|
||||
@Slot(int)
|
||||
def onSortPolicyComboBoxChanged(
|
||||
self,
|
||||
policy: int
|
||||
):
|
||||
|
||||
mapping = {
|
||||
0: self.SortPolicy.BY_NAME,
|
||||
1: self.SortPolicy.BY_ADD_TIME,
|
||||
2: self.SortPolicy.BY_EXECUTE_TIME
|
||||
}
|
||||
self.__sort_policy = mapping[policy]
|
||||
self.updateTimerTaskList()
|
||||
|
||||
@Slot()
|
||||
def onSortOrderToggleButtonClicked(
|
||||
self
|
||||
):
|
||||
|
||||
self.__sort_order = Qt.SortOrder.AscendingOrder\
|
||||
if self.__sort_order is Qt.SortOrder.DescendingOrder\
|
||||
else Qt.SortOrder.DescendingOrder
|
||||
self.TimerTaskSortOrderToggleButton.setText(
|
||||
"↑" if self.__sort_order is Qt.SortOrder.AscendingOrder else "↓"
|
||||
)
|
||||
self.updateTimerTaskList()
|
||||
|
||||
@Slot()
|
||||
def onTimerTasksChanged(
|
||||
self
|
||||
):
|
||||
|
||||
self.setTimerTasks(copy.deepcopy(self.__timer_tasks))
|
||||
self.updateTimerTaskList()
|
||||
self.updateStat()
|
||||
|
||||
|
||||
@Slot(dict)
|
||||
def onTimerTaskIsRunning(
|
||||
self,
|
||||
timer_task: dict
|
||||
):
|
||||
|
||||
for task in self.__timer_tasks:
|
||||
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 "history" not in timer_task:
|
||||
timer_task["history"] = []
|
||||
if status != ALTimerTaskStatus.OUTDATED:
|
||||
executed_time = datetime.now()
|
||||
duration = (executed_time - timer_task["execute_time"]).total_seconds()
|
||||
timer_task["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["history"].append({
|
||||
"execute_time": (execute_time + timedelta(days=i)).strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"executed_time": (execute_time + timedelta(days=i)).strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"result": status,
|
||||
"duration": 0,
|
||||
"uuid": timer_task["uuid"]
|
||||
})
|
||||
next_time = TimerUtils.calculateNextRepeatTime(
|
||||
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(
|
||||
self,
|
||||
timer_task: dict
|
||||
):
|
||||
|
||||
for task in self.__timer_tasks:
|
||||
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)
|
||||
def onTimerTaskIsError(
|
||||
self,
|
||||
timer_task: dict
|
||||
):
|
||||
|
||||
for task in self.__timer_tasks:
|
||||
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,506 +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 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
|
||||
)
|
||||
from PySide6.QtGui import (
|
||||
QCloseEvent
|
||||
)
|
||||
|
||||
from gui.Ui_ALTimerTaskWidget import Ui_ALTimerTaskWidget
|
||||
from gui.ALAddTimerTaskDialog import ALAddTimerTaskWidget, TimerTaskStatus
|
||||
|
||||
from utils.ConfigReader import ConfigReader
|
||||
from utils.ConfigWriter import ConfigWriter
|
||||
|
||||
|
||||
class SortPolicy(Enum):
|
||||
|
||||
BY_NAME = "按名称"
|
||||
BY_ADD_TIME = "按添加时间"
|
||||
BY_EXECUTE_TIME = "按执行时间"
|
||||
|
||||
|
||||
class TimerTaskItemWidget(QWidget):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent = None,
|
||||
timer_task: dict = None
|
||||
):
|
||||
|
||||
super().__init__(parent)
|
||||
self.__timer_task = timer_task
|
||||
|
||||
self.modifyUi()
|
||||
|
||||
|
||||
def modifyUi(
|
||||
self
|
||||
):
|
||||
|
||||
self.ItemWidgetLayout = QHBoxLayout(self)
|
||||
self.ItemWidgetLayout.setSpacing(10)
|
||||
self.ItemWidgetLayout.setContentsMargins(10, 5, 10, 5)
|
||||
|
||||
self.TaskInfoLayout = QVBoxLayout()
|
||||
self.TaskInfoLayout.setSpacing(5)
|
||||
TaskNameLabel = QLabel(self.__timer_task["name"])
|
||||
TaskNameLabelFont = TaskNameLabel.font()
|
||||
TaskNameLabelFont.setBold(True)
|
||||
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}")
|
||||
ExecuteTimeLabel.setStyleSheet("color: gray;")
|
||||
ExecuteTimeLabel.setFixedHeight(20)
|
||||
self.TaskInfoLayout.addWidget(ExecuteTimeLabel)
|
||||
|
||||
self.ItemWidgetLayout.addLayout(self.TaskInfoLayout)
|
||||
self.ItemWidgetLayout.addStretch()
|
||||
|
||||
match self.__timer_task["status"]:
|
||||
case TimerTaskStatus.PENDING:
|
||||
TaskStatusText = "等待中"
|
||||
TaskStatusColor = "#FF9800"
|
||||
case TimerTaskStatus.READY:
|
||||
TaskStatusText = "已就绪"
|
||||
TaskStatusColor = "#316BFF"
|
||||
case TimerTaskStatus.RUNNING:
|
||||
TaskStatusText = "执行中"
|
||||
TaskStatusColor = "#2294FF"
|
||||
case TimerTaskStatus.EXECUTED:
|
||||
TaskStatusText = "已执行"
|
||||
TaskStatusColor = "#4CAF50"
|
||||
case TimerTaskStatus.ERROR:
|
||||
TaskStatusText = "执行失败"
|
||||
TaskStatusColor = "#FF5722"
|
||||
case TimerTaskStatus.OUTDATED:
|
||||
TaskStatusText = "已过期"
|
||||
TaskStatusColor = "#FF5722"
|
||||
TaskStatusLabel = QLabel(TaskStatusText)
|
||||
TaskStatusLabel.setStyleSheet(f"""
|
||||
QLabel {{
|
||||
background-color: {TaskStatusColor};
|
||||
color: white;
|
||||
border-radius: 5px;
|
||||
font-weight: bold;
|
||||
}}
|
||||
""")
|
||||
TaskStatusLabel.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
TaskStatusLabel.setFixedSize(80, 25)
|
||||
self.ItemWidgetLayout.addWidget(TaskStatusLabel)
|
||||
|
||||
TaskModeText = "静默" if self.__timer_task["silent"] else "显示"
|
||||
TaskModeColor = "#6325FF" if self.__timer_task["silent"] else "#2294FF"
|
||||
TaskModeLabel = QLabel(TaskModeText)
|
||||
TaskModeLabel.setStyleSheet(f"""
|
||||
QLabel {{
|
||||
background-color: {TaskModeColor};
|
||||
color: white;
|
||||
border-radius: 5px;
|
||||
font-weight: bold;
|
||||
}}
|
||||
""")
|
||||
TaskModeLabel.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
TaskModeLabel.setFixedSize(60, 25)
|
||||
self.ItemWidgetLayout.addWidget(TaskModeLabel)
|
||||
|
||||
self.DeleteButton = QPushButton("删除")
|
||||
self.DeleteButton.setFixedSize(80, 25)
|
||||
self.ItemWidgetLayout.addWidget(self.DeleteButton)
|
||||
if self.__timer_task["status"] == TimerTaskStatus.READY\
|
||||
or self.__timer_task["status"] == TimerTaskStatus.RUNNING:
|
||||
self.DeleteButton.setEnabled(False)
|
||||
self.setFixedHeight(55)
|
||||
|
||||
|
||||
class ALTimerTaskWidget(QWidget, Ui_ALTimerTaskWidget):
|
||||
|
||||
timerTasksChanged = Signal()
|
||||
timerTaskIsReady = Signal(dict)
|
||||
timerTaskWidgetClosed = Signal()
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent = None,
|
||||
timer_tasks_config_path: str = ""
|
||||
):
|
||||
|
||||
super().__init__(parent)
|
||||
|
||||
self.__timer_tasks = []
|
||||
self.__check_timer = None
|
||||
self.__sort_policy = SortPolicy.BY_EXECUTE_TIME
|
||||
self.__sort_order = Qt.SortOrder.AscendingOrder
|
||||
self.__timer_tasks_config_path = timer_tasks_config_path
|
||||
|
||||
self.setupUi(self)
|
||||
self.connectSignals()
|
||||
self.setupTimer()
|
||||
if not self.initializeTimerTasks():
|
||||
return
|
||||
|
||||
|
||||
def connectSignals(
|
||||
self
|
||||
):
|
||||
|
||||
self.AddTimerTaskButton.clicked.connect(self.addTask)
|
||||
self.ClearAllTimerTasksButton.clicked.connect(self.clearAllTasks)
|
||||
self.TimerTaskSortTypeComboBox.currentIndexChanged.connect(self.onSortPolicyComboBoxChanged)
|
||||
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)
|
||||
|
||||
|
||||
def initializeTimerTasks(
|
||||
self
|
||||
) -> bool:
|
||||
|
||||
timer_tasks = self.loadTimerTasks(self.__timer_tasks_config_path)
|
||||
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)):
|
||||
QMessageBox.information(
|
||||
self,
|
||||
"信息 - AutoLibrary",
|
||||
f"定时任务配置文件初始化完成: \n{self.__timer_tasks_config_path}"
|
||||
)
|
||||
self.__timer_tasks = timer_tasks
|
||||
self.updateTimerTaskList()
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def loadTimerTasks(
|
||||
self,
|
||||
timer_tasks_config_path: str
|
||||
) -> 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()
|
||||
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["execute_time"] = datetime.strptime(task["execute_time"], "%Y-%m-%d %H:%M:%S")
|
||||
task["status"] = TimerTaskStatus(task["status"])
|
||||
return timer_tasks["timer_tasks"]
|
||||
raise Exception("定时任务配置文件格式错误")
|
||||
except Exception as e:
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
"警告 - AutoLibrary",
|
||||
f"加载定时任务配置发生错误 ! : {e}\n"\
|
||||
f"文件路径: {timer_tasks_config_path}"
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def saveTimerTasks(
|
||||
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["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 }
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
"警告 - AutoLibrary",
|
||||
f"保存定时任务配置发生错误 ! : {e}\n"\
|
||||
f"文件路径: {timer_tasks_config_path}"
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
def showEvent(
|
||||
self,
|
||||
event
|
||||
):
|
||||
|
||||
result = super().showEvent(event)
|
||||
|
||||
screen_rect = self.screen().geometry()
|
||||
target_pos = self.parent().geometry().center()
|
||||
target_pos.setX(target_pos.x() - self.width()//2)
|
||||
target_pos.setY(target_pos.y() - self.height()//2)
|
||||
if target_pos.x() < 0:
|
||||
target_pos.setX(0)
|
||||
if target_pos.x() + self.width() > screen_rect.width():
|
||||
target_pos.setX(screen_rect.width() - self.width())
|
||||
if target_pos.y() < 0:
|
||||
target_pos.setY(0)
|
||||
if target_pos.y() + self.height() > screen_rect.height():
|
||||
target_pos.setY(screen_rect.height() - self.height())
|
||||
self.move(target_pos)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def closeEvent(
|
||||
self,
|
||||
event: QCloseEvent
|
||||
):
|
||||
|
||||
self.hide()
|
||||
self.timerTaskWidgetClosed.emit()
|
||||
event.ignore()
|
||||
|
||||
|
||||
def sortTimerTasks(
|
||||
self,
|
||||
policy: SortPolicy = SortPolicy.BY_EXECUTE_TIME,
|
||||
order: Qt.SortOrder = Qt.SortOrder.AscendingOrder
|
||||
):
|
||||
|
||||
if policy == SortPolicy.BY_NAME:
|
||||
self.__timer_tasks.sort(
|
||||
key = lambda x: x["name"],
|
||||
reverse = order is Qt.SortOrder.DescendingOrder
|
||||
)
|
||||
elif policy == SortPolicy.BY_ADD_TIME:
|
||||
self.__timer_tasks.sort(
|
||||
key = lambda x: x["add_time"],
|
||||
reverse = order is Qt.SortOrder.DescendingOrder
|
||||
)
|
||||
elif policy == SortPolicy.BY_EXECUTE_TIME:
|
||||
self.__timer_tasks.sort(
|
||||
key = lambda x: x["execute_time"],
|
||||
reverse = order is Qt.SortOrder.DescendingOrder
|
||||
)
|
||||
|
||||
|
||||
def updateStat(
|
||||
self
|
||||
):
|
||||
|
||||
pending = 0
|
||||
in_queue = 0
|
||||
executed = 0
|
||||
invalid = 0
|
||||
total = len(self.__timer_tasks)
|
||||
for timer_task in self.__timer_tasks:
|
||||
if timer_task["status"] == TimerTaskStatus.PENDING:
|
||||
pending += 1
|
||||
elif timer_task["status"] == TimerTaskStatus.READY\
|
||||
or timer_task["status"] == TimerTaskStatus.RUNNING:
|
||||
in_queue += 1
|
||||
elif timer_task["status"] == TimerTaskStatus.EXECUTED:
|
||||
executed += 1
|
||||
elif timer_task["status"] == TimerTaskStatus.ERROR\
|
||||
or timer_task["status"] == TimerTaskStatus.OUTDATED:
|
||||
invalid += 1
|
||||
self.TotalTaskLabel.setText(f"总任务:{total}")
|
||||
self.PendingTaskLabel.setText(f"待执行:{pending}")
|
||||
self.InQueueTaskLabel.setText(f"队列中:{in_queue}")
|
||||
self.ExecutedTaskLabel.setText(f"已执行:{executed}")
|
||||
self.InvalidTaskLabel.setText(f"无效的:{invalid}")
|
||||
|
||||
|
||||
def updateTimerTaskList(
|
||||
self
|
||||
):
|
||||
|
||||
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 = TimerTaskItemWidget(self, timer_task)
|
||||
widget.DeleteButton.clicked.connect(
|
||||
lambda _, uuid = timer_task["task_uuid"]: self.deleteTask(uuid)
|
||||
)
|
||||
item.setSizeHint(widget.size())
|
||||
self.TimerTasksListWidget.addItem(item)
|
||||
self.TimerTasksListWidget.setItemWidget(item, widget)
|
||||
|
||||
|
||||
def addTask(
|
||||
self
|
||||
):
|
||||
|
||||
dialog = ALAddTimerTaskWidget(self)
|
||||
if dialog.exec() == QDialog.DialogCode.Accepted:
|
||||
timer_task = dialog.getTimerTask()
|
||||
self.__timer_tasks.append(timer_task)
|
||||
self.timerTasksChanged.emit()
|
||||
|
||||
|
||||
def deleteTask(
|
||||
self,
|
||||
task_uuid: str
|
||||
):
|
||||
|
||||
self.__timer_tasks = [
|
||||
x for x in self.__timer_tasks
|
||||
if x["task_uuid"] != task_uuid
|
||||
]
|
||||
self.timerTasksChanged.emit()
|
||||
|
||||
|
||||
def clearAllTasks(
|
||||
self
|
||||
):
|
||||
|
||||
if not self.__timer_tasks:
|
||||
return
|
||||
result = QMessageBox.question(
|
||||
self,
|
||||
"确认 - AutoLibrary",
|
||||
"是否要清除所有定时任务 ?",
|
||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
|
||||
)
|
||||
if result is QMessageBox.StandardButton.No:
|
||||
return
|
||||
in_queue_tasks = [
|
||||
x for x in self.__timer_tasks
|
||||
if x["status"] == TimerTaskStatus.READY
|
||||
or x["status"] == TimerTaskStatus.RUNNING
|
||||
]
|
||||
in_queue_count = len(in_queue_tasks)
|
||||
if in_queue_count > 0:
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
"警告 - AutoLibrary",
|
||||
"存在正在执行或已就绪的队列任务,无法清除所有定时任务 !"
|
||||
)
|
||||
self.__timer_tasks = in_queue_tasks
|
||||
self.timerTasksChanged.emit()
|
||||
|
||||
|
||||
def checkTasks(
|
||||
self
|
||||
):
|
||||
|
||||
need_update = False
|
||||
|
||||
now = datetime.now()
|
||||
for timer_task in self.__timer_tasks:
|
||||
if timer_task["execute_time"] > now:
|
||||
continue
|
||||
if timer_task["status"] is not TimerTaskStatus.PENDING:
|
||||
continue
|
||||
if timer_task["execute_time"] <= now + timedelta(seconds = -5):
|
||||
timer_task["status"] = TimerTaskStatus.OUTDATED
|
||||
need_update = True
|
||||
else:
|
||||
timer_task["status"] = TimerTaskStatus.READY
|
||||
self.timerTaskIsReady.emit(timer_task)
|
||||
need_update = True
|
||||
if need_update:
|
||||
self.timerTasksChanged.emit()
|
||||
|
||||
@Slot(int)
|
||||
def onSortPolicyComboBoxChanged(
|
||||
self,
|
||||
policy: int
|
||||
):
|
||||
|
||||
mapping = {
|
||||
0: SortPolicy.BY_NAME,
|
||||
1: SortPolicy.BY_ADD_TIME,
|
||||
2: SortPolicy.BY_EXECUTE_TIME
|
||||
}
|
||||
self.__sort_policy = mapping[policy]
|
||||
self.updateTimerTaskList()
|
||||
|
||||
@Slot()
|
||||
def onSortOrderToggleButtonClicked(
|
||||
self
|
||||
):
|
||||
|
||||
self.__sort_order = Qt.SortOrder.AscendingOrder\
|
||||
if self.__sort_order is Qt.SortOrder.DescendingOrder\
|
||||
else Qt.SortOrder.DescendingOrder
|
||||
self.TimerTaskSortOrderToggleButton.setText(
|
||||
"↑" if self.__sort_order is Qt.SortOrder.AscendingOrder else "↓"
|
||||
)
|
||||
self.updateTimerTaskList()
|
||||
|
||||
@Slot()
|
||||
def onTimerTasksChanged(
|
||||
self
|
||||
):
|
||||
|
||||
self.saveTimerTasks(self.__timer_tasks_config_path, copy.deepcopy(self.__timer_tasks))
|
||||
self.updateTimerTaskList()
|
||||
self.updateStat()
|
||||
|
||||
|
||||
@Slot(dict)
|
||||
def onTimerTaskIsRunning(
|
||||
self,
|
||||
timer_task: dict
|
||||
):
|
||||
|
||||
for task in self.__timer_tasks:
|
||||
if task["task_uuid"] == timer_task["task_uuid"]:
|
||||
task["status"] = TimerTaskStatus.RUNNING
|
||||
self.timerTasksChanged.emit()
|
||||
|
||||
|
||||
@Slot(dict)
|
||||
def onTimerTaskIsExecuted(
|
||||
self,
|
||||
timer_task: dict
|
||||
):
|
||||
|
||||
for task in self.__timer_tasks:
|
||||
if task["task_uuid"] == timer_task["task_uuid"]:
|
||||
task["status"] = TimerTaskStatus.EXECUTED
|
||||
self.timerTasksChanged.emit()
|
||||
|
||||
@Slot(dict)
|
||||
def onTimerTaskIsError(
|
||||
self,
|
||||
timer_task: dict
|
||||
):
|
||||
|
||||
for task in self.__timer_tasks:
|
||||
if task["task_uuid"] == timer_task["task_uuid"]:
|
||||
task["status"] = TimerTaskStatus.ERROR
|
||||
self.timerTasksChanged.emit()
|
||||
@@ -21,7 +21,7 @@ from PySide6.QtGui import (
|
||||
)
|
||||
|
||||
|
||||
class TreeItemType(Enum):
|
||||
class ALUserTreeItemType(Enum):
|
||||
|
||||
GROUP = 0
|
||||
USER = 1
|
||||
@@ -111,15 +111,15 @@ class ALUserTreeWidget(QTreeWidget):
|
||||
if source_item is None:
|
||||
event.ignore()
|
||||
return
|
||||
if source_item.type() == TreeItemType.GROUP.value:
|
||||
if source_item.type() == ALUserTreeItemType.GROUP.value:
|
||||
if target_item is not None:
|
||||
event.ignore()
|
||||
return
|
||||
elif source_item.type() == TreeItemType.USER.value:
|
||||
elif source_item.type() == ALUserTreeItemType.USER.value:
|
||||
if target_item is None:
|
||||
event.ignore()
|
||||
return
|
||||
if target_item.type() != TreeItemType.GROUP.value:
|
||||
if target_item.type() != ALUserTreeItemType.GROUP.value:
|
||||
event.ignore()
|
||||
return
|
||||
if target_item.checkState(1) == Qt.CheckState.Unchecked:
|
||||
|
||||
@@ -5,11 +5,11 @@
|
||||
workflow process. Do not edit manually.
|
||||
|
||||
This file is auto-generated during the workflow process.
|
||||
Last updated: 2026-01-17 17:51:55 UTC
|
||||
Last updated: 2026-03-22 14:14:19 UTC
|
||||
"""
|
||||
|
||||
AL_VERSION = "1.0.3"
|
||||
AL_TAG = "v1.0.3"
|
||||
AL_VERSION = "1.2.1"
|
||||
AL_TAG = "v1.2.1"
|
||||
AL_COMMIT_SHA = "local"
|
||||
AL_COMMIT_DATE = "null" # time zone : UTC
|
||||
AL_BUILD_DATE = "null" # time zone : UTC
|
||||
|
||||
@@ -0,0 +1,576 @@
|
||||
# -*- 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 webdriver_manager_instance,
|
||||
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 = webdriver_manager_instance(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_idx = 0
|
||||
for i, driver_info in enumerate(self.__driver_infos):
|
||||
if driver_info.driver_status == WebDriverStatus.INSTALLED:
|
||||
installed_idx = i # get the installed driver index
|
||||
display_text = f"{driver_info.driver_type.value} - {driver_info.browser_version}"
|
||||
self.DriverComboBox.addItem(display_text)
|
||||
count = len(self.__driver_infos)
|
||||
self.BrowserCountLabel.setText(f"检测到 {count} 个可用浏览器:")
|
||||
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)
|
||||
|
||||
|
||||
@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()
|
||||
|
||||
|
||||
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)
|
||||
@@ -0,0 +1,18 @@
|
||||
"""
|
||||
GUI module for the AutoLibrary project.
|
||||
|
||||
Here are the classes and modules in this package:
|
||||
- ALMainWindow: Main window class.
|
||||
- ALAboutDialog: About dialog class.
|
||||
- ALConfigWidget: Configuration widget class.
|
||||
- ALSeatFrame: Seat frame class.
|
||||
- ALSeatMapView: Seat map view class.
|
||||
- ALSeatMapTable: Seat map table class.
|
||||
- ALSeatMapSelectDialog: Seat map select dialog class.
|
||||
- ALTimerTaskAddDialog: Timer task add dialog class.
|
||||
- ALTimerTaskHistoryDialog: Timer task history dialog class.
|
||||
- ALTimerTaskManageWidget: Timer task manage widget class.
|
||||
- ALUserTreeWidget: User tree widget class.
|
||||
- ALMainWorkers: Main workers class.
|
||||
- ALVersionInfo: Version info class.
|
||||
"""
|
||||
@@ -1 +0,0 @@
|
||||
this folder is used to store the batch scripts.
|
||||
@@ -1 +0,0 @@
|
||||
this folder is used to store the config files.
|
||||
@@ -0,0 +1,3 @@
|
||||
"""
|
||||
GUI resources module for the AutoLibrary project.
|
||||
"""
|
||||
|
Before Width: | Height: | Size: 785 KiB After Width: | Height: | Size: 785 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 21 KiB |
@@ -104,7 +104,7 @@
|
||||
<number>0</number>
|
||||
</property>
|
||||
<item>
|
||||
<widget class="QFrame" name="frame">
|
||||
<widget class="QFrame" name="AboutInfoSpaceFrame">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>56</width>
|
||||
@@ -129,21 +129,24 @@
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QTextEdit" name="AboutInfoEdit">
|
||||
<property name="font">
|
||||
<font>
|
||||
<family>Courier New</family>
|
||||
<bold>false</bold>
|
||||
</font>
|
||||
<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="readOnly">
|
||||
<property name="openExternalLinks">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
<property name="textInteractionFlags">
|
||||
<set>Qt::TextInteractionFlag::TextBrowserInteraction</set>
|
||||
<property name="openLinks">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
@@ -94,19 +94,19 @@
|
||||
</property>
|
||||
<layout class="QVBoxLayout" name="UserListLayout">
|
||||
<property name="spacing">
|
||||
<number>0</number>
|
||||
<number>5</number>
|
||||
</property>
|
||||
<property name="leftMargin">
|
||||
<number>5</number>
|
||||
<number>3</number>
|
||||
</property>
|
||||
<property name="topMargin">
|
||||
<number>5</number>
|
||||
<number>3</number>
|
||||
</property>
|
||||
<property name="rightMargin">
|
||||
<number>5</number>
|
||||
<number>3</number>
|
||||
</property>
|
||||
<property name="bottomMargin">
|
||||
<number>5</number>
|
||||
<number>3</number>
|
||||
</property>
|
||||
<item>
|
||||
<widget class="QTreeWidget" name="UserTreeWidget">
|
||||
@@ -178,6 +178,9 @@
|
||||
</item>
|
||||
<item>
|
||||
<layout class="QHBoxLayout" name="UserListControlLayout">
|
||||
<property name="spacing">
|
||||
<number>5</number>
|
||||
</property>
|
||||
<item>
|
||||
<widget class="QPushButton" name="DelUserButton">
|
||||
<property name="minimumSize">
|
||||
@@ -192,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>
|
||||
@@ -309,7 +317,7 @@
|
||||
<item row="2" column="1">
|
||||
<layout class="QHBoxLayout" name="PasswordLayout">
|
||||
<property name="spacing">
|
||||
<number>0</number>
|
||||
<number>5</number>
|
||||
</property>
|
||||
<item>
|
||||
<widget class="QLineEdit" name="PasswordEdit">
|
||||
@@ -546,7 +554,7 @@
|
||||
<item row="4" column="4">
|
||||
<layout class="QHBoxLayout" name="SeatIDLayout">
|
||||
<property name="spacing">
|
||||
<number>0</number>
|
||||
<number>5</number>
|
||||
</property>
|
||||
<item>
|
||||
<widget class="QLineEdit" name="SeatIDEdit">
|
||||
@@ -703,7 +711,7 @@
|
||||
<item row="10" column="4">
|
||||
<layout class="QHBoxLayout" name="EndTimeDiffLayout">
|
||||
<property name="spacing">
|
||||
<number>0</number>
|
||||
<number>5</number>
|
||||
</property>
|
||||
<item>
|
||||
<widget class="QSpinBox" name="MaxEndTimeDiffSpinBox">
|
||||
@@ -899,7 +907,7 @@
|
||||
<item row="7" column="4">
|
||||
<layout class="QHBoxLayout" name="BeginTimeDiffLayout">
|
||||
<property name="spacing">
|
||||
<number>0</number>
|
||||
<number>5</number>
|
||||
</property>
|
||||
<item>
|
||||
<widget class="QSpinBox" name="MaxBeginTimeDiffSpinBox">
|
||||
@@ -1049,7 +1057,7 @@
|
||||
<item row="15" column="4">
|
||||
<layout class="QHBoxLayout" name="RenewTimeDiffLayout">
|
||||
<property name="spacing">
|
||||
<number>0</number>
|
||||
<number>5</number>
|
||||
</property>
|
||||
<item>
|
||||
<widget class="QSpinBox" name="MaxRenewTimeDiffSpinBox">
|
||||
@@ -1092,7 +1100,7 @@
|
||||
</layout>
|
||||
</item>
|
||||
<item row="15" column="1">
|
||||
<widget class="QLabel" name="label">
|
||||
<widget class="QLabel" name="MaxRenewTimeDiffLabel">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>80</width>
|
||||
@@ -1153,10 +1161,10 @@
|
||||
<property name="rightMargin">
|
||||
<number>3</number>
|
||||
</property>
|
||||
<property name="horizontalSpacing">
|
||||
<property name="bottomMargin">
|
||||
<number>3</number>
|
||||
</property>
|
||||
<property name="verticalSpacing">
|
||||
<property name="spacing">
|
||||
<number>5</number>
|
||||
</property>
|
||||
<item row="0" column="0" colspan="2">
|
||||
@@ -1225,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>
|
||||
@@ -1247,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.cv/docs/manual_lists.html"><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>
|
||||
@@ -1521,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>
|
||||
@@ -1542,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">
|
||||
@@ -1649,6 +1688,21 @@
|
||||
<string>当前配置</string>
|
||||
</property>
|
||||
<layout class="QGridLayout" name="CurrentConfigLayout">
|
||||
<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>
|
||||
<property name="spacing">
|
||||
<number>5</number>
|
||||
</property>
|
||||
<item row="1" column="0">
|
||||
<widget class="QLineEdit" name="CurrentRunConfigEdit">
|
||||
<property name="minimumSize">
|
||||
@@ -1775,6 +1829,21 @@
|
||||
<string>导出路径</string>
|
||||
</property>
|
||||
<layout class="QGridLayout" name="ExportConfigLayout">
|
||||
<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>
|
||||
<property name="spacing">
|
||||
<number>5</number>
|
||||
</property>
|
||||
<item row="3" column="0">
|
||||
<widget class="QLineEdit" name="ExportUserConfigEdit">
|
||||
<property name="minimumSize">
|
||||
@@ -34,13 +34,13 @@
|
||||
<number>5</number>
|
||||
</property>
|
||||
<property name="leftMargin">
|
||||
<number>3</number>
|
||||
<number>5</number>
|
||||
</property>
|
||||
<property name="topMargin">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<property name="rightMargin">
|
||||
<number>3</number>
|
||||
<number>5</number>
|
||||
</property>
|
||||
<property name="bottomMargin">
|
||||
<number>0</number>
|
||||
@@ -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>
|
||||
@@ -156,11 +156,9 @@
|
||||
<string><html><head/><body><p><br/></p></body></html></string>
|
||||
</property>
|
||||
<property name="styleSheet">
|
||||
<string notr="true">background-color: rgb(10, 170, 10);
|
||||
font: 12pt "Microsoft YaHei UI";
|
||||
color: rgb(255, 255, 255);
|
||||
font: 9pt "Segoe UI";
|
||||
font: 700 9pt "Microsoft YaHei UI";</string>
|
||||
<string notr="true">background-color: #0AAA0A;
|
||||
color: #FFFFFF;
|
||||
font: 700 9pt;</string>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>启动脚本</string>
|
||||
@@ -0,0 +1,496 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ui version="4.0">
|
||||
<class>ALTimerTaskAddDialog</class>
|
||||
<widget class="QDialog" name="ALTimerTaskAddDialog">
|
||||
<property name="geometry">
|
||||
<rect>
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>350</width>
|
||||
<height>400</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>350</width>
|
||||
<height>400</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="maximumSize">
|
||||
<size>
|
||||
<width>350</width>
|
||||
<height>500</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="windowTitle">
|
||||
<string>添加定时任务 - AutoLibrary</string>
|
||||
</property>
|
||||
<property name="sizeGripEnabled">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
<property name="modal">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
<layout class="QVBoxLayout" name="ALAddTimerTaskLayout">
|
||||
<property name="spacing">
|
||||
<number>5</number>
|
||||
</property>
|
||||
<property name="leftMargin">
|
||||
<number>5</number>
|
||||
</property>
|
||||
<property name="topMargin">
|
||||
<number>5</number>
|
||||
</property>
|
||||
<property name="rightMargin">
|
||||
<number>5</number>
|
||||
</property>
|
||||
<property name="bottomMargin">
|
||||
<number>5</number>
|
||||
</property>
|
||||
<item>
|
||||
<layout class="QHBoxLayout" name="TaskNameLayout">
|
||||
<property name="spacing">
|
||||
<number>5</number>
|
||||
</property>
|
||||
<item>
|
||||
<widget class="QLabel" name="TaskNameLabel">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>60</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="QLineEdit" name="TaskNameLineEdit"/>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QGroupBox" name="TimerConfigGroupBox">
|
||||
<property name="title">
|
||||
<string>定时设置</string>
|
||||
</property>
|
||||
<layout class="QVBoxLayout" name="TimerConfigLayout">
|
||||
<property name="spacing">
|
||||
<number>5</number>
|
||||
</property>
|
||||
<property name="leftMargin">
|
||||
<number>3</number>
|
||||
</property>
|
||||
<property name="topMargin">
|
||||
<number>3</number>
|
||||
</property>
|
||||
<property name="rightMargin">
|
||||
<number>3</number>
|
||||
</property>
|
||||
<property name="bottomMargin">
|
||||
<number>3</number>
|
||||
</property>
|
||||
<item>
|
||||
<layout class="QHBoxLayout" name="TimerTypeSelectLayout">
|
||||
<property name="spacing">
|
||||
<number>5</number>
|
||||
</property>
|
||||
<item>
|
||||
<widget class="QLabel" name="TimerTypeLabel">
|
||||
<property name="text">
|
||||
<string>定时类型:</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QComboBox" name="TimerTypeComboBox">
|
||||
<item>
|
||||
<property name="text">
|
||||
<string>特定时间</string>
|
||||
</property>
|
||||
</item>
|
||||
<item>
|
||||
<property name="text">
|
||||
<string>相对时间</string>
|
||||
</property>
|
||||
</item>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QGroupBox" name="TaskConfigGroupBox">
|
||||
<property name="title">
|
||||
<string>运行设置</string>
|
||||
</property>
|
||||
<layout class="QGridLayout" name="TaskConfigLayout">
|
||||
<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>
|
||||
<property name="spacing">
|
||||
<number>5</number>
|
||||
</property>
|
||||
<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>
|
||||
<property name="checkable">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
<property name="checked">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
<property name="autoRepeat">
|
||||
<bool>false</bool>
|
||||
</property>
|
||||
<property name="autoExclusive">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<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>
|
||||
<item>
|
||||
<layout class="QHBoxLayout" name="ControLayout">
|
||||
<property name="spacing">
|
||||
<number>5</number>
|
||||
</property>
|
||||
<item>
|
||||
<widget class="QFrame" name="ControlSpaceFrame">
|
||||
<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="QPushButton" name="CancelButton">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>80</width>
|
||||
<height>25</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="maximumSize">
|
||||
<size>
|
||||
<width>80</width>
|
||||
<height>25</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>取消</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QPushButton" name="ConfirmButton">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>80</width>
|
||||
<height>25</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="maximumSize">
|
||||
<size>
|
||||
<width>80</width>
|
||||
<height>25</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>添加</string>
|
||||
</property>
|
||||
<property name="autoDefault">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
<property name="default">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<resources/>
|
||||
<connections/>
|
||||
</ui>
|
||||
@@ -1,31 +1,31 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ui version="4.0">
|
||||
<class>ALTimerTaskWidget</class>
|
||||
<widget class="QWidget" name="ALTimerTaskWidget">
|
||||
<class>ALTimerTaskManageWidget</class>
|
||||
<widget class="QWidget" name="ALTimerTaskManageWidget">
|
||||
<property name="geometry">
|
||||
<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>
|
||||
<string>定时任务管理 - AutoLibrary</string>
|
||||
</property>
|
||||
<layout class="QVBoxLayout" name="ALTimerTaskWidgetLayout">
|
||||
<layout class="QVBoxLayout" name="ALTimerTaskManageWidgetLayout">
|
||||
<property name="spacing">
|
||||
<number>5</number>
|
||||
</property>
|
||||
@@ -153,7 +153,7 @@
|
||||
</property>
|
||||
<property name="styleSheet">
|
||||
<string notr="true">QLabel {
|
||||
color: #FF5722
|
||||
color: #DC0000
|
||||
}</string>
|
||||
</property>
|
||||
<property name="text">
|
||||
@@ -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,8 @@
|
||||
"""
|
||||
Managers module for the AutoLibrary project.
|
||||
|
||||
Here are the classes and modules in this package:
|
||||
- ConfigManager: Config manager for managing configuration files.
|
||||
- LogManager: Log manager for logging.
|
||||
- WebDriverManager: Web driver manager for managing web drivers.
|
||||
"""
|
||||
@@ -0,0 +1,245 @@
|
||||
# -*- 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 enum import Enum
|
||||
from typing import Any, Optional
|
||||
|
||||
from utils.JSONReader import JSONReader
|
||||
from utils.JSONWriter import JSONWriter
|
||||
|
||||
|
||||
# This config manager class only responsible for global and other
|
||||
# unconfigurable config files.
|
||||
|
||||
|
||||
class ConfigType(Enum):
|
||||
"""
|
||||
Config type class. Values represent the default filename.
|
||||
"""
|
||||
GLOBAL = "autolibrary.json" # Global config file.
|
||||
BULLETIN = "bulletin.json" # Bulletin board config file.
|
||||
TIMERTASK = "timer_task.json" # Timer task config file.
|
||||
|
||||
|
||||
class ConfigTemplate:
|
||||
"""
|
||||
Config template class.
|
||||
"""
|
||||
|
||||
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,
|
||||
config_type: ConfigType,
|
||||
key: str = "",
|
||||
default: Optional[Any] = None
|
||||
) -> Any:
|
||||
|
||||
with self.__config_lock:
|
||||
config_data = self.__config_data[config_type.value]
|
||||
if key == "":
|
||||
return config_data
|
||||
keys = key.split('.')
|
||||
for k in keys[:-1]:
|
||||
config_data = config_data.get(k, None)
|
||||
if config_data is None:
|
||||
return default
|
||||
return config_data.get(keys[-1], default)
|
||||
|
||||
|
||||
def set(
|
||||
self,
|
||||
config_type: ConfigType,
|
||||
key: str = "",
|
||||
value: Any = None
|
||||
):
|
||||
|
||||
with self.__config_lock:
|
||||
root_data = self.__config_data[config_type.value]
|
||||
if key == "":
|
||||
self.__config_data[config_type.value] = value
|
||||
else:
|
||||
keys = key.split('.')
|
||||
config_data = root_data
|
||||
for k in keys[:-1]:
|
||||
if k not in config_data:
|
||||
config_data[k] = {}
|
||||
config_data = config_data[k]
|
||||
config_data[keys[-1]] = value
|
||||
self.save(config_type)
|
||||
|
||||
|
||||
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 = None
|
||||
|
||||
# Utility functions.
|
||||
#
|
||||
# Utility function to get validated automation config paths.
|
||||
def getValidateAutomationConfigPaths(
|
||||
) -> dict:
|
||||
"""
|
||||
Get validated automation config paths from ConfigManager instance.
|
||||
These function will validate the config paths and return the validated paths in a dict.
|
||||
|
||||
Returns:
|
||||
dict: Validated automation config paths.
|
||||
"""
|
||||
config_paths = {"run": "", "user": ""}
|
||||
auto_config = _config_manager_instance.get(ConfigType.GLOBAL, "automation", {})
|
||||
for cfg_type in ["run", "user"]:
|
||||
paths = auto_config.get(f"{cfg_type}_path", {}).get("paths", [])
|
||||
index = auto_config.get(f"{cfg_type}_path", {}).get("current", 0)
|
||||
if paths == []:
|
||||
paths.append(os.path.join(_config_manager_instance.configDir(), f"{cfg_type}.json"))
|
||||
if index < 0:
|
||||
index = 0
|
||||
if index >= len(paths):
|
||||
index = len(paths) - 1
|
||||
config_paths[cfg_type] = paths[index]
|
||||
data = {"current": index, "paths": paths}
|
||||
auto_config[f"{cfg_type}_path"] = data
|
||||
_config_manager_instance.set(ConfigType.GLOBAL, "automation", auto_config)
|
||||
return config_paths
|
||||
|
||||
# Utility function to get base config directory.
|
||||
def getBaseConfigDir(
|
||||
) -> str:
|
||||
"""
|
||||
Get base config directory, on Windows, it is usually at :
|
||||
'C:\\Users\\<username>\\AppData\\Local\\AutoLibrary\\config'.
|
||||
|
||||
Returns:
|
||||
str: Base config directory.
|
||||
"""
|
||||
|
||||
return _config_manager_instance.configDir()
|
||||
|
||||
# Singleton instance of ConfigManager.
|
||||
_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 getBaseConfigDir() != config_dir:
|
||||
raise ValueError("ConfigManager 的实例已初始化,不能使用不同的配置目录。")
|
||||
return _config_manager_instance
|
||||
@@ -0,0 +1,6 @@
|
||||
"""
|
||||
Config managers module for the AutoLibrary project.
|
||||
|
||||
Here are the classes and modules in this package:
|
||||
- ConfigManager: Config manager for managing configuration files.
|
||||
"""
|
||||
@@ -0,0 +1,175 @@
|
||||
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,452 @@
|
||||
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,
|
||||
cancel_event: Optional[threading.Event] = None
|
||||
) -> Optional[Path]:
|
||||
|
||||
try:
|
||||
# downlaod file : 0% - 98%
|
||||
if not self._download(progress_callback, cancel_event=cancel_event):
|
||||
return None
|
||||
# verify file : 98% - 99%
|
||||
if not self._verify(progress_callback):
|
||||
progress_callback(0, 100, 0.0, "验证失败")
|
||||
return None
|
||||
# extract file : 99% - 100%
|
||||
driver_path = self._extract(progress_callback)
|
||||
if not driver_path:
|
||||
progress_callback(0, 100, 0.0, "解压失败")
|
||||
return None
|
||||
return driver_path
|
||||
except Exception as e:
|
||||
raise e
|
||||
|
||||
|
||||
def _download(
|
||||
self,
|
||||
progress_callback: Optional[Callable[[float, int, float, str], None]] = None,
|
||||
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()
|
||||
|
||||
|
||||
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,471 @@
|
||||
# -*- 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,8 @@
|
||||
"""
|
||||
Driver managers module for the AutoLibrary project.
|
||||
|
||||
Here are the classes and modules in this package:
|
||||
- WebBrowserDetector: Web browser detector class.
|
||||
- WebDriverDownloader: Web driver downloader class.
|
||||
- WebDriverManager: Web driver manager class.
|
||||
"""
|
||||
@@ -0,0 +1,196 @@
|
||||
# -*- 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
|
||||
|
||||
|
||||
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,6 @@
|
||||
"""
|
||||
Log managers module for the AutoLibrary project.
|
||||
|
||||
Here are the classes and modules in this package:
|
||||
- LogManager: Log manager for logging.
|
||||
"""
|
||||
+60
-25
@@ -11,6 +11,7 @@ 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
|
||||
@@ -41,10 +42,11 @@ class AutoLib(MsgBase):
|
||||
self.__user_config = None
|
||||
self.__driver = None
|
||||
if not self.__initBrowserDriver():
|
||||
raise Exception("浏览器驱动初始化失败")
|
||||
raise Exception("浏览器驱动初始化失败 !")
|
||||
else:
|
||||
if not self.__initDriverUrl():
|
||||
raise Exception("浏览器驱动URL初始化失败")
|
||||
self.close()
|
||||
raise Exception("浏览器驱动URL初始化失败 !")
|
||||
self.__initLibOperators()
|
||||
|
||||
|
||||
@@ -52,7 +54,7 @@ class AutoLib(MsgBase):
|
||||
self
|
||||
) -> bool:
|
||||
|
||||
self._showTrace("正在初始化浏览器驱动......")
|
||||
self._showTrace("正在初始化浏览器驱动......", no_log=True)
|
||||
|
||||
web_driver_config = self.__run_config.get("web_driver", None)
|
||||
self.__driver_type = web_driver_config.get("driver_type")
|
||||
@@ -64,10 +66,14 @@ class AutoLib(MsgBase):
|
||||
case "firefox":
|
||||
driver_options = webdriver.FirefoxOptions()
|
||||
case _:
|
||||
raise Exception(f"不支持的浏览器驱动类型: {self.__driver_type} !")
|
||||
self._showTrace(
|
||||
f"不支持的浏览器驱动类型: {self.__driver_type} !",
|
||||
self.TraceLevel.WARNING
|
||||
)
|
||||
return False
|
||||
|
||||
if not web_driver_config:
|
||||
self._showTrace("未配置浏览器驱动参数 !")
|
||||
self._showTrace("未配置浏览器驱动参数 !", self.TraceLevel.ERROR)
|
||||
return False
|
||||
if web_driver_config.get("headless"):
|
||||
driver_options.add_argument("--headless")
|
||||
@@ -107,7 +113,8 @@ class AutoLib(MsgBase):
|
||||
# init browser driver
|
||||
self.__driver_path = web_driver_config.get("driver_path")
|
||||
if not self.__driver_path:
|
||||
raise Exception(f"未配置浏览器驱动路径 !")
|
||||
self._showTrace("未配置浏览器驱动路径 !", self.TraceLevel.WARNING)
|
||||
return False
|
||||
self.__driver_path = os.path.abspath(self.__driver_path)
|
||||
try:
|
||||
service = None
|
||||
@@ -119,17 +126,18 @@ class AutoLib(MsgBase):
|
||||
service = ChromeService(executable_path=self.__driver_path)
|
||||
self.__driver = webdriver.Chrome(service=service, options=driver_options)
|
||||
case "firefox":
|
||||
self._showTrace(f"Firefox 浏览器驱动初始化略慢, 请耐心等待...")
|
||||
self._showTrace(f"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}")
|
||||
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}")
|
||||
self._showTrace(f"浏览器驱动初始化失败: {e}", self.TraceLevel.ERROR)
|
||||
return False
|
||||
self._showTrace(f"浏览器驱动已初始化, 类型: {self.__driver_type}, 路径: {self.__driver_path}")
|
||||
return True
|
||||
@@ -140,7 +148,7 @@ class AutoLib(MsgBase):
|
||||
):
|
||||
|
||||
if not self.__driver:
|
||||
self._showTrace(f"浏览器驱动未初始化, 请先初始化浏览器驱动 !")
|
||||
self._showTrace(f"浏览器驱动未初始化, 请先初始化浏览器驱动 !", self.TraceLevel.WARNING)
|
||||
return
|
||||
self.__lib_checker = LibChecker(self._input_queue, self._output_queue, self.__driver)
|
||||
self.__lib_login = LibLogin(self._input_queue, self._output_queue, self.__driver)
|
||||
@@ -173,7 +181,7 @@ class AutoLib(MsgBase):
|
||||
)
|
||||
return True
|
||||
except:
|
||||
self._showTrace(f"登录页面加载失败 !")
|
||||
self._showTrace(f"登录页面加载失败 !", self.TraceLevel.ERROR)
|
||||
return False
|
||||
|
||||
|
||||
@@ -183,10 +191,18 @@ class AutoLib(MsgBase):
|
||||
|
||||
lib_config = self.__run_config.get("library", None)
|
||||
if not lib_config:
|
||||
self._showError("未配置图书馆参数 !")
|
||||
self._showTrace("未配置图书馆参数 !", self.TraceLevel.ERROR)
|
||||
return False
|
||||
url = lib_config.get("host_url") + lib_config.get("login_url")
|
||||
self.__driver.get(url)
|
||||
self.__driver.set_page_load_timeout(5)
|
||||
try:
|
||||
self.__driver.get(url)
|
||||
except TimeoutException:
|
||||
self.__driver.execute_script("window.stop();")
|
||||
self._showTrace(
|
||||
f"图书馆登录页面加载超时 ! 请检查网络环境是否正常", self.TraceLevel.ERROR
|
||||
)
|
||||
return False
|
||||
if not self.__waitResponseLoad():
|
||||
return False
|
||||
return True
|
||||
@@ -227,31 +243,45 @@ class AutoLib(MsgBase):
|
||||
else:
|
||||
result = 1
|
||||
else:
|
||||
self._showTrace(f"用户 {username} 无法预约,已跳过")
|
||||
self._showTrace(f"用户 {username} 无法预约, 已跳过")
|
||||
result = 2
|
||||
|
||||
# checkin
|
||||
if run_mode["auto_checkin"] and result == 2:
|
||||
last_result = result
|
||||
if run_mode["auto_checkin"] and last_result != 1:
|
||||
if self.__lib_checker.canCheckin():
|
||||
if self.__lib_checkin.checkin(username):
|
||||
result = 0
|
||||
else:
|
||||
result = 1
|
||||
else:
|
||||
self._showTrace(f"用户 {username} 无法签到,已跳过")
|
||||
self._showTrace(f"用户 {username} 无法签到, 已跳过")
|
||||
result = 2
|
||||
if last_result == 0: # partly success
|
||||
result = 0
|
||||
|
||||
# renewal
|
||||
if run_mode["auto_renewal"] and result == 2:
|
||||
if record := self.__lib_checker.canRenew():
|
||||
last_result = result
|
||||
if run_mode["auto_renewal"] and last_result != 1:
|
||||
can_renew, record = self.__lib_checker.canRenew()
|
||||
if can_renew:
|
||||
if self.__lib_renew.renew(username, record, reserve_info):
|
||||
if self.__lib_checker.postRenewCheck(record):
|
||||
self._showTrace(f"用户 {username} 续约成功 !")
|
||||
result = 0
|
||||
else:
|
||||
result = 1
|
||||
if result != 1: # partly success
|
||||
result = 0
|
||||
else:
|
||||
result = 1
|
||||
else:
|
||||
result = 1
|
||||
else:
|
||||
self._showTrace(f"用户 {username} 无法续约,已跳过")
|
||||
self._showTrace(f"用户 {username} 无法续约, 已跳过")
|
||||
result = 2
|
||||
if last_result == 0: # partly success
|
||||
result = 0
|
||||
|
||||
# logout
|
||||
if not self.__lib_logout.logout(
|
||||
username
|
||||
@@ -276,7 +306,8 @@ class AutoLib(MsgBase):
|
||||
for user in users:
|
||||
user_counter["current"] += 1
|
||||
self._showTrace(
|
||||
f"正在处理第 {user_counter["current"]}/{len(users)} 个用户: {user["username"]}......"
|
||||
f"正在处理第 {user_counter["current"]}/{len(users)} 个用户: {user["username"]}......",
|
||||
no_log=True
|
||||
)
|
||||
if not user["enabled"]:
|
||||
self._showTrace(f"用户 {user["username"]} 已跳过")
|
||||
@@ -291,7 +322,8 @@ class AutoLib(MsgBase):
|
||||
)
|
||||
if r == -1:
|
||||
self._showTrace(
|
||||
f"用户 {user["username"]} 处理过程中页面发生异常,无法继续操作, 任务已终止 !"
|
||||
f"用户 {user["username"]} 处理过程中页面发生异常, 无法继续操作, 任务已终止 !",
|
||||
self.TraceLevel.WARNING
|
||||
)
|
||||
break
|
||||
elif r == 0:
|
||||
@@ -314,11 +346,14 @@ class AutoLib(MsgBase):
|
||||
|
||||
if self.__driver:
|
||||
if self.__driver_type.lower() == "firefox":
|
||||
self._showTrace(f"Firefox 浏览器驱动关闭略慢, 请耐心等待...")
|
||||
self._showTrace(
|
||||
f"Firefox 浏览器驱动关闭略慢, 请耐心等待...",
|
||||
no_log=True
|
||||
)
|
||||
self.__driver.quit()
|
||||
self.__driver = None
|
||||
self._showTrace(f"浏览器驱动已关闭")
|
||||
return True
|
||||
else:
|
||||
self._showTrace(f"浏览器驱动未初始化, 无需关闭")
|
||||
self._showTrace(f"浏览器驱动未初始化, 无需关闭", no_log=True)
|
||||
return False
|
||||
+14
-12
@@ -63,7 +63,7 @@ class LibChecker(LibOperator):
|
||||
EC.presence_of_element_located((By.CLASS_NAME, "myReserveList"))
|
||||
)
|
||||
except:
|
||||
self._showTrace("加载预约记录页面失败 !")
|
||||
self._showTrace("加载预约记录页面失败 !", self.TraceLevel.ERROR)
|
||||
return False
|
||||
return True
|
||||
|
||||
@@ -174,7 +174,7 @@ class LibChecker(LibOperator):
|
||||
)
|
||||
return reservations
|
||||
except:
|
||||
self._showTrace("加载预约记录失败 !")
|
||||
self._showTrace("加载预约记录失败 !", self.TraceLevel.ERROR)
|
||||
return None
|
||||
|
||||
|
||||
@@ -197,10 +197,10 @@ class LibChecker(LibOperator):
|
||||
self.__driver.execute_script("arguments[0].click();", more_btn)
|
||||
return True
|
||||
else:
|
||||
self._showTrace("用户无法加载更多预约记录")
|
||||
self._showTrace("用户无法加载更多预约记录", self.TraceLevel.WARNING)
|
||||
return False
|
||||
except:
|
||||
self._showTrace("加载更多预约记录失败 !")
|
||||
self._showTrace("加载更多预约记录失败 !", self.TraceLevel.ERROR)
|
||||
return False
|
||||
|
||||
|
||||
@@ -211,9 +211,9 @@ class LibChecker(LibOperator):
|
||||
) -> dict:
|
||||
|
||||
if wanted_date is None:
|
||||
self._showTrace("日期未指定, 无法检查当前预约状态")
|
||||
self._showTrace("日期未指定, 无法检查当前预约状态", self.TraceLevel.WARNING)
|
||||
return None
|
||||
self._showTrace(f"正在检查用户在 {wanted_date} 是否有预约状态为 {wanted_status} 的预约记录......")
|
||||
self._showTrace(f"正在检查用户在 {wanted_date} 是否有预约状态为 {wanted_status} 的预约记录......", no_log=True)
|
||||
|
||||
checked_count = 0
|
||||
max_check_times = 6 # we only check (4*(6-1)=)20 reservations, the last time cant be checked
|
||||
@@ -245,7 +245,8 @@ class LibChecker(LibOperator):
|
||||
self._showTrace(
|
||||
f"寻找到用户第 {checked_count} 条状态为 {wanted_status} 的预约记录, "
|
||||
f"详细信息: {record["date"]} "
|
||||
f"{record["time"]["begin"]} - {record["time"]["end"]} {record["info"]["location"]}"
|
||||
f"{record["time"]["begin"]} - {record["time"]["end"]} {record["info"]["location"]}",
|
||||
no_log=True
|
||||
)
|
||||
return record
|
||||
if not self.__showMoreReserveRecords():
|
||||
@@ -309,7 +310,7 @@ class LibChecker(LibOperator):
|
||||
|
||||
def canRenew(
|
||||
self
|
||||
):
|
||||
) -> tuple[bool, dict]:
|
||||
|
||||
# only check the current date
|
||||
date = time.strftime("%Y-%m-%d", time.localtime())
|
||||
@@ -326,12 +327,13 @@ class LibChecker(LibOperator):
|
||||
)
|
||||
if abs(time_diff_seconds) < 120*60:
|
||||
self._showTrace(f"{trace_msg}, 可以续约")
|
||||
return record
|
||||
return True, record
|
||||
else:
|
||||
self._showTrace(f"{trace_msg}, 无法续约")
|
||||
return None
|
||||
return False, None # we do not need to return the record, because if current
|
||||
# time is not available for renewal, the record is not required
|
||||
self._showTrace(f"用户在 {date} 没有有效预约记录, 无法续约")
|
||||
return None
|
||||
return False, None
|
||||
|
||||
|
||||
def postRenewCheck(
|
||||
@@ -368,7 +370,7 @@ class LibChecker(LibOperator):
|
||||
else:
|
||||
self._showTrace(f"\n"\
|
||||
f" 续约失败 !\n"\
|
||||
f" 续约后结束时间为 {act_record["time"]["end"]},与预期结束时间 {record["time"]["end"]} 不符 !"
|
||||
f" 续约后结束时间为 {act_record["time"]["end"]},与预期结束时间 {record["time"]["end"]} 不符 !"
|
||||
)
|
||||
return False
|
||||
self._showTrace(f"用户在 {date} 没有有效预约记录, 无法检查续约结果")
|
||||
|
||||
@@ -51,7 +51,7 @@ class LibCheckin(LibOperator):
|
||||
)
|
||||
ok_btn = self.__driver.find_element(By.CLASS_NAME, "btnOK")
|
||||
except:
|
||||
self._showTrace("签到时发生未知错误 !")
|
||||
self._showTrace("签到时发生未知错误 !", self.TraceLevel.ERROR)
|
||||
return False
|
||||
result_message = result_message_element.text
|
||||
if "签到成功" in result_message:
|
||||
@@ -88,28 +88,55 @@ class LibCheckin(LibOperator):
|
||||
return False
|
||||
|
||||
|
||||
def __enableCheckinBtn(
|
||||
self
|
||||
) -> bool:
|
||||
|
||||
script = """
|
||||
try {
|
||||
var checkin_btn = document.getElementById('btnCheckIn');
|
||||
if (checkin_btn) {
|
||||
checkin_btn.classList.remove('disabled');
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
"""
|
||||
result = self.__driver.execute_script(script)
|
||||
time.sleep(0.1)
|
||||
if result:
|
||||
self._showTrace("签到按钮已启用", no_log=True)
|
||||
else:
|
||||
self._showTrace("签到按钮启用失败", self.TraceLevel.WARNING)
|
||||
return result
|
||||
|
||||
|
||||
def checkin(
|
||||
self,
|
||||
username: str
|
||||
) -> bool:
|
||||
|
||||
if self.__driver is None:
|
||||
self._showTrace("未提供有效 WebDriver 实例 !")
|
||||
self._showTrace("未提供有效 WebDriver 实例 !", self.TraceLevel.WARNING)
|
||||
return False
|
||||
try:
|
||||
checkin_btn = WebDriverWait(self.__driver, 2).until(
|
||||
EC.element_to_be_clickable((By.ID, "btnCheckIn"))
|
||||
)
|
||||
except:
|
||||
self._showTrace(f"用户 {username} 签到界面加载失败 !")
|
||||
self._showTrace(f"用户 {username} 签到界面加载失败 !", self.TraceLevel.ERROR)
|
||||
return False
|
||||
if "disabled" in checkin_btn.get_attribute("class"):
|
||||
self._showTrace("签到按钮不可用, 可能不在场馆内, 请连接图书馆网络后重试")
|
||||
return False
|
||||
self._showTrace("签到按钮不可用, 可能不在场馆内, 正在尝试启用......", no_log=True)
|
||||
if not self.__enableCheckinBtn():
|
||||
self._showTrace(f"签到按钮启用失败 !", self.TraceLevel.ERROR)
|
||||
return False
|
||||
checkin_btn.click()
|
||||
if self._waitResponseLoad():
|
||||
self._showTrace(f"用户 {username} 签到成功 !")
|
||||
self._showTrace(f"用户 {username} 签到成功 !", no_log=True)
|
||||
return True
|
||||
else:
|
||||
self._showTrace(f"用户 {username} 签到失败 !")
|
||||
self._showTrace(f"用户 {username} 签到失败 !", self.TraceLevel.ERROR)
|
||||
return False
|
||||
|
||||
+23
-15
@@ -52,7 +52,10 @@ class LibLogin(LibOperator):
|
||||
)
|
||||
return True
|
||||
except:
|
||||
self._showTrace(f"登录页面加载失败 ! : 用户账号或者密码错误/验证码错误, 具体以页面提示为准")
|
||||
self._showTrace(
|
||||
f"登录页面加载失败 ! : 用户账号或者密码错误/验证码错误, 具体以页面提示为准",
|
||||
self.TraceLevel.ERROR
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
@@ -71,7 +74,7 @@ class LibLogin(LibOperator):
|
||||
password_element.clear()
|
||||
password_element.send_keys(password)
|
||||
except Exception as e:
|
||||
self._showTrace(f"用户名或密码填写失败 ! : {e}")
|
||||
self._showTrace(f"用户名或密码填写失败 ! : {e}", self.TraceLevel.ERROR)
|
||||
return False
|
||||
return True
|
||||
|
||||
@@ -88,12 +91,13 @@ class LibLogin(LibOperator):
|
||||
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}'")
|
||||
self._showTrace(f"识别到验证码为 : '{captcha_text}'", no_log=True)
|
||||
if len(captcha_text) != 4:
|
||||
self._showLog("识别到的验证码长度不等于 4 个字符 !", self.TraceLevel.WARNING)
|
||||
raise Exception("识别到的验证码长度不等于 4 个字符 !")
|
||||
return captcha_text
|
||||
except Exception as e:
|
||||
self._showTrace(f"验证码识别失败 ! : {e}")
|
||||
self._showTrace(f"验证码识别失败 ! : {e}", self.TraceLevel.ERROR)
|
||||
return ""
|
||||
|
||||
|
||||
@@ -105,12 +109,13 @@ class LibLogin(LibOperator):
|
||||
try:
|
||||
self._showMsg("请输入验证码:")
|
||||
captcha_text = self._waitMsg(timeout=15)
|
||||
self._showTrace(f"输入的验证码为 : '{captcha_text}'")
|
||||
self._showTrace(f"输入的验证码为 : '{captcha_text}'", no_log=True)
|
||||
if len(captcha_text) != 4:
|
||||
self._showLog("输入的验证码长度不等于 4 个字符 !", self.TraceLevel.WARNING)
|
||||
raise Exception("输入的验证码长度不等于 4 个字符 !")
|
||||
return captcha_text
|
||||
except Exception as e:
|
||||
self._showTrace(f"输入验证码失败 ! : {e}")
|
||||
self._showTrace(f"输入验证码失败 ! : {e}", self.TraceLevel.ERROR)
|
||||
return ""
|
||||
|
||||
|
||||
@@ -120,13 +125,13 @@ class LibLogin(LibOperator):
|
||||
|
||||
# refresh captcha
|
||||
try:
|
||||
self._showTrace("刷新验证码......")
|
||||
self._showTrace("刷新验证码......", no_log=True)
|
||||
self.__driver.find_element(
|
||||
By.ID, "loadImgId"
|
||||
).click()
|
||||
return True
|
||||
except Exception as e:
|
||||
self._showTrace(f"刷新验证码失败 ! : {e}")
|
||||
self._showTrace(f"刷新验证码失败 ! : {e}", self.TraceLevel.ERROR)
|
||||
return False
|
||||
|
||||
|
||||
@@ -140,14 +145,17 @@ class LibLogin(LibOperator):
|
||||
if auto_captcha:
|
||||
captcha_text = self.__autoRecognizeCaptcha()
|
||||
else:
|
||||
self._showTrace(f"用户未配置自动识别验证码, 请手动输入验证码 !")
|
||||
self._showTrace(f"用户未配置自动识别验证码, 请手动输入验证码 !", no_log=True)
|
||||
captcha_text = self.__manualRecognizeCaptcha()
|
||||
if captcha_text:
|
||||
return captcha_text
|
||||
else:
|
||||
if not self.__refreshCaptcha():
|
||||
return ""
|
||||
self._showTrace(f"验证码识别失败 {max_attempts} 次, 达到最大尝试次数 !")
|
||||
self._showTrace(
|
||||
f"验证码识别失败 {max_attempts} 次, 达到最大尝试次数 !",
|
||||
self.TraceLevel.WARNING
|
||||
)
|
||||
return ""
|
||||
|
||||
|
||||
@@ -162,7 +170,7 @@ class LibLogin(LibOperator):
|
||||
captcha_element.send_keys(captcha_text)
|
||||
return True
|
||||
except Exception as e:
|
||||
self._showTrace(f"验证码填写失败 ! : {e}")
|
||||
self._showTrace(f"验证码填写失败 ! : {e}", self.TraceLevel.ERROR)
|
||||
return False
|
||||
|
||||
|
||||
@@ -175,11 +183,11 @@ class LibLogin(LibOperator):
|
||||
) -> bool:
|
||||
|
||||
if self.__driver is None:
|
||||
self._showTrace("未提供有效 WebDriver 实例 !")
|
||||
self._showTrace("未提供有效 WebDriver 实例 !", self.TraceLevel.WARNING)
|
||||
return False
|
||||
# begin login process
|
||||
for attempt in range(max_attempts):
|
||||
self._showTrace(f"用户 {username} 第 {attempt + 1} 次尝试登录......")
|
||||
self._showTrace(f"用户 {username} 第 {attempt + 1} 次尝试登录......", no_log=True)
|
||||
if not self.__fillLogInElements(
|
||||
username,
|
||||
password,
|
||||
@@ -190,7 +198,7 @@ class LibLogin(LibOperator):
|
||||
continue
|
||||
if not self.__fillCaptchaElement(captcha_text):
|
||||
continue
|
||||
self._showTrace("尝试登录...")
|
||||
self._showTrace("尝试登录...", no_log=True)
|
||||
try:
|
||||
self.__driver.find_element(
|
||||
By.XPATH,
|
||||
@@ -203,5 +211,5 @@ class LibLogin(LibOperator):
|
||||
self._showTrace(f"用户 {username} 第 {attempt + 1} 次登录成功 !")
|
||||
return True
|
||||
else:
|
||||
self._showTrace(f"用户 {username} 第 {attempt + 1} 次登录失败 !")
|
||||
self._showTrace(f"用户 {username} 第 {attempt + 1} 次登录失败 !",self.TraceLevel.WARNING)
|
||||
return False
|
||||
|
||||
@@ -42,7 +42,7 @@ class LibLogout(LibOperator):
|
||||
) -> bool:
|
||||
|
||||
if self.__driver is None:
|
||||
self._showTrace("未提供有效 WebDriver 实例 !")
|
||||
self._showTrace("未提供有效 WebDriver 实例 !", self.TraceLevel.WARNING)
|
||||
return False
|
||||
try:
|
||||
self.__driver.find_element(
|
||||
@@ -51,5 +51,5 @@ class LibLogout(LibOperator):
|
||||
self._showTrace(f"用户 {username} 注销成功 !")
|
||||
return True
|
||||
except Exception as e:
|
||||
self._showTrace(f"用户 {username} 注销失败 ! : {e}")
|
||||
self._showTrace(f"用户 {username} 注销失败 ! : {e}", self.TraceLevel.ERROR)
|
||||
return False
|
||||
|
||||
+86
-95
@@ -14,10 +14,10 @@ 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
|
||||
from base.LibTimeSelector import LibTimeSelector
|
||||
|
||||
|
||||
class LibRenew(LibOperator):
|
||||
class LibRenew(LibTimeSelector):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -38,22 +38,6 @@ class LibRenew(LibOperator):
|
||||
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
|
||||
@@ -70,14 +54,14 @@ class LibRenew(LibOperator):
|
||||
EC.presence_of_element_located((By.CSS_SELECTOR, "#extendDiv div.resultMessage"))
|
||||
)
|
||||
except:
|
||||
self._showTrace("续约时间选择界面加载失败 !")
|
||||
self._showTrace("续约时间选择界面加载失败 !", self.TraceLevel.ERROR)
|
||||
return False
|
||||
head_message = head_message.text.strip()
|
||||
if "警告" in head_message:
|
||||
result_message = result_message.text.strip()
|
||||
self._showTrace(f"\n"\
|
||||
f" 续约失败 !\n"\
|
||||
f" {result_message}")
|
||||
f" {result_message}", no_log=True)
|
||||
return False
|
||||
try:
|
||||
WebDriverWait(self.__driver, 2).until(
|
||||
@@ -89,92 +73,98 @@ class LibRenew(LibOperator):
|
||||
EC.presence_of_element_located((By.CSS_SELECTOR, "#extendDiv .btnOK"))
|
||||
)
|
||||
except:
|
||||
self._showTrace("续约时间选择界面加载失败 !")
|
||||
self._showTrace("续约时间选择界面加载失败 !", self.TraceLevel.ERROR)
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def __selectNearstTime(
|
||||
def __selectNearestTime(
|
||||
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...
|
||||
Select the nearest available renewal time.
|
||||
"""
|
||||
|
||||
end_time = record["time"]["end"]
|
||||
renew_info = reserve_info["renew_time"]
|
||||
max_diff = renew_info["max_diff"]
|
||||
prefer_earlier = renew_info["prefer_early"]
|
||||
target_renew_mins = self.__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
|
||||
target_renew_mins = self._timeStrToMins(end_time) + renew_info["expect_duration"]*60
|
||||
|
||||
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}"
|
||||
)
|
||||
# Validate and adjust target renew time to library closing time
|
||||
if not self.__validateAndAdjustRenewTime(end_time, target_renew_mins):
|
||||
return False
|
||||
renew_ok_btn = self.__driver.find_element(By.CSS_SELECTOR, "#extendDiv .btnOK")
|
||||
renew_time_opts = self.__driver.find_elements(By.CSS_SELECTOR, "#extendDiv .renewal_List li")
|
||||
if not renew_time_opts:
|
||||
self._showTrace("当前未查询到可用续约时间 !", self.TraceLevel.WARNING)
|
||||
return False
|
||||
|
||||
# Find best renewal time option
|
||||
best_opt, best_text, actual_diff, free_times = self._findBestTimeOption(
|
||||
renew_time_opts, target_renew_mins, max_diff, prefer_earlier, is_reserve=False
|
||||
)
|
||||
if best_opt is not None:
|
||||
return self.__confirmRenewal(best_opt, best_text, actual_diff, record, renew_ok_btn)
|
||||
self._showTrace(
|
||||
"无法选择最近的可用续约时间 ! "
|
||||
f"所有可选时间与目标时间相差都超过了 {max_diff} 分钟 !",
|
||||
self.TraceLevel.WARNING
|
||||
)
|
||||
self._showTrace(f"当前可供续约的时间有: {free_times}")
|
||||
return False
|
||||
|
||||
|
||||
def __validateAndAdjustRenewTime(
|
||||
self,
|
||||
end_time: str,
|
||||
target_renew_mins: int
|
||||
) -> bool:
|
||||
|
||||
"""
|
||||
Validate and adjust renewal time to library closing time if needed.
|
||||
"""
|
||||
LIBRARY_CLOSE_TIME = 1410 # 23:30 in minutes
|
||||
if target_renew_mins > LIBRARY_CLOSE_TIME:
|
||||
actual_renew_duration = LIBRARY_CLOSE_TIME - self._timeStrToMins(end_time)
|
||||
if actual_renew_duration <= 0:
|
||||
self._showTrace(f"当前结束时间 {end_time} 已接近闭馆时间,无法续约 !", self.TraceLevel.ERROR)
|
||||
return False
|
||||
self._showTrace(
|
||||
f"续约时间已调整至闭馆时间 {self._minsToTimeStr(LIBRARY_CLOSE_TIME)},"
|
||||
f"实际续约时长为 {actual_renew_duration//60} 小时 {actual_renew_duration%60} 分钟"
|
||||
)
|
||||
return True
|
||||
return True
|
||||
|
||||
|
||||
def __confirmRenewal(
|
||||
self,
|
||||
best_opt,
|
||||
best_text: str,
|
||||
actual_diff: int,
|
||||
record: dict,
|
||||
ok_btn
|
||||
) -> bool:
|
||||
|
||||
"""
|
||||
Confirm the selected renewal time.
|
||||
"""
|
||||
try:
|
||||
best_opt.click()
|
||||
abs_diff = abs(actual_diff)
|
||||
time_relation = self._formatTimeRelation(abs_diff, actual_diff, "续约时间")
|
||||
self._showTrace(
|
||||
f"选择距离期望续约时间最近的 {best_text}, "
|
||||
f"与期望续约时间相比 {time_relation}"
|
||||
)
|
||||
record["time"]["end"] = best_text.strip()
|
||||
ok_btn.click()
|
||||
return True
|
||||
except:
|
||||
self._showTrace("查询可用续约时间时发生未知错误 !")
|
||||
self._showTrace("确认续约时发生错误 !", self.TraceLevel.ERROR)
|
||||
return False
|
||||
|
||||
|
||||
@@ -186,28 +176,29 @@ class LibRenew(LibOperator):
|
||||
) -> bool:
|
||||
|
||||
if self.__driver is None:
|
||||
self._showTrace("未提供有效 WebDriver 实例 !")
|
||||
self._showTrace("未提供有效 WebDriver 实例 !", self.TraceLevel.WARNING)
|
||||
return False
|
||||
try:
|
||||
renew_btn = WebDriverWait(self.__driver, 2).until(
|
||||
EC.element_to_be_clickable((By.ID, "btnExtend"))
|
||||
)
|
||||
except:
|
||||
self._showTrace(f"用户 {username} 续约界面加载失败 !")
|
||||
self._showTrace(f"用户 {username} 续约界面加载失败 !", self.TraceLevel.ERROR)
|
||||
return False
|
||||
if "disabled" in renew_btn.get_attribute("class"):
|
||||
self._showTrace(f"用户 {username} 续约按钮不可用, 可能不在场馆内, 请连接图书馆网络后重试")
|
||||
self._showLog(f"用户 {username} 续约按钮不可用, 可能不在场馆内")
|
||||
self._showTrace(f"用户 {username} 续约按钮不可用, 可能不在场馆内, 请连接图书馆网络后重试", no_log=True)
|
||||
return False
|
||||
renew_btn.click()
|
||||
if not self.__waitRenewDialog():
|
||||
self._showTrace(f"用户 {username} 续约失败 !")
|
||||
self._showTrace(f"用户 {username} 续约失败 !", self.TraceLevel.ERROR)
|
||||
|
||||
# After the renewal, the webpage will display a mask overlay,
|
||||
# so we need to refresh the page for subsequent operations.
|
||||
# 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} 续约失败 !")
|
||||
if not self.__selectNearestTime(record, reserve_info):
|
||||
self._showTrace(f"用户 {username} 续约失败 !", self.TraceLevel.ERROR)
|
||||
self.__driver.refresh()
|
||||
return False
|
||||
if self._waitResponseLoad():
|
||||
|
||||
+151
-150
@@ -10,16 +10,15 @@ 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
|
||||
from base.LibTimeSelector import LibTimeSelector
|
||||
|
||||
|
||||
class LibReserve(LibOperator):
|
||||
class LibReserve(LibTimeSelector):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -73,13 +72,13 @@ class LibReserve(LibOperator):
|
||||
By.CSS_SELECTOR, ".layoutSeat dd"
|
||||
)
|
||||
if not content_elements:
|
||||
self._showTrace("未找到预约结果")
|
||||
self._showTrace("未找到预约结果", self.TraceLevel.WARNING)
|
||||
raise
|
||||
title = title_elements[0].text if title_elements else ""
|
||||
contents = [element.text for element in content_elements if element.text.strip()]
|
||||
for message in contents:
|
||||
if "预约失败" in message or "已有1个有效预约" in message:
|
||||
self._showTrace(f"预约失败 - {"".join(contents)}")
|
||||
self._showTrace(f"预约失败 - {"".join(contents)}", self.TraceLevel.ERROR)
|
||||
raise
|
||||
if "预定好了" in title or "预约成功" in title or "操作成功" in title:
|
||||
if len(contents) >= 6:
|
||||
@@ -97,25 +96,9 @@ class LibReserve(LibOperator):
|
||||
)
|
||||
return True
|
||||
except:
|
||||
self._showTrace(f"预约结果加载失败 !")
|
||||
self._showTrace(f"预约结果加载失败 !", self.TraceLevel.ERROR)
|
||||
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,
|
||||
@@ -124,6 +107,8 @@ class LibReserve(LibOperator):
|
||||
|
||||
try:
|
||||
# must contain the required infomation
|
||||
# key 'place' is no need to check
|
||||
# because 'place' is only has one possible value '1' or '图书馆'
|
||||
if reserve_info.get("floor") is None: # if existence ?
|
||||
raise ValueError("未指定楼层")
|
||||
if reserve_info["floor"] not in self.__floor_map: # if in the mao ?
|
||||
@@ -140,7 +125,13 @@ class LibReserve(LibOperator):
|
||||
except ValueError as e:
|
||||
self._showTrace(
|
||||
f"预约信息错误 ! : {e}, "\
|
||||
f"由于缺少必要的预约信息, 无法开始预约流程, 请检查预约信息是否完整"
|
||||
f"由于缺少必要的预约信息, 无法开始预约流程",
|
||||
self.TraceLevel.ERROR
|
||||
)
|
||||
self._showTrace(
|
||||
f"预约信息错误 ! : {e}, "\
|
||||
f"由于缺少必要的预约信息, 无法开始预约流程, 请检查预约信息是否完整",
|
||||
no_log=True
|
||||
)
|
||||
return False
|
||||
|
||||
@@ -150,17 +141,20 @@ class LibReserve(LibOperator):
|
||||
reserve_info: dict
|
||||
) -> bool:
|
||||
|
||||
cur_date = time.strftime("%Y-%m-%d", time.localtime())
|
||||
cur_date_str = time.strftime("%Y-%m-%d", time.localtime())
|
||||
cur_timestamp = time.mktime(time.strptime(cur_date_str, "%Y-%m-%d"))
|
||||
if reserve_info.get("date") is None:
|
||||
reserve_info["date"] = cur_date
|
||||
self._showTrace(f"预约日期未指定, 自动设置为当前日期: {cur_date}")
|
||||
reserve_info["date"] = cur_date_str
|
||||
self._showTrace(f"预约日期未指定, 自动设置为当前日期: {cur_date_str}")
|
||||
else:
|
||||
if reserve_info["date"] < cur_date:
|
||||
res_timestamp = time.mktime(time.strptime(reserve_info["date"], "%Y-%m-%d"))
|
||||
if res_timestamp < cur_timestamp:
|
||||
self._showTrace(
|
||||
f"预约日期错误 ! :"\
|
||||
f"{reserve_info['date']} 早于当前日期 {cur_date}, 自动设置为当前日期"
|
||||
f"{reserve_info['date']} 早于当前日期 {cur_date_str}, 自动设置为当前日期",
|
||||
self.TraceLevel.WARNING
|
||||
)
|
||||
reserve_info["date"] = cur_date
|
||||
reserve_info["date"] = cur_date_str
|
||||
return True
|
||||
|
||||
|
||||
@@ -207,10 +201,13 @@ class LibReserve(LibOperator):
|
||||
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"])
|
||||
# here we add the expect duration to the begin time first,
|
||||
# the edge case that the end time is later than 23:30 will
|
||||
# be handled in __finalCheck. so no need to concern about it.
|
||||
end_mins = self._timeStrToMins(reserve_info["begin_time"]["time"])
|
||||
end_mins = end_mins + int(reserve_info["expect_duration"]*60)
|
||||
reserve_info["end_time"] = {
|
||||
"time": self.__minsToTime(end_mins),
|
||||
"time": self._minsToTimeStr(end_mins),
|
||||
"max_diff": 30,
|
||||
"prefer_early": False
|
||||
}
|
||||
@@ -232,32 +229,39 @@ class LibReserve(LibOperator):
|
||||
):
|
||||
|
||||
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"])
|
||||
begin_mins = self._timeStrToMins(begin_time["time"])
|
||||
end_mins = self._timeStrToMins(end_time["time"])
|
||||
|
||||
# if end time is earlier than begin_time, exchange them
|
||||
if end_mins < begin_mins:
|
||||
# except that the user has set the satisfy_duration to True
|
||||
if end_mins < begin_mins and reserve_info["satisfy_duration"] is False:
|
||||
self._showTrace(
|
||||
f"结束时间 {end_time['time']} 早于开始时间 {begin_time['time']}, 尝试交换时间"
|
||||
f"结束时间 {end_time['time']} 早于开始时间 {begin_time['time']}, 尝试交换时间",
|
||||
self.TraceLevel.WARNING
|
||||
)
|
||||
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"])
|
||||
reserve_info["end_time"], reserve_info["begin_time"] = begin_time, end_time
|
||||
begin_time, end_time = end_time, begin_time
|
||||
begin_mins = self._timeStrToMins(begin_time["time"])
|
||||
end_mins = self._timeStrToMins(end_time["time"])
|
||||
|
||||
# ensure the end time is not later than 23:30
|
||||
if end_mins > self.__timeToMins("23:30"):
|
||||
max_end_mins = self._timeStrToMins("23:30")
|
||||
if end_mins > max_end_mins:
|
||||
self._showTrace(
|
||||
f"结束时间 {end_time['time']} 晚于 23:30, 自动设置为 23:30"
|
||||
f"结束时间 {end_time['time']} 晚于 23:30, 自动设置为 23:30",
|
||||
self.TraceLevel.WARNING
|
||||
)
|
||||
reserve_info["end_time"]["time"] = "23:30"
|
||||
end_mins = self.__timeToMins("23:30")
|
||||
end_mins = max_end_mins
|
||||
|
||||
# ensure the duration is not longer than 8 hours
|
||||
if reserve_info["satisfy_duration"]:
|
||||
if reserve_info["expect_duration"] > 8:
|
||||
self._showTrace(
|
||||
f"该用户设置了优先满足时长要求, 但是预约期望持续时间 "
|
||||
f"{reserve_info['expect_duration']} 小时 "
|
||||
f"超出最大时长 8 小时, 自动设置为 8 小时"
|
||||
f"超出最大时长 8 小时, 自动设置为 8 小时",
|
||||
self.TraceLevel.WARNING
|
||||
)
|
||||
reserve_info["expect_duration"] = 8
|
||||
else:
|
||||
@@ -265,9 +269,10 @@ class LibReserve(LibOperator):
|
||||
self._showTrace(
|
||||
f"该用户未设置优先满足时长要求, 但是检查到预约持续时间 "
|
||||
f"{float((end_mins - begin_mins)/60)} 小时 "
|
||||
f"超出最大时长 8 小时, 自动设置为 8 小时"
|
||||
f"超出最大时长 8 小时, 自动设置为 8 小时",
|
||||
self.TraceLevel.WARNING
|
||||
)
|
||||
reserve_info["end_time"]["time"] = self.__minsToTime(begin_mins + 8*60)
|
||||
reserve_info["end_time"]["time"] = self._minsToTimeStr(begin_mins + 8*60)
|
||||
return True
|
||||
|
||||
|
||||
@@ -291,8 +296,8 @@ class LibReserve(LibOperator):
|
||||
self._showTrace(
|
||||
f"预约信息检查完成, 准备预约 "
|
||||
f"{reserve_info['date']} "
|
||||
f"{reserve_info['begin_time']["time"]} - "
|
||||
f"{reserve_info['end_time']["time"]} "
|
||||
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']]} "
|
||||
@@ -435,7 +440,7 @@ class LibReserve(LibOperator):
|
||||
EC.element_to_be_clickable((By.ID, "findRoom"))
|
||||
).click()
|
||||
except:
|
||||
self._showTrace("加载房间/区域失败 !")
|
||||
self._showTrace("加载房间/区域失败 !", self.TraceLevel.ERROR)
|
||||
return False
|
||||
# select room
|
||||
try:
|
||||
@@ -445,7 +450,7 @@ class LibReserve(LibOperator):
|
||||
self._showTrace(f"房间 {display_room} 选择成功 !")
|
||||
return True
|
||||
except:
|
||||
self._showTrace(f"选择房间失败 ! : {display_room} 不可用")
|
||||
self._showTrace(f"选择房间失败 ! : {display_room} 不可用", self.TraceLevel.ERROR)
|
||||
return False
|
||||
|
||||
|
||||
@@ -463,7 +468,7 @@ class LibReserve(LibOperator):
|
||||
EC.presence_of_all_elements_located((By.CSS_SELECTOR, "li[id^='seat_']"))
|
||||
)
|
||||
except:
|
||||
self._showTrace(f"座位加载失败 !")
|
||||
self._showTrace(f"座位加载失败 !", self.TraceLevel.ERROR)
|
||||
return False
|
||||
try:
|
||||
all_seats = self.__driver.find_elements(
|
||||
@@ -481,9 +486,10 @@ class LibReserve(LibOperator):
|
||||
seat_status = seat_link.get_attribute("title")
|
||||
self._showTrace(f"座位 {seat_id} 选择成功 ! : 当前状态 - '{seat_status}'")
|
||||
return True
|
||||
self._showTrace(f"座位 {seat_id} 在该楼层区域中不存在, 请检查座位号是否正确")
|
||||
self._showLog(f"座位 {seat_id} 在该楼层区域中不存在", self.TraceLevel.WARNING)
|
||||
self._showTrace(f"座位 {seat_id} 在该楼层区域中不存在, 请检查座位号是否正确", no_log=True)
|
||||
except:
|
||||
self._showTrace(f"座位选择失败 !")
|
||||
self._showTrace(f"座位选择失败 !", self.TraceLevel.ERROR)
|
||||
return False
|
||||
|
||||
|
||||
@@ -496,6 +502,13 @@ class LibReserve(LibOperator):
|
||||
prefer_earlier: bool = True
|
||||
) -> int:
|
||||
|
||||
"""
|
||||
Select the nearest available time option.
|
||||
|
||||
Returns:
|
||||
int: The actual selected time value in minutes.
|
||||
"""
|
||||
# Wait for time options to load
|
||||
try:
|
||||
WebDriverWait(self.__driver, 2).until(
|
||||
EC.presence_of_all_elements_located(
|
||||
@@ -503,130 +516,118 @@ class LibReserve(LibOperator):
|
||||
)
|
||||
)
|
||||
except:
|
||||
self._showTrace(f"{time_type} 选择失败 ! : 当前未查询到可用时间")
|
||||
self._showTrace(f"{time_type} 选择失败 ! : 当前未查询到可用时间", self.TraceLevel.ERROR)
|
||||
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
|
||||
# Find best time option
|
||||
all_time_opts = self.__driver.find_elements(
|
||||
By.CSS_SELECTOR,
|
||||
f"#{time_id} ul li a"
|
||||
)
|
||||
if not all_time_opts:
|
||||
self._showTrace(f"{time_type} 选择失败 ! : 当前未查询到可用时间", self.TraceLevel.ERROR)
|
||||
return -1
|
||||
best_opt, best_text, actual_diff, free_times = self._findBestTimeOption(
|
||||
all_time_opts, target_time, max_time_diff, prefer_earlier, is_reserve=True
|
||||
)
|
||||
if best_opt is not None:
|
||||
best_opt.click()
|
||||
abs_diff = abs(actual_diff)
|
||||
time_relation = self._formatTimeRelation(abs_diff, actual_diff, time_type)
|
||||
target_time += actual_diff
|
||||
self._showTrace(
|
||||
f"无法选择最近的 {time_type} {self.__minsToTime(target_time)}, "\
|
||||
f"所有可选时间与目标时间相差都超过 {max_time_diff} 分钟"
|
||||
f"选择距离期望 {time_type} 最近的 {best_text}, "
|
||||
f"与期望 {time_type} 相比 {time_relation}"
|
||||
)
|
||||
self._showTrace(f"当前可供预约的 {time_type} 有: {free_times}")
|
||||
return -1
|
||||
except:
|
||||
self._showTrace(f"{time_type} {self.__minsToTime(target_time)} 选择失败 !")
|
||||
return -1
|
||||
return target_time
|
||||
self._showTrace(
|
||||
f"无法选择最近的 {time_type} {self._minsToTimeStr(target_time)}, "
|
||||
f"所有可选时间与目标时间相差都超过 {max_time_diff} 分钟", self.TraceLevel.WARNING
|
||||
)
|
||||
self._showTrace(f"当前可供预约的 {time_type} 有: {free_times}")
|
||||
return -1
|
||||
|
||||
|
||||
def __selectSeatTime(
|
||||
self,
|
||||
begin_time: dict,
|
||||
end_time: dict,
|
||||
expct_duration: int = 4,
|
||||
expect_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 seat begin and end time.
|
||||
"""
|
||||
exp_beg_tm_str = begin_time["time"]
|
||||
exp_end_tm_str = end_time["time"]
|
||||
# Initialize actual time strings for logging
|
||||
act_beg_tm_str = exp_beg_tm_str
|
||||
act_end_tm_str = exp_end_tm_str
|
||||
exp_beg_mins = self._timeStrToMins(exp_beg_tm_str)
|
||||
act_beg_mins = exp_beg_mins
|
||||
exp_end_mins = self._timeStrToMins(exp_end_tm_str)
|
||||
act_end_mins = exp_end_mins
|
||||
|
||||
# select the begin time
|
||||
if self.__selectNearestTime(
|
||||
time_id="startTime", # dont change into begin, this is the element in the page
|
||||
# Select begin time
|
||||
act_beg_mins = self.__selectNearestTime(
|
||||
time_id="startTime",
|
||||
time_type="开始时间",
|
||||
target_time=expect_begin_mins,
|
||||
target_time=exp_beg_mins,
|
||||
max_time_diff=begin_time["max_diff"],
|
||||
prefer_earlier=begin_time["prefer_early"]
|
||||
) == -1:
|
||||
)
|
||||
if act_beg_mins == -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.
|
||||
act_beg_tm_str = self._minsToTimeStr(act_beg_mins)
|
||||
|
||||
# If 'satisfy_duration' is True, select end time based on actual begin time
|
||||
if satisfy_duration:
|
||||
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)
|
||||
exp_end_mins = int(self.validateAndAdjustEndTime(act_beg_mins, expect_duration))
|
||||
exp_end_tm_str = self._minsToTimeStr(exp_end_mins)
|
||||
self._showTrace(
|
||||
f"需要满足期望预约持续时间: {expct_duration} 小时, "\
|
||||
f"根据开始时间 {actual_begin_time} 计算结束时间: {self.__minsToTime(expect_end_mins)}"
|
||||
f"需要满足期望预约持续时间: {expect_duration} 小时, "
|
||||
f"根据开始时间 {act_beg_tm_str} 计算结束时间: {exp_end_tm_str}"
|
||||
)
|
||||
# select the end time
|
||||
if self.__selectNearestTime(
|
||||
|
||||
# Select end time
|
||||
act_end_mins = self.__selectNearestTime(
|
||||
time_id="endTime",
|
||||
time_type="结束时间",
|
||||
target_time=expect_end_mins,
|
||||
target_time=exp_end_mins,
|
||||
max_time_diff=end_time["max_diff"],
|
||||
prefer_earlier=end_time["prefer_early"]
|
||||
) == -1:
|
||||
)
|
||||
if act_end_mins == -1:
|
||||
return False
|
||||
else:
|
||||
actual_end_time = self.__minsToTime(expect_end_mins)
|
||||
act_end_tm_str = self._minsToTimeStr(act_end_mins)
|
||||
self._showTrace(
|
||||
f"期望预约时间段: {expect_begin_time} - {expect_end_time}, "
|
||||
f"实际预约时间段: {actual_begin_time} - {actual_end_time}"
|
||||
f"期望预约时间段: {exp_beg_tm_str} - {exp_end_tm_str}, "
|
||||
f"实际预约时间段: {act_beg_tm_str} - {act_end_tm_str}"
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def validateAndAdjustEndTime(
|
||||
self,
|
||||
begin_mins: int,
|
||||
duration: int
|
||||
) -> int:
|
||||
|
||||
"""
|
||||
Validate and adjust reserve end time to library closing time if needed.
|
||||
"""
|
||||
LIBRARY_CLOSE_TIME = self._timeStrToMins("23:30")
|
||||
expect_end_mins = int(begin_mins + duration*60)
|
||||
if expect_end_mins > LIBRARY_CLOSE_TIME:
|
||||
expect_end_mins = LIBRARY_CLOSE_TIME
|
||||
self._showTrace(
|
||||
f"预约持续时间 {duration} 小时, 超过最大预约时间 23:30, 自动调整为 23:30",
|
||||
self.TraceLevel.WARNING
|
||||
)
|
||||
return expect_end_mins
|
||||
|
||||
|
||||
def reserve(
|
||||
self,
|
||||
username: str,
|
||||
@@ -649,7 +650,7 @@ class LibReserve(LibOperator):
|
||||
EC.presence_of_element_located((By.ID, "seatLayout"))
|
||||
)
|
||||
except:
|
||||
self._showTrace(f"加载预约选座页面失败 !")
|
||||
self._showTrace(f"加载预约选座页面失败 !", self.TraceLevel.ERROR)
|
||||
return False
|
||||
# date, place, floor, room
|
||||
if not self.__selectDate(reserve_info["date"]):
|
||||
@@ -668,7 +669,7 @@ class LibReserve(LibOperator):
|
||||
elif not self.__selectSeatTime(
|
||||
begin_time=reserve_info["begin_time"],
|
||||
end_time=reserve_info["end_time"],
|
||||
expct_duration=reserve_info["expect_duration"],
|
||||
expect_duration=reserve_info["expect_duration"],
|
||||
satisfy_duration=reserve_info["satisfy_duration"]
|
||||
):
|
||||
pass
|
||||
@@ -682,11 +683,11 @@ class LibReserve(LibOperator):
|
||||
raise
|
||||
reserve_success = True
|
||||
except:
|
||||
self._showTrace(f"预约提交失败 !")
|
||||
self._showTrace(f"预约提交失败 !", self.TraceLevel.ERROR)
|
||||
if not submit_reserve and have_hover_on_page:
|
||||
self.__driver.refresh()
|
||||
if reserve_success:
|
||||
self._showTrace(f"用户 {username} 预约成功 !")
|
||||
else:
|
||||
self._showTrace(f"用户 {username} 预约失败 !")
|
||||
self._showTrace(f"用户 {username} 预约失败 !", self.TraceLevel.ERROR)
|
||||
return reserve_success
|
||||
@@ -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 json
|
||||
import copy
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
class ConfigReader:
|
||||
"""
|
||||
Config reader class.
|
||||
|
||||
This class is used to read config file in JSON format.
|
||||
|
||||
Args:
|
||||
config_path (str): The path of config file.
|
||||
|
||||
Examples:
|
||||
>>> print(open("config.json", "r", encoding="utf-8").read())
|
||||
{
|
||||
"key1": {
|
||||
"key2": "value1"
|
||||
}
|
||||
}
|
||||
>>> config_reader = ConfigReader("config.json")
|
||||
>>> config_reader.get("key1/key2")
|
||||
"value1"
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config_path: str
|
||||
):
|
||||
|
||||
self.__config_path = config_path
|
||||
self.__config_data = None
|
||||
self.__readConfig()
|
||||
|
||||
|
||||
def __readConfig(
|
||||
self
|
||||
):
|
||||
|
||||
try:
|
||||
with open(self.__config_path, 'r', encoding='utf-8') as file:
|
||||
self.__config_data = json.load(file)
|
||||
except FileNotFoundError as e:
|
||||
raise Exception(f"Config file not found: {self.__config_path}") from e
|
||||
except PermissionError as e:
|
||||
raise Exception(f"Without enough permission to read config file: {self.__config_path}") from e
|
||||
except json.JSONDecodeError as e:
|
||||
raise Exception(f"JSON decode error in config file: {self.__config_path}") from e
|
||||
except Exception as e:
|
||||
raise Exception(f"Unknown error occurred while reading config file: {e}") from e
|
||||
|
||||
|
||||
def getConfigs(
|
||||
self
|
||||
) -> dict:
|
||||
|
||||
return self.__config_data.copy()
|
||||
|
||||
|
||||
def getConfig(
|
||||
self,
|
||||
key: str
|
||||
) -> Any:
|
||||
|
||||
config = self.__config_data.get(key, {})
|
||||
return copy.deepcopy(config)
|
||||
|
||||
|
||||
def get(
|
||||
self,
|
||||
key: str,
|
||||
default: Any = None
|
||||
) -> Any:
|
||||
|
||||
keys = key.split('/')
|
||||
current = self.__config_data
|
||||
for k in keys:
|
||||
if isinstance(current, dict) and k in current:
|
||||
current = current[k]
|
||||
else:
|
||||
return default
|
||||
return copy.deepcopy(current)
|
||||
|
||||
|
||||
def hasConfig(
|
||||
self,
|
||||
key: str
|
||||
) -> bool:
|
||||
|
||||
return self.getConfig(key) != {}
|
||||
|
||||
|
||||
def reReadConfig(
|
||||
self
|
||||
) -> bool:
|
||||
|
||||
return self.__readConfig()
|
||||
|
||||
|
||||
def configPath(
|
||||
self
|
||||
) -> str:
|
||||
|
||||
return self.__config_path
|
||||
@@ -1,116 +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 json
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
class ConfigWriter:
|
||||
"""
|
||||
Config writer class.
|
||||
|
||||
This class is used to write config file in JSON format.
|
||||
|
||||
Args:
|
||||
config_path (str): The path of config file.
|
||||
config_data (dict): The config data to be written.
|
||||
|
||||
Examples:
|
||||
>>> config_data = {
|
||||
... "key1": {
|
||||
... "key2": "value1"
|
||||
... }
|
||||
... }
|
||||
>>> config_writer = ConfigWriter("config.json", config_data)
|
||||
>>> config_writer.set("key1/key2", "value1")
|
||||
True
|
||||
>>> print(open("config.json", "r", encoding="utf-8").read())
|
||||
{
|
||||
"key1": {
|
||||
"key2": "value1"
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config_path: str,
|
||||
config_data: dict
|
||||
):
|
||||
|
||||
self.__config_path = config_path
|
||||
self.__config_data = config_data.copy() if config_data is not None else {}
|
||||
self.__writeConfig()
|
||||
|
||||
|
||||
def __writeConfig(
|
||||
self
|
||||
):
|
||||
|
||||
try:
|
||||
with open(self.__config_path, "w", encoding="utf-8") as f:
|
||||
json.dump(self.__config_data, f, indent=4, sort_keys=False)
|
||||
except PermissionError as e:
|
||||
raise Exception(f"Without enough permission to write config file: {self.__config_path}") from e
|
||||
except IOError as e:
|
||||
raise Exception(f"IO error occurred while writing config file: {self.__config_path}") from e
|
||||
except TypeError as e:
|
||||
raise Exception(f"Config data contains invalid type that can not be JSON serialized: {e}") from e
|
||||
except Exception as e:
|
||||
raise Exception(f"Unknown error occurred while writing config file: {e}") from e
|
||||
|
||||
|
||||
def setConfigs(
|
||||
self,
|
||||
configs: dict
|
||||
) -> bool:
|
||||
|
||||
self.__config_data = configs
|
||||
return self.__writeConfig()
|
||||
|
||||
|
||||
def setConfig(
|
||||
self,
|
||||
key: str,
|
||||
value: dict
|
||||
) -> bool:
|
||||
|
||||
self.__config_data[key] = value
|
||||
return self.__writeConfig()
|
||||
|
||||
|
||||
def set(
|
||||
self,
|
||||
key: str,
|
||||
value: Any
|
||||
) -> bool:
|
||||
|
||||
keys = key.replace("\\", "/").split("/")
|
||||
current = self.__config_data
|
||||
for k in keys[:-1]:
|
||||
if k not in current or not isinstance(current[k], dict):
|
||||
current[k] = {}
|
||||
current = current[k]
|
||||
current[keys[-1]] = value
|
||||
return self.__writeConfig()
|
||||
|
||||
|
||||
def reWriteConfig(
|
||||
self
|
||||
) -> bool:
|
||||
|
||||
return self.__writeConfig()
|
||||
|
||||
|
||||
def configPath(
|
||||
self
|
||||
) -> str:
|
||||
|
||||
return self.__config_path
|
||||
@@ -0,0 +1,85 @@
|
||||
# -*- 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 json
|
||||
|
||||
|
||||
class JSONReader:
|
||||
"""
|
||||
JSON reader class.
|
||||
|
||||
This class is used to read JSON file.
|
||||
|
||||
Args:
|
||||
json_path (str): The path of JSON file.
|
||||
|
||||
Examples:
|
||||
>>> print(open("config.json", "r", encoding="utf-8").read())
|
||||
{
|
||||
"key1": {
|
||||
"key2": "value1"
|
||||
}
|
||||
}
|
||||
>>> json_reader = JSONReader("config.json")
|
||||
>>> data = json_reader.data()
|
||||
>>> data["key1"]["key2"]
|
||||
"value1"
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
json_path: str
|
||||
):
|
||||
|
||||
self.__json_path = os.path.abspath(json_path)
|
||||
self.__json_data = None
|
||||
self.__read()
|
||||
|
||||
|
||||
def __read(
|
||||
self
|
||||
):
|
||||
|
||||
try:
|
||||
with open(self.__json_path, 'r', encoding='utf-8') as file:
|
||||
self.__json_data = json.load(file)
|
||||
except FileNotFoundError as e:
|
||||
raise Exception(f"文件不存在: {self.__json_path}") from e
|
||||
except PermissionError as e:
|
||||
raise Exception(f"没有足够的权限读取文件: {self.__json_path}") from e
|
||||
except json.JSONDecodeError as e:
|
||||
raise Exception(f"JSON 解析错误: {self.__json_path}") from e
|
||||
except Exception as e:
|
||||
raise Exception(f"读取文件时发生未知错误: {e}") from e
|
||||
|
||||
|
||||
def read(
|
||||
self
|
||||
) -> bool:
|
||||
|
||||
try:
|
||||
self.__read()
|
||||
except:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def data(
|
||||
self
|
||||
) -> dict:
|
||||
|
||||
return self.__json_data.copy()
|
||||
|
||||
|
||||
def path(
|
||||
self
|
||||
) -> str:
|
||||
|
||||
return self.__json_path
|
||||
@@ -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
|
||||
import json
|
||||
|
||||
|
||||
class JSONWriter:
|
||||
"""
|
||||
JSON writer class.
|
||||
|
||||
This class is used to write JSON file.
|
||||
|
||||
Args:
|
||||
json_path (str): The path of JSON file.
|
||||
json_data (dict): The JSON data to be written.
|
||||
|
||||
Examples:
|
||||
>>> json_data = {
|
||||
... "key1": {
|
||||
... "key2": "value1"
|
||||
... }
|
||||
... }
|
||||
>>> json_writer = JSONWriter("config.json", json_data)
|
||||
>>> print(open("config.json", "r", encoding="utf-8").read())
|
||||
{
|
||||
"key1": {
|
||||
"key2": "value1"
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
json_path: str,
|
||||
json_data: dict
|
||||
):
|
||||
|
||||
self.__json_path = os.path.abspath(json_path)
|
||||
self.__json_data = json_data.copy() if json_data is not None else {}
|
||||
self.__write()
|
||||
|
||||
|
||||
def __write(
|
||||
self
|
||||
):
|
||||
|
||||
try:
|
||||
with open(self.__json_path, "w", encoding="utf-8") as f:
|
||||
json.dump(self.__json_data, f, indent=4, sort_keys=False)
|
||||
except PermissionError as e:
|
||||
raise Exception(f"没有足够的权限写入文件: {self.__json_path}") from e
|
||||
except IOError as e:
|
||||
raise Exception(f"写入文件时发生 IO 错误: {self.__json_path}") from e
|
||||
except TypeError as e:
|
||||
raise Exception(f"JSON 数据包含无法 JSON 序列化的类型: {e}") from e
|
||||
except Exception as e:
|
||||
raise Exception(f"写入文件时发生未知错误: {e}") from e
|
||||
|
||||
|
||||
def write(
|
||||
self
|
||||
) -> bool:
|
||||
|
||||
try:
|
||||
self.__write()
|
||||
except:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def path(
|
||||
self
|
||||
) -> str:
|
||||
|
||||
return self.__json_path
|
||||
@@ -0,0 +1,50 @@
|
||||
# -*- 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.
|
||||
"""
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
|
||||
def calculateNextRepeatTime(
|
||||
repeat_days: list,
|
||||
hour: int,
|
||||
minute: int,
|
||||
second: int
|
||||
) -> datetime:
|
||||
"""
|
||||
Calculate the next repeat time based on repeat days and target time.
|
||||
|
||||
This function calculates the next execution time for a repeatable task.
|
||||
If the current day is in repeat_days and the target time has not passed,
|
||||
it returns today's target time. Otherwise, it finds the next matching day.
|
||||
|
||||
Args:
|
||||
repeat_days (list): List of weekdays to repeat (0=Monday, 6=Sunday).
|
||||
hour (int): Target hour (0-23).
|
||||
minute (int): Target minute (0-59).
|
||||
second (int): Target second (0-59).
|
||||
|
||||
Returns:
|
||||
datetime: The next repeat execution time.
|
||||
"""
|
||||
|
||||
current_time = datetime.now()
|
||||
current_weekday = current_time.weekday()
|
||||
target_time = current_time.replace(hour=hour, minute=minute, second=second, microsecond=0)
|
||||
if current_weekday in repeat_days:
|
||||
if target_time > current_time:
|
||||
return target_time
|
||||
repeat_days_sorted = sorted(repeat_days)
|
||||
for day in repeat_days_sorted:
|
||||
if day > current_weekday:
|
||||
days_until = day - current_weekday
|
||||
next_time = target_time + timedelta(days=days_until)
|
||||
return next_time
|
||||
days_until = 7 - current_weekday + repeat_days_sorted[0]
|
||||
next_time = target_time + timedelta(days=days_until)
|
||||
return next_time
|
||||
@@ -2,6 +2,7 @@
|
||||
Utils module for the AutoLibrary project.
|
||||
|
||||
Here are the classes and modules in this package:
|
||||
- ConfigReader: Configuration reader class for the AutoLibrary project.
|
||||
- ConfigWriter: Configuration writer class for the AutoLibrary project.
|
||||
- TimerUtils: Timer utils class for the AutoLibrary project.
|
||||
- JSONReader: JSON reader class for the AutoLibrary project.
|
||||
- JSONWriter: JSON writer class for the AutoLibrary project.
|
||||
"""
|
||||
@@ -0,0 +1 @@
|
||||
This folder is used to store the template config files.
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"library": {
|
||||
"host_url": "http://10.1.20.7",
|
||||
"login_url": "/login"
|
||||
},
|
||||
"login": {
|
||||
"auto_captcha": true,
|
||||
"max_attempt": 3
|
||||
},
|
||||
"web_driver": {
|
||||
"driver_type": "edge",
|
||||
"driver_path": "",
|
||||
"headless": false
|
||||
},
|
||||
"mode": {
|
||||
"run_mode": 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"groups": []
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
This folder is used to store the template files.
|
||||
|
||||
Directory structure:
|
||||
|
||||
templates
|
||||
|─── configs // template config files
|
||||
Reference in New Issue
Block a user