Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
308a1dfcf3 | ||
|
|
c250fa4a6e | ||
|
|
8f8e3e4ba7 | ||
|
|
88a74a7a47 | ||
|
|
5552af1345 | ||
|
|
6d05d4c7cb | ||
|
|
01f4ccaa0e | ||
|
|
f9175371dc | ||
|
|
8e1b28f3fe | ||
|
|
57f1cfb3f2 | ||
|
|
007b4dc2ef | ||
|
|
0bad34d7a8 | ||
|
|
61d1b44402 | ||
|
|
72301c63fd | ||
|
|
609850ab60 | ||
|
|
8e14d45b71 | ||
|
|
f6ef9af39d | ||
|
|
72dce83c55 | ||
|
|
67f297b434 | ||
|
|
86f0761eed | ||
|
|
79e5b43498 | ||
|
|
b56d2c203e | ||
|
|
44dbde3355 | ||
|
|
62f8ec3d91 | ||
|
|
2d77cbec79 | ||
|
|
10d731518a | ||
|
|
9fdb6f7652 | ||
|
|
ef903ee817 | ||
|
|
d6e8eef8c8 | ||
|
|
e893752c25 | ||
|
|
1cfd7382be | ||
|
|
1d9e41ab86 | ||
|
|
645f07b4d2 | ||
|
|
732f104c5c | ||
|
|
a2bc1881bc | ||
|
|
c1004ed2bc | ||
|
|
38489191f5 | ||
|
|
35253dadbb | ||
|
|
c0b6e0899c | ||
|
|
9c1772b186 | ||
|
|
05b93799d4 | ||
|
|
c337904010 | ||
|
|
779aad13b8 | ||
|
|
f3360423e5 | ||
|
|
bea12d5f0c | ||
|
|
b24f39456e | ||
|
|
bb63ee6f03 | ||
|
|
3ebebe015f | ||
|
|
02b3a62868 | ||
|
|
d7e19dcd52 | ||
|
|
59c06b3a19 | ||
|
|
b78fd2d1e4 | ||
|
|
2aace40a26 | ||
|
|
df7ad92f7f | ||
|
|
910e3e3224 | ||
|
|
f7167c13f4 | ||
|
|
eb8da498a2 | ||
|
|
b279b51b42 | ||
|
|
43336f98d2 | ||
|
|
e77c561685 | ||
|
|
345cb95b98 | ||
|
|
caa563e770 | ||
|
|
280028259f | ||
|
|
a6bc103c73 | ||
|
|
2226e8ac90 | ||
|
|
106463b9e5 | ||
|
|
5e898180c7 | ||
|
|
a03ab38279 | ||
|
|
4761cade26 | ||
|
|
531b05651e | ||
|
|
3cea7df736 | ||
|
|
a0fd03f12f | ||
|
|
9b47886e5b | ||
|
|
82738be99a | ||
|
|
e097b5afc9 | ||
|
|
fe7453fe02 | ||
|
|
1d4b03d162 | ||
|
|
4642916fd5 | ||
|
|
5800437ba2 | ||
|
|
23467c1d3d | ||
|
|
b8c0a29c59 | ||
|
|
87787ad3dc | ||
|
|
e800f6ece1 | ||
|
|
600a304ab8 | ||
|
|
c038c8005d | ||
|
|
6cf182c8c8 | ||
|
|
33c0f4414c | ||
|
|
2843300cf9 | ||
|
|
9bdc9a3de9 | ||
|
|
500ddd41c5 | ||
|
|
14c6db3384 | ||
|
|
bbd97970a6 | ||
|
|
22d3c3462c | ||
|
|
dc287f3aa5 | ||
|
|
7886379875 | ||
|
|
967ede4b04 | ||
|
|
27250dba2f | ||
|
|
46b3447d1e | ||
|
|
4d0d7a952c | ||
|
|
e11f696b76 | ||
|
|
ffae43d5bd | ||
|
|
baa4f23136 | ||
|
|
1c88d3db7b | ||
|
|
3880f90916 | ||
|
|
d3d146b1b3 | ||
|
|
0f74a3b0ec | ||
|
|
9305c559cd | ||
|
|
f56945f29e | ||
|
|
37132de4fc | ||
|
|
ac5385bcfe | ||
|
|
f984217bda | ||
|
|
4e7780fe70 | ||
|
|
7149cb2b7d | ||
|
|
2c90008fcd | ||
|
|
5c393595d7 | ||
|
|
4924f4b031 | ||
|
|
62c1ecdb07 | ||
|
|
aef28b6d5e | ||
|
|
afa1d39051 | ||
|
|
84cff6acc3 | ||
|
|
e40c7f4f3e | ||
|
|
c8e202dc8c | ||
|
|
9a3abc365c | ||
|
|
6b2bf4863e | ||
|
|
95aa2bb518 | ||
|
|
571af554d2 | ||
|
|
706fc889f9 | ||
|
|
bf93cc2cbc | ||
|
|
1cfe261324 | ||
|
|
e5dea7bcc5 | ||
|
|
30b36b68dd | ||
|
|
595f43d852 | ||
|
|
02463f087e | ||
|
|
e481824344 | ||
|
|
160d6a2428 | ||
|
|
ec683cf154 | ||
|
|
2d0782c368 | ||
|
|
824b9b8869 | ||
|
|
c26f19b6b3 | ||
|
|
1d99ca92f2 | ||
|
|
50ebeb0fab | ||
|
|
faa26b489a | ||
|
|
c03eed1d51 | ||
|
|
2f5680c547 | ||
|
|
1cd39ec84c | ||
|
|
73aab7b957 | ||
|
|
0a94c344d5 | ||
|
|
68e002ba8e | ||
|
|
94dc22819f | ||
|
|
d55d2075cb | ||
|
|
82744e3a2d | ||
|
|
67493349dd | ||
|
|
0aea9b1540 | ||
|
|
c02c6ddbe3 | ||
|
|
c679a1c79e | ||
|
|
b73242be00 | ||
|
|
9accf5ddc1 | ||
|
|
883859d1f9 | ||
|
|
f37bcf836b | ||
|
|
b0d1c0e99e | ||
|
|
5af6120be8 | ||
|
|
60e055f6bb | ||
|
|
01e8100774 | ||
|
|
cf8493565e | ||
|
|
24bb76d039 | ||
|
|
7111411115 | ||
|
|
7df6a9157d | ||
|
|
ebe3910df5 | ||
|
|
84367e4abe | ||
|
|
3a50991860 | ||
|
|
e4482b01da | ||
|
|
c06e0e05da | ||
|
|
ff083884b6 | ||
|
|
9ae89b61a4 | ||
|
|
2152cc46a3 | ||
|
|
95a3ae2a24 | ||
|
|
896242a1e3 | ||
|
|
fd96fc235e | ||
|
|
25aab588a8 | ||
|
|
6e1b8e6b10 | ||
|
|
5f2327cf61 | ||
|
|
96e7adabb0 | ||
|
|
42afbbe694 | ||
|
|
3777970332 | ||
|
|
9fb28e1368 | ||
|
|
4aeca08ce8 | ||
|
|
a1ff85256a | ||
|
|
169de92d5b | ||
|
|
5ca4a14a14 | ||
|
|
155b3fe3ca | ||
|
|
99d454a566 | ||
|
|
3963b3f2e6 | ||
|
|
f2a05809bd | ||
|
|
b55a0c06a5 | ||
|
|
2496c4e367 | ||
|
|
de30559af1 | ||
|
|
e1c2efc8c0 | ||
|
|
26a70cdceb | ||
|
|
ce14be2555 | ||
|
|
eda16f01f1 | ||
|
|
22f806bfb0 | ||
|
|
d26852eaaf | ||
|
|
2ffe620532 | ||
|
|
fe42d3cd98 | ||
|
|
0795939aa3 | ||
|
|
8b6baf9b6a | ||
|
|
7098d7075f | ||
|
|
be3942ea2f | ||
|
|
7e3a089e21 | ||
|
|
f3d68c40cb | ||
|
|
0ceff677e4 | ||
|
|
6f6b415bff | ||
|
|
735f31830d | ||
|
|
7be5afeae1 | ||
|
|
3d6978c9c2 | ||
|
|
db7a868598 | ||
|
|
f1e0334ce3 | ||
|
|
b9411261ea | ||
|
|
fa737711d4 | ||
|
|
79e2128fca | ||
|
|
128c8e7a83 | ||
|
|
6474f6e3bb | ||
|
|
ba60a5d884 | ||
|
|
4d8f8130dc | ||
|
|
eba99cab9f | ||
|
|
aa7a806ff7 | ||
|
|
bb180f8c8e | ||
|
|
107ed41b58 | ||
|
|
43b87db4eb | ||
|
|
ae23f65e5a | ||
|
|
a7b9c340ae | ||
|
|
96d733d2ed | ||
|
|
65cb951ada | ||
|
|
94ce3433a3 | ||
|
|
dd48c8a01c | ||
|
|
924db3bdcc | ||
|
|
1e5452d411 | ||
|
|
1b378e5aaa | ||
|
|
e069efb2ea | ||
|
|
407d25570a | ||
|
|
bfcb65f56a | ||
|
|
cde1e966e7 | ||
|
|
8c4f463889 | ||
|
|
39867cc20c | ||
|
|
149910d628 | ||
|
|
2a7ed099bf | ||
|
|
473f32ca29 | ||
|
|
580052f1e3 | ||
|
|
6abf530307 | ||
|
|
577c651ef8 | ||
|
|
18ae949900 | ||
|
|
ca9059d1db | ||
|
|
ad4deae0c6 | ||
|
|
55ae4d0d96 | ||
|
|
7dcd72939b | ||
|
|
bfce61f4b4 | ||
|
|
60a5699822 | ||
|
|
aab9565012 | ||
|
|
9255eec9f1 | ||
|
|
cff6fd8fc0 | ||
|
|
b129f47b48 | ||
|
|
069429be71 | ||
|
|
7d064fc8e7 | ||
|
|
1b172ad396 | ||
|
|
05c9d433f4 | ||
|
|
65ca40438d | ||
|
|
0a8763add5 | ||
|
|
c5e589f3d1 | ||
|
|
5e5deba773 | ||
|
|
842fb434f4 | ||
|
|
6cabddf0cd | ||
|
|
0322558339 | ||
|
|
703ee527ae | ||
|
|
9a925fecb6 | ||
|
|
189fddfb6a | ||
|
|
c2d53a8b78 | ||
|
|
b99431476a | ||
|
|
977c0835b7 | ||
|
|
cd565ec57d | ||
|
|
9f17474c1b |
No files matched your search
@@ -0,0 +1,494 @@
|
|||||||
|
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
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
#
|
||||||
|
# Build Windows
|
||||||
|
#
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-windows:
|
||||||
|
runs-on: windows-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
ref: ${{ github.ref }}
|
||||||
|
|
||||||
|
- name: Get version info
|
||||||
|
id: get_version
|
||||||
|
run: |
|
||||||
|
$version = "pr-test"
|
||||||
|
$tagName = "pr-test"
|
||||||
|
|
||||||
|
Write-Host "✓ Mode: Pull Request Test Build"
|
||||||
|
Write-Host "✓ Tag: $tagName"
|
||||||
|
Write-Host "✓ Version: $version"
|
||||||
|
|
||||||
|
"VERSION=$version" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append
|
||||||
|
"TAG_NAME=$tagName" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append
|
||||||
|
shell: pwsh
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v6
|
||||||
|
with:
|
||||||
|
python-version: '3.13'
|
||||||
|
cache: 'pip'
|
||||||
|
cache-dependency-path: requirements.txt
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
- name: Solve ddddocr compatibility and copy model files
|
||||||
|
run: |
|
||||||
|
$ddddocrPath = python -c "import ddddocr, os; print(os.path.dirname(ddddocr.__file__))"
|
||||||
|
Write-Host "ddddocr package location: $ddddocrPath"
|
||||||
|
|
||||||
|
$initFile = Join-Path $ddddocrPath "__init__.py"
|
||||||
|
if (Test-Path $initFile) {
|
||||||
|
Write-Host "Fixing ddddocr compatibility in: $initFile"
|
||||||
|
(Get-Content $initFile) -replace 'Image\.ANTIALIAS', 'Image.Resampling.LANCZOS' | Set-Content $initFile
|
||||||
|
Write-Host "✓ Fixed: Image.ANTIALIAS -> Image.Resampling.LANCZOS"
|
||||||
|
} else {
|
||||||
|
Write-Error "✗ ddddocr __init__.py not found"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not (Test-Path "models")) {
|
||||||
|
New-Item -ItemType Directory -Path "models" | Out-Null
|
||||||
|
Write-Host "✓ Created models directory"
|
||||||
|
}
|
||||||
|
|
||||||
|
$onnxSource = Join-Path $ddddocrPath "common.onnx"
|
||||||
|
$onnxDest = "models/common.onnx"
|
||||||
|
if (Test-Path $onnxSource) {
|
||||||
|
Copy-Item $onnxSource $onnxDest -Force
|
||||||
|
Write-Host "✓ Copied ONNX model from: $onnxSource"
|
||||||
|
Write-Host "✓ ONNX model copied to: $onnxDest"
|
||||||
|
} else {
|
||||||
|
Write-Error "✗ ONNX model not found in ddddocr package: $onnxSource"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Test-Path $onnxDest) {
|
||||||
|
$fileSize = (Get-Item $onnxDest).Length / 1MB
|
||||||
|
Write-Host "✓ Model file verified: $onnxDest (Size: $([math]::Round($fileSize, 2)) MB)"
|
||||||
|
} else {
|
||||||
|
Write-Error "✗ Failed to copy model file"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
shell: pwsh
|
||||||
|
|
||||||
|
- name: Compile Qt Resource files
|
||||||
|
run: |
|
||||||
|
cd batchs
|
||||||
|
./compile_rc.bat
|
||||||
|
shell: cmd
|
||||||
|
|
||||||
|
- name: Compile Qt UI files
|
||||||
|
run: |
|
||||||
|
cd batchs
|
||||||
|
./compile_ui.bat
|
||||||
|
shell: cmd
|
||||||
|
|
||||||
|
- name: Generate 'Main.spec'
|
||||||
|
run: |
|
||||||
|
$version = "${{ steps.get_version.outputs.VERSION }}"
|
||||||
|
$exeName = "AutoLibrary-$version"
|
||||||
|
|
||||||
|
Write-Host "Generating Main.spec for version: $version"
|
||||||
|
Write-Host "Executable name: $exeName"
|
||||||
|
|
||||||
|
$specLines = @(
|
||||||
|
"# -*- mode: python ; coding: utf-8 -*-"
|
||||||
|
""
|
||||||
|
""
|
||||||
|
"a = Analysis("
|
||||||
|
" ['src\\Main.py'],"
|
||||||
|
" pathex=[],"
|
||||||
|
" binaries=[],"
|
||||||
|
" datas=["
|
||||||
|
" ('models\\common.onnx', 'ddddocr'),"
|
||||||
|
" ('src\\gui\\resources\\icons\\AutoLibrary_Logo_64.ico', 'gui\\resources\\icons'),"
|
||||||
|
" ],"
|
||||||
|
" hiddenimports=[],"
|
||||||
|
" hookspath=[],"
|
||||||
|
" hooksconfig={},"
|
||||||
|
" runtime_hooks=[],"
|
||||||
|
" excludes=[],"
|
||||||
|
" noarchive=False,"
|
||||||
|
" optimize=0,"
|
||||||
|
")"
|
||||||
|
"pyz = PYZ(a.pure)"
|
||||||
|
""
|
||||||
|
"exe = EXE("
|
||||||
|
" pyz,"
|
||||||
|
" a.scripts,"
|
||||||
|
" name='AutoLibrary',"
|
||||||
|
" debug=False,"
|
||||||
|
" bootloader_ignore_signals=False,"
|
||||||
|
" strip=False,"
|
||||||
|
" upx=True,"
|
||||||
|
" upx_exclude=[],"
|
||||||
|
" runtime_tmpdir=None,"
|
||||||
|
" console=False,"
|
||||||
|
" disable_windowed_traceback=False,"
|
||||||
|
" argv_emulation=False,"
|
||||||
|
" target_arch=None,"
|
||||||
|
" codesign_identity=None,"
|
||||||
|
" entitlements_file=None,"
|
||||||
|
" icon=['src\\gui\\resources\\icons\\AutoLibrary_Logo_64.ico'],"
|
||||||
|
")"
|
||||||
|
""
|
||||||
|
"coll = COLLECT("
|
||||||
|
" exe,"
|
||||||
|
" a.binaries,"
|
||||||
|
" a.datas,"
|
||||||
|
" strip=False,"
|
||||||
|
" upx=True,"
|
||||||
|
" upx_exclude=[],"
|
||||||
|
" name='$exeName'"
|
||||||
|
")"
|
||||||
|
)
|
||||||
|
$specLines | Out-File -FilePath "Main.spec" -Encoding UTF8
|
||||||
|
|
||||||
|
Write-Host "✓ Main.spec (non-single file) generated successfully"
|
||||||
|
Write-Host "`n========================================"
|
||||||
|
Write-Host "Generated Main.spec"
|
||||||
|
Write-Host "========================================"
|
||||||
|
Get-Content "Main.spec" | Write-Host
|
||||||
|
Write-Host "========================================`n"
|
||||||
|
shell: pwsh
|
||||||
|
|
||||||
|
- name: Build with PyInstaller
|
||||||
|
run: |
|
||||||
|
pyinstaller Main.spec
|
||||||
|
|
||||||
|
- name: Zip windows release
|
||||||
|
id: zip_release
|
||||||
|
run: |
|
||||||
|
$tagName = "${{ steps.get_version.outputs.TAG_NAME }}"
|
||||||
|
$version = "${{ steps.get_version.outputs.VERSION }}"
|
||||||
|
$distDir = "dist/AutoLibrary-$version"
|
||||||
|
$zipName = "AutoLibrary.$tagName-windows-x86_64.zip"
|
||||||
|
|
||||||
|
"ZIP_NAME=$zipName" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append
|
||||||
|
|
||||||
|
Write-Host "Looking for distribution directory: $distDir"
|
||||||
|
if (Test-Path $distDir) {
|
||||||
|
Compress-Archive -Path "$distDir/*" -DestinationPath $zipName
|
||||||
|
Write-Host "✓ Created release archive (directory mode): $zipName"
|
||||||
|
} else {
|
||||||
|
Write-Error "✗ Distribution directory not found: $distDir"
|
||||||
|
Write-Host "Files in dist directory:"
|
||||||
|
Get-ChildItem "dist" | ForEach-Object { Write-Host " - $($_.Name)" }
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
shell: pwsh
|
||||||
|
|
||||||
|
- name: Archive artifacts
|
||||||
|
uses: actions/upload-artifact@v6
|
||||||
|
with:
|
||||||
|
name: AutoLibrary.${{ steps.get_version.outputs.TAG_NAME }}-windows-x86_64
|
||||||
|
path: |
|
||||||
|
${{ steps.zip_release.outputs.ZIP_NAME }}
|
||||||
|
retention-days: 7
|
||||||
|
|
||||||
|
- name: Upload build summary
|
||||||
|
run: |
|
||||||
|
"## Build Test Summary" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8
|
||||||
|
"" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
|
||||||
|
"========================================" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
|
||||||
|
"✓ Pull request build test completed successfully!" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
|
||||||
|
"- Version: ${{ steps.get_version.outputs.VERSION }}" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
|
||||||
|
"- Tag: ${{ steps.get_version.outputs.TAG_NAME }}" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
|
||||||
|
"- Pull Request #${{ github.event.pull_request.number || 'N/A' }}" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
|
||||||
|
"- Branch: ${{ github.event.pull_request.head.ref || github.ref }}" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
|
||||||
|
"- Event: ${{ github.event_name }}" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
|
||||||
|
shell: pwsh
|
||||||
|
|
||||||
|
#
|
||||||
|
# Build macOS
|
||||||
|
#
|
||||||
|
|
||||||
|
build-macos:
|
||||||
|
runs-on: macos-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
ref: ${{ github.ref }}
|
||||||
|
|
||||||
|
- name: Get version info
|
||||||
|
id: get_version
|
||||||
|
run: |
|
||||||
|
version="pr-test"
|
||||||
|
tag_name="pr-test"
|
||||||
|
echo "✓ Mode: Pull Request Test Build"
|
||||||
|
echo "✓ Tag: $tag_name"
|
||||||
|
echo "✓ Version: $version"
|
||||||
|
echo "VERSION=$version" >> $GITHUB_OUTPUT
|
||||||
|
echo "TAG_NAME=$tag_name" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v6
|
||||||
|
with:
|
||||||
|
python-version: '3.13'
|
||||||
|
cache: 'pip'
|
||||||
|
cache-dependency-path: requirements.txt
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
- name: Solve ddddocr compatibility and copy model files
|
||||||
|
run: |
|
||||||
|
ddddocr_path=$(python -c "import ddddocr, os; print(os.path.dirname(ddddocr.__file__))")
|
||||||
|
echo "ddddocr package location: $ddddocr_path"
|
||||||
|
|
||||||
|
init_file="$ddddocr_path/__init__.py"
|
||||||
|
if [ -f "$init_file" ]; then
|
||||||
|
echo "Fixing ddddocr compatibility in: $init_file"
|
||||||
|
# macOS 使用 BSD sed,要求 -i 后跟备份后缀名(空字符串 = 不备份)
|
||||||
|
sed -i '' 's/Image\.ANTIALIAS/Image.Resampling.LANCZOS/g' "$init_file"
|
||||||
|
echo "✓ Fixed: Image.ANTIALIAS -> Image.Resampling.LANCZOS"
|
||||||
|
else
|
||||||
|
echo "✗ ddddocr __init__.py not found"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p models
|
||||||
|
echo "✓ Created models directory"
|
||||||
|
|
||||||
|
# 多路径 fallback 搜索 ONNX 模型,提升 ddddocr 版本兼容性
|
||||||
|
onnx_source=""
|
||||||
|
onnx_dest="models/common.onnx"
|
||||||
|
candidate_paths=(
|
||||||
|
"$ddddocr_path/common.onnx"
|
||||||
|
"$ddddocr_path/models/common.onnx"
|
||||||
|
"$ddddocr_path/ddddocr/common.onnx"
|
||||||
|
)
|
||||||
|
for candidate in "${candidate_paths[@]}"; do
|
||||||
|
if [ -f "$candidate" ]; then
|
||||||
|
onnx_source="$candidate"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
# 若以上候选均未命中,回退到全包搜索
|
||||||
|
if [ -z "$onnx_source" ]; then
|
||||||
|
echo "⚠ 未在已知路径找到 ONNX 模型,回退到全包搜索..."
|
||||||
|
onnx_source=$(find "$ddddocr_path" -name "*.onnx" -type f 2>/dev/null | head -1)
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -n "$onnx_source" ] && [ -f "$onnx_source" ]; then
|
||||||
|
cp "$onnx_source" "$onnx_dest"
|
||||||
|
echo "✓ ONNX model copied: $onnx_source -> $onnx_dest"
|
||||||
|
else
|
||||||
|
echo "✗ ONNX model not found in ddddocr package"
|
||||||
|
echo " Searched directory: $ddddocr_path"
|
||||||
|
ls -la "$ddddocr_path/" || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -f "$onnx_dest" ]; then
|
||||||
|
file_size=$(du -h "$onnx_dest" | cut -f1)
|
||||||
|
echo "✓ Model file verified: $onnx_dest (Size: $file_size)"
|
||||||
|
else
|
||||||
|
echo "✗ Failed to copy model file"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Compile Qt Resource files
|
||||||
|
run: |
|
||||||
|
cd batchs
|
||||||
|
bash compile_rc.sh
|
||||||
|
|
||||||
|
- name: Compile Qt UI files
|
||||||
|
run: |
|
||||||
|
cd batchs
|
||||||
|
bash compile_ui.sh
|
||||||
|
|
||||||
|
- name: Generate 'Main.spec'
|
||||||
|
env:
|
||||||
|
APP_VERSION: ${{ steps.get_version.outputs.VERSION }}
|
||||||
|
run: |
|
||||||
|
version="$APP_VERSION"
|
||||||
|
exe_name="AutoLibrary-$version"
|
||||||
|
echo "Generating Main.spec for version: $version"
|
||||||
|
echo "App name: $exe_name"
|
||||||
|
|
||||||
|
printf '%s\n' \
|
||||||
|
'# -*- mode: python ; coding: utf-8 -*-' \
|
||||||
|
'' \
|
||||||
|
'a = Analysis(' \
|
||||||
|
" ['src/Main.py']," \
|
||||||
|
' pathex=[],' \
|
||||||
|
' binaries=[],' \
|
||||||
|
' datas=[' \
|
||||||
|
" ('models/common.onnx', 'ddddocr')," \
|
||||||
|
" ('src/gui/resources/icons/AutoLibrary_Logo_64.ico', 'gui/resources/icons')," \
|
||||||
|
' ],' \
|
||||||
|
' hiddenimports=[],' \
|
||||||
|
' hookspath=[],' \
|
||||||
|
' hooksconfig={},' \
|
||||||
|
' runtime_hooks=[],' \
|
||||||
|
' excludes=[],' \
|
||||||
|
' noarchive=False,' \
|
||||||
|
' optimize=0,' \
|
||||||
|
')' \
|
||||||
|
'pyz = PYZ(a.pure)' \
|
||||||
|
'' \
|
||||||
|
'exe = EXE(' \
|
||||||
|
' pyz,' \
|
||||||
|
' a.scripts,' \
|
||||||
|
' [],' \
|
||||||
|
' exclude_binaries=True,' \
|
||||||
|
" name='AutoLibrary'," \
|
||||||
|
' debug=False,' \
|
||||||
|
' bootloader_ignore_signals=False,' \
|
||||||
|
' strip=False,' \
|
||||||
|
' upx=True,' \
|
||||||
|
' upx_exclude=[],' \
|
||||||
|
' runtime_tmpdir=None,' \
|
||||||
|
' console=False,' \
|
||||||
|
' disable_windowed_traceback=False,' \
|
||||||
|
' argv_emulation=False,' \
|
||||||
|
' target_arch=None,' \
|
||||||
|
' codesign_identity=None,' \
|
||||||
|
' entitlements_file=None,' \
|
||||||
|
" icon=['src/gui/resources/icons/AutoLibrary_Logo_64.ico']," \
|
||||||
|
')' \
|
||||||
|
'' \
|
||||||
|
'coll = COLLECT(' \
|
||||||
|
' exe,' \
|
||||||
|
' a.binaries,' \
|
||||||
|
' a.zipfiles,' \
|
||||||
|
' a.datas,' \
|
||||||
|
' strip=False,' \
|
||||||
|
' upx=True,' \
|
||||||
|
' upx_exclude=[],' \
|
||||||
|
" name='${exe_name}'," \
|
||||||
|
')' \
|
||||||
|
'' \
|
||||||
|
'app = BUNDLE(' \
|
||||||
|
' coll,' \
|
||||||
|
" name='AutoLibrary.app'," \
|
||||||
|
" icon='src/gui/resources/icons/AutoLibrary_Logo.icns'," \
|
||||||
|
" bundle_identifier='com.kenanzhu.autolibrary'," \
|
||||||
|
' info_plist={' \
|
||||||
|
" 'NSHighResolutionCapable': 'True'," \
|
||||||
|
" 'CFBundleName': 'AutoLibrary'," \
|
||||||
|
" 'CFBundleDisplayName': 'AutoLibrary'," \
|
||||||
|
" 'CFBundleShortVersionString': '${version}'," \
|
||||||
|
" 'CFBundleVersion': '${version}'," \
|
||||||
|
" 'CFBundleExecutable': 'AutoLibrary'," \
|
||||||
|
" 'NSPrincipalClass': 'NSApplication'," \
|
||||||
|
" 'LSMinimumSystemVersion': '11.0'," \
|
||||||
|
" 'NSRequiresAquaSystemAppearance': 'False'," \
|
||||||
|
" 'NSHumanReadableCopyright': 'Copyright 2025 - 2026 KenanZhu. All rights reserved.'," \
|
||||||
|
' },' \
|
||||||
|
')' \
|
||||||
|
> Main.spec
|
||||||
|
|
||||||
|
echo "✓ Main.spec generated successfully"
|
||||||
|
echo ""
|
||||||
|
echo "========================================"
|
||||||
|
echo "Generated Main.spec"
|
||||||
|
echo "========================================"
|
||||||
|
cat Main.spec
|
||||||
|
echo "========================================"
|
||||||
|
|
||||||
|
- name: Build with PyInstaller
|
||||||
|
run: |
|
||||||
|
python -m PyInstaller Main.spec
|
||||||
|
|
||||||
|
- name: Create DMG
|
||||||
|
run: |
|
||||||
|
tag_name="${{ steps.get_version.outputs.TAG_NAME }}"
|
||||||
|
dmg_name="AutoLibrary.$tag_name-macos-arm64.dmg"
|
||||||
|
app_path="dist/AutoLibrary.app"
|
||||||
|
|
||||||
|
if [ ! -d "$app_path" ]; then
|
||||||
|
echo "✗ App bundle not found: $app_path"
|
||||||
|
echo "Files in dist directory:"
|
||||||
|
ls -la dist/
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Creating DMG from: $app_path"
|
||||||
|
xattr -cr "$app_path" 2>/dev/null || true
|
||||||
|
|
||||||
|
tmp_dmg_dir=$(mktemp -d)
|
||||||
|
cp -R "$app_path" "$tmp_dmg_dir/"
|
||||||
|
ln -s /Applications "$tmp_dmg_dir/Applications"
|
||||||
|
|
||||||
|
hdiutil create \
|
||||||
|
-volname "AutoLibrary $tag_name" \
|
||||||
|
-srcfolder "$tmp_dmg_dir" \
|
||||||
|
-ov \
|
||||||
|
-format UDZO \
|
||||||
|
"$dmg_name"
|
||||||
|
|
||||||
|
rm -rf "$tmp_dmg_dir"
|
||||||
|
|
||||||
|
if [ -f "$dmg_name" ]; then
|
||||||
|
dmg_size=$(du -h "$dmg_name" | cut -f1)
|
||||||
|
echo "✓ DMG created: $dmg_name (Size: $dmg_size)"
|
||||||
|
else
|
||||||
|
echo "✗ Failed to create DMG"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "✓ Artifacts ready:"
|
||||||
|
echo " - $dmg_name"
|
||||||
|
echo " - $app_path"
|
||||||
|
|
||||||
|
- name: Archive artifacts
|
||||||
|
uses: actions/upload-artifact@v6
|
||||||
|
with:
|
||||||
|
name: AutoLibrary.${{ steps.get_version.outputs.TAG_NAME }}-macos-arm64
|
||||||
|
path: |
|
||||||
|
AutoLibrary.${{ steps.get_version.outputs.TAG_NAME }}-macos-arm64.dmg
|
||||||
|
dist/AutoLibrary.app/**
|
||||||
|
retention-days: 7
|
||||||
|
|
||||||
|
- name: Upload build summary
|
||||||
|
run: |
|
||||||
|
tag_name="${{ steps.get_version.outputs.TAG_NAME }}"
|
||||||
|
version="${{ steps.get_version.outputs.VERSION }}"
|
||||||
|
dmg_name="AutoLibrary.$tag_name-macos-arm64.dmg"
|
||||||
|
|
||||||
|
{
|
||||||
|
echo "## Build Test Summary (macOS)"
|
||||||
|
echo ""
|
||||||
|
echo "========================================"
|
||||||
|
echo "✓ Pull request build test completed successfully!"
|
||||||
|
echo "- Version: $version"
|
||||||
|
echo "- Tag: $tag_name"
|
||||||
|
echo "- Pull Request #${{ github.event.pull_request.number || 'N/A' }}"
|
||||||
|
echo "- Branch: ${{ github.event.pull_request.head.ref || github.ref }}"
|
||||||
|
echo "- Event: ${{ github.event_name }}"
|
||||||
|
echo "- DMG: $dmg_name"
|
||||||
|
echo ""
|
||||||
|
echo "### How to test on macOS"
|
||||||
|
echo '1. Download the DMG artifact'
|
||||||
|
echo '2. Open the DMG and drag AutoLibrary.app to /Applications'
|
||||||
|
echo '3. Right-click → Open the app (to bypass Gatekeeper)'
|
||||||
|
} >> $GITHUB_STEP_SUMMARY
|
||||||
@@ -0,0 +1,563 @@
|
|||||||
|
name: Build
|
||||||
|
|
||||||
|
# This workflow compiles the application for Windows and macOS platforms using
|
||||||
|
# PyInstaller, and archives the built artifacts.
|
||||||
|
#
|
||||||
|
# - Windows: AutoLibrary.<tag_name>-windows-x86_64.zip
|
||||||
|
# - macOS: AutoLibrary.<tag_name>-macos-arm64.dmg
|
||||||
|
#
|
||||||
|
# It is triggered when called by the release workflow.
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_call:
|
||||||
|
inputs:
|
||||||
|
tag_name:
|
||||||
|
description: 'Tag name'
|
||||||
|
required: false
|
||||||
|
type: string
|
||||||
|
version:
|
||||||
|
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
|
||||||
|
#
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-windows:
|
||||||
|
runs-on: windows-latest
|
||||||
|
outputs:
|
||||||
|
tag_name: ${{ steps.get_version.outputs.TAG_NAME }}
|
||||||
|
version: ${{ steps.get_version.outputs.VERSION }}
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
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@v6
|
||||||
|
with:
|
||||||
|
name: updated-version-info-for-build
|
||||||
|
path: src/gui/
|
||||||
|
|
||||||
|
- name: Get version info
|
||||||
|
id: get_version
|
||||||
|
run: |
|
||||||
|
$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)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "✓ Tag: $tagName"
|
||||||
|
Write-Host "✓ Version: $version"
|
||||||
|
|
||||||
|
"VERSION=$version" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append
|
||||||
|
"TAG_NAME=$tagName" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append
|
||||||
|
shell: pwsh
|
||||||
|
|
||||||
|
- name: Verify 'ALVersionInfo.py' was updated
|
||||||
|
run: |
|
||||||
|
$versionInfoFile = "src/gui/ALVersionInfo.py"
|
||||||
|
Write-Host "Verifying $versionInfoFile content:"
|
||||||
|
Write-Host "========================================"
|
||||||
|
Get-Content $versionInfoFile | Write-Host
|
||||||
|
Write-Host "========================================"
|
||||||
|
shell: pwsh
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v6
|
||||||
|
with:
|
||||||
|
python-version: '3.13'
|
||||||
|
cache: 'pip'
|
||||||
|
cache-dependency-path: requirements.txt
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
- name: Solve ddddocr compatibility and copy model files
|
||||||
|
run: |
|
||||||
|
$ddddocrPath = python -c "import ddddocr, os; print(os.path.dirname(ddddocr.__file__))"
|
||||||
|
Write-Host "ddddocr package location: $ddddocrPath"
|
||||||
|
|
||||||
|
$initFile = Join-Path $ddddocrPath "__init__.py"
|
||||||
|
if (Test-Path $initFile) {
|
||||||
|
Write-Host "Fixing ddddocr compatibility in: $initFile"
|
||||||
|
(Get-Content $initFile) -replace 'Image\.ANTIALIAS', 'Image.Resampling.LANCZOS' | Set-Content $initFile
|
||||||
|
Write-Host "✓ Fixed: Image.ANTIALIAS -> Image.Resampling.LANCZOS"
|
||||||
|
} else {
|
||||||
|
Write-Error "✗ ddddocr __init__.py not found"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not (Test-Path "models")) {
|
||||||
|
New-Item -ItemType Directory -Path "models" | Out-Null
|
||||||
|
Write-Host "✓ Created models directory"
|
||||||
|
}
|
||||||
|
|
||||||
|
$onnxSource = Join-Path $ddddocrPath "common.onnx"
|
||||||
|
$onnxDest = "models/common.onnx"
|
||||||
|
if (Test-Path $onnxSource) {
|
||||||
|
Copy-Item $onnxSource $onnxDest -Force
|
||||||
|
Write-Host "✓ Copied ONNX model from: $onnxSource"
|
||||||
|
Write-Host "✓ ONNX model copied to: $onnxDest"
|
||||||
|
} else {
|
||||||
|
Write-Error "✗ ONNX model not found in ddddocr package: $onnxSource"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Test-Path $onnxDest) {
|
||||||
|
$fileSize = (Get-Item $onnxDest).Length / 1MB
|
||||||
|
Write-Host "✓ Model file verified: $onnxDest (Size: $([math]::Round($fileSize, 2)) MB)"
|
||||||
|
} else {
|
||||||
|
Write-Error "✗ Failed to copy model file"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
shell: pwsh
|
||||||
|
|
||||||
|
- name: Compile Qt Resource files
|
||||||
|
run: |
|
||||||
|
cd batchs
|
||||||
|
./compile_rc.bat
|
||||||
|
shell: cmd
|
||||||
|
|
||||||
|
- name: Compile Qt UI files
|
||||||
|
run: |
|
||||||
|
cd batchs
|
||||||
|
./compile_ui.bat
|
||||||
|
shell: cmd
|
||||||
|
|
||||||
|
- name: Generate 'Main.spec'
|
||||||
|
run: |
|
||||||
|
$version = "${{ steps.get_version.outputs.VERSION }}"
|
||||||
|
$exeName = "AutoLibrary-$version"
|
||||||
|
|
||||||
|
Write-Host "Generating Main.spec for version: $version"
|
||||||
|
Write-Host "Executable name: $exeName"
|
||||||
|
|
||||||
|
$specLines = @(
|
||||||
|
"# -*- mode: python ; coding: utf-8 -*-"
|
||||||
|
""
|
||||||
|
""
|
||||||
|
"a = Analysis("
|
||||||
|
" ['src\\Main.py'],"
|
||||||
|
" pathex=[],"
|
||||||
|
" binaries=[],"
|
||||||
|
" datas=["
|
||||||
|
" ('models\\common.onnx', 'ddddocr'),"
|
||||||
|
" ('src\\gui\\resources\\icons\\AutoLibrary_Logo_64.ico', 'gui\\resources\\icons'),"
|
||||||
|
" ],"
|
||||||
|
" hiddenimports=[],"
|
||||||
|
" hookspath=[],"
|
||||||
|
" hooksconfig={},"
|
||||||
|
" runtime_hooks=[],"
|
||||||
|
" excludes=[],"
|
||||||
|
" noarchive=False,"
|
||||||
|
" optimize=0,"
|
||||||
|
")"
|
||||||
|
"pyz = PYZ(a.pure)"
|
||||||
|
""
|
||||||
|
"exe = EXE("
|
||||||
|
" pyz,"
|
||||||
|
" a.scripts,"
|
||||||
|
" name='AutoLibrary',"
|
||||||
|
" debug=False,"
|
||||||
|
" bootloader_ignore_signals=False,"
|
||||||
|
" strip=False,"
|
||||||
|
" upx=True,"
|
||||||
|
" upx_exclude=[],"
|
||||||
|
" runtime_tmpdir=None,"
|
||||||
|
" console=False,"
|
||||||
|
" disable_windowed_traceback=False,"
|
||||||
|
" argv_emulation=False,"
|
||||||
|
" target_arch=None,"
|
||||||
|
" codesign_identity=None,"
|
||||||
|
" entitlements_file=None,"
|
||||||
|
" icon=['src\\gui\\resources\\icons\\AutoLibrary_Logo_64.ico'],"
|
||||||
|
")"
|
||||||
|
""
|
||||||
|
"coll = COLLECT("
|
||||||
|
" exe,"
|
||||||
|
" a.binaries,"
|
||||||
|
" a.datas,"
|
||||||
|
" strip=False,"
|
||||||
|
" upx=True,"
|
||||||
|
" upx_exclude=[],"
|
||||||
|
" name='$exeName'"
|
||||||
|
")"
|
||||||
|
)
|
||||||
|
$specLines | Out-File -FilePath "Main.spec" -Encoding UTF8
|
||||||
|
|
||||||
|
Write-Host "✓ Main.spec (non-single file) generated successfully"
|
||||||
|
Write-Host "`n========================================"
|
||||||
|
Write-Host "Generated Main.spec"
|
||||||
|
Write-Host "========================================"
|
||||||
|
Get-Content "Main.spec" | Write-Host
|
||||||
|
Write-Host "========================================`n"
|
||||||
|
shell: pwsh
|
||||||
|
|
||||||
|
- name: Build with PyInstaller
|
||||||
|
run: |
|
||||||
|
pyinstaller Main.spec
|
||||||
|
|
||||||
|
- name: Zip windows release
|
||||||
|
id: zip_release
|
||||||
|
run: |
|
||||||
|
$tagName = "${{ steps.get_version.outputs.TAG_NAME }}"
|
||||||
|
$version = "${{ steps.get_version.outputs.VERSION }}"
|
||||||
|
$distDir = "dist/AutoLibrary-$version"
|
||||||
|
$zipName = "AutoLibrary.$tagName-windows-x86_64.zip"
|
||||||
|
|
||||||
|
"ZIP_NAME=$zipName" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append
|
||||||
|
|
||||||
|
Write-Host "Looking for distribution directory: $distDir"
|
||||||
|
if (Test-Path $distDir) {
|
||||||
|
Compress-Archive -Path "$distDir/*" -DestinationPath $zipName
|
||||||
|
Write-Host "✓ Created release archive (directory mode): $zipName"
|
||||||
|
} else {
|
||||||
|
Write-Error "✗ Distribution directory not found: $distDir"
|
||||||
|
Write-Host "Files in dist directory:"
|
||||||
|
Get-ChildItem "dist" | ForEach-Object { Write-Host " - $($_.Name)" }
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
shell: pwsh
|
||||||
|
|
||||||
|
- name: Archive artifacts
|
||||||
|
uses: actions/upload-artifact@v6
|
||||||
|
with:
|
||||||
|
name: AutoLibrary.${{ steps.get_version.outputs.TAG_NAME }}-windows-x86_64
|
||||||
|
path: |
|
||||||
|
${{ steps.zip_release.outputs.ZIP_NAME }}
|
||||||
|
retention-days: ${{ inputs.is_test == 'true' && 7 || 90 }}
|
||||||
|
|
||||||
|
- name: Upload build summary
|
||||||
|
if: ${{ inputs.is_test == 'true' }}
|
||||||
|
run: |
|
||||||
|
"## Build Summary" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8
|
||||||
|
"" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
|
||||||
|
"========================================" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
|
||||||
|
"✓ Build test completed successfully!" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
|
||||||
|
"- Version: ${{ steps.get_version.outputs.VERSION }}" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
|
||||||
|
"- Tag: ${{ steps.get_version.outputs.TAG_NAME }}" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
|
||||||
|
"- Event: ${{ github.event_name }}" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
|
||||||
|
"- Ref: ${{ github.ref }}" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
|
||||||
|
shell: pwsh
|
||||||
|
|
||||||
|
#
|
||||||
|
# Build macOS
|
||||||
|
#
|
||||||
|
|
||||||
|
build-macos:
|
||||||
|
runs-on: macos-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
ref: ${{ github.ref }}
|
||||||
|
|
||||||
|
- name: Download build version of ALVersionInfo.py
|
||||||
|
uses: actions/download-artifact@v6
|
||||||
|
with:
|
||||||
|
name: updated-version-info-for-build
|
||||||
|
path: src/gui/
|
||||||
|
|
||||||
|
- name: Get version info
|
||||||
|
id: get_version
|
||||||
|
run: |
|
||||||
|
is_test="${{ inputs.is_test }}"
|
||||||
|
if [ "$is_test" = "true" ]; then
|
||||||
|
version="test"
|
||||||
|
tag_name="test"
|
||||||
|
echo "✓ Mode: Test Build"
|
||||||
|
else
|
||||||
|
version="${{ inputs.version }}"
|
||||||
|
tag_name="${{ inputs.tag_name }}"
|
||||||
|
|
||||||
|
if [ -z "$version" ]; then
|
||||||
|
version="test"
|
||||||
|
tag_name="test"
|
||||||
|
echo "✓ Mode: Independent Build (No inputs provided)"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "✓ Tag: $tag_name"
|
||||||
|
echo "✓ Version: $version"
|
||||||
|
|
||||||
|
echo "VERSION=$version" >> $GITHUB_OUTPUT
|
||||||
|
echo "TAG_NAME=$tag_name" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Verify 'ALVersionInfo.py' was updated
|
||||||
|
run: |
|
||||||
|
version_info_file="src/gui/ALVersionInfo.py"
|
||||||
|
echo "Verifying $version_info_file content:"
|
||||||
|
echo "========================================"
|
||||||
|
cat "$version_info_file"
|
||||||
|
echo "========================================"
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v6
|
||||||
|
with:
|
||||||
|
python-version: '3.13'
|
||||||
|
cache: 'pip'
|
||||||
|
cache-dependency-path: requirements.txt
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
- name: Solve ddddocr compatibility and copy model files
|
||||||
|
run: |
|
||||||
|
ddddocr_path=$(python -c "import ddddocr, os; print(os.path.dirname(ddddocr.__file__))")
|
||||||
|
echo "ddddocr package location: $ddddocr_path"
|
||||||
|
|
||||||
|
init_file="$ddddocr_path/__init__.py"
|
||||||
|
if [ -f "$init_file" ]; then
|
||||||
|
echo "Fixing ddddocr compatibility in: $init_file"
|
||||||
|
# macOS 使用 BSD sed,要求 -i 后跟备份后缀名(空字符串 = 不备份)
|
||||||
|
sed -i '' 's/Image\.ANTIALIAS/Image.Resampling.LANCZOS/g' "$init_file"
|
||||||
|
echo "✓ Fixed: Image.ANTIALIAS -> Image.Resampling.LANCZOS"
|
||||||
|
else
|
||||||
|
echo "✗ ddddocr __init__.py not found"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p models
|
||||||
|
echo "✓ Created models directory"
|
||||||
|
|
||||||
|
# 多路径 fallback 搜索 ONNX 模型,提升 ddddocr 版本兼容性
|
||||||
|
onnx_source=""
|
||||||
|
onnx_dest="models/common.onnx"
|
||||||
|
candidate_paths=(
|
||||||
|
"$ddddocr_path/common.onnx"
|
||||||
|
"$ddddocr_path/models/common.onnx"
|
||||||
|
"$ddddocr_path/ddddocr/common.onnx"
|
||||||
|
)
|
||||||
|
for candidate in "${candidate_paths[@]}"; do
|
||||||
|
if [ -f "$candidate" ]; then
|
||||||
|
onnx_source="$candidate"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
# 若以上候选均未命中,回退到全包搜索
|
||||||
|
if [ -z "$onnx_source" ]; then
|
||||||
|
echo "⚠ 未在已知路径找到 ONNX 模型,回退到全包搜索..."
|
||||||
|
onnx_source=$(find "$ddddocr_path" -name "*.onnx" -type f 2>/dev/null | head -1)
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -n "$onnx_source" ] && [ -f "$onnx_source" ]; then
|
||||||
|
cp "$onnx_source" "$onnx_dest"
|
||||||
|
echo "✓ ONNX model copied: $onnx_source -> $onnx_dest"
|
||||||
|
else
|
||||||
|
echo "✗ ONNX model not found in ddddocr package"
|
||||||
|
echo " Searched directory: $ddddocr_path"
|
||||||
|
ls -la "$ddddocr_path/" || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -f "$onnx_dest" ]; then
|
||||||
|
file_size=$(du -h "$onnx_dest" | cut -f1)
|
||||||
|
echo "✓ Model file verified: $onnx_dest (Size: $file_size)"
|
||||||
|
else
|
||||||
|
echo "✗ Failed to copy model file"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Compile Qt Resource files
|
||||||
|
run: |
|
||||||
|
cd batchs
|
||||||
|
bash compile_rc.sh
|
||||||
|
|
||||||
|
- name: Compile Qt UI files
|
||||||
|
run: |
|
||||||
|
cd batchs
|
||||||
|
bash compile_ui.sh
|
||||||
|
|
||||||
|
- name: Generate 'Main.spec'
|
||||||
|
env:
|
||||||
|
APP_VERSION: ${{ steps.get_version.outputs.VERSION }}
|
||||||
|
run: |
|
||||||
|
version="$APP_VERSION"
|
||||||
|
exe_name="AutoLibrary-$version"
|
||||||
|
echo "Generating Main.spec for version: $version"
|
||||||
|
echo "App name: $exe_name"
|
||||||
|
|
||||||
|
printf '%s\n' \
|
||||||
|
'# -*- mode: python ; coding: utf-8 -*-' \
|
||||||
|
'' \
|
||||||
|
'a = Analysis(' \
|
||||||
|
" ['src/Main.py']," \
|
||||||
|
' pathex=[],' \
|
||||||
|
' binaries=[],' \
|
||||||
|
' datas=[' \
|
||||||
|
" ('models/common.onnx', 'ddddocr')," \
|
||||||
|
" ('src/gui/resources/icons/AutoLibrary_Logo_64.ico', 'gui/resources/icons')," \
|
||||||
|
' ],' \
|
||||||
|
' hiddenimports=[],' \
|
||||||
|
' hookspath=[],' \
|
||||||
|
' hooksconfig={},' \
|
||||||
|
' runtime_hooks=[],' \
|
||||||
|
' excludes=[],' \
|
||||||
|
' noarchive=False,' \
|
||||||
|
' optimize=0,' \
|
||||||
|
')' \
|
||||||
|
'pyz = PYZ(a.pure)' \
|
||||||
|
'' \
|
||||||
|
'exe = EXE(' \
|
||||||
|
' pyz,' \
|
||||||
|
' a.scripts,' \
|
||||||
|
' [],' \
|
||||||
|
' exclude_binaries=True,' \
|
||||||
|
" name='AutoLibrary'," \
|
||||||
|
' debug=False,' \
|
||||||
|
' bootloader_ignore_signals=False,' \
|
||||||
|
' strip=False,' \
|
||||||
|
' upx=True,' \
|
||||||
|
' upx_exclude=[],' \
|
||||||
|
' runtime_tmpdir=None,' \
|
||||||
|
' console=False,' \
|
||||||
|
' disable_windowed_traceback=False,' \
|
||||||
|
' argv_emulation=False,' \
|
||||||
|
' target_arch=None,' \
|
||||||
|
' codesign_identity=None,' \
|
||||||
|
' entitlements_file=None,' \
|
||||||
|
" icon=['src/gui/resources/icons/AutoLibrary_Logo_64.ico']," \
|
||||||
|
')' \
|
||||||
|
'' \
|
||||||
|
'coll = COLLECT(' \
|
||||||
|
' exe,' \
|
||||||
|
' a.binaries,' \
|
||||||
|
' a.zipfiles,' \
|
||||||
|
' a.datas,' \
|
||||||
|
' strip=False,' \
|
||||||
|
' upx=True,' \
|
||||||
|
' upx_exclude=[],' \
|
||||||
|
" name='${exe_name}'," \
|
||||||
|
')' \
|
||||||
|
'' \
|
||||||
|
'app = BUNDLE(' \
|
||||||
|
' coll,' \
|
||||||
|
" name='AutoLibrary.app'," \
|
||||||
|
" icon='src/gui/resources/icons/AutoLibrary_Logo.icns'," \
|
||||||
|
" bundle_identifier='com.kenanzhu.autolibrary'," \
|
||||||
|
' info_plist={' \
|
||||||
|
" 'NSHighResolutionCapable': 'True'," \
|
||||||
|
" 'CFBundleName': 'AutoLibrary'," \
|
||||||
|
" 'CFBundleDisplayName': 'AutoLibrary'," \
|
||||||
|
" 'CFBundleShortVersionString': '${version}'," \
|
||||||
|
" 'CFBundleVersion': '${version}'," \
|
||||||
|
" 'CFBundleExecutable': 'AutoLibrary'," \
|
||||||
|
" 'NSPrincipalClass': 'NSApplication'," \
|
||||||
|
" 'LSMinimumSystemVersion': '11.0'," \
|
||||||
|
" 'NSRequiresAquaSystemAppearance': 'False'," \
|
||||||
|
" 'NSHumanReadableCopyright': 'Copyright 2025 - 2026 KenanZhu. All rights reserved.'," \
|
||||||
|
' },' \
|
||||||
|
')' \
|
||||||
|
> Main.spec
|
||||||
|
|
||||||
|
echo "✓ Main.spec generated successfully"
|
||||||
|
echo ""
|
||||||
|
echo "========================================"
|
||||||
|
echo "Generated Main.spec"
|
||||||
|
echo "========================================"
|
||||||
|
cat Main.spec
|
||||||
|
echo "========================================"
|
||||||
|
|
||||||
|
- name: Build with PyInstaller
|
||||||
|
run: |
|
||||||
|
python -m PyInstaller Main.spec
|
||||||
|
|
||||||
|
- name: Create DMG
|
||||||
|
run: |
|
||||||
|
tag_name="${{ steps.get_version.outputs.TAG_NAME }}"
|
||||||
|
dmg_name="AutoLibrary.$tag_name-macos-arm64.dmg"
|
||||||
|
app_path="dist/AutoLibrary.app"
|
||||||
|
|
||||||
|
if [ ! -d "$app_path" ]; then
|
||||||
|
echo "✗ App bundle not found: $app_path"
|
||||||
|
echo "Files in dist directory:"
|
||||||
|
ls -la dist/
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Creating DMG from: $app_path"
|
||||||
|
xattr -cr "$app_path" 2>/dev/null || true
|
||||||
|
|
||||||
|
tmp_dmg_dir=$(mktemp -d)
|
||||||
|
cp -R "$app_path" "$tmp_dmg_dir/"
|
||||||
|
ln -s /Applications "$tmp_dmg_dir/Applications"
|
||||||
|
|
||||||
|
hdiutil create \
|
||||||
|
-volname "AutoLibrary $tag_name" \
|
||||||
|
-srcfolder "$tmp_dmg_dir" \
|
||||||
|
-ov \
|
||||||
|
-format UDZO \
|
||||||
|
"$dmg_name"
|
||||||
|
|
||||||
|
rm -rf "$tmp_dmg_dir"
|
||||||
|
|
||||||
|
if [ -f "$dmg_name" ]; then
|
||||||
|
dmg_size=$(du -h "$dmg_name" | cut -f1)
|
||||||
|
echo "✓ DMG created: $dmg_name (Size: $dmg_size)"
|
||||||
|
else
|
||||||
|
echo "✗ Failed to create DMG"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "✓ Artifacts ready:"
|
||||||
|
echo " - $dmg_name"
|
||||||
|
echo " - $app_path"
|
||||||
|
|
||||||
|
- name: Archive artifacts
|
||||||
|
uses: actions/upload-artifact@v6
|
||||||
|
with:
|
||||||
|
name: AutoLibrary.${{ steps.get_version.outputs.TAG_NAME }}-macos-arm64
|
||||||
|
path: |
|
||||||
|
AutoLibrary.${{ steps.get_version.outputs.TAG_NAME }}-macos-arm64.dmg
|
||||||
|
dist/AutoLibrary.app/**
|
||||||
|
retention-days: ${{ inputs.is_test == 'true' && 7 || 90 }}
|
||||||
|
|
||||||
|
- name: Upload build summary
|
||||||
|
if: ${{ inputs.is_test == 'true' }}
|
||||||
|
run: |
|
||||||
|
tag_name="${{ steps.get_version.outputs.TAG_NAME }}"
|
||||||
|
version="${{ steps.get_version.outputs.VERSION }}"
|
||||||
|
dmg_name="AutoLibrary.$tag_name-macos-arm64.dmg"
|
||||||
|
|
||||||
|
{
|
||||||
|
echo "## Build Summary (macOS)"
|
||||||
|
echo ""
|
||||||
|
echo "========================================"
|
||||||
|
echo "✓ Build test completed successfully!"
|
||||||
|
echo "- Version: $version"
|
||||||
|
echo "- Tag: $tag_name"
|
||||||
|
echo "- Event: ${{ github.event_name }}"
|
||||||
|
echo "- Ref: ${{ github.ref }}"
|
||||||
|
echo "- DMG: $dmg_name"
|
||||||
|
echo ""
|
||||||
|
echo "### How to test on macOS"
|
||||||
|
echo '1. Download the DMG artifact'
|
||||||
|
echo '2. Open the DMG and drag AutoLibrary.app to /Applications'
|
||||||
|
echo '3. Right-click → Open the app (to bypass Gatekeeper)'
|
||||||
|
} >> $GITHUB_STEP_SUMMARY
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
name: Commit Release
|
||||||
|
|
||||||
|
# This workflow commits version changes in 'ALVersionInfo.py' (get from artifacts) and
|
||||||
|
# creates/moves the release tag to this new release commit.
|
||||||
|
#
|
||||||
|
# It is triggered when called by the release workflow.
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_call:
|
||||||
|
inputs:
|
||||||
|
tag_name:
|
||||||
|
description: 'Tag name to create/move (e.g., v1.0.0 or v1.0.0-rc1)'
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
version:
|
||||||
|
description: 'Version number for commit message'
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
file_path:
|
||||||
|
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 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:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
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@v6
|
||||||
|
with:
|
||||||
|
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@v6
|
||||||
|
with:
|
||||||
|
name: updated-version-info-for-commit
|
||||||
|
path: downloaded-file/
|
||||||
|
|
||||||
|
- name: Replace file with updated version
|
||||||
|
run: |
|
||||||
|
FILE_PATH="${{ inputs.file_path }}"
|
||||||
|
FILE_NAME=$(basename "$FILE_PATH")
|
||||||
|
TARGET_DIR=$(dirname "$FILE_PATH")
|
||||||
|
|
||||||
|
mkdir -p "$TARGET_DIR"
|
||||||
|
cp "downloaded-file/$FILE_NAME" "$FILE_PATH"
|
||||||
|
|
||||||
|
echo "✓ File replaced: $FILE_PATH"
|
||||||
|
echo ""
|
||||||
|
echo "========================================"
|
||||||
|
echo "Updated file content"
|
||||||
|
echo "========================================"
|
||||||
|
cat "$FILE_PATH"
|
||||||
|
echo "========================================"
|
||||||
|
|
||||||
|
- name: Commit changes
|
||||||
|
id: commit_changes
|
||||||
|
run: |
|
||||||
|
git config --local user.email "github-actions[bot]@users.noreply.github.com"
|
||||||
|
git config --local user.name "github-actions[bot]"
|
||||||
|
|
||||||
|
FILE_PATH="${{ inputs.file_path }}"
|
||||||
|
VERSION="${{ inputs.version }}"
|
||||||
|
|
||||||
|
if [ ! -f "$FILE_PATH" ]; then
|
||||||
|
echo "✗ Error: File $FILE_PATH not found"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
git add "$FILE_PATH"
|
||||||
|
git commit -m "chore(release): v${VERSION} [auto release commit]"
|
||||||
|
echo "✓ Changes committed"
|
||||||
|
|
||||||
|
- name: Push to release branch
|
||||||
|
id: push_release
|
||||||
|
run: |
|
||||||
|
# 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: ${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 }}"
|
||||||
|
|
||||||
|
echo "Moving tag ${TAG_NAME} to the new commit..."
|
||||||
|
git tag -f ${TAG_NAME}
|
||||||
|
git push origin ${TAG_NAME} --force
|
||||||
|
echo "✓ Tag ${TAG_NAME} moved to commit $(git rev-parse --short HEAD)"
|
||||||
|
|
||||||
|
- name: Output commit info
|
||||||
|
id: commit_info
|
||||||
|
run: |
|
||||||
|
COMMIT_SHA=$(git rev-parse --short HEAD)
|
||||||
|
echo "commit_sha=$COMMIT_SHA" >> $GITHUB_OUTPUT
|
||||||
|
echo "✓ New commit SHA: $COMMIT_SHA"
|
||||||
|
echo "## Commit Release Summary" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "========================================" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "- Version: ${{ inputs.version }}" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "- Tag: ${{ inputs.tag_name }}" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "- Commit SHA: $COMMIT_SHA" >> $GITHUB_STEP_SUMMARY
|
||||||
@@ -0,0 +1,338 @@
|
|||||||
|
name: Release
|
||||||
|
|
||||||
|
# This workflow automates the complete release process for AutoLibrary application
|
||||||
|
# It is triggered when a new release branch is created (release/vX.Y.Z or release/vX.Y.Z-rc*)
|
||||||
|
#
|
||||||
|
# Workflow Steps:
|
||||||
|
# START >
|
||||||
|
|
||||||
|
# 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'
|
||||||
|
|
||||||
|
# 3. Commit Release:
|
||||||
|
# Commits version changes to release branch and creates the release tag.
|
||||||
|
|
||||||
|
# 4. Build:
|
||||||
|
# Compiles the application for Windows and macOS platforms using PyInstaller, and
|
||||||
|
# archives the built artifacts.
|
||||||
|
# - Windows: AutoLibrary.<tag_name>-windows-x86_64.zip
|
||||||
|
# - macOS: AutoLibrary.<tag_name>-macos-arm64.dmg
|
||||||
|
|
||||||
|
# 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:
|
||||||
|
branches:
|
||||||
|
- 'release/v*'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
#
|
||||||
|
# Start :
|
||||||
|
# virtual job that indicates the start of the release process
|
||||||
|
#
|
||||||
|
|
||||||
|
start:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- 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 :
|
||||||
|
# this job updates the version in the file 'ALVersionInfo.py'
|
||||||
|
#
|
||||||
|
|
||||||
|
update-version:
|
||||||
|
needs:
|
||||||
|
- extract-version
|
||||||
|
uses: ./.github/workflows/update-version.yml
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
with:
|
||||||
|
tag_name: ${{ needs.extract-version.outputs.tag_name }}
|
||||||
|
ref: ${{ github.ref }}
|
||||||
|
|
||||||
|
#
|
||||||
|
# Commit release :
|
||||||
|
# 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.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 :
|
||||||
|
# this job builds the application artifacts and archives them
|
||||||
|
|
||||||
|
build:
|
||||||
|
needs:
|
||||||
|
- update-version
|
||||||
|
- commit-release
|
||||||
|
if: always() && needs.update-version.result == 'success' && (needs.commit-release.result == 'success' || needs.commit-release.result == 'skipped')
|
||||||
|
uses: ./.github/workflows/build.yml
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
with:
|
||||||
|
tag_name: ${{ needs.update-version.outputs.tag_name }}
|
||||||
|
version: ${{ needs.update-version.outputs.version }}
|
||||||
|
is_test: 'false'
|
||||||
|
|
||||||
|
#
|
||||||
|
# Release :
|
||||||
|
# this job creates a GitHub release and uploads the archive files
|
||||||
|
|
||||||
|
release:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs:
|
||||||
|
- build
|
||||||
|
- extract-version
|
||||||
|
if: always() && needs.build.result == 'success'
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Download Windows artifacts
|
||||||
|
uses: actions/download-artifact@v6
|
||||||
|
with:
|
||||||
|
name: AutoLibrary.${{ needs.extract-version.outputs.tag_name }}-windows-x86_64
|
||||||
|
path: artifacts/
|
||||||
|
|
||||||
|
- name: Download macOS artifacts
|
||||||
|
uses: actions/download-artifact@v6
|
||||||
|
with:
|
||||||
|
name: AutoLibrary.${{ needs.extract-version.outputs.tag_name }}-macos-arm64
|
||||||
|
path: artifacts/
|
||||||
|
|
||||||
|
- name: Create release
|
||||||
|
id: create_release
|
||||||
|
uses: softprops/action-gh-release@v2
|
||||||
|
with:
|
||||||
|
tag_name: ${{ needs.extract-version.outputs.tag_name }}
|
||||||
|
name: AutoLibrary ${{ needs.extract-version.outputs.tag_name }}
|
||||||
|
files: |
|
||||||
|
artifacts/AutoLibrary.${{ needs.extract-version.outputs.tag_name }}-windows-x86_64.zip
|
||||||
|
artifacts/AutoLibrary.${{ needs.extract-version.outputs.tag_name }}-macos-arm64.dmg
|
||||||
|
draft: false
|
||||||
|
prerelease: ${{ needs.extract-version.outputs.is_rc == 'true' }}
|
||||||
|
generate_release_notes: true
|
||||||
|
body: |
|
||||||
|
---
|
||||||
|
**完整更新日志见下方自动生成的 Release Notes**
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
# End :
|
||||||
|
# virtual job that indicates the end of the release process
|
||||||
|
#
|
||||||
|
|
||||||
|
end:
|
||||||
|
needs:
|
||||||
|
- release
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: End release
|
||||||
|
run: |
|
||||||
|
echo "✓ Ending release"
|
||||||
|
|
||||||
|
#
|
||||||
|
# Merge Back :
|
||||||
|
# this job merges the release branch to main after successful release
|
||||||
|
#
|
||||||
|
|
||||||
|
merge-back:
|
||||||
|
needs:
|
||||||
|
- release
|
||||||
|
- extract-version
|
||||||
|
- commit-release
|
||||||
|
if: ${{ needs.release.result == 'success' && (needs.commit-release.result == 'success' || needs.commit-release.result == 'skipped') }}
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Merge release branch to main
|
||||||
|
run: |
|
||||||
|
git config user.name "github-actions[bot]"
|
||||||
|
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||||
|
|
||||||
|
# Use the release branch name from the original trigger
|
||||||
|
BRANCH_NAME="${{ needs.extract-version.outputs.tag_name }}"
|
||||||
|
# Extract branch name: v1.0.0 -> release/v1.0.0
|
||||||
|
if [[ ! "$BRANCH_NAME" =~ ^release/ ]]; then
|
||||||
|
BRANCH_NAME="release/${BRANCH_NAME}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
MAIN_BRANCH=$(git remote show origin | grep 'HEAD branch' | cut -d' ' -f5)
|
||||||
|
|
||||||
|
if [ -z "$MAIN_BRANCH" ]; then
|
||||||
|
MAIN_BRANCH="main"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Merging ${BRANCH_NAME} to ${MAIN_BRANCH}..."
|
||||||
|
echo "Current commit info:"
|
||||||
|
git log --oneline -3
|
||||||
|
|
||||||
|
# Fetch all branches including the release branch
|
||||||
|
git fetch origin ${BRANCH_NAME}
|
||||||
|
git fetch origin ${MAIN_BRANCH}
|
||||||
|
|
||||||
|
# Checkout main branch
|
||||||
|
git checkout ${MAIN_BRANCH}
|
||||||
|
|
||||||
|
# Show branch status before merge
|
||||||
|
echo "========================================"
|
||||||
|
echo "Branch status before merge"
|
||||||
|
echo "========================================"
|
||||||
|
git log --oneline --graph --all -5
|
||||||
|
echo "========================================"
|
||||||
|
echo "Diff: ${MAIN_BRANCH} vs origin/${BRANCH_NAME}"
|
||||||
|
echo "========================================"
|
||||||
|
git diff ${MAIN_BRANCH} origin/${BRANCH_NAME} --stat || echo "No differences found"
|
||||||
|
|
||||||
|
# Force create a merge commit even if there are no changes
|
||||||
|
# This ensures the release history is properly recorded
|
||||||
|
git merge origin/${BRANCH_NAME} \
|
||||||
|
--no-ff \
|
||||||
|
-m "chore(release): merge ${BRANCH_NAME} to ${MAIN_BRANCH} [auto release commit]"
|
||||||
|
|
||||||
|
# Show merge result
|
||||||
|
echo "========================================"
|
||||||
|
echo "Merge result"
|
||||||
|
echo "========================================"
|
||||||
|
git log --oneline --graph -3
|
||||||
|
|
||||||
|
# Push to main
|
||||||
|
git push origin ${MAIN_BRANCH}
|
||||||
|
|
||||||
|
echo "✓ Successfully merged ${BRANCH_NAME} to ${MAIN_BRANCH}"
|
||||||
|
|
||||||
|
- name: Delete release branch
|
||||||
|
run: |
|
||||||
|
BRANCH_NAME="${{ needs.extract-version.outputs.tag_name }}"
|
||||||
|
# Extract branch name: v1.0.0 -> release/v1.0.0
|
||||||
|
if [[ ! "$BRANCH_NAME" =~ ^release/ ]]; then
|
||||||
|
BRANCH_NAME="release/${BRANCH_NAME}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Deleting release branch: ${BRANCH_NAME}"
|
||||||
|
git push origin --delete ${BRANCH_NAME}
|
||||||
|
echo "✓ Deleted branch ${BRANCH_NAME}"
|
||||||
|
|
||||||
|
- name: Release cleanup summary
|
||||||
|
run: |
|
||||||
|
BRANCH_NAME="${{ github.ref_name }}"
|
||||||
|
TAG_NAME="${{ needs.extract-version.outputs.tag_name }}"
|
||||||
|
MAIN_BRANCH=$(git remote show origin | grep 'HEAD branch' | cut -d' ' -f5)
|
||||||
|
if [ -z "$MAIN_BRANCH" ]; then
|
||||||
|
MAIN_BRANCH="main"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "## Release Cleanup Summary" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "========================================" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "✓ Release completed successfully!" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "### Actions Performed:" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "- Merged \`${BRANCH_NAME}\` to \`${MAIN_BRANCH}\`" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "- Deleted release branch \`${BRANCH_NAME}\`" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "### Release Details:" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "- Tag: \`${TAG_NAME}\`" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "- Version: \`${{ needs.extract-version.outputs.version }}\`" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "- Release Type: $([ "${{ needs.extract-version.outputs.is_rc }}" = "true" ] && echo "Release Candidate" || echo "Stable Release")" >> $GITHUB_STEP_SUMMARY
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
name: Update Version
|
||||||
|
|
||||||
|
# This workflow updates version information in 'ALVersionInfo.py' with build metadata.
|
||||||
|
# In progress, it will generate two version files, the first one is locate in 'src/gui/ALVersionInfo.py',
|
||||||
|
# and the second one is locate in 'src/gui/temp/ALVersionInfo.py'. The first one is use
|
||||||
|
# in the release process, it only update the version and tag name. The commit and build infomation
|
||||||
|
# is 'local' or 'null'. All of them will finally archive as artifacts.
|
||||||
|
#
|
||||||
|
# It is triggered when called by the release workflow.
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_call:
|
||||||
|
inputs:
|
||||||
|
tag_name:
|
||||||
|
description: 'Tag name'
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
ref:
|
||||||
|
description: 'Git ref to checkout'
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
outputs:
|
||||||
|
tag_name:
|
||||||
|
description: 'The tag name'
|
||||||
|
value: ${{ jobs.update-version.outputs.tag_name }}
|
||||||
|
version:
|
||||||
|
description: 'The version number'
|
||||||
|
value: ${{ jobs.update-version.outputs.version }}
|
||||||
|
has_changes:
|
||||||
|
description: 'Whether ALVersionInfo.py was modified'
|
||||||
|
value: ${{ jobs.update-version.outputs.has_changes }}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
update-version:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
outputs:
|
||||||
|
tag_name: ${{ steps.get_version.outputs.TAG_NAME }}
|
||||||
|
version: ${{ steps.get_version.outputs.VERSION }}
|
||||||
|
has_changes: ${{ steps.check_changes.outputs.has_changes }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
ref: ${{ inputs.ref }}
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Get tag name and version
|
||||||
|
id: get_version
|
||||||
|
env:
|
||||||
|
TZ: UTC
|
||||||
|
run: |
|
||||||
|
TAG_NAME="${{ inputs.tag_name }}"
|
||||||
|
VERSION="${TAG_NAME#v}"
|
||||||
|
COMMIT_SHA="${GITHUB_SHA:0:7}"
|
||||||
|
COMMIT_DATE=$(TZ=UTC git log -1 --format=%cd --date=format-local:'%Y-%m-%d %H:%M:%S UTC')
|
||||||
|
|
||||||
|
echo "TAG_NAME=$TAG_NAME" >> $GITHUB_OUTPUT
|
||||||
|
echo "VERSION=$VERSION" >> $GITHUB_OUTPUT
|
||||||
|
echo "COMMIT_SHA=$COMMIT_SHA" >> $GITHUB_OUTPUT
|
||||||
|
echo "COMMIT_DATE=$COMMIT_DATE" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
echo "✓ Tag: $TAG_NAME"
|
||||||
|
echo "✓ Version: $VERSION"
|
||||||
|
echo "✓ Commit SHA: $COMMIT_SHA"
|
||||||
|
echo "✓ Commit Date: $COMMIT_DATE"
|
||||||
|
|
||||||
|
- name: Create 'temp' directory
|
||||||
|
run: |
|
||||||
|
echo "Creating temp directory..."
|
||||||
|
mkdir -p "src/gui/temp"
|
||||||
|
echo "✓ temp directory created successfully"
|
||||||
|
|
||||||
|
- name: Update ALVersionInfo.py with version info
|
||||||
|
run: |
|
||||||
|
VERSION="${{ steps.get_version.outputs.VERSION }}"
|
||||||
|
TAG_NAME="${{ steps.get_version.outputs.TAG_NAME }}"
|
||||||
|
COMMIT_SHA="${{ steps.get_version.outputs.COMMIT_SHA }}"
|
||||||
|
COMMIT_DATE="${{ steps.get_version.outputs.COMMIT_DATE }}"
|
||||||
|
VER_INFO_BUILDFILE="src/gui/temp/ALVersionInfo.py"
|
||||||
|
VER_INFO_COMMITFILE="src/gui/ALVersionInfo.py"
|
||||||
|
BUILD_DATE=$(date -u '+%Y-%m-%d %H:%M:%S UTC')
|
||||||
|
|
||||||
|
echo "Updating ALVersionInfo.py files with build information..."
|
||||||
|
{
|
||||||
|
echo '# -*- coding: utf-8 -*-'
|
||||||
|
echo ''
|
||||||
|
echo '"""'
|
||||||
|
echo ' The contents of this file will automatically be updated by the'
|
||||||
|
echo ' workflow process. Do not edit manually.'
|
||||||
|
echo ''
|
||||||
|
echo ' This file is auto-generated during the workflow process.'
|
||||||
|
echo " Last updated: ${BUILD_DATE}"
|
||||||
|
echo '"""'
|
||||||
|
echo ''
|
||||||
|
echo "AL_VERSION = \"${VERSION}\""
|
||||||
|
echo "AL_TAG = \"${TAG_NAME}\""
|
||||||
|
echo "AL_COMMIT_SHA = \"${COMMIT_SHA}\""
|
||||||
|
echo "AL_COMMIT_DATE = \"${COMMIT_DATE}\" # time zone : UTC"
|
||||||
|
echo "AL_BUILD_DATE = \"${BUILD_DATE}\" # time zone : UTC"
|
||||||
|
echo 'AL_VERSION_FULL = f"{AL_VERSION} ({AL_COMMIT_SHA})"'
|
||||||
|
} > "$VER_INFO_BUILDFILE"
|
||||||
|
|
||||||
|
echo "Updating ALVersionInfo.py for release commit..."
|
||||||
|
{
|
||||||
|
echo '# -*- coding: utf-8 -*-'
|
||||||
|
echo ''
|
||||||
|
echo '"""'
|
||||||
|
echo ' The contents of this file will automatically be updated by the'
|
||||||
|
echo ' workflow process. Do not edit manually.'
|
||||||
|
echo ''
|
||||||
|
echo ' This file is auto-generated during the workflow process.'
|
||||||
|
echo " Last updated: ${BUILD_DATE}"
|
||||||
|
echo '"""'
|
||||||
|
echo ''
|
||||||
|
echo "AL_VERSION = \"${VERSION}\""
|
||||||
|
echo "AL_TAG = \"${TAG_NAME}\""
|
||||||
|
echo "AL_COMMIT_SHA = \"local\""
|
||||||
|
echo "AL_COMMIT_DATE = \"null\" # time zone : UTC"
|
||||||
|
echo "AL_BUILD_DATE = \"null\" # time zone : UTC"
|
||||||
|
echo 'AL_VERSION_FULL = f"{AL_VERSION} ({AL_COMMIT_SHA})"'
|
||||||
|
} > "$VER_INFO_COMMITFILE"
|
||||||
|
|
||||||
|
echo "✓ ALVersionInfo.py files updated successfully"
|
||||||
|
echo ""
|
||||||
|
echo "Build version file location: $VER_INFO_BUILDFILE"
|
||||||
|
echo "Commit version file location: $VER_INFO_COMMITFILE"
|
||||||
|
echo ""
|
||||||
|
echo "========================================"
|
||||||
|
echo "Build version ALVersionInfo.py"
|
||||||
|
echo "========================================"
|
||||||
|
cat "$VER_INFO_BUILDFILE"
|
||||||
|
echo "========================================"
|
||||||
|
echo ""
|
||||||
|
echo "========================================"
|
||||||
|
echo "Commit version ALVersionInfo.py"
|
||||||
|
echo "========================================"
|
||||||
|
cat "$VER_INFO_COMMITFILE"
|
||||||
|
echo "========================================"
|
||||||
|
|
||||||
|
- name: Check if ALVersionInfo.py was modified
|
||||||
|
id: check_changes
|
||||||
|
run: |
|
||||||
|
if git diff --quiet src/gui/ALVersionInfo.py; then
|
||||||
|
echo "has_changes=false" >> $GITHUB_OUTPUT
|
||||||
|
echo "⚠ No changes detected in ALVersionInfo.py"
|
||||||
|
else
|
||||||
|
echo "has_changes=true" >> $GITHUB_OUTPUT
|
||||||
|
echo "✓ ALVersionInfo.py has been modified"
|
||||||
|
fi
|
||||||
|
echo "## Update Version Summary" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "========================================" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "- Version: ${{ steps.get_version.outputs.VERSION }}" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "- Tag: ${{ steps.get_version.outputs.TAG_NAME }}" >> $GITHUB_STEP_SUMMARY
|
||||||
|
|
||||||
|
- name: Upload modified ALVersionInfo.py ready for build
|
||||||
|
if: steps.check_changes.outputs.has_changes == 'true'
|
||||||
|
uses: actions/upload-artifact@v6
|
||||||
|
with:
|
||||||
|
name: updated-version-info-for-build
|
||||||
|
path: src/gui/temp/ALVersionInfo.py
|
||||||
|
retention-days: 1
|
||||||
|
|
||||||
|
- name: Upload modified ALVersionInfo.py ready for commit
|
||||||
|
if: steps.check_changes.outputs.has_changes == 'true'
|
||||||
|
uses: actions/upload-artifact@v6
|
||||||
|
with:
|
||||||
|
name: updated-version-info-for-commit
|
||||||
|
path: src/gui/ALVersionInfo.py
|
||||||
|
retention-days: 1
|
||||||
@@ -6,12 +6,16 @@
|
|||||||
__pycache__/
|
__pycache__/
|
||||||
build/
|
build/
|
||||||
dist/
|
dist/
|
||||||
model/*.onnx
|
|
||||||
driver/*.exe
|
models/*.*
|
||||||
gui/configs/*.json
|
drivers/*.*
|
||||||
gui/translators/qtbase_zh_CN.qm
|
!models/*.md
|
||||||
gui/AutoLibraryResources.py
|
!drivers/*.md
|
||||||
gui/AutoLibraryResource.py
|
!templates/*.md
|
||||||
gui/Ui_ALMainWindow.py
|
!templates/configs/*.md
|
||||||
gui/Ui_ALConfigWidget.py
|
|
||||||
|
src/gui/resources/ui/Ui_*.py
|
||||||
|
src/gui/resources/translators/qtbase_zh_CN.qm
|
||||||
|
src/gui/resources/ALResource.py
|
||||||
|
|
||||||
Main.spec
|
Main.spec
|
||||||
@@ -1,283 +0,0 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
"""
|
|
||||||
Copyright (c) 2025 KenanZhu.
|
|
||||||
All rights reserved.
|
|
||||||
|
|
||||||
This software is provided "as is", without any warranty of any kind.
|
|
||||||
You may use, modify, and distribute this file under the terms of the MIT License.
|
|
||||||
See the LICENSE file for details.
|
|
||||||
"""
|
|
||||||
import os
|
|
||||||
import queue
|
|
||||||
|
|
||||||
from selenium import webdriver
|
|
||||||
from selenium.webdriver.common.by import By
|
|
||||||
from selenium.webdriver.support.ui import WebDriverWait
|
|
||||||
from selenium.webdriver.support import expected_conditions as EC
|
|
||||||
from selenium.webdriver.edge.service import Service
|
|
||||||
|
|
||||||
from MsgBase import MsgBase
|
|
||||||
from LibChecker import LibChecker
|
|
||||||
from LibLogin import LibLogin
|
|
||||||
from LibLogout import LibLogout
|
|
||||||
from LibReserve import LibReserve
|
|
||||||
from LibCheckin import LibCheckin
|
|
||||||
|
|
||||||
from ConfigReader import ConfigReader
|
|
||||||
|
|
||||||
|
|
||||||
class AutoLib(MsgBase):
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
input_queue: queue.Queue,
|
|
||||||
output_queue: queue.Queue
|
|
||||||
):
|
|
||||||
super().__init__(input_queue, output_queue)
|
|
||||||
|
|
||||||
self.__system_config_reader = None
|
|
||||||
self.__users_config_reader = None
|
|
||||||
self.__driver = None
|
|
||||||
|
|
||||||
|
|
||||||
def __initBrowserDriver(
|
|
||||||
self
|
|
||||||
) -> bool:
|
|
||||||
|
|
||||||
self._showTrace("正在初始化浏览器驱动......")
|
|
||||||
edge_options = webdriver.EdgeOptions()
|
|
||||||
|
|
||||||
if self.__system_config_reader.get("web_driver/headless"):
|
|
||||||
edge_options.add_argument("--headless")
|
|
||||||
edge_options.add_argument("--disable-gpu")
|
|
||||||
edge_options.add_argument("--no-sandbox")
|
|
||||||
edge_options.add_argument("--disable-dev-shm-usage")
|
|
||||||
|
|
||||||
# must be 1920x1080, otherwise the page will cause some elements not accessible
|
|
||||||
edge_options.add_argument("--window-size=1920,1080")
|
|
||||||
edge_options.add_argument("--remote-allow-origins=*")
|
|
||||||
|
|
||||||
# omit ssl errors and verbose log level
|
|
||||||
edge_options.add_argument("--ignore-certificate-errors")
|
|
||||||
edge_options.add_argument("--ignore-ssl-errors")
|
|
||||||
edge_options.add_argument("--log-level=OFF")
|
|
||||||
edge_options.add_argument("--silent")
|
|
||||||
|
|
||||||
edge_options.add_experimental_option("excludeSwitches", ["enable-automation"])
|
|
||||||
edge_options.add_experimental_option("useAutomationExtension", False)
|
|
||||||
edge_options.add_argument("--disable-blink-features=AutomationControlled")
|
|
||||||
edge_options.add_argument(
|
|
||||||
"--user-agent=Mozilla/5.0 (Windows NT 10.0; Win64; x64) "\
|
|
||||||
"AppleWebKit/537.36 (KHTML, like Gecko) "\
|
|
||||||
"Chrome/120.0.0.0 "\
|
|
||||||
"Safari/537.36 "\
|
|
||||||
"Edg/120.0.0.0"
|
|
||||||
)
|
|
||||||
|
|
||||||
# init browser driver
|
|
||||||
self.__driver_path = self.__system_config_reader.get("web_driver/driver_path")
|
|
||||||
self.__driver_type = self.__system_config_reader.get("web_driver/driver_type")
|
|
||||||
self.__driver_path = os.path.abspath(self.__driver_path)
|
|
||||||
try:
|
|
||||||
service = None
|
|
||||||
if self.__driver_path:
|
|
||||||
service = Service(executable_path=self.__driver_path)
|
|
||||||
match self.__driver_type.lower():
|
|
||||||
case "edge":
|
|
||||||
self.__driver = webdriver.Edge(service=service, options=edge_options)
|
|
||||||
case "chrome":
|
|
||||||
self.__driver = webdriver.Chrome(service=service, options=edge_options)
|
|
||||||
case "firefox":
|
|
||||||
self.__driver = webdriver.Firefox(service=service, options=edge_options)
|
|
||||||
case _:
|
|
||||||
raise Exception(f"不支持的浏览器驱动类型: {self.__driver_type}")
|
|
||||||
self.__driver.implicitly_wait(1)
|
|
||||||
self.__driver.execute_script(
|
|
||||||
"Object.defineProperty(navigator, 'webdriver', {get: () => undefined})"
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
self._showTrace(f"浏览器驱动初始化失败: {e}")
|
|
||||||
return False
|
|
||||||
self._showTrace(f"浏览器驱动已初始化, 类型: {self.__driver_type}, 路径: {self.__driver_path}")
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def __initLibOperators(
|
|
||||||
self
|
|
||||||
):
|
|
||||||
|
|
||||||
if not self.__driver:
|
|
||||||
self._showTrace(f"浏览器驱动未初始化, 请先初始化浏览器驱动 !")
|
|
||||||
return
|
|
||||||
self.__lib_checker = LibChecker(self._input_queue, self._output_queue, self.__driver)
|
|
||||||
self.__lib_login = LibLogin(self._input_queue, self._output_queue, self.__driver)
|
|
||||||
self.__lib_logout = LibLogout(self._input_queue, self._output_queue, self.__driver)
|
|
||||||
self.__lib_reserve = LibReserve(self._input_queue, self._output_queue, self.__driver)
|
|
||||||
self.__lib_checkin = LibCheckin(self._input_queue, self._output_queue, self.__driver)
|
|
||||||
|
|
||||||
|
|
||||||
def __waitResponseLoad(
|
|
||||||
self
|
|
||||||
) -> bool:
|
|
||||||
|
|
||||||
# wait for page load
|
|
||||||
try:
|
|
||||||
WebDriverWait(self.__driver, 2).until( # title contains "首页"
|
|
||||||
EC.title_contains("首页")
|
|
||||||
)
|
|
||||||
WebDriverWait(self.__driver, 2).until( # username field presence
|
|
||||||
EC.presence_of_element_located((By.NAME, "username"))
|
|
||||||
)
|
|
||||||
WebDriverWait(self.__driver, 2).until( # password field presence
|
|
||||||
EC.presence_of_element_located((By.NAME, "password"))
|
|
||||||
)
|
|
||||||
WebDriverWait(self.__driver, 2).until( # captcha field presence
|
|
||||||
EC.presence_of_element_located((By.NAME, "answer"))
|
|
||||||
)
|
|
||||||
WebDriverWait(self.__driver, 2).until( # captcha image presence
|
|
||||||
EC.presence_of_element_located((By.ID, "loadImgId"))
|
|
||||||
)
|
|
||||||
return True
|
|
||||||
except:
|
|
||||||
self._showTrace(f"登录页面加载失败 !")
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def __initDriverUrl(
|
|
||||||
self,
|
|
||||||
) -> bool:
|
|
||||||
|
|
||||||
url = self.__system_config_reader.get("library/host_url")
|
|
||||||
url += self.__system_config_reader.get("library/login_url")
|
|
||||||
self.__driver.get(url)
|
|
||||||
if not self.__waitResponseLoad():
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def __run(
|
|
||||||
self,
|
|
||||||
username: str,
|
|
||||||
password: str,
|
|
||||||
reserve_info: dict
|
|
||||||
) -> int:
|
|
||||||
|
|
||||||
# result : 0 - success, 1 - failed, 2 - passed
|
|
||||||
result = 2
|
|
||||||
|
|
||||||
# login
|
|
||||||
if not self.__lib_login.login(
|
|
||||||
username,
|
|
||||||
password,
|
|
||||||
self.__system_config_reader.get("login/max_attempt", 5),
|
|
||||||
self.__system_config_reader.get("login/auto_captcha", True),
|
|
||||||
):
|
|
||||||
return 1
|
|
||||||
"""
|
|
||||||
Here, we collect the run mode from the config file.
|
|
||||||
"""
|
|
||||||
run_mode = self.__system_config_reader.get("mode/run_mode", 0)
|
|
||||||
run_mode = {
|
|
||||||
"auto_reserve": run_mode&0x1,
|
|
||||||
"auto_checkin": run_mode&0x2,
|
|
||||||
"auto_renewal": run_mode&0x4,
|
|
||||||
}
|
|
||||||
# reserve
|
|
||||||
if run_mode["auto_reserve"]:
|
|
||||||
if self.__lib_checker.canReserve(reserve_info.get("date")):
|
|
||||||
if self.__lib_reserve.reserve(reserve_info):
|
|
||||||
self._showTrace(f"用户 {username} 预约成功 !")
|
|
||||||
result = 0
|
|
||||||
else:
|
|
||||||
self._showTrace(f"用户 {username} 预约失败 !")
|
|
||||||
result = 1
|
|
||||||
else:
|
|
||||||
self._showTrace(f"用户 {username} 无法预约,已跳过")
|
|
||||||
result = 2
|
|
||||||
# checkin
|
|
||||||
if run_mode["auto_checkin"] and result == 2:
|
|
||||||
if self.__lib_checker.canCheckin(reserve_info.get("date")):
|
|
||||||
if self.__lib_checkin.checkin(username):
|
|
||||||
self._showTrace(f"用户 {username} 签到成功 !")
|
|
||||||
result = 0
|
|
||||||
else:
|
|
||||||
self._showTrace(f"用户 {username} 签到失败 !")
|
|
||||||
result = 1
|
|
||||||
else:
|
|
||||||
self._showTrace(f"用户 {username} 无法签到,已跳过")
|
|
||||||
result = 2
|
|
||||||
# renewal
|
|
||||||
if run_mode["auto_renewal"] and result == 2:
|
|
||||||
if self.__lib_checker.canRenew(reserve_info.get("date")):
|
|
||||||
pass
|
|
||||||
else:
|
|
||||||
self._showTrace(f"用户 {username} 无法续约,已跳过")
|
|
||||||
result = 2
|
|
||||||
# logout
|
|
||||||
if not self.__lib_logout.logout(
|
|
||||||
username,
|
|
||||||
):
|
|
||||||
# if logout is failed, we must make sure the host to be reloaded
|
|
||||||
# otherwise, the next login may fail
|
|
||||||
self.__driver.get(self.__system_config_reader.get("library/host_url"))
|
|
||||||
return 1
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def run(
|
|
||||||
self,
|
|
||||||
system_config_reader: ConfigReader,
|
|
||||||
users_config_reader: ConfigReader
|
|
||||||
):
|
|
||||||
|
|
||||||
self.__system_config_reader = system_config_reader
|
|
||||||
self.__users_config_reader = users_config_reader
|
|
||||||
if not self.__initBrowserDriver():
|
|
||||||
return
|
|
||||||
else:
|
|
||||||
if not self.__initDriverUrl():
|
|
||||||
return
|
|
||||||
self.__initLibOperators()
|
|
||||||
|
|
||||||
user_counter = {"current": 0, "success": 0, "failed": 0, "passed": 0}
|
|
||||||
users = self.__users_config_reader.get("users")
|
|
||||||
self._showTrace(
|
|
||||||
f"共发现 {len(users)} 个用户, "\
|
|
||||||
f"用户配置文件路径: {self.__users_config_reader.configPath()}"
|
|
||||||
)
|
|
||||||
for user in users:
|
|
||||||
user_counter["current"] += 1
|
|
||||||
self._showTrace(
|
|
||||||
f"正在处理第 {user_counter["current"]}/{len(users)} 个用户: {user['username']}......"
|
|
||||||
)
|
|
||||||
r = self.__run(
|
|
||||||
username=user["username"],
|
|
||||||
password=user["password"],
|
|
||||||
reserve_info=user["reserve_info"],
|
|
||||||
)
|
|
||||||
if r == 0:
|
|
||||||
user_counter["success"] += 1
|
|
||||||
elif r == 1:
|
|
||||||
user_counter["failed"] += 1
|
|
||||||
elif r == 2:
|
|
||||||
user_counter["passed"] += 1
|
|
||||||
self._showTrace(f"处理完成, 共计 {user_counter["current"]} 个用户, "\
|
|
||||||
f"成功 {user_counter["success"]} 个用户, "\
|
|
||||||
f"失败 {user_counter["failed"]} 个用户, "\
|
|
||||||
f"跳过 {user_counter["passed"]} 个用户"
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
|
|
||||||
def close(
|
|
||||||
self
|
|
||||||
) -> bool:
|
|
||||||
|
|
||||||
if self.__driver:
|
|
||||||
self.__driver.quit()
|
|
||||||
self.__driver = None
|
|
||||||
self._showTrace(f"浏览器驱动已关闭")
|
|
||||||
return True
|
|
||||||
else:
|
|
||||||
self._showTrace(f"浏览器驱动未初始化, 无需关闭")
|
|
||||||
return False
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
"""
|
|
||||||
Copyright (c) 2025 KenanZhu.
|
|
||||||
All rights reserved.
|
|
||||||
|
|
||||||
This software is provided "as is", without any warranty of any kind.
|
|
||||||
You may use, modify, and distribute this file under the terms of the MIT License.
|
|
||||||
See the LICENSE file for details.
|
|
||||||
"""
|
|
||||||
import json
|
|
||||||
|
|
||||||
|
|
||||||
class ConfigReader:
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
config_path: str
|
|
||||||
):
|
|
||||||
|
|
||||||
self._config_path = config_path
|
|
||||||
self._config_data = {}
|
|
||||||
if not self.__readConfig():
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def __readConfig(
|
|
||||||
self
|
|
||||||
) -> bool:
|
|
||||||
|
|
||||||
try:
|
|
||||||
with open(self._config_path, 'r', encoding='utf-8') as file:
|
|
||||||
self._config_data = json.load(file)
|
|
||||||
return True
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Error reading config file: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def getConfigs(
|
|
||||||
self
|
|
||||||
) -> dict:
|
|
||||||
|
|
||||||
return self._config_data.copy()
|
|
||||||
|
|
||||||
|
|
||||||
def getConfig(
|
|
||||||
self,
|
|
||||||
key: str
|
|
||||||
) -> dict:
|
|
||||||
|
|
||||||
return self._config_data.get(key, {})
|
|
||||||
|
|
||||||
|
|
||||||
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 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,87 +0,0 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
"""
|
|
||||||
Copyright (c) 2025 KenanZhu.
|
|
||||||
All rights reserved.
|
|
||||||
|
|
||||||
This software is provided "as is", without any warranty of any kind.
|
|
||||||
You may use, modify, and distribute this file under the terms of the MIT License.
|
|
||||||
See the LICENSE file for details.
|
|
||||||
"""
|
|
||||||
import json
|
|
||||||
|
|
||||||
|
|
||||||
class ConfigWriter:
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
config_path: str,
|
|
||||||
config_data: dict
|
|
||||||
):
|
|
||||||
|
|
||||||
self.__config_path = config_path
|
|
||||||
self.__config_data = config_data if config_data is not None else {}
|
|
||||||
if config_data is None:
|
|
||||||
return None
|
|
||||||
if not self.__writeConfig():
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def __writeConfig(
|
|
||||||
self
|
|
||||||
) -> bool:
|
|
||||||
|
|
||||||
try:
|
|
||||||
with open(self.__config_path, "w") as f:
|
|
||||||
json.dump(self.__config_data, f, indent=4, sort_keys=False)
|
|
||||||
return True
|
|
||||||
except:
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
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: dict
|
|
||||||
) -> 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
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
Copyright 2025 KenanZhu
|
Copyright 2025 - 2026 KenanZhu
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
@@ -1,346 +0,0 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
"""
|
|
||||||
Copyright (c) 2025 KenanZhu.
|
|
||||||
All rights reserved.
|
|
||||||
|
|
||||||
This software is provided "as is", without any warranty of any kind.
|
|
||||||
You may use, modify, and distribute this file under the terms of the MIT License.
|
|
||||||
See the LICENSE file for details.
|
|
||||||
"""
|
|
||||||
import re
|
|
||||||
import time
|
|
||||||
import queue
|
|
||||||
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
from selenium.webdriver.common.by import By
|
|
||||||
from selenium.webdriver.support.ui import WebDriverWait
|
|
||||||
from selenium.webdriver.support import expected_conditions as EC
|
|
||||||
|
|
||||||
from LibOperator import LibOperator
|
|
||||||
|
|
||||||
|
|
||||||
class LibChecker(LibOperator):
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
input_queue: queue.Queue,
|
|
||||||
output_queue: queue.Queue,
|
|
||||||
driver
|
|
||||||
):
|
|
||||||
|
|
||||||
super().__init__(input_queue, output_queue)
|
|
||||||
|
|
||||||
self.__driver = driver
|
|
||||||
|
|
||||||
|
|
||||||
def _waitResponseLoad(
|
|
||||||
self
|
|
||||||
) -> bool:
|
|
||||||
|
|
||||||
pass
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def __formatDiffTime(
|
|
||||||
seconds: float
|
|
||||||
) -> str:
|
|
||||||
|
|
||||||
hours = int(seconds//3600)
|
|
||||||
minutes = int(seconds%3600//60)
|
|
||||||
seconds = int(seconds%60)
|
|
||||||
return f"{hours} 时 {minutes} 分 {seconds} 秒"
|
|
||||||
|
|
||||||
|
|
||||||
def __navigateToReserveRecordPage(
|
|
||||||
self
|
|
||||||
) -> bool:
|
|
||||||
|
|
||||||
try:
|
|
||||||
WebDriverWait(self.__driver, 2).until(
|
|
||||||
EC.element_to_be_clickable((By.XPATH, "//a[@href='/history?type=SEAT']"))
|
|
||||||
).click()
|
|
||||||
WebDriverWait(self.__driver, 2).until(
|
|
||||||
EC.presence_of_element_located((By.CLASS_NAME, "myReserveList"))
|
|
||||||
)
|
|
||||||
except:
|
|
||||||
self._showTrace("加载预约记录页面失败 !")
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def __decodeReserveTime(
|
|
||||||
self,
|
|
||||||
time_element
|
|
||||||
) -> dict:
|
|
||||||
|
|
||||||
time_str = time_element.text.strip()
|
|
||||||
today = datetime.now().date()
|
|
||||||
if "明天" in time_str:
|
|
||||||
target_date = today + timedelta(days=1)
|
|
||||||
date = target_date.strftime("%Y-%m-%d")
|
|
||||||
elif "今天" in time_str:
|
|
||||||
target_date = today
|
|
||||||
date = target_date.strftime("%Y-%m-%d")
|
|
||||||
elif "昨天" in time_str:
|
|
||||||
target_date = today - timedelta(days=1)
|
|
||||||
date = target_date.strftime("%Y-%m-%d")
|
|
||||||
else:
|
|
||||||
date_match = re.search(r"(\d{4}-\d{1,2}-\d{1,2})", time_str)
|
|
||||||
if date_match:
|
|
||||||
date = date_match.group(1)
|
|
||||||
else:
|
|
||||||
date = ""
|
|
||||||
time_match = re.search(r"(\d{1,2}:\d{2}) -- (\d{1,2}:\d{2})", time_str)
|
|
||||||
if time_match:
|
|
||||||
begin_time = time_match.group(1)
|
|
||||||
end_time = time_match.group(2)
|
|
||||||
else:
|
|
||||||
begin_time = ""
|
|
||||||
end_time = ""
|
|
||||||
return {
|
|
||||||
"date": date,
|
|
||||||
"time": {
|
|
||||||
"begin": begin_time,
|
|
||||||
"end": end_time
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def __decodeReserveInfo(
|
|
||||||
self,
|
|
||||||
info_elements
|
|
||||||
) -> str:
|
|
||||||
|
|
||||||
location = ""
|
|
||||||
status = ""
|
|
||||||
for info in info_elements:
|
|
||||||
if "已预约" in info.text:
|
|
||||||
status = "已预约"
|
|
||||||
elif "使用中" in info.text:
|
|
||||||
status = "使用中"
|
|
||||||
elif "已完成" in info.text:
|
|
||||||
status = "已完成"
|
|
||||||
elif "已结束使用" in info.text:
|
|
||||||
status = "已结束使用"
|
|
||||||
elif "已取消" in info.text:
|
|
||||||
status = "已取消"
|
|
||||||
elif "失约" in info.text:
|
|
||||||
status = "失约"
|
|
||||||
elif "图书馆" in info.text:
|
|
||||||
location = info.text.strip()
|
|
||||||
return {
|
|
||||||
"location": location,
|
|
||||||
"status": status,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def __decodeReserveRecord(
|
|
||||||
self,
|
|
||||||
reservation
|
|
||||||
) -> dict:
|
|
||||||
|
|
||||||
try:
|
|
||||||
time_element = reservation.find_element(
|
|
||||||
By.CSS_SELECTOR, "dt"
|
|
||||||
)
|
|
||||||
info_elements = reservation.find_elements(
|
|
||||||
By.CSS_SELECTOR, "a"
|
|
||||||
)
|
|
||||||
except:
|
|
||||||
return {
|
|
||||||
"date": "",
|
|
||||||
"time": {"begin": "", "end": ""},
|
|
||||||
"info": {"location": "", "status": ""}
|
|
||||||
}
|
|
||||||
time = self.__decodeReserveTime(time_element)
|
|
||||||
info = self.__decodeReserveInfo(info_elements)
|
|
||||||
return {
|
|
||||||
"date": time["date"],
|
|
||||||
"time": time["time"],
|
|
||||||
"info": info
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def __decodeReserveRecords(
|
|
||||||
self,
|
|
||||||
reservations
|
|
||||||
) -> list:
|
|
||||||
|
|
||||||
records = []
|
|
||||||
|
|
||||||
for reservation in reservations:
|
|
||||||
record = self.__decodeReserveRecord(reservation)
|
|
||||||
if record["date"] == "":
|
|
||||||
record = None
|
|
||||||
if record["time"] == {"begin": "", "end": ""}:
|
|
||||||
record = None
|
|
||||||
records.append(record)
|
|
||||||
return records
|
|
||||||
|
|
||||||
|
|
||||||
def __loadReserveRecords(
|
|
||||||
self
|
|
||||||
) -> list:
|
|
||||||
try:
|
|
||||||
# check if there's any reservation on the date
|
|
||||||
WebDriverWait(self.__driver, 2).until(
|
|
||||||
EC.presence_of_element_located((By.CSS_SELECTOR, ".myReserveList > dl"))
|
|
||||||
)
|
|
||||||
reservations = self.__driver.find_elements(
|
|
||||||
By.CSS_SELECTOR, ".myReserveList > dl:not(#moreBlock)"
|
|
||||||
)
|
|
||||||
return reservations
|
|
||||||
except:
|
|
||||||
self._showTrace("加载预约记录失败 !")
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def __showMoreReserveRecords(
|
|
||||||
self
|
|
||||||
) -> bool:
|
|
||||||
|
|
||||||
# load new reservations if still not sure
|
|
||||||
try:
|
|
||||||
WebDriverWait(self.__driver, 0.1).until(
|
|
||||||
EC.element_to_be_clickable((By.ID, "moreBtn"))
|
|
||||||
)
|
|
||||||
except:
|
|
||||||
# the reservation is the last one
|
|
||||||
return False
|
|
||||||
try:
|
|
||||||
more_btn = self.__driver.find_element(By.ID, "moreBtn")
|
|
||||||
if more_btn.is_displayed() and more_btn.is_enabled():
|
|
||||||
self.__driver.execute_script("arguments[0].scrollIntoView(true);", more_btn)
|
|
||||||
self.__driver.execute_script("arguments[0].click();", more_btn)
|
|
||||||
return True
|
|
||||||
else:
|
|
||||||
self._showTrace("用户无法加载更多预约记录")
|
|
||||||
return False
|
|
||||||
except:
|
|
||||||
self._showTrace("加载更多预约记录失败 !")
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def __getReserveRecord(
|
|
||||||
self,
|
|
||||||
wanted_date: str,
|
|
||||||
wanted_status: str
|
|
||||||
) -> dict:
|
|
||||||
|
|
||||||
if wanted_date is None:
|
|
||||||
self._showTrace("日期未指定, 无法检查当前预约状态")
|
|
||||||
return None
|
|
||||||
self._showTrace(f"正在检查用户在 {wanted_date} 是否有预约状态为 {wanted_status} 的预约记录......")
|
|
||||||
|
|
||||||
checked_count = 0
|
|
||||||
max_check_times = 6 # we only check (4*(6-1)=)20 reservations, the last time cant be checked
|
|
||||||
|
|
||||||
if not self.__navigateToReserveRecordPage():
|
|
||||||
return None
|
|
||||||
for _ in range(max_check_times):
|
|
||||||
reservations = self.__loadReserveRecords()
|
|
||||||
if reservations is None:
|
|
||||||
return None
|
|
||||||
records = self.__decodeReserveRecords(reservations[checked_count:])
|
|
||||||
for record in records:
|
|
||||||
checked_count += 1
|
|
||||||
if record is None:
|
|
||||||
continue
|
|
||||||
# record date is later than the given date, check the next one
|
|
||||||
if datetime.strptime(record["date"], "%Y-%m-%d").date() >\
|
|
||||||
datetime.strptime(wanted_date, "%Y-%m-%d").date():
|
|
||||||
continue
|
|
||||||
# record date is earlier than the given date, so there is no wanted record
|
|
||||||
if datetime.strptime(record["date"], "%Y-%m-%d").date() <\
|
|
||||||
datetime.strptime(wanted_date, "%Y-%m-%d").date():
|
|
||||||
return None
|
|
||||||
if record["info"]["status"] == wanted_status:
|
|
||||||
self._showTrace(
|
|
||||||
f"寻找到用户第 {checked_count} 条状态为 {wanted_status} 的预约记录, "
|
|
||||||
f"详细信息: {record["date"]} "
|
|
||||||
f"{record["time"]["begin"]} - {record["time"]["end"]} {record["info"]["location"]}"
|
|
||||||
)
|
|
||||||
return record
|
|
||||||
if not self.__showMoreReserveRecords():
|
|
||||||
break
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def canReserve(
|
|
||||||
self,
|
|
||||||
date: str
|
|
||||||
) -> bool:
|
|
||||||
|
|
||||||
# no reserved or using record in the given date
|
|
||||||
# then can reserve
|
|
||||||
if self.__getReserveRecord(date, "已预约") is None:
|
|
||||||
if self.__getReserveRecord(date, "使用中") is None:
|
|
||||||
self._showTrace(f"用户在 {date} 可以预约")
|
|
||||||
return True
|
|
||||||
self._showTrace(f"用户在 {date} 有使用中的预约, 无法预约")
|
|
||||||
return False
|
|
||||||
self._showTrace(f"用户在 {date} 已存在有效预约, 无法预约")
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def canCheckin(
|
|
||||||
self,
|
|
||||||
date: str
|
|
||||||
) -> bool:
|
|
||||||
|
|
||||||
# have a reserved record in the given date
|
|
||||||
record = self.__getReserveRecord(date, "已预约")
|
|
||||||
if record is not None:
|
|
||||||
begin_time = record["time"]["begin"]
|
|
||||||
begin_time = datetime.strptime(f"{date} {begin_time}", "%Y-%m-%d %H:%M")
|
|
||||||
time_diff = datetime.now() - begin_time
|
|
||||||
time_diff_seconds = time_diff.total_seconds()
|
|
||||||
# before 30 minutes, cant checkin
|
|
||||||
if time_diff_seconds < -30*60:
|
|
||||||
self._showTrace(
|
|
||||||
f"用户在 {date} 的预约开始时间为 {begin_time}, "
|
|
||||||
f"当前距离预约开始时间还有 {self.__formatDiffTime(abs(time_diff_seconds))}, 无法签到"
|
|
||||||
)
|
|
||||||
return False
|
|
||||||
# before in 30 minutes, can checkin
|
|
||||||
elif -30*60 <= time_diff_seconds < 0:
|
|
||||||
self._showTrace(
|
|
||||||
f"用户在 {date} 的预约开始时间为 {begin_time}, "
|
|
||||||
f"当前距离预约开始时间还有 {self.__formatDiffTime(abs(time_diff_seconds))}, 可以签到"
|
|
||||||
)
|
|
||||||
return True
|
|
||||||
# past less than 30 minutes, can checkin
|
|
||||||
elif 0 <= time_diff_seconds < 30*60 - 5: # spare 5 seconds for the checkin process
|
|
||||||
self._showTrace(
|
|
||||||
f"用户在 {date} 的预约开始时间为 {begin_time}, "
|
|
||||||
f"当前距离预约开始时间已经过去 {self.__formatDiffTime(abs(time_diff_seconds))}, 可以签到"
|
|
||||||
)
|
|
||||||
return True
|
|
||||||
self._showTrace(f"用户在 {date} 没有有效预约记录, 无法签到")
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def canRenew(
|
|
||||||
self,
|
|
||||||
date: str
|
|
||||||
) -> bool:
|
|
||||||
|
|
||||||
# have a using record in the given date
|
|
||||||
record = self.__getReserveRecord(date, "使用中")
|
|
||||||
if record is not None:
|
|
||||||
end_time = record["time"]["end"]
|
|
||||||
end_time = datetime.strptime(f"{date} {end_time}", "%Y-%m-%d %H:%M")
|
|
||||||
time_diff = end_time - datetime.now()
|
|
||||||
time_diff_seconds = time_diff.total_seconds()
|
|
||||||
# a using record is definitely after the begin time
|
|
||||||
trace_msg = (
|
|
||||||
f"用户在 {date} 的预约结束时间为 {end_time}, "
|
|
||||||
f"当前距离预约结束时间还有 {self.__formatDiffTime(abs(time_diff_seconds))}"
|
|
||||||
)
|
|
||||||
if abs(time_diff_seconds) < 120*60:
|
|
||||||
self._showTrace(f"{trace_msg}, 可以续约")
|
|
||||||
return True
|
|
||||||
else:
|
|
||||||
self._showTrace(f"{trace_msg}, 无法续约")
|
|
||||||
return False
|
|
||||||
self._showTrace(f"用户在 {date} 没有有效预约记录, 无法续约")
|
|
||||||
return False
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
"""
|
|
||||||
Copyright (c) 2025 KenanZhu.
|
|
||||||
All rights reserved.
|
|
||||||
|
|
||||||
This software is provided "as is", without any warranty of any kind.
|
|
||||||
You may use, modify, and distribute this file under the terms of the MIT License.
|
|
||||||
See the LICENSE file for details.
|
|
||||||
"""
|
|
||||||
import re
|
|
||||||
import time
|
|
||||||
import queue
|
|
||||||
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
from selenium.webdriver.common.by import By
|
|
||||||
from selenium.webdriver.support.ui import WebDriverWait
|
|
||||||
from selenium.webdriver.support import expected_conditions as EC
|
|
||||||
|
|
||||||
from LibOperator import LibOperator
|
|
||||||
|
|
||||||
|
|
||||||
class LibCheckin(LibOperator):
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
input_queue: queue.Queue,
|
|
||||||
output_queue: queue.Queue,
|
|
||||||
driver
|
|
||||||
):
|
|
||||||
|
|
||||||
super().__init__(input_queue, output_queue)
|
|
||||||
|
|
||||||
self.__driver = driver
|
|
||||||
|
|
||||||
|
|
||||||
def _waitResponseLoad(
|
|
||||||
self
|
|
||||||
) -> bool:
|
|
||||||
|
|
||||||
try:
|
|
||||||
WebDriverWait(self.__driver, 2).until(
|
|
||||||
EC.presence_of_element_located((By.CLASS_NAME, "ui_dialog"))
|
|
||||||
)
|
|
||||||
WebDriverWait(self.__driver, 2).until(
|
|
||||||
EC.presence_of_element_located((By.CLASS_NAME, "resultMessage"))
|
|
||||||
)
|
|
||||||
WebDriverWait(self.__driver, 2).until(
|
|
||||||
EC.element_to_be_clickable((By.CLASS_NAME, "btnOK"))
|
|
||||||
)
|
|
||||||
result_message_element = self.__driver.find_element(
|
|
||||||
By.CLASS_NAME, "resultMessage"
|
|
||||||
)
|
|
||||||
ok_btn = self.__driver.find_element(By.CLASS_NAME, "btnOK")
|
|
||||||
except:
|
|
||||||
self._showTrace("签到时发生未知错误 !")
|
|
||||||
return False
|
|
||||||
print(result_message_element)
|
|
||||||
result_message = result_message_element.text
|
|
||||||
if "签到成功" in result_message:
|
|
||||||
try:
|
|
||||||
detail_elements = self.__driver.find_elements(
|
|
||||||
By.CSS_SELECTOR, ".resultMessage dd"
|
|
||||||
)
|
|
||||||
except:
|
|
||||||
pass
|
|
||||||
if detail_elements:
|
|
||||||
details = [element.text for element in detail_elements if element.text.strip()]
|
|
||||||
if len(details) >= 5:
|
|
||||||
self._showTrace(f"\n"\
|
|
||||||
f" 签到成功 !\n"\
|
|
||||||
f" {details[1]}\n"\
|
|
||||||
f" {details[2]}\n"\
|
|
||||||
f" {details[3]}\n"\
|
|
||||||
f" {details[4]}")
|
|
||||||
else:
|
|
||||||
self._showTrace(
|
|
||||||
" 签到成功 !\n"\
|
|
||||||
" 未获取到签到详情 !")
|
|
||||||
ok_btn.click()
|
|
||||||
return True
|
|
||||||
else:
|
|
||||||
failure_reason = result_message.replace("签到失败", "").strip()
|
|
||||||
self._showTrace(f"签到失败: {failure_reason}")
|
|
||||||
ok_btn.click()
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def checkin(
|
|
||||||
self,
|
|
||||||
username: str
|
|
||||||
) -> bool:
|
|
||||||
|
|
||||||
if self.__driver is None:
|
|
||||||
self._showTrace("未提供有效 WebDriver 实例 !")
|
|
||||||
return False
|
|
||||||
try:
|
|
||||||
checkin_btn = WebDriverWait(self.__driver, 2).until(
|
|
||||||
EC.element_to_be_clickable((By.ID, "btnCheckIn"))
|
|
||||||
)
|
|
||||||
except:
|
|
||||||
self._showTrace(f"用户 {username} 签到界面加载失败 !")
|
|
||||||
return False
|
|
||||||
if "disabled" in checkin_btn.get_attribute("class"):
|
|
||||||
self._showTrace("签到按钮不可用, 可能不在场馆内, 请连接图书馆网络后重试")
|
|
||||||
return False
|
|
||||||
checkin_btn.click()
|
|
||||||
return self._waitResponseLoad()
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
"""
|
|
||||||
Copyright (c) 2025 KenanZhu.
|
|
||||||
All rights reserved.
|
|
||||||
|
|
||||||
This software is provided "as is", without any warranty of any kind.
|
|
||||||
You may use, modify, and distribute this file under the terms of the MIT License.
|
|
||||||
See the LICENSE file for details.
|
|
||||||
"""
|
|
||||||
import re
|
|
||||||
import time
|
|
||||||
import queue
|
|
||||||
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
from selenium.webdriver.common.by import By
|
|
||||||
from selenium.webdriver.support.ui import WebDriverWait
|
|
||||||
from selenium.webdriver.support import expected_conditions as EC
|
|
||||||
|
|
||||||
from LibOperator import LibOperator
|
|
||||||
|
|
||||||
|
|
||||||
class LibCheckout(LibOperator):
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
input_queue: queue.Queue,
|
|
||||||
output_queue: queue.Queue,
|
|
||||||
driver
|
|
||||||
):
|
|
||||||
|
|
||||||
super().__init__(input_queue, output_queue)
|
|
||||||
|
|
||||||
self.__driver = driver
|
|
||||||
|
|
||||||
|
|
||||||
def _waitResponseLoad(
|
|
||||||
self
|
|
||||||
) -> bool:
|
|
||||||
|
|
||||||
pass
|
|
||||||
@@ -1,210 +0,0 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
"""
|
|
||||||
Copyright (c) 2025 KenanZhu.
|
|
||||||
All rights reserved.
|
|
||||||
|
|
||||||
This software is provided "as is", without any warranty of any kind.
|
|
||||||
You may use, modify, and distribute this file under the terms of the MIT License.
|
|
||||||
See the LICENSE file for details.
|
|
||||||
"""
|
|
||||||
import time
|
|
||||||
import queue
|
|
||||||
import base64
|
|
||||||
|
|
||||||
import ddddocr
|
|
||||||
|
|
||||||
from selenium.webdriver.common.by import By
|
|
||||||
from selenium.webdriver.support.ui import WebDriverWait
|
|
||||||
from selenium.webdriver.support import expected_conditions as EC
|
|
||||||
|
|
||||||
from LibOperator import LibOperator
|
|
||||||
|
|
||||||
|
|
||||||
class LibLogin(LibOperator):
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
input_queue: queue.Queue,
|
|
||||||
output_queue: queue.Queue,
|
|
||||||
driver
|
|
||||||
):
|
|
||||||
|
|
||||||
super().__init__(input_queue, output_queue)
|
|
||||||
|
|
||||||
self.__driver = driver
|
|
||||||
self.__ddddocr = ddddocr.DdddOcr()
|
|
||||||
|
|
||||||
|
|
||||||
def _waitResponseLoad(
|
|
||||||
self
|
|
||||||
) -> bool:
|
|
||||||
|
|
||||||
# wait to verify login success
|
|
||||||
try:
|
|
||||||
WebDriverWait(self.__driver, 2).until( # title contains "自选座位 :: 座位预约系统"
|
|
||||||
EC.title_contains("自选座位 :: 座位预约系统")
|
|
||||||
)
|
|
||||||
WebDriverWait(self.__driver, 2).until( # search button presence
|
|
||||||
EC.presence_of_element_located((By.ID, "search"))
|
|
||||||
)
|
|
||||||
WebDriverWait(self.__driver, 2).until( # select content presence
|
|
||||||
EC.presence_of_element_located((By.CLASS_NAME, "selectContent"))
|
|
||||||
)
|
|
||||||
return True
|
|
||||||
except:
|
|
||||||
self._showTrace(f"登录页面加载失败 ! : 用户账号或者密码错误/验证码错误, 具体以页面提示为准")
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def __fillLogInElements(
|
|
||||||
self,
|
|
||||||
username: str,
|
|
||||||
password: str
|
|
||||||
) -> bool:
|
|
||||||
|
|
||||||
# ensure elements presence and fill them
|
|
||||||
try:
|
|
||||||
username_element = self.__driver.find_element(By.NAME, "username")
|
|
||||||
username_element.clear()
|
|
||||||
username_element.send_keys(username)
|
|
||||||
password_element = self.__driver.find_element(By.NAME, "password")
|
|
||||||
password_element.clear()
|
|
||||||
password_element.send_keys(password)
|
|
||||||
except Exception as e:
|
|
||||||
self._showTrace(f"用户名或密码填写失败 ! : {e}")
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def __autoRecognizeCaptcha(
|
|
||||||
self
|
|
||||||
) -> str:
|
|
||||||
|
|
||||||
# auto recognize captcha
|
|
||||||
try:
|
|
||||||
captcha_img = self.__driver.find_element(By.ID, "loadImgId")
|
|
||||||
img_src = captcha_img.get_attribute("src")
|
|
||||||
base64_str = img_src.split(',', 1)[1]
|
|
||||||
captcha_img = base64.b64decode(base64_str)
|
|
||||||
captcha_text = self.__ddddocr.classification(captcha_img)
|
|
||||||
captcha_text = ''.join(filter(str.isalnum, captcha_text)).lower()
|
|
||||||
self._showTrace(f"识别到验证码为 : '{captcha_text}'.")
|
|
||||||
if len(captcha_text) != 4:
|
|
||||||
raise Exception("识别到的验证码长度不等于 4 个字符 !")
|
|
||||||
return captcha_text
|
|
||||||
except Exception as e:
|
|
||||||
self._showTrace(f"验证码识别失败 ! : {e}")
|
|
||||||
self.__refreshCaptcha()
|
|
||||||
return ""
|
|
||||||
|
|
||||||
|
|
||||||
def __manualRecognizeCaptcha(
|
|
||||||
self
|
|
||||||
) -> str:
|
|
||||||
|
|
||||||
# manual recognize captcha
|
|
||||||
try:
|
|
||||||
self._show_msg("请输入验证码:")
|
|
||||||
captcha_text = self._wait_msg(timeout=15)
|
|
||||||
self._showTrace(f"输入的验证码为 : '{captcha_text}'.")
|
|
||||||
if len(captcha_text) != 4:
|
|
||||||
raise Exception("输入的验证码长度不等于 4 个字符 !")
|
|
||||||
return captcha_text
|
|
||||||
except Exception as e:
|
|
||||||
self._showTrace(f"输入验证码失败 ! : {e}")
|
|
||||||
self.__refreshCaptcha()
|
|
||||||
return ""
|
|
||||||
|
|
||||||
|
|
||||||
def __refreshCaptcha(
|
|
||||||
self
|
|
||||||
):
|
|
||||||
|
|
||||||
# refresh captcha
|
|
||||||
try:
|
|
||||||
self._showTrace("刷新验证码......")
|
|
||||||
self.__driver.find_element(
|
|
||||||
By.ID, "loadImgId"
|
|
||||||
).click()
|
|
||||||
time.sleep(1)
|
|
||||||
return True
|
|
||||||
except Exception as e:
|
|
||||||
self._showTrace(f"刷新验证码失败 ! : {e}")
|
|
||||||
self.__refreshCaptcha()
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def __solveCaptcha(
|
|
||||||
self,
|
|
||||||
auto_captcha: bool = True
|
|
||||||
) -> str:
|
|
||||||
|
|
||||||
max_attempts = 5
|
|
||||||
|
|
||||||
for _ in range(max_attempts):
|
|
||||||
if auto_captcha:
|
|
||||||
captcha_text = self.__autoRecognizeCaptcha()
|
|
||||||
else:
|
|
||||||
self._showTrace(f"用户未配置自动识别验证码, 请手动输入验证码 !")
|
|
||||||
captcha_text = self.__manualRecognizeCaptcha()
|
|
||||||
if captcha_text:
|
|
||||||
return captcha_text
|
|
||||||
self._showTrace(f"验证码识别失败 {max_attempts} 次, 请检查验证码是否正确 !")
|
|
||||||
return ""
|
|
||||||
|
|
||||||
|
|
||||||
def __fillCaptchaElement(
|
|
||||||
self,
|
|
||||||
captcha_text: str
|
|
||||||
) -> bool:
|
|
||||||
|
|
||||||
try:
|
|
||||||
captcha_element = self.__driver.find_element(By.NAME, "answer")
|
|
||||||
captcha_element.clear()
|
|
||||||
captcha_element.send_keys(captcha_text)
|
|
||||||
return True
|
|
||||||
except Exception as e:
|
|
||||||
self._showTrace(f"验证码填写失败 ! : {e}")
|
|
||||||
self.__refreshCaptcha()
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def login(
|
|
||||||
self,
|
|
||||||
username: str,
|
|
||||||
password: str,
|
|
||||||
max_attempts: int = 5,
|
|
||||||
auto_captcha: bool = True
|
|
||||||
) -> bool:
|
|
||||||
|
|
||||||
if self.__driver is None:
|
|
||||||
self._showTrace("未提供有效 WebDriver 实例 !")
|
|
||||||
return False
|
|
||||||
# begin login process
|
|
||||||
for attempt in range(max_attempts):
|
|
||||||
self._showTrace(f"用户 {username} 第 {attempt + 1} 次尝试登录......")
|
|
||||||
if not self.__fillLogInElements(
|
|
||||||
username,
|
|
||||||
password,
|
|
||||||
):
|
|
||||||
continue
|
|
||||||
captcha_text = self.__solveCaptcha(auto_captcha)
|
|
||||||
if not captcha_text:
|
|
||||||
continue
|
|
||||||
if not self.__fillCaptchaElement(captcha_text):
|
|
||||||
continue
|
|
||||||
self._showTrace("尝试登录...")
|
|
||||||
try:
|
|
||||||
self.__driver.find_element(
|
|
||||||
By.XPATH,
|
|
||||||
"//input[@type='button' and @value='登录']"
|
|
||||||
).click()
|
|
||||||
except Exception as e:
|
|
||||||
self._showTrace(f"登录失败 ! : {e}")
|
|
||||||
continue
|
|
||||||
if self._waitResponseLoad():
|
|
||||||
self._showTrace(f"用户 {username} 第 {attempt + 1} 次登录成功 !")
|
|
||||||
return True
|
|
||||||
else:
|
|
||||||
self._showTrace(f"用户 {username} 第 {attempt + 1} 次登录失败 !")
|
|
||||||
return False
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
"""
|
|
||||||
Copyright (c) 2025 KenanZhu.
|
|
||||||
All rights reserved.
|
|
||||||
|
|
||||||
This software is provided "as is", without any warranty of any kind.
|
|
||||||
You may use, modify, and distribute this file under the terms of the MIT License.
|
|
||||||
See the LICENSE file for details.
|
|
||||||
"""
|
|
||||||
import queue
|
|
||||||
|
|
||||||
from selenium.webdriver.common.by import By
|
|
||||||
from selenium.webdriver.support.ui import WebDriverWait
|
|
||||||
from selenium.webdriver.support import expected_conditions as EC
|
|
||||||
|
|
||||||
from LibOperator import LibOperator
|
|
||||||
|
|
||||||
|
|
||||||
class LibLogout(LibOperator):
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
input_queue: queue.Queue,
|
|
||||||
output_queue: queue.Queue,
|
|
||||||
driver
|
|
||||||
):
|
|
||||||
|
|
||||||
super().__init__(input_queue, output_queue)
|
|
||||||
|
|
||||||
self.__driver = driver
|
|
||||||
|
|
||||||
|
|
||||||
def _waitResponseLoad(
|
|
||||||
self
|
|
||||||
) -> bool:
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def logout(
|
|
||||||
self,
|
|
||||||
username: str
|
|
||||||
) -> bool:
|
|
||||||
|
|
||||||
if self.__driver is None:
|
|
||||||
self._showTrace("未提供有效 WebDriver 实例 !")
|
|
||||||
return False
|
|
||||||
try:
|
|
||||||
self.__driver.find_element(
|
|
||||||
By.XPATH, "//a[@href='/logout']"
|
|
||||||
).click()
|
|
||||||
self._showTrace(f"用户 {username} 注销成功 !")
|
|
||||||
return True
|
|
||||||
except Exception as e:
|
|
||||||
self._showTrace(f"用户 {username} 注销失败 ! : {e}")
|
|
||||||
return False
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
"""
|
|
||||||
Copyright (c) 2025 KenanZhu.
|
|
||||||
All rights reserved.
|
|
||||||
|
|
||||||
This software is provided "as is", without any warranty of any kind.
|
|
||||||
You may use, modify, and distribute this file under the terms of the MIT License.
|
|
||||||
See the LICENSE file for details.
|
|
||||||
"""
|
|
||||||
import queue
|
|
||||||
|
|
||||||
from MsgBase import MsgBase
|
|
||||||
|
|
||||||
|
|
||||||
class LibOperator(MsgBase):
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
input_queue: queue.Queue,
|
|
||||||
output_queue: queue.Queue
|
|
||||||
):
|
|
||||||
|
|
||||||
super().__init__(input_queue, output_queue)
|
|
||||||
|
|
||||||
|
|
||||||
def _waitResponseLoad(
|
|
||||||
self
|
|
||||||
) -> bool:
|
|
||||||
|
|
||||||
pass
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
"""
|
|
||||||
Copyright (c) 2025 KenanZhu.
|
|
||||||
All rights reserved.
|
|
||||||
|
|
||||||
This software is provided "as is", without any warranty of any kind.
|
|
||||||
You may use, modify, and distribute this file under the terms of the MIT License.
|
|
||||||
See the LICENSE file for details.
|
|
||||||
"""
|
|
||||||
import os
|
|
||||||
import queue
|
|
||||||
|
|
||||||
from LibOperator import LibOperator
|
|
||||||
|
|
||||||
|
|
||||||
class LibRenew(LibOperator):
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
input_queue: queue.Queue,
|
|
||||||
output_queue: queue.Queue,
|
|
||||||
driver
|
|
||||||
):
|
|
||||||
|
|
||||||
super().__init__(input_queue, output_queue)
|
|
||||||
|
|
||||||
self.__driver = driver
|
|
||||||
|
|
||||||
|
|
||||||
def _waitResponseLoad(
|
|
||||||
self
|
|
||||||
) -> bool:
|
|
||||||
|
|
||||||
pass
|
|
||||||
@@ -1,618 +0,0 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
"""
|
|
||||||
Copyright (c) 2025 KenanZhu.
|
|
||||||
All rights reserved.
|
|
||||||
|
|
||||||
This software is provided "as is", without any warranty of any kind.
|
|
||||||
You may use, modify, and distribute this file under the terms of the MIT License.
|
|
||||||
See the LICENSE file for details.
|
|
||||||
"""
|
|
||||||
import re
|
|
||||||
import time
|
|
||||||
import queue
|
|
||||||
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
from selenium.webdriver.common.by import By
|
|
||||||
from selenium.webdriver.support.ui import WebDriverWait
|
|
||||||
from selenium.webdriver.support import expected_conditions as EC
|
|
||||||
|
|
||||||
from LibOperator import LibOperator
|
|
||||||
|
|
||||||
|
|
||||||
class LibReserve(LibOperator):
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
input_queue: queue.Queue,
|
|
||||||
output_queue: queue.Queue,
|
|
||||||
driver
|
|
||||||
):
|
|
||||||
|
|
||||||
super().__init__(input_queue, output_queue)
|
|
||||||
|
|
||||||
self.__driver = driver
|
|
||||||
# library floor and room mapping in website
|
|
||||||
self.__floor_map = {
|
|
||||||
"2": "二层",
|
|
||||||
"3": "三层",
|
|
||||||
"4": "四层",
|
|
||||||
"5": "五层"
|
|
||||||
}
|
|
||||||
self.__room_map = {
|
|
||||||
"1": "二层内环",
|
|
||||||
"2": "二层外环",
|
|
||||||
"3": "三层内环",
|
|
||||||
"4": "三层外环",
|
|
||||||
"5": "四层内环",
|
|
||||||
"6": "四层外环",
|
|
||||||
"7": "四层期刊区",
|
|
||||||
"8": "五层考研"
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _waitResponseLoad(
|
|
||||||
self,
|
|
||||||
) -> bool:
|
|
||||||
|
|
||||||
try:
|
|
||||||
WebDriverWait(self.__driver, 2).until(
|
|
||||||
EC.presence_of_element_located((By.CLASS_NAME, "layoutSeat"))
|
|
||||||
)
|
|
||||||
title_elements = []
|
|
||||||
# reserve failed without title elements, so we need to try
|
|
||||||
try:
|
|
||||||
WebDriverWait(self.__driver, 2).until(
|
|
||||||
EC.presence_of_element_located((By.CSS_SELECTOR, ".layoutSeat dt"))
|
|
||||||
)
|
|
||||||
title_elements = self.__driver.find_elements(
|
|
||||||
By.CSS_SELECTOR, ".layoutSeat dt"
|
|
||||||
)
|
|
||||||
except:
|
|
||||||
pass
|
|
||||||
content_elements = self.__driver.find_elements(
|
|
||||||
By.CSS_SELECTOR, ".layoutSeat dd"
|
|
||||||
)
|
|
||||||
if not content_elements:
|
|
||||||
self._showTrace("未找到预约结果")
|
|
||||||
raise
|
|
||||||
title = title_elements[0].text if title_elements else ""
|
|
||||||
contents = [element.text for element in content_elements if element.text.strip()]
|
|
||||||
for message in contents:
|
|
||||||
if "预约失败" in message or "已有1个有效预约" in message:
|
|
||||||
self._showTrace(f"预约失败 - {"".join(contents)}")
|
|
||||||
raise
|
|
||||||
if "预定好了" in title or "预约成功" in title or "操作成功" in title:
|
|
||||||
if len(contents) >= 6:
|
|
||||||
self._showTrace(f"\n"\
|
|
||||||
f" 预约成功 !\n"\
|
|
||||||
f" {contents[1]}\n"\
|
|
||||||
f" {contents[2]}\n"\
|
|
||||||
f" {contents[3]}\n"\
|
|
||||||
f" 签到时间 :{contents[5]}")
|
|
||||||
else:
|
|
||||||
self._showTrace(f"\n"\
|
|
||||||
f" 预约成功 !\n"\
|
|
||||||
f" 未找获取到详细信息")
|
|
||||||
return True
|
|
||||||
except:
|
|
||||||
self._showTrace(f"预约结果加载失败 !")
|
|
||||||
return False
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def __timeToMins(
|
|
||||||
time_str: str
|
|
||||||
) -> int:
|
|
||||||
|
|
||||||
hour, minute = map(int, time_str.split(":"))
|
|
||||||
return hour*60 + minute
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def __minsToTime(
|
|
||||||
mins: int
|
|
||||||
) -> str:
|
|
||||||
|
|
||||||
hour, minute = divmod(mins, 60)
|
|
||||||
return f"{hour:02d}:{minute:02d}"
|
|
||||||
|
|
||||||
|
|
||||||
def __containRequiredInfo(
|
|
||||||
self,
|
|
||||||
reserve_info: dict
|
|
||||||
) -> bool:
|
|
||||||
|
|
||||||
try:
|
|
||||||
# must contain the required infomation
|
|
||||||
if reserve_info.get("floor") is None: # if existence ?
|
|
||||||
raise ValueError("未指定楼层")
|
|
||||||
if reserve_info["floor"] not in self.__floor_map: # if in the mao ?
|
|
||||||
raise ValueError(f"该楼层 '{reserve_info['floor']}' 不存在")
|
|
||||||
if reserve_info.get("room") is None:
|
|
||||||
raise ValueError("未指定房间")
|
|
||||||
if reserve_info["room"] not in self.__room_map:
|
|
||||||
raise ValueError(f"该房间 '{reserve_info['room']}' 不存在")
|
|
||||||
if reserve_info.get("seat_id") is None:
|
|
||||||
raise ValueError("未指定座位")
|
|
||||||
if reserve_info["seat_id"] == "":
|
|
||||||
raise ValueError("未指定座位号")
|
|
||||||
return True
|
|
||||||
except ValueError as e:
|
|
||||||
self._showTrace(
|
|
||||||
f"预约信息错误 ! : {e}, "\
|
|
||||||
f"由于缺少必要的预约信息, 无法开始预约流程, 请检查预约信息是否完整"
|
|
||||||
)
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def __isValidDate(
|
|
||||||
self,
|
|
||||||
reserve_info: dict
|
|
||||||
) -> bool:
|
|
||||||
|
|
||||||
cur_date = time.strftime("%Y-%m-%d", time.localtime())
|
|
||||||
if reserve_info.get("date") is None:
|
|
||||||
reserve_info["date"] = cur_date
|
|
||||||
self._showTrace(f"预约日期未指定, 自动设置为当前日期: {cur_date}")
|
|
||||||
else:
|
|
||||||
if reserve_info["date"] < cur_date:
|
|
||||||
self._showTrace(
|
|
||||||
f"预约日期错误 ! :"\
|
|
||||||
f"{reserve_info['date']} 早于当前日期 {cur_date}, 自动设置为当前日期"
|
|
||||||
)
|
|
||||||
reserve_info["date"] = cur_date
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def __isValidBeginTime(
|
|
||||||
self,
|
|
||||||
reserve_info: dict
|
|
||||||
) -> bool:
|
|
||||||
|
|
||||||
cur_time = time.strftime("%H:%M", time.localtime())
|
|
||||||
if reserve_info.get("begin_time") is None:
|
|
||||||
reserve_info["begin_time"] = {}
|
|
||||||
if "time" not in reserve_info["begin_time"]:
|
|
||||||
reserve_info["begin_time"]["time"] = cur_time
|
|
||||||
self._showTrace(f"开始时间未指定, 自动设置为当前时间: {cur_time}")
|
|
||||||
if "max_diff" not in reserve_info["begin_time"]:
|
|
||||||
reserve_info["begin_time"]["max_diff"] = 30
|
|
||||||
self._showTrace(f"开始时间最大时间差未指定, 自动设置为 30 分钟")
|
|
||||||
if "prefer_early" not in reserve_info["begin_time"]:
|
|
||||||
reserve_info["begin_time"]["prefer_early"] = True
|
|
||||||
self._showTrace(f"是否优先选择更早开始时间未指定, 自动设置为 True")
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def __isValidExpectDuration(
|
|
||||||
self,
|
|
||||||
reserve_info: dict
|
|
||||||
) -> bool:
|
|
||||||
|
|
||||||
if reserve_info.get("expect_duration") is None:
|
|
||||||
reserve_info["expect_duration"] = 4
|
|
||||||
self._showTrace("预约持续时间未指定, 使用默认时长为 4 小时")
|
|
||||||
if reserve_info.get("satisfy_duration") is None:
|
|
||||||
reserve_info["satisfy_duration"] = True
|
|
||||||
self._showTrace("预约满足时长要求未指定, 默认满足")
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def __isValidEndTime(
|
|
||||||
self,
|
|
||||||
reserve_info: dict
|
|
||||||
) -> bool:
|
|
||||||
|
|
||||||
if reserve_info.get("end_time") is None:
|
|
||||||
reserve_info["end_time"] = {}
|
|
||||||
if "time" not in reserve_info["end_time"]:
|
|
||||||
end_mins = self.__timeToMins(reserve_info["begin_time"]["time"])
|
|
||||||
end_mins = end_mins + int(reserve_info["expect_duration"]*60)
|
|
||||||
reserve_info["end_time"] = {
|
|
||||||
"time": self.__minsToTime(end_mins),
|
|
||||||
"max_diff": 30,
|
|
||||||
"prefer_early": False
|
|
||||||
}
|
|
||||||
self._showTrace(
|
|
||||||
f"结束时间未指定, 自动设置为开始时间加上期望时长: {reserve_info['end_time']['time']}"
|
|
||||||
)
|
|
||||||
if "max_diff" not in reserve_info["end_time"]:
|
|
||||||
reserve_info["end_time"]["max_diff"] = 30
|
|
||||||
self._showTrace(f"结束时间最大时间差未指定, 自动设置为 30 分钟")
|
|
||||||
if "prefer_early" not in reserve_info["end_time"]:
|
|
||||||
reserve_info["end_time"]["prefer_early"] = False
|
|
||||||
self._showTrace(f"是否优先选择较晚结束时间未指定, 自动设置为 True")
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def __finalCheck(
|
|
||||||
self,
|
|
||||||
reserve_info: dict
|
|
||||||
):
|
|
||||||
|
|
||||||
begin_time, end_time = reserve_info["begin_time"], reserve_info["end_time"]
|
|
||||||
begin_mins = self.__timeToMins(begin_time["time"])
|
|
||||||
end_mins = self.__timeToMins(end_time["time"])
|
|
||||||
# if end time is earlier than begin_time, exchange them
|
|
||||||
if end_mins < begin_mins:
|
|
||||||
self._showTrace(
|
|
||||||
f"结束时间 {end_time['time']} 早于开始时间 {begin_time['time']}, 自动交换"
|
|
||||||
)
|
|
||||||
reserve_info["end_time"] = begin_time
|
|
||||||
reserve_info["begin_time"] = end_time
|
|
||||||
begin_time, end_time = reserve_info["begin_time"], reserve_info["end_time"]
|
|
||||||
begin_mins = self.__timeToMins(begin_time["time"])
|
|
||||||
end_mins = self.__timeToMins(end_time["time"])
|
|
||||||
# ensure the end time is not later than 23:30
|
|
||||||
if end_mins > self.__timeToMins("23:30"):
|
|
||||||
self._showTrace(
|
|
||||||
f"结束时间 {end_time['time']} 晚于 23:30, 自动设置为 23:30"
|
|
||||||
)
|
|
||||||
reserve_info["end_time"]["time"] = "23:30"
|
|
||||||
end_mins = self.__timeToMins("23:30")
|
|
||||||
# ensure the duration is not longer than 8 hours
|
|
||||||
if reserve_info["satisfy_duration"]:
|
|
||||||
if reserve_info["expect_duration"] > 8:
|
|
||||||
self._showTrace(
|
|
||||||
f"该用户设置了优先满足时长要求, 但是预约期望持续时间 "
|
|
||||||
f"{reserve_info['expect_duration']} 小时 "
|
|
||||||
f"超出最大时长 8 小时, 自动设置为 8 小时"
|
|
||||||
)
|
|
||||||
reserve_info["expect_duration"] = 8
|
|
||||||
else:
|
|
||||||
if end_mins - begin_mins > 8*60:
|
|
||||||
self._showTrace(
|
|
||||||
f"该用户未设置优先满足时长要求, 但是检查到预约持续时间 "
|
|
||||||
f"{int((end_mins - begin_mins)/60)} 小时 "
|
|
||||||
f"超出最大时长 8 小时, 自动设置为 8 小时"
|
|
||||||
)
|
|
||||||
reserve_info["expect_duration"] = 8
|
|
||||||
reserve_info["end_time"]["time"] = self.__minsToTime(begin_mins + 8*60)
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def __checkReserveInfo(
|
|
||||||
self,
|
|
||||||
reserve_info: dict
|
|
||||||
) -> bool:
|
|
||||||
|
|
||||||
if not self.__containRequiredInfo(reserve_info):
|
|
||||||
return False
|
|
||||||
if not self.__isValidDate(reserve_info):
|
|
||||||
return False
|
|
||||||
if not self.__isValidBeginTime(reserve_info):
|
|
||||||
return False
|
|
||||||
if not self.__isValidExpectDuration(reserve_info):
|
|
||||||
return False
|
|
||||||
if not self.__isValidEndTime(reserve_info):
|
|
||||||
return False
|
|
||||||
if not self.__finalCheck(reserve_info):
|
|
||||||
return False
|
|
||||||
self._showTrace(
|
|
||||||
f"预约信息检查完成, 准备预约 "
|
|
||||||
f"{reserve_info['date']} "
|
|
||||||
f"{reserve_info['begin_time']["time"]} - "
|
|
||||||
f"{reserve_info['end_time']["time"]} "
|
|
||||||
f"图书馆 "
|
|
||||||
f"{self.__floor_map[reserve_info['floor']]} "
|
|
||||||
f"{self.__room_map[reserve_info['room']]} "
|
|
||||||
f"的座位 {reserve_info['seat_id']}"
|
|
||||||
)
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def __clickElement(
|
|
||||||
self,
|
|
||||||
trigger_locator: tuple,
|
|
||||||
fail_msg: str,
|
|
||||||
success_msg: str,
|
|
||||||
option_locator: tuple = None
|
|
||||||
) -> bool:
|
|
||||||
|
|
||||||
try:
|
|
||||||
# click the trigger element
|
|
||||||
WebDriverWait(self.__driver, 2).until(
|
|
||||||
EC.element_to_be_clickable(trigger_locator)
|
|
||||||
).click()
|
|
||||||
if option_locator:
|
|
||||||
# select the option element if specified
|
|
||||||
WebDriverWait(self.__driver, 2).until(
|
|
||||||
EC.element_to_be_clickable(option_locator)
|
|
||||||
).click()
|
|
||||||
self._showTrace(success_msg)
|
|
||||||
return True
|
|
||||||
except:
|
|
||||||
self._showTrace(fail_msg)
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def __selectDate(
|
|
||||||
self,
|
|
||||||
date_str: str
|
|
||||||
) -> bool:
|
|
||||||
|
|
||||||
return self.__clickElement(
|
|
||||||
trigger_locator=(By.ID, "onDate_select"),
|
|
||||||
option_locator=(By.XPATH, f"//p[@id='options_onDate']/a[@value='{date_str}']"),
|
|
||||||
success_msg=f"日期 {date_str} 选择成功 !",
|
|
||||||
fail_msg=f"选择日期失败 ! : {date_str} 不可用"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def __selectPlace(
|
|
||||||
self,
|
|
||||||
place: str
|
|
||||||
) -> bool:
|
|
||||||
|
|
||||||
actual_place = "1" if place == "图书馆" else "1"
|
|
||||||
return self.__clickElement(
|
|
||||||
trigger_locator=(By.ID, "display_building"),
|
|
||||||
option_locator=(By.XPATH, f"//p[@id='options_building']/a[@value='{actual_place}']"),
|
|
||||||
success_msg=f"预约场所 {place} 选择成功 !",
|
|
||||||
fail_msg=f"选择预约场所失败 ! : {place} 不可用"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def __selectFloor(
|
|
||||||
self,
|
|
||||||
floor: str
|
|
||||||
) -> bool:
|
|
||||||
|
|
||||||
display_floor = self.__floor_map.get(floor)
|
|
||||||
return self.__clickElement(
|
|
||||||
trigger_locator=(By.ID, "floor_select"),
|
|
||||||
option_locator=(By.XPATH, f"//p[@id='options_floor']/a[@value='{floor}']"),
|
|
||||||
success_msg=f"楼层 {display_floor} 选择成功 !",
|
|
||||||
fail_msg=f"选择楼层失败 ! : {display_floor} 不可用"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def __selectRoom(
|
|
||||||
self,
|
|
||||||
room: str
|
|
||||||
) -> bool:
|
|
||||||
|
|
||||||
display_room = self.__room_map.get(room)
|
|
||||||
return self.__clickElement(
|
|
||||||
trigger_locator=(By.ID, f"room_{room}"),
|
|
||||||
option_locator=None,
|
|
||||||
success_msg=f"房间 {display_room} 选择成功 !",
|
|
||||||
fail_msg=f"选择房间失败 ! : {display_room} 不可用"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def __selectSeat(
|
|
||||||
self,
|
|
||||||
seat_id: str
|
|
||||||
) -> bool:
|
|
||||||
|
|
||||||
try:
|
|
||||||
# wait fot seat layout element to load
|
|
||||||
WebDriverWait(self.__driver, 2).until(
|
|
||||||
EC.presence_of_element_located((By.ID, "seatLayout"))
|
|
||||||
)
|
|
||||||
WebDriverWait(self.__driver, 2).until(
|
|
||||||
EC.presence_of_all_elements_located((By.CSS_SELECTOR, "li[id^='seat_']"))
|
|
||||||
)
|
|
||||||
except:
|
|
||||||
self._showTrace(f"座位加载失败 !")
|
|
||||||
return False
|
|
||||||
try:
|
|
||||||
all_seats = self.__driver.find_elements(
|
|
||||||
By.CSS_SELECTOR, "li[id^='seat_']"
|
|
||||||
)
|
|
||||||
seat_id_upper = seat_id.lstrip('0').upper()
|
|
||||||
for seat in all_seats:
|
|
||||||
if not seat_id_upper == seat.text.lstrip('0'):
|
|
||||||
continue
|
|
||||||
seat_link = seat.find_element(By.TAG_NAME, "a")
|
|
||||||
WebDriverWait(self.__driver, 2).until(
|
|
||||||
EC.element_to_be_clickable(seat_link)
|
|
||||||
)
|
|
||||||
seat_link.click()
|
|
||||||
seat_status = seat_link.get_attribute("title")
|
|
||||||
self._showTrace(f"座位 {seat_id} 选择成功 ! : 当前状态 - '{seat_status}'")
|
|
||||||
return True
|
|
||||||
self._showTrace(f"座位 {seat_id} 在该楼层区域中不存在, 请检查座位号是否正确")
|
|
||||||
except:
|
|
||||||
self._showTrace(f"座位选择失败 !")
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def __selectNearestTime(
|
|
||||||
self,
|
|
||||||
time_id: str,
|
|
||||||
time_type: str,
|
|
||||||
target_time: int,
|
|
||||||
max_time_diff: int = 30,
|
|
||||||
prefer_earlier: bool = True
|
|
||||||
) -> int:
|
|
||||||
|
|
||||||
try:
|
|
||||||
WebDriverWait(self.__driver, 2).until(
|
|
||||||
EC.presence_of_all_elements_located(
|
|
||||||
(By.CSS_SELECTOR, f"#{time_id} ul li a")
|
|
||||||
)
|
|
||||||
)
|
|
||||||
except:
|
|
||||||
self._showTrace(f"{time_type} 选择失败 ! : 当前未查询到可用时间")
|
|
||||||
return -1
|
|
||||||
try:
|
|
||||||
all_time_opts = self.__driver.find_elements(
|
|
||||||
By.CSS_SELECTOR,
|
|
||||||
f"#{time_id} ul li a"
|
|
||||||
)
|
|
||||||
free_times = []
|
|
||||||
best_time_diff = max_time_diff
|
|
||||||
best_actual_diff = None
|
|
||||||
best_time_opt = None
|
|
||||||
|
|
||||||
if not all_time_opts:
|
|
||||||
self._showTrace(f"{time_type} 选择失败 ! : 当前未查询到可用时间")
|
|
||||||
return -1
|
|
||||||
for time_opt in all_time_opts:
|
|
||||||
time_attr = time_opt.get_attribute("time")
|
|
||||||
if time_attr == "now":
|
|
||||||
now = datetime.now()
|
|
||||||
time_val = int(now.hour*60 + now.minute)
|
|
||||||
elif time_attr and time_attr.isdigit():
|
|
||||||
time_val = int(time_attr)
|
|
||||||
else:
|
|
||||||
continue
|
|
||||||
free_times.append(self.__minsToTime(time_val))
|
|
||||||
actual_diff = time_val - target_time
|
|
||||||
abs_diff = abs(actual_diff)
|
|
||||||
if abs_diff < best_time_diff or (
|
|
||||||
abs_diff == best_time_diff and (
|
|
||||||
# prefer earlier time
|
|
||||||
(prefer_earlier and actual_diff <= 0) or
|
|
||||||
# prefer later time
|
|
||||||
(not prefer_earlier and actual_diff >= 0)
|
|
||||||
)
|
|
||||||
):
|
|
||||||
best_time_diff = abs_diff
|
|
||||||
best_actual_diff = actual_diff
|
|
||||||
best_time_opt = time_opt
|
|
||||||
|
|
||||||
if best_time_opt is not None:
|
|
||||||
best_time_opt.click()
|
|
||||||
abs_time_diff = abs(best_actual_diff)
|
|
||||||
if best_actual_diff < 0:
|
|
||||||
time_relation = f"早了 {abs_time_diff} 分钟"
|
|
||||||
elif best_actual_diff > 0:
|
|
||||||
time_relation = f"晚了 {abs_time_diff} 分钟"
|
|
||||||
else:
|
|
||||||
time_relation = f"正好等于 {time_type}"
|
|
||||||
target_time += best_actual_diff
|
|
||||||
self._showTrace(
|
|
||||||
f"选择距离期望 {time_type} 最近的 {best_time_opt.text}, "\
|
|
||||||
f"与期望 {time_type} 相比 {time_relation}"
|
|
||||||
)
|
|
||||||
return target_time
|
|
||||||
self._showTrace(
|
|
||||||
f"无法选择最近的 {time_type} {self.__minsToTime(target_time)}, "\
|
|
||||||
f"所有可选时间与目标时间相差都超过 {max_time_diff} 分钟"
|
|
||||||
)
|
|
||||||
self._showTrace(f"当前可供预约的 {time_type} 有: {free_times}")
|
|
||||||
return -1
|
|
||||||
except:
|
|
||||||
self._showTrace(f"{time_type} {self.__minsToTime(target_time)} 选择失败 !")
|
|
||||||
return -1
|
|
||||||
|
|
||||||
|
|
||||||
def __selectSeatTime(
|
|
||||||
self,
|
|
||||||
begin_time: dict,
|
|
||||||
end_time: dict,
|
|
||||||
expct_duration: int = 4,
|
|
||||||
satisfy_duration: bool = True
|
|
||||||
) -> bool:
|
|
||||||
|
|
||||||
expect_begin_time = actual_begin_time = begin_time["time"]
|
|
||||||
expect_end_time = actual_end_time = end_time["time"]
|
|
||||||
expect_begin_mins = self.__timeToMins(expect_begin_time)
|
|
||||||
expect_end_mins = self.__timeToMins(expect_end_time)
|
|
||||||
|
|
||||||
# select the begin time
|
|
||||||
if self.__selectNearestTime(
|
|
||||||
time_id="startTime", # dont change into begin, this is the element in the page
|
|
||||||
time_type="开始时间",
|
|
||||||
target_time=expect_begin_mins,
|
|
||||||
max_time_diff=begin_time["max_diff"],
|
|
||||||
prefer_earlier=begin_time["prefer_early"]
|
|
||||||
) == -1:
|
|
||||||
return False
|
|
||||||
else:
|
|
||||||
actual_begin_time = self.__minsToTime(expect_begin_mins)
|
|
||||||
# if 'satisfy_duration' is True.
|
|
||||||
# select the end time based on the begin time
|
|
||||||
# (because it may be changed under the 'max time diff' strategy) and expect duration.
|
|
||||||
if satisfy_duration:
|
|
||||||
expect_end_mins = int(expect_begin_mins + expct_duration*60)
|
|
||||||
self._showTrace(
|
|
||||||
f"需要满足期望预约持续时间: {expct_duration} 小时, "\
|
|
||||||
f"根据开始时间 {actual_begin_time} 计算结束时间: {self.__minsToTime(expect_end_mins)}"
|
|
||||||
)
|
|
||||||
# select the end time
|
|
||||||
if self.__selectNearestTime(
|
|
||||||
time_id="endTime",
|
|
||||||
time_type="结束时间",
|
|
||||||
target_time=expect_end_mins,
|
|
||||||
max_time_diff=end_time["max_diff"],
|
|
||||||
prefer_earlier=end_time["prefer_early"]
|
|
||||||
) == -1:
|
|
||||||
return False
|
|
||||||
else:
|
|
||||||
actual_end_time = self.__minsToTime(expect_end_mins)
|
|
||||||
self._showTrace(
|
|
||||||
f"期望预约时间段: {expect_begin_time} - {expect_end_time}, "
|
|
||||||
f"实际预约时间段: {actual_begin_time} - {actual_end_time}"
|
|
||||||
)
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def reserve(
|
|
||||||
self,
|
|
||||||
reserve_info: dict
|
|
||||||
) -> bool:
|
|
||||||
|
|
||||||
submit_reserve = False
|
|
||||||
reserve_success = False
|
|
||||||
have_hover_on_page = False
|
|
||||||
|
|
||||||
# reserve info
|
|
||||||
if not self.__checkReserveInfo(reserve_info):
|
|
||||||
return False
|
|
||||||
# map page
|
|
||||||
try:
|
|
||||||
WebDriverWait(self.__driver, 2).until(
|
|
||||||
EC.element_to_be_clickable((By.XPATH, "//a[@href='/map']"))
|
|
||||||
).click()
|
|
||||||
WebDriverWait(self.__driver, 2).until(
|
|
||||||
EC.presence_of_element_located((By.ID, "seatLayout"))
|
|
||||||
)
|
|
||||||
except:
|
|
||||||
self._showTrace(f"加载预约选座页面失败 !")
|
|
||||||
return False
|
|
||||||
# date, place, floor
|
|
||||||
if not self.__selectDate(reserve_info["date"]):
|
|
||||||
return False
|
|
||||||
if not self.__selectPlace(reserve_info["place"]):
|
|
||||||
return False
|
|
||||||
if not self.__selectFloor(reserve_info["floor"]):
|
|
||||||
return False
|
|
||||||
# room find
|
|
||||||
try:
|
|
||||||
WebDriverWait(self.__driver, 2).until(
|
|
||||||
EC.element_to_be_clickable((By.ID, "findRoom"))
|
|
||||||
).click()
|
|
||||||
except:
|
|
||||||
self._showTrace("加载房间/区域失败 !")
|
|
||||||
return False
|
|
||||||
# room
|
|
||||||
if not self.__selectRoom(reserve_info["room"]):
|
|
||||||
return False
|
|
||||||
else:
|
|
||||||
have_hover_on_page = True
|
|
||||||
# seat selections
|
|
||||||
if not self.__selectSeat(reserve_info["seat_id"]):
|
|
||||||
pass
|
|
||||||
elif not self.__selectSeatTime(
|
|
||||||
begin_time=reserve_info["begin_time"],
|
|
||||||
end_time=reserve_info["end_time"],
|
|
||||||
expct_duration=reserve_info["expect_duration"],
|
|
||||||
satisfy_duration=reserve_info["satisfy_duration"]
|
|
||||||
):
|
|
||||||
pass
|
|
||||||
else:
|
|
||||||
try:
|
|
||||||
WebDriverWait(self.__driver, 2).until(
|
|
||||||
EC.element_to_be_clickable((By.ID, "reserveBtn"))
|
|
||||||
).click()
|
|
||||||
submit_reserve = True
|
|
||||||
if not self._waitResponseLoad():
|
|
||||||
raise
|
|
||||||
reserve_success = True
|
|
||||||
except:
|
|
||||||
self._showTrace(f"预约提交失败 !")
|
|
||||||
if not submit_reserve and have_hover_on_page:
|
|
||||||
self.__driver.refresh()
|
|
||||||
return reserve_success
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
"""
|
|
||||||
Copyright (c) 2025 KenanZhu.
|
|
||||||
All rights reserved.
|
|
||||||
|
|
||||||
This software is provided "as is", without any warranty of any kind.
|
|
||||||
You may use, modify, and distribute this file under the terms of the MIT License.
|
|
||||||
See the LICENSE file for details.
|
|
||||||
"""
|
|
||||||
import time
|
|
||||||
import queue
|
|
||||||
|
|
||||||
|
|
||||||
class MsgBase:
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
input_queue: queue.Queue,
|
|
||||||
output_queue: queue.Queue
|
|
||||||
):
|
|
||||||
|
|
||||||
self._class_name = self.__class__.__name__
|
|
||||||
self._input_queue = input_queue
|
|
||||||
self._output_queue = output_queue
|
|
||||||
|
|
||||||
|
|
||||||
def _showMsg(
|
|
||||||
self,
|
|
||||||
msg: str
|
|
||||||
):
|
|
||||||
|
|
||||||
self._output_queue.put(f"[{self._class_name:<12}] >>> : {msg}")
|
|
||||||
|
|
||||||
|
|
||||||
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:<12}] : {msg}")
|
|
||||||
|
|
||||||
|
|
||||||
def _waitMsg(
|
|
||||||
self,
|
|
||||||
timeout: float = 1.0
|
|
||||||
) -> str:
|
|
||||||
|
|
||||||
try:
|
|
||||||
msg = self._input_queue.get(timeout=timeout)
|
|
||||||
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
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
[[source]]
|
|
||||||
url = "https://pypi.org/simple"
|
|
||||||
verify_ssl = true
|
|
||||||
name = "pypi"
|
|
||||||
|
|
||||||
[packages]
|
|
||||||
ddddocr = "*"
|
|
||||||
selenium = "*"
|
|
||||||
pyinstaller = "*"
|
|
||||||
pyside6 = "*"
|
|
||||||
|
|
||||||
[dev-packages]
|
|
||||||
|
|
||||||
[requires]
|
|
||||||
python_version = "3.13"
|
|
||||||
@@ -1,630 +0,0 @@
|
|||||||
{
|
|
||||||
"_meta": {
|
|
||||||
"hash": {
|
|
||||||
"sha256": "26dffc26812d5328611959b95713a7ed65e20c08c60089b54283b0f406dd08e4"
|
|
||||||
},
|
|
||||||
"pipfile-spec": 6,
|
|
||||||
"requires": {
|
|
||||||
"python_version": "3.13"
|
|
||||||
},
|
|
||||||
"sources": [
|
|
||||||
{
|
|
||||||
"name": "pypi",
|
|
||||||
"url": "https://pypi.org/simple",
|
|
||||||
"verify_ssl": true
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"default": {
|
|
||||||
"altgraph": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:1b5afbb98f6c4dcadb2e2ae6ab9fa994bbb8c1d75f4fa96d340f9437ae454406",
|
|
||||||
"sha256:642743b4750de17e655e6711601b077bc6598dbfa3ba5fa2b2a35ce12b508dff"
|
|
||||||
],
|
|
||||||
"version": "==0.17.4"
|
|
||||||
},
|
|
||||||
"attrs": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11",
|
|
||||||
"sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373"
|
|
||||||
],
|
|
||||||
"markers": "python_version >= '3.9'",
|
|
||||||
"version": "==25.4.0"
|
|
||||||
},
|
|
||||||
"certifi": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:0f212c2744a9bb6de0c56639a6f68afe01ecd92d91f14ae897c4fe7bbeeef0de",
|
|
||||||
"sha256:47c09d31ccf2acf0be3f701ea53595ee7e0b8fa08801c6624be771df09ae7b43"
|
|
||||||
],
|
|
||||||
"markers": "python_version >= '3.7'",
|
|
||||||
"version": "==2025.10.5"
|
|
||||||
},
|
|
||||||
"cffi": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb",
|
|
||||||
"sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b",
|
|
||||||
"sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f",
|
|
||||||
"sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9",
|
|
||||||
"sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44",
|
|
||||||
"sha256:0f6084a0ea23d05d20c3edcda20c3d006f9b6f3fefeac38f59262e10cef47ee2",
|
|
||||||
"sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c",
|
|
||||||
"sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75",
|
|
||||||
"sha256:1cd13c99ce269b3ed80b417dcd591415d3372bcac067009b6e0f59c7d4015e65",
|
|
||||||
"sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e",
|
|
||||||
"sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a",
|
|
||||||
"sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e",
|
|
||||||
"sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25",
|
|
||||||
"sha256:2081580ebb843f759b9f617314a24ed5738c51d2aee65d31e02f6f7a2b97707a",
|
|
||||||
"sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe",
|
|
||||||
"sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b",
|
|
||||||
"sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91",
|
|
||||||
"sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592",
|
|
||||||
"sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187",
|
|
||||||
"sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c",
|
|
||||||
"sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1",
|
|
||||||
"sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94",
|
|
||||||
"sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba",
|
|
||||||
"sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb",
|
|
||||||
"sha256:3f4d46d8b35698056ec29bca21546e1551a205058ae1a181d871e278b0b28165",
|
|
||||||
"sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529",
|
|
||||||
"sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca",
|
|
||||||
"sha256:4647afc2f90d1ddd33441e5b0e85b16b12ddec4fca55f0d9671fef036ecca27c",
|
|
||||||
"sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6",
|
|
||||||
"sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c",
|
|
||||||
"sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0",
|
|
||||||
"sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743",
|
|
||||||
"sha256:61d028e90346df14fedc3d1e5441df818d095f3b87d286825dfcbd6459b7ef63",
|
|
||||||
"sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5",
|
|
||||||
"sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5",
|
|
||||||
"sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4",
|
|
||||||
"sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d",
|
|
||||||
"sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b",
|
|
||||||
"sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93",
|
|
||||||
"sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205",
|
|
||||||
"sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27",
|
|
||||||
"sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512",
|
|
||||||
"sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d",
|
|
||||||
"sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c",
|
|
||||||
"sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037",
|
|
||||||
"sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26",
|
|
||||||
"sha256:89472c9762729b5ae1ad974b777416bfda4ac5642423fa93bd57a09204712322",
|
|
||||||
"sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb",
|
|
||||||
"sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c",
|
|
||||||
"sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8",
|
|
||||||
"sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4",
|
|
||||||
"sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414",
|
|
||||||
"sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9",
|
|
||||||
"sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664",
|
|
||||||
"sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9",
|
|
||||||
"sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775",
|
|
||||||
"sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739",
|
|
||||||
"sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc",
|
|
||||||
"sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062",
|
|
||||||
"sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe",
|
|
||||||
"sha256:b882b3df248017dba09d6b16defe9b5c407fe32fc7c65a9c69798e6175601be9",
|
|
||||||
"sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92",
|
|
||||||
"sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5",
|
|
||||||
"sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13",
|
|
||||||
"sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d",
|
|
||||||
"sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26",
|
|
||||||
"sha256:cb527a79772e5ef98fb1d700678fe031e353e765d1ca2d409c92263c6d43e09f",
|
|
||||||
"sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495",
|
|
||||||
"sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b",
|
|
||||||
"sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6",
|
|
||||||
"sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c",
|
|
||||||
"sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef",
|
|
||||||
"sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5",
|
|
||||||
"sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18",
|
|
||||||
"sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad",
|
|
||||||
"sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3",
|
|
||||||
"sha256:de8dad4425a6ca6e4e5e297b27b5c824ecc7581910bf9aee86cb6835e6812aa7",
|
|
||||||
"sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5",
|
|
||||||
"sha256:e6e73b9e02893c764e7e8d5bb5ce277f1a009cd5243f8228f75f842bf937c534",
|
|
||||||
"sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49",
|
|
||||||
"sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2",
|
|
||||||
"sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5",
|
|
||||||
"sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453",
|
|
||||||
"sha256:fe562eb1a64e67dd297ccc4f5addea2501664954f2692b69a76449ec7913ecbf"
|
|
||||||
],
|
|
||||||
"markers": "python_version >= '3.9'",
|
|
||||||
"version": "==2.0.0"
|
|
||||||
},
|
|
||||||
"coloredlogs": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:612ee75c546f53e92e70049c9dbfcc18c935a2b9a53b66085ce9ef6a6e5c0934",
|
|
||||||
"sha256:7c991aa71a4577af2f82600d8f8f3a89f936baeaf9b50a9c197da014e5bf16b0"
|
|
||||||
],
|
|
||||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
|
|
||||||
"version": "==15.0.1"
|
|
||||||
},
|
|
||||||
"ddddocr": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:5991594d481d33ba0b136022e910f578d6d5b0ca536b44886591359622ab0c70",
|
|
||||||
"sha256:7c44b58ba7d7566d785c65b8526ec5b78efacd121e993dea4fda5f7966897428"
|
|
||||||
],
|
|
||||||
"index": "pypi",
|
|
||||||
"markers": "python_version >= '3.8'",
|
|
||||||
"version": "==1.0.6"
|
|
||||||
},
|
|
||||||
"flatbuffers": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:255538574d6cb6d0a79a17ec8bc0d30985913b87513a01cce8bcdb6b4c44d0e2",
|
|
||||||
"sha256:676f9fa62750bb50cf531b42a0a2a118ad8f7f797a511eda12881c016f093b12"
|
|
||||||
],
|
|
||||||
"version": "==25.9.23"
|
|
||||||
},
|
|
||||||
"h11": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1",
|
|
||||||
"sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"
|
|
||||||
],
|
|
||||||
"markers": "python_version >= '3.8'",
|
|
||||||
"version": "==0.16.0"
|
|
||||||
},
|
|
||||||
"humanfriendly": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:1697e1a8a8f550fd43c2865cd84542fc175a61dcb779b6fee18cf6b6ccba1477",
|
|
||||||
"sha256:6b0b831ce8f15f7300721aa49829fc4e83921a9a301cc7f606be6686a2288ddc"
|
|
||||||
],
|
|
||||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
|
|
||||||
"version": "==10.0"
|
|
||||||
},
|
|
||||||
"idna": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea",
|
|
||||||
"sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902"
|
|
||||||
],
|
|
||||||
"markers": "python_version >= '3.8'",
|
|
||||||
"version": "==3.11"
|
|
||||||
},
|
|
||||||
"mpmath": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:7a28eb2a9774d00c7bc92411c19a89209d5da7c4c9a9e227be8330a23a25b91f",
|
|
||||||
"sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c"
|
|
||||||
],
|
|
||||||
"version": "==1.3.0"
|
|
||||||
},
|
|
||||||
"numpy": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:035796aaaddfe2f9664b9a9372f089cfc88bd795a67bd1bfe15e6e770934cf64",
|
|
||||||
"sha256:043885b4f7e6e232d7df4f51ffdef8c36320ee9d5f227b380ea636722c7ed12e",
|
|
||||||
"sha256:04a69abe45b49c5955923cf2c407843d1c85013b424ae8a560bba16c92fe44a0",
|
|
||||||
"sha256:0f2bcc76f1e05e5ab58893407c63d90b2029908fa41f9f1cc51eecce936c3365",
|
|
||||||
"sha256:13b9062e4f5c7ee5c7e5be96f29ba71bc5a37fed3d1d77c37390ae00724d296d",
|
|
||||||
"sha256:15eea9f306b98e0be91eb344a94c0e630689ef302e10c2ce5f7e11905c704f9c",
|
|
||||||
"sha256:15fb27364ed84114438fff8aaf998c9e19adbeba08c0b75409f8c452a8692c52",
|
|
||||||
"sha256:1b219560ae2c1de48ead517d085bc2d05b9433f8e49d0955c82e8cd37bd7bf36",
|
|
||||||
"sha256:22758999b256b595cf0b1d102b133bb61866ba5ceecf15f759623b64c020c9ec",
|
|
||||||
"sha256:2ec646892819370cf3558f518797f16597b4e4669894a2ba712caccc9da53f1f",
|
|
||||||
"sha256:3634093d0b428e6c32c3a69b78e554f0cd20ee420dcad5a9f3b2a63762ce4197",
|
|
||||||
"sha256:36dc13af226aeab72b7abad501d370d606326a0029b9f435eacb3b8c94b8a8b7",
|
|
||||||
"sha256:3da3491cee49cf16157e70f607c03a217ea6647b1cea4819c4f48e53d49139b9",
|
|
||||||
"sha256:40cc556d5abbc54aabe2b1ae287042d7bdb80c08edede19f0c0afb36ae586f37",
|
|
||||||
"sha256:4121c5beb58a7f9e6dfdee612cb24f4df5cd4db6e8261d7f4d7450a997a65d6a",
|
|
||||||
"sha256:4635239814149e06e2cb9db3dd584b2fa64316c96f10656983b8026a82e6e4db",
|
|
||||||
"sha256:4c01835e718bcebe80394fd0ac66c07cbb90147ebbdad3dcecd3f25de2ae7e2c",
|
|
||||||
"sha256:4ee6a571d1e4f0ea6d5f22d6e5fbd6ed1dc2b18542848e1e7301bd190500c9d7",
|
|
||||||
"sha256:56209416e81a7893036eea03abcb91c130643eb14233b2515c90dcac963fe99d",
|
|
||||||
"sha256:5e199c087e2aa71c8f9ce1cb7a8e10677dc12457e7cc1be4798632da37c3e86e",
|
|
||||||
"sha256:62b2198c438058a20b6704351b35a1d7db881812d8512d67a69c9de1f18ca05f",
|
|
||||||
"sha256:64c5825affc76942973a70acf438a8ab618dbd692b84cd5ec40a0a0509edc09a",
|
|
||||||
"sha256:65611ecbb00ac9846efe04db15cbe6186f562f6bb7e5e05f077e53a599225d16",
|
|
||||||
"sha256:6d34ed9db9e6395bb6cd33286035f73a59b058169733a9db9f85e650b88df37e",
|
|
||||||
"sha256:6d9cd732068e8288dbe2717177320723ccec4fb064123f0caf9bbd90ab5be868",
|
|
||||||
"sha256:6e274603039f924c0fe5cb73438fa9246699c78a6df1bd3decef9ae592ae1c05",
|
|
||||||
"sha256:77b84453f3adcb994ddbd0d1c5d11db2d6bda1a2b7fd5ac5bd4649d6f5dc682e",
|
|
||||||
"sha256:7c26b0b2bf58009ed1f38a641f3db4be8d960a417ca96d14e5b06df1506d41ff",
|
|
||||||
"sha256:7fd09cc5d65bda1e79432859c40978010622112e9194e581e3415a3eccc7f43f",
|
|
||||||
"sha256:817e719a868f0dacde4abdfc5c1910b301877970195db9ab6a5e2c4bd5b121f7",
|
|
||||||
"sha256:81b3a59793523e552c4a96109dde028aa4448ae06ccac5a76ff6532a85558a7f",
|
|
||||||
"sha256:81c3e6d8c97295a7360d367f9f8553973651b76907988bb6066376bc2252f24e",
|
|
||||||
"sha256:838f045478638b26c375ee96ea89464d38428c69170360b23a1a50fa4baa3562",
|
|
||||||
"sha256:84f01a4d18b2cc4ade1814a08e5f3c907b079c847051d720fad15ce37aa930b6",
|
|
||||||
"sha256:85597b2d25ddf655495e2363fe044b0ae999b75bc4d630dc0d886484b03a5eb0",
|
|
||||||
"sha256:85d9fb2d8cd998c84d13a79a09cc0c1091648e848e4e6249b0ccd7f6b487fa26",
|
|
||||||
"sha256:85e071da78d92a214212cacea81c6da557cab307f2c34b5f85b628e94803f9c0",
|
|
||||||
"sha256:863e3b5f4d9915aaf1b8ec79ae560ad21f0b8d5e3adc31e73126491bb86dee1d",
|
|
||||||
"sha256:86966db35c4040fdca64f0816a1c1dd8dbd027d90fca5a57e00e1ca4cd41b879",
|
|
||||||
"sha256:8ab1c5f5ee40d6e01cbe96de5863e39b215a4d24e7d007cad56c7184fdf4aeef",
|
|
||||||
"sha256:8b5a9a39c45d852b62693d9b3f3e0fe052541f804296ff401a72a1b60edafb29",
|
|
||||||
"sha256:8dc20bde86802df2ed8397a08d793da0ad7a5fd4ea3ac85d757bf5dd4ad7c252",
|
|
||||||
"sha256:957e92defe6c08211eb77902253b14fe5b480ebc5112bc741fd5e9cd0608f847",
|
|
||||||
"sha256:962064de37b9aef801d33bc579690f8bfe6c5e70e29b61783f60bcba838a14d6",
|
|
||||||
"sha256:985f1e46358f06c2a09921e8921e2c98168ed4ae12ccd6e5e87a4f1857923f32",
|
|
||||||
"sha256:9984bd645a8db6ca15d850ff996856d8762c51a2239225288f08f9050ca240a0",
|
|
||||||
"sha256:9cb177bc55b010b19798dc5497d540dea67fd13a8d9e882b2dae71de0cf09eb3",
|
|
||||||
"sha256:9d729d60f8d53a7361707f4b68a9663c968882dd4f09e0d58c044c8bf5faee7b",
|
|
||||||
"sha256:a13fc473b6db0be619e45f11f9e81260f7302f8d180c49a22b6e6120022596b3",
|
|
||||||
"sha256:a49d797192a8d950ca59ee2d0337a4d804f713bb5c3c50e8db26d49666e351dc",
|
|
||||||
"sha256:a700a4031bc0fd6936e78a752eefb79092cecad2599ea9c8039c548bc097f9bc",
|
|
||||||
"sha256:a7b2f9a18b5ff9824a6af80de4f37f4ec3c2aab05ef08f51c77a093f5b89adda",
|
|
||||||
"sha256:a7d018bfedb375a8d979ac758b120ba846a7fe764911a64465fd87b8729f4a6a",
|
|
||||||
"sha256:b6c231c9c2fadbae4011ca5e7e83e12dc4a5072f1a1d85a0a7b3ed754d145a40",
|
|
||||||
"sha256:bafa7d87d4c99752d07815ed7a2c0964f8ab311eb8168f41b910bd01d15b6032",
|
|
||||||
"sha256:bd0c630cf256b0a7fd9d0a11c9413b42fef5101219ce6ed5a09624f5a65392c7",
|
|
||||||
"sha256:c090d4860032b857d94144d1a9976b8e36709e40386db289aaf6672de2a81966",
|
|
||||||
"sha256:c2f91f496a87235c6aaf6d3f3d89b17dba64996abadccb289f48456cff931ca9",
|
|
||||||
"sha256:d149aee5c72176d9ddbc6803aef9c0f6d2ceeea7626574fc68518da5476fa346",
|
|
||||||
"sha256:d5e081bc082825f8b139f9e9fe42942cb4054524598aaeb177ff476cc76d09d2",
|
|
||||||
"sha256:d7315ed1dab0286adca467377c8381cd748f3dc92235f22a7dfc42745644a96a",
|
|
||||||
"sha256:dabc42f9c6577bcc13001b8810d300fe814b4cfbe8a92c873f269484594f9786",
|
|
||||||
"sha256:e1708fac43ef8b419c975926ce1eaf793b0c13b7356cfab6ab0dc34c0a02ac0f",
|
|
||||||
"sha256:e73d63fd04e3a9d6bc187f5455d81abfad05660b212c8804bf3b407e984cd2bc",
|
|
||||||
"sha256:e78aecd2800b32e8347ce49316d3eaf04aed849cd5b38e0af39f829a4e59f5eb",
|
|
||||||
"sha256:e8370eb6925bb8c1c4264fec52b0384b44f675f191df91cbe0140ec9f0955646",
|
|
||||||
"sha256:ecb63014bb7f4ce653f8be7f1df8cbc6093a5a2811211770f6606cc92b5a78fd",
|
|
||||||
"sha256:ed759bf7a70342f7817d88376eb7142fab9fef8320d6019ef87fae05a99874e1",
|
|
||||||
"sha256:ef1b5a3e808bc40827b5fa2c8196151a4c5abe110e1726949d7abddfe5c7ae11",
|
|
||||||
"sha256:f77e5b3d3da652b474cc80a14084927a5e86a5eccf54ca8ca5cbd697bf7f2667",
|
|
||||||
"sha256:faba246fb30ea2a526c2e9645f61612341de1a83fb1e0c5edf4ddda5a9c10996",
|
|
||||||
"sha256:fc8a63918b04b8571789688b2780ab2b4a33ab44bfe8ccea36d3eba51228c953",
|
|
||||||
"sha256:fdebe771ca06bb8d6abce84e51dca9f7921fe6ad34a0c914541b063e9a68928b",
|
|
||||||
"sha256:fea80f4f4cf83b54c3a051f2f727870ee51e22f0248d3114b8e755d160b38cfb"
|
|
||||||
],
|
|
||||||
"markers": "python_version >= '3.11'",
|
|
||||||
"version": "==2.3.4"
|
|
||||||
},
|
|
||||||
"onnxruntime": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:0be6a37a45e6719db5120e9986fcd30ea205ac8103fd1fb74b6c33348327a0cc",
|
|
||||||
"sha256:0f9b4ae77f8e3c9bee50c27bc1beede83f786fe1d52e99ac85aa8d65a01e9b77",
|
|
||||||
"sha256:162f4ca894ec3de1a6fd53589e511e06ecdc3ff646849b62a9da7489dee9ce95",
|
|
||||||
"sha256:1f9cc0a55349c584f083c1c076e611a7c35d5b867d5d6e6d6c823bf821978088",
|
|
||||||
"sha256:218295a8acae83905f6f1aed8cacb8e3eb3bd7513a13fe4ba3b2664a19fc4a6b",
|
|
||||||
"sha256:25de5214923ce941a3523739d34a520aac30f21e631de53bba9174dc9c004435",
|
|
||||||
"sha256:2ff531ad8496281b4297f32b83b01cdd719617e2351ffe0dba5684fb283afa1f",
|
|
||||||
"sha256:45d127d6e1e9b99d1ebeae9bcd8f98617a812f53f46699eafeb976275744826b",
|
|
||||||
"sha256:4ca88747e708e5c67337b0f65eed4b7d0dd70d22ac332038c9fc4635760018f7",
|
|
||||||
"sha256:6f91d2c9b0965e86827a5ba01531d5b669770b01775b23199565d6c1f136616c",
|
|
||||||
"sha256:76ff670550dc23e58ea9bc53b5149b99a44e63b34b524f7b8547469aaa0dcb8c",
|
|
||||||
"sha256:87d8b6eaf0fbeb6835a60a4265fde7a3b60157cf1b2764773ac47237b4d48612",
|
|
||||||
"sha256:8bace4e0d46480fbeeb7bbe1ffe1f080e6663a42d1086ff95c1551f2d39e7872",
|
|
||||||
"sha256:8f7d1fe034090a1e371b7f3ca9d3ccae2fabae8c1d8844fb7371d1ea38e8e8d2",
|
|
||||||
"sha256:902c756d8b633ce0dedd889b7c08459433fbcf35e9c38d1c03ddc020f0648c6e",
|
|
||||||
"sha256:9d2385e774f46ac38f02b3a91a91e30263d41b2f1f4f26ae34805b2a9ddef466",
|
|
||||||
"sha256:a7730122afe186a784660f6ec5807138bf9d792fa1df76556b27307ea9ebcbe3",
|
|
||||||
"sha256:b28740f4ecef1738ea8f807461dd541b8287d5650b5be33bca7b474e3cbd1f36",
|
|
||||||
"sha256:b8f029a6b98d3cf5be564d52802bb50a8489ab73409fa9db0bf583eabb7c2321",
|
|
||||||
"sha256:bbfd2fca76c855317568c1b36a885ddea2272c13cb0e395002c402f2360429a6",
|
|
||||||
"sha256:da44b99206e77734c5819aa2142c69e64f3b46edc3bd314f6a45a932defc0b3e",
|
|
||||||
"sha256:e2b9233c4947907fd1818d0e581c049c41ccc39b2856cc942ff6d26317cee145"
|
|
||||||
],
|
|
||||||
"markers": "python_version >= '3.10'",
|
|
||||||
"version": "==1.23.2"
|
|
||||||
},
|
|
||||||
"outcome": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:9dcf02e65f2971b80047b377468e72a268e15c0af3cf1238e6ff14f7f91143b8",
|
|
||||||
"sha256:e771c5ce06d1415e356078d3bdd68523f284b4ce5419828922b6871e65eda82b"
|
|
||||||
],
|
|
||||||
"markers": "python_version >= '3.7'",
|
|
||||||
"version": "==1.3.0.post0"
|
|
||||||
},
|
|
||||||
"packaging": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484",
|
|
||||||
"sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"
|
|
||||||
],
|
|
||||||
"markers": "python_version >= '3.8'",
|
|
||||||
"version": "==25.0"
|
|
||||||
},
|
|
||||||
"pefile": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:82e6114004b3d6911c77c3953e3838654b04511b8b66e8583db70c65998017dc",
|
|
||||||
"sha256:da185cd2af68c08a6cd4481f7325ed600a88f6a813bad9dea07ab3ef73d8d8d6"
|
|
||||||
],
|
|
||||||
"markers": "python_full_version >= '3.6.0'",
|
|
||||||
"version": "==2023.2.7"
|
|
||||||
},
|
|
||||||
"pillow": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:0869154a2d0546545cde61d1789a6524319fc1897d9ee31218eae7a60ccc5643",
|
|
||||||
"sha256:09f2d0abef9e4e2f349305a4f8cc784a8a6c2f58a8c4892eea13b10a943bd26e",
|
|
||||||
"sha256:0b817e7035ea7f6b942c13aa03bb554fc44fea70838ea21f8eb31c638326584e",
|
|
||||||
"sha256:0fd00cac9c03256c8b2ff58f162ebcd2587ad3e1f2e397eab718c47e24d231cc",
|
|
||||||
"sha256:110486b79f2d112cf6add83b28b627e369219388f64ef2f960fef9ebaf54c642",
|
|
||||||
"sha256:1979f4566bb96c1e50a62d9831e2ea2d1211761e5662afc545fa766f996632f6",
|
|
||||||
"sha256:1ac11e8ea4f611c3c0147424eae514028b5e9077dd99ab91e1bd7bc33ff145e1",
|
|
||||||
"sha256:1b1b133e6e16105f524a8dec491e0586d072948ce15c9b914e41cdadd209052b",
|
|
||||||
"sha256:1ee80a59f6ce048ae13cda1abf7fbd2a34ab9ee7d401c46be3ca685d1999a399",
|
|
||||||
"sha256:21f241bdd5080a15bc86d3466a9f6074a9c2c2b314100dd896ac81ee6db2f1ba",
|
|
||||||
"sha256:266cd5f2b63ff316d5a1bba46268e603c9caf5606d44f38c2873c380950576ad",
|
|
||||||
"sha256:26d9f7d2b604cd23aba3e9faf795787456ac25634d82cd060556998e39c6fa47",
|
|
||||||
"sha256:27f95b12453d165099c84f8a8bfdfd46b9e4bda9e0e4b65f0635430027f55739",
|
|
||||||
"sha256:2c54c1a783d6d60595d3514f0efe9b37c8808746a66920315bfd34a938d7994b",
|
|
||||||
"sha256:2fa5f0b6716fc88f11380b88b31fe591a06c6315e955c096c35715788b339e3f",
|
|
||||||
"sha256:32ed80ea8a90ee3e6fa08c21e2e091bba6eda8eccc83dbc34c95169507a91f10",
|
|
||||||
"sha256:3830c769decf88f1289680a59d4f4c46c72573446352e2befec9a8512104fa52",
|
|
||||||
"sha256:38df9b4bfd3db902c9c2bd369bcacaf9d935b2fff73709429d95cc41554f7b3d",
|
|
||||||
"sha256:3adfb466bbc544b926d50fe8f4a4e6abd8c6bffd28a26177594e6e9b2b76572b",
|
|
||||||
"sha256:3e42edad50b6909089750e65c91aa09aaf1e0a71310d383f11321b27c224ed8a",
|
|
||||||
"sha256:4078242472387600b2ce8d93ade8899c12bf33fa89e55ec89fe126e9d6d5d9e9",
|
|
||||||
"sha256:455247ac8a4cfb7b9bc45b7e432d10421aea9fc2e74d285ba4072688a74c2e9d",
|
|
||||||
"sha256:4cc6b3b2efff105c6a1656cfe59da4fdde2cda9af1c5e0b58529b24525d0a098",
|
|
||||||
"sha256:4cf7fed4b4580601c4345ceb5d4cbf5a980d030fd5ad07c4d2ec589f95f09905",
|
|
||||||
"sha256:5193fde9a5f23c331ea26d0cf171fbf67e3f247585f50c08b3e205c7aeb4589b",
|
|
||||||
"sha256:5269cc1caeedb67e6f7269a42014f381f45e2e7cd42d834ede3c703a1d915fe3",
|
|
||||||
"sha256:53561a4ddc36facb432fae7a9d8afbfaf94795414f5cdc5fc52f28c1dca90371",
|
|
||||||
"sha256:55f818bd74fe2f11d4d7cbc65880a843c4075e0ac7226bc1a23261dbea531953",
|
|
||||||
"sha256:58eea5ebe51504057dd95c5b77d21700b77615ab0243d8152793dc00eb4faf01",
|
|
||||||
"sha256:5d5c411a8eaa2299322b647cd932586b1427367fd3184ffbb8f7a219ea2041ca",
|
|
||||||
"sha256:6846bd2d116ff42cba6b646edf5bf61d37e5cbd256425fa089fee4ff5c07a99e",
|
|
||||||
"sha256:6ace95230bfb7cd79ef66caa064bbe2f2a1e63d93471c3a2e1f1348d9f22d6b7",
|
|
||||||
"sha256:6e51b71417049ad6ab14c49608b4a24d8fb3fe605e5dfabfe523b58064dc3d27",
|
|
||||||
"sha256:71db6b4c1653045dacc1585c1b0d184004f0d7e694c7b34ac165ca70c0838082",
|
|
||||||
"sha256:7438839e9e053ef79f7112c881cef684013855016f928b168b81ed5835f3e75e",
|
|
||||||
"sha256:759de84a33be3b178a64c8ba28ad5c135900359e85fb662bc6e403ad4407791d",
|
|
||||||
"sha256:792a2c0be4dcc18af9d4a2dfd8a11a17d5e25274a1062b0ec1c2d79c76f3e7f8",
|
|
||||||
"sha256:7d87ef5795da03d742bf49439f9ca4d027cde49c82c5371ba52464aee266699a",
|
|
||||||
"sha256:7dfb439562f234f7d57b1ac6bc8fe7f838a4bd49c79230e0f6a1da93e82f1fad",
|
|
||||||
"sha256:7fa22993bac7b77b78cae22bad1e2a987ddf0d9015c63358032f84a53f23cdc3",
|
|
||||||
"sha256:805ebf596939e48dbb2e4922a1d3852cfc25c38160751ce02da93058b48d252a",
|
|
||||||
"sha256:82240051c6ca513c616f7f9da06e871f61bfd7805f566275841af15015b8f98d",
|
|
||||||
"sha256:87d4f8125c9988bfbed67af47dd7a953e2fc7b0cc1e7800ec6d2080d490bb353",
|
|
||||||
"sha256:8d8ca2b210ada074d57fcee40c30446c9562e542fc46aedc19baf758a93532ee",
|
|
||||||
"sha256:8dc232e39d409036af549c86f24aed8273a40ffa459981146829a324e0848b4b",
|
|
||||||
"sha256:90387104ee8400a7b4598253b4c406f8958f59fcf983a6cea2b50d59f7d63d0b",
|
|
||||||
"sha256:905b0365b210c73afb0ebe9101a32572152dfd1c144c7e28968a331b9217b94a",
|
|
||||||
"sha256:99353a06902c2e43b43e8ff74ee65a7d90307d82370604746738a1e0661ccca7",
|
|
||||||
"sha256:99a7f72fb6249302aa62245680754862a44179b545ded638cf1fef59befb57ef",
|
|
||||||
"sha256:9f0b04c6b8584c2c193babcccc908b38ed29524b29dd464bc8801bf10d746a3a",
|
|
||||||
"sha256:9fe611163f6303d1619bbcb653540a4d60f9e55e622d60a3108be0d5b441017a",
|
|
||||||
"sha256:a3475b96f5908b3b16c47533daaa87380c491357d197564e0ba34ae75c0f3257",
|
|
||||||
"sha256:a6597ff2b61d121172f5844b53f21467f7082f5fb385a9a29c01414463f93b07",
|
|
||||||
"sha256:a7921c5a6d31b3d756ec980f2f47c0cfdbce0fc48c22a39347a895f41f4a6ea4",
|
|
||||||
"sha256:aa5129de4e174daccbc59d0a3b6d20eaf24417d59851c07ebb37aeb02947987c",
|
|
||||||
"sha256:aeaefa96c768fc66818730b952a862235d68825c178f1b3ffd4efd7ad2edcb7c",
|
|
||||||
"sha256:afbefa430092f71a9593a99ab6a4e7538bc9eabbf7bf94f91510d3503943edc4",
|
|
||||||
"sha256:aff9e4d82d082ff9513bdd6acd4f5bd359f5b2c870907d2b0a9c5e10d40c88fe",
|
|
||||||
"sha256:b22bd8c974942477156be55a768f7aa37c46904c175be4e158b6a86e3a6b7ca8",
|
|
||||||
"sha256:b290fd8aa38422444d4b50d579de197557f182ef1068b75f5aa8558638b8d0a5",
|
|
||||||
"sha256:b2e4b27a6e15b04832fe9bf292b94b5ca156016bbc1ea9c2c20098a0320d6cf6",
|
|
||||||
"sha256:b583dc9070312190192631373c6c8ed277254aa6e6084b74bdd0a6d3b221608e",
|
|
||||||
"sha256:b87843e225e74576437fd5b6a4c2205d422754f84a06942cfaf1dc32243e45a8",
|
|
||||||
"sha256:bc91a56697869546d1b8f0a3ff35224557ae7f881050e99f615e0119bf934b4e",
|
|
||||||
"sha256:bd87e140e45399c818fac4247880b9ce719e4783d767e030a883a970be632275",
|
|
||||||
"sha256:bde737cff1a975b70652b62d626f7785e0480918dece11e8fef3c0cf057351c3",
|
|
||||||
"sha256:bdee52571a343d721fb2eb3b090a82d959ff37fc631e3f70422e0c2e029f3e76",
|
|
||||||
"sha256:bee2a6db3a7242ea309aa7ee8e2780726fed67ff4e5b40169f2c940e7eb09227",
|
|
||||||
"sha256:beeae3f27f62308f1ddbcfb0690bf44b10732f2ef43758f169d5e9303165d3f9",
|
|
||||||
"sha256:c50f36a62a22d350c96e49ad02d0da41dbd17ddc2e29750dbdba4323f85eb4a5",
|
|
||||||
"sha256:c607c90ba67533e1b2355b821fef6764d1dd2cbe26b8c1005ae84f7aea25ff79",
|
|
||||||
"sha256:c7b2a63fd6d5246349f3d3f37b14430d73ee7e8173154461785e43036ffa96ca",
|
|
||||||
"sha256:c828a1ae702fc712978bda0320ba1b9893d99be0badf2647f693cc01cf0f04fa",
|
|
||||||
"sha256:c85de1136429c524e55cfa4e033b4a7940ac5c8ee4d9401cc2d1bf48154bbc7b",
|
|
||||||
"sha256:c98fa880d695de164b4135a52fd2e9cd7b7c90a9d8ac5e9e443a24a95ef9248e",
|
|
||||||
"sha256:cae81479f77420d217def5f54b5b9d279804d17e982e0f2fa19b1d1e14ab5197",
|
|
||||||
"sha256:d034140032870024e6b9892c692fe2968493790dd57208b2c37e3fb35f6df3ab",
|
|
||||||
"sha256:d120c38a42c234dc9a8c5de7ceaaf899cf33561956acb4941653f8bdc657aa79",
|
|
||||||
"sha256:d4827615da15cd59784ce39d3388275ec093ae3ee8d7f0c089b76fa87af756c2",
|
|
||||||
"sha256:d49e2314c373f4c2b39446fb1a45ed333c850e09d0c59ac79b72eb3b95397363",
|
|
||||||
"sha256:d52610d51e265a51518692045e372a4c363056130d922a7351429ac9f27e70b0",
|
|
||||||
"sha256:d64317d2587c70324b79861babb9c09f71fbb780bad212018874b2c013d8600e",
|
|
||||||
"sha256:d77153e14b709fd8b8af6f66a3afbb9ed6e9fc5ccf0b6b7e1ced7b036a228782",
|
|
||||||
"sha256:d7e091d464ac59d2c7ad8e7e08105eaf9dafbc3883fd7265ffccc2baad6ac925",
|
|
||||||
"sha256:dd333073e0cacdc3089525c7df7d39b211bcdf31fc2824e49d01c6b6187b07d0",
|
|
||||||
"sha256:e5d8efac84c9afcb40914ab49ba063d94f5dbdf5066db4482c66a992f47a3a3b",
|
|
||||||
"sha256:f135c702ac42262573fe9714dfe99c944b4ba307af5eb507abef1667e2cbbced",
|
|
||||||
"sha256:f13711b1a5ba512d647a0e4ba79280d3a9a045aaf7e0cc6fbe96b91d4cdf6b0c",
|
|
||||||
"sha256:f4f1231b7dec408e8670264ce63e9c71409d9583dd21d32c163e25213ee2a344",
|
|
||||||
"sha256:fa3ed2a29a9e9d2d488b4da81dcb54720ac3104a20bf0bd273f1e4648aff5af9",
|
|
||||||
"sha256:fb3096c30df99fd01c7bf8e544f392103d0795b9f98ba71a8054bcbf56b255f1"
|
|
||||||
],
|
|
||||||
"markers": "python_version >= '3.10'",
|
|
||||||
"version": "==12.0.0"
|
|
||||||
},
|
|
||||||
"protobuf": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:140303d5c8d2037730c548f8c7b93b20bb1dc301be280c378b82b8894589c954",
|
|
||||||
"sha256:25c9e1963c6734448ea2d308cfa610e692b801304ba0908d7bfa564ac5132995",
|
|
||||||
"sha256:35be49fd3f4fefa4e6e2aacc35e8b837d6703c37a2168a55ac21e9b1bc7559ef",
|
|
||||||
"sha256:905b07a65f1a4b72412314082c7dbfae91a9e8b68a0cc1577515f8df58ecf455",
|
|
||||||
"sha256:9a031d10f703f03768f2743a1c403af050b6ae1f3480e9c140f39c45f81b13ee",
|
|
||||||
"sha256:c963e86c3655af3a917962c9619e1a6b9670540351d7af9439d06064e3317cc9",
|
|
||||||
"sha256:cd33a8e38ea3e39df66e1bbc462b076d6e5ba3a4ebbde58219d777223a7873d3",
|
|
||||||
"sha256:d6101ded078042a8f17959eccd9236fb7a9ca20d3b0098bbcb91533a5680d035",
|
|
||||||
"sha256:e0697ece353e6239b90ee43a9231318302ad8353c70e6e45499fa52396debf90",
|
|
||||||
"sha256:e0a1715e4f27355afd9570f3ea369735afc853a6c3951a6afe1f80d8569ad298"
|
|
||||||
],
|
|
||||||
"markers": "python_version >= '3.9'",
|
|
||||||
"version": "==6.33.0"
|
|
||||||
},
|
|
||||||
"pycparser": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2",
|
|
||||||
"sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934"
|
|
||||||
],
|
|
||||||
"markers": "python_version >= '3.8'",
|
|
||||||
"version": "==2.23"
|
|
||||||
},
|
|
||||||
"pyinstaller": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:0a48f55b85ff60f83169e10050f2759019cf1d06773ad1c4da3a411cd8751058",
|
|
||||||
"sha256:53559fe1e041a234f2b4dcc3288ea8bdd57f7cad8a6644e422c27bb407f3edef",
|
|
||||||
"sha256:6d5f8617f3650ff9ef893e2ab4ddbf3c0d23d0c602ef74b5df8fbef4607840c8",
|
|
||||||
"sha256:73ba72e04fcece92e32518bbb1e1fb5ac2892677943dfdff38e01a06e8742851",
|
|
||||||
"sha256:7fd1c785219a87ca747c21fa92f561b0d2926a7edc06d0a0fe37f3736e00bd7a",
|
|
||||||
"sha256:b1752488248f7899281b17ca3238eefb5410521291371a686a4f5830f29f52b3",
|
|
||||||
"sha256:b756ddb9007b8141c5476b553351f9d97559b8af5d07f9460869bfae02be26b0",
|
|
||||||
"sha256:ba618a61627ee674d6d68e5de084ba17c707b59a4f2a856084b3999bdffbd3f0",
|
|
||||||
"sha256:bc10eb1a787f99fea613509f55b902fbd2d8b73ff5f51ff245ea29a481d97d41",
|
|
||||||
"sha256:c8b7ef536711617e12fef4673806198872033fa06fa92326ad7fd1d84a9fa454",
|
|
||||||
"sha256:d0af8a401de792c233c32c44b16d065ca9ab8262ee0c906835c12bdebc992a64",
|
|
||||||
"sha256:d1ebf84d02c51fed19b82a8abb4df536923abd55bb684d694e1356e4ae2a0ce5"
|
|
||||||
],
|
|
||||||
"index": "pypi",
|
|
||||||
"markers": "python_version < '3.15' and python_version >= '3.8'",
|
|
||||||
"version": "==6.16.0"
|
|
||||||
},
|
|
||||||
"pyinstaller-hooks-contrib": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:56e972bdaad4e9af767ed47d132362d162112260cbe488c9da7fee01f228a5a6",
|
|
||||||
"sha256:ccbfaa49399ef6b18486a165810155e5a8d4c59b41f20dc5da81af7482aaf038"
|
|
||||||
],
|
|
||||||
"markers": "python_version >= '3.8'",
|
|
||||||
"version": "==2025.9"
|
|
||||||
},
|
|
||||||
"pyreadline3": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:8d57d53039a1c75adba8e50dd3d992b28143480816187ea5efbd5c78e6c885b7",
|
|
||||||
"sha256:eaf8e6cc3c49bcccf145fc6067ba8643d1df34d604a1ec0eccbf7a18e6d3fae6"
|
|
||||||
],
|
|
||||||
"markers": "python_version >= '3.8'",
|
|
||||||
"version": "==3.5.4"
|
|
||||||
},
|
|
||||||
"pyside6": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:4b709bdeeb89d386059343a5a706fc185cee37b517bda44c7d6b64d5fdaf3339",
|
|
||||||
"sha256:70a8bcc73ea8d6baab70bba311eac77b9a1d31f658d0b418e15eb6ea36c97e6f",
|
|
||||||
"sha256:9f402f883e640048fab246d36e298a5e16df9b18ba2e8c519877e472d3602820",
|
|
||||||
"sha256:ae8c3c8339cd7c3c9faa7cc5c52670dcc8662ccf4b63a6fed61c6345b90c4c01",
|
|
||||||
"sha256:c2cbc5dc2a164e3c7c51b3435e24203e90e5edd518c865466afccbd2e5872bb0"
|
|
||||||
],
|
|
||||||
"index": "pypi",
|
|
||||||
"markers": "python_version < '3.14' and python_version >= '3.9'",
|
|
||||||
"version": "==6.10.0"
|
|
||||||
},
|
|
||||||
"pyside6-addons": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:08d4ed46c4c9a353a9eb84134678f8fdd4ce17fb8cce2b3686172a7575025464",
|
|
||||||
"sha256:15d32229d681be0bba1b936c4a300da43d01e1917ada5b57f9e03a387c245ab0",
|
|
||||||
"sha256:88e61e21ee4643cdd9efb39ec52f4dc1ac74c0b45c5b7fa453d03c094f0a8a5c",
|
|
||||||
"sha256:92536427413f3b6557cf53f1a515cd766725ee46a170aff57ad2ff1dfce0ffb1",
|
|
||||||
"sha256:99d93a32c17c5f6d797c3b90dd58f2a8bae13abde81e85802c34ceafaee11859"
|
|
||||||
],
|
|
||||||
"markers": "python_version < '3.14' and python_version >= '3.9'",
|
|
||||||
"version": "==6.10.0"
|
|
||||||
},
|
|
||||||
"pyside6-essentials": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:003e871effe1f3e5b876bde715c15a780d876682005a6e989d89f48b8b93e93a",
|
|
||||||
"sha256:1d5e013a8698e37ab8ef360e6960794eb5ef20832a8d562e649b8c5a0574b2d8",
|
|
||||||
"sha256:6dd0936394cb14da2fd8e869899f5e0925a738b1c8d74c2f22503720ea363fb1",
|
|
||||||
"sha256:b1dd0864f0577a448fb44426b91cafff7ee7cccd1782ba66491e1c668033f998",
|
|
||||||
"sha256:fc167eb211dd1580e20ba90d299e74898e7a5a1306d832421e879641fc03b6fe"
|
|
||||||
],
|
|
||||||
"markers": "python_version < '3.14' and python_version >= '3.9'",
|
|
||||||
"version": "==6.10.0"
|
|
||||||
},
|
|
||||||
"pysocks": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:08e69f092cc6dbe92a0fdd16eeb9b9ffbc13cadfe5ca4c7bd92ffb078b293299",
|
|
||||||
"sha256:2725bd0a9925919b9b51739eea5f9e2bae91e83288108a9ad338b2e3a4435ee5",
|
|
||||||
"sha256:3f8804571ebe159c380ac6de37643bb4685970655d3bba243530d6558b799aa0"
|
|
||||||
],
|
|
||||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
|
|
||||||
"version": "==1.7.1"
|
|
||||||
},
|
|
||||||
"pywin32-ctypes": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8",
|
|
||||||
"sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755"
|
|
||||||
],
|
|
||||||
"markers": "python_version >= '3.6'",
|
|
||||||
"version": "==0.2.3"
|
|
||||||
},
|
|
||||||
"selenium": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:c117af6727859d50f622d6d0785b945c5db3e28a45ec12ad85cee2e7cc84fc4c",
|
|
||||||
"sha256:ed47563f188130a6fd486b327ca7ba48c5b11fb900e07d6457befdde320e35fd"
|
|
||||||
],
|
|
||||||
"index": "pypi",
|
|
||||||
"markers": "python_version >= '3.10'",
|
|
||||||
"version": "==4.38.0"
|
|
||||||
},
|
|
||||||
"setuptools": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922",
|
|
||||||
"sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c"
|
|
||||||
],
|
|
||||||
"markers": "python_version >= '3.9'",
|
|
||||||
"version": "==80.9.0"
|
|
||||||
},
|
|
||||||
"shiboken6": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:0bc5631c1bf150cbef768a17f5f289aae1cb4db6c6b0c19b2421394e27783717",
|
|
||||||
"sha256:7a5f5f400ebfb3a13616030815708289c2154e701a60b9db7833b843e0bee543",
|
|
||||||
"sha256:b01377e68d14132360efb0f4b7233006d26aa8ae9bb50edf00960c2a5f52d148",
|
|
||||||
"sha256:dfc4beab5fec7dbbebbb418f3bf99af865d6953aa0795435563d4cbb82093b61",
|
|
||||||
"sha256:e612734da515d683696980107cdc0396a3ae0f07b059f0f422ec8a2333810234"
|
|
||||||
],
|
|
||||||
"markers": "python_version < '3.14' and python_version >= '3.9'",
|
|
||||||
"version": "==6.10.0"
|
|
||||||
},
|
|
||||||
"sniffio": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2",
|
|
||||||
"sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"
|
|
||||||
],
|
|
||||||
"markers": "python_version >= '3.7'",
|
|
||||||
"version": "==1.3.1"
|
|
||||||
},
|
|
||||||
"sortedcontainers": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88",
|
|
||||||
"sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0"
|
|
||||||
],
|
|
||||||
"version": "==2.4.0"
|
|
||||||
},
|
|
||||||
"sympy": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:d3d3fe8df1e5a0b42f0e7bdf50541697dbe7d23746e894990c030e2b05e72517",
|
|
||||||
"sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5"
|
|
||||||
],
|
|
||||||
"markers": "python_version >= '3.9'",
|
|
||||||
"version": "==1.14.0"
|
|
||||||
},
|
|
||||||
"trio": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:150f29ec923bcd51231e1d4c71c7006e65247d68759dd1c19af4ea815a25806b",
|
|
||||||
"sha256:4ab65984ef8370b79a76659ec87aa3a30c5c7c83ff250b4de88c29a8ab6123c5"
|
|
||||||
],
|
|
||||||
"markers": "python_version >= '3.10'",
|
|
||||||
"version": "==0.32.0"
|
|
||||||
},
|
|
||||||
"trio-websocket": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:22c72c436f3d1e264d0910a3951934798dcc5b00ae56fc4ee079d46c7cf20fae",
|
|
||||||
"sha256:df605665f1db533f4a386c94525870851096a223adcb97f72a07e8b4beba45b6"
|
|
||||||
],
|
|
||||||
"markers": "python_version >= '3.8'",
|
|
||||||
"version": "==0.12.2"
|
|
||||||
},
|
|
||||||
"typing-extensions": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466",
|
|
||||||
"sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"
|
|
||||||
],
|
|
||||||
"markers": "python_version >= '3.9'",
|
|
||||||
"version": "==4.15.0"
|
|
||||||
},
|
|
||||||
"urllib3": {
|
|
||||||
"extras": [
|
|
||||||
"socks"
|
|
||||||
],
|
|
||||||
"hashes": [
|
|
||||||
"sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760",
|
|
||||||
"sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc"
|
|
||||||
],
|
|
||||||
"markers": "python_version >= '3.9'",
|
|
||||||
"version": "==2.5.0"
|
|
||||||
},
|
|
||||||
"websocket-client": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:9e813624b6eb619999a97dc7958469217c3176312b3a16a4bd1bc7e08a46ec98",
|
|
||||||
"sha256:af248a825037ef591efbf6ed20cc5faa03d3b47b9e5a2230a529eeee1c1fc3ef"
|
|
||||||
],
|
|
||||||
"markers": "python_version >= '3.9'",
|
|
||||||
"version": "==1.9.0"
|
|
||||||
},
|
|
||||||
"wsproto": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:ad565f26ecb92588a3e43bc3d96164de84cd9902482b130d0ddbaa9664a85065",
|
|
||||||
"sha256:b9acddd652b585d75b20477888c56642fdade28bdfd3579aa24a4d2c037dd736"
|
|
||||||
],
|
|
||||||
"markers": "python_full_version >= '3.7.0'",
|
|
||||||
"version": "==1.2.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"develop": {}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
|
||||||
|
# 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://www.autolibrary.kenanzhu.com)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 功能
|
||||||
|
|
||||||
|
1. 自动预约 - 支持自动预约
|
||||||
|
2. 自动续约 - 支持自动续约
|
||||||
|
3. 自动签到 - 支持自动签到
|
||||||
|
4. 远程签到 - 支持远程签到,无需在图书馆网络环境下即可签到
|
||||||
|
5. 批量操作 - 支持同时预约多个用户,可以指定当前需要跳过的用户,并将用户分成多个组
|
||||||
|
6. 定时任务 - 使用内置定时任务管理,添加定时任务,指定时间后按当前预约信息自动运行,支持设置重复任务
|
||||||
|
7. 驱动管理 - 内置浏览器驱动自动管理,支持自动检测浏览器版本并下载对应驱动,无需手动下载
|
||||||
|
|
||||||
|
*具体操作方法和注意事项请访问我们的 [帮助手册](https://manuals.autolibrary.kenanzhu.com)*
|
||||||
|
|
||||||
|
### 如何使用
|
||||||
|
|
||||||
|
1. 下载最新版本的 [AutoLibrary 安装程序](https://github.com/KenanZhu/AutoLibrary/releases/latest) 或 [压缩包](https://github.com/KenanZhu/AutoLibrary/releases/latest) 。
|
||||||
|
2. 双击运行安装程序进行安装,或将压缩包解压到任意目录。
|
||||||
|
3. 运行 `AutoLibrary`,即可打开主界面。
|
||||||
|
4. 点击 [配置] 按钮,在配置界面填写好预约信息和运行配置后,点击 [确认] 按钮。
|
||||||
|
5. 点击 [启动脚本] 按钮,即可开始自动预约、续约、签到等操作。
|
||||||
|
|
||||||
|
*注意 1*: 工具内置浏览器驱动自动管理功能,会自动检测本地浏览器版本并下载对应的驱动文件。如果自动下载失败,也可以手动下载驱动文件并在配置界面的运行配置选项卡对应位置选择驱动文件路径。
|
||||||
|
|
||||||
|
#### 平台支持 & 编译步骤
|
||||||
|
|
||||||
|
本工具目前仅支持 Windows 平台,由于使用 PySide6 库开发,理论上是可以自行编译并在 Linux 和 macOS 上运行,这里提供简单的编译步骤:
|
||||||
|
|
||||||
|
1. 确保系统安装了 Python 3.13 版本 (推荐,过低或高版本会导致兼容问题)。
|
||||||
|
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 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
|
||||||
|
def classification(self, img: bytes):
|
||||||
|
image = Image.open(io.BytesIO(img))
|
||||||
|
image = image.resize((int(image.size[0]*(64/image.size[1])), 64), Image.ANTIALIAS).convert('L')
|
||||||
|
^^^^^
|
||||||
|
请将上述参数替换为 `Image.Resampling.LANCZOS`
|
||||||
|
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
[1](@ref):[pillow 中已经删除或已经弃用的常量](https://pillow.ac.cn/en/stable/deprecations.html#constants)
|
||||||
|
|
||||||
|
### 注意事项
|
||||||
|
|
||||||
|
#### 关于预约等操作
|
||||||
|
|
||||||
|
工具会自动处理登录过程的验证码识别过程,正常情况下单次识别准确率在 90% 以上,如遇验证码识别错误,大概率是校园网网络环境不佳导致的。
|
||||||
|
|
||||||
|
只要确保处于校园网网络环境内,工具都是可以正常运行的。操作处理速度基本上取决于校园网的网络环境,一般情况下在 3-4 秒(不考虑硬件差异)左右即可完成一个用户的操作,完全满足正常使用需求。
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> 工具仅作为正常的预约,签到和续约的图书馆辅助工具,请勿干扰图书馆的正常运行(如故意预约多个座位,或同时预约大量的用户等,对此影响图书馆正常运行本工具概不负责,请在善用工具方便自己的情况下尽量不用影响其他同学的使用)。
|
||||||
|
|
||||||
|
#### 关于批量操作
|
||||||
|
|
||||||
|
批量操作时,建议将需要操作的用户分成多个组,每个组的用户数量不要超过 4 人(即一整张桌子的数量),否则会影响操作效率,大量用户同时预约会一定程度上增加图书馆服务器的压力,影响正常使用。根据需要在用户管理界面中可以勾选本次操作是否跳过该用户,以提高运行效率。
|
||||||
|
|
||||||
|
#### 关于定时任务
|
||||||
|
|
||||||
|
定时任务会在指定的时间自动运行,运行时会根据当前预约信息进行操作。一般情况下不建议设置两个运行开始时间比较接近的定时任务,否则后一个任务会等待前一个任务完成后才会运行,按照队列的顺序执行。
|
||||||
|
|
||||||
|
### Q&A
|
||||||
|
|
||||||
|
#### 为什么开发这个工具?
|
||||||
|
|
||||||
|
当前图书馆的座位预约系统在使用中确实会遇到一些不便。例如,系统登录界面较为陈旧,在输入验证码时,若出现错误常常需要全部重新填写,过程繁琐。尤其在网络环境不稳定的情况下,登录和加载速度缓慢,让人难以快速完成当天的签到或预约次日座位。
|
||||||
|
此外,当朋友需要帮忙预约座位时,手动操作也会分散自己学习和工作的注意力。
|
||||||
|
因此,很希望有一个便捷的工具能自动处理这些预约、续约和签到等操作,从而让自己从这些琐碎事务中解脱出来,更专注于手头的重要事项。
|
||||||
|
|
||||||
|
#### 工具后续会收费吗?
|
||||||
|
|
||||||
|
不会,本工具完全免费使用,也不会有任何额外收费项。如果你觉工具对你很有帮助,可以为我捐助一瓶饮料的价格,以用于 AutoLibrary 网站的维护和软件的稳定更新。
|
||||||
|
|
||||||
|
<a href="https://afdian.com/a/autolibrary" style="display:inline-block;padding:10px 30px;background:linear-gradient(135deg,#946CE6,#946CE6);color:white;text-decoration:none;border-radius:6px;font-weight:bold;">❤ 支持作者</a>
|
||||||
|
|
||||||
|
#### 会有手机端的版本吗?
|
||||||
|
|
||||||
|
暂时没有考虑,而且也没有足够的时间和能力开发多平台的版本并测试维护,所以暂时只提供 Windows 版本。
|
||||||
|
|
||||||
|
#### 后续会有哪些功能?
|
||||||
|
|
||||||
|
当前版本的功能对于正常使用已经足够,不过后续会着重完善预约时的使用体验,暂时有以下构想:
|
||||||
|
|
||||||
|
- 引入交互预约面板功能,预约时直接在座位分布图中选择可用座位,并按用户分配,无需事先配置预约信息。
|
||||||
|
- ~~优化定时任务管理功能,用户可以在定时任务管理界面设置重复运行的定时任务,如每日预约、每周预约等。~~ (已完成)
|
||||||
|
- 软件的自动更新以及公告栏功能,用户可以自动更新最新版本并获取最新公告事项。
|
||||||
|
|
||||||
|
不过由于本人的时间和能力有限,也需要考虑到图书馆的正常运行,所以后续功能会有所取舍,但也许会进行一些小的功能验证。
|
||||||
|
|
||||||
|
#### 其他功能建议?
|
||||||
|
|
||||||
|
如果你有其他功能建议,或者遇到了什么功能性,操作上的问题,欢迎提交 Issue 到本项目。
|
||||||
|
如果你有足够的开发能力,欢迎为本项目提交 PR,也可以 Fork 本项目,根据自己的需求进行修改和完善。
|
||||||
|
|
||||||
|
### 联系我
|
||||||
|
|
||||||
|
- 项目维护:[KenanZhu (Nanoki)](https://github.com/KenanZhu)
|
||||||
|
- 电子邮箱:<nanoki_zh@163.com>
|
||||||
|
|
||||||
|
_**Free to use** —— AutoLibrary 是一个基于 MIT 协议免费开源的工具_
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
This folder is used to store the batch scripts.
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
@echo off
|
||||||
|
chcp 65001 >nul
|
||||||
|
setlocal enabledelayedexpansion
|
||||||
|
|
||||||
|
cd /d "%~dp0.."
|
||||||
|
cd src/gui/resources
|
||||||
|
|
||||||
|
echo [AutoLibrary compile] 检查翻译文件...
|
||||||
|
if exist translators (
|
||||||
|
cd translators
|
||||||
|
set ts_count=0
|
||||||
|
for %%f in (*.ts) do set /a ts_count+=1
|
||||||
|
|
||||||
|
if !ts_count! gtr 0 (
|
||||||
|
echo [AutoLibrary compile] 找到 !ts_count! 个 .ts 文件,开始编译翻译文件...
|
||||||
|
for %%f in (*.ts) do (
|
||||||
|
set "qm_filename=%%~nf.qm"
|
||||||
|
echo [AutoLibrary compile] 正在编译翻译文件: "%%f" -> "!qm_filename!"
|
||||||
|
|
||||||
|
pyside6-lrelease "%%f"
|
||||||
|
if !errorlevel! equ 0 (
|
||||||
|
echo [AutoLibrary compile] 翻译文件 "%%f" 编译成功,输出文件: "!qm_filename!"
|
||||||
|
) else (
|
||||||
|
echo [AutoLibrary compile] 翻译文件 "%%f" 编译失败
|
||||||
|
)
|
||||||
|
)
|
||||||
|
) else (
|
||||||
|
echo [AutoLibrary compile] 未找到任何 .ts 翻译文件
|
||||||
|
)
|
||||||
|
cd ..
|
||||||
|
) else (
|
||||||
|
echo [AutoLibrary compile] 未找到 translators 目录
|
||||||
|
)
|
||||||
|
echo.
|
||||||
|
|
||||||
|
set count=0
|
||||||
|
for %%f in (*.qrc) do set /a count+=1
|
||||||
|
|
||||||
|
if %count% equ 0 (
|
||||||
|
echo [AutoLibrary compile] 错误: 未找到任何 .qrc 文件
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
echo [AutoLibrary compile] 找到 %count% 个 .qrc 文件,开始编译...
|
||||||
|
echo.
|
||||||
|
|
||||||
|
for %%f in (*.qrc) do (
|
||||||
|
set "filename=%%~nf"
|
||||||
|
set "output_file=!filename!.py"
|
||||||
|
echo [AutoLibrary compile] 正在编译: "%%f" -> "!output_file!"
|
||||||
|
|
||||||
|
pyside6-rcc "%%f" -o "!output_file!"
|
||||||
|
if !errorlevel! equ 0 (
|
||||||
|
echo [AutoLibrary compile] 文件 "%%f" 编译成功,输出文件: "!output_file!"
|
||||||
|
) else (
|
||||||
|
echo [AutoLibrary compile] 文件 "%%f" 编译失败
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
echo [AutoLibrary compile] 所有操作完成。
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
PRJECT_DIR="$SCRIPT_DIR/.."
|
||||||
|
|
||||||
|
cd "$PRJECT_DIR/src/gui/resources"
|
||||||
|
|
||||||
|
echo "[AutoLibrary compile] 检查翻译文件..."
|
||||||
|
if [ -d "translators" ]; then
|
||||||
|
cd translators
|
||||||
|
ts_files=(*.ts)
|
||||||
|
ts_count=${#ts_files[@]}
|
||||||
|
|
||||||
|
if [ "$ts_count" -eq 1 ] && [ "${ts_files[0]}" = "*.ts" ]; then
|
||||||
|
ts_count=0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ $ts_count -gt 0 ]; then
|
||||||
|
echo "[AutoLibrary compile] 找到 $ts_count 个 .ts 文件,开始编译翻译文件..."
|
||||||
|
for file in *.ts; do
|
||||||
|
base_name=$(basename "$file" .ts)
|
||||||
|
qm_file="${base_name}.qm"
|
||||||
|
echo "[AutoLibrary compile] 正在编译翻译文件: \"$file\" -> \"$qm_file\""
|
||||||
|
|
||||||
|
if pyside6-lrelease "$file"; then
|
||||||
|
echo "[AutoLibrary compile] 翻译文件 \"$file\" 编译成功,输出文件: \"$qm_file\""
|
||||||
|
else
|
||||||
|
echo "[AutoLibrary compile] 翻译文件 \"$file\" 编译失败"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
else
|
||||||
|
echo "[AutoLibrary compile] 未找到任何 .ts 翻译文件"
|
||||||
|
fi
|
||||||
|
cd ..
|
||||||
|
else
|
||||||
|
echo "[AutoLibrary compile] 未找到 translators 目录"
|
||||||
|
fi
|
||||||
|
|
||||||
|
file_count=$(ls *.qrc 2>/dev/null | wc -l)
|
||||||
|
|
||||||
|
if [ $file_count -eq 0 ]; then
|
||||||
|
echo "[AutoLibrary compile] 错误: 未找到任何 .qrc 文件"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[AutoLibrary compile] 找到 $file_count 个 .qrc 文件,开始编译..."
|
||||||
|
|
||||||
|
for file in *.qrc; do
|
||||||
|
base_name=$(basename "$file" .qrc)
|
||||||
|
output_file="${base_name}.py"
|
||||||
|
echo "[AutoLibrary compile] 正在编译: \"$file\" -> \"$output_file\""
|
||||||
|
|
||||||
|
if pyside6-rcc "$file" -o "$output_file"; then
|
||||||
|
echo "[AutoLibrary compile] 文件 \"$file\" 编译成功,输出文件: \"$output_file\""
|
||||||
|
else
|
||||||
|
echo "[AutoLibrary compile] 文件 \"$file\" 编译失败"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "[AutoLibrary compile] 所有操作完成。"
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
@echo off
|
||||||
|
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
|
||||||
|
|
||||||
|
if %count% equ 0 (
|
||||||
|
echo [AutoLibrary compile] 错误: 未找到任何 .ui 文件
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
echo [AutoLibrary compile] 找到 %count% 个 .ui 文件,开始编译...
|
||||||
|
echo.
|
||||||
|
|
||||||
|
for %%f in (*.ui) do (
|
||||||
|
set "filename=%%~nf"
|
||||||
|
set "output_file=Ui_!filename!.py"
|
||||||
|
echo [AutoLibrary compile] 正在编译: "%%f" -> "!output_file!"
|
||||||
|
|
||||||
|
pyside6-uic "%%f" -o "!output_file!"
|
||||||
|
if !errorlevel! equ 0 (
|
||||||
|
echo [AutoLibrary compile] 文件 "%%f" 编译成功,输出文件: "!output_file!"
|
||||||
|
) else (
|
||||||
|
echo [AutoLibrary compile] 文件 "%%f" 编译失败
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
echo [AutoLibrary compile] 所有操作完成。
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
PRJECT_DIR="$SCRIPT_DIR/.."
|
||||||
|
|
||||||
|
cd "$PRJECT_DIR/src/gui/resources/ui"
|
||||||
|
|
||||||
|
file_count=$(ls *.ui 2>/dev/null | wc -l)
|
||||||
|
|
||||||
|
if [ $file_count -eq 0 ]; then
|
||||||
|
echo "[AutoLibrary compile] 错误: 未找到任何 .ui 文件"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[AutoLibrary compile] 找到 $file_count 个 .ui 文件,开始编译..."
|
||||||
|
|
||||||
|
for file in *.ui; do
|
||||||
|
base_name=$(basename "$file" .ui)
|
||||||
|
output_file="Ui_${base_name}.py"
|
||||||
|
echo "[AutoLibrary compile] 正在编译: \"$file\" -> \"$output_file\""
|
||||||
|
|
||||||
|
if pyside6-uic "$file" -o "$output_file"; then
|
||||||
|
echo "[AutoLibrary compile] 文件 \"$file\" 编译成功,输出文件: \"$output_file\""
|
||||||
|
else
|
||||||
|
echo "[AutoLibrary compile] 文件 \"$file\" 编译失败"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "[AutoLibrary compile] 所有操作完成。"
|
||||||
@@ -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.
|
||||||
@@ -1,798 +0,0 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
"""
|
|
||||||
Copyright (c) 2025 KenanZhu.
|
|
||||||
All rights reserved.
|
|
||||||
|
|
||||||
This software is provided "as is", without any warranty of any kind.
|
|
||||||
You may use, modify, and distribute this file under the terms of the MIT License.
|
|
||||||
See the LICENSE file for details.
|
|
||||||
"""
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
|
|
||||||
from PySide6.QtCore import (
|
|
||||||
Qt, Signal, Slot, QTime, QDate, QDir, QFileInfo
|
|
||||||
)
|
|
||||||
from PySide6.QtWidgets import (
|
|
||||||
QWidget, QLineEdit, QMessageBox, QFileDialog, QListWidgetItem
|
|
||||||
)
|
|
||||||
from PySide6.QtGui import QCloseEvent
|
|
||||||
|
|
||||||
from .Ui_ALConfigWidget import Ui_ALConfigWidget
|
|
||||||
|
|
||||||
from ConfigReader import ConfigReader
|
|
||||||
from ConfigWriter import ConfigWriter
|
|
||||||
|
|
||||||
|
|
||||||
class ALConfigWidget(QWidget, Ui_ALConfigWidget):
|
|
||||||
|
|
||||||
configWidgetCloseSingal = Signal(dict)
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
parent = None,
|
|
||||||
config_paths = {
|
|
||||||
"system":
|
|
||||||
f"{QDir.toNativeSeparators(QFileInfo(sys.executable).absoluteDir().absoluteFilePath("system.json"))}",
|
|
||||||
"users":
|
|
||||||
f"{QDir.toNativeSeparators(QFileInfo(sys.executable).absoluteDir().absoluteFilePath("users.json"))}",
|
|
||||||
}
|
|
||||||
):
|
|
||||||
|
|
||||||
super().__init__(parent)
|
|
||||||
|
|
||||||
self.setupUi(self)
|
|
||||||
self.connectSignals()
|
|
||||||
self.modifyUi()
|
|
||||||
self.__config_paths = config_paths
|
|
||||||
self.__system_config_data = self.loadSystemConfig(self.__config_paths["system"])
|
|
||||||
self.__users_config_data = self.loadUsersConfig(self.__config_paths["users"])
|
|
||||||
if not self.__system_config_data:
|
|
||||||
self.initlizeDefaultConfig("system")
|
|
||||||
if not self.__users_config_data:
|
|
||||||
self.initlizeDefaultConfig("users")
|
|
||||||
self.initlizeConfigToWidget("system", self.__system_config_data)
|
|
||||||
self.initlizeConfigToWidget("users", self.__users_config_data)
|
|
||||||
|
|
||||||
|
|
||||||
def modifyUi(
|
|
||||||
self
|
|
||||||
):
|
|
||||||
|
|
||||||
self.initlizeFloorRoomMap()
|
|
||||||
self.initilizeUserInfoWidget()
|
|
||||||
|
|
||||||
|
|
||||||
def connectSignals(
|
|
||||||
self
|
|
||||||
):
|
|
||||||
|
|
||||||
self.ShowPasswordCheckBox.clicked.connect(self.onShowPasswordCheckBoxChecked)
|
|
||||||
self.FloorComboBox.currentIndexChanged.connect(self.onFloorComboBoxCurrentIndexChanged)
|
|
||||||
self.UserListWidget.currentItemChanged.connect(self.onUserListWidgetCurrentItemChanged)
|
|
||||||
self.AddUserButton.clicked.connect(self.onAddUserButtonClicked)
|
|
||||||
self.DelUserButton.clicked.connect(self.onDelUserButtonClicked)
|
|
||||||
self.BrowseBrowserDriverButton.clicked.connect(self.onBrowseBrowserDriverButtonClicked)
|
|
||||||
self.BrowseCurrentSystemConfigButton.clicked.connect(self.onBrowseCurrentSystemConfigButtonClicked)
|
|
||||||
self.BrowseCurrentUserConfigButton.clicked.connect(self.onBrowseCurrentUserConfigButtonClicked)
|
|
||||||
self.BrowseExportSystemConfigButton.clicked.connect(self.onBrowseExportSystemConfigButtonClicked)
|
|
||||||
self.BrowseExportUserConfigButton.clicked.connect(self.onBrowseExportUserConfigButtonClicked)
|
|
||||||
self.ExportConfigButton.clicked.connect(self.onExportConfigButtonClicked)
|
|
||||||
self.NewConfigButton.clicked.connect(self.onNewConfigButtonClicked)
|
|
||||||
self.LoadConfigButton.clicked.connect(self.onLoadConfigButtonClicked)
|
|
||||||
self.ConfirmButton.clicked.connect(self.onConfirmButtonClicked)
|
|
||||||
self.CancelButton.clicked.connect(self.onCancelButtonClicked)
|
|
||||||
|
|
||||||
|
|
||||||
def closeEvent(
|
|
||||||
self,
|
|
||||||
event: QCloseEvent
|
|
||||||
):
|
|
||||||
|
|
||||||
self.configWidgetCloseSingal.emit(self.__config_paths)
|
|
||||||
super().closeEvent(event)
|
|
||||||
|
|
||||||
|
|
||||||
def initlizeFloorRoomMap(
|
|
||||||
self
|
|
||||||
):
|
|
||||||
|
|
||||||
self.__floor_map = {
|
|
||||||
"2": "二层",
|
|
||||||
"3": "三层",
|
|
||||||
"4": "四层",
|
|
||||||
"5": "五层"
|
|
||||||
}
|
|
||||||
self.__room_map = {
|
|
||||||
"1": "二层内环",
|
|
||||||
"2": "二层外环",
|
|
||||||
"3": "三层内环",
|
|
||||||
"4": "三层外环",
|
|
||||||
"5": "四层内环",
|
|
||||||
"6": "四层外环",
|
|
||||||
"7": "四层期刊区",
|
|
||||||
"8": "五层考研"
|
|
||||||
}
|
|
||||||
self.__floor_rmap = {
|
|
||||||
v: k for k, v in self.__floor_map.items()
|
|
||||||
}
|
|
||||||
self.__room_rmap = {
|
|
||||||
v: k for k, v in self.__room_map.items()
|
|
||||||
}
|
|
||||||
self.__floor_room_map = {
|
|
||||||
"二层": ["二层内环", "二层外环"],
|
|
||||||
"三层": ["三层内环", "三层外环"],
|
|
||||||
"四层": ["四层内环", "四层外环", "四层期刊区"],
|
|
||||||
"五层": ["五层考研"]
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def initlizeDefaultConfigPaths(
|
|
||||||
self
|
|
||||||
) -> dict:
|
|
||||||
|
|
||||||
script_path = sys.executable
|
|
||||||
script_dir = QFileInfo(script_path).absoluteDir()
|
|
||||||
return {
|
|
||||||
"users": QDir.toNativeSeparators(script_dir.absoluteFilePath("users.json")),
|
|
||||||
"system": QDir.toNativeSeparators(script_dir.absoluteFilePath("system.json"))
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def initlizeDefaultConfig(
|
|
||||||
self,
|
|
||||||
which: str
|
|
||||||
):
|
|
||||||
|
|
||||||
default_config_paths = self.initlizeDefaultConfigPaths()
|
|
||||||
if which == "system":
|
|
||||||
self.__system_config_data = self.defaultSystemConfig()
|
|
||||||
self.__config_paths["system"] = default_config_paths["system"]
|
|
||||||
self.saveSystemConfig(self.__config_paths["system"], self.__system_config_data)
|
|
||||||
elif which == "users":
|
|
||||||
self.__users_config_data = self.defaultUsersConfig()
|
|
||||||
self.__config_paths["users"] = default_config_paths["users"]
|
|
||||||
self.saveUsersConfig(self.__config_paths["users"], self.__users_config_data)
|
|
||||||
if which == "system":
|
|
||||||
file_type = "系统配置文件"
|
|
||||||
elif which == "users":
|
|
||||||
file_type = "用户配置文件"
|
|
||||||
QMessageBox.information(
|
|
||||||
self,
|
|
||||||
"提示 - AutoLibrary",
|
|
||||||
f"{file_type}已初始化, \n"\
|
|
||||||
f" 文件路径: {self.__config_paths[which]}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def initlizeConfigToWidget(
|
|
||||||
self,
|
|
||||||
which: str,
|
|
||||||
config_data: dict
|
|
||||||
):
|
|
||||||
|
|
||||||
if which == "system":
|
|
||||||
self.setSystemConfigToWidget(config_data)
|
|
||||||
self.CurrentSystemConfigEdit.setText(self.__config_paths["system"])
|
|
||||||
elif which == "users":
|
|
||||||
self.initilizeUserInfoWidget()
|
|
||||||
self.fillUsersList(config_data)
|
|
||||||
self.CurrentUserConfigEdit.setText(self.__config_paths["users"])
|
|
||||||
|
|
||||||
|
|
||||||
def defaultSystemConfig(
|
|
||||||
self
|
|
||||||
) -> dict:
|
|
||||||
|
|
||||||
return {
|
|
||||||
"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": "msedgedriver.exe",
|
|
||||||
"headless": False
|
|
||||||
},
|
|
||||||
"mode": {
|
|
||||||
"run_mode": 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def defaultUsersConfig(
|
|
||||||
self
|
|
||||||
) -> dict:
|
|
||||||
|
|
||||||
return {
|
|
||||||
"users": []
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def collectSystemConfigFromWidget(
|
|
||||||
self
|
|
||||||
) -> dict:
|
|
||||||
|
|
||||||
system_config = self.defaultSystemConfig()
|
|
||||||
# library config is never changed
|
|
||||||
system_config["login"]["auto_captcha"] = self.AutoCaptchaCheckBox.isChecked()
|
|
||||||
system_config["login"]["max_attempt"] = self.LoginAttemptSpinBox.value()
|
|
||||||
system_config["web_driver"]["driver_type"] = self.BrowserTypeComboBox.currentText()
|
|
||||||
system_config["web_driver"]["driver_path"] = self.BrowseBrowserDriverEdit.text()
|
|
||||||
system_config["web_driver"]["headless"] = self.HeadlessCheckBox.isChecked()
|
|
||||||
run_mode = 0
|
|
||||||
if self.AutoReserveCheckBox.isChecked():
|
|
||||||
run_mode |= 0x01
|
|
||||||
if self.AutoCheckinCheckBox.isChecked():
|
|
||||||
run_mode |= 0x02
|
|
||||||
if self.AutoRenewalCheckBox.isChecked():
|
|
||||||
run_mode |= 0x04
|
|
||||||
system_config["mode"]["run_mode"] = run_mode
|
|
||||||
return system_config
|
|
||||||
|
|
||||||
|
|
||||||
def setSystemConfigToWidget(
|
|
||||||
self,
|
|
||||||
system_config: dict
|
|
||||||
):
|
|
||||||
|
|
||||||
self.HostUrlEdit.setText(system_config["library"]["host_url"])
|
|
||||||
self.LoginUrlEdit.setText(system_config["library"]["login_url"])
|
|
||||||
self.AutoCaptchaCheckBox.setChecked(system_config["login"]["auto_captcha"])
|
|
||||||
self.LoginAttemptSpinBox.setValue(system_config["login"]["max_attempt"])
|
|
||||||
self.BrowserTypeComboBox.setCurrentText(system_config["web_driver"]["driver_type"])
|
|
||||||
driver_path = os.path.abspath(system_config["web_driver"]["driver_path"])
|
|
||||||
self.BrowseBrowserDriverEdit.setText(QDir.toNativeSeparators(driver_path))
|
|
||||||
self.HeadlessCheckBox.setChecked(system_config["web_driver"]["headless"])
|
|
||||||
run_mode = system_config["mode"]["run_mode"]
|
|
||||||
self.AutoReserveCheckBox.setChecked(run_mode&0x01)
|
|
||||||
self.AutoCheckinCheckBox.setChecked(run_mode&0x02)
|
|
||||||
self.AutoRenewalCheckBox.setChecked(run_mode&0x04)
|
|
||||||
|
|
||||||
|
|
||||||
def initilizeUserInfoWidget(
|
|
||||||
self
|
|
||||||
):
|
|
||||||
|
|
||||||
self.UsernameEdit.setText("")
|
|
||||||
self.PasswordEdit.setText("")
|
|
||||||
self.UserListWidget.setSortingEnabled(True)
|
|
||||||
self.PasswordEdit.setEchoMode(QLineEdit.EchoMode.Password)
|
|
||||||
self.ShowPasswordCheckBox.setChecked(False)
|
|
||||||
self.FloorComboBox.setCurrentIndex(0)
|
|
||||||
self.onFloorComboBoxCurrentIndexChanged()
|
|
||||||
self.DateEdit.setDate(QDate.currentDate())
|
|
||||||
self.DateEdit.setMinimumDate(QDate.currentDate())
|
|
||||||
self.BeginTimeEdit.setTime(QTime.currentTime())
|
|
||||||
self.PreferEarlyBeginTimeCheckBox.setChecked(False)
|
|
||||||
self.MaxBeginTimeDiffSpinBox.setValue(30)
|
|
||||||
self.EndTimeEdit.setTime(QTime.currentTime().addSecs(120*60))
|
|
||||||
self.PreferLateEndTimeCheckBox.setChecked(False)
|
|
||||||
self.MaxEndTimeDiffSpinBox.setValue(30)
|
|
||||||
self.ExpectDurationSpinBox.setValue(self.BeginTimeEdit.time().secsTo(self.EndTimeEdit.time())/3600)
|
|
||||||
self.SatisfyDurationCheckBox.setChecked(False)
|
|
||||||
|
|
||||||
|
|
||||||
def collectUserConfigFromUserInfoWidget(
|
|
||||||
self
|
|
||||||
) -> dict:
|
|
||||||
|
|
||||||
user_config = {
|
|
||||||
"username": self.UsernameEdit.text(),
|
|
||||||
"password": self.PasswordEdit.text(),
|
|
||||||
"reserve_info": {
|
|
||||||
"begin_time":{},
|
|
||||||
"end_time": {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
user_config["reserve_info"]["date"] = self.DateEdit.dateTime().toString("yyyy-MM-dd")
|
|
||||||
user_config["reserve_info"]["place"] = self.PlaceComboBox.currentText()
|
|
||||||
user_config["reserve_info"]["floor"] = self.__floor_rmap[self.FloorComboBox.currentText()]
|
|
||||||
user_config["reserve_info"]["room"] = self.__room_rmap[self.RoomComboBox.currentText()]
|
|
||||||
user_config["reserve_info"]["seat_id"] = self.SeatIDEdit.text()
|
|
||||||
user_config["reserve_info"]["begin_time"]["time"] = self.BeginTimeEdit.time().toString("HH:mm")
|
|
||||||
user_config["reserve_info"]["begin_time"]["max_diff"] = self.MaxBeginTimeDiffSpinBox.value()
|
|
||||||
user_config["reserve_info"]["begin_time"]["prefer_early"] = self.PreferEarlyBeginTimeCheckBox.isChecked()
|
|
||||||
user_config["reserve_info"]["end_time"]["time"] = self.EndTimeEdit.time().toString("HH:mm")
|
|
||||||
user_config["reserve_info"]["end_time"]["max_diff"] = self.MaxEndTimeDiffSpinBox.value()
|
|
||||||
user_config["reserve_info"]["end_time"]["prefer_early"] = not self.PreferLateEndTimeCheckBox.isChecked()
|
|
||||||
user_config["reserve_info"]["expect_duration"] = self.ExpectDurationSpinBox.value()
|
|
||||||
user_config["reserve_info"]["satisfy_duration"] = self.SatisfyDurationCheckBox.isChecked()
|
|
||||||
return user_config
|
|
||||||
|
|
||||||
|
|
||||||
def collectUserConfigFromUserListWidget(
|
|
||||||
self,
|
|
||||||
index: int
|
|
||||||
) -> dict:
|
|
||||||
|
|
||||||
user_config = self.defaultUsersConfig()
|
|
||||||
if index < 0 or index >= self.UserListWidget.count():
|
|
||||||
return user_config
|
|
||||||
user_item = self.UserListWidget.item(index)
|
|
||||||
if user_item:
|
|
||||||
user_config = user_item.data(Qt.UserRole)
|
|
||||||
return user_config
|
|
||||||
|
|
||||||
|
|
||||||
def setUserConfigToWidget(
|
|
||||||
self,
|
|
||||||
user_config: dict
|
|
||||||
) -> None:
|
|
||||||
|
|
||||||
try:
|
|
||||||
self.UsernameEdit.setText(user_config["username"])
|
|
||||||
self.PasswordEdit.setText(user_config["password"])
|
|
||||||
self.DateEdit.setDate(QDate.fromString(user_config["reserve_info"]["date"], "yyyy-MM-dd"))
|
|
||||||
self.PlaceComboBox.setCurrentText(user_config["reserve_info"]["place"])
|
|
||||||
self.FloorComboBox.setCurrentText(self.__floor_map[user_config["reserve_info"]["floor"]])
|
|
||||||
self.RoomComboBox.setCurrentText(self.__room_map[user_config["reserve_info"]["room"]])
|
|
||||||
self.SeatIDEdit.setText(user_config["reserve_info"]["seat_id"])
|
|
||||||
self.BeginTimeEdit.setTime(QTime.fromString(user_config["reserve_info"]["begin_time"]["time"], "H:mm"))
|
|
||||||
self.MaxBeginTimeDiffSpinBox.setValue(user_config["reserve_info"]["begin_time"]["max_diff"])
|
|
||||||
self.PreferEarlyBeginTimeCheckBox.setChecked(user_config["reserve_info"]["begin_time"]["prefer_early"])
|
|
||||||
self.EndTimeEdit.setTime(QTime.fromString(user_config["reserve_info"]["end_time"]["time"], "H:mm"))
|
|
||||||
self.MaxEndTimeDiffSpinBox.setValue(user_config["reserve_info"]["end_time"]["max_diff"])
|
|
||||||
self.PreferLateEndTimeCheckBox.setChecked(not user_config["reserve_info"]["end_time"]["prefer_early"])
|
|
||||||
self.ExpectDurationSpinBox.setValue(user_config["reserve_info"]["expect_duration"])
|
|
||||||
self.SatisfyDurationCheckBox.setChecked(user_config["reserve_info"]["satisfy_duration"])
|
|
||||||
except:
|
|
||||||
QMessageBox.warning(
|
|
||||||
self,
|
|
||||||
"警告 - AutoLibrary",
|
|
||||||
"用户配置文件读取发生错误 !\n"\
|
|
||||||
f"用户: {user_config['username']} 配置文件可能已损坏"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def loadSystemConfig(
|
|
||||||
self,
|
|
||||||
system_config_path: str
|
|
||||||
) -> dict:
|
|
||||||
|
|
||||||
try:
|
|
||||||
if not system_config_path or not os.path.exists(system_config_path):
|
|
||||||
raise Exception("文件路径不存在")
|
|
||||||
system_config = ConfigReader(system_config_path).getConfigs()
|
|
||||||
if system_config and "library" in system_config\
|
|
||||||
and "web_driver" in system_config\
|
|
||||||
and "login" in system_config:
|
|
||||||
return system_config
|
|
||||||
return None
|
|
||||||
except Exception as e:
|
|
||||||
QMessageBox.warning(
|
|
||||||
self,
|
|
||||||
"警告 - AutoLibrary",
|
|
||||||
f"系统配置文件读取发生错误 ! : {e}\n"\
|
|
||||||
f"文件路径: {system_config_path}"
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def saveSystemConfig(
|
|
||||||
self,
|
|
||||||
system_config_path: str,
|
|
||||||
system_config_data: dict
|
|
||||||
) -> bool:
|
|
||||||
|
|
||||||
try:
|
|
||||||
if not system_config_path:
|
|
||||||
raise Exception("文件路径为空")
|
|
||||||
if not system_config_data or not isinstance(system_config_data, dict):
|
|
||||||
raise Exception("系统配置数据为空或类型错误")
|
|
||||||
ConfigWriter(system_config_path, system_config_data)
|
|
||||||
return True
|
|
||||||
except Exception as e:
|
|
||||||
QMessageBox.warning(
|
|
||||||
self,
|
|
||||||
"警告 - AutoLibrary",
|
|
||||||
f"配置文件写入发生错误 ! : {e}\n"\
|
|
||||||
f"文件路径: {system_config_path}"
|
|
||||||
)
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def loadUsersConfig(
|
|
||||||
self,
|
|
||||||
users_config_path: str
|
|
||||||
) -> dict:
|
|
||||||
|
|
||||||
try:
|
|
||||||
if not users_config_path or not os.path.exists(users_config_path):
|
|
||||||
raise Exception("文件路径不存在")
|
|
||||||
users_config = ConfigReader(users_config_path).getConfigs()
|
|
||||||
if users_config and "users" in users_config:
|
|
||||||
return users_config
|
|
||||||
return None
|
|
||||||
except Exception as e:
|
|
||||||
QMessageBox.warning(
|
|
||||||
self,
|
|
||||||
"警告 - AutoLibrary",
|
|
||||||
f"用户配置文件读取发生错误 ! : {e}\n"\
|
|
||||||
f"文件路径: {users_config_path}"
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def saveUsersConfig(
|
|
||||||
self,
|
|
||||||
users_config_path: str,
|
|
||||||
users_config_data: dict
|
|
||||||
) -> bool:
|
|
||||||
|
|
||||||
try:
|
|
||||||
if not users_config_path:
|
|
||||||
raise Exception("文件路径为空")
|
|
||||||
if not users_config_data or not isinstance(users_config_data, dict):
|
|
||||||
raise Exception("用户配置数据为空或类型错误")
|
|
||||||
ConfigWriter(users_config_path, users_config_data)
|
|
||||||
return True
|
|
||||||
except Exception as e:
|
|
||||||
QMessageBox.warning(
|
|
||||||
self,
|
|
||||||
"警告 - AutoLibrary",
|
|
||||||
f"用户配置文件写入发生错误 ! : {e}\n"\
|
|
||||||
f"文件路径: \n{users_config_path}"
|
|
||||||
)
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def saveConfigs(
|
|
||||||
self,
|
|
||||||
system_config_path: str,
|
|
||||||
users_config_path: str
|
|
||||||
) -> bool:
|
|
||||||
|
|
||||||
if users_config_path:
|
|
||||||
self.__users_config_data = self.defaultUsersConfig()
|
|
||||||
for index in range(self.UserListWidget.count()):
|
|
||||||
user_config = self.collectUserConfigFromUserListWidget(index)
|
|
||||||
if user_config:
|
|
||||||
self.__users_config_data["users"].append(user_config)
|
|
||||||
if not self.saveUsersConfig(
|
|
||||||
users_config_path,
|
|
||||||
self.__users_config_data
|
|
||||||
):
|
|
||||||
return False
|
|
||||||
if system_config_path:
|
|
||||||
self.__system_config_data = self.collectSystemConfigFromWidget()
|
|
||||||
if not self.saveSystemConfig(
|
|
||||||
system_config_path,
|
|
||||||
self.__system_config_data
|
|
||||||
):
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def loadConfig(
|
|
||||||
self,
|
|
||||||
config_path: str
|
|
||||||
) -> bool:
|
|
||||||
|
|
||||||
if not config_path:
|
|
||||||
config_path = QFileDialog.getOpenFileName(
|
|
||||||
self,
|
|
||||||
"从现有配置文件中加载 - AutoLibrary",
|
|
||||||
f"{QDir.toNativeSeparators(QDir.currentPath())}",
|
|
||||||
"JSON 文件 (*.json);;所有文件 (*)"
|
|
||||||
)[0]
|
|
||||||
if not config_path:
|
|
||||||
return False
|
|
||||||
try:
|
|
||||||
system_config = self.loadSystemConfig(config_path)
|
|
||||||
users_config = self.loadUsersConfig(config_path)
|
|
||||||
if system_config is not None:
|
|
||||||
self.__system_config_data.update(system_config)
|
|
||||||
self.setSystemConfigToWidget(self.__system_config_data)
|
|
||||||
return True
|
|
||||||
if users_config is not None:
|
|
||||||
self.__users_config_data.update(users_config)
|
|
||||||
self.fillUsersList(self.__users_config_data)
|
|
||||||
return True
|
|
||||||
except:
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def fillUsersList(
|
|
||||||
self,
|
|
||||||
users_config_data: list[dict]
|
|
||||||
):
|
|
||||||
|
|
||||||
self.UserListWidget.clear()
|
|
||||||
if "users" in users_config_data:
|
|
||||||
for user in users_config_data["users"]:
|
|
||||||
user_item = QListWidgetItem(user["username"])
|
|
||||||
user_item.setData(Qt.UserRole, user)
|
|
||||||
self.UserListWidget.addItem(user_item)
|
|
||||||
|
|
||||||
|
|
||||||
def addUser(
|
|
||||||
self
|
|
||||||
):
|
|
||||||
|
|
||||||
new_user = {
|
|
||||||
"username": f"新用户-{self.UserListWidget.count()}",
|
|
||||||
"password": "000000",
|
|
||||||
"reserve_info": {
|
|
||||||
"date": f"{QDate.currentDate().toString("yyyy-MM-dd")}",
|
|
||||||
"place": "\u56fe\u4e66\u9986",
|
|
||||||
"floor": "2",
|
|
||||||
"room": "1",
|
|
||||||
"seat_id": "",
|
|
||||||
"begin_time": {
|
|
||||||
"time": f"{QTime.currentTime().toString("hh:mm")}",
|
|
||||||
"max_diff": 30,
|
|
||||||
"prefer_early": False
|
|
||||||
},
|
|
||||||
"end_time": {
|
|
||||||
"time": f"{QTime.currentTime().addSecs(2*3600).toString("hh:mm")}",
|
|
||||||
"max_diff": 30,
|
|
||||||
"prefer_early": True
|
|
||||||
},
|
|
||||||
"expect_duration": 2.0,
|
|
||||||
"satisfy_duration": False
|
|
||||||
}
|
|
||||||
}
|
|
||||||
user_item = QListWidgetItem(new_user["username"])
|
|
||||||
user_item.setData(Qt.UserRole, new_user)
|
|
||||||
self.UserListWidget.addItem(user_item)
|
|
||||||
self.UserListWidget.setCurrentItem(user_item)
|
|
||||||
self.setUserConfigToWidget(new_user)
|
|
||||||
|
|
||||||
|
|
||||||
def delUser(
|
|
||||||
self
|
|
||||||
):
|
|
||||||
|
|
||||||
current_item = self.UserListWidget.currentItem()
|
|
||||||
if current_item:
|
|
||||||
self.UserListWidget.takeItem(self.UserListWidget.row(current_item))
|
|
||||||
self.UserListWidget.setCurrentItem(None)
|
|
||||||
|
|
||||||
@Slot()
|
|
||||||
def onShowPasswordCheckBoxChecked(
|
|
||||||
self,
|
|
||||||
checked: bool
|
|
||||||
):
|
|
||||||
|
|
||||||
if checked:
|
|
||||||
self.PasswordEdit.setEchoMode(QLineEdit.Normal)
|
|
||||||
else:
|
|
||||||
self.PasswordEdit.setEchoMode(QLineEdit.Password)
|
|
||||||
|
|
||||||
@Slot()
|
|
||||||
def onFloorComboBoxCurrentIndexChanged(
|
|
||||||
self
|
|
||||||
):
|
|
||||||
|
|
||||||
floor = self.FloorComboBox.currentText()
|
|
||||||
self.RoomComboBox.clear()
|
|
||||||
self.RoomComboBox.addItems(self.__floor_room_map[floor])
|
|
||||||
self.RoomComboBox.setCurrentIndex(0)
|
|
||||||
|
|
||||||
@Slot()
|
|
||||||
def onUserListWidgetCurrentItemChanged(
|
|
||||||
self,
|
|
||||||
current: QListWidgetItem,
|
|
||||||
previous: QListWidgetItem
|
|
||||||
):
|
|
||||||
# dont care about the 'self.__users_config_data', we already
|
|
||||||
# cant effectively update the data of each user, due to the
|
|
||||||
# possiblity of frequency edit. we just let the QListWidget
|
|
||||||
# help us.
|
|
||||||
if not current:
|
|
||||||
self.initilizeUserInfoWidget()
|
|
||||||
return
|
|
||||||
if previous:
|
|
||||||
user = self.collectUserConfigFromUserInfoWidget()
|
|
||||||
if user:
|
|
||||||
previous.setText(user["username"])
|
|
||||||
previous.setData(Qt.UserRole, user)
|
|
||||||
user = current.data(Qt.UserRole)
|
|
||||||
if user:
|
|
||||||
self.setUserConfigToWidget(user)
|
|
||||||
|
|
||||||
@Slot()
|
|
||||||
def onAddUserButtonClicked(
|
|
||||||
self
|
|
||||||
):
|
|
||||||
|
|
||||||
self.addUser()
|
|
||||||
|
|
||||||
@Slot()
|
|
||||||
def onDelUserButtonClicked(
|
|
||||||
self
|
|
||||||
):
|
|
||||||
|
|
||||||
self.delUser()
|
|
||||||
|
|
||||||
@Slot()
|
|
||||||
def onBrowseBrowserDriverButtonClicked(
|
|
||||||
self
|
|
||||||
):
|
|
||||||
|
|
||||||
browser_driver_path = QFileDialog.getOpenFileName(
|
|
||||||
self,
|
|
||||||
"选择浏览器驱动 - AutoLibrary",
|
|
||||||
self.BrowseBrowserDriverEdit.text(),
|
|
||||||
"可执行文件 (*.exe);;所有文件 (*)"
|
|
||||||
)[0]
|
|
||||||
if browser_driver_path:
|
|
||||||
self.BrowseBrowserDriverEdit.setText(QDir.toNativeSeparators(browser_driver_path))
|
|
||||||
|
|
||||||
@Slot()
|
|
||||||
def onBrowseCurrentSystemConfigButtonClicked(
|
|
||||||
self
|
|
||||||
):
|
|
||||||
|
|
||||||
system_config_path = QFileDialog.getOpenFileName(
|
|
||||||
self,
|
|
||||||
"选择其它的系统配置 - AutoLibrary",
|
|
||||||
self.CurrentSystemConfigEdit.text(),
|
|
||||||
"JSON 文件 (*.json);;所有文件 (*)"
|
|
||||||
)[0]
|
|
||||||
if system_config_path:
|
|
||||||
system_config_path = QDir.toNativeSeparators(system_config_path)
|
|
||||||
if self.loadConfig(system_config_path):
|
|
||||||
self.__config_paths["system"] = system_config_path
|
|
||||||
self.CurrentSystemConfigEdit.setText(system_config_path)
|
|
||||||
|
|
||||||
@Slot()
|
|
||||||
def onBrowseCurrentUserConfigButtonClicked(
|
|
||||||
self
|
|
||||||
):
|
|
||||||
|
|
||||||
users_config_path = QFileDialog.getOpenFileName(
|
|
||||||
self,
|
|
||||||
"选择其它的用户配置 - AutoLibrary",
|
|
||||||
self.CurrentUserConfigEdit.text(),
|
|
||||||
"JSON 文件 (*.json);;所有文件 (*)"
|
|
||||||
)[0]
|
|
||||||
if users_config_path:
|
|
||||||
users_config_path = QDir.toNativeSeparators(users_config_path)
|
|
||||||
if self.loadConfig(users_config_path):
|
|
||||||
self.__config_paths["users"] = users_config_path
|
|
||||||
self.CurrentUserConfigEdit.setText(users_config_path)
|
|
||||||
|
|
||||||
@Slot()
|
|
||||||
def onBrowseExportSystemConfigButtonClicked(
|
|
||||||
self
|
|
||||||
):
|
|
||||||
|
|
||||||
system_config_path = QFileDialog.getSaveFileName(
|
|
||||||
self,
|
|
||||||
"导出系统配置 - AutoLibrary",
|
|
||||||
self.CurrentSystemConfigEdit.text(),
|
|
||||||
"JSON 文件 (*.json);;所有文件 (*)"
|
|
||||||
)[0]
|
|
||||||
if system_config_path:
|
|
||||||
self.ExportSystemConfigEdit.setText(QDir.toNativeSeparators(system_config_path))
|
|
||||||
|
|
||||||
@Slot()
|
|
||||||
def onBrowseExportUserConfigButtonClicked(
|
|
||||||
self
|
|
||||||
):
|
|
||||||
|
|
||||||
users_config_path = QFileDialog.getSaveFileName(
|
|
||||||
self,
|
|
||||||
"导出用户配置 - AutoLibrary",
|
|
||||||
self.CurrentUserConfigEdit.text(),
|
|
||||||
"JSON 文件 (*.json);;所有文件 (*)"
|
|
||||||
)[0]
|
|
||||||
if users_config_path:
|
|
||||||
self.ExportUserConfigEdit.setText(QDir.toNativeSeparators(users_config_path))
|
|
||||||
|
|
||||||
@Slot()
|
|
||||||
def onExportConfigButtonClicked(
|
|
||||||
self
|
|
||||||
):
|
|
||||||
|
|
||||||
msg = ""
|
|
||||||
|
|
||||||
system_config_path = self.ExportSystemConfigEdit.text()
|
|
||||||
users_config_path = self.ExportUserConfigEdit.text()
|
|
||||||
if system_config_path:
|
|
||||||
if self.saveConfigs(
|
|
||||||
system_config_path, ""
|
|
||||||
):
|
|
||||||
msg += f"系统配置文件已导出到: \n'{system_config_path}'\n"
|
|
||||||
else:
|
|
||||||
msg += f"系统配置文件导出失败: \n'{system_config_path}'\n"
|
|
||||||
if users_config_path:
|
|
||||||
if self.saveConfigs(
|
|
||||||
"", users_config_path
|
|
||||||
):
|
|
||||||
msg += f"用户配置文件已导出到: \n'{users_config_path}'\n"
|
|
||||||
if msg:
|
|
||||||
QMessageBox.information(
|
|
||||||
self,
|
|
||||||
"提示 - AutoLibrary",
|
|
||||||
msg
|
|
||||||
)
|
|
||||||
|
|
||||||
@Slot()
|
|
||||||
def onLoadConfigButtonClicked(
|
|
||||||
self
|
|
||||||
):
|
|
||||||
|
|
||||||
self.loadConfig("")
|
|
||||||
|
|
||||||
@Slot()
|
|
||||||
def onNewConfigButtonClicked(
|
|
||||||
self
|
|
||||||
):
|
|
||||||
|
|
||||||
file_path = self.CurrentSystemConfigEdit.text()
|
|
||||||
folder_dir = QFileDialog.getExistingDirectory(
|
|
||||||
self,
|
|
||||||
"选择新建配置的文件夹 - AutoLibrary",
|
|
||||||
QDir.toNativeSeparators(QFileInfo(os.path.abspath(file_path)).absoluteDir().path())
|
|
||||||
)
|
|
||||||
if not folder_dir:
|
|
||||||
return
|
|
||||||
system_config_path = QDir.toNativeSeparators(os.path.join(folder_dir, "system.json"))
|
|
||||||
users_config_path = QDir.toNativeSeparators(os.path.join(folder_dir, "users.json"))
|
|
||||||
system_exists = os.path.isfile(system_config_path)
|
|
||||||
users_exists = os.path.isfile(users_config_path)
|
|
||||||
if system_exists or users_exists:
|
|
||||||
exist_files = []
|
|
||||||
if system_exists:
|
|
||||||
exist_files.append(system_config_path)
|
|
||||||
if users_exists:
|
|
||||||
exist_files.append(users_config_path)
|
|
||||||
reply = QMessageBox.information(
|
|
||||||
self,
|
|
||||||
"信息 - AutoLibrary",
|
|
||||||
f"文件夹中已存在以下文件, 是否覆盖 ?\n{chr(10).join(exist_files)}",
|
|
||||||
QMessageBox.Yes | QMessageBox.No,
|
|
||||||
QMessageBox.No
|
|
||||||
)
|
|
||||||
if reply == QMessageBox.No:
|
|
||||||
return
|
|
||||||
self.__system_config_data = self.defaultSystemConfig()
|
|
||||||
self.__users_config_data = self.defaultUsersConfig()
|
|
||||||
self.__config_paths = {
|
|
||||||
"system": system_config_path,
|
|
||||||
"users": users_config_path
|
|
||||||
}
|
|
||||||
self.initlizeConfigToWidget("system", self.__system_config_data)
|
|
||||||
self.initlizeConfigToWidget("users", self.__users_config_data)
|
|
||||||
|
|
||||||
@Slot()
|
|
||||||
def onConfirmButtonClicked(
|
|
||||||
self
|
|
||||||
):
|
|
||||||
|
|
||||||
if self.UserListWidget.currentItem() is not None:
|
|
||||||
user = self.collectUserConfigFromUserInfoWidget()
|
|
||||||
if user:
|
|
||||||
self.UserListWidget.currentItem().setData(Qt.UserRole, user)
|
|
||||||
if self.saveConfigs(
|
|
||||||
self.__config_paths["system"],
|
|
||||||
self.__config_paths["users"]
|
|
||||||
):
|
|
||||||
QMessageBox.information(
|
|
||||||
self,
|
|
||||||
"信息 - AutoLibrary",
|
|
||||||
"配置文件保存成功 !\n"
|
|
||||||
f"系统配置文件路径: \n{self.__config_paths['system']}\n"\
|
|
||||||
f"用户配置文件路径: \n{self.__config_paths['users']}"
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
QMessageBox.warning(
|
|
||||||
self,
|
|
||||||
"警告 - AutoLibrary",
|
|
||||||
"配置文件保存失败, 请检查文件路径权限"
|
|
||||||
)
|
|
||||||
self.close()
|
|
||||||
|
|
||||||
@Slot()
|
|
||||||
def onCancelButtonClicked(
|
|
||||||
self
|
|
||||||
):
|
|
||||||
|
|
||||||
self.close()
|
|
||||||
@@ -1,317 +0,0 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
"""
|
|
||||||
Copyright (c) 2025 KenanZhu.
|
|
||||||
All rights reserved.
|
|
||||||
|
|
||||||
This software is provided "as is", without any warranty of any kind.
|
|
||||||
You may use, modify, and distribute this file under the terms of the MIT License.
|
|
||||||
See the LICENSE file for details.
|
|
||||||
"""
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
import time
|
|
||||||
import queue
|
|
||||||
|
|
||||||
from PySide6.QtCore import (
|
|
||||||
Qt, Signal, Slot, QDir, QFileInfo, QTimer, QThread
|
|
||||||
)
|
|
||||||
from PySide6.QtWidgets import (
|
|
||||||
QMainWindow, QMenu
|
|
||||||
)
|
|
||||||
from PySide6.QtGui import (
|
|
||||||
QTextCursor, QCloseEvent, QFont, QIcon
|
|
||||||
)
|
|
||||||
|
|
||||||
from .Ui_ALMainWindow import Ui_ALMainWindow
|
|
||||||
from .ALConfigWidget import ALConfigWidget
|
|
||||||
|
|
||||||
from . import AutoLibraryResource
|
|
||||||
|
|
||||||
from AutoLib import AutoLib
|
|
||||||
from ConfigReader import ConfigReader
|
|
||||||
|
|
||||||
|
|
||||||
class AutoLibWorker(QThread):
|
|
||||||
|
|
||||||
finishedSignal = Signal()
|
|
||||||
showTraceSignal = Signal(str)
|
|
||||||
showMsgSignal = Signal(str)
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
input_queue: queue.Queue,
|
|
||||||
output_queue: queue.Queue,
|
|
||||||
config_paths: dict
|
|
||||||
):
|
|
||||||
|
|
||||||
super().__init__()
|
|
||||||
|
|
||||||
self.__input_queue = input_queue
|
|
||||||
self.__output_queue = output_queue
|
|
||||||
self.__config_paths = config_paths
|
|
||||||
self.__stopped = False
|
|
||||||
|
|
||||||
|
|
||||||
def checkTimeAvailable(
|
|
||||||
self,
|
|
||||||
) -> bool:
|
|
||||||
|
|
||||||
current_time = time.strftime("%H:%M", time.localtime())
|
|
||||||
if current_time >= "23:30" or current_time <= "07:30":
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def checkConfigPaths(
|
|
||||||
self,
|
|
||||||
) -> bool:
|
|
||||||
|
|
||||||
if not all(
|
|
||||||
os.path.exists(path) for path in self.__config_paths.values()
|
|
||||||
):
|
|
||||||
self.showTraceSignal.emit(
|
|
||||||
"配置文件路径不存在, 请检查配置文件路径是否正确。"
|
|
||||||
)
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def run(
|
|
||||||
self
|
|
||||||
):
|
|
||||||
|
|
||||||
auto_lib = None
|
|
||||||
try:
|
|
||||||
if not self.checkTimeAvailable():
|
|
||||||
self.showTraceSignal.emit(
|
|
||||||
"当前时间不在图书馆开放时间内。\n"\
|
|
||||||
" 请在 07:30 - 23:30 之间尝试"
|
|
||||||
)
|
|
||||||
return
|
|
||||||
if not self.checkConfigPaths():
|
|
||||||
return
|
|
||||||
self.showTraceSignal.emit("AutoLibrary 开始运行")
|
|
||||||
auto_lib = AutoLib(
|
|
||||||
self.__input_queue,
|
|
||||||
self.__output_queue,
|
|
||||||
)
|
|
||||||
auto_lib.run(
|
|
||||||
ConfigReader(self.__config_paths["system"]),
|
|
||||||
ConfigReader(self.__config_paths["users"]),
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
self.showTraceSignal.emit(
|
|
||||||
f"AutoLibrary 运行时发生异常 : {e}"
|
|
||||||
)
|
|
||||||
finally:
|
|
||||||
if auto_lib:
|
|
||||||
auto_lib.close()
|
|
||||||
self.showTraceSignal.emit("AutoLibrary 运行结束")
|
|
||||||
self.finishedSignal.emit()
|
|
||||||
|
|
||||||
|
|
||||||
def stop(
|
|
||||||
self
|
|
||||||
):
|
|
||||||
|
|
||||||
self.__stopped = True
|
|
||||||
|
|
||||||
|
|
||||||
class ALMainWindow(QMainWindow, Ui_ALMainWindow):
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self
|
|
||||||
):
|
|
||||||
|
|
||||||
super().__init__()
|
|
||||||
self.__class_name = self.__class__.__name__
|
|
||||||
|
|
||||||
self.setupUi(self)
|
|
||||||
self.__input_queue = queue.Queue()
|
|
||||||
self.__output_queue = queue.Queue()
|
|
||||||
self.__config_paths = {
|
|
||||||
"system":
|
|
||||||
f"{QDir.toNativeSeparators(QFileInfo(sys.executable).absoluteDir().absoluteFilePath("system.json"))}",
|
|
||||||
"users":
|
|
||||||
f"{QDir.toNativeSeparators(QFileInfo(sys.executable).absoluteDir().absoluteFilePath("users.json"))}",
|
|
||||||
}
|
|
||||||
self.__alConfigWidget = None
|
|
||||||
self.__auto_lib_thread = None
|
|
||||||
|
|
||||||
self.modifyUi()
|
|
||||||
self.connectSignals()
|
|
||||||
self.startMsgPolling()
|
|
||||||
|
|
||||||
|
|
||||||
def modifyUi(
|
|
||||||
self
|
|
||||||
):
|
|
||||||
|
|
||||||
icon = QIcon(":/res/icon/icons/AutoLibrary.ico")
|
|
||||||
self.setWindowIcon(icon)
|
|
||||||
self.MessageIOTextEdit.setFont(QFont("Courier New", 10))
|
|
||||||
|
|
||||||
|
|
||||||
def connectSignals(
|
|
||||||
self
|
|
||||||
):
|
|
||||||
|
|
||||||
self.ConfigButton.clicked.connect(self.onConfigButtonClicked)
|
|
||||||
self.StartButton.clicked.connect(self.onStartButtonClicked)
|
|
||||||
self.StopButton.clicked.connect(self.onStopButtonClicked)
|
|
||||||
self.SendButton.clicked.connect(self.onSendButtonClicked)
|
|
||||||
self.MessageEdit.returnPressed.connect(self.onSendButtonClicked)
|
|
||||||
|
|
||||||
|
|
||||||
def closeEvent(
|
|
||||||
self,
|
|
||||||
event: QCloseEvent
|
|
||||||
):
|
|
||||||
|
|
||||||
if self.__timer and self.__timer.isActive():
|
|
||||||
self.__timer.stop()
|
|
||||||
if self.__alConfigWidget:
|
|
||||||
self.__alConfigWidget.close()
|
|
||||||
super().closeEvent(event)
|
|
||||||
|
|
||||||
|
|
||||||
def appendToTextEdit(
|
|
||||||
self,
|
|
||||||
text: str
|
|
||||||
):
|
|
||||||
|
|
||||||
cursor = self.MessageIOTextEdit.textCursor()
|
|
||||||
cursor.movePosition(QTextCursor.End)
|
|
||||||
cursor.insertText(text + "\n")
|
|
||||||
self.MessageIOTextEdit.setTextCursor(cursor)
|
|
||||||
self.MessageIOTextEdit.ensureCursorVisible()
|
|
||||||
scrollbar = self.MessageIOTextEdit.verticalScrollBar()
|
|
||||||
scrollbar.setValue(scrollbar.maximum())
|
|
||||||
|
|
||||||
|
|
||||||
def startMsgPolling(
|
|
||||||
self
|
|
||||||
):
|
|
||||||
|
|
||||||
self.__timer = QTimer()
|
|
||||||
self.__timer.timeout.connect(self.pollMsgQueue)
|
|
||||||
self.__timer.start(100)
|
|
||||||
|
|
||||||
|
|
||||||
def setControlButtons(
|
|
||||||
self,
|
|
||||||
config_button_enabled: bool,
|
|
||||||
start_button_enabled: bool,
|
|
||||||
stop_button_enabled: bool
|
|
||||||
):
|
|
||||||
|
|
||||||
self.ConfigButton.setEnabled(config_button_enabled)
|
|
||||||
self.StartButton.setEnabled(start_button_enabled)
|
|
||||||
self.StopButton.setEnabled(stop_button_enabled)
|
|
||||||
|
|
||||||
@Slot()
|
|
||||||
def showMsg(
|
|
||||||
self,
|
|
||||||
msg: str
|
|
||||||
):
|
|
||||||
|
|
||||||
self.appendToTextEdit(f"[{self.__class_name:<12}] >>> : {msg}")
|
|
||||||
|
|
||||||
@Slot()
|
|
||||||
def showTrace(
|
|
||||||
self,
|
|
||||||
msg: str
|
|
||||||
):
|
|
||||||
|
|
||||||
timestamp = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())
|
|
||||||
self.appendToTextEdit(f"{timestamp}-[{self.__class_name:<12}] : {msg}")
|
|
||||||
|
|
||||||
@Slot()
|
|
||||||
def pollMsgQueue(
|
|
||||||
self
|
|
||||||
):
|
|
||||||
|
|
||||||
try:
|
|
||||||
while True:
|
|
||||||
msg = self.__output_queue.get_nowait()
|
|
||||||
self.appendToTextEdit(msg)
|
|
||||||
except queue.Empty:
|
|
||||||
pass
|
|
||||||
|
|
||||||
@Slot(dict)
|
|
||||||
def onConfigWidgetClosed(
|
|
||||||
self,
|
|
||||||
config_paths: dict
|
|
||||||
):
|
|
||||||
|
|
||||||
if self.__alConfigWidget:
|
|
||||||
self.__alConfigWidget.configWidgetCloseSingal.disconnect(self.onConfigWidgetClosed)
|
|
||||||
self.__alConfigWidget.deleteLater()
|
|
||||||
self.__alConfigWidget = None
|
|
||||||
self.ConfigButton.setEnabled(True)
|
|
||||||
self.StartButton.setEnabled(True)
|
|
||||||
self.StopButton.setEnabled(False)
|
|
||||||
self.__config_paths = config_paths
|
|
||||||
|
|
||||||
@Slot()
|
|
||||||
def onConfigButtonClicked(
|
|
||||||
self
|
|
||||||
):
|
|
||||||
|
|
||||||
if self.__alConfigWidget is None:
|
|
||||||
self.__alConfigWidget = ALConfigWidget(
|
|
||||||
self,
|
|
||||||
self.__config_paths
|
|
||||||
)
|
|
||||||
self.__alConfigWidget.configWidgetCloseSingal.connect(self.onConfigWidgetClosed)
|
|
||||||
self.__alConfigWidget.setWindowFlags(Qt.Window)
|
|
||||||
self.__alConfigWidget.setWindowModality(Qt.ApplicationModal)
|
|
||||||
self.__alConfigWidget.show()
|
|
||||||
self.__alConfigWidget.raise_()
|
|
||||||
self.__alConfigWidget.activateWindow()
|
|
||||||
self.ConfigButton.setEnabled(False)
|
|
||||||
|
|
||||||
@Slot()
|
|
||||||
def onStartButtonClicked(
|
|
||||||
self
|
|
||||||
):
|
|
||||||
|
|
||||||
self.setControlButtons(False, False, True)
|
|
||||||
if self.__auto_lib_thread is None:
|
|
||||||
self.__auto_lib_thread = AutoLibWorker(
|
|
||||||
self.__input_queue,
|
|
||||||
self.__output_queue,
|
|
||||||
self.__config_paths,
|
|
||||||
)
|
|
||||||
self.__auto_lib_thread.finishedSignal.connect(self.onStopButtonClicked)
|
|
||||||
self.__auto_lib_thread.showMsgSignal.connect(self.showMsg)
|
|
||||||
self.__auto_lib_thread.showTraceSignal.connect(self.showTrace)
|
|
||||||
self.__auto_lib_thread.start()
|
|
||||||
|
|
||||||
@Slot()
|
|
||||||
def onStopButtonClicked(
|
|
||||||
self
|
|
||||||
):
|
|
||||||
|
|
||||||
if self.__auto_lib_thread:
|
|
||||||
self.showTrace("正在停止操作......")
|
|
||||||
self.__auto_lib_thread.stop()
|
|
||||||
self.__auto_lib_thread.wait()
|
|
||||||
self.showTrace("操作已停止")
|
|
||||||
self.__auto_lib_thread.showMsgSignal.disconnect(self.showMsg)
|
|
||||||
self.__auto_lib_thread.showTraceSignal.disconnect(self.showTrace)
|
|
||||||
self.__auto_lib_thread.finishedSignal.disconnect(self.onStopButtonClicked)
|
|
||||||
self.__auto_lib_thread.deleteLater()
|
|
||||||
self.__auto_lib_thread = None
|
|
||||||
self.setControlButtons(True, True, False)
|
|
||||||
|
|
||||||
@Slot()
|
|
||||||
def onSendButtonClicked(
|
|
||||||
self
|
|
||||||
):
|
|
||||||
|
|
||||||
msg = self.MessageEdit.text().strip()
|
|
||||||
if not msg:
|
|
||||||
return
|
|
||||||
self.showMsg(msg)
|
|
||||||
self.MessageEdit.clear()
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
<RCC>
|
|
||||||
<qresource prefix="/res/icon">
|
|
||||||
<file>icons/AutoLibrary.ico</file>
|
|
||||||
</qresource>
|
|
||||||
<qresource prefix="/res/trans">
|
|
||||||
<file>translators/qtbase_zh_CN.qm</file>
|
|
||||||
</qresource>
|
|
||||||
</RCC>
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
this folder is used to store the config files.
|
|
||||||
|
Before Width: | Height: | Size: 785 KiB |
@@ -0,0 +1,3 @@
|
|||||||
|
This folder is used to store the manuals.
|
||||||
|
|
||||||
|
Our manuals are available at https://manuals.autolibrary.kenanzhu.com
|
||||||
@@ -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.
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
|
|
||||||
# AutoLibrary
|
|
||||||
|
|
||||||
请访问[AutoLibrary 网站](http://autolibrary.cv)
|
|
||||||
|
|
||||||
Please access the [AutoLibrary Website](http://autolibrary.cv)
|
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
attrs==26.1.0
|
||||||
|
certifi==2026.2.25
|
||||||
|
cffi==2.0.0
|
||||||
|
charset-normalizer==3.4.6
|
||||||
|
ddddocr==1.0.6
|
||||||
|
flatbuffers==25.12.19
|
||||||
|
h11==0.16.0
|
||||||
|
idna==3.11
|
||||||
|
lupa==2.8
|
||||||
|
numpy==2.4.3
|
||||||
|
onnxruntime==1.24.4
|
||||||
|
outcome==1.3.0.post0
|
||||||
|
packaging==26.0
|
||||||
|
pillow==12.1.1
|
||||||
|
protobuf==7.34.0
|
||||||
|
pybrowsers==1.3.2
|
||||||
|
pycparser==3.0
|
||||||
|
pyinstaller==6.19.0
|
||||||
|
PySide6==6.10.2
|
||||||
|
PySide6_Addons==6.10.2
|
||||||
|
PySide6_Essentials==6.10.2
|
||||||
|
PySocks==1.7.1
|
||||||
|
QtAwesome==1.4.2
|
||||||
|
QtPy==2.4.3
|
||||||
|
requests==2.32.5
|
||||||
|
selenium==4.38.0
|
||||||
|
shiboken6==6.10.2
|
||||||
|
sniffio==1.3.1
|
||||||
|
sortedcontainers==2.4.0
|
||||||
|
trio==0.33.0
|
||||||
|
trio-websocket==0.12.2
|
||||||
|
typing_extensions==4.15.0
|
||||||
|
urllib3==2.6.3
|
||||||
|
wsproto==1.3.2
|
||||||
@@ -13,20 +13,23 @@ from PySide6.QtCore import QTranslator
|
|||||||
from PySide6.QtWidgets import QApplication
|
from PySide6.QtWidgets import QApplication
|
||||||
|
|
||||||
from gui.ALMainWindow import ALMainWindow
|
from gui.ALMainWindow import ALMainWindow
|
||||||
from gui import AutoLibraryResource
|
from gui.resources import ALResource
|
||||||
|
|
||||||
|
from boot.AppInitializer import initializeApp
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
|
|
||||||
app = QApplication(sys.argv)
|
app = QApplication(sys.argv)
|
||||||
translator = QTranslator()
|
translator = QTranslator()
|
||||||
if translator.load(":/res/trans/translators/qtbase_zh_CN.ts"):
|
if translator.load(":/res/translators/qtbase_zh_CN.ts"):
|
||||||
app.installTranslator(translator)
|
app.installTranslator(translator)
|
||||||
app.setStyle('Fusion')
|
app.setApplicationName("AutoLibrary")
|
||||||
|
if not initializeApp():
|
||||||
|
sys.exit(-1)
|
||||||
window = ALMainWindow()
|
window = ALMainWindow()
|
||||||
window.show()
|
window.show()
|
||||||
sys.exit(app.exec_())
|
sys.exit(app.exec())
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
Copyright (c) 2026 KenanZhu.
|
||||||
|
All rights reserved.
|
||||||
|
|
||||||
|
This software is provided "as is", without any warranty of any kind.
|
||||||
|
You may use, modify, and distribute this file under the terms of the MIT License.
|
||||||
|
See the LICENSE file for details.
|
||||||
|
"""
|
||||||
|
from datetime import (
|
||||||
|
date,
|
||||||
|
datetime,
|
||||||
|
)
|
||||||
|
|
||||||
|
from lupa import LuaRuntime as _LuaRuntime
|
||||||
|
|
||||||
|
from autoscript._helpers import (
|
||||||
|
_TYPE_DEFAULT_VAR,
|
||||||
|
_assignPath,
|
||||||
|
_checkDateFormat,
|
||||||
|
_checkTimeFormat,
|
||||||
|
_checkType,
|
||||||
|
_cleanLuaError,
|
||||||
|
_navigatePath,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from lupa.lua55 import LuaError as _LuaError, LuaSyntaxError as _LuaSyntaxError
|
||||||
|
except ImportError:
|
||||||
|
try:
|
||||||
|
from lupa.lua54 import LuaError as _LuaError, LuaSyntaxError as _LuaSyntaxError
|
||||||
|
except ImportError:
|
||||||
|
_LuaError = Exception
|
||||||
|
_LuaSyntaxError = Exception
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["ASEngine"]
|
||||||
|
|
||||||
|
|
||||||
|
class ASEngine:
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def getCurrentDate(
|
||||||
|
) -> str:
|
||||||
|
|
||||||
|
return date.today().isoformat()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def getCurrentTime(
|
||||||
|
) -> str:
|
||||||
|
|
||||||
|
return datetime.now().strftime("%H:%M")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _sandbox(
|
||||||
|
lua,
|
||||||
|
) -> None:
|
||||||
|
|
||||||
|
lua.execute("""
|
||||||
|
io = nil
|
||||||
|
require = nil
|
||||||
|
dofile = nil
|
||||||
|
loadfile = nil
|
||||||
|
load = nil
|
||||||
|
package = nil
|
||||||
|
rawget = nil
|
||||||
|
rawset = nil
|
||||||
|
rawequal = nil
|
||||||
|
getfenv = nil
|
||||||
|
setfenv = nil
|
||||||
|
debug = nil
|
||||||
|
if os then
|
||||||
|
os.execute = nil
|
||||||
|
os.exit = nil
|
||||||
|
os.getenv = nil
|
||||||
|
os.remove = nil
|
||||||
|
os.rename = nil
|
||||||
|
os.tmpname = nil
|
||||||
|
os.setlocale = nil
|
||||||
|
end
|
||||||
|
""")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _registerHelpers(
|
||||||
|
lua,
|
||||||
|
) -> None:
|
||||||
|
|
||||||
|
lua.execute("""
|
||||||
|
function date(y, m, d)
|
||||||
|
return os.time({year = y, month = m, day = d})
|
||||||
|
end
|
||||||
|
|
||||||
|
function time(h, m)
|
||||||
|
return h * 60 + m
|
||||||
|
end
|
||||||
|
|
||||||
|
function datenow()
|
||||||
|
local now = os.date("*t")
|
||||||
|
return os.time({year = now.year, month = now.month, day = now.day})
|
||||||
|
end
|
||||||
|
|
||||||
|
function timenow()
|
||||||
|
local now = os.date("*t")
|
||||||
|
return now.hour * 60 + now.min
|
||||||
|
end
|
||||||
|
|
||||||
|
function dateadd(date_val, n)
|
||||||
|
return date_val + n * 86400
|
||||||
|
end
|
||||||
|
|
||||||
|
function timeadd(time_val, n)
|
||||||
|
return (time_val + n * 60) % 1440
|
||||||
|
end
|
||||||
|
|
||||||
|
function strtodate(iso_str)
|
||||||
|
local y, m, d = iso_str:match("(%d+)-(%d+)-(%d+)")
|
||||||
|
return os.time({year = y, month = m, day = d})
|
||||||
|
end
|
||||||
|
|
||||||
|
function strtotime(hm_str)
|
||||||
|
local h, m = hm_str:match("(%d+):(%d+)")
|
||||||
|
return h * 60 + m
|
||||||
|
end
|
||||||
|
|
||||||
|
function datetostr(ts)
|
||||||
|
return os.date("%Y-%m-%d", ts)
|
||||||
|
end
|
||||||
|
|
||||||
|
function timetostr(m)
|
||||||
|
return string.format("%02d:%02d", math.floor(m / 60), m % 60)
|
||||||
|
end
|
||||||
|
""")
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
targetVars: list[tuple] = None,
|
||||||
|
):
|
||||||
|
|
||||||
|
self._targetVars: dict[str, dict] = {}
|
||||||
|
self._lua = None
|
||||||
|
|
||||||
|
if targetVars:
|
||||||
|
for item in targetVars:
|
||||||
|
name, varType, keyPath = item[0], item[1], item[2]
|
||||||
|
self.addTargetVar(name, varType, keyPath)
|
||||||
|
|
||||||
|
def _getLua(
|
||||||
|
self,
|
||||||
|
):
|
||||||
|
|
||||||
|
if self._lua is None:
|
||||||
|
self._lua = _LuaRuntime(unpack_returned_tuples=True)
|
||||||
|
self._sandbox(self._lua)
|
||||||
|
self._registerHelpers(self._lua)
|
||||||
|
return self._lua
|
||||||
|
|
||||||
|
def _push(
|
||||||
|
self,
|
||||||
|
targetData: dict,
|
||||||
|
) -> None:
|
||||||
|
|
||||||
|
lua = self._getLua()
|
||||||
|
g = lua.globals()
|
||||||
|
strToDate = g["strtodate"]
|
||||||
|
strToTime = g["strtotime"]
|
||||||
|
|
||||||
|
for varName, info in self._targetVars.items():
|
||||||
|
keyPath = info["keyPath"]
|
||||||
|
vt = info["type"]
|
||||||
|
raw = _navigatePath(targetData, keyPath)
|
||||||
|
if vt == "Date":
|
||||||
|
if not isinstance(raw, str) or not raw.strip():
|
||||||
|
raise ValueError(
|
||||||
|
f"Date 类型变量 '{varName}' 对应的数据为空或不是字符串类型,"
|
||||||
|
f"请检查路径 {keyPath} 的值是否为合法的日期字符串 (YYYY-MM-DD)"
|
||||||
|
)
|
||||||
|
raw = raw.strip()
|
||||||
|
_checkDateFormat(raw, varName)
|
||||||
|
g[varName] = strToDate(raw)
|
||||||
|
elif vt == "Time":
|
||||||
|
if not isinstance(raw, str) or not raw.strip():
|
||||||
|
raise ValueError(
|
||||||
|
f"Time 类型变量 '{varName}' 对应的数据为空或不是字符串类型,"
|
||||||
|
f"请检查路径 {keyPath} 的值是否为合法的时间字符串 (HH:MM)"
|
||||||
|
)
|
||||||
|
raw = raw.strip()
|
||||||
|
_checkTimeFormat(raw, varName)
|
||||||
|
g[varName] = strToTime(raw)
|
||||||
|
else:
|
||||||
|
if raw is None:
|
||||||
|
raw = _TYPE_DEFAULT_VAR.get(vt, False)
|
||||||
|
g[varName] = raw
|
||||||
|
|
||||||
|
def _pull(
|
||||||
|
self,
|
||||||
|
targetData: dict,
|
||||||
|
) -> None:
|
||||||
|
|
||||||
|
lua = self._getLua()
|
||||||
|
g = lua.globals()
|
||||||
|
dateToStr = g["datetostr"]
|
||||||
|
timeToStr = g["timetostr"]
|
||||||
|
|
||||||
|
for varName, info in self._targetVars.items():
|
||||||
|
try:
|
||||||
|
luaVal = g[varName]
|
||||||
|
except KeyError:
|
||||||
|
continue
|
||||||
|
vt = info["type"]
|
||||||
|
if vt == "Date":
|
||||||
|
luaVal = dateToStr(luaVal)
|
||||||
|
elif vt == "Time":
|
||||||
|
luaVal = timeToStr(luaVal)
|
||||||
|
elif vt == "Float" and isinstance(luaVal, int) and not isinstance(luaVal, bool):
|
||||||
|
luaVal = float(luaVal)
|
||||||
|
_checkType(varName, vt, luaVal)
|
||||||
|
_assignPath(targetData, info["keyPath"], luaVal)
|
||||||
|
|
||||||
|
def addTargetVar(
|
||||||
|
self,
|
||||||
|
name: str,
|
||||||
|
varType: str,
|
||||||
|
keyPath: list,
|
||||||
|
) -> None:
|
||||||
|
|
||||||
|
upperName = name.upper().strip()
|
||||||
|
self._targetVars[upperName] = {
|
||||||
|
"type": varType,
|
||||||
|
"keyPath": keyPath,
|
||||||
|
}
|
||||||
|
|
||||||
|
def execute(
|
||||||
|
self,
|
||||||
|
scriptText: str,
|
||||||
|
targetData: dict,
|
||||||
|
) -> None:
|
||||||
|
|
||||||
|
if not scriptText or not scriptText.strip():
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
self._push(targetData)
|
||||||
|
self._getLua().execute(scriptText)
|
||||||
|
self._pull(targetData)
|
||||||
|
except _LuaSyntaxError as e:
|
||||||
|
raise ValueError(
|
||||||
|
f"AutoScript 语法错误: {_cleanLuaError(str(e))}"
|
||||||
|
)
|
||||||
|
except _LuaError as e:
|
||||||
|
raise ValueError(
|
||||||
|
f"AutoScript 运行时错误: {_cleanLuaError(str(e))}"
|
||||||
|
)
|
||||||
|
except ValueError as e:
|
||||||
|
raise ValueError(f"AutoScript 数据错误: {e}")
|
||||||
|
except Exception as e:
|
||||||
|
raise ValueError(f"AutoScript 未知错误: {e}")
|
||||||
|
|
||||||
|
def reset(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
|
||||||
|
self._targetVars = {}
|
||||||
|
self._lua = None
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
Copyright (c) 2026 KenanZhu.
|
||||||
|
All rights reserved.
|
||||||
|
|
||||||
|
This software is provided "as is", without any warranty of any kind.
|
||||||
|
You may use, modify, and distribute this file under the terms of the MIT License.
|
||||||
|
See the LICENSE file for details.
|
||||||
|
"""
|
||||||
|
from .ASEngine import ASEngine
|
||||||
|
|
||||||
|
__version__ = "1.0.0" # autoscript version
|
||||||
|
|
||||||
|
_TARGET_VAR_DEFS = [
|
||||||
|
("USERNAME", "String", ["username"], "用户名"),
|
||||||
|
("USER_ENABLE", "Boolean", ["enabled"], "用户启用"),
|
||||||
|
("RESERVE_DATE", "Date", ["reserve_info", "date"], "预约日期"),
|
||||||
|
("RESERVE_BEGIN_TIME", "Time", ["reserve_info", "begin_time", "time"], "预约开始时间"),
|
||||||
|
("RESERVE_END_TIME", "Time", ["reserve_info", "end_time", "time"], "预约结束时间"),
|
||||||
|
]
|
||||||
|
_MOCK_TYPE_VALUES = {
|
||||||
|
"String": "__mock__",
|
||||||
|
"Boolean": True,
|
||||||
|
"Date": "2099-01-01",
|
||||||
|
"Time": "00:00",
|
||||||
|
"Int": 0,
|
||||||
|
"Float": 0.0,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def createAllVariablesTable(
|
||||||
|
) -> dict:
|
||||||
|
|
||||||
|
return {
|
||||||
|
displayName: (name, varType)
|
||||||
|
for name, varType, _, displayName in _TARGET_VAR_DEFS
|
||||||
|
}
|
||||||
|
|
||||||
|
def createTargetVarDefs(
|
||||||
|
) -> list:
|
||||||
|
|
||||||
|
return list(_TARGET_VAR_DEFS)
|
||||||
|
|
||||||
|
def createMockTargetData(
|
||||||
|
) -> dict:
|
||||||
|
|
||||||
|
data = {}
|
||||||
|
for _, varType, keyPath, _ in _TARGET_VAR_DEFS:
|
||||||
|
d = data
|
||||||
|
for key in keyPath[:-1]:
|
||||||
|
d = d.setdefault(key, {})
|
||||||
|
d[keyPath[-1]] = _MOCK_TYPE_VALUES.get(varType, "")
|
||||||
|
return data
|
||||||
|
|
||||||
|
def createEngine(
|
||||||
|
) -> ASEngine:
|
||||||
|
|
||||||
|
return ASEngine(_TARGET_VAR_DEFS)
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
Copyright (c) 2026 KenanZhu.
|
||||||
|
All rights reserved.
|
||||||
|
|
||||||
|
This software is provided "as is", without any warranty of any kind.
|
||||||
|
You may use, modify, and distribute this file under the terms of the MIT License.
|
||||||
|
See the LICENSE file for details.
|
||||||
|
"""
|
||||||
|
from datetime import (
|
||||||
|
date,
|
||||||
|
datetime,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_TYPE_DEFAULT_VAR: dict[str, str | int | float | bool] = {
|
||||||
|
"String": "",
|
||||||
|
"Int": 0,
|
||||||
|
"Float": 0.0,
|
||||||
|
"Boolean": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _navigatePath(
|
||||||
|
data: dict,
|
||||||
|
keyPath: list,
|
||||||
|
default=None,
|
||||||
|
):
|
||||||
|
|
||||||
|
d = data
|
||||||
|
for key in keyPath[:-1]:
|
||||||
|
d = d.get(key, {})
|
||||||
|
if not isinstance(d, dict):
|
||||||
|
return default
|
||||||
|
return d.get(keyPath[-1], default)
|
||||||
|
|
||||||
|
def _assignPath(
|
||||||
|
data: dict,
|
||||||
|
keyPath: list,
|
||||||
|
value,
|
||||||
|
) -> None:
|
||||||
|
|
||||||
|
d = data
|
||||||
|
for key in keyPath[:-1]:
|
||||||
|
d = d.setdefault(key, {})
|
||||||
|
d[keyPath[-1]] = value
|
||||||
|
|
||||||
|
def _checkDateFormat(
|
||||||
|
dateStr: str,
|
||||||
|
varName: str = "",
|
||||||
|
) -> None:
|
||||||
|
|
||||||
|
prefix = f"Date 类型变量 '{varName}' 的" if varName else ""
|
||||||
|
try:
|
||||||
|
date.fromisoformat(dateStr)
|
||||||
|
except ValueError:
|
||||||
|
raise ValueError(
|
||||||
|
f"{prefix}值 '{dateStr}' 不是合法的日期格式,"
|
||||||
|
f"应为 YYYY-MM-DD"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _checkTimeFormat(
|
||||||
|
timeStr: str,
|
||||||
|
varName: str = "",
|
||||||
|
) -> None:
|
||||||
|
|
||||||
|
prefix = f"Time 类型变量 '{varName}' 的" if varName else ""
|
||||||
|
try:
|
||||||
|
datetime.strptime(timeStr, "%H:%M")
|
||||||
|
except ValueError:
|
||||||
|
raise ValueError(
|
||||||
|
f"{prefix}值 '{timeStr}' 不是合法的时间格式,"
|
||||||
|
f"应为 HH:MM"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _checkType(
|
||||||
|
varName: str,
|
||||||
|
varType: str,
|
||||||
|
value,
|
||||||
|
) -> None:
|
||||||
|
|
||||||
|
if varType == "Date":
|
||||||
|
if not isinstance(value, str):
|
||||||
|
raise ValueError(
|
||||||
|
f"Date 类型变量 '{varName}' 只能接受日期字符串,"
|
||||||
|
f"不能接受 {_pyTypeToASType(value)} 类型"
|
||||||
|
)
|
||||||
|
_checkDateFormat(value, varName)
|
||||||
|
return
|
||||||
|
if varType == "Time":
|
||||||
|
if not isinstance(value, str):
|
||||||
|
raise ValueError(
|
||||||
|
f"Time 类型变量 '{varName}' 只能接受时间字符串,"
|
||||||
|
f"不能接受 {_pyTypeToASType(value)} 类型"
|
||||||
|
)
|
||||||
|
_checkTimeFormat(value, varName)
|
||||||
|
return
|
||||||
|
if varType == "Int":
|
||||||
|
if isinstance(value, bool):
|
||||||
|
raise ValueError(
|
||||||
|
f"Int 类型变量 '{varName}' 不能接受 Boolean 类型的值"
|
||||||
|
)
|
||||||
|
if not isinstance(value, int) and not (isinstance(value, float) and value == int(value)):
|
||||||
|
raise ValueError(
|
||||||
|
f"Int 类型变量 '{varName}' 不能接受 {_pyTypeToASType(value)} 类型的值"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
if varType == "Float":
|
||||||
|
if isinstance(value, bool):
|
||||||
|
raise ValueError(
|
||||||
|
f"Float 类型变量 '{varName}' 不能接受 Boolean 类型的值"
|
||||||
|
)
|
||||||
|
if not isinstance(value, (int, float)):
|
||||||
|
raise ValueError(
|
||||||
|
f"Float 类型变量 '{varName}' 不能接受 {_pyTypeToASType(value)} 类型的值"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
if varType == "Boolean":
|
||||||
|
if not isinstance(value, bool):
|
||||||
|
raise ValueError(
|
||||||
|
f"Boolean 类型变量 '{varName}' 不能接受 {_pyTypeToASType(value)} 类型的值"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
if varType == "String":
|
||||||
|
if not isinstance(value, str):
|
||||||
|
raise ValueError(
|
||||||
|
f"String 类型变量 '{varName}' 不能接受 {_pyTypeToASType(value)} 类型的值"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
def _pyTypeToASType(
|
||||||
|
value,
|
||||||
|
) -> str:
|
||||||
|
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return "Boolean"
|
||||||
|
if isinstance(value, int):
|
||||||
|
return "Int"
|
||||||
|
if isinstance(value, float):
|
||||||
|
return "Float"
|
||||||
|
if isinstance(value, str):
|
||||||
|
return "String"
|
||||||
|
return "Unknown"
|
||||||
|
|
||||||
|
def _cleanLuaError(
|
||||||
|
rawMsg: str,
|
||||||
|
) -> str:
|
||||||
|
|
||||||
|
msg = rawMsg.replace('[string "<python>"]:', "").strip()
|
||||||
|
stackIdx = msg.find("stack traceback:")
|
||||||
|
if stackIdx != -1:
|
||||||
|
msg = msg[:stackIdx].strip()
|
||||||
|
return msg
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
Copyright (c) 2025 KenanZhu.
|
||||||
|
All rights reserved.
|
||||||
|
|
||||||
|
This software is provided "as is", without any warranty of any kind.
|
||||||
|
You may use, modify, and distribute this file under the terms of the MIT License.
|
||||||
|
See the LICENSE file for details.
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
import queue
|
||||||
|
import datetime
|
||||||
|
|
||||||
|
import managers.log.LogManager as LogManager
|
||||||
|
|
||||||
|
|
||||||
|
class MsgBase:
|
||||||
|
"""
|
||||||
|
Base class for message and trace abilities (thread-safe).
|
||||||
|
|
||||||
|
This class provides the foundation for message handling and tracing
|
||||||
|
abilities based on the provided input and output queues. It enables
|
||||||
|
thread-safe communication between components using queue-based messaging.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
input_queue (queue.Queue): The input queue for receiving messages.
|
||||||
|
output_queue (queue.Queue): The output queue for sending messages.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
This class must be initialized with input and output queues. The queue
|
||||||
|
provider (the caller of this class or its subclasses) must explicitly
|
||||||
|
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,
|
||||||
|
output_queue: queue.Queue
|
||||||
|
):
|
||||||
|
|
||||||
|
self._class_name = self.__class__.__name__
|
||||||
|
self._input_queue = input_queue
|
||||||
|
self._output_queue = output_queue
|
||||||
|
try:
|
||||||
|
self._logger = LogManager.getLogger(self._class_name)
|
||||||
|
except RuntimeError:
|
||||||
|
self._logger = None
|
||||||
|
|
||||||
|
def _showMsg(
|
||||||
|
self,
|
||||||
|
msg: str
|
||||||
|
):
|
||||||
|
|
||||||
|
self._output_queue.put(f"[{self._class_name:<15}] >>> : {msg}")
|
||||||
|
|
||||||
|
def _showTrace(
|
||||||
|
self,
|
||||||
|
msg: str,
|
||||||
|
level: int = logging.INFO,
|
||||||
|
no_log: bool = False
|
||||||
|
):
|
||||||
|
|
||||||
|
timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")[:-3]
|
||||||
|
self._output_queue.put(f"{timestamp}-[{self._class_name:<15}] : {msg}")
|
||||||
|
if self._logger and not no_log:
|
||||||
|
self._logger.log(level, msg)
|
||||||
|
|
||||||
|
def _showLog(
|
||||||
|
self,
|
||||||
|
msg: str,
|
||||||
|
level: int = logging.INFO
|
||||||
|
):
|
||||||
|
|
||||||
|
if self._logger:
|
||||||
|
self._logger.log(level, msg)
|
||||||
|
|
||||||
|
def _waitMsg(
|
||||||
|
self,
|
||||||
|
timeout: float = 1.0
|
||||||
|
) -> str:
|
||||||
|
|
||||||
|
try:
|
||||||
|
msg = self._input_queue.get(timeout=timeout)
|
||||||
|
return msg
|
||||||
|
except queue.Empty:
|
||||||
|
return None
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
Copyright (c) 2026 KenanZhu.
|
||||||
|
All rights reserved.
|
||||||
|
|
||||||
|
This software is provided "as is", without any warranty of any kind.
|
||||||
|
You may use, modify, and distribute this file under the terms of the MIT License.
|
||||||
|
See the LICENSE file for details.
|
||||||
|
"""
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
Copyright (c) 2026 KenanZhu.
|
||||||
|
All rights reserved.
|
||||||
|
|
||||||
|
This software is provided "as is", without any warranty of any kind.
|
||||||
|
You may use, modify, and distribute this file under the terms of the MIT License.
|
||||||
|
See the LICENSE file for details.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
|
||||||
|
from PySide6.QtCore import QStandardPaths, QDir
|
||||||
|
from PySide6.QtWidgets import QApplication
|
||||||
|
|
||||||
|
from interfaces.ConfigProvider import CfgKey
|
||||||
|
from managers.config.ConfigManager import instance as configInstance
|
||||||
|
from managers.driver.WebDriverManager import instance as webdriverInstance
|
||||||
|
from managers.log.LogManager import instance as logInstance
|
||||||
|
from managers.theme.ThemeManager import(
|
||||||
|
setActiveStyle,
|
||||||
|
instance as themeInstance
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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 _initializeAppearance(
|
||||||
|
):
|
||||||
|
|
||||||
|
app = QApplication.instance()
|
||||||
|
if not app:
|
||||||
|
return
|
||||||
|
cfg = configInstance()
|
||||||
|
saved_style = cfg.get(CfgKey.GLOBAL.APPEARANCE.STYLE, "Fusion")
|
||||||
|
saved_theme = cfg.get(CfgKey.GLOBAL.APPEARANCE.THEME, "system")
|
||||||
|
saved_custom_theme = cfg.get(CfgKey.GLOBAL.APPEARANCE.CUSTOM_THEME, "")
|
||||||
|
app.setStyle(saved_style)
|
||||||
|
setActiveStyle(saved_style)
|
||||||
|
logger = logInstance().getLogger("AppInitializer")
|
||||||
|
if saved_custom_theme:
|
||||||
|
try:
|
||||||
|
themeInstance().applyTheme(saved_custom_theme)
|
||||||
|
except Exception:
|
||||||
|
logger.warning("无法应用自定义主题 '%s',回退到默认外观", saved_custom_theme)
|
||||||
|
themeInstance().clearTheme(saved_theme)
|
||||||
|
return
|
||||||
|
themeInstance().clearTheme(saved_theme)
|
||||||
|
|
||||||
|
def initializeApp(
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Initialize the application components
|
||||||
|
|
||||||
|
Order:
|
||||||
|
LogManager -> ConfigManager -> WebDriverManager -> Appearance
|
||||||
|
"""
|
||||||
|
|
||||||
|
if not _initializeLogManager():
|
||||||
|
return False
|
||||||
|
if not _initializeConfigManager():
|
||||||
|
return False
|
||||||
|
if not _initializeWebDriverManager():
|
||||||
|
return False
|
||||||
|
_initializeAppearance()
|
||||||
|
return True
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
Copyright (c) 2026 KenanZhu.
|
||||||
|
All rights reserved.
|
||||||
|
|
||||||
|
This software is provided "as is", without any warranty of any kind.
|
||||||
|
You may use, modify, and distribute this file under the terms of the MIT License.
|
||||||
|
See the LICENSE file for details.
|
||||||
|
"""
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
Copyright (c) 2025 KenanZhu.
|
||||||
|
All rights reserved.
|
||||||
|
|
||||||
|
This software is provided "as is", without any warranty of any kind.
|
||||||
|
You may use, modify, and distribute this file under the terms of the MIT License.
|
||||||
|
See the LICENSE file for details.
|
||||||
|
"""
|
||||||
|
import platform
|
||||||
|
|
||||||
|
from PySide6.QtCore import (
|
||||||
|
Qt,
|
||||||
|
QTimer
|
||||||
|
)
|
||||||
|
from PySide6.QtGui import QIcon
|
||||||
|
from PySide6.QtWidgets import (
|
||||||
|
QApplication,
|
||||||
|
QDialog,
|
||||||
|
QTabWidget,
|
||||||
|
QTextBrowser
|
||||||
|
)
|
||||||
|
|
||||||
|
from gui.ALVersionInfo import (
|
||||||
|
AL_VERSION,
|
||||||
|
AL_COMMIT_SHA,
|
||||||
|
AL_COMMIT_DATE,
|
||||||
|
AL_BUILD_DATE
|
||||||
|
)
|
||||||
|
from gui.resources.ui.Ui_ALAboutDialog import Ui_ALAboutDialog
|
||||||
|
from gui.resources import ALResource
|
||||||
|
|
||||||
|
|
||||||
|
class ALAboutDialog(QDialog, Ui_ALAboutDialog):
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
parent = None
|
||||||
|
):
|
||||||
|
super().__init__(parent)
|
||||||
|
|
||||||
|
self.setupUi(self)
|
||||||
|
self.modifyUi()
|
||||||
|
self.connectSignals()
|
||||||
|
|
||||||
|
def modifyUi(
|
||||||
|
self
|
||||||
|
):
|
||||||
|
|
||||||
|
self.LogoIconLabel.setPixmap(QIcon(":/res/icons/AutoLibrary_Logo_64.svg").pixmap(48, 48))
|
||||||
|
self.TabWidget = QTabWidget()
|
||||||
|
self.TabWidget.setDocumentMode(True)
|
||||||
|
AboutBrowser = QTextBrowser()
|
||||||
|
AboutBrowser.setHtml(self.generateAboutText())
|
||||||
|
AboutBrowser.setOpenExternalLinks(True)
|
||||||
|
AboutBrowser.setLineWrapMode(QTextBrowser.LineWrapMode.NoWrap)
|
||||||
|
AboutBrowser.setTextInteractionFlags(Qt.TextBrowserInteraction)
|
||||||
|
BrowserFont = AboutBrowser.font()
|
||||||
|
BrowserFont.setFamilies(["Courier New", "Consolas", "Menlo", "DejaVu Sans Mono", "monospace"])
|
||||||
|
AboutBrowser.setFont(BrowserFont)
|
||||||
|
self.TabWidget.addTab(AboutBrowser, "关于")
|
||||||
|
LicenseBrowser = QTextBrowser()
|
||||||
|
LicenseBrowser.setHtml(self.generateLicenseText())
|
||||||
|
LicenseBrowser.setOpenExternalLinks(True)
|
||||||
|
LicenseBrowser.setTextInteractionFlags(Qt.TextBrowserInteraction)
|
||||||
|
self.TabWidget.addTab(LicenseBrowser, "许可证")
|
||||||
|
self.AboutInfoLayout.addWidget(self.TabWidget)
|
||||||
|
|
||||||
|
def connectSignals(
|
||||||
|
self
|
||||||
|
):
|
||||||
|
|
||||||
|
self.CopyButton.clicked.connect(self.copyAboutInfo)
|
||||||
|
|
||||||
|
def generateAboutText(
|
||||||
|
self
|
||||||
|
) -> str:
|
||||||
|
|
||||||
|
os_info = self.getOSInfo()
|
||||||
|
run_on = f"{os_info['system']} {os_info['version']} {os_info['architecture']}"
|
||||||
|
selenium_ver = self.getSeleniumVersion()
|
||||||
|
about_text = f"""
|
||||||
|
<b style="font-size:14px;">VERSION: {AL_VERSION}</b><br>
|
||||||
|
Commit SHA: {AL_COMMIT_SHA}<br>
|
||||||
|
Commit date: {AL_COMMIT_DATE}<br>
|
||||||
|
Build date: {AL_BUILD_DATE}<br>
|
||||||
|
<br>
|
||||||
|
<b style="font-size:14px;">SYSTEM</b><br>
|
||||||
|
Running on: {run_on}<br>
|
||||||
|
Processor: {platform.processor()}<br>
|
||||||
|
<br>
|
||||||
|
<b style="font-size:14px;">DEPENDENCIES</b><br>
|
||||||
|
Python: {platform.python_version()}<br>
|
||||||
|
Qt(PySide6): {self.getQtVersion()}<br>
|
||||||
|
Selenium: {selenium_ver}<br>
|
||||||
|
<br>
|
||||||
|
<b style="font-size:14px;">PROJECT</b><br>
|
||||||
|
Website: <a href="https://www.autolibrary.kenanzhu.com" style="text-decoration:none;">https://www.autolibrary.kenanzhu.com</a><br>
|
||||||
|
Repository: <a href="https://www.github.com/KenanZhu/AutoLibrary" style="text-decoration:none;">https://www.github.com/KenanZhu/AutoLibrary</a><br>
|
||||||
|
<br>
|
||||||
|
<b style="font-size:14px;">AUTHOR</b><br>
|
||||||
|
Developer/Maintainer: KenanZhu<br>
|
||||||
|
Contact: <a href="mailto:nanoki_zh@163.com">nanoki_zh@163.com</a><br>
|
||||||
|
GitHub: <a href="https://www.github.com/KenanZhu" style="text-decoration:none;">https://www.github.com/KenanZhu</a><br>
|
||||||
|
"""
|
||||||
|
return about_text
|
||||||
|
|
||||||
|
def generateLicenseText(
|
||||||
|
self
|
||||||
|
) -> str:
|
||||||
|
|
||||||
|
return """
|
||||||
|
<b>MIT License</b>
|
||||||
|
<p>Copyright © 2025 - 2026 KenanZhu</p>
|
||||||
|
<p>Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:</p>
|
||||||
|
<p>The above copyright notice and this permission notice shall be included in
|
||||||
|
all copies or substantial portions of the Software.</p>
|
||||||
|
<p>THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
|
THE SOFTWARE.</p>"""
|
||||||
|
|
||||||
|
def getOSInfo(
|
||||||
|
self
|
||||||
|
):
|
||||||
|
|
||||||
|
system = platform.system()
|
||||||
|
version = platform.version()
|
||||||
|
architecture = platform.architecture()[0]
|
||||||
|
|
||||||
|
if system == "Windows":
|
||||||
|
try:
|
||||||
|
version = platform.win32_ver()[1]
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
elif system == "Darwin":
|
||||||
|
try:
|
||||||
|
version = platform.mac_ver()[0]
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
elif system == "Linux":
|
||||||
|
try:
|
||||||
|
import distro # try to get Linux distro info
|
||||||
|
version = f"{distro.name()} {distro.version()}"
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return {
|
||||||
|
'system': system,
|
||||||
|
'version': version,
|
||||||
|
'architecture': architecture
|
||||||
|
}
|
||||||
|
|
||||||
|
def getQtVersion(
|
||||||
|
self
|
||||||
|
):
|
||||||
|
|
||||||
|
try:
|
||||||
|
from PySide6.QtCore import qVersion
|
||||||
|
return qVersion()
|
||||||
|
except:
|
||||||
|
return "Unknown"
|
||||||
|
|
||||||
|
def getSeleniumVersion(
|
||||||
|
self
|
||||||
|
):
|
||||||
|
|
||||||
|
try:
|
||||||
|
import selenium
|
||||||
|
return selenium.__version__
|
||||||
|
except Exception:
|
||||||
|
return "Unknown"
|
||||||
|
|
||||||
|
def copyAboutInfo(
|
||||||
|
self
|
||||||
|
):
|
||||||
|
|
||||||
|
about_text = self.TabWidget.currentWidget().toPlainText()
|
||||||
|
Clipboard = QApplication.clipboard()
|
||||||
|
Clipboard.setText(about_text)
|
||||||
|
original_text = self.CopyButton.text()
|
||||||
|
self.CopyButton.setText("已复制")
|
||||||
|
QTimer.singleShot(2000, lambda: self.CopyButton.setText(original_text))
|
||||||
@@ -0,0 +1,684 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
Copyright (c) 2026 KenanZhu.
|
||||||
|
All rights reserved.
|
||||||
|
|
||||||
|
This software is provided "as is", without any warranty of any kind.
|
||||||
|
You may use, modify, and distribute this file under the terms of the MIT License.
|
||||||
|
See the LICENSE file for details.
|
||||||
|
"""
|
||||||
|
from copy import deepcopy
|
||||||
|
|
||||||
|
import qtawesome as qta
|
||||||
|
|
||||||
|
from PySide6.QtCore import (
|
||||||
|
QDate,
|
||||||
|
QSize,
|
||||||
|
Qt,
|
||||||
|
QTime,
|
||||||
|
QTimer,
|
||||||
|
Slot
|
||||||
|
)
|
||||||
|
from PySide6.QtGui import (
|
||||||
|
QColor,
|
||||||
|
QFont,
|
||||||
|
QSyntaxHighlighter,
|
||||||
|
QTextCharFormat,
|
||||||
|
)
|
||||||
|
from PySide6.QtWidgets import (
|
||||||
|
QApplication,
|
||||||
|
QComboBox,
|
||||||
|
QDateEdit,
|
||||||
|
QDialog,
|
||||||
|
QDialogButtonBox,
|
||||||
|
QDoubleSpinBox,
|
||||||
|
QFormLayout,
|
||||||
|
QFrame,
|
||||||
|
QGridLayout,
|
||||||
|
QGroupBox,
|
||||||
|
QHBoxLayout,
|
||||||
|
QHeaderView,
|
||||||
|
QLabel,
|
||||||
|
QLineEdit,
|
||||||
|
QMessageBox,
|
||||||
|
QPlainTextEdit,
|
||||||
|
QPushButton,
|
||||||
|
QSpinBox,
|
||||||
|
QSplitter,
|
||||||
|
QStyle,
|
||||||
|
QTabWidget,
|
||||||
|
QTableWidget,
|
||||||
|
QTableWidgetItem,
|
||||||
|
QTimeEdit,
|
||||||
|
QVBoxLayout,
|
||||||
|
QWidget,
|
||||||
|
)
|
||||||
|
|
||||||
|
from autoscript import (
|
||||||
|
createAllVariablesTable,
|
||||||
|
createMockTargetData,
|
||||||
|
createTargetVarDefs,
|
||||||
|
createEngine,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ALScriptHighlighter(QSyntaxHighlighter):
|
||||||
|
"""
|
||||||
|
Syntax highlighter for Lua-based AutoScript.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
parent = None
|
||||||
|
):
|
||||||
|
|
||||||
|
super().__init__(parent)
|
||||||
|
self._rules = []
|
||||||
|
|
||||||
|
KeywordFmt = QTextCharFormat()
|
||||||
|
KeywordFmt.setForeground(QColor("#569CD6"))
|
||||||
|
KeywordFmt.setFontWeight(QFont.Weight.Bold)
|
||||||
|
for kw in [
|
||||||
|
"if", "elseif", "else", "end", "then",
|
||||||
|
"and", "or", "not",
|
||||||
|
"local", "function", "return", "nil",
|
||||||
|
]:
|
||||||
|
self._rules.append((r"\b" + kw + r"\b", KeywordFmt))
|
||||||
|
BoolFmt = QTextCharFormat()
|
||||||
|
BoolFmt.setForeground(QColor("#4FC1FF"))
|
||||||
|
BoolFmt.setFontWeight(QFont.Weight.Bold)
|
||||||
|
self._rules.append((r"\btrue\b", BoolFmt))
|
||||||
|
self._rules.append((r"\bfalse\b", BoolFmt))
|
||||||
|
CmpFmt = QTextCharFormat()
|
||||||
|
CmpFmt.setForeground(QColor("#C586C0"))
|
||||||
|
CmpFmt.setFontWeight(QFont.Weight.Normal)
|
||||||
|
for op in [r"==", r"~=", r">=", r"<=", r">", r"<"]:
|
||||||
|
self._rules.append((op, CmpFmt))
|
||||||
|
ArithFmt = QTextCharFormat()
|
||||||
|
ArithFmt.setForeground(QColor("#C586C0"))
|
||||||
|
ArithFmt.setFontWeight(QFont.Weight.Normal)
|
||||||
|
for op in [r"\+", r"-", r"\*", r"/", r"\.\."]:
|
||||||
|
self._rules.append((op, ArithFmt))
|
||||||
|
FuncFmt = QTextCharFormat()
|
||||||
|
FuncFmt.setForeground(QColor("#DCDCAA"))
|
||||||
|
FuncFmt.setFontWeight(QFont.Weight.Normal)
|
||||||
|
for fn in [ "time", "date", "datenow", "timenow", "dateadd", "timeadd"]:
|
||||||
|
self._rules.append((r"\b" + fn + r"\b", FuncFmt))
|
||||||
|
VarFmt = QTextCharFormat()
|
||||||
|
VarFmt.setForeground(QColor("#9CDCFE"))
|
||||||
|
VarFmt.setFontWeight(QFont.Weight.Normal)
|
||||||
|
var_names = [name for _, (name, _) in createAllVariablesTable().items()]
|
||||||
|
for var in var_names:
|
||||||
|
self._rules.append((r"\b" + var + r"\b", VarFmt))
|
||||||
|
StrFmt = QTextCharFormat()
|
||||||
|
StrFmt.setForeground(QColor("#CE9178"))
|
||||||
|
StrFmt.setFontWeight(QFont.Weight.Normal)
|
||||||
|
self._rules.append((r'"[^"]*"', StrFmt))
|
||||||
|
self._rules.append((r"'[^']*'", StrFmt))
|
||||||
|
NumFmt = QTextCharFormat()
|
||||||
|
NumFmt.setForeground(QColor("#B5CEA8"))
|
||||||
|
NumFmt.setFontWeight(QFont.Weight.Normal)
|
||||||
|
self._rules.append((r"\b\d+(?:\.\d+)?\b", NumFmt))
|
||||||
|
CommentFmt = QTextCharFormat()
|
||||||
|
CommentFmt.setForeground(QColor("#6A9955"))
|
||||||
|
CommentFmt.setFontItalic(True)
|
||||||
|
self._rules.append((r"--[^\n]*", CommentFmt))
|
||||||
|
|
||||||
|
def highlightBlock(
|
||||||
|
self,
|
||||||
|
text
|
||||||
|
):
|
||||||
|
|
||||||
|
import re
|
||||||
|
for pattern, fmt in self._rules:
|
||||||
|
for match in re.finditer(pattern, text, re.IGNORECASE):
|
||||||
|
start = match.start()
|
||||||
|
length = match.end() - match.start()
|
||||||
|
self.setFormat(start, length, fmt)
|
||||||
|
|
||||||
|
|
||||||
|
class _DebugResultDialog(QDialog):
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
changes: list,
|
||||||
|
parent = None
|
||||||
|
):
|
||||||
|
|
||||||
|
super().__init__(parent)
|
||||||
|
self.setWindowTitle("调试运行结果 - AutoLibrary")
|
||||||
|
self.setMinimumSize(600, 200)
|
||||||
|
DbgLayout = QVBoxLayout(self)
|
||||||
|
DbgTable = QTableWidget(len(changes), 3)
|
||||||
|
DbgTable.setHorizontalHeaderLabels(["目标变量", "原始数据", "运行后数据"])
|
||||||
|
DbgTable.horizontalHeader().setStretchLastSection(True)
|
||||||
|
DbgTable.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch)
|
||||||
|
DbgTable.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers)
|
||||||
|
for row, (display_name, name, var_type, before_val, after_val) in enumerate(changes):
|
||||||
|
label = f"{display_name}: {name}({var_type})"
|
||||||
|
DbgTable.setItem(row, 0, QTableWidgetItem(label))
|
||||||
|
DbgTable.setItem(row, 1, QTableWidgetItem(str(before_val)))
|
||||||
|
DbgTable.setItem(row, 2, QTableWidgetItem(str(after_val)))
|
||||||
|
DbgLayout.addWidget(DbgTable)
|
||||||
|
DbgBtnBox = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok)
|
||||||
|
DbgBtnBox.button(QDialogButtonBox.StandardButton.Ok).setText("确定")
|
||||||
|
DbgBtnBox.accepted.connect(self.accept)
|
||||||
|
DbgLayout.addWidget(DbgBtnBox)
|
||||||
|
|
||||||
|
|
||||||
|
class _TabToSpacesEditor(QPlainTextEdit):
|
||||||
|
|
||||||
|
def keyPressEvent(
|
||||||
|
self,
|
||||||
|
event
|
||||||
|
):
|
||||||
|
|
||||||
|
if event.key() == Qt.Key.Key_Tab:
|
||||||
|
self.insertPlainText(" ")
|
||||||
|
return
|
||||||
|
super().keyPressEvent(event)
|
||||||
|
|
||||||
|
|
||||||
|
class ALAutoScriptEditDialog(QDialog):
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
parent = None,
|
||||||
|
script: str = "",
|
||||||
|
mockData: dict = None
|
||||||
|
):
|
||||||
|
|
||||||
|
super().__init__(parent)
|
||||||
|
self._fontSize = 21
|
||||||
|
self._mockWidgets = {}
|
||||||
|
|
||||||
|
self.setupUi()
|
||||||
|
self.connectSignals()
|
||||||
|
self.TextEdit.setPlainText(script)
|
||||||
|
self._Highlighter = ALScriptHighlighter(
|
||||||
|
self.TextEdit.document()
|
||||||
|
)
|
||||||
|
if mockData:
|
||||||
|
self.setMockData(mockData)
|
||||||
|
|
||||||
|
def setupUi(
|
||||||
|
self
|
||||||
|
):
|
||||||
|
|
||||||
|
self.setWindowTitle("AutoScript 编辑 - AutoLibrary")
|
||||||
|
self.setMinimumSize(660, 600)
|
||||||
|
Layout = QVBoxLayout(self)
|
||||||
|
Layout.setSpacing(3)
|
||||||
|
Layout.setContentsMargins(3, 3, 3, 3)
|
||||||
|
ToolbarLayout = QHBoxLayout()
|
||||||
|
self.ZoomInBtn = QPushButton("")
|
||||||
|
self.ZoomInBtn.setIcon(qta.icon("fa6s.plus", color=self._iconColor()))
|
||||||
|
self.ZoomInBtn.setIconSize(QSize(14, 14))
|
||||||
|
self.ZoomInBtn.setFixedSize(25, 25)
|
||||||
|
self.ZoomOutBtn = QPushButton("")
|
||||||
|
self.ZoomOutBtn.setIcon(qta.icon("fa6s.minus", color=self._iconColor()))
|
||||||
|
self.ZoomOutBtn.setIconSize(QSize(14, 14))
|
||||||
|
self.ZoomOutBtn.setFixedSize(25, 25)
|
||||||
|
self.ZoomResetBtn = QPushButton("")
|
||||||
|
self.ZoomResetBtn.setIcon(qta.icon("fa6s.rotate-left", color=self._iconColor()))
|
||||||
|
self.ZoomResetBtn.setIconSize(QSize(14, 14))
|
||||||
|
self.ZoomResetBtn.setFixedSize(25, 25)
|
||||||
|
self.ZoomResetBtn.setToolTip("重置缩放")
|
||||||
|
self.ZoomLabel = QLabel(f"{self._fontSize}px")
|
||||||
|
self.ZoomLabel.setFixedHeight(25)
|
||||||
|
self.OrchBtn = QPushButton("编排")
|
||||||
|
self.OrchBtn.setFixedSize(80, 25)
|
||||||
|
self.OrchBtn.setToolTip("可视化生成 AutoScript 代码并插入到光标位置")
|
||||||
|
ToolbarLayout.addWidget(self.OrchBtn)
|
||||||
|
self.DebugBtn = QPushButton("▶ 调试运行")
|
||||||
|
self.DebugBtn.setFixedSize(80, 25)
|
||||||
|
self.DebugBtn.setToolTip("使用右侧模拟数据执行脚本,查看目标变量变化")
|
||||||
|
ToolbarLayout.addWidget(self.DebugBtn)
|
||||||
|
Sep = QFrame()
|
||||||
|
Sep.setFrameShape(QFrame.Shape.VLine)
|
||||||
|
Sep.setFrameShadow(QFrame.Shadow.Sunken)
|
||||||
|
Sep.setFixedWidth(1)
|
||||||
|
ToolbarLayout.addWidget(Sep)
|
||||||
|
ToolbarLayout.addWidget(self.ZoomInBtn)
|
||||||
|
ToolbarLayout.addWidget(self.ZoomOutBtn)
|
||||||
|
ToolbarLayout.addWidget(self.ZoomResetBtn)
|
||||||
|
ToolbarLayout.addWidget(self.ZoomLabel)
|
||||||
|
ToolbarLayout.addStretch()
|
||||||
|
self.CopyBtn = QPushButton("")
|
||||||
|
self.CopyBtn.setIcon(qta.icon("fa6s.copy", color=self._iconColor()))
|
||||||
|
self.CopyBtn.setIconSize(QSize(14, 14))
|
||||||
|
self.CopyBtn.setFixedSize(25, 25)
|
||||||
|
self.CopyBtn.setToolTip("复制脚本")
|
||||||
|
ToolbarLayout.addWidget(self.CopyBtn)
|
||||||
|
Layout.addLayout(ToolbarLayout)
|
||||||
|
self.TextEdit = _TabToSpacesEditor(self)
|
||||||
|
self.TextEdit.setTabStopDistance(40)
|
||||||
|
self.TextEdit.setLineWrapMode(
|
||||||
|
QPlainTextEdit.LineWrapMode.NoWrap
|
||||||
|
)
|
||||||
|
self.TextEdit.setStyleSheet(
|
||||||
|
"QPlainTextEdit {"
|
||||||
|
" font-family: 'Courier New', 'Consolas', monospace;"
|
||||||
|
f" font-size: {self._fontSize}px;"
|
||||||
|
"}"
|
||||||
|
)
|
||||||
|
Layout.addWidget(self.TextEdit)
|
||||||
|
self.createButtonPanel(Layout)
|
||||||
|
self.BtnBox = QDialogButtonBox(
|
||||||
|
QDialogButtonBox.StandardButton.Ok |
|
||||||
|
QDialogButtonBox.StandardButton.Cancel
|
||||||
|
)
|
||||||
|
self.BtnBox.button(QDialogButtonBox.StandardButton.Ok).setText("确定")
|
||||||
|
self.BtnBox.button(QDialogButtonBox.StandardButton.Ok).setFixedSize(80, 25)
|
||||||
|
self.BtnBox.button(QDialogButtonBox.StandardButton.Cancel).setText("取消")
|
||||||
|
self.BtnBox.button(QDialogButtonBox.StandardButton.Cancel).setFixedSize(80, 25)
|
||||||
|
Layout.addWidget(self.BtnBox)
|
||||||
|
|
||||||
|
def createButtonPanel(
|
||||||
|
self,
|
||||||
|
ParentLayout
|
||||||
|
):
|
||||||
|
|
||||||
|
Splitter = QSplitter(Qt.Orientation.Horizontal)
|
||||||
|
TabWidget = QTabWidget()
|
||||||
|
TabWidget.setMaximumHeight(150)
|
||||||
|
BasicWidget = QWidget()
|
||||||
|
BasicLayout = QGridLayout(BasicWidget)
|
||||||
|
BasicLayout.setSpacing(4)
|
||||||
|
BasicLayout.setContentsMargins(4, 4, 4, 4)
|
||||||
|
BasicLayout.setAlignment(Qt.AlignLeft | Qt.AlignTop)
|
||||||
|
controlButtons = [
|
||||||
|
("如果 (if...)", "if then\n \nend"),
|
||||||
|
("再如果 (elseif...)", "elseif then\n "),
|
||||||
|
("否则 (else)", "else"),
|
||||||
|
("结束 (end)", "end"),
|
||||||
|
("跳过 (pass)", "-- pass"),
|
||||||
|
]
|
||||||
|
self.addButtonsToGrid(BasicLayout, controlButtons, 0, 0, 3)
|
||||||
|
assignButtons = [
|
||||||
|
("赋值 (=)", " = "),
|
||||||
|
]
|
||||||
|
self.addButtonsToGrid(BasicLayout, assignButtons, 1, 2, 3)
|
||||||
|
TabWidget.addTab(BasicWidget, "基本语法")
|
||||||
|
OperatorWidget = QWidget()
|
||||||
|
OperatorLayout = QGridLayout(OperatorWidget)
|
||||||
|
OperatorLayout.setSpacing(4)
|
||||||
|
OperatorLayout.setContentsMargins(4, 4, 4, 4)
|
||||||
|
OperatorLayout.setAlignment(Qt.AlignLeft | Qt.AlignTop)
|
||||||
|
arithmeticButtons = [
|
||||||
|
("加 (+)", " + "),
|
||||||
|
("减 (-)", " - "),
|
||||||
|
]
|
||||||
|
self.addButtonsToGrid(OperatorLayout, arithmeticButtons, 0, 0, 3)
|
||||||
|
compareButtons = [
|
||||||
|
("等于 (==)", " == "),
|
||||||
|
("不等于 (~=)", " ~= "),
|
||||||
|
("大于 (>)", " > "),
|
||||||
|
("小于 (<)", " < "),
|
||||||
|
("大于等于 (>=)", " >= "),
|
||||||
|
("小于等于 (<=)", " <= "),
|
||||||
|
]
|
||||||
|
self.addButtonsToGrid(OperatorLayout, compareButtons, 1, 0, 3)
|
||||||
|
logic_buttons = [
|
||||||
|
("且 (and)", " and "),
|
||||||
|
("或 (or)", " or "),
|
||||||
|
]
|
||||||
|
self.addButtonsToGrid(OperatorLayout, logic_buttons, 2, 0, 3)
|
||||||
|
TabWidget.addTab(OperatorWidget, "运算符")
|
||||||
|
LiteralWidget = QWidget()
|
||||||
|
LiteralLayout = QGridLayout(LiteralWidget)
|
||||||
|
LiteralLayout.setSpacing(4)
|
||||||
|
LiteralLayout.setContentsMargins(4, 4, 4, 4)
|
||||||
|
LiteralLayout.setAlignment(Qt.AlignLeft | Qt.AlignTop)
|
||||||
|
bool_buttons = [
|
||||||
|
("真 (true)", "true"),
|
||||||
|
("假 (false)", "false"),
|
||||||
|
]
|
||||||
|
self.addButtonsToGrid(LiteralLayout, bool_buttons, 0, 0, 3)
|
||||||
|
dateTimeButtons = [
|
||||||
|
("日期", 'date(2026, 1, 1)'),
|
||||||
|
("时间", 'time(0, 0)'),
|
||||||
|
]
|
||||||
|
self.addButtonsToGrid(LiteralLayout, dateTimeButtons, 1, 0, 3)
|
||||||
|
hintButtons = [
|
||||||
|
("字符串", '"请输入文本"'),
|
||||||
|
("数字", "123"),
|
||||||
|
("注释", "-- 请输入注释"),
|
||||||
|
]
|
||||||
|
self.addButtonsToGrid(LiteralLayout, hintButtons, 2, 0, 3)
|
||||||
|
TabWidget.addTab(LiteralWidget, "字面量")
|
||||||
|
VarWidget = QWidget()
|
||||||
|
VarLayout = QGridLayout(VarWidget)
|
||||||
|
VarLayout.setSpacing(4)
|
||||||
|
VarLayout.setContentsMargins(4, 4, 4, 4)
|
||||||
|
VarLayout.setAlignment(Qt.AlignLeft | Qt.AlignTop)
|
||||||
|
varButtons = [
|
||||||
|
(display_name, name) for display_name, (name, _) in createAllVariablesTable().items()
|
||||||
|
]
|
||||||
|
self.addButtonsToGrid(VarLayout, varButtons, 0, 0, 3)
|
||||||
|
TabWidget.addTab(VarWidget, "变量")
|
||||||
|
FuncWidget = QWidget()
|
||||||
|
FuncLayout = QGridLayout(FuncWidget)
|
||||||
|
FuncLayout.setSpacing(4)
|
||||||
|
FuncLayout.setContentsMargins(4, 4, 4, 4)
|
||||||
|
FuncLayout.setAlignment(Qt.AlignLeft | Qt.AlignTop)
|
||||||
|
funcButtons = [
|
||||||
|
("datenow()", "datenow()", "返回当前日期的 Unix 时间戳"),
|
||||||
|
("timenow()", "timenow()", "返回当前时间在一天中的分钟数"),
|
||||||
|
("dateadd(day, n)", "dateadd(, )", "日期偏移: dateadd(日期时间戳, 天数)"),
|
||||||
|
("timeadd(time, n)", "timeadd(, )", "时间偏移: timeadd(分钟数, 分钟数)"),
|
||||||
|
]
|
||||||
|
for i, (text, template, tooltip) in enumerate(funcButtons):
|
||||||
|
Btn = QPushButton(text)
|
||||||
|
Btn.setProperty("template", template)
|
||||||
|
Btn.clicked.connect(self.insertTemplate)
|
||||||
|
Btn.setFixedWidth(100)
|
||||||
|
Btn.setFixedHeight(25)
|
||||||
|
Btn.setToolTip(tooltip)
|
||||||
|
FuncLayout.addWidget(Btn, i // 2, i % 2)
|
||||||
|
TabWidget.addTab(FuncWidget, "工具函数")
|
||||||
|
MockPanel = self.createMockPanel()
|
||||||
|
MockPanel.setMinimumWidth(260)
|
||||||
|
Splitter.addWidget(TabWidget)
|
||||||
|
Splitter.addWidget(MockPanel)
|
||||||
|
Splitter.setStretchFactor(0, 1)
|
||||||
|
Splitter.setStretchFactor(1, 1)
|
||||||
|
Splitter.setSizes([530, 530])
|
||||||
|
ParentLayout.addWidget(Splitter)
|
||||||
|
|
||||||
|
def addButtonsToGrid(
|
||||||
|
self,
|
||||||
|
grid_layout,
|
||||||
|
buttons,
|
||||||
|
start_row,
|
||||||
|
start_col,
|
||||||
|
max_columns
|
||||||
|
):
|
||||||
|
|
||||||
|
col = start_col
|
||||||
|
row = start_row
|
||||||
|
|
||||||
|
for btn_text, template in buttons:
|
||||||
|
Btn = QPushButton(btn_text)
|
||||||
|
Btn.setProperty("template", template)
|
||||||
|
Btn.clicked.connect(self.insertTemplate)
|
||||||
|
Btn.setFixedWidth(100)
|
||||||
|
Btn.setFixedHeight(25)
|
||||||
|
Btn.setToolTip(f"插入: {template}")
|
||||||
|
grid_layout.addWidget(Btn, row, col)
|
||||||
|
col += 1
|
||||||
|
if col >= start_col + max_columns:
|
||||||
|
col = start_col
|
||||||
|
row += 1
|
||||||
|
|
||||||
|
def createMockPanel(
|
||||||
|
self
|
||||||
|
) -> QGroupBox:
|
||||||
|
|
||||||
|
Group = QGroupBox("模拟目标数据")
|
||||||
|
Form = QFormLayout(Group)
|
||||||
|
Form.setSpacing(4)
|
||||||
|
Form.setContentsMargins(5, 10, 5, 5)
|
||||||
|
self._mockWidgets = {}
|
||||||
|
mockData = createMockTargetData()
|
||||||
|
for name, var_type, key_path, display_name in createTargetVarDefs():
|
||||||
|
d = mockData
|
||||||
|
for key in key_path:
|
||||||
|
d = d[key]
|
||||||
|
default = d
|
||||||
|
Widget = self.makeMockInput(var_type, default)
|
||||||
|
Label = QLabel(f"{display_name}: {name}({var_type})")
|
||||||
|
Form.addRow(Label, Widget)
|
||||||
|
self._mockWidgets[name] = (Widget, var_type, key_path)
|
||||||
|
return Group
|
||||||
|
|
||||||
|
def makeMockInput(
|
||||||
|
self,
|
||||||
|
var_type: str,
|
||||||
|
default
|
||||||
|
) -> QWidget:
|
||||||
|
|
||||||
|
if var_type == "String":
|
||||||
|
W = QLineEdit()
|
||||||
|
W.setText(str(default))
|
||||||
|
return W
|
||||||
|
if var_type == "Boolean":
|
||||||
|
W = QComboBox()
|
||||||
|
W.addItems(["是", "否"])
|
||||||
|
W.setCurrentIndex(0 if default else 1)
|
||||||
|
return W
|
||||||
|
if var_type == "Date":
|
||||||
|
W = QDateEdit()
|
||||||
|
W.setCalendarPopup(True)
|
||||||
|
W.setDisplayFormat("yyyy-MM-dd")
|
||||||
|
W.setDate(QDate.fromString(str(default), "yyyy-MM-dd"))
|
||||||
|
return W
|
||||||
|
if var_type == "Time":
|
||||||
|
W = QTimeEdit()
|
||||||
|
W.setDisplayFormat("HH:mm")
|
||||||
|
W.setTime(QTime.fromString(str(default), "HH:mm"))
|
||||||
|
return W
|
||||||
|
if var_type == "Int":
|
||||||
|
W = QSpinBox()
|
||||||
|
W.setMinimum(-999999)
|
||||||
|
W.setMaximum(999999)
|
||||||
|
W.setValue(int(default) if default else 0)
|
||||||
|
return W
|
||||||
|
if var_type == "Float":
|
||||||
|
W = QDoubleSpinBox()
|
||||||
|
W.setMinimum(-999999.0)
|
||||||
|
W.setMaximum(999999.0)
|
||||||
|
W.setDecimals(2)
|
||||||
|
W.setValue(float(default) if default else 0.0)
|
||||||
|
return W
|
||||||
|
W = QLineEdit()
|
||||||
|
W.setText(str(default))
|
||||||
|
return W
|
||||||
|
|
||||||
|
def getMockData(
|
||||||
|
self
|
||||||
|
) -> dict:
|
||||||
|
|
||||||
|
data = {}
|
||||||
|
for name, var_type, key_path, display_name in createTargetVarDefs():
|
||||||
|
widget, _, _ = self._mockWidgets[name]
|
||||||
|
value = self.getMockValue(widget, var_type)
|
||||||
|
d = data
|
||||||
|
for key in key_path[:-1]:
|
||||||
|
d = d.setdefault(key, {})
|
||||||
|
d[key_path[-1]] = value
|
||||||
|
return data
|
||||||
|
|
||||||
|
def setMockData(
|
||||||
|
self,
|
||||||
|
data: dict
|
||||||
|
):
|
||||||
|
|
||||||
|
if not data:
|
||||||
|
return
|
||||||
|
for name, var_type, key_path, display_name in createTargetVarDefs():
|
||||||
|
d = data
|
||||||
|
try:
|
||||||
|
for key in key_path:
|
||||||
|
d = d[key]
|
||||||
|
except (KeyError, TypeError):
|
||||||
|
continue
|
||||||
|
widget, _, _ = self._mockWidgets[name]
|
||||||
|
self.setMockValue(widget, var_type, d)
|
||||||
|
|
||||||
|
def getMockValue(
|
||||||
|
self,
|
||||||
|
widget: QWidget,
|
||||||
|
var_type: str
|
||||||
|
):
|
||||||
|
|
||||||
|
if var_type == "Boolean":
|
||||||
|
return widget.currentIndex() == 0
|
||||||
|
if var_type == "Date":
|
||||||
|
return widget.date().toString("yyyy-MM-dd")
|
||||||
|
if var_type == "Time":
|
||||||
|
return widget.time().toString("HH:mm")
|
||||||
|
if var_type == "Int":
|
||||||
|
return widget.value()
|
||||||
|
if var_type == "Float":
|
||||||
|
return widget.value()
|
||||||
|
return widget.text()
|
||||||
|
|
||||||
|
def setMockValue(
|
||||||
|
self,
|
||||||
|
widget: QWidget,
|
||||||
|
var_type: str,
|
||||||
|
value
|
||||||
|
):
|
||||||
|
|
||||||
|
if var_type == "Boolean":
|
||||||
|
widget.setCurrentIndex(0 if value else 1)
|
||||||
|
elif var_type == "Date":
|
||||||
|
widget.setDate(QDate.fromString(str(value), "yyyy-MM-dd"))
|
||||||
|
elif var_type == "Time":
|
||||||
|
widget.setTime(QTime.fromString(str(value), "HH:mm"))
|
||||||
|
elif var_type == "Int":
|
||||||
|
widget.setValue(int(value))
|
||||||
|
elif var_type == "Float":
|
||||||
|
widget.setValue(float(value))
|
||||||
|
else:
|
||||||
|
widget.setText(str(value))
|
||||||
|
|
||||||
|
def _iconColor(
|
||||||
|
self
|
||||||
|
) -> str:
|
||||||
|
|
||||||
|
return QApplication.instance().palette().color(
|
||||||
|
QApplication.instance().palette().ColorRole.WindowText
|
||||||
|
).name()
|
||||||
|
|
||||||
|
def connectSignals(
|
||||||
|
self
|
||||||
|
):
|
||||||
|
|
||||||
|
self.BtnBox.accepted.connect(self.accept)
|
||||||
|
self.BtnBox.rejected.connect(self.reject)
|
||||||
|
self.OrchBtn.clicked.connect(self.onOpenOrchDialog)
|
||||||
|
self.DebugBtn.clicked.connect(self.onDebugRun)
|
||||||
|
self.ZoomInBtn.clicked.connect(self.onZoomIn)
|
||||||
|
self.ZoomOutBtn.clicked.connect(self.onZoomOut)
|
||||||
|
self.ZoomResetBtn.clicked.connect(self.onZoomReset)
|
||||||
|
self.CopyBtn.clicked.connect(self.onCopy)
|
||||||
|
|
||||||
|
def getScript(
|
||||||
|
self
|
||||||
|
) -> str:
|
||||||
|
|
||||||
|
return self.TextEdit.toPlainText()
|
||||||
|
|
||||||
|
def updateFontSize(
|
||||||
|
self
|
||||||
|
):
|
||||||
|
|
||||||
|
self.TextEdit.setStyleSheet(
|
||||||
|
"QPlainTextEdit {"
|
||||||
|
" font-family: 'Courier New', 'Consolas', monospace;"
|
||||||
|
f" font-size: {self._fontSize}px;"
|
||||||
|
"}"
|
||||||
|
)
|
||||||
|
self.ZoomLabel.setText(f"{self._fontSize}px")
|
||||||
|
|
||||||
|
@Slot()
|
||||||
|
def insertTemplate(
|
||||||
|
self
|
||||||
|
):
|
||||||
|
|
||||||
|
Btn = self.sender()
|
||||||
|
if not isinstance(Btn, QPushButton):
|
||||||
|
return
|
||||||
|
template = Btn.property("template")
|
||||||
|
if not template:
|
||||||
|
return
|
||||||
|
cursor = self.TextEdit.textCursor()
|
||||||
|
cursor.insertText(template)
|
||||||
|
|
||||||
|
@Slot()
|
||||||
|
def onZoomIn(
|
||||||
|
self
|
||||||
|
):
|
||||||
|
|
||||||
|
self._fontSize = min(self._fontSize + 2, 40)
|
||||||
|
self.updateFontSize()
|
||||||
|
|
||||||
|
@Slot()
|
||||||
|
def onZoomOut(
|
||||||
|
self
|
||||||
|
):
|
||||||
|
|
||||||
|
self._fontSize = max(self._fontSize - 2, 8)
|
||||||
|
self.updateFontSize()
|
||||||
|
|
||||||
|
@Slot()
|
||||||
|
def onZoomReset(
|
||||||
|
self
|
||||||
|
):
|
||||||
|
|
||||||
|
self._fontSize = 21
|
||||||
|
self.updateFontSize()
|
||||||
|
|
||||||
|
@Slot()
|
||||||
|
def onCopy(
|
||||||
|
self
|
||||||
|
):
|
||||||
|
|
||||||
|
Clipboard = QApplication.clipboard()
|
||||||
|
Clipboard.setText(self.TextEdit.toPlainText())
|
||||||
|
self.CopyBtn.setEnabled(False)
|
||||||
|
QTimer.singleShot(2000, lambda: (
|
||||||
|
self.CopyBtn.setEnabled(True)
|
||||||
|
))
|
||||||
|
|
||||||
|
@Slot()
|
||||||
|
def onOpenOrchDialog(
|
||||||
|
self
|
||||||
|
):
|
||||||
|
|
||||||
|
from gui.ALAutoScriptOrchDialog import ALAutoScriptOrchDialog
|
||||||
|
Dlg = ALAutoScriptOrchDialog(self)
|
||||||
|
if Dlg.exec() == QDialog.DialogCode.Accepted:
|
||||||
|
script = Dlg.getScript()
|
||||||
|
if script:
|
||||||
|
cursor = self.TextEdit.textCursor()
|
||||||
|
cursor.insertText(script)
|
||||||
|
Dlg.deleteLater()
|
||||||
|
|
||||||
|
@Slot()
|
||||||
|
def onDebugRun(
|
||||||
|
self
|
||||||
|
):
|
||||||
|
|
||||||
|
script = self.TextEdit.toPlainText().strip()
|
||||||
|
if not script:
|
||||||
|
QMessageBox.warning(self, "提示", "脚本内容为空。")
|
||||||
|
return
|
||||||
|
target_data = self.getMockData()
|
||||||
|
before = deepcopy(target_data)
|
||||||
|
try:
|
||||||
|
engine = createEngine()
|
||||||
|
engine.execute(script, target_data)
|
||||||
|
except ValueError as e:
|
||||||
|
QMessageBox.warning(self, "运行错误", str(e))
|
||||||
|
return
|
||||||
|
changes = []
|
||||||
|
for name, var_type, key_path, display_name in createTargetVarDefs():
|
||||||
|
before_val = before
|
||||||
|
after_val = target_data
|
||||||
|
try:
|
||||||
|
for key in key_path:
|
||||||
|
before_val = before_val[key]
|
||||||
|
after_val = after_val[key]
|
||||||
|
except (KeyError, TypeError):
|
||||||
|
continue
|
||||||
|
if before_val != after_val:
|
||||||
|
changes.append((display_name, name, var_type, before_val, after_val))
|
||||||
|
if not changes:
|
||||||
|
QMessageBox.information(self, "调试运行", "目标变量未发生变化。")
|
||||||
|
return
|
||||||
|
Dlg = _DebugResultDialog(changes, self)
|
||||||
|
Dlg.exec()
|
||||||
|
Dlg.deleteLater()
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
Copyright (c) 2026 KenanZhu.
|
||||||
|
All rights reserved.
|
||||||
|
|
||||||
|
This software is provided "as is", without any warranty of any kind.
|
||||||
|
You may use, modify, and distribute this file under the terms of the MIT License.
|
||||||
|
See the LICENSE file for details.
|
||||||
|
"""
|
||||||
|
from ._dialog import ALAutoScriptOrchDialog
|
||||||
@@ -0,0 +1,265 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
Copyright (c) 2026 KenanZhu.
|
||||||
|
All rights reserved.
|
||||||
|
|
||||||
|
This software is provided "as is", without any warranty of any kind.
|
||||||
|
You may use, modify, and distribute this file under the terms of the MIT License.
|
||||||
|
See the LICENSE file for details.
|
||||||
|
"""
|
||||||
|
"""
|
||||||
|
Conditional block widget for the AutoScript orchestration dialog.
|
||||||
|
"""
|
||||||
|
from PySide6.QtCore import Slot
|
||||||
|
from PySide6.QtWidgets import (
|
||||||
|
QComboBox,
|
||||||
|
QGroupBox,
|
||||||
|
QHBoxLayout,
|
||||||
|
QLabel,
|
||||||
|
QPushButton,
|
||||||
|
QSizePolicy,
|
||||||
|
QVBoxLayout,
|
||||||
|
QWidget,
|
||||||
|
)
|
||||||
|
|
||||||
|
from gui.ALAutoScriptOrchDialog._widgets import (
|
||||||
|
ActionStepFrame,
|
||||||
|
ConditionRowFrame,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ConditionalBlock(QGroupBox):
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
blockIndex: int,
|
||||||
|
varMgr = None,
|
||||||
|
parent = None
|
||||||
|
):
|
||||||
|
|
||||||
|
super().__init__(parent)
|
||||||
|
self.blockIndex = blockIndex
|
||||||
|
self._varMgr = varMgr
|
||||||
|
self._actionWidgets = []
|
||||||
|
self._conditionRows = []
|
||||||
|
|
||||||
|
self.setupUi()
|
||||||
|
self.connectSignals()
|
||||||
|
self.addInitialConditionRow()
|
||||||
|
|
||||||
|
def setupUi(
|
||||||
|
self
|
||||||
|
):
|
||||||
|
|
||||||
|
self.setUpdatesEnabled(False)
|
||||||
|
self.setStyleSheet(
|
||||||
|
"QGroupBox { font-weight: bold; border: 1px solid #ccc; "
|
||||||
|
"margin-top: 5px; padding-top: 5px; }"
|
||||||
|
)
|
||||||
|
self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
|
||||||
|
MainLayout = QVBoxLayout(self)
|
||||||
|
MainLayout.setSpacing(6)
|
||||||
|
MainLayout.setContentsMargins(8, 8, 8, 8)
|
||||||
|
HeaderLayout = QHBoxLayout()
|
||||||
|
HeaderLayout.setSpacing(8)
|
||||||
|
self.TypeCombo = QComboBox(self)
|
||||||
|
self.TypeCombo.addItem("IF", "IF")
|
||||||
|
self.TypeCombo.addItem("ELSE IF", "ELSE IF")
|
||||||
|
self.TypeCombo.addItem("ELSE", "ELSE")
|
||||||
|
self.TypeCombo.setFixedHeight(25)
|
||||||
|
if self.blockIndex == 0:
|
||||||
|
self.TypeCombo.setEnabled(False)
|
||||||
|
HeaderLayout.addWidget(QLabel("类型:", self))
|
||||||
|
HeaderLayout.addWidget(self.TypeCombo)
|
||||||
|
HeaderLayout.addStretch()
|
||||||
|
self.DeleteBlockBtn = QPushButton("删除此块", self)
|
||||||
|
self.DeleteBlockBtn.setStyleSheet("color: red;")
|
||||||
|
self.DeleteBlockBtn.setFixedHeight(25)
|
||||||
|
HeaderLayout.addWidget(self.DeleteBlockBtn)
|
||||||
|
MainLayout.addLayout(HeaderLayout)
|
||||||
|
self.ConditionWidget = QWidget(self)
|
||||||
|
self.ConditionWidget.setSizePolicy(
|
||||||
|
QSizePolicy.Preferred, QSizePolicy.Preferred
|
||||||
|
)
|
||||||
|
CondLayout = QVBoxLayout(self.ConditionWidget)
|
||||||
|
CondLayout.setContentsMargins(4, 4, 4, 4)
|
||||||
|
CondLayout.setSpacing(6)
|
||||||
|
self.CondRowsLayout = QVBoxLayout()
|
||||||
|
self.CondRowsLayout.setSpacing(4)
|
||||||
|
CondLayout.addLayout(self.CondRowsLayout)
|
||||||
|
self.AddCondBtn = QPushButton("+ 添加条件", self.ConditionWidget)
|
||||||
|
self.AddCondBtn.setFixedHeight(25)
|
||||||
|
CondLayout.addWidget(self.AddCondBtn)
|
||||||
|
MainLayout.addWidget(self.ConditionWidget)
|
||||||
|
self.ActionLabel = QLabel("执行步骤:", self)
|
||||||
|
self.ActionLabel.setFixedHeight(25)
|
||||||
|
MainLayout.addWidget(self.ActionLabel)
|
||||||
|
self.ActionsLayout = QVBoxLayout()
|
||||||
|
self.ActionsLayout.setSpacing(4)
|
||||||
|
MainLayout.addLayout(self.ActionsLayout)
|
||||||
|
self.AddActionBtn = QPushButton("+ 添加执行步骤", self)
|
||||||
|
self.AddActionBtn.setFixedHeight(25)
|
||||||
|
MainLayout.addWidget(self.AddActionBtn)
|
||||||
|
self.setUpdatesEnabled(True)
|
||||||
|
|
||||||
|
def connectSignals(
|
||||||
|
self
|
||||||
|
):
|
||||||
|
|
||||||
|
self.TypeCombo.currentIndexChanged.connect(self.onTypeChanged)
|
||||||
|
self.AddCondBtn.clicked.connect(self.addConditionRow)
|
||||||
|
self.AddActionBtn.clicked.connect(self.addActionStep)
|
||||||
|
|
||||||
|
def addInitialConditionRow(
|
||||||
|
self
|
||||||
|
):
|
||||||
|
|
||||||
|
Row = ConditionRowFrame(
|
||||||
|
self._varMgr, self.blockIndex,
|
||||||
|
isFirst=True, parent=self
|
||||||
|
)
|
||||||
|
self._conditionRows.append(Row)
|
||||||
|
self.CondRowsLayout.addWidget(Row)
|
||||||
|
|
||||||
|
def addConditionRow(
|
||||||
|
self
|
||||||
|
):
|
||||||
|
|
||||||
|
Row = ConditionRowFrame(
|
||||||
|
self._varMgr, self.blockIndex,
|
||||||
|
isFirst=False, parent=self
|
||||||
|
)
|
||||||
|
Row.DeleteBtn.clicked.connect(lambda: self.removeConditionRow(Row))
|
||||||
|
self._conditionRows.append(Row)
|
||||||
|
self.CondRowsLayout.addWidget(Row)
|
||||||
|
|
||||||
|
def removeConditionRow(
|
||||||
|
self,
|
||||||
|
row: ConditionRowFrame
|
||||||
|
):
|
||||||
|
|
||||||
|
if row in self._conditionRows and len(self._conditionRows) > 1:
|
||||||
|
self._conditionRows.remove(row)
|
||||||
|
self.CondRowsLayout.removeWidget(row)
|
||||||
|
row.hide()
|
||||||
|
row.deleteLater()
|
||||||
|
|
||||||
|
def addActionStep(
|
||||||
|
self
|
||||||
|
):
|
||||||
|
|
||||||
|
Step = ActionStepFrame(self._varMgr, self.blockIndex, parent=self)
|
||||||
|
Step.DeleteBtn.clicked.connect(lambda: self.removeActionStep(Step))
|
||||||
|
self._actionWidgets.append(Step)
|
||||||
|
self.ActionsLayout.addWidget(Step)
|
||||||
|
|
||||||
|
def removeActionStep(
|
||||||
|
self,
|
||||||
|
step: ActionStepFrame
|
||||||
|
):
|
||||||
|
|
||||||
|
if step in self._actionWidgets:
|
||||||
|
self._actionWidgets.remove(step)
|
||||||
|
self.ActionsLayout.removeWidget(step)
|
||||||
|
step.hide()
|
||||||
|
step.deleteLater()
|
||||||
|
|
||||||
|
def getBlockType(
|
||||||
|
self
|
||||||
|
) -> str:
|
||||||
|
|
||||||
|
return self.TypeCombo.currentData()
|
||||||
|
|
||||||
|
def getConditionRows(
|
||||||
|
self
|
||||||
|
):
|
||||||
|
|
||||||
|
return list(self._conditionRows)
|
||||||
|
|
||||||
|
def getActionSteps(
|
||||||
|
self
|
||||||
|
):
|
||||||
|
|
||||||
|
return list(self._actionWidgets)
|
||||||
|
|
||||||
|
def countActionSteps(
|
||||||
|
self
|
||||||
|
) -> int:
|
||||||
|
|
||||||
|
return len(self._actionWidgets)
|
||||||
|
|
||||||
|
def toScript(
|
||||||
|
self
|
||||||
|
) -> list:
|
||||||
|
"""
|
||||||
|
Generate Lua script lines for this conditional block.
|
||||||
|
"""
|
||||||
|
|
||||||
|
blockType = self.getBlockType()
|
||||||
|
lines = []
|
||||||
|
if blockType in ("IF", "ELSE IF"):
|
||||||
|
condTexts = [
|
||||||
|
r.toScript() for r in self._conditionRows if r.toScript()
|
||||||
|
]
|
||||||
|
if not condTexts:
|
||||||
|
condTexts = ["true"]
|
||||||
|
if len(condTexts) == 1:
|
||||||
|
combined = condTexts[0]
|
||||||
|
else:
|
||||||
|
parts = []
|
||||||
|
for i, ct in enumerate(condTexts):
|
||||||
|
if i > 0:
|
||||||
|
logic = self._conditionRows[i].getLogic() or "and"
|
||||||
|
parts.append(f" {logic} ")
|
||||||
|
parts.append(f"({ct})")
|
||||||
|
combined = "".join(parts)
|
||||||
|
if blockType == "IF":
|
||||||
|
lines.append(f"if {combined} then")
|
||||||
|
else:
|
||||||
|
lines.append(f"elseif {combined} then")
|
||||||
|
else:
|
||||||
|
lines.append("else")
|
||||||
|
for step in self._actionWidgets:
|
||||||
|
scriptLine = step.toScript()
|
||||||
|
if scriptLine:
|
||||||
|
lines.append(scriptLine)
|
||||||
|
return lines
|
||||||
|
|
||||||
|
def refreshVarCombos(
|
||||||
|
self
|
||||||
|
):
|
||||||
|
|
||||||
|
for row in self._conditionRows:
|
||||||
|
row.refreshVarCombos()
|
||||||
|
for step in self._actionWidgets:
|
||||||
|
step.refreshVarCombos()
|
||||||
|
|
||||||
|
def setPrevBlockType(
|
||||||
|
self,
|
||||||
|
prevType: str | None
|
||||||
|
):
|
||||||
|
|
||||||
|
model = self.TypeCombo.model()
|
||||||
|
if model is None:
|
||||||
|
return
|
||||||
|
for data in ("ELSE IF", "ELSE"):
|
||||||
|
idx = self.TypeCombo.findData(data)
|
||||||
|
if idx < 0:
|
||||||
|
continue
|
||||||
|
item = model.item(idx)
|
||||||
|
shouldEnable = prevType != "ELSE"
|
||||||
|
item.setEnabled(shouldEnable)
|
||||||
|
if prevType == "ELSE" and self.TypeCombo.currentData() in ("ELSE IF", "ELSE"):
|
||||||
|
self.TypeCombo.setCurrentIndex(0)
|
||||||
|
|
||||||
|
@Slot(int)
|
||||||
|
def onTypeChanged(
|
||||||
|
self,
|
||||||
|
_idx
|
||||||
|
):
|
||||||
|
|
||||||
|
isCond = self.TypeCombo.currentData() in ("IF", "ELSE IF")
|
||||||
|
self.ConditionWidget.setVisible(isCond)
|
||||||
|
self.ActionLabel.setText(
|
||||||
|
"执行步骤:" if isCond else "ELSE 执行步骤:"
|
||||||
|
)
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
Copyright (c) 2026 KenanZhu.
|
||||||
|
All rights reserved.
|
||||||
|
|
||||||
|
This software is provided "as is", without any warranty of any kind.
|
||||||
|
You may use, modify, and distribute this file under the terms of the MIT License.
|
||||||
|
See the LICENSE file for details.
|
||||||
|
"""
|
||||||
|
"""
|
||||||
|
Orchestration dialog for visually composing AutoScript scripts.
|
||||||
|
"""
|
||||||
|
from PySide6.QtCore import Slot
|
||||||
|
from PySide6.QtWidgets import (
|
||||||
|
QDialog,
|
||||||
|
QDialogButtonBox,
|
||||||
|
QFrame,
|
||||||
|
QMessageBox,
|
||||||
|
QPushButton,
|
||||||
|
QScrollArea,
|
||||||
|
QVBoxLayout,
|
||||||
|
QWidget,
|
||||||
|
)
|
||||||
|
|
||||||
|
from gui.ALAutoScriptOrchDialog._helpers import VariableManager
|
||||||
|
from gui.ALAutoScriptOrchDialog._blocks import ConditionalBlock
|
||||||
|
|
||||||
|
|
||||||
|
class ALAutoScriptOrchDialog(QDialog):
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
parent = None
|
||||||
|
):
|
||||||
|
|
||||||
|
super().__init__(parent)
|
||||||
|
self._blocks = []
|
||||||
|
self._varMgr = VariableManager(self)
|
||||||
|
|
||||||
|
self.setupUi()
|
||||||
|
self.connectSignals()
|
||||||
|
self.addBlock()
|
||||||
|
self.ScrollLayout.addStretch()
|
||||||
|
|
||||||
|
def setupUi(
|
||||||
|
self
|
||||||
|
):
|
||||||
|
|
||||||
|
self.setWindowTitle("AutoScript 指令编排 - AutoLibrary")
|
||||||
|
self.setMinimumSize(640, 600)
|
||||||
|
self.setModal(True)
|
||||||
|
MainLayout = QVBoxLayout(self)
|
||||||
|
Scroll = QScrollArea()
|
||||||
|
Scroll.setWidgetResizable(True)
|
||||||
|
Scroll.setFrameShape(QFrame.NoFrame)
|
||||||
|
ScrollContent = QWidget()
|
||||||
|
self.ScrollLayout = QVBoxLayout(ScrollContent)
|
||||||
|
self.ScrollLayout.setSpacing(5)
|
||||||
|
Scroll.setWidget(ScrollContent)
|
||||||
|
MainLayout.addWidget(Scroll)
|
||||||
|
self.AddBlockBtn = QPushButton("+ 添加判断块")
|
||||||
|
self.AddBlockBtn.setFixedHeight(25)
|
||||||
|
MainLayout.addWidget(self.AddBlockBtn)
|
||||||
|
self.BtnBox = QDialogButtonBox(
|
||||||
|
QDialogButtonBox.StandardButton.Ok |
|
||||||
|
QDialogButtonBox.StandardButton.Cancel
|
||||||
|
)
|
||||||
|
self.BtnBox.button(QDialogButtonBox.StandardButton.Ok).setText("确定")
|
||||||
|
self.BtnBox.button(QDialogButtonBox.StandardButton.Cancel).setText("取消")
|
||||||
|
MainLayout.addWidget(self.BtnBox)
|
||||||
|
|
||||||
|
def connectSignals(
|
||||||
|
self
|
||||||
|
):
|
||||||
|
|
||||||
|
self.BtnBox.accepted.connect(self.onAccept)
|
||||||
|
self.BtnBox.rejected.connect(self.reject)
|
||||||
|
self.AddBlockBtn.clicked.connect(self.addBlock)
|
||||||
|
|
||||||
|
def updateBlockTypeRestrictions(
|
||||||
|
self
|
||||||
|
):
|
||||||
|
|
||||||
|
prevType = None
|
||||||
|
for block in self._blocks:
|
||||||
|
block.setPrevBlockType(prevType)
|
||||||
|
prevType = block.getBlockType()
|
||||||
|
|
||||||
|
def addBlock(
|
||||||
|
self
|
||||||
|
):
|
||||||
|
|
||||||
|
Block = ConditionalBlock(
|
||||||
|
len(self._blocks), self._varMgr, parent=self
|
||||||
|
)
|
||||||
|
Block.DeleteBlockBtn.clicked.connect(lambda: self.removeBlock(Block))
|
||||||
|
Block.TypeCombo.currentIndexChanged.connect(self.updateBlockTypeRestrictions)
|
||||||
|
Block.addActionStep()
|
||||||
|
self._blocks.append(Block)
|
||||||
|
self.updateBlockTypeRestrictions()
|
||||||
|
if self.ScrollLayout.count() > 0:
|
||||||
|
lastItem = self.ScrollLayout.itemAt(
|
||||||
|
self.ScrollLayout.count() - 1
|
||||||
|
)
|
||||||
|
if lastItem and lastItem.spacerItem():
|
||||||
|
self.ScrollLayout.insertWidget(
|
||||||
|
self.ScrollLayout.count() - 1, Block
|
||||||
|
)
|
||||||
|
return
|
||||||
|
self.ScrollLayout.addWidget(Block)
|
||||||
|
|
||||||
|
def removeBlock(
|
||||||
|
self,
|
||||||
|
block: ConditionalBlock
|
||||||
|
):
|
||||||
|
|
||||||
|
if len(self._blocks) <= 1:
|
||||||
|
QMessageBox.information(self, "提示", "至少保留一个判断块。")
|
||||||
|
return
|
||||||
|
if block in self._blocks:
|
||||||
|
self._blocks.remove(block)
|
||||||
|
self.ScrollLayout.removeWidget(block)
|
||||||
|
block.hide()
|
||||||
|
block.deleteLater()
|
||||||
|
for i, blk in enumerate(self._blocks):
|
||||||
|
blk.blockIndex = i
|
||||||
|
if i == 0:
|
||||||
|
blk.TypeCombo.setEnabled(False)
|
||||||
|
blk.TypeCombo.setCurrentIndex(0)
|
||||||
|
else:
|
||||||
|
blk.TypeCombo.setEnabled(True)
|
||||||
|
blk.refreshVarCombos()
|
||||||
|
self.updateBlockTypeRestrictions()
|
||||||
|
|
||||||
|
def getScript(
|
||||||
|
self
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Generate the complete Lua script from all blocks.
|
||||||
|
"""
|
||||||
|
|
||||||
|
parts = []
|
||||||
|
prevType = None
|
||||||
|
for block in self._blocks:
|
||||||
|
blockType = block.getBlockType()
|
||||||
|
if blockType == "IF" and prevType is not None:
|
||||||
|
parts.append("end")
|
||||||
|
lines = block.toScript()
|
||||||
|
parts.extend(lines)
|
||||||
|
prevType = blockType
|
||||||
|
if self._blocks and self._blocks[0].getBlockType() == "IF":
|
||||||
|
parts.append("end")
|
||||||
|
return "\n".join(parts)
|
||||||
|
|
||||||
|
@Slot()
|
||||||
|
def onAccept(
|
||||||
|
self
|
||||||
|
):
|
||||||
|
|
||||||
|
script = self.getScript().strip()
|
||||||
|
if not script:
|
||||||
|
QMessageBox.warning(self, "提示", "脚本内容为空,请添加至少一个操作步骤。")
|
||||||
|
return
|
||||||
|
self.accept()
|
||||||
@@ -0,0 +1,516 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
Copyright (c) 2026 KenanZhu.
|
||||||
|
All rights reserved.
|
||||||
|
|
||||||
|
This software is provided "as is", without any warranty of any kind.
|
||||||
|
You may use, modify, and distribute this file under the terms of the MIT License.
|
||||||
|
See the LICENSE file for details.
|
||||||
|
"""
|
||||||
|
"""
|
||||||
|
Helper utilities and constants for the AutoScript orchestration dialog.
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
|
||||||
|
from PySide6.QtCore import QObject
|
||||||
|
from PySide6.QtWidgets import (
|
||||||
|
QComboBox,
|
||||||
|
QDateEdit,
|
||||||
|
QDoubleSpinBox,
|
||||||
|
QHBoxLayout,
|
||||||
|
QLabel,
|
||||||
|
QLineEdit,
|
||||||
|
QSizePolicy,
|
||||||
|
QSpinBox,
|
||||||
|
QStackedWidget,
|
||||||
|
QTimeEdit,
|
||||||
|
QWidget,
|
||||||
|
)
|
||||||
|
|
||||||
|
from autoscript import createAllVariablesTable
|
||||||
|
|
||||||
|
VARTYPE_INFOS = [
|
||||||
|
# varType, isArithType
|
||||||
|
("String", False),
|
||||||
|
("Int", True),
|
||||||
|
("Float", True),
|
||||||
|
("Boolean", False),
|
||||||
|
("Date", True),
|
||||||
|
("Time", True),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def getTypeOrder(
|
||||||
|
) -> list:
|
||||||
|
|
||||||
|
return [t for t, _ in VARTYPE_INFOS]
|
||||||
|
|
||||||
|
def getArithType(
|
||||||
|
varType: str
|
||||||
|
) -> bool:
|
||||||
|
|
||||||
|
for t, a in VARTYPE_INFOS:
|
||||||
|
if t == varType:
|
||||||
|
return a
|
||||||
|
|
||||||
|
def getPresetVars(
|
||||||
|
) -> list:
|
||||||
|
|
||||||
|
return [
|
||||||
|
{"name": name.upper(), "type": vtype, "display": display}
|
||||||
|
for display, (name, vtype) in createAllVariablesTable().items()
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
COMPARE_OPTIONS = [
|
||||||
|
("等于", "=="),
|
||||||
|
("不等于", "~="),
|
||||||
|
("大于", ">"),
|
||||||
|
("小于", "<"),
|
||||||
|
("大于等于", ">="),
|
||||||
|
("小于等于", "<="),
|
||||||
|
]
|
||||||
|
LOGIC_OPTIONS = [
|
||||||
|
("并且 (and)", "and"),
|
||||||
|
("或者 (or)", "or"),
|
||||||
|
]
|
||||||
|
ACTION_OPTIONS = [
|
||||||
|
("设置为", "set"),
|
||||||
|
("增加", "add"),
|
||||||
|
("减少", "sub"),
|
||||||
|
]
|
||||||
|
DATE_OPTIONS = [
|
||||||
|
("前天", "day_before_yesterday"),
|
||||||
|
("昨天", "yesterday"),
|
||||||
|
("今天", "today"),
|
||||||
|
("明天", "tomorrow"),
|
||||||
|
("后天", "day_after_tomorrow")
|
||||||
|
]
|
||||||
|
DATE_OFFSET_OPTIONS = [
|
||||||
|
("天", "days"),
|
||||||
|
("周", "weeks"),
|
||||||
|
# NOTE: "月" and "年" use fixed day counts (30 / 365), not calendar months/years,
|
||||||
|
# because dateadd() works with second-level offsets (n * 86400).
|
||||||
|
("月", "months"),
|
||||||
|
("年", "years"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class _DateInputContainer(QWidget):
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
parent = None
|
||||||
|
):
|
||||||
|
|
||||||
|
super().__init__(parent)
|
||||||
|
self.setupUi()
|
||||||
|
|
||||||
|
def setupUi(
|
||||||
|
self
|
||||||
|
):
|
||||||
|
|
||||||
|
Layout = QHBoxLayout(self)
|
||||||
|
Layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
Layout.setSpacing(4)
|
||||||
|
self._ModeCombo = QComboBox(self)
|
||||||
|
self._ModeCombo.addItem("相对日期", "relative")
|
||||||
|
self._ModeCombo.addItem("绝对日期", "absolute")
|
||||||
|
self._ModeCombo.setFixedHeight(25)
|
||||||
|
self._Stack = QStackedWidget(self)
|
||||||
|
self._RelCombo = QComboBox(self)
|
||||||
|
for display, data in DATE_OPTIONS:
|
||||||
|
self._RelCombo.addItem(display, data)
|
||||||
|
self._RelCombo.setFixedHeight(25)
|
||||||
|
self._Stack.addWidget(self._RelCombo)
|
||||||
|
self._DateEdit = QDateEdit(self)
|
||||||
|
self._DateEdit.setDisplayFormat("yyyy-MM-dd")
|
||||||
|
self._DateEdit.setCalendarPopup(True)
|
||||||
|
self._DateEdit.setFixedHeight(25)
|
||||||
|
self._Stack.addWidget(self._DateEdit)
|
||||||
|
self._ModeCombo.currentIndexChanged.connect(
|
||||||
|
lambda i: self._Stack.setCurrentIndex(i)
|
||||||
|
)
|
||||||
|
Layout.addWidget(self._ModeCombo)
|
||||||
|
Layout.addWidget(self._Stack)
|
||||||
|
Layout.addStretch()
|
||||||
|
|
||||||
|
def getValue(
|
||||||
|
self
|
||||||
|
) -> str:
|
||||||
|
|
||||||
|
mode = self._ModeCombo.currentData()
|
||||||
|
if mode == "relative":
|
||||||
|
return self._RelCombo.currentText()
|
||||||
|
return self._DateEdit.date().toString("yyyy-MM-dd")
|
||||||
|
|
||||||
|
|
||||||
|
class _TimeInputContainer(QWidget):
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
parent = None
|
||||||
|
):
|
||||||
|
|
||||||
|
super().__init__(parent)
|
||||||
|
self._TimeEdit = QTimeEdit(self)
|
||||||
|
self._TimeEdit.setDisplayFormat("HH:mm")
|
||||||
|
self._TimeEdit.setFixedHeight(25)
|
||||||
|
|
||||||
|
Layout = QHBoxLayout(self)
|
||||||
|
Layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
Layout.addWidget(self._TimeEdit)
|
||||||
|
|
||||||
|
def getValue(
|
||||||
|
self
|
||||||
|
) -> str:
|
||||||
|
|
||||||
|
return self._TimeEdit.time().toString("HH:mm")
|
||||||
|
|
||||||
|
|
||||||
|
class _DateOffsetContainer(QWidget):
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
parent = None
|
||||||
|
):
|
||||||
|
|
||||||
|
super().__init__(parent)
|
||||||
|
self._SpinBox = QSpinBox(self)
|
||||||
|
self._SpinBox.setRange(0, 99999)
|
||||||
|
self._SpinBox.setFixedHeight(25)
|
||||||
|
self._UnitCombo = QComboBox(self)
|
||||||
|
for display, data in DATE_OFFSET_OPTIONS:
|
||||||
|
self._UnitCombo.addItem(display, data)
|
||||||
|
self._UnitCombo.setFixedHeight(25)
|
||||||
|
|
||||||
|
Layout = QHBoxLayout(self)
|
||||||
|
Layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
Layout.setSpacing(4)
|
||||||
|
Layout.addWidget(self._SpinBox)
|
||||||
|
Layout.addWidget(self._UnitCombo)
|
||||||
|
Layout.addStretch()
|
||||||
|
|
||||||
|
def getValue(
|
||||||
|
self
|
||||||
|
) -> str:
|
||||||
|
|
||||||
|
return str(self.getOffsetDays())
|
||||||
|
|
||||||
|
def getOffsetDays(
|
||||||
|
self
|
||||||
|
) -> int:
|
||||||
|
|
||||||
|
val = self._SpinBox.value()
|
||||||
|
unit = self._UnitCombo.currentData()
|
||||||
|
if unit == "weeks":
|
||||||
|
return val*7
|
||||||
|
if unit == "months":
|
||||||
|
return val*30
|
||||||
|
if unit == "years":
|
||||||
|
return val*365
|
||||||
|
return val
|
||||||
|
|
||||||
|
|
||||||
|
class _TimeOffsetContainer(QWidget):
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
parent = None
|
||||||
|
):
|
||||||
|
|
||||||
|
super().__init__(parent)
|
||||||
|
self._SpinBox = QSpinBox(self)
|
||||||
|
self._SpinBox.setRange(0, 99999)
|
||||||
|
self._SpinBox.setSuffix(" 小时")
|
||||||
|
self._SpinBox.setFixedHeight(25)
|
||||||
|
|
||||||
|
Layout = QHBoxLayout(self)
|
||||||
|
Layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
Layout.addWidget(self._SpinBox)
|
||||||
|
|
||||||
|
def getValue(
|
||||||
|
self
|
||||||
|
) -> str:
|
||||||
|
|
||||||
|
return str(self.getOffsetHours())
|
||||||
|
|
||||||
|
def getOffsetHours(
|
||||||
|
self
|
||||||
|
) -> int:
|
||||||
|
|
||||||
|
return self._SpinBox.value()
|
||||||
|
|
||||||
|
|
||||||
|
class VariableManager(QObject):
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
parent = None
|
||||||
|
):
|
||||||
|
|
||||||
|
super().__init__(parent)
|
||||||
|
self._vars = []
|
||||||
|
self._nameMap = {}
|
||||||
|
|
||||||
|
self.initPresetVars()
|
||||||
|
|
||||||
|
def initPresetVars(
|
||||||
|
self
|
||||||
|
):
|
||||||
|
|
||||||
|
for p in getPresetVars():
|
||||||
|
entry = {"name": p["name"], "type": p["type"], "display": p["display"]}
|
||||||
|
self._vars.append(entry)
|
||||||
|
self._nameMap[p["name"]] = entry
|
||||||
|
|
||||||
|
def getInfoByName(
|
||||||
|
self,
|
||||||
|
name: str
|
||||||
|
):
|
||||||
|
|
||||||
|
return self._nameMap.get(name.upper().strip())
|
||||||
|
|
||||||
|
def populateCombo(
|
||||||
|
self,
|
||||||
|
combo: QComboBox
|
||||||
|
):
|
||||||
|
|
||||||
|
currentData = combo.currentData()
|
||||||
|
combo.blockSignals(True)
|
||||||
|
combo.clear()
|
||||||
|
for entry in self._vars:
|
||||||
|
combo.addItem(
|
||||||
|
entry["display"],
|
||||||
|
(entry["name"], entry["type"])
|
||||||
|
)
|
||||||
|
if currentData:
|
||||||
|
for i in range(combo.count()):
|
||||||
|
d = combo.itemData(i)
|
||||||
|
if d and d[0] == currentData[0]:
|
||||||
|
combo.setCurrentIndex(i)
|
||||||
|
break
|
||||||
|
combo.blockSignals(False)
|
||||||
|
|
||||||
|
|
||||||
|
def makeValueWidget(
|
||||||
|
var_type: str,
|
||||||
|
parent: QWidget = None
|
||||||
|
) -> QWidget:
|
||||||
|
|
||||||
|
if var_type == "Int":
|
||||||
|
w = QSpinBox(parent)
|
||||||
|
w.setRange(-999999, 999999)
|
||||||
|
w.setFixedHeight(25)
|
||||||
|
w.setMinimumWidth(100)
|
||||||
|
return w
|
||||||
|
if var_type == "Float":
|
||||||
|
w = QDoubleSpinBox(parent)
|
||||||
|
w.setRange(-999999.0, 999999.0)
|
||||||
|
w.setDecimals(2)
|
||||||
|
w.setFixedHeight(25)
|
||||||
|
w.setMinimumWidth(100)
|
||||||
|
return w
|
||||||
|
if var_type == "String":
|
||||||
|
w = QLineEdit(parent)
|
||||||
|
w.setPlaceholderText("输入值")
|
||||||
|
w.setFixedHeight(25)
|
||||||
|
w.setMinimumWidth(120)
|
||||||
|
return w
|
||||||
|
if var_type == "Boolean":
|
||||||
|
w = QComboBox(parent)
|
||||||
|
w.addItem("是 (true)", "true")
|
||||||
|
w.addItem("否 (false)", "false")
|
||||||
|
w.setFixedHeight(25)
|
||||||
|
w.setMinimumWidth(100)
|
||||||
|
return w
|
||||||
|
if var_type == "Date":
|
||||||
|
return _DateInputContainer(parent)
|
||||||
|
if var_type == "Time":
|
||||||
|
return _TimeInputContainer(parent)
|
||||||
|
w = QLineEdit(parent)
|
||||||
|
w.setPlaceholderText("输入值")
|
||||||
|
w.setFixedHeight(25)
|
||||||
|
w.setMinimumWidth(120)
|
||||||
|
return w
|
||||||
|
|
||||||
|
def makeOffsetWidget(
|
||||||
|
var_type: str,
|
||||||
|
parent: QWidget = None
|
||||||
|
) -> QWidget:
|
||||||
|
|
||||||
|
if var_type == "Int":
|
||||||
|
w = QSpinBox(parent)
|
||||||
|
w.setRange(-999999, 999999)
|
||||||
|
w.setFixedHeight(25)
|
||||||
|
w.setMinimumWidth(100)
|
||||||
|
return w
|
||||||
|
if var_type == "Float":
|
||||||
|
w = QDoubleSpinBox(parent)
|
||||||
|
w.setRange(-999999.0, 999999.0)
|
||||||
|
w.setDecimals(2)
|
||||||
|
w.setFixedHeight(25)
|
||||||
|
w.setMinimumWidth(100)
|
||||||
|
return w
|
||||||
|
if var_type == "Date":
|
||||||
|
return _DateOffsetContainer(parent)
|
||||||
|
if var_type == "Time":
|
||||||
|
return _TimeOffsetContainer(parent)
|
||||||
|
w = QLabel("(不支持该操作)", parent)
|
||||||
|
w.setFixedHeight(25)
|
||||||
|
return w
|
||||||
|
|
||||||
|
def makeVarRefCombo(
|
||||||
|
parent: QWidget = None
|
||||||
|
) -> QComboBox:
|
||||||
|
|
||||||
|
Cb = QComboBox(parent)
|
||||||
|
Cb.setFixedHeight(25)
|
||||||
|
Cb.setMinimumWidth(120)
|
||||||
|
Cb.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
|
||||||
|
return Cb
|
||||||
|
|
||||||
|
def makeComboWidget(
|
||||||
|
items,
|
||||||
|
min_width: int = 80,
|
||||||
|
parent: QWidget = None
|
||||||
|
) -> QComboBox:
|
||||||
|
|
||||||
|
Cb = QComboBox(parent)
|
||||||
|
for display, data in items:
|
||||||
|
Cb.addItem(display, data)
|
||||||
|
Cb.setFixedHeight(25)
|
||||||
|
Cb.setMinimumWidth(min_width)
|
||||||
|
return Cb
|
||||||
|
|
||||||
|
def makeLabel(
|
||||||
|
text: str,
|
||||||
|
parent: QWidget = None,
|
||||||
|
width: int = None
|
||||||
|
) -> QLabel:
|
||||||
|
|
||||||
|
Lbl = QLabel(text, parent)
|
||||||
|
Lbl.setFixedHeight(25)
|
||||||
|
if width:
|
||||||
|
Lbl.setFixedWidth(width)
|
||||||
|
return Lbl
|
||||||
|
|
||||||
|
def getValueFromWidget(
|
||||||
|
w: QWidget
|
||||||
|
) -> str:
|
||||||
|
|
||||||
|
if hasattr(w, "getValue"):
|
||||||
|
return w.getValue()
|
||||||
|
if isinstance(w, QTimeEdit):
|
||||||
|
return w.time().toString("HH:mm")
|
||||||
|
if isinstance(w, QDateEdit):
|
||||||
|
return w.date().toString("yyyy-MM-dd")
|
||||||
|
if isinstance(w, QComboBox):
|
||||||
|
return w.currentData() or w.currentText()
|
||||||
|
if isinstance(w, QSpinBox):
|
||||||
|
return str(w.value())
|
||||||
|
if isinstance(w, QDoubleSpinBox):
|
||||||
|
return str(w.value())
|
||||||
|
if isinstance(w, QLineEdit):
|
||||||
|
return w.text()
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def encodeValueStr(
|
||||||
|
raw_value: str,
|
||||||
|
var_type: str
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Encode a raw widget value as a Lua expression.
|
||||||
|
|
||||||
|
Arithmetic expressions (A + B) are passed through for numeric types;
|
||||||
|
Date/Time arithmetic is translated to ``dateadd()`` / ``timeadd()`` calls.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if var_type in ("Date", "Time"):
|
||||||
|
return encodeDateOrTime(str(raw_value), var_type)
|
||||||
|
if isinstance(raw_value, bool):
|
||||||
|
return "true" if raw_value else "false"
|
||||||
|
s = str(raw_value)
|
||||||
|
if isArithExpr(s):
|
||||||
|
return s
|
||||||
|
if var_type == "Boolean":
|
||||||
|
up = s.upper().strip()
|
||||||
|
if up in ("TRUE", "FALSE"):
|
||||||
|
return up.lower()
|
||||||
|
return "true" if raw_value else "false"
|
||||||
|
if var_type == "String":
|
||||||
|
escaped = s.replace("\\", "\\\\").replace('"', '\\"')
|
||||||
|
return f'"{escaped}"'
|
||||||
|
return s
|
||||||
|
|
||||||
|
def encodeDateOrTime(
|
||||||
|
raw_value: str,
|
||||||
|
var_type: str
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Translate a date/time widget value into a Lua expression.
|
||||||
|
"""
|
||||||
|
|
||||||
|
s = raw_value.strip()
|
||||||
|
up = s.upper()
|
||||||
|
# Input comes from widget values — single binary expressions only (e.g. "A + 3",
|
||||||
|
# "CURRENT_DATE + 5"). Multi-operator expressions are not produced by the UI.
|
||||||
|
m_arith_spaced = re.match(r'^(.+?)\s+([+-])\s+(.+)$', s)
|
||||||
|
m_arith_nospace = re.match(r'^([A-Za-z_]\w*)([+-])(\d+|[A-Za-z_]\w*)$', s)
|
||||||
|
m_arith = m_arith_spaced or m_arith_nospace
|
||||||
|
if m_arith:
|
||||||
|
left = m_arith.group(1).strip().upper()
|
||||||
|
sign = m_arith.group(2)
|
||||||
|
right = m_arith.group(3).strip()
|
||||||
|
operand = right if sign == "+" else f"-{right}"
|
||||||
|
if left == "CURRENT_DATE":
|
||||||
|
return f"dateadd(datenow(), {operand})"
|
||||||
|
if left == "CURRENT_TIME":
|
||||||
|
return f"timeadd(timenow(), {operand})"
|
||||||
|
if var_type == "Date":
|
||||||
|
return f"dateadd({left}, {operand})"
|
||||||
|
if var_type == "Time":
|
||||||
|
return f"timeadd({left}, {operand})"
|
||||||
|
return f"{left} {sign} {right}"
|
||||||
|
if up == "CURRENT_DATE":
|
||||||
|
return "datenow()"
|
||||||
|
if up == "CURRENT_TIME":
|
||||||
|
return "timenow()"
|
||||||
|
_REL_MAP = {
|
||||||
|
"前天": "dateadd(datenow(), -2)",
|
||||||
|
"昨天": "dateadd(datenow(), -1)",
|
||||||
|
"今天": "datenow()",
|
||||||
|
"明天": "dateadd(datenow(), 1)",
|
||||||
|
"后天": "dateadd(datenow(), 2)",
|
||||||
|
}
|
||||||
|
if s in _REL_MAP:
|
||||||
|
return _REL_MAP[s]
|
||||||
|
if var_type == "Date":
|
||||||
|
m_date = re.match(r"^(\d{4})-(\d{2})-(\d{2})$", s)
|
||||||
|
if m_date:
|
||||||
|
y, m, d = int(m_date.group(1)), int(m_date.group(2)), int(m_date.group(3))
|
||||||
|
return f"date({y}, {m}, {d})"
|
||||||
|
if var_type == "Time":
|
||||||
|
m_time = re.match(r"^(\d{1,2}):(\d{2})$", s)
|
||||||
|
if m_time:
|
||||||
|
h, m = int(m_time.group(1)), int(m_time.group(2))
|
||||||
|
return f"time({h}, {m})"
|
||||||
|
if re.match(r"^[+-]?\d+$", s):
|
||||||
|
return s
|
||||||
|
if re.match(r"^[A-Za-z_]\w*$", s):
|
||||||
|
return s
|
||||||
|
return f'"{s}"'
|
||||||
|
|
||||||
|
# Pre-compiled patterns for detecting arithmetic expressions (A + B / A - B)
|
||||||
|
_RE_ARITH_SPACED = re.compile(r'^(.+?)\s+([+-])\s+(.+)$')
|
||||||
|
_RE_ARITH_NOSPACE = re.compile(r'^([A-Za-z_]\w*)([+-])(\d+|[A-Za-z_]\w*)$')
|
||||||
|
|
||||||
|
def isArithExpr(
|
||||||
|
expr: str
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Return True if expr looks like a two-operand arithmetic expression (A ± B).
|
||||||
|
"""
|
||||||
|
|
||||||
|
s = expr.strip()
|
||||||
|
return bool(_RE_ARITH_SPACED.match(s) or _RE_ARITH_NOSPACE.match(s))
|
||||||
@@ -0,0 +1,468 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
Copyright (c) 2026 KenanZhu.
|
||||||
|
All rights reserved.
|
||||||
|
|
||||||
|
This software is provided "as is", without any warranty of any kind.
|
||||||
|
You may use, modify, and distribute this file under the terms of the MIT License.
|
||||||
|
See the LICENSE file for details.
|
||||||
|
"""
|
||||||
|
"""
|
||||||
|
Widget components for the AutoScript orchestration dialog.
|
||||||
|
"""
|
||||||
|
from PySide6.QtCore import Slot
|
||||||
|
from PySide6.QtWidgets import (
|
||||||
|
QComboBox,
|
||||||
|
QFrame,
|
||||||
|
QHBoxLayout,
|
||||||
|
QLabel,
|
||||||
|
QPushButton,
|
||||||
|
QSizePolicy,
|
||||||
|
QStackedWidget
|
||||||
|
)
|
||||||
|
|
||||||
|
from gui.ALAutoScriptOrchDialog._helpers import (
|
||||||
|
ACTION_OPTIONS,
|
||||||
|
COMPARE_OPTIONS,
|
||||||
|
LOGIC_OPTIONS,
|
||||||
|
encodeValueStr,
|
||||||
|
getPresetVars,
|
||||||
|
getTypeOrder,
|
||||||
|
getValueFromWidget,
|
||||||
|
getArithType,
|
||||||
|
makeComboWidget,
|
||||||
|
makeLabel,
|
||||||
|
makeOffsetWidget,
|
||||||
|
makeValueWidget,
|
||||||
|
makeVarRefCombo,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ConditionRowFrame(QFrame):
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
varMgr,
|
||||||
|
parentBlockIndex: int = 0,
|
||||||
|
isFirst: bool = False,
|
||||||
|
parent = None
|
||||||
|
):
|
||||||
|
|
||||||
|
super().__init__(parent)
|
||||||
|
self._varMgr = varMgr
|
||||||
|
self._blockIndex = parentBlockIndex
|
||||||
|
self._isFirst = isFirst
|
||||||
|
self._isBoolMode = False
|
||||||
|
self._rawRhsExpr = ""
|
||||||
|
|
||||||
|
self.setupUi()
|
||||||
|
self.connectSignals()
|
||||||
|
|
||||||
|
def setupUi(
|
||||||
|
self
|
||||||
|
):
|
||||||
|
|
||||||
|
self.setUpdatesEnabled(False)
|
||||||
|
self.setFrameShape(QFrame.StyledPanel)
|
||||||
|
self.setFrameShadow(QFrame.Raised)
|
||||||
|
self.setFixedHeight(32)
|
||||||
|
Layout = QHBoxLayout(self)
|
||||||
|
Layout.setContentsMargins(2, 2, 2, 2)
|
||||||
|
Layout.setSpacing(4)
|
||||||
|
if self._isFirst:
|
||||||
|
self.LogicCombo = None
|
||||||
|
else:
|
||||||
|
self.LogicCombo = makeComboWidget(LOGIC_OPTIONS, min_width=110, parent=self)
|
||||||
|
Layout.addWidget(self.LogicCombo)
|
||||||
|
self.LeftVarCombo = QComboBox(self)
|
||||||
|
self.LeftVarCombo.setFixedHeight(25)
|
||||||
|
self.LeftVarCombo.setMinimumWidth(120)
|
||||||
|
self.LeftVarCombo.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
|
||||||
|
self.populateLeftVarCombo()
|
||||||
|
Layout.addWidget(self.LeftVarCombo)
|
||||||
|
self.OpCombo = makeComboWidget(COMPARE_OPTIONS, min_width=80, parent=self)
|
||||||
|
Layout.addWidget(self.OpCombo)
|
||||||
|
self._CompTypeCombo = makeComboWidget([
|
||||||
|
("特定值", "literal"),
|
||||||
|
("变量", "variable"),
|
||||||
|
], min_width=70, parent=self)
|
||||||
|
Layout.addWidget(self._CompTypeCombo)
|
||||||
|
self.RhsStack = QStackedWidget(self)
|
||||||
|
self.RhsStack.setFixedHeight(25)
|
||||||
|
self.initLiteralStack()
|
||||||
|
self.RhsVarCombo = makeVarRefCombo(self)
|
||||||
|
self.RhsStack.addWidget(self.RhsVarCombo)
|
||||||
|
self.RhsStack.setCurrentIndex(0)
|
||||||
|
Layout.addWidget(self.RhsStack)
|
||||||
|
if not self._isFirst:
|
||||||
|
self.DeleteBtn = QPushButton("×", self)
|
||||||
|
self.DeleteBtn.setFixedSize(25, 25)
|
||||||
|
self.DeleteBtn.setStyleSheet("color: red; font-weight: bold;")
|
||||||
|
Layout.addWidget(self.DeleteBtn)
|
||||||
|
else:
|
||||||
|
self.DeleteBtn = None
|
||||||
|
Layout.addStretch()
|
||||||
|
self.setUpdatesEnabled(True)
|
||||||
|
|
||||||
|
def populateLeftVarCombo(
|
||||||
|
self
|
||||||
|
):
|
||||||
|
|
||||||
|
wasBool = self._isBoolMode
|
||||||
|
boolName = None
|
||||||
|
if wasBool:
|
||||||
|
data = self.LeftVarCombo.currentData()
|
||||||
|
if data:
|
||||||
|
boolName = data[0]
|
||||||
|
self._varMgr.populateCombo(self.LeftVarCombo)
|
||||||
|
# Append boolean literal sentinels at the end
|
||||||
|
self.LeftVarCombo.insertSeparator(self.LeftVarCombo.count())
|
||||||
|
self.LeftVarCombo.addItem("true", ("true", "Boolean"))
|
||||||
|
self.LeftVarCombo.addItem("false", ("false", "Boolean"))
|
||||||
|
if wasBool and boolName:
|
||||||
|
for ci in range(self.LeftVarCombo.count()):
|
||||||
|
d = self.LeftVarCombo.itemData(ci)
|
||||||
|
if d and d[0] == boolName:
|
||||||
|
self.LeftVarCombo.setCurrentIndex(ci)
|
||||||
|
break
|
||||||
|
|
||||||
|
def populateRHSVarCombo(
|
||||||
|
self
|
||||||
|
):
|
||||||
|
|
||||||
|
self._varMgr.populateCombo(self.RhsVarCombo)
|
||||||
|
|
||||||
|
def initLiteralStack(
|
||||||
|
self
|
||||||
|
):
|
||||||
|
|
||||||
|
self.LiteralStack = QStackedWidget(self)
|
||||||
|
self.LiteralStack.setFixedHeight(25)
|
||||||
|
self._literalWidgets = {}
|
||||||
|
for vt in getTypeOrder():
|
||||||
|
W = makeValueWidget(vt, self.LiteralStack)
|
||||||
|
self._literalWidgets[vt] = W
|
||||||
|
self.LiteralStack.addWidget(W)
|
||||||
|
self.LiteralStack.setCurrentWidget(self._literalWidgets.get("String"))
|
||||||
|
self.RhsStack.addWidget(self.LiteralStack)
|
||||||
|
|
||||||
|
def connectSignals(
|
||||||
|
self
|
||||||
|
):
|
||||||
|
|
||||||
|
self.LeftVarCombo.currentIndexChanged.connect(self.onLeftVarChanged)
|
||||||
|
self._CompTypeCombo.currentIndexChanged.connect(self.onCompTypeChanged)
|
||||||
|
|
||||||
|
def getLogic(
|
||||||
|
self
|
||||||
|
) -> str:
|
||||||
|
|
||||||
|
return self.LogicCombo.currentData() if self.LogicCombo else ""
|
||||||
|
|
||||||
|
def updateRHSLiteralWidget(
|
||||||
|
self,
|
||||||
|
vartype: str
|
||||||
|
):
|
||||||
|
|
||||||
|
if vartype not in self._literalWidgets:
|
||||||
|
vartype = "String"
|
||||||
|
self.LiteralStack.setCurrentWidget(self._literalWidgets[vartype])
|
||||||
|
|
||||||
|
def toScript(
|
||||||
|
self
|
||||||
|
) -> str:
|
||||||
|
|
||||||
|
data = self.LeftVarCombo.currentData()
|
||||||
|
if self._isBoolMode and data:
|
||||||
|
return data[0]
|
||||||
|
if not data:
|
||||||
|
return ""
|
||||||
|
name, vartype = data
|
||||||
|
# CURRENT_DATE / CURRENT_TIME map to datenow() / timenow()
|
||||||
|
if name == "CURRENT_DATE":
|
||||||
|
name = "datenow()"
|
||||||
|
elif name == "CURRENT_TIME":
|
||||||
|
name = "timenow()"
|
||||||
|
opSym = self.OpCombo.currentData()
|
||||||
|
if self._rawRhsExpr:
|
||||||
|
return f"{name} {opSym} {self._rawRhsExpr}"
|
||||||
|
isVarRef = (self._CompTypeCombo.currentData() == "variable")
|
||||||
|
if isVarRef:
|
||||||
|
rd = self.RhsVarCombo.currentData()
|
||||||
|
if rd:
|
||||||
|
rhsName = rd[0]
|
||||||
|
if rhsName == "CURRENT_DATE":
|
||||||
|
rhsName = "datenow()"
|
||||||
|
elif rhsName == "CURRENT_TIME":
|
||||||
|
rhsName = "timenow()"
|
||||||
|
return f"{name} {opSym} {rhsName}"
|
||||||
|
rhsText = self.RhsVarCombo.currentText().strip()
|
||||||
|
if rhsText:
|
||||||
|
return f"{name} {opSym} {rhsText}"
|
||||||
|
return ""
|
||||||
|
w = self._literalWidgets.get(vartype)
|
||||||
|
if w:
|
||||||
|
rawVal = getValueFromWidget(w)
|
||||||
|
encoded = encodeValueStr(rawVal, vartype)
|
||||||
|
return f"{name} {opSym} {encoded}"
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def refreshVarCombos(
|
||||||
|
self
|
||||||
|
):
|
||||||
|
|
||||||
|
self.populateLeftVarCombo()
|
||||||
|
self.populateRHSVarCombo()
|
||||||
|
|
||||||
|
@Slot(int)
|
||||||
|
def onLeftVarChanged(
|
||||||
|
self,
|
||||||
|
idx
|
||||||
|
):
|
||||||
|
|
||||||
|
self._rawRhsExpr = ""
|
||||||
|
if idx < 0:
|
||||||
|
return
|
||||||
|
data = self.LeftVarCombo.itemData(idx)
|
||||||
|
if not data:
|
||||||
|
return
|
||||||
|
name, vartype = data
|
||||||
|
isBool = name in ("true", "false")
|
||||||
|
self._isBoolMode = isBool
|
||||||
|
self.OpCombo.setVisible(not isBool)
|
||||||
|
self._CompTypeCombo.setVisible(not isBool)
|
||||||
|
self.RhsStack.setVisible(not isBool)
|
||||||
|
if not isBool:
|
||||||
|
self.updateRHSLiteralWidget(vartype)
|
||||||
|
|
||||||
|
@Slot(int)
|
||||||
|
def onCompTypeChanged(
|
||||||
|
self,
|
||||||
|
idx
|
||||||
|
):
|
||||||
|
|
||||||
|
self._rawRhsExpr = ""
|
||||||
|
isVar = (self._CompTypeCombo.currentData() == "variable")
|
||||||
|
self.RhsStack.setCurrentIndex(1 if isVar else 0)
|
||||||
|
if isVar:
|
||||||
|
self.populateRHSVarCombo()
|
||||||
|
|
||||||
|
|
||||||
|
class ActionStepFrame(QFrame):
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
varMgr,
|
||||||
|
parentBlockIndex: int = 0,
|
||||||
|
parent = None
|
||||||
|
):
|
||||||
|
|
||||||
|
super().__init__(parent)
|
||||||
|
self._varMgr = varMgr
|
||||||
|
self._blockIndex = parentBlockIndex
|
||||||
|
self._currentTargetType = "String"
|
||||||
|
|
||||||
|
self.setupUi()
|
||||||
|
self.connectSignals()
|
||||||
|
|
||||||
|
def setupUi(
|
||||||
|
self
|
||||||
|
):
|
||||||
|
|
||||||
|
self.setUpdatesEnabled(False)
|
||||||
|
self.setFrameShape(QFrame.StyledPanel)
|
||||||
|
self.setFrameShadow(QFrame.Raised)
|
||||||
|
self.setFixedHeight(35)
|
||||||
|
Layout = QHBoxLayout(self)
|
||||||
|
Layout.setContentsMargins(2, 2, 2, 2)
|
||||||
|
Layout.setSpacing(4)
|
||||||
|
self.OpTypeCombo = makeComboWidget(ACTION_OPTIONS, min_width=70, parent=self)
|
||||||
|
Layout.addWidget(self.OpTypeCombo)
|
||||||
|
Layout.addWidget(makeLabel("设置", self))
|
||||||
|
self.TargetCombo = QComboBox(self)
|
||||||
|
self.TargetCombo.setFixedHeight(25)
|
||||||
|
self.TargetCombo.setMinimumWidth(120)
|
||||||
|
self.populateTargetCombo()
|
||||||
|
Layout.addWidget(self.TargetCombo)
|
||||||
|
Layout.addWidget(makeLabel("为", self))
|
||||||
|
self.ValueSrcCombo = makeComboWidget([
|
||||||
|
("特定值", "literal"),
|
||||||
|
("变量", "variable"),
|
||||||
|
], min_width=70, parent=self)
|
||||||
|
Layout.addWidget(self.ValueSrcCombo)
|
||||||
|
self.ValueStack = QStackedWidget(self)
|
||||||
|
self.ValueStack.setFixedHeight(25)
|
||||||
|
self.initValueStacks()
|
||||||
|
Layout.addWidget(self.ValueStack)
|
||||||
|
self.ExistingVarCombo = makeVarRefCombo(self)
|
||||||
|
self.ExistingVarCombo.setVisible(False)
|
||||||
|
Layout.addWidget(self.ExistingVarCombo)
|
||||||
|
self.DeleteBtn = QPushButton("×", self)
|
||||||
|
self.DeleteBtn.setFixedSize(25, 25)
|
||||||
|
self.DeleteBtn.setStyleSheet("color: red; font-weight: bold;")
|
||||||
|
Layout.addWidget(self.DeleteBtn)
|
||||||
|
self.setUpdatesEnabled(True)
|
||||||
|
|
||||||
|
def populateTargetCombo(
|
||||||
|
self
|
||||||
|
):
|
||||||
|
|
||||||
|
self.TargetCombo.blockSignals(True)
|
||||||
|
self.TargetCombo.clear()
|
||||||
|
for p in getPresetVars():
|
||||||
|
if p["name"] in ("CURRENT_TIME", "CURRENT_DATE"):
|
||||||
|
continue
|
||||||
|
info = self._varMgr.getInfoByName(p["name"])
|
||||||
|
if info:
|
||||||
|
self.TargetCombo.addItem(
|
||||||
|
info["display"],
|
||||||
|
(info["name"], info["type"])
|
||||||
|
)
|
||||||
|
self.TargetCombo.blockSignals(False)
|
||||||
|
|
||||||
|
def initValueStacks(
|
||||||
|
self
|
||||||
|
):
|
||||||
|
|
||||||
|
self._literalWidgets = {}
|
||||||
|
self._offsetWidgets = {}
|
||||||
|
for vt in getTypeOrder():
|
||||||
|
self._literalWidgets[vt] = makeValueWidget(vt, self.ValueStack)
|
||||||
|
self.ValueStack.addWidget(self._literalWidgets[vt])
|
||||||
|
if getArithType(vt):
|
||||||
|
self._offsetWidgets[vt] = makeOffsetWidget(vt, self.ValueStack)
|
||||||
|
self.ValueStack.addWidget(self._offsetWidgets[vt])
|
||||||
|
else:
|
||||||
|
Lbl = QLabel("(不支持该操作)", self.ValueStack)
|
||||||
|
Lbl.setFixedHeight(25)
|
||||||
|
self._offsetWidgets[vt] = Lbl
|
||||||
|
self.ValueStack.addWidget(Lbl)
|
||||||
|
|
||||||
|
def connectSignals(
|
||||||
|
self
|
||||||
|
):
|
||||||
|
|
||||||
|
self.OpTypeCombo.currentIndexChanged.connect(self.onOpTypeChanged)
|
||||||
|
self.TargetCombo.currentIndexChanged.connect(self.onTargetChanged)
|
||||||
|
self.ValueSrcCombo.currentIndexChanged.connect(self.onValueSrcChanged)
|
||||||
|
|
||||||
|
def getTargetName(
|
||||||
|
self
|
||||||
|
) -> str:
|
||||||
|
|
||||||
|
data = self.TargetCombo.currentData()
|
||||||
|
return data[0] if data else ""
|
||||||
|
|
||||||
|
def updateValueWidget(
|
||||||
|
self
|
||||||
|
):
|
||||||
|
|
||||||
|
op = self.OpTypeCombo.currentData()
|
||||||
|
isArith = (op in ("add", "sub"))
|
||||||
|
actualType = self._currentTargetType
|
||||||
|
if isArith and actualType in self._offsetWidgets:
|
||||||
|
self.ValueStack.setCurrentWidget(self._offsetWidgets[actualType])
|
||||||
|
elif actualType in self._literalWidgets:
|
||||||
|
self.ValueStack.setCurrentWidget(self._literalWidgets[actualType])
|
||||||
|
else:
|
||||||
|
self.ValueStack.setCurrentWidget(self._literalWidgets.get("String"))
|
||||||
|
|
||||||
|
def toScript(
|
||||||
|
self
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Generate a single line of Lua script from the current widget state.
|
||||||
|
"""
|
||||||
|
|
||||||
|
target = self.getTargetName()
|
||||||
|
op = self.OpTypeCombo.currentData()
|
||||||
|
if op == "pass":
|
||||||
|
return " -- pass"
|
||||||
|
if not target:
|
||||||
|
return ""
|
||||||
|
rawVal = self.getValueRaw()
|
||||||
|
vartype = self._currentTargetType
|
||||||
|
if op == "set":
|
||||||
|
encoded = encodeValueStr(rawVal, vartype)
|
||||||
|
return f" {target} = {encoded}"
|
||||||
|
elif op == "add":
|
||||||
|
if vartype == "Date" and hasattr(self.ValueStack.currentWidget(), "getOffsetDays"):
|
||||||
|
days = self.ValueStack.currentWidget().getOffsetDays()
|
||||||
|
return f" {target} = dateadd({target}, {days})"
|
||||||
|
if vartype == "Time" and hasattr(self.ValueStack.currentWidget(), "getOffsetHours"):
|
||||||
|
hours = self.ValueStack.currentWidget().getOffsetHours()
|
||||||
|
return f" {target} = timeadd({target}, {hours})"
|
||||||
|
return f" {target} = {target} + {rawVal}"
|
||||||
|
elif op == "sub":
|
||||||
|
if vartype == "Date" and hasattr(self.ValueStack.currentWidget(), "getOffsetDays"):
|
||||||
|
days = self.ValueStack.currentWidget().getOffsetDays()
|
||||||
|
return f" {target} = dateadd({target}, -{days})"
|
||||||
|
if vartype == "Time" and hasattr(self.ValueStack.currentWidget(), "getOffsetHours"):
|
||||||
|
hours = self.ValueStack.currentWidget().getOffsetHours()
|
||||||
|
return f" {target} = timeadd({target}, -{hours})"
|
||||||
|
return f" {target} = {target} - {rawVal}"
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def getValueRaw(
|
||||||
|
self
|
||||||
|
) -> str:
|
||||||
|
|
||||||
|
if self.ValueSrcCombo.currentData() == "variable":
|
||||||
|
data = self.ExistingVarCombo.currentData()
|
||||||
|
return data[0] if data else ""
|
||||||
|
w = self.ValueStack.currentWidget()
|
||||||
|
if w:
|
||||||
|
return getValueFromWidget(w)
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def refreshVarCombos(
|
||||||
|
self
|
||||||
|
):
|
||||||
|
|
||||||
|
currentData = self.TargetCombo.currentData()
|
||||||
|
self.populateTargetCombo()
|
||||||
|
if currentData:
|
||||||
|
for i in range(self.TargetCombo.count()):
|
||||||
|
d = self.TargetCombo.itemData(i)
|
||||||
|
if d and d[0] == currentData[0]:
|
||||||
|
self.TargetCombo.setCurrentIndex(i)
|
||||||
|
break
|
||||||
|
self._varMgr.populateCombo(self.ExistingVarCombo)
|
||||||
|
|
||||||
|
@Slot(int)
|
||||||
|
def onTargetChanged(
|
||||||
|
self,
|
||||||
|
idx
|
||||||
|
):
|
||||||
|
|
||||||
|
if idx < 0:
|
||||||
|
return
|
||||||
|
data = self.TargetCombo.itemData(idx)
|
||||||
|
if not data:
|
||||||
|
return
|
||||||
|
_, vartype = data
|
||||||
|
self._currentTargetType = vartype
|
||||||
|
self.updateValueWidget()
|
||||||
|
self.onValueSrcChanged(self.ValueSrcCombo.currentIndex())
|
||||||
|
|
||||||
|
@Slot(int)
|
||||||
|
def onOpTypeChanged(
|
||||||
|
self,
|
||||||
|
idx
|
||||||
|
):
|
||||||
|
|
||||||
|
self.updateValueWidget()
|
||||||
|
|
||||||
|
@Slot(int)
|
||||||
|
def onValueSrcChanged(
|
||||||
|
self,
|
||||||
|
idx
|
||||||
|
):
|
||||||
|
|
||||||
|
isVar = (self.ValueSrcCombo.currentData() == "variable")
|
||||||
|
self.ValueStack.setVisible(not isVar)
|
||||||
|
self.ExistingVarCombo.setVisible(isVar)
|
||||||
|
if isVar:
|
||||||
|
self._varMgr.populateCombo(self.ExistingVarCombo)
|
||||||
|
else:
|
||||||
|
self.updateValueWidget()
|
||||||
@@ -0,0 +1,440 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
Copyright (c) 2025 KenanZhu.
|
||||||
|
All rights reserved.
|
||||||
|
|
||||||
|
This software is provided "as is", without any warranty of any kind.
|
||||||
|
You may use, modify, and distribute this file under the terms of the MIT License.
|
||||||
|
See the LICENSE file for details.
|
||||||
|
"""
|
||||||
|
import queue
|
||||||
|
|
||||||
|
from PySide6.QtCore import (
|
||||||
|
QTimer,
|
||||||
|
QUrl,
|
||||||
|
Qt,
|
||||||
|
Signal,
|
||||||
|
Slot
|
||||||
|
)
|
||||||
|
from PySide6.QtGui import (
|
||||||
|
QCloseEvent,
|
||||||
|
QDesktopServices,
|
||||||
|
QFont,
|
||||||
|
QIcon,
|
||||||
|
QTextCursor
|
||||||
|
)
|
||||||
|
from PySide6.QtWidgets import (
|
||||||
|
QMainWindow,
|
||||||
|
QMenu,
|
||||||
|
QMessageBox,
|
||||||
|
QSystemTrayIcon
|
||||||
|
)
|
||||||
|
|
||||||
|
from base.MsgBase import MsgBase
|
||||||
|
from gui.ALAboutDialog import ALAboutDialog
|
||||||
|
from gui.ALConfigWidget import ALConfigWidget
|
||||||
|
from gui.ALSettingsWidget import ALSettingsWidget
|
||||||
|
from gui.ALMainWorkers import (
|
||||||
|
AutoLibWorker,
|
||||||
|
TimerTaskWorker
|
||||||
|
)
|
||||||
|
from gui.ALTimerTaskManageWidget import ALTimerTaskManageWidget
|
||||||
|
from gui.resources import ALResource
|
||||||
|
from gui.resources.ui.Ui_ALMainWindow import Ui_ALMainWindow
|
||||||
|
from managers.config.ConfigUtils import ConfigUtils
|
||||||
|
|
||||||
|
|
||||||
|
class ALMainWindow(MsgBase, QMainWindow, Ui_ALMainWindow):
|
||||||
|
|
||||||
|
# signal : timer task
|
||||||
|
timerTaskIsRunning = Signal(dict)
|
||||||
|
timerTaskIsExecuted = Signal(dict)
|
||||||
|
timerTaskIsError = Signal(dict)
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self
|
||||||
|
):
|
||||||
|
|
||||||
|
MsgBase.__init__(self, queue.Queue(), queue.Queue())
|
||||||
|
QMainWindow.__init__(self)
|
||||||
|
self.__timer_task_queue = queue.Queue()
|
||||||
|
self.__config_paths = ConfigUtils.getAutomationConfigPaths()
|
||||||
|
self.__alTimerTaskManageWidget = None
|
||||||
|
self.__alConfigWidget = None
|
||||||
|
self.__alSettingsWidget = None
|
||||||
|
self.__auto_lib_thread = None
|
||||||
|
self.__current_timer_task_thread = None
|
||||||
|
self.__is_running_timer_task = False
|
||||||
|
|
||||||
|
self.setupUi(self)
|
||||||
|
self.modifyUi()
|
||||||
|
self.setupTray()
|
||||||
|
self.connectSignals()
|
||||||
|
self.startMsgPolling()
|
||||||
|
self.startTimerTaskPolling()
|
||||||
|
self._showLog("主窗口初始化完成")
|
||||||
|
|
||||||
|
def modifyUi(
|
||||||
|
self
|
||||||
|
):
|
||||||
|
|
||||||
|
self.Icon = QIcon(":/res/icons/AutoLibrary_Logo_64.svg")
|
||||||
|
self.setWindowIcon(self.Icon)
|
||||||
|
self.MessageIOTextEdit.setFont(QFont("Courier New", 10))
|
||||||
|
self.ManualAction.triggered.connect(self.onManualActionTriggered)
|
||||||
|
self.AboutAction.triggered.connect(self.onAboutActionTriggered)
|
||||||
|
self.SettingsAction.triggered.connect(self.onSettingsActionTriggered)
|
||||||
|
|
||||||
|
# initialize timer task widget, but not show it
|
||||||
|
try:
|
||||||
|
self.__alTimerTaskManageWidget = ALTimerTaskManageWidget(self)
|
||||||
|
except Exception as e:
|
||||||
|
QMessageBox.critical(
|
||||||
|
self,
|
||||||
|
"错误 - AutoLibrary",
|
||||||
|
f"初始化定时任务功能失败: \n{e}"
|
||||||
|
)
|
||||||
|
self.__alTimerTaskManageWidget = None
|
||||||
|
self.TimerTaskManageWidgetButton.setEnabled(False)
|
||||||
|
self.TimerTaskManageWidgetButton.setToolTip("定时任务功能初始化失败, 请检查配置文件。")
|
||||||
|
return
|
||||||
|
self.timerTaskIsRunning.connect(self.__alTimerTaskManageWidget.onTimerTaskIsRunning)
|
||||||
|
self.timerTaskIsExecuted.connect(self.__alTimerTaskManageWidget.onTimerTaskIsExecuted)
|
||||||
|
self.timerTaskIsError.connect(self.__alTimerTaskManageWidget.onTimerTaskIsError)
|
||||||
|
self.__alTimerTaskManageWidget.timerTaskIsReady.connect(self.onTimerTaskIsReady)
|
||||||
|
self.__alTimerTaskManageWidget.timerTaskManageWidgetIsClosed.connect(self.onTimerTaskManageWidgetClosed)
|
||||||
|
self.__alTimerTaskManageWidget.setWindowFlags(Qt.WindowType.Window|Qt.WindowType.WindowCloseButtonHint)
|
||||||
|
|
||||||
|
def onAboutActionTriggered(
|
||||||
|
self
|
||||||
|
):
|
||||||
|
|
||||||
|
AboutDialog = ALAboutDialog(self)
|
||||||
|
AboutDialog.exec()
|
||||||
|
|
||||||
|
def onManualActionTriggered(
|
||||||
|
self
|
||||||
|
):
|
||||||
|
|
||||||
|
Url = QUrl("https://www.autolibrary.kenanzhu.com/manuals")
|
||||||
|
QDesktopServices.openUrl(Url)
|
||||||
|
|
||||||
|
def setupTray(
|
||||||
|
self
|
||||||
|
):
|
||||||
|
|
||||||
|
if not QSystemTrayIcon.isSystemTrayAvailable():
|
||||||
|
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.onTimerTaskManageWidgetButtonClicked)
|
||||||
|
self.TrayMenu.addAction("最小化到托盘", self.hideToTray)
|
||||||
|
self.TrayMenu.addSeparator()
|
||||||
|
self.TrayMenu.addAction("退出", self.close)
|
||||||
|
self.TrayIcon.setContextMenu(self.TrayMenu)
|
||||||
|
|
||||||
|
self.TrayIcon.activated.connect(self.onTrayIconActivated)
|
||||||
|
self.TrayIcon.show()
|
||||||
|
|
||||||
|
def hideToTray(
|
||||||
|
self
|
||||||
|
):
|
||||||
|
|
||||||
|
self.hide()
|
||||||
|
self.TrayIcon.showMessage(
|
||||||
|
"AutoLibrary",
|
||||||
|
"\n已最小化到托盘",
|
||||||
|
QSystemTrayIcon.MessageIcon.Information,
|
||||||
|
2000
|
||||||
|
)
|
||||||
|
|
||||||
|
def onTrayIconActivated(
|
||||||
|
self,
|
||||||
|
reason: QSystemTrayIcon.ActivationReason
|
||||||
|
):
|
||||||
|
|
||||||
|
if reason == QSystemTrayIcon.DoubleClick:
|
||||||
|
self.showNormal()
|
||||||
|
|
||||||
|
def connectSignals(
|
||||||
|
self
|
||||||
|
):
|
||||||
|
|
||||||
|
self.ConfigButton.clicked.connect(self.onConfigButtonClicked)
|
||||||
|
self.TimerTaskManageWidgetButton.clicked.connect(self.onTimerTaskManageWidgetButtonClicked)
|
||||||
|
self.StartButton.clicked.connect(self.onStartButtonClicked)
|
||||||
|
self.StopButton.clicked.connect(self.onStopButtonClicked)
|
||||||
|
self.SendButton.clicked.connect(self.onSendButtonClicked)
|
||||||
|
self.MessageEdit.returnPressed.connect(self.onSendButtonClicked)
|
||||||
|
|
||||||
|
def closeEvent(
|
||||||
|
self,
|
||||||
|
event: QCloseEvent
|
||||||
|
):
|
||||||
|
|
||||||
|
if not self.isVisible():
|
||||||
|
self.showNormal()
|
||||||
|
event.ignore()
|
||||||
|
return
|
||||||
|
if self.__msg_queue_timer and self.__msg_queue_timer.isActive():
|
||||||
|
self.__msg_queue_timer.stop()
|
||||||
|
if self.__timer_task_timer and self.__timer_task_timer.isActive():
|
||||||
|
self.__timer_task_timer.stop()
|
||||||
|
if self.__is_running_timer_task:
|
||||||
|
self.__current_timer_task_thread.wait(2000)
|
||||||
|
self.__current_timer_task_thread.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'
|
||||||
|
if self.__alSettingsWidget:
|
||||||
|
self.__alSettingsWidget.close()
|
||||||
|
# the settings widget is already deleted in the 'self.onSettingsWidgetClosed'
|
||||||
|
self._showLog("主窗口关闭")
|
||||||
|
QMainWindow.closeEvent(self, event)
|
||||||
|
|
||||||
|
def appendToTextEdit(
|
||||||
|
self,
|
||||||
|
text: str
|
||||||
|
):
|
||||||
|
|
||||||
|
cursor = self.MessageIOTextEdit.textCursor()
|
||||||
|
cursor.movePosition(QTextCursor.End)
|
||||||
|
cursor.insertText(text + "\n")
|
||||||
|
self.MessageIOTextEdit.setTextCursor(cursor)
|
||||||
|
self.MessageIOTextEdit.ensureCursorVisible()
|
||||||
|
scrollbar = self.MessageIOTextEdit.verticalScrollBar()
|
||||||
|
scrollbar.setValue(scrollbar.maximum())
|
||||||
|
|
||||||
|
def startMsgPolling(
|
||||||
|
self
|
||||||
|
):
|
||||||
|
|
||||||
|
self.__msg_queue_timer = QTimer()
|
||||||
|
self.__msg_queue_timer.timeout.connect(self.pollMsgQueue)
|
||||||
|
self.__msg_queue_timer.start(100)
|
||||||
|
|
||||||
|
def startTimerTaskPolling(
|
||||||
|
self
|
||||||
|
):
|
||||||
|
|
||||||
|
self.__timer_task_timer = QTimer()
|
||||||
|
self.__timer_task_timer.timeout.connect(self.pollTimerTaskQueue)
|
||||||
|
self.__timer_task_timer.start(500)
|
||||||
|
|
||||||
|
def pollTimerTaskQueue(
|
||||||
|
self
|
||||||
|
):
|
||||||
|
|
||||||
|
if self.__is_running_timer_task:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
while not self.__is_running_timer_task:
|
||||||
|
timer_task = self.__timer_task_queue.get_nowait()
|
||||||
|
self.timerTaskIsRunning.emit(timer_task)
|
||||||
|
self.__timer_task_timer.stop()
|
||||||
|
self.__is_running_timer_task = True
|
||||||
|
self.setControlButtons(None, True, False)
|
||||||
|
if not timer_task["silent"]:
|
||||||
|
self.TrayIcon.showMessage(
|
||||||
|
"定时任务 - AutoLibrary",
|
||||||
|
f"\n已开始执行定时任务: \n{timer_task['name']}",
|
||||||
|
QSystemTrayIcon.MessageIcon.Information,
|
||||||
|
1000
|
||||||
|
)
|
||||||
|
self.showNormal()
|
||||||
|
self.__current_timer_task_thread = TimerTaskWorker(
|
||||||
|
timer_task,
|
||||||
|
self._input_queue,
|
||||||
|
self._output_queue,
|
||||||
|
self.__config_paths
|
||||||
|
)
|
||||||
|
self.__current_timer_task_thread.timerTaskWorkerIsFinished.connect(self.onTimerTaskFinished)
|
||||||
|
self.__current_timer_task_thread.start()
|
||||||
|
except queue.Empty:
|
||||||
|
self.__is_running_timer_task = False
|
||||||
|
pass
|
||||||
|
|
||||||
|
def setControlButtons(
|
||||||
|
self,
|
||||||
|
config_button_enabled: bool,
|
||||||
|
stop_button_enabled: bool,
|
||||||
|
start_button_enabled: bool
|
||||||
|
):
|
||||||
|
|
||||||
|
# if the enable is None, then keep the original state
|
||||||
|
if config_button_enabled is not None:
|
||||||
|
self.ConfigButton.setEnabled(config_button_enabled)
|
||||||
|
if stop_button_enabled is not None:
|
||||||
|
self.StopButton.setEnabled(stop_button_enabled)
|
||||||
|
if start_button_enabled is not None:
|
||||||
|
self.StartButton.setEnabled(start_button_enabled)
|
||||||
|
|
||||||
|
@Slot()
|
||||||
|
def pollMsgQueue(
|
||||||
|
self
|
||||||
|
):
|
||||||
|
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
msg = self._output_queue.get_nowait()
|
||||||
|
self.appendToTextEdit(msg)
|
||||||
|
except queue.Empty:
|
||||||
|
pass
|
||||||
|
|
||||||
|
@Slot()
|
||||||
|
def onTimerTaskManageWidgetClosed(
|
||||||
|
self
|
||||||
|
):
|
||||||
|
|
||||||
|
self.TimerTaskManageWidgetButton.setEnabled(True)
|
||||||
|
|
||||||
|
@Slot(dict)
|
||||||
|
def onConfigWidgetClosed(
|
||||||
|
self
|
||||||
|
):
|
||||||
|
|
||||||
|
if self.__alConfigWidget:
|
||||||
|
self.__alConfigWidget.configWidgetIsClosed.disconnect(self.onConfigWidgetClosed)
|
||||||
|
self.__alConfigWidget.deleteLater()
|
||||||
|
self.__alConfigWidget = None
|
||||||
|
self.__config_paths = ConfigUtils.getAutomationConfigPaths()
|
||||||
|
self.setControlButtons(True, None, None)
|
||||||
|
self._showLog("配置窗口已关闭,配置文件路径已更新")
|
||||||
|
|
||||||
|
@Slot()
|
||||||
|
def onSettingsWidgetClosed(
|
||||||
|
self
|
||||||
|
):
|
||||||
|
|
||||||
|
if self.__alSettingsWidget:
|
||||||
|
self.__alSettingsWidget.settingsWidgetIsClosed.disconnect(self.onSettingsWidgetClosed)
|
||||||
|
self.__alSettingsWidget.deleteLater()
|
||||||
|
self.__alSettingsWidget = None
|
||||||
|
self.SettingsAction.setEnabled(True)
|
||||||
|
|
||||||
|
@Slot()
|
||||||
|
def onSettingsActionTriggered(
|
||||||
|
self
|
||||||
|
):
|
||||||
|
|
||||||
|
if self.__alSettingsWidget is None:
|
||||||
|
self.__alSettingsWidget = ALSettingsWidget(self)
|
||||||
|
self.__alSettingsWidget.settingsWidgetIsClosed.connect(self.onSettingsWidgetClosed)
|
||||||
|
self.__alSettingsWidget.show()
|
||||||
|
self.__alSettingsWidget.raise_()
|
||||||
|
self.__alSettingsWidget.activateWindow()
|
||||||
|
self.SettingsAction.setEnabled(False)
|
||||||
|
self._showLog("打开全局设置窗口")
|
||||||
|
|
||||||
|
@Slot(dict)
|
||||||
|
def onTimerTaskIsReady(
|
||||||
|
self,
|
||||||
|
timer_task: dict
|
||||||
|
):
|
||||||
|
|
||||||
|
self.__timer_task_queue.put(timer_task)
|
||||||
|
|
||||||
|
@Slot(dict)
|
||||||
|
def onTimerTaskFinished(
|
||||||
|
self,
|
||||||
|
is_error: bool,
|
||||||
|
timer_task: dict
|
||||||
|
):
|
||||||
|
|
||||||
|
self.__current_timer_task_thread.wait(1000)
|
||||||
|
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)
|
||||||
|
self.__is_running_timer_task = False
|
||||||
|
self.__timer_task_timer.start(500)
|
||||||
|
timer_task["executed"] = True
|
||||||
|
self.TrayIcon.showMessage(
|
||||||
|
"定时任务 - AutoLibrary",
|
||||||
|
f"\n定时任务 '{timer_task['name']}' 执行{'失败' if is_error else '完成'}",
|
||||||
|
QSystemTrayIcon.MessageIcon.Warning if is_error else QSystemTrayIcon.MessageIcon.Information,
|
||||||
|
1000
|
||||||
|
)
|
||||||
|
self._showTrace(
|
||||||
|
f"定时任务 {timer_task['name']} 执行{'失败' if is_error else '完成'}, uuid: {timer_task['uuid']}"
|
||||||
|
)
|
||||||
|
if not is_error:
|
||||||
|
self.timerTaskIsExecuted.emit(timer_task)
|
||||||
|
else:
|
||||||
|
self.timerTaskIsError.emit(timer_task)
|
||||||
|
|
||||||
|
@Slot()
|
||||||
|
def onTimerTaskManageWidgetButtonClicked(
|
||||||
|
self
|
||||||
|
):
|
||||||
|
|
||||||
|
self.__alTimerTaskManageWidget.show()
|
||||||
|
self.__alTimerTaskManageWidget.raise_()
|
||||||
|
self.__alTimerTaskManageWidget.activateWindow()
|
||||||
|
self.TimerTaskManageWidgetButton.setEnabled(False)
|
||||||
|
self._showLog("打开定时任务管理窗口")
|
||||||
|
|
||||||
|
@Slot()
|
||||||
|
def onConfigButtonClicked(
|
||||||
|
self
|
||||||
|
):
|
||||||
|
|
||||||
|
if self.__alConfigWidget is None:
|
||||||
|
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(
|
||||||
|
self
|
||||||
|
):
|
||||||
|
|
||||||
|
self.setControlButtons(None, True, False)
|
||||||
|
if self.__auto_lib_thread is None:
|
||||||
|
self.__auto_lib_thread = AutoLibWorker(
|
||||||
|
self._input_queue,
|
||||||
|
self._output_queue,
|
||||||
|
self.__config_paths
|
||||||
|
)
|
||||||
|
self.__auto_lib_thread.autoLibWorkerIsFinished.connect(self.onStopButtonClicked)
|
||||||
|
self.__auto_lib_thread.autoLibWorkerFinishedWithError.connect(self.onStopButtonClicked)
|
||||||
|
self.__auto_lib_thread.start()
|
||||||
|
self._showLog("开始手动执行任务")
|
||||||
|
|
||||||
|
@Slot()
|
||||||
|
def onStopButtonClicked(
|
||||||
|
self
|
||||||
|
):
|
||||||
|
|
||||||
|
if self.__auto_lib_thread:
|
||||||
|
self._showTrace("正在停止操作......", no_log=True)
|
||||||
|
self.__auto_lib_thread.wait(2000)
|
||||||
|
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(
|
||||||
|
self
|
||||||
|
):
|
||||||
|
|
||||||
|
msg = self.MessageEdit.text().strip()
|
||||||
|
if not msg:
|
||||||
|
return
|
||||||
|
self._showMsg(msg)
|
||||||
|
self._input_queue.put(msg) # put message to input queue
|
||||||
|
self.MessageEdit.clear()
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
Copyright (c) 2025 KenanZhu.
|
||||||
|
All rights reserved.
|
||||||
|
|
||||||
|
This software is provided "as is", without any warranty of any kind.
|
||||||
|
You may use, modify, and distribute this file under the terms of the MIT License.
|
||||||
|
See the LICENSE file for details.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
import queue
|
||||||
|
|
||||||
|
from PySide6.QtCore import (
|
||||||
|
Signal,
|
||||||
|
QThread,
|
||||||
|
)
|
||||||
|
|
||||||
|
from base.MsgBase import MsgBase
|
||||||
|
from pages.AutoLib import AutoLib
|
||||||
|
from utils.JSONReader import JSONReader
|
||||||
|
from autoscript import createEngine
|
||||||
|
|
||||||
|
|
||||||
|
class AutoLibWorker(MsgBase, QThread):
|
||||||
|
|
||||||
|
autoLibWorkerIsFinished = Signal()
|
||||||
|
autoLibWorkerFinishedWithError = Signal()
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
input_queue: queue.Queue,
|
||||||
|
output_queue: queue.Queue,
|
||||||
|
config_paths: dict,
|
||||||
|
):
|
||||||
|
|
||||||
|
MsgBase.__init__(self, input_queue, output_queue)
|
||||||
|
QThread.__init__(self)
|
||||||
|
self.__config_paths = config_paths
|
||||||
|
|
||||||
|
def checkTimeAvailable(
|
||||||
|
self,
|
||||||
|
) -> bool:
|
||||||
|
|
||||||
|
current_time = time.strftime("%H:%M", time.localtime())
|
||||||
|
if current_time >= "23:30" or current_time <= "07:30":
|
||||||
|
self._showTrace(
|
||||||
|
"当前时间不在图书馆开放时间内, 请在 07:30 - 23:30 之间尝试",
|
||||||
|
self.TraceLevel.WARNING,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
self._showLog(f"时间检查通过, 当前时间: {current_time}", self.TraceLevel.INFO)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def checkConfigPaths(
|
||||||
|
self,
|
||||||
|
) -> bool:
|
||||||
|
|
||||||
|
if not all(
|
||||||
|
os.path.exists(path) for path in self.__config_paths.values()
|
||||||
|
):
|
||||||
|
self._showTrace(
|
||||||
|
"配置文件路径不存在, 请检查配置文件路径是否正确",
|
||||||
|
self.TraceLevel.ERROR,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
self._showLog(
|
||||||
|
f"配置文件路径检查通过, 路径: {self.__config_paths}",
|
||||||
|
self.TraceLevel.INFO,
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def loadConfigs(
|
||||||
|
self,
|
||||||
|
) -> bool:
|
||||||
|
|
||||||
|
self._showTrace(
|
||||||
|
f"正在加载配置文件, 运行配置文件路径: {self.__config_paths["run"]}",
|
||||||
|
no_log=True,
|
||||||
|
)
|
||||||
|
self._run_config = JSONReader(self.__config_paths["run"]).data()
|
||||||
|
self._showTrace(
|
||||||
|
f"正在加载配置文件, 用户配置文件路径: {self.__config_paths["user"]}",
|
||||||
|
no_log=True,
|
||||||
|
)
|
||||||
|
self._user_config = JSONReader(self.__config_paths["user"]).data()
|
||||||
|
if self._run_config is None or self._user_config is None:
|
||||||
|
self._showTrace(
|
||||||
|
"配置文件加载失败, 请检查配置文件是否正确",
|
||||||
|
self.TraceLevel.ERROR,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
if not self._user_config.get("groups"):
|
||||||
|
self._showTrace(
|
||||||
|
"用户配置文件中无有效任务组, 请检查用户配置文件是否正确",
|
||||||
|
self.TraceLevel.WARNING,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
self._showLog(
|
||||||
|
f"配置文件加载成功, 任务组数量: {len(self._user_config.get("groups"))}",
|
||||||
|
self.TraceLevel.INFO,
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _runName(
|
||||||
|
self,
|
||||||
|
) -> str:
|
||||||
|
|
||||||
|
return "常规任务"
|
||||||
|
|
||||||
|
def _beforeCreateAutoLib(
|
||||||
|
self,
|
||||||
|
):
|
||||||
|
|
||||||
|
return
|
||||||
|
|
||||||
|
def _onChecksFailed(
|
||||||
|
self,
|
||||||
|
) -> bool:
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _onFinished(
|
||||||
|
self,
|
||||||
|
):
|
||||||
|
|
||||||
|
self.autoLibWorkerIsFinished.emit()
|
||||||
|
|
||||||
|
def _onError(
|
||||||
|
self,
|
||||||
|
error_msg: str,
|
||||||
|
):
|
||||||
|
|
||||||
|
self._showTrace(error_msg, self.TraceLevel.ERROR)
|
||||||
|
self.autoLibWorkerFinishedWithError.emit()
|
||||||
|
|
||||||
|
def run(
|
||||||
|
self,
|
||||||
|
):
|
||||||
|
|
||||||
|
auto_lib = None
|
||||||
|
self._showTrace(f"{self._runName()} 开始运行")
|
||||||
|
|
||||||
|
if not self.checkTimeAvailable() or not self.checkConfigPaths():
|
||||||
|
if not self._onChecksFailed():
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
if not self.loadConfigs():
|
||||||
|
raise Exception("配置文件加载失败")
|
||||||
|
self._beforeCreateAutoLib()
|
||||||
|
auto_lib = AutoLib(
|
||||||
|
self._input_queue,
|
||||||
|
self._output_queue,
|
||||||
|
self._run_config,
|
||||||
|
)
|
||||||
|
groups = self._user_config.get("groups")
|
||||||
|
for group in groups:
|
||||||
|
if not group.get("enabled", False):
|
||||||
|
self._showTrace(f"任务组 {group.get("name", "未知")} 已跳过", no_log=True)
|
||||||
|
continue
|
||||||
|
self._showTrace(f"正在运行任务组 {group.get("name", "未知")}", no_log=True)
|
||||||
|
auto_lib.run({"users": group.get("users", [])})
|
||||||
|
except Exception as e:
|
||||||
|
self._onError(f"{self._runName()} 运行时发生异常 : {e}")
|
||||||
|
return
|
||||||
|
if auto_lib:
|
||||||
|
auto_lib.close()
|
||||||
|
self._showTrace(f"{self._runName()} 运行结束")
|
||||||
|
self._onFinished()
|
||||||
|
|
||||||
|
|
||||||
|
class TimerTaskWorker(AutoLibWorker):
|
||||||
|
|
||||||
|
timerTaskWorkerIsFinished = Signal(bool, dict)
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
timer_task: dict,
|
||||||
|
input_queue: queue.Queue,
|
||||||
|
output_queue: queue.Queue,
|
||||||
|
config_paths: dict,
|
||||||
|
):
|
||||||
|
|
||||||
|
super().__init__(input_queue, output_queue, config_paths)
|
||||||
|
self.__timer_task = timer_task
|
||||||
|
|
||||||
|
def _runName(
|
||||||
|
self,
|
||||||
|
) -> str:
|
||||||
|
|
||||||
|
return f"定时任务 '{self.__timer_task.get("name", "未知")}'"
|
||||||
|
|
||||||
|
def _beforeCreateAutoLib(
|
||||||
|
self,
|
||||||
|
):
|
||||||
|
|
||||||
|
self.applyRepeatAutoScript()
|
||||||
|
|
||||||
|
def _onChecksFailed(
|
||||||
|
self,
|
||||||
|
) -> bool:
|
||||||
|
|
||||||
|
self._showTrace("定时任务跳过执行: 时间或配置文件检查未通过")
|
||||||
|
self.timerTaskWorkerIsFinished.emit(False, self.__timer_task)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _onFinished(
|
||||||
|
self,
|
||||||
|
):
|
||||||
|
|
||||||
|
self.timerTaskWorkerIsFinished.emit(False, self.__timer_task)
|
||||||
|
|
||||||
|
def _onError(
|
||||||
|
self,
|
||||||
|
error_msg: str,
|
||||||
|
):
|
||||||
|
|
||||||
|
self._showTrace(error_msg, self.TraceLevel.ERROR)
|
||||||
|
self.timerTaskWorkerIsFinished.emit(True, self.__timer_task)
|
||||||
|
|
||||||
|
def applyRepeatAutoScript(
|
||||||
|
self,
|
||||||
|
):
|
||||||
|
|
||||||
|
auto_script = self.__timer_task.get("repeat_auto_script", "")
|
||||||
|
if not auto_script or not auto_script.strip():
|
||||||
|
return
|
||||||
|
self._showTrace("检测到重复定时任务 AutoScript, 开始执行...", no_log=True)
|
||||||
|
groups = self._user_config.get("groups", [])
|
||||||
|
affected_count = 0
|
||||||
|
for group in groups:
|
||||||
|
if not group.get("enabled", False):
|
||||||
|
continue
|
||||||
|
for user in group.get("users", []):
|
||||||
|
try:
|
||||||
|
engine = createEngine()
|
||||||
|
engine.execute(auto_script, user)
|
||||||
|
affected_count += 1
|
||||||
|
except ValueError as e:
|
||||||
|
self._showTrace(
|
||||||
|
f"AutoScript 执行错误 (用户 {user.get("username", "未知")}): {e}",
|
||||||
|
self.TraceLevel.ERROR,
|
||||||
|
)
|
||||||
|
self._showLog(
|
||||||
|
f"AutoScript 执行完毕, 影响 {affected_count} 个用户",
|
||||||
|
self.TraceLevel.INFO,
|
||||||
|
)
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
Copyright (c) 2025 KenanZhu.
|
||||||
|
All rights reserved.
|
||||||
|
|
||||||
|
This software is provided "as is", without any warranty of any kind.
|
||||||
|
You may use, modify, and distribute this file under the terms of the MIT License.
|
||||||
|
See the LICENSE file for details.
|
||||||
|
"""
|
||||||
|
from PySide6.QtCore import (
|
||||||
|
Qt, Signal
|
||||||
|
)
|
||||||
|
from PySide6.QtWidgets import (
|
||||||
|
QFrame, QLabel
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ALSeatFrame(QFrame):
|
||||||
|
|
||||||
|
clicked = Signal(str)
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
seat_number,
|
||||||
|
parent = None
|
||||||
|
):
|
||||||
|
|
||||||
|
super().__init__(parent)
|
||||||
|
self.__seat_number = seat_number
|
||||||
|
self.__is_selected = False
|
||||||
|
|
||||||
|
self.setupUi()
|
||||||
|
|
||||||
|
def setupUi(
|
||||||
|
self
|
||||||
|
):
|
||||||
|
|
||||||
|
self.setFixedSize(60, 40)
|
||||||
|
self.setFrameStyle(QFrame.Box | QFrame.Plain)
|
||||||
|
self.setLineWidth(2)
|
||||||
|
self.setStyleSheet("""
|
||||||
|
QFrame {
|
||||||
|
background-color: #2294FF;
|
||||||
|
border: 2px solid #2294FF;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
QLabel {
|
||||||
|
color: #FFFFFF;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
""")
|
||||||
|
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,
|
||||||
|
event
|
||||||
|
):
|
||||||
|
|
||||||
|
if event.button() == Qt.LeftButton:
|
||||||
|
self.toggleSelection()
|
||||||
|
self.clicked.emit(self.__seat_number)
|
||||||
|
|
||||||
|
def isSelected(
|
||||||
|
self
|
||||||
|
):
|
||||||
|
|
||||||
|
return self.__is_selected
|
||||||
|
|
||||||
|
def toggleSelection(self):
|
||||||
|
|
||||||
|
self.__is_selected = not self.__is_selected
|
||||||
|
if self.__is_selected:
|
||||||
|
self.setStyleSheet("""
|
||||||
|
QFrame {
|
||||||
|
background-color: #4CAF50;
|
||||||
|
border: 2px solid #4CAF50;
|
||||||
|
border-radius: 5px;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
QLabel {
|
||||||
|
color: #FFFFFF;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
""")
|
||||||
|
else:
|
||||||
|
self.setStyleSheet("""
|
||||||
|
QFrame {
|
||||||
|
background-color: #2294FF;
|
||||||
|
border: 2px solid #2294FF;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
QLabel {
|
||||||
|
color: #FFFFFF;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
""")
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
Copyright (c) 2026 KenanZhu.
|
||||||
|
All rights reserved.
|
||||||
|
|
||||||
|
This software is provided "as is", without any warranty of any kind.
|
||||||
|
You may use, modify, and distribute this file under the terms of the MIT License.
|
||||||
|
See the LICENSE file for details.
|
||||||
|
"""
|
||||||
|
from PySide6.QtCore import (
|
||||||
|
Qt,
|
||||||
|
Signal,
|
||||||
|
Slot
|
||||||
|
)
|
||||||
|
from PySide6.QtGui import (
|
||||||
|
QCloseEvent
|
||||||
|
)
|
||||||
|
from PySide6.QtWidgets import (
|
||||||
|
QDialog,
|
||||||
|
QHBoxLayout,
|
||||||
|
QLabel,
|
||||||
|
QPushButton,
|
||||||
|
QVBoxLayout
|
||||||
|
)
|
||||||
|
|
||||||
|
from gui.ALSeatMapView import ALSeatMapView
|
||||||
|
from gui.ALWidgetMixin import CenterOnParentMixin
|
||||||
|
|
||||||
|
class ALSeatMapSelectDialog(CenterOnParentMixin, 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 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()
|
||||||
@@ -0,0 +1,270 @@
|
|||||||
|
ALSeatMapTable = {
|
||||||
|
"2": {
|
||||||
|
"1": """
|
||||||
|
,,,,,,,,,,,039A,039B,,040A,040B,,041A,041B,,042A,042B,,043A,043B,,044A,044B,,,,,,,,,
|
||||||
|
,,,,,,,,,,,039C,039D,,040C,040D,,041C,041D,,042C,042D,,043C,043D,,044C,044D,,,,,,,,,
|
||||||
|
038B,038D,,037B,037D,,036B,036D,,,,,,,,,,,,,,,,,,,,,,045C,045A,,046C,046A,,047C,047A
|
||||||
|
038A,038C,,037A,037C,,036A,036C,,,,,,,,,,,,,,,,,,,,,,045D,045B,,046D,046B,,047D,047B
|
||||||
|
035B,035D,,034B,034D,,033B,033D,,,,,,,,,,,,,,,,,,,,,,048C,048A,,049C,049A,,050C,050A
|
||||||
|
035A,035C,,034A,034C,,033A,033C,,,,,,,,,,,,,,,,,,,,,,048D,048B,,049D,049B,,050D,050B
|
||||||
|
032B,032D,,031B,031D,,030B,030D,,,,,,,,,,,,,,,,,,,,,,051C,051A,,052C,052A,,053C,053A
|
||||||
|
032A,032C,,031A,031C,,030A,030C,,,,,,,,,,,,,,,,,,,,,,051D,051B,,052D,052B,,053D,053B
|
||||||
|
029B,029D,,028B,028D,,027B,027D,,,,,,,,,,,,,,,,,,,,,,054C,054A,,055C,055A,,056C,056A
|
||||||
|
029A,029C,,028A,028C,,027A,027C,,,,,,,,,,,,,,,,,,,,,,054D,054B,,055D,055B,,056D,056B
|
||||||
|
026B,026D,,025B,025D,,024B,024D,,,,,,,,,,,,,,,,,,,,,,057C,057A,,058C,058A,,059C,059A
|
||||||
|
026A,026C,,025A,025C,,024A,024C,,,,,,,,,,,,,,,,,,,,,,057D,057B,,058D,058B,,059D,059B
|
||||||
|
023B,023D,,022B,022D,,021B,021D,,,,,,,,,,,,,,,,,,,,,,060C,060A,,061C,061A,,062C,062A
|
||||||
|
023A,023C,,022A,022C,,021A,021C,,,,,,,,,,,,,,,,,,,,,,060D,060B,,061D,061B,,062D,062B
|
||||||
|
020B,020D,,019B,019D,,018B,018D,,,,,,,,,,,,,,,,,,,,,,063C,063A,,064C,064A,,065C,065A
|
||||||
|
020A,020C,,019A,019C,,018A,018C,,,,,,,,,,,,,,,,,,,,,,063D,063B,,064D,064B,,065D,065B
|
||||||
|
,,,,,,,,,,,017D,017C,,014D,014C,,011D,011C,,008D,008C,,005D,005C,,002D,002C,001D,001C,,,,,,,
|
||||||
|
,,,,,,,,,,,017B,017A,,014B,014A,,011B,011A,,008B,008A,,005B,005A,,002B,002A,001B,001A,,,,,,,
|
||||||
|
,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
||||||
|
,,,,,,,,,,,073D,073C,,015D,015C,,012D,012C,,,,,006D,006C,,003D,003C,,,,,,,,,
|
||||||
|
,,,,,,,,,,,073B,073A,,015B,015A,,012B,012A,,,,,006B,006A,,003B,003A,,,,,,,,,
|
||||||
|
,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
||||||
|
,,,,,,,,,,,072D,072C,,016D,016C,,013D,013C,,,,,007D,007C,,004D,004C,,,,,,,,,
|
||||||
|
,,,,,,,,,,,072B,072A,,016B,016A,,013B,013A,,,,,007B,007A,,004B,004A,,,,,,,,,
|
||||||
|
,,,,,,,,,,,071D,071C,,070D,070C,,069D,069C,,068D,068C,,067D,067C,,066D,066C,,,,,,,,,
|
||||||
|
,,,,,,,,,,,071B,071A,,070B,070A,,069B,069A,,068B,068A,,067B,067A,,066B,066A,,,,,,,,,
|
||||||
|
""",
|
||||||
|
"2": """
|
||||||
|
023B,023D,024B,024D,,,,,,,,,,,,,,,
|
||||||
|
023A,023C,024A,024C,,,,,,,,,,,,,,,
|
||||||
|
022B,022D,032D,032C,,,,,,,,,,,,,,,
|
||||||
|
022A,022C,032B,032A,,,,,,,,,,,,,,,
|
||||||
|
021B,021D,,,,,,,,,,,,,,,,,
|
||||||
|
021A,021C,,,,,,,,,,,,,,,,,
|
||||||
|
020B,020D,,,,,,,,,,,,,,,,,
|
||||||
|
020A,020C,,,,,,,,,,,,,,,,,
|
||||||
|
019B,019D,,,,,,,,,,,,,,,,,
|
||||||
|
019A,019C,,,,,,,,,,,,,,,,,
|
||||||
|
018B,018D,,,,,,,,,,,,,,,,,
|
||||||
|
018A,018C,,,,,,,,,,,,,,,,,
|
||||||
|
017B,017D,,,,,,,,,,,,,,,,,
|
||||||
|
017A,017C,,,,,,,,,,,,,,,,,
|
||||||
|
016B,016D,,,,,,,,,,,,,,,,,
|
||||||
|
016A,016C,,,,,031A,031C,,,,,,,,,,,
|
||||||
|
015B,015D,,,,,030B,030D,,,,,,,,,,,
|
||||||
|
015A,015C,,,,,030A,030C,,,,,,,,,,,
|
||||||
|
014B,014D,,,,,029B,029D,,,,,,,,,,,
|
||||||
|
014A,014C,,,,,029A,029C,,,,,,,,,,,
|
||||||
|
013B,013D,,,,,028B,028D,,,,,,,,,,,
|
||||||
|
013A,013C,,,,,028A,028C,,,,,,,,,,,
|
||||||
|
012B,012D,,,,,027B,027D,,,,,,,,,,,
|
||||||
|
012A,012C,,,,,027A,027C,,,,,,,,,,,
|
||||||
|
011B,011D,,,,,026B,026D,,,,,,,,,,,
|
||||||
|
011A,011C,,,,,026A,026C,,,,,,,,,,,
|
||||||
|
010B,010D,,,,,025B,025D,,,,,,,,,,,
|
||||||
|
010A,010C,,,,,,,,,,,,,,,,,
|
||||||
|
009B,009D,,,,,,,,,,,,,,,,,
|
||||||
|
009A,009C,,,,,,,,,,,,,,,,,
|
||||||
|
008B,008D,,,,,,,,,,,,,,,,,
|
||||||
|
008A,008C,,,,,,,,,,,,,,,,,
|
||||||
|
007B,007D,,,,,,,,,,,,,,,,,
|
||||||
|
007A,007C,,,,,,,,,,,,,,,,,
|
||||||
|
006B,006D,,,,,,,,,,,,,,,,,
|
||||||
|
006A,006C,,,,,,,,,,,,,,,,,
|
||||||
|
005B,005D,,,,,,,,,,,,,,,,,
|
||||||
|
005A,005C,,,,,,,,,,,,,,,,,
|
||||||
|
004D,004C,003D,003C,002D,002C,001D,001C,,,,,,,,,,,
|
||||||
|
004B,004A,003B,003A,002B,002A,001B,001A,,,,,,,,,,,
|
||||||
|
|
||||||
|
"""
|
||||||
|
},
|
||||||
|
"3": {
|
||||||
|
"3": """
|
||||||
|
,,007B,007D,,,,,,,,008C,008A,,
|
||||||
|
,,007A,007C,,,,,,,,008D,008B,,
|
||||||
|
,,006B,006D,,,,,,,,009C,009A,,
|
||||||
|
,,006A,006C,,,,,,,,009D,009B,,
|
||||||
|
,,005B,005D,,,,,,,,010C,010a,,
|
||||||
|
,,005A,005C,,,,,,,,010D,010B,,
|
||||||
|
,,004B,004D,,,,,,,,011C,011A,,
|
||||||
|
,,004A,004C,,,,,,,,011D,011B,,
|
||||||
|
,,003B,003D,,,,,,,,012C,012A,,
|
||||||
|
,,003A,003C,,,,,,,,012D,012B,,
|
||||||
|
,,002B,002D,,,,,,,,013C,013A,,
|
||||||
|
,,002A,002C,,,,,,,,013D,013B,,
|
||||||
|
,,001B,001D,,,,,,,,014C,014A,,
|
||||||
|
,,001A,001C,,,,,,,,014D,014B,,
|
||||||
|
""",
|
||||||
|
"4": """
|
||||||
|
,,037D,037C,038D,038C,039D,039C,040D,040C,041D,041C,042D,042C,043D,043C,044D,044C,045D,045C,,,046D,046C,047D,047C,048D,048C,049D,049C,050D,050C,051D,051C,052D,052C,053D,053C,054D,054C,055D,055C,056D,056C,057D,057C,,
|
||||||
|
,,037B,037A,038B,038A,039B,039A,040B,040A,041B,041A,042B,042A,043B,043A,044B,044A,045B,045A,,,046B,046A,047B,047A,048B,048A,049B,049A,050B,050A,051B,051A,052B,052A,053B,053A,054B,054A,055B,055A,056B,056A,057B,057A,,
|
||||||
|
036B,036D,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,058C,058A,,060C,060A
|
||||||
|
036A,036C,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,058D,058B,,060D,060B
|
||||||
|
035B,035D,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,059C,059A,,061C,061A
|
||||||
|
035A,035C,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,059D,059B,,061D,061B
|
||||||
|
034B,034D,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,062C,062A
|
||||||
|
034A,034C,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,062D,062B
|
||||||
|
033B,033D,,,,,,,,,,,,080B,080D,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,063C,063A
|
||||||
|
033A,033C,,,,,,,,,,,,080A,080C,,081A,081B,082A,082B,083A,083B,084A,084B,085A,085B,086A,086B,087A,,,,,,,,,,,,,,,,,,063D,063B
|
||||||
|
032B,032D,,,,,,,,,,,,079B,079D,,081C,081D,082C,082D,083C,083D,084C,084D,085C,085D,086C,086D,087C,,,,,,,,,,,,,,,,,,064C,064A
|
||||||
|
032A,032C,,,,,,,,,,,,079A,079C,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,064D,064B
|
||||||
|
031B,031D,,,,,,,,,,,,078B,078D,,,,,,,,,,,,,,088A,088C,,,,,,,,,,,,,,,,,065C,065A
|
||||||
|
031A,031C,,,,,,,,,,,,078A,078C,,,,,,,,,,,,,,088B,088D,,,,,,,,,,,,,,,,,065D,065B
|
||||||
|
030B,030D,,,,,,,,,,,,077B,077D,,,,,,,,,,,,,,089A,089C,,,,,,,,,,,,,,,,,066C,066A
|
||||||
|
030A,030C,,,,,,,,,,,,077A,077C,,,,,,,,,,,,,,089B,089D,,,,,,,,,,,,,,,,,066D,066B
|
||||||
|
029B,029D,,,,,,,,,,,,076B,076D,,,,,,,,,,,,,,090A,090C,,,,,,,,,,,,,,,,,,
|
||||||
|
029A,029C,,,,,,,,,,,,076A,076C,,,,,,,,,,,,,,090B,090D,,,,,,,,,,,,,,,,,,
|
||||||
|
028B,028D,,,,,,,,,,,,075B,075D,,,,,,,,,,,,,,091A,091C,,,,,,,,,,,,,,,,,,
|
||||||
|
028A,028C,,,,,,,,,,,,075A,075C,,,,,,,,,,,,,,091B,091D,,,,,,,,,,,,,,,,,,
|
||||||
|
027B,027D,,,,,,,,,,,,074B,074D,,,,,,,,,,,,,,092A,092C,,,,,,,,,,,,,,,,,,
|
||||||
|
027A,027C,,,,,,,,,,,,,,,,,,,,,,,,,,,092B,092D,,,,,,,,,,,,,,,,,,
|
||||||
|
026B,026D,,,,,,,,,,,,,,,073D,073C,072D,072C,071D,071C,070D,070C,069D,069C,068D,068C,,,,,,,,,,,,,,,,,,,,
|
||||||
|
026A,026C,,,,,,,,,,,,,,,073B,073A,072B,072A,071B,071A,070B,070A,069B,069A,068B,068A,,,,,,,,,,,,,,,,,,,,
|
||||||
|
025B,025D,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
||||||
|
025A,025C,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
||||||
|
024B,024D,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
||||||
|
024A,024C,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
||||||
|
023B,023D,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
||||||
|
023A,023C,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,067C,,
|
||||||
|
022B,022D,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,067B,,
|
||||||
|
022A,022C,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,067A,,
|
||||||
|
,,021D,021C,020D,020C,019D,019C,018D,018C,017D,017C,016D,016C,015D,015C,014D,014C,013D,013C,012D,012C,011D,011C,010D,010C,009D,009C,008D,008C,007D,007C,006D,006C,005D,005C,004D,004C,003D,003C,002D,002C,001D,001C,,,,
|
||||||
|
,,021B,021A,020B,020A,019B,019A,018B,018A,017B,017A,016B,016A,015B,015A,014B,014A,013B,013A,012B,012A,011B,011A,010B,010A,009B,009A,008B,008A,007B,007a,006B,006A,005B,005A,004B,004A,003b,003A,002B,002A,001B,001A,,,,
|
||||||
|
|
||||||
|
"""
|
||||||
|
},
|
||||||
|
"4": {
|
||||||
|
"5": """
|
||||||
|
,,,,,,,,042A,042B,045A,045B,048A,048B,051A,051B,054A,054B,057A,057B,060A,060B,,,,,,
|
||||||
|
,,,,,,,,042C,042D,045C,045D,048C,048D,051C,051D,054C,054D,057C,057D,060C,060D,,,,,,
|
||||||
|
,,,,,,,,041A,041B,044A,044B,047A,047B,050A,050B,053A,053B,056A,056B,059A,059B,,,,,,
|
||||||
|
,,,,,,,,041C,041D,044C,044D,047C,047D,050C,050D,053C,053D,056C,056D,059C,059D,,,,,,
|
||||||
|
,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
||||||
|
,,,,,,,,040A,040B,043A,043B,046A,046B,049A,049B,052A,052B,055A,055B,058A,058B,,,,,,
|
||||||
|
,,,,,,,,040C,040D,043C,043D,046C,046D,049C,049D,052C,052D,055C,055D,058C,058D,,,,,,
|
||||||
|
,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
||||||
|
,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
||||||
|
,039B,039D,038B,038D,,037B,037D,,,,,,,,,,,,,,,,,,,,,
|
||||||
|
,039A,039C,038A,038C,,037A,037C,,,,,,,,,,,,,,,,,,,,,
|
||||||
|
,036B,036D,035B,035D,,034B,034D,,,,,,,,,,,,,,,,,,,,,
|
||||||
|
,036A,036C,035A,035C,,034A,034C,,,,,,,,,,,,,,,,,,,,,
|
||||||
|
,033B,033D,032B,032D,,031B,031D,,,,,,,,,,,,,,,,,,,,,
|
||||||
|
,033A,033C,032A,032C,,031A,031C,,,,,,,,,,,,,,,,,,,,,
|
||||||
|
,030B,030D,029B,029D,,028B,028D,,,,,,,,,,,,,,,,,,,,,
|
||||||
|
,030A,030C,029A,029C,,028A,028C,,,,,,,,,,,,,,,,,,,,,
|
||||||
|
,027B,027D,026B,026D,,025B,025D,,,,,,,,,,,,,,,,,,,,,
|
||||||
|
,027A,027C,026A,026C,,025A,025C,,,,,,,,,,,,,,,,,,,,,
|
||||||
|
,024B,024D,023B,023D,,022B,022D,,,,,,,,,,,,,,,,,,,,,
|
||||||
|
,024A,024C,023A,023C,,022A,022C,,,,,,,,,,,,,,,,,,,,,
|
||||||
|
,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
||||||
|
,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
||||||
|
,,,,,,,,019D,019C,016D,016C,013D,013C,010D,010C,007D,007C,004D,004C,001D,001C,,,,,,
|
||||||
|
,,,,,,,,019B,019A,016B,016A,013B,013A,010B,010A,007B,007A,004B,004A,001B,001A,,,,,,
|
||||||
|
,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
||||||
|
,,,,,,,,020D,020C,017D,017C,014D,014C,011D,011C,008D,008C,005D,005C,002D,002C,,,,,,
|
||||||
|
,,,,,,,,020B,020A,017B,017A,014B,014A,011B,011A,008B,008A,005B,005A,002B,002A,,,,,,
|
||||||
|
,,,,,,,,021D,021C,018D,018C,015D,015C,012D,012C,009D,009C,006D,006C,003D,003C,,,,,,
|
||||||
|
,,,,,,,,021B,021A,018B,018A,015B,015A,012B,012A,009B,009A,006B,006A,003B,003A,,,,,,
|
||||||
|
|
||||||
|
""",
|
||||||
|
"6": """
|
||||||
|
,,,026C,026D,027D,027C,028D,028C,029D,029C,030D,030C,031D,031C,032D,032C,033D,033C,035D,035C,036D,036C,037D,037C,038D,038C,039D,039C,040D,040C,041D,041C,042D,042C,043D,043C,044D,044C,045D,045C,046D,046C
|
||||||
|
,,,026A,026B,027B,027A,028B,028A,029B,029A,030B,030A,031B,031A,032B,032A,033B,033A,035B,035A,036B,036A,037B,037A,038B,038A,039B,039A,040B,040A,041B,041A,042B,042A,043B,043A,044B,044A,045B,045A,046B,046A
|
||||||
|
025D,025C,,,,,,,,,,,,,,,,034D,034C,,,,,,,,,,,,,,,,,,,,,,,047C,047A
|
||||||
|
025B,025A,,,,,,,,,,,,,,,,034B,034A,,,,,,,,,,,,,,,,,,,,,,,047D,047B
|
||||||
|
024D,024C,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,048C,048A
|
||||||
|
024B,024A,,,,,,,,,,,,,,050D,050C,052D,052C,054D,054C,056D,056C,058D,058C,060D,060C,,,,,,,,,,,,,,,048D,048B
|
||||||
|
023D,023C,,,,,,,,,,,,,,050B,050A,052B,052A,054B,054A,056B,056A,058B,058A,060B,060A,,,,,,,,,,,,,,,,
|
||||||
|
023B,023A,,,,,,,,,,,,,,049D,049C,051D,051C,053D,053C,055D,055C,057D,057C,059D,059C,,,,,,,,,,,,,,,,
|
||||||
|
022D,022C,,,,,,,,,,,,,,049B,049A,051B,051A,053B,053A,055B,055A,057B,057A,059B,059A,,,,,,,,,,,,,,,,
|
||||||
|
022B,022A,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
||||||
|
021D,021C,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
||||||
|
021B,021A,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
||||||
|
020D,020C,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
||||||
|
020B,020A,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
||||||
|
019D,019C,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
||||||
|
019B,019A,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
||||||
|
,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
||||||
|
,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
||||||
|
,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
||||||
|
,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
||||||
|
,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
||||||
|
,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
||||||
|
015D,015C,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
||||||
|
015B,015A,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
||||||
|
014D,014C,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
||||||
|
014B,014A,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
||||||
|
013D,013C,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
||||||
|
013B,013A,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
||||||
|
012D,012C,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
||||||
|
012B,012A,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
||||||
|
011D,011C,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
||||||
|
011B,011A,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
||||||
|
010D,010C,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
||||||
|
010B,010A,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
||||||
|
009D,009C,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
||||||
|
009B,009A,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
||||||
|
008D,008C,,007D,007C,006D,006C,005D,005C,004D,004C,003D,003C,002D,002C,001D,001C,,,,,,,,,,,,,,,,,,,,,,,,,,
|
||||||
|
008B,008A,,007B,007A,006B,006A,005B,005A,004B,004A,003B,003A,002B,002A,001B,001A,,,,,,,,,,,,,,,,,,,,,,,,,,
|
||||||
|
|
||||||
|
""",
|
||||||
|
"7": """
|
||||||
|
,,,,,,,,022D,022C,021D,021C,020D,020C,019D,019C,018D,018C,017D,017C,,,,,,,,,,,,
|
||||||
|
,,,,,,,,022B,022A,021B,021A,020B,020A,019B,019A,018B,018A,017B,017A,,,,,,,,,,,,
|
||||||
|
,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
||||||
|
,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
||||||
|
,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
||||||
|
,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
||||||
|
,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
||||||
|
016D,016C,015D,015C,014D,014C,013D,013C,012D,012C,011D,011C,010D,010C,009D,009C,008D,008C,007D,007C,006D,006C,005D,005C,004D,004C,003D,003C,002D,002C,001D,001C
|
||||||
|
016B,016A,015B,015A,014B,014A,013B,013A,012B,012A,011B,011A,010B,010A,009B,009A,008B,008A,007B,007A,006B,006A,005B,005A,004B,004A,003B,003A,002B,002A,001B,001A
|
||||||
|
|
||||||
|
"""
|
||||||
|
},
|
||||||
|
"5": {
|
||||||
|
"8": """
|
||||||
|
,,,046D,046C,047D,047C,048D,048C,049D,049C,050D,050C,051D,051C,052D,052C,053D,053C,054D,054C,055D,055C,,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
||||||
|
,,,046B,046A,047B,047A,048B,048A,049B,049A,050B,050A,051B,051A,052B,052A,053B,053A,054B,054A,055B,055A,,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
||||||
|
,,,,,,,,,,,,,,,,,,,,,,,056C,056A,,,,,,,,,,,,,,,,,,,,,,,,,,
|
||||||
|
045B,045D,,,,,,,,,,,,,,,,,,,,,,056D,056B,,,,,,,,,,,,,,,,,,,,,,,,,,
|
||||||
|
045A,045C,,,,,,,,,,,,,,,,,,,,,,057C,057A,,,,,,,,,,,,,,,,,,,,,,,,,,
|
||||||
|
044B,044D,,,,,,,,,,,,,,,,,,,,,,057D,057B,,,,,,,,,,,,,,,,,,,,,,,,,,
|
||||||
|
044A,044C,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
||||||
|
043B,043D,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
||||||
|
043A,043C,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
||||||
|
042B,042D,,,,,,,,,,,,,,,,,070B,070D,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
||||||
|
042A,042C,,,,,,,,,,,,,,,,,070A,070C,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
||||||
|
041B,041D,,,,,,,,,,,,,,,,,069B,069D,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
||||||
|
041A,041C,,,,,,,,,,,,,,,,,069A,069C,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
||||||
|
040B,040D,,,,,,,,,,,,,,,,,068B,068D,,071A,071B,072A,072B,073A,073B,074A,074B,075A,075B,076A,076B,077A,077B,,,,,,,,,,,,,,,,
|
||||||
|
040A,040C,,,,,,,,,,,,,,,,,068A,068C,,071C,071D,072C,072D,073C,073D,074C,074D,075C,075D,076C,076D,077C,077D,,,,,,,,,,,,,,,,
|
||||||
|
039B,039D,,,,,,,,,,,,,,,,,067B,067D,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
||||||
|
039A,039C,,,,,,,,,,,,,,,,,067A,067C,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
||||||
|
038B,038D,,,,,,,,,,,,,,,,,066B,066D,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
||||||
|
038A,038C,,,,,,,,,,,,,,,,,066A,066C,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
||||||
|
037B,037D,,,,,,,,,,,,,,,,,065B,065D,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
||||||
|
037A,037C,,,,,,,,,,,,,,,,,065A,065C,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
||||||
|
036B,036D,,,,,,,,,,,,,,,,,064B,064D,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
||||||
|
036A,036C,,,,,,,,,,,,,,,,,064A,064C,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
||||||
|
035B,035D,,,,,,,,,,,,,,,,,063B,063D,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
||||||
|
035A,035C,,,,,,,,,,,,,,,,,063A,063C,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
||||||
|
034B,034D,,,,,,,,,,,,,,,,,062B,062D,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
||||||
|
034A,034C,,,,,,,,,,,,,,,,,062A,062C,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
||||||
|
033B,033D,,,,,,,,,,,,,,,,,,,061D,061C,,060D,060C,,059D,059C,,058D,058C,,,,,,,,,,,,,,,,,,,,
|
||||||
|
033A,033C,,,,,,,,,,,,,,,,,,,061B,061A,,060B,060A,,059B,059A,,058B,058A,,,,,,,,,,,,,,,,,,,,
|
||||||
|
032B,032D,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
||||||
|
032A,032C,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
||||||
|
031B,031D,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
||||||
|
031A,031C,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
||||||
|
030B,030D,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
||||||
|
030A,030C,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
||||||
|
029B,029D,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
||||||
|
029A,029C,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
||||||
|
028B,028D,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
||||||
|
028A,028C,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
||||||
|
027B,027D,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
||||||
|
027A,027C,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
||||||
|
026B,026D,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
||||||
|
026A,026C,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
||||||
|
025B,025D,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
||||||
|
025A,025C,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
||||||
|
,,,024D,024C,023D,023C,022D,022C,021D,021C,020D,020C,019D,019C,018D,018C,017D,017C,016D,016C,015D,015C,014D,014C,013D,013C,012D,012C,011D,011C,010D,010C,009D,009C,008D,008C,007D,007C,006D,006C,005D,005C,004D,004C,003D,003C,002D,002C,001D,001C
|
||||||
|
,,,024B,024A,023B,023A,022B,022A,021B,021A,020B,020A,019B,019A,018B,018A,017B,017A,016B,016A,015B,015A,014B,014A,013B,013A,012B,012A,011B,011A,010B,010A,009B,009A,008B,008A,007B,007A,006B,006A,005B,005A,004B,004A,003B,003A,002B,002A,001B,001A
|
||||||
|
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
Copyright (c) 2026 KenanZhu.
|
||||||
|
All rights reserved.
|
||||||
|
|
||||||
|
This software is provided "as is", without any warranty of any kind.
|
||||||
|
You may use, modify, and distribute this file under the terms of the MIT License.
|
||||||
|
See the LICENSE file for details.
|
||||||
|
"""
|
||||||
|
from PySide6.QtCore import (
|
||||||
|
Qt,
|
||||||
|
Slot,
|
||||||
|
QEvent
|
||||||
|
)
|
||||||
|
from PySide6.QtWidgets import (
|
||||||
|
QFrame,
|
||||||
|
QWidget,
|
||||||
|
QGridLayout,
|
||||||
|
QGraphicsView,
|
||||||
|
QGraphicsScene,
|
||||||
|
QGraphicsItem
|
||||||
|
)
|
||||||
|
from PySide6.QtGui import (
|
||||||
|
QPainter,
|
||||||
|
QWheelEvent
|
||||||
|
)
|
||||||
|
|
||||||
|
from gui.ALSeatFrame import ALSeatFrame
|
||||||
|
|
||||||
|
|
||||||
|
class ALSeatMapView(QGraphicsView):
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
parent: QWidget = None,
|
||||||
|
seats_data: dict = {},
|
||||||
|
):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.__seats_data = seats_data
|
||||||
|
self.__selected_seats = []
|
||||||
|
self.__seat_frames = {}
|
||||||
|
|
||||||
|
self.setupUi()
|
||||||
|
|
||||||
|
def eventFilter(
|
||||||
|
self,
|
||||||
|
watched,
|
||||||
|
event
|
||||||
|
):
|
||||||
|
|
||||||
|
if (watched is self.viewport() and
|
||||||
|
event.type() == QEvent.Type.Wheel and
|
||||||
|
event.modifiers() == Qt.KeyboardModifier.ControlModifier
|
||||||
|
):
|
||||||
|
self.zoomGraphicsView(event)
|
||||||
|
return True
|
||||||
|
return super().eventFilter(watched, event)
|
||||||
|
|
||||||
|
def zoomGraphicsView(
|
||||||
|
self,
|
||||||
|
event: QWheelEvent
|
||||||
|
):
|
||||||
|
|
||||||
|
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
|
||||||
|
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 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
|
||||||
|
):
|
||||||
|
|
||||||
|
rows = self.__seats_data.strip().split("\n")
|
||||||
|
for row_idx, row in enumerate(rows):
|
||||||
|
col_idx = 0
|
||||||
|
seats_number = [seat.strip() for seat in row.split(",")]
|
||||||
|
for seat_number in seats_number:
|
||||||
|
if seat_number:
|
||||||
|
SeatWidget = ALSeatFrame(seat_number)
|
||||||
|
SeatWidget.clicked.connect(self.onSeatClicked)
|
||||||
|
self.SeatsContainerLayout.addWidget(SeatWidget, row_idx, col_idx)
|
||||||
|
self.__seat_frames[seat_number] = SeatWidget
|
||||||
|
else:
|
||||||
|
Spacer = QFrame()
|
||||||
|
Spacer.setFixedSize(20, 30)
|
||||||
|
Spacer.setStyleSheet("background-color: transparent; border: none;")
|
||||||
|
self.SeatsContainerLayout.addWidget(Spacer, row_idx, col_idx)
|
||||||
|
col_idx += 1
|
||||||
|
self.SeatsContainerLayout.setSpacing(20)
|
||||||
|
self.SeatsContainerLayout.setContentsMargins(20, 20, 20, 20)
|
||||||
|
self.SeatsContainerWidget.adjustSize()
|
||||||
|
|
||||||
|
def selectSeat(
|
||||||
|
self,
|
||||||
|
seat_number: str
|
||||||
|
):
|
||||||
|
|
||||||
|
if len(self.__selected_seats) >= 1:
|
||||||
|
return
|
||||||
|
seat_number = self.formatSeatNumber(seat_number)
|
||||||
|
if seat_number not in self.__seat_frames:
|
||||||
|
return
|
||||||
|
widget = self.__seat_frames[seat_number]
|
||||||
|
if widget.isSelected():
|
||||||
|
return
|
||||||
|
widget.toggleSelection()
|
||||||
|
self.__selected_seats.append(seat_number)
|
||||||
|
|
||||||
|
def selectSeats(
|
||||||
|
self,
|
||||||
|
selected_seats: list
|
||||||
|
):
|
||||||
|
|
||||||
|
self.clearSelections()
|
||||||
|
for seat_number in selected_seats:
|
||||||
|
self.selectSeat(seat_number)
|
||||||
|
|
||||||
|
def getSelectedSeats(
|
||||||
|
self
|
||||||
|
) -> list[str]:
|
||||||
|
|
||||||
|
return self.__selected_seats
|
||||||
|
|
||||||
|
def clearSelections(
|
||||||
|
self
|
||||||
|
):
|
||||||
|
|
||||||
|
seats_to_clear = self.__selected_seats.copy()
|
||||||
|
for seat_number in seats_to_clear:
|
||||||
|
if seat_number not in self.__seat_frames:
|
||||||
|
continue
|
||||||
|
widget = self.__seat_frames[seat_number]
|
||||||
|
if widget.isSelected():
|
||||||
|
widget.toggleSelection()
|
||||||
|
self.__selected_seats = []
|
||||||
|
|
||||||
|
@Slot(str)
|
||||||
|
def onSeatClicked(
|
||||||
|
self,
|
||||||
|
seat_number: str
|
||||||
|
):
|
||||||
|
|
||||||
|
if seat_number in self.__selected_seats:
|
||||||
|
self.__selected_seats.remove(seat_number)
|
||||||
|
else:
|
||||||
|
if len(self.__selected_seats) < 1:
|
||||||
|
self.__selected_seats.append(seat_number)
|
||||||
|
else:
|
||||||
|
self.__seat_frames[seat_number].toggleSelection()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def formatSeatNumber(
|
||||||
|
seat_number: str
|
||||||
|
) -> str:
|
||||||
|
|
||||||
|
if seat_number and not seat_number[-1].isdigit():
|
||||||
|
digits = seat_number[:-1]
|
||||||
|
letter = seat_number[-1]
|
||||||
|
return digits.zfill(3) + letter
|
||||||
|
return seat_number.zfill(3)
|
||||||
@@ -0,0 +1,433 @@
|
|||||||
|
# -*- 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 sys
|
||||||
|
|
||||||
|
import qtawesome as qta
|
||||||
|
|
||||||
|
from PySide6.QtCore import (
|
||||||
|
QProcess,
|
||||||
|
Qt,
|
||||||
|
Signal,
|
||||||
|
Slot
|
||||||
|
)
|
||||||
|
from PySide6.QtGui import (
|
||||||
|
QCloseEvent
|
||||||
|
)
|
||||||
|
from PySide6.QtWidgets import (
|
||||||
|
QApplication,
|
||||||
|
QFileDialog,
|
||||||
|
QMessageBox,
|
||||||
|
QStyleFactory,
|
||||||
|
QWidget
|
||||||
|
)
|
||||||
|
|
||||||
|
import managers.config.ConfigManager as ConfigManager
|
||||||
|
from managers.log.LogManager import instance as logInstance
|
||||||
|
from managers.theme.ThemeManager import(
|
||||||
|
getActiveStyle,
|
||||||
|
setActiveStyle,
|
||||||
|
instance as themeInstance
|
||||||
|
)
|
||||||
|
|
||||||
|
from gui.ALWidgetMixin import CenterOnParentMixin
|
||||||
|
from gui.resources.ui.Ui_ALSettingsWidget import Ui_ALSettingsWidget
|
||||||
|
from interfaces.ConfigProvider import (
|
||||||
|
CfgKey,
|
||||||
|
ConfigProvider
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _applyCustomTheme(
|
||||||
|
name: str,
|
||||||
|
fallback_theme: str = "system"
|
||||||
|
) -> bool:
|
||||||
|
|
||||||
|
if not name:
|
||||||
|
themeInstance().clearTheme(fallback_theme)
|
||||||
|
return True
|
||||||
|
try:
|
||||||
|
themeInstance().applyTheme(name)
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logInstance().getLogger("ALSettingsWidget").warning(
|
||||||
|
f"无法应用自定义主题 '{name}',回退到 {fallback_theme} 外观: {e}"
|
||||||
|
)
|
||||||
|
themeInstance().clearTheme(fallback_theme)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _themeToReadable(
|
||||||
|
need_theme: str
|
||||||
|
) -> str:
|
||||||
|
|
||||||
|
if need_theme == "dark":
|
||||||
|
return "深色"
|
||||||
|
elif need_theme == "light":
|
||||||
|
return "浅色"
|
||||||
|
elif need_theme == "both":
|
||||||
|
return "所有"
|
||||||
|
else:
|
||||||
|
return "未知"
|
||||||
|
|
||||||
|
def _restartApp(
|
||||||
|
):
|
||||||
|
|
||||||
|
QApplication.instance().quit()
|
||||||
|
QProcess.startDetached(sys.executable, sys.argv)
|
||||||
|
|
||||||
|
|
||||||
|
class ALSettingsWidget(CenterOnParentMixin, QWidget, Ui_ALSettingsWidget):
|
||||||
|
|
||||||
|
settingsWidgetIsClosed = Signal()
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
parent=None
|
||||||
|
):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.__cfg_mgr: ConfigProvider = ConfigManager.instance()
|
||||||
|
self.__original_theme: str = ""
|
||||||
|
self.__original_custom_theme: str = ""
|
||||||
|
self.__original_style: str = ""
|
||||||
|
|
||||||
|
self.setupUi(self)
|
||||||
|
self.modifyUi()
|
||||||
|
self.connectSignals()
|
||||||
|
self.loadSettings()
|
||||||
|
|
||||||
|
def closeEvent(
|
||||||
|
self,
|
||||||
|
event: QCloseEvent
|
||||||
|
):
|
||||||
|
|
||||||
|
self.settingsWidgetIsClosed.emit()
|
||||||
|
super().closeEvent(event)
|
||||||
|
|
||||||
|
def modifyUi(
|
||||||
|
self
|
||||||
|
):
|
||||||
|
|
||||||
|
self.setWindowFlags(Qt.WindowType.Window)
|
||||||
|
self.setNavigationIcons()
|
||||||
|
color = QApplication.instance().palette().color(
|
||||||
|
QApplication.instance().palette().ColorRole.WindowText
|
||||||
|
).name()
|
||||||
|
self.ImportCustomThemeButton.setIcon(qta.icon("fa6s.plus", color=color))
|
||||||
|
self.ImportCustomThemeButton.setText("")
|
||||||
|
self.RemoveCustomThemeButton.setIcon(qta.icon("fa6s.minus", color=color))
|
||||||
|
self.RemoveCustomThemeButton.setText("")
|
||||||
|
self.CustomThemeInfoLabel.setTextFormat(Qt.TextFormat.RichText)
|
||||||
|
self.CustomThemeInfoLabel.setStyleSheet(
|
||||||
|
"border: 1px solid palette(mid);"\
|
||||||
|
"border-radius: 2px;"\
|
||||||
|
"padding: 5px;"
|
||||||
|
)
|
||||||
|
self.NavigationList.setCurrentRow(0)
|
||||||
|
self.populateStyles()
|
||||||
|
self.populateCustomThemes()
|
||||||
|
|
||||||
|
def setNavigationIcons(
|
||||||
|
self
|
||||||
|
):
|
||||||
|
|
||||||
|
app : QApplication | None = QApplication.instance()
|
||||||
|
color = app.palette().color(app.palette().ColorRole.WindowText).name()
|
||||||
|
item = self.NavigationList.item(0)
|
||||||
|
if item:
|
||||||
|
item.setIcon(qta.icon("fa6s.palette", color=color))
|
||||||
|
|
||||||
|
def populateStyles(
|
||||||
|
self
|
||||||
|
):
|
||||||
|
|
||||||
|
self.StyleComboBox.clear()
|
||||||
|
self.StyleComboBox.addItems(QStyleFactory.keys())
|
||||||
|
|
||||||
|
def populateCustomThemes(
|
||||||
|
self
|
||||||
|
):
|
||||||
|
|
||||||
|
self.CustomThemeComboBox.blockSignals(True)
|
||||||
|
self.CustomThemeComboBox.clear()
|
||||||
|
self.CustomThemeComboBox.addItem("默认", "")
|
||||||
|
self.__theme_cache = {}
|
||||||
|
themes = themeInstance().listThemes()
|
||||||
|
for t in themes:
|
||||||
|
name = t.get("name", "")
|
||||||
|
file = t.get("file", name)
|
||||||
|
author = t.get("author", "")
|
||||||
|
if name:
|
||||||
|
self.__theme_cache[file] = t
|
||||||
|
self.CustomThemeComboBox.addItem(name, file)
|
||||||
|
self.CustomThemeComboBox.blockSignals(False)
|
||||||
|
|
||||||
|
def connectSignals(
|
||||||
|
self
|
||||||
|
):
|
||||||
|
|
||||||
|
self.ImportCustomThemeButton.clicked.connect(self.onImportCustomThemeButtonClicked)
|
||||||
|
self.RemoveCustomThemeButton.clicked.connect(self.onRemoveCustomThemeButtonClicked)
|
||||||
|
self.CustomThemeComboBox.currentIndexChanged.connect(self.onCustomThemeComboBoxChanged)
|
||||||
|
self.ResetCustomThemeButton.clicked.connect(self.onResetCustomThemeButtonClicked)
|
||||||
|
self.CancelButton.clicked.connect(self.onCancelButtonClicked)
|
||||||
|
self.ApplyButton.clicked.connect(self.onApplyButtonClicked)
|
||||||
|
self.ConfirmButton.clicked.connect(self.onConfirmButtonClicked)
|
||||||
|
|
||||||
|
def loadSettings(
|
||||||
|
self
|
||||||
|
):
|
||||||
|
|
||||||
|
theme = self.__cfg_mgr.get(CfgKey.GLOBAL.APPEARANCE.THEME, "system")
|
||||||
|
style = self.__cfg_mgr.get(CfgKey.GLOBAL.APPEARANCE.STYLE, "Fusion")
|
||||||
|
custom_theme = self.__cfg_mgr.get(CfgKey.GLOBAL.APPEARANCE.CUSTOM_THEME, "")
|
||||||
|
self.__original_theme = theme
|
||||||
|
self.__original_custom_theme = custom_theme
|
||||||
|
self.__original_style = getActiveStyle()
|
||||||
|
if theme == "light":
|
||||||
|
self.LightThemeRadio.setChecked(True)
|
||||||
|
elif theme == "dark":
|
||||||
|
self.DarkThemeRadio.setChecked(True)
|
||||||
|
else:
|
||||||
|
self.SystemThemeRadio.setChecked(True)
|
||||||
|
index = self.StyleComboBox.findText(style)
|
||||||
|
if index < 0:
|
||||||
|
index = 0
|
||||||
|
self.StyleComboBox.setCurrentIndex(index)
|
||||||
|
if custom_theme:
|
||||||
|
idx = self.CustomThemeComboBox.findData(custom_theme)
|
||||||
|
if idx >= 0:
|
||||||
|
self.CustomThemeComboBox.setCurrentIndex(idx)
|
||||||
|
self.updateCustomThemeInfo()
|
||||||
|
self.updateCustomThemeStatus()
|
||||||
|
|
||||||
|
def updateCustomThemeInfo(
|
||||||
|
self
|
||||||
|
):
|
||||||
|
|
||||||
|
file = self.CustomThemeComboBox.currentData()
|
||||||
|
if not file:
|
||||||
|
self.CustomThemeInfoLabel.setText("")
|
||||||
|
return
|
||||||
|
t = self.__theme_cache.get(file)
|
||||||
|
if t:
|
||||||
|
name = t.get("name", "未知")
|
||||||
|
author = t.get("author", "未知作者")
|
||||||
|
need_theme = t.get("need_theme", "both")
|
||||||
|
brief = t.get("brief", "没有相关简介")
|
||||||
|
self.CustomThemeInfoLabel.setText(
|
||||||
|
f"<b>{name}</b> - 适用于 <i>{_themeToReadable(need_theme)}</i> 主题<br>"
|
||||||
|
f"作者:{author}<br><br>"
|
||||||
|
f"{brief}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.CustomThemeInfoLabel.setText("")
|
||||||
|
|
||||||
|
def updateCustomThemeStatus(
|
||||||
|
self
|
||||||
|
):
|
||||||
|
|
||||||
|
file = self.CustomThemeComboBox.currentData()
|
||||||
|
t = self.__theme_cache.get(file) if file else None
|
||||||
|
name = t.get("name", "") if t else ""
|
||||||
|
if name:
|
||||||
|
self.CustomThemeStatusLabel.setText(f"当前使用 {name} 主题。")
|
||||||
|
else:
|
||||||
|
self.CustomThemeStatusLabel.setText("当前使用 默认 主题。")
|
||||||
|
|
||||||
|
def syncRadioFromNeedTheme(
|
||||||
|
self,
|
||||||
|
name: str
|
||||||
|
):
|
||||||
|
|
||||||
|
t = self.__theme_cache.get(name)
|
||||||
|
if t:
|
||||||
|
need_theme = t.get("need_theme", "both")
|
||||||
|
if need_theme == "light":
|
||||||
|
self.LightThemeRadio.setChecked(True)
|
||||||
|
elif need_theme == "dark":
|
||||||
|
self.DarkThemeRadio.setChecked(True)
|
||||||
|
|
||||||
|
def collectSettings(
|
||||||
|
self
|
||||||
|
):
|
||||||
|
|
||||||
|
if self.LightThemeRadio.isChecked():
|
||||||
|
theme = "light"
|
||||||
|
elif self.DarkThemeRadio.isChecked():
|
||||||
|
theme = "dark"
|
||||||
|
else:
|
||||||
|
theme = "system"
|
||||||
|
style = self.StyleComboBox.currentText()
|
||||||
|
custom_theme = self.CustomThemeComboBox.currentData() or ""
|
||||||
|
if not custom_theme:
|
||||||
|
custom_theme = ""
|
||||||
|
return theme, style, custom_theme
|
||||||
|
|
||||||
|
def saveAndApply(
|
||||||
|
self
|
||||||
|
):
|
||||||
|
|
||||||
|
theme, style, custom_theme = self.collectSettings()
|
||||||
|
self.__cfg_mgr.set(CfgKey.GLOBAL.APPEARANCE.STYLE, style)
|
||||||
|
self.__cfg_mgr.set(CfgKey.GLOBAL.APPEARANCE.CUSTOM_THEME, custom_theme)
|
||||||
|
setActiveStyle(style)
|
||||||
|
if not _applyCustomTheme(custom_theme, theme):
|
||||||
|
self.__cfg_mgr.set(CfgKey.GLOBAL.APPEARANCE.CUSTOM_THEME, "")
|
||||||
|
self.syncRadioFromNeedTheme(custom_theme)
|
||||||
|
# Re-read theme after syncRadioFromNeedTheme — the radio may have
|
||||||
|
# changed to match the custom theme's need_theme
|
||||||
|
theme, _, _ = self.collectSettings()
|
||||||
|
self.__cfg_mgr.set(CfgKey.GLOBAL.APPEARANCE.THEME, theme)
|
||||||
|
self.setNavigationIcons()
|
||||||
|
self.updateCustomThemeStatus()
|
||||||
|
self.updateCustomThemeInfo()
|
||||||
|
self.__original_theme = theme
|
||||||
|
self.__original_custom_theme = custom_theme if custom_theme else ""
|
||||||
|
self.__original_style = getActiveStyle()
|
||||||
|
|
||||||
|
def maybeRestart(
|
||||||
|
self
|
||||||
|
) -> bool:
|
||||||
|
|
||||||
|
reply = QMessageBox.question(
|
||||||
|
self,
|
||||||
|
"提示 - AutoLibrary",
|
||||||
|
"界面风格已修改,需要重启程序才能生效。是否立即重启?",
|
||||||
|
QMessageBox.Yes | QMessageBox.No,
|
||||||
|
QMessageBox.Yes
|
||||||
|
)
|
||||||
|
if reply == QMessageBox.Yes:
|
||||||
|
_restartApp()
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
@Slot()
|
||||||
|
def onRemoveCustomThemeButtonClicked(
|
||||||
|
self
|
||||||
|
):
|
||||||
|
|
||||||
|
file = self.CustomThemeComboBox.currentData()
|
||||||
|
if not file:
|
||||||
|
QMessageBox.information(
|
||||||
|
self,
|
||||||
|
"提示 - AutoLibrary",
|
||||||
|
"请先选择一个主题。"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
t = self.__theme_cache.get(file)
|
||||||
|
name = t.get("name", file) if t else file
|
||||||
|
reply = QMessageBox.question(
|
||||||
|
self,
|
||||||
|
"删除主题 - AutoLibrary",
|
||||||
|
f"确定要删除主题 \"{name}\" 吗?",
|
||||||
|
QMessageBox.Yes | QMessageBox.No,
|
||||||
|
QMessageBox.No
|
||||||
|
)
|
||||||
|
if reply != QMessageBox.Yes:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
themeInstance().removeTheme(file)
|
||||||
|
self.populateCustomThemes()
|
||||||
|
self.CustomThemeComboBox.setCurrentIndex(0)
|
||||||
|
self.updateCustomThemeStatus()
|
||||||
|
self.updateCustomThemeInfo()
|
||||||
|
except Exception as e:
|
||||||
|
QMessageBox.warning(
|
||||||
|
self,
|
||||||
|
"删除失败 - AutoLibrary",
|
||||||
|
f"无法删除主题:{e}"
|
||||||
|
)
|
||||||
|
|
||||||
|
@Slot()
|
||||||
|
def onImportCustomThemeButtonClicked(
|
||||||
|
self
|
||||||
|
):
|
||||||
|
|
||||||
|
file_path, _ = QFileDialog.getOpenFileName(
|
||||||
|
self,
|
||||||
|
"导入主题 - AutoLibrary",
|
||||||
|
"",
|
||||||
|
"主题文件 (*.altheme *.qss);;所有文件 (*)"
|
||||||
|
)
|
||||||
|
if not file_path:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
file_id = themeInstance().importTheme(file_path)
|
||||||
|
self.populateCustomThemes()
|
||||||
|
idx = self.CustomThemeComboBox.findData(file_id)
|
||||||
|
if idx >= 0:
|
||||||
|
self.CustomThemeComboBox.setCurrentIndex(idx)
|
||||||
|
self.updateCustomThemeStatus()
|
||||||
|
self.updateCustomThemeInfo()
|
||||||
|
except Exception as e:
|
||||||
|
QMessageBox.warning(
|
||||||
|
self,
|
||||||
|
"导入失败 - AutoLibrary",
|
||||||
|
f"无法导入主题文件:{e}"
|
||||||
|
)
|
||||||
|
|
||||||
|
@Slot()
|
||||||
|
def onCustomThemeComboBoxChanged(
|
||||||
|
self,
|
||||||
|
index: int
|
||||||
|
):
|
||||||
|
|
||||||
|
self.updateCustomThemeInfo()
|
||||||
|
# no status update, because custom theme is not applied yet.
|
||||||
|
|
||||||
|
@Slot()
|
||||||
|
def onResetCustomThemeButtonClicked(
|
||||||
|
self
|
||||||
|
):
|
||||||
|
|
||||||
|
self.CustomThemeComboBox.blockSignals(True)
|
||||||
|
if self.__original_custom_theme:
|
||||||
|
idx = self.CustomThemeComboBox.findData(self.__original_custom_theme)
|
||||||
|
if idx >= 0:
|
||||||
|
self.CustomThemeComboBox.setCurrentIndex(idx)
|
||||||
|
else:
|
||||||
|
self.CustomThemeComboBox.setCurrentIndex(0)
|
||||||
|
else:
|
||||||
|
self.CustomThemeComboBox.setCurrentIndex(0)
|
||||||
|
self.CustomThemeComboBox.blockSignals(False)
|
||||||
|
if self.__original_theme == "light":
|
||||||
|
self.LightThemeRadio.setChecked(True)
|
||||||
|
elif self.__original_theme == "dark":
|
||||||
|
self.DarkThemeRadio.setChecked(True)
|
||||||
|
else:
|
||||||
|
self.SystemThemeRadio.setChecked(True)
|
||||||
|
self.updateCustomThemeInfo()
|
||||||
|
self.updateCustomThemeStatus()
|
||||||
|
|
||||||
|
@Slot()
|
||||||
|
def onCancelButtonClicked(
|
||||||
|
self
|
||||||
|
):
|
||||||
|
|
||||||
|
self.close()
|
||||||
|
|
||||||
|
@Slot()
|
||||||
|
def onApplyButtonClicked(
|
||||||
|
self
|
||||||
|
):
|
||||||
|
|
||||||
|
_, style, _ = self.collectSettings()
|
||||||
|
style_changed = self.__original_style != style
|
||||||
|
self.saveAndApply()
|
||||||
|
if style_changed:
|
||||||
|
self.maybeRestart()
|
||||||
|
|
||||||
|
@Slot()
|
||||||
|
def onConfirmButtonClicked(
|
||||||
|
self
|
||||||
|
):
|
||||||
|
|
||||||
|
self.onApplyButtonClicked() # virtually call apply button clicked
|
||||||
|
self.close()
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
Copyright (c) 2026 KenanZhu.
|
||||||
|
All rights reserved.
|
||||||
|
|
||||||
|
This software is provided "as is", without any warranty of any kind.
|
||||||
|
You may use, modify, and distribute this file under the terms of the MIT License.
|
||||||
|
See the LICENSE file for details.
|
||||||
|
"""
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
from PySide6.QtCore import (
|
||||||
|
Property,
|
||||||
|
QEasingCurve,
|
||||||
|
QPropertyAnimation,
|
||||||
|
Qt
|
||||||
|
)
|
||||||
|
from PySide6.QtGui import (
|
||||||
|
QColor,
|
||||||
|
QConicalGradient,
|
||||||
|
QPainter,
|
||||||
|
QPalette
|
||||||
|
)
|
||||||
|
from PySide6.QtWidgets import (
|
||||||
|
QLabel
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ALStatusLabel(QLabel):
|
||||||
|
|
||||||
|
class Status(Enum):
|
||||||
|
"""
|
||||||
|
Enum class for representing the status of ALStatusLabel.
|
||||||
|
"""
|
||||||
|
|
||||||
|
WAITING = 0
|
||||||
|
RUNNING = 1
|
||||||
|
SUCCESS = 2
|
||||||
|
WARNING = 3
|
||||||
|
FAILURE = 4
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
parent = None
|
||||||
|
):
|
||||||
|
|
||||||
|
super().__init__(parent)
|
||||||
|
self.__status = self.Status.WAITING
|
||||||
|
self.__icon_angle = 0
|
||||||
|
|
||||||
|
self.setupUi()
|
||||||
|
|
||||||
|
def setupUi(
|
||||||
|
self
|
||||||
|
):
|
||||||
|
|
||||||
|
self.setFixedSize(36, 36)
|
||||||
|
self.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
|
self.RunningAnimation = QPropertyAnimation(self, b"iconAngle")
|
||||||
|
self.RunningAnimation.setDuration(1000)
|
||||||
|
self.RunningAnimation.setStartValue(0)
|
||||||
|
self.RunningAnimation.setEndValue(-360)
|
||||||
|
self.RunningAnimation.setLoopCount(-1)
|
||||||
|
self.RunningAnimation.setEasingCurve(QEasingCurve.Type.Linear)
|
||||||
|
|
||||||
|
def isDarkMode(
|
||||||
|
self
|
||||||
|
) -> bool:
|
||||||
|
|
||||||
|
return self.palette().color(QPalette.ColorRole.Window).value() < 128
|
||||||
|
|
||||||
|
def getMarkColor(
|
||||||
|
self
|
||||||
|
) -> QColor:
|
||||||
|
|
||||||
|
return QColor("#FFFFFF") if self.isDarkMode() else QColor("#454545")
|
||||||
|
|
||||||
|
@Property(Status)
|
||||||
|
def status(
|
||||||
|
self
|
||||||
|
) -> Status:
|
||||||
|
|
||||||
|
return self.__status
|
||||||
|
|
||||||
|
@Property(int)
|
||||||
|
def iconAngle(
|
||||||
|
self
|
||||||
|
) -> int:
|
||||||
|
|
||||||
|
return self.__icon_angle
|
||||||
|
|
||||||
|
@status.setter
|
||||||
|
def status(
|
||||||
|
self,
|
||||||
|
status: Status
|
||||||
|
):
|
||||||
|
|
||||||
|
if status not in self.Status:
|
||||||
|
raise ValueError(f"Invalid (class)Status[enum.Enum] value: {status}")
|
||||||
|
self.__status = status
|
||||||
|
if self.__status == self.Status.RUNNING:
|
||||||
|
self.RunningAnimation.start()
|
||||||
|
else:
|
||||||
|
self.RunningAnimation.stop()
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
@iconAngle.setter
|
||||||
|
def iconAngle(
|
||||||
|
self,
|
||||||
|
value: int
|
||||||
|
):
|
||||||
|
|
||||||
|
self.__icon_angle = value
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
def paintEvent(
|
||||||
|
self,
|
||||||
|
event
|
||||||
|
):
|
||||||
|
|
||||||
|
Painter = QPainter(self)
|
||||||
|
Painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||||
|
center_x = self.width()/2
|
||||||
|
center_y = self.height()/2
|
||||||
|
radius = min(center_x, center_y) - 3
|
||||||
|
match self.__status:
|
||||||
|
case self.Status.WAITING:
|
||||||
|
Pen = Painter.pen()
|
||||||
|
Pen.setWidth(2)
|
||||||
|
Pen.setBrush(Qt.BrushStyle.NoBrush)
|
||||||
|
Pen.setCapStyle(Qt.PenCapStyle.RoundCap)
|
||||||
|
Pen.setColor(QColor("#969696")) # grey
|
||||||
|
Painter.setPen(Pen)
|
||||||
|
Painter.drawEllipse(
|
||||||
|
int(center_x - radius),
|
||||||
|
int(center_y - radius),
|
||||||
|
int(radius*2),
|
||||||
|
int(radius*2)
|
||||||
|
)
|
||||||
|
case self.Status.RUNNING:
|
||||||
|
Gradient = QConicalGradient(center_x, center_y, self.__icon_angle)
|
||||||
|
Gradient.setColorAt(0.0, QColor("#2294FF" if self.isDarkMode() else "#0094FF"))
|
||||||
|
Gradient.setColorAt(1.0, QColor("#2294FF00"))
|
||||||
|
Pen = Painter.pen()
|
||||||
|
Pen.setWidth(3)
|
||||||
|
Pen.setBrush(Gradient)
|
||||||
|
Pen.setCapStyle(Qt.PenCapStyle.RoundCap)
|
||||||
|
Painter.setPen(Pen)
|
||||||
|
Painter.drawEllipse(
|
||||||
|
int(center_x - radius),
|
||||||
|
int(center_y - radius),
|
||||||
|
int(radius*2),
|
||||||
|
int(radius*2)
|
||||||
|
)
|
||||||
|
case self.Status.SUCCESS:
|
||||||
|
# draw the success green circle
|
||||||
|
Pen = Painter.pen()
|
||||||
|
Pen.setWidth(2)
|
||||||
|
Pen.setBrush(Qt.BrushStyle.NoBrush)
|
||||||
|
Pen.setCapStyle(Qt.PenCapStyle.RoundCap)
|
||||||
|
Pen.setColor(QColor("#4CAF50" if self.isDarkMode() else "#00AF50")) # green
|
||||||
|
Painter.setPen(Pen)
|
||||||
|
Painter.drawEllipse(
|
||||||
|
int(center_x - radius),
|
||||||
|
int(center_y - radius),
|
||||||
|
int(radius*2),
|
||||||
|
int(radius*2)
|
||||||
|
)
|
||||||
|
# draw the success check mark '✓'
|
||||||
|
Painter.setPen(Qt.PenStyle.SolidLine)
|
||||||
|
Pen = Painter.pen()
|
||||||
|
Pen.setWidth(3)
|
||||||
|
Pen.setBrush(Qt.BrushStyle.NoBrush)
|
||||||
|
Pen.setCapStyle(Qt.PenCapStyle.RoundCap)
|
||||||
|
# white when dark mode, black when light mode
|
||||||
|
Pen.setColor(self.getMarkColor())
|
||||||
|
Painter.setPen(Pen)
|
||||||
|
mark_size = radius/2
|
||||||
|
mark_path = [
|
||||||
|
(center_x - mark_size, center_y),
|
||||||
|
(center_x - mark_size/3, center_y + mark_size/2),
|
||||||
|
(center_x + mark_size, center_y - mark_size/2)
|
||||||
|
]
|
||||||
|
Painter.drawLine(
|
||||||
|
int(mark_path[0][0]),int(mark_path[0][1]),
|
||||||
|
int(mark_path[1][0]),int(mark_path[1][1])
|
||||||
|
)
|
||||||
|
Painter.drawLine(
|
||||||
|
int(mark_path[1][0]),int(mark_path[1][1]),
|
||||||
|
int(mark_path[2][0]),int(mark_path[2][1])
|
||||||
|
)
|
||||||
|
case self.Status.WARNING:
|
||||||
|
# draw the warning orange circle
|
||||||
|
Pen = Painter.pen()
|
||||||
|
Pen.setWidth(2)
|
||||||
|
Pen.setBrush(Qt.BrushStyle.NoBrush)
|
||||||
|
Pen.setCapStyle(Qt.PenCapStyle.RoundCap)
|
||||||
|
Pen.setColor(QColor("#FF9800")) # orange
|
||||||
|
Painter.setPen(Pen)
|
||||||
|
Painter.drawEllipse(
|
||||||
|
int(center_x - radius),
|
||||||
|
int(center_y - radius),
|
||||||
|
int(radius*2),
|
||||||
|
int(radius*2)
|
||||||
|
)
|
||||||
|
# draw the warning exclamation mark '!'
|
||||||
|
Painter.setPen(Qt.PenStyle.SolidLine)
|
||||||
|
Pen = Painter.pen()
|
||||||
|
Pen.setWidth(3)
|
||||||
|
Pen.setBrush(Qt.BrushStyle.NoBrush)
|
||||||
|
Pen.setCapStyle(Qt.PenCapStyle.RoundCap)
|
||||||
|
# white when dark mode, black when light mode
|
||||||
|
Pen.setColor(self.getMarkColor())
|
||||||
|
Painter.setPen(Pen)
|
||||||
|
Painter.drawLine(
|
||||||
|
int(center_x), int(center_y - radius/2),
|
||||||
|
int(center_x), int(center_y + radius/6)
|
||||||
|
)
|
||||||
|
Painter.drawPoint(
|
||||||
|
int(center_x), int(center_y + radius/2)
|
||||||
|
)
|
||||||
|
case self.Status.FAILURE:
|
||||||
|
# draw the failure red circle
|
||||||
|
Pen = Painter.pen()
|
||||||
|
Pen.setWidth(2)
|
||||||
|
Pen.setBrush(Qt.BrushStyle.NoBrush)
|
||||||
|
Pen.setCapStyle(Qt.PenCapStyle.RoundCap)
|
||||||
|
Pen.setColor(QColor("#DC0000")) # red
|
||||||
|
Painter.setPen(Pen)
|
||||||
|
Painter.drawEllipse(
|
||||||
|
int(center_x - radius),
|
||||||
|
int(center_y - radius),
|
||||||
|
int(radius*2),
|
||||||
|
int(radius*2)
|
||||||
|
)
|
||||||
|
# draw the failure cross mark '✗'
|
||||||
|
Painter.setPen(Qt.PenStyle.SolidLine)
|
||||||
|
Pen = Painter.pen()
|
||||||
|
Pen.setWidth(3)
|
||||||
|
Pen.setBrush(Qt.BrushStyle.NoBrush)
|
||||||
|
Pen.setCapStyle(Qt.PenCapStyle.RoundCap)
|
||||||
|
# white when dark mode, black when light mode
|
||||||
|
Pen.setColor(self.getMarkColor())
|
||||||
|
Painter.setPen(Pen)
|
||||||
|
mark_size = radius/3
|
||||||
|
Painter.drawLine(
|
||||||
|
int(center_x - mark_size), int(center_y - mark_size),
|
||||||
|
int(center_x + mark_size), int(center_y + mark_size)
|
||||||
|
)
|
||||||
|
Painter.drawLine(
|
||||||
|
int(center_x + mark_size), int(center_y - mark_size),
|
||||||
|
int(center_x - mark_size), int(center_y + mark_size)
|
||||||
|
)
|
||||||
|
Painter.end()
|
||||||
|
super().paintEvent(event)
|
||||||
@@ -0,0 +1,326 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
Copyright (c) 2025 KenanZhu.
|
||||||
|
All rights reserved.
|
||||||
|
|
||||||
|
This software is provided "as is", without any warranty of any kind.
|
||||||
|
You may use, modify, and distribute this file under the terms of the MIT License.
|
||||||
|
See the LICENSE file for details.
|
||||||
|
"""
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from enum import Enum
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
from PySide6.QtCore import (
|
||||||
|
Slot,
|
||||||
|
QDateTime,
|
||||||
|
QUrl
|
||||||
|
)
|
||||||
|
from PySide6.QtGui import QDesktopServices
|
||||||
|
from PySide6.QtWidgets import (
|
||||||
|
QLabel,
|
||||||
|
QDialog,
|
||||||
|
QWidget,
|
||||||
|
QSpinBox,
|
||||||
|
QHBoxLayout,
|
||||||
|
QVBoxLayout,
|
||||||
|
QDateTimeEdit,
|
||||||
|
QGroupBox,
|
||||||
|
QPushButton
|
||||||
|
)
|
||||||
|
|
||||||
|
from gui.resources.ui.Ui_ALTimerTaskAddDialog import Ui_ALTimerTaskAddDialog
|
||||||
|
from utils.TimerUtils import TimerUtils
|
||||||
|
|
||||||
|
|
||||||
|
class ALTimerTaskStatus(Enum):
|
||||||
|
|
||||||
|
PENDING = "等待中"
|
||||||
|
READY = "已就绪"
|
||||||
|
RUNNING = "执行中"
|
||||||
|
EXECUTED = "已执行"
|
||||||
|
ERROR = "执行失败"
|
||||||
|
OUTDATED = "已过期"
|
||||||
|
UNKNOWN = "未知"
|
||||||
|
|
||||||
|
|
||||||
|
class ALTimerTaskAddDialog(QDialog, Ui_ALTimerTaskAddDialog):
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
parent = None,
|
||||||
|
timer_task: dict = None
|
||||||
|
):
|
||||||
|
|
||||||
|
super().__init__(parent)
|
||||||
|
self.__edit_timer_task = timer_task
|
||||||
|
|
||||||
|
self.setupUi(self)
|
||||||
|
self.modifyUi()
|
||||||
|
self.connectSignals()
|
||||||
|
|
||||||
|
if self.__edit_timer_task:
|
||||||
|
self.loadTask(self.__edit_timer_task)
|
||||||
|
|
||||||
|
def modifyUi(
|
||||||
|
self
|
||||||
|
):
|
||||||
|
|
||||||
|
self.TimerTypeComboBox.setCurrentIndex(0)
|
||||||
|
self.SpecificTimerWidget = QWidget()
|
||||||
|
self.SpecificTimerLayout = QHBoxLayout(self.SpecificTimerWidget)
|
||||||
|
self.SpecificTimerLayout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
self.SpecificTimerLayout.setSpacing(5)
|
||||||
|
self.SpecificTimerLayout.addWidget(QLabel("定时时间:"))
|
||||||
|
self.SpecificDateTimeEdit = QDateTimeEdit()
|
||||||
|
self.SpecificDateTimeEdit.setCalendarPopup(True)
|
||||||
|
self.SpecificDateTimeEdit.setDisplayFormat("yyyy-MM-dd HH:mm:ss")
|
||||||
|
self.SpecificDateTimeEdit.setMinimumDateTime(QDateTime.currentDateTime())
|
||||||
|
self.SpecificDateTimeEdit.setDateTime(QDateTime.currentDateTime().addSecs(60))
|
||||||
|
self.SpecificTimerLayout.addWidget(self.SpecificDateTimeEdit)
|
||||||
|
self.TimerConfigLayout.addWidget(self.SpecificTimerWidget)
|
||||||
|
self.RelativeTimerWidget = QWidget()
|
||||||
|
self.RelativeTimerLayout = QHBoxLayout(self.RelativeTimerWidget)
|
||||||
|
self.RelativeTimerLayout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
self.RelativeTimerLayout.setSpacing(5)
|
||||||
|
self.RelativeTimerLayout.addWidget(QLabel("相对时间:"))
|
||||||
|
self.RelativeDaySpinBox = QSpinBox()
|
||||||
|
self.RelativeDaySpinBox.setMinimum(0)
|
||||||
|
self.RelativeDaySpinBox.setMaximum(364)
|
||||||
|
self.RelativeDaySpinBox.setSuffix("天")
|
||||||
|
self.RelativeTimerLayout.addWidget(self.RelativeDaySpinBox)
|
||||||
|
self.RelativeHourSpinBox = QSpinBox()
|
||||||
|
self.RelativeHourSpinBox.setMinimum(0)
|
||||||
|
self.RelativeHourSpinBox.setMaximum(23)
|
||||||
|
self.RelativeHourSpinBox.setSuffix("时")
|
||||||
|
self.RelativeTimerLayout.addWidget(self.RelativeHourSpinBox)
|
||||||
|
self.RelativeMinuteSpinBox = QSpinBox()
|
||||||
|
self.RelativeMinuteSpinBox.setMinimum(0)
|
||||||
|
self.RelativeMinuteSpinBox.setMaximum(59)
|
||||||
|
self.RelativeMinuteSpinBox.setSuffix("分")
|
||||||
|
self.RelativeTimerLayout.addWidget(self.RelativeMinuteSpinBox)
|
||||||
|
self.RelativeSecondSpinBox = QSpinBox()
|
||||||
|
self.RelativeSecondSpinBox.setMinimum(0)
|
||||||
|
self.RelativeSecondSpinBox.setMaximum(59)
|
||||||
|
self.RelativeSecondSpinBox.setSuffix("秒")
|
||||||
|
self.RelativeTimerLayout.addWidget(self.RelativeSecondSpinBox)
|
||||||
|
self.TimerConfigLayout.addWidget(self.RelativeTimerWidget)
|
||||||
|
self.RelativeTimerWidget.setVisible(False)
|
||||||
|
self.AutoScriptGroupBox = QGroupBox("AutoScript 指令")
|
||||||
|
self.AutoScriptLayout = QVBoxLayout(self.AutoScriptGroupBox)
|
||||||
|
self.AutoScriptLayout.setContentsMargins(3, 3, 3, 3)
|
||||||
|
self.AutoScriptLayout.setSpacing(3)
|
||||||
|
AutoScriptBtnLayout = QHBoxLayout()
|
||||||
|
self.AutoScriptEditButton = QPushButton("编辑")
|
||||||
|
self.AutoScriptEditButton.setMinimumHeight(25)
|
||||||
|
self.AutoScriptEditButton.setFixedWidth(80)
|
||||||
|
AutoScriptBtnLayout.addWidget(self.AutoScriptEditButton)
|
||||||
|
AutoScriptBtnLayout.addStretch()
|
||||||
|
self.AutoScriptHelpButton = QPushButton("?")
|
||||||
|
self.AutoScriptHelpButton.setFixedSize(20, 20)
|
||||||
|
self.AutoScriptHelpButton.setToolTip(
|
||||||
|
"AutoScript 是一种轻量级 DSL 语言,基于 Lua 实现。\n"
|
||||||
|
"用于在重复定时任务执行前,对用户的预约数据进行预处理\n"
|
||||||
|
"\n"
|
||||||
|
"点击查看完整在线文档"
|
||||||
|
)
|
||||||
|
self.AutoScriptHelpButton.setStyleSheet(
|
||||||
|
"QPushButton { border-radius: 10px; border: 1px solid #999; "
|
||||||
|
"font-weight: bold; color: #555; }"
|
||||||
|
"QPushButton:hover { background-color: #E0E0E0; }"
|
||||||
|
)
|
||||||
|
AutoScriptBtnLayout.addWidget(self.AutoScriptHelpButton)
|
||||||
|
self.AutoScriptStatusLabel = QLabel("未设置")
|
||||||
|
self.AutoScriptStatusLabel.setStyleSheet("color: #969696;")
|
||||||
|
self.AutoScriptStatusLabel.setFixedHeight(25)
|
||||||
|
AutoScriptBtnLayout.addWidget(self.AutoScriptStatusLabel)
|
||||||
|
self.AutoScriptLayout.addLayout(AutoScriptBtnLayout)
|
||||||
|
self.ALAddTimerTaskLayout.insertWidget(
|
||||||
|
self.ALAddTimerTaskLayout.indexOf(self.TaskConfigGroupBox) + 1,
|
||||||
|
self.AutoScriptGroupBox
|
||||||
|
)
|
||||||
|
self.AutoScriptGroupBox.setVisible(False)
|
||||||
|
self.__auto_script = ""
|
||||||
|
self.__mock_target_data = None
|
||||||
|
|
||||||
|
def loadTask(
|
||||||
|
self,
|
||||||
|
task: dict
|
||||||
|
):
|
||||||
|
|
||||||
|
self.TaskNameLineEdit.setText(task.get("name", ""))
|
||||||
|
time_type = task.get("time_type", "特定时间")
|
||||||
|
self.TimerTypeComboBox.setCurrentText(time_type)
|
||||||
|
self.SpecificDateTimeEdit.setDateTime(
|
||||||
|
QDateTime(task["execute_time"])
|
||||||
|
)
|
||||||
|
self.RelativeDaySpinBox.setValue(0)
|
||||||
|
self.RelativeHourSpinBox.setValue(0)
|
||||||
|
self.RelativeMinuteSpinBox.setValue(0)
|
||||||
|
self.RelativeSecondSpinBox.setValue(0)
|
||||||
|
if task.get("silent", False):
|
||||||
|
self.SilentlyRunRadioButton.setChecked(True)
|
||||||
|
else:
|
||||||
|
self.ShowBeforeRunRadioButton.setChecked(True)
|
||||||
|
repeat = task.get("repeat", False)
|
||||||
|
self.RepeatCheckBox.setChecked(repeat)
|
||||||
|
if repeat:
|
||||||
|
repeat_days = task.get("repeat_days", [])
|
||||||
|
self.MonCheckBox.setChecked(0 in repeat_days)
|
||||||
|
self.TueCheckBox.setChecked(1 in repeat_days)
|
||||||
|
self.WedCheckBox.setChecked(2 in repeat_days)
|
||||||
|
self.ThuCheckBox.setChecked(3 in repeat_days)
|
||||||
|
self.FriCheckBox.setChecked(4 in repeat_days)
|
||||||
|
self.SatCheckBox.setChecked(5 in repeat_days)
|
||||||
|
self.SunCheckBox.setChecked(6 in repeat_days)
|
||||||
|
auto_script = task.get("repeat_auto_script", "")
|
||||||
|
if auto_script:
|
||||||
|
self.__auto_script = auto_script
|
||||||
|
self.AutoScriptStatusLabel.setText("已设置")
|
||||||
|
self.AutoScriptStatusLabel.setStyleSheet("color: #4CAF50;")
|
||||||
|
mock_data = task.get("mock_target_data")
|
||||||
|
if mock_data:
|
||||||
|
self.__mock_target_data = mock_data
|
||||||
|
self.ConfirmButton.setText("保存")
|
||||||
|
|
||||||
|
def connectSignals(
|
||||||
|
self
|
||||||
|
):
|
||||||
|
|
||||||
|
self.CancelButton.clicked.connect(self.reject)
|
||||||
|
self.ConfirmButton.clicked.connect(self.accept)
|
||||||
|
self.TimerTypeComboBox.currentIndexChanged.connect(self.onTimerTypeComboBoxIndexChanged)
|
||||||
|
self.RepeatCheckBox.toggled.connect(self.onRepeatCheckBoxToggled)
|
||||||
|
self.AutoScriptEditButton.clicked.connect(self.onPreviewAutoScript)
|
||||||
|
self.AutoScriptHelpButton.clicked.connect(self.onAutoScriptHelp)
|
||||||
|
|
||||||
|
def getTimerTask(
|
||||||
|
self
|
||||||
|
) -> dict:
|
||||||
|
|
||||||
|
added_time = datetime.now()
|
||||||
|
if not self.TaskNameLineEdit.text():
|
||||||
|
name = f"未命名任务-{added_time.strftime("%Y%m%d%H%M%S")}"
|
||||||
|
else:
|
||||||
|
name = self.TaskNameLineEdit.text()
|
||||||
|
timer_type_index = self.TimerTypeComboBox.currentIndex()
|
||||||
|
silent = not self.ShowBeforeRunRadioButton.isChecked()
|
||||||
|
if timer_type_index == 0:
|
||||||
|
execute_time = self.SpecificDateTimeEdit.dateTime()
|
||||||
|
tmp_time_str = execute_time.toString("yyyy-MM-dd HH:mm:ss")
|
||||||
|
execute_time = datetime.strptime(tmp_time_str, "%Y-%m-%d %H:%M:%S")
|
||||||
|
else:
|
||||||
|
execute_time = datetime.now() + timedelta(
|
||||||
|
days = self.RelativeDaySpinBox.value(),
|
||||||
|
hours = self.RelativeHourSpinBox.value(),
|
||||||
|
minutes = self.RelativeMinuteSpinBox.value(),
|
||||||
|
seconds = self.RelativeSecondSpinBox.value()
|
||||||
|
)
|
||||||
|
|
||||||
|
if self.__edit_timer_task:
|
||||||
|
task_data = dict(self.__edit_timer_task)
|
||||||
|
task_data["name"] = name
|
||||||
|
task_data["execute_time"] = execute_time
|
||||||
|
task_data["silent"] = silent
|
||||||
|
task_data["status"] = ALTimerTaskStatus.PENDING
|
||||||
|
task_data["executed"] = False
|
||||||
|
task_data["repeat_auto_script"] = self.__auto_script
|
||||||
|
task_data["mock_target_data"] = self.__mock_target_data
|
||||||
|
else:
|
||||||
|
task_data = {
|
||||||
|
"name": name,
|
||||||
|
"uuid": uuid.uuid4().hex.upper() + f"-{added_time.strftime("%Y%m%d%H%M%S")}",
|
||||||
|
"time_type": self.TimerTypeComboBox.currentText(),
|
||||||
|
"execute_time": execute_time,
|
||||||
|
"silent": silent,
|
||||||
|
"added_time": added_time,
|
||||||
|
"status": ALTimerTaskStatus.PENDING,
|
||||||
|
"executed": False,
|
||||||
|
"repeat": self.RepeatCheckBox.isChecked(),
|
||||||
|
"repeat_auto_script": self.__auto_script,
|
||||||
|
"mock_target_data": self.__mock_target_data,
|
||||||
|
}
|
||||||
|
|
||||||
|
repeat = self.RepeatCheckBox.isChecked()
|
||||||
|
task_data["repeat"] = repeat
|
||||||
|
if repeat:
|
||||||
|
if "repeat_history" not in task_data:
|
||||||
|
task_data["repeat_history"] = []
|
||||||
|
repeat_days = []
|
||||||
|
if self.MonCheckBox.isChecked():
|
||||||
|
repeat_days.append(0)
|
||||||
|
if self.TueCheckBox.isChecked():
|
||||||
|
repeat_days.append(1)
|
||||||
|
if self.WedCheckBox.isChecked():
|
||||||
|
repeat_days.append(2)
|
||||||
|
if self.ThuCheckBox.isChecked():
|
||||||
|
repeat_days.append(3)
|
||||||
|
if self.FriCheckBox.isChecked():
|
||||||
|
repeat_days.append(4)
|
||||||
|
if self.SatCheckBox.isChecked():
|
||||||
|
repeat_days.append(5)
|
||||||
|
if self.SunCheckBox.isChecked():
|
||||||
|
repeat_days.append(6)
|
||||||
|
if not repeat_days:
|
||||||
|
repeat_days = [0, 1, 2, 3, 4, 5, 6]
|
||||||
|
task_data["repeat_days"] = repeat_days
|
||||||
|
task_data["repeat_hour"] = execute_time.hour
|
||||||
|
task_data["repeat_minute"] = execute_time.minute
|
||||||
|
task_data["repeat_second"] = execute_time.second
|
||||||
|
task_data["execute_time"] = TimerUtils.getNextTimerRepeatTime(
|
||||||
|
task_data["repeat_days"],
|
||||||
|
task_data["repeat_hour"],
|
||||||
|
task_data["repeat_minute"],
|
||||||
|
task_data["repeat_second"]
|
||||||
|
)
|
||||||
|
return task_data
|
||||||
|
|
||||||
|
@Slot(int)
|
||||||
|
def onTimerTypeComboBoxIndexChanged(
|
||||||
|
self,
|
||||||
|
index: int
|
||||||
|
):
|
||||||
|
|
||||||
|
self.SpecificTimerWidget.setVisible(index == 0)
|
||||||
|
self.RelativeTimerWidget.setVisible(index == 1)
|
||||||
|
|
||||||
|
@Slot(bool)
|
||||||
|
def onRepeatCheckBoxToggled(
|
||||||
|
self,
|
||||||
|
checked: bool
|
||||||
|
):
|
||||||
|
|
||||||
|
self.MonCheckBox.setEnabled(checked)
|
||||||
|
self.TueCheckBox.setEnabled(checked)
|
||||||
|
self.WedCheckBox.setEnabled(checked)
|
||||||
|
self.ThuCheckBox.setEnabled(checked)
|
||||||
|
self.FriCheckBox.setEnabled(checked)
|
||||||
|
self.SatCheckBox.setEnabled(checked)
|
||||||
|
self.SunCheckBox.setEnabled(checked)
|
||||||
|
self.AutoScriptGroupBox.setVisible(checked)
|
||||||
|
|
||||||
|
@Slot()
|
||||||
|
def onPreviewAutoScript(self):
|
||||||
|
from gui.ALAutoScriptEditDialog import ALAutoScriptEditDialog
|
||||||
|
Dlg = ALAutoScriptEditDialog(self, self.__auto_script, self.__mock_target_data)
|
||||||
|
if Dlg.exec() == QDialog.DialogCode.Accepted:
|
||||||
|
script = Dlg.getScript()
|
||||||
|
self.__auto_script = script
|
||||||
|
self.__mock_target_data = Dlg.getMockData()
|
||||||
|
if script:
|
||||||
|
self.AutoScriptStatusLabel.setText("已设置")
|
||||||
|
self.AutoScriptStatusLabel.setStyleSheet("color: #4CAF50;")
|
||||||
|
else:
|
||||||
|
self.AutoScriptStatusLabel.setText("未设置")
|
||||||
|
self.AutoScriptStatusLabel.setStyleSheet("color: #969696;")
|
||||||
|
Dlg.deleteLater()
|
||||||
|
|
||||||
|
@Slot()
|
||||||
|
def onAutoScriptHelp(
|
||||||
|
self
|
||||||
|
):
|
||||||
|
|
||||||
|
QDesktopServices.openUrl(
|
||||||
|
QUrl("https://www.autolibrary.kenanzhu.com/manuals/autoscript")
|
||||||
|
)
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
Copyright (c) 2026 KenanZhu.
|
||||||
|
All rights reserved.
|
||||||
|
|
||||||
|
This software is provided "as is", without any warranty of any kind.
|
||||||
|
You may use, modify, and distribute this file under the terms of the MIT License.
|
||||||
|
See the LICENSE file for details.
|
||||||
|
"""
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from PySide6.QtCore import Slot, Qt
|
||||||
|
from PySide6.QtWidgets import (
|
||||||
|
QDialog, QTableWidget, QTableWidgetItem,
|
||||||
|
QVBoxLayout, QHBoxLayout, QGridLayout,
|
||||||
|
QPushButton, QLabel, QHeaderView
|
||||||
|
)
|
||||||
|
|
||||||
|
from gui.ALTimerTaskAddDialog import ALTimerTaskStatus
|
||||||
|
|
||||||
|
|
||||||
|
class ALTimerTaskHistoryDialog(QDialog):
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
parent = None,
|
||||||
|
task_data: dict = None
|
||||||
|
):
|
||||||
|
|
||||||
|
super().__init__(parent)
|
||||||
|
self.__task_data = task_data
|
||||||
|
self.__history = task_data.get("repeat_history", [])
|
||||||
|
|
||||||
|
self.setupUi()
|
||||||
|
self.connectSignals()
|
||||||
|
|
||||||
|
def setupUi(
|
||||||
|
self
|
||||||
|
):
|
||||||
|
|
||||||
|
self.setWindowTitle("定时任务执行历史 - AutoLibrary")
|
||||||
|
self.setMinimumSize(300, 300)
|
||||||
|
self.setMaximumSize(500, 400)
|
||||||
|
MainLayout = QVBoxLayout(self)
|
||||||
|
InfoLayout = QGridLayout()
|
||||||
|
TaskNameLabel = QLabel(f"任务: {self.__task_data.get('name', '未命名')}")
|
||||||
|
TaskNameLabel.setStyleSheet("font-weight: bold; font-size: 12px;")
|
||||||
|
InfoLayout.addWidget(TaskNameLabel, 0, 0)
|
||||||
|
TaskUUIDLabel = QLabel(f"UUID: {self.__task_data.get('uuid', '未命名')}")
|
||||||
|
TaskUUIDLabel.setStyleSheet("color: #969696; font-size: 11px;")
|
||||||
|
InfoLayout.addWidget(TaskUUIDLabel, 1, 0)
|
||||||
|
InfoLayout.setColumnStretch(0, 1)
|
||||||
|
if self.__task_data.get("repeat", False):
|
||||||
|
RepeatLabel = QLabel("可重复性任务")
|
||||||
|
RepeatLabel.setStyleSheet("color: #2294FF; font-size: 12px;")
|
||||||
|
InfoLayout.addWidget(RepeatLabel, 0, 1)
|
||||||
|
MainLayout.addLayout(InfoLayout)
|
||||||
|
self.HistoryTableWidget = QTableWidget()
|
||||||
|
self.HistoryTableWidget.setColumnCount(3)
|
||||||
|
self.HistoryTableWidget.setHorizontalHeaderLabels(["执行时间", "结果", "耗时(秒/s)"])
|
||||||
|
self.HistoryTableWidget.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch)
|
||||||
|
self.HistoryTableWidget.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.ResizeToContents)
|
||||||
|
self.HistoryTableWidget.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeMode.Stretch)
|
||||||
|
self.HistoryTableWidget.verticalHeader().setVisible(False)
|
||||||
|
self.HistoryTableWidget.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers)
|
||||||
|
self.HistoryTableWidget.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows)
|
||||||
|
self.loadHistory()
|
||||||
|
MainLayout.addWidget(self.HistoryTableWidget)
|
||||||
|
ButtonLayout = QHBoxLayout()
|
||||||
|
ButtonLayout.addStretch()
|
||||||
|
self.CloseButton = QPushButton("关闭")
|
||||||
|
self.CloseButton.setFixedSize(80, 25)
|
||||||
|
self.CloseButton.setDefault(True)
|
||||||
|
self.ClearHistoryButton = QPushButton("清空历史")
|
||||||
|
self.ClearHistoryButton.setFixedSize(80, 25)
|
||||||
|
self.ClearHistoryButton.setStyleSheet("color: #DC0000;")
|
||||||
|
ButtonLayout.addWidget(self.ClearHistoryButton)
|
||||||
|
ButtonLayout.addWidget(self.CloseButton)
|
||||||
|
MainLayout.addLayout(ButtonLayout)
|
||||||
|
|
||||||
|
def connectSignals(
|
||||||
|
self
|
||||||
|
):
|
||||||
|
|
||||||
|
self.CloseButton.clicked.connect(self.accept)
|
||||||
|
self.ClearHistoryButton.clicked.connect(self.onClearHistoryButtonClicked)
|
||||||
|
|
||||||
|
def loadHistory(
|
||||||
|
self
|
||||||
|
):
|
||||||
|
|
||||||
|
self.HistoryTableWidget.setRowCount(len(self.__history))
|
||||||
|
for row, record in enumerate(self.__history):
|
||||||
|
self.addHistoryRow(row, record)
|
||||||
|
|
||||||
|
def getHistory(
|
||||||
|
self
|
||||||
|
) -> list:
|
||||||
|
|
||||||
|
return self.__history
|
||||||
|
|
||||||
|
def addHistoryRow(
|
||||||
|
self,
|
||||||
|
row: int,
|
||||||
|
record: dict
|
||||||
|
):
|
||||||
|
|
||||||
|
execute_time = record.get("execute_time", "")
|
||||||
|
result = record.get("result", ALTimerTaskStatus.UNKNOWN)
|
||||||
|
duration = record.get("duration", 0)
|
||||||
|
ExecuteTimeItem = QTableWidgetItem(execute_time)
|
||||||
|
ExecuteTimeItem.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
|
self.HistoryTableWidget.setItem(row, 0, ExecuteTimeItem)
|
||||||
|
ResultItem = QTableWidgetItem(result.value)
|
||||||
|
ResultItem.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
|
match result:
|
||||||
|
case ALTimerTaskStatus.EXECUTED:
|
||||||
|
ResultItem.setForeground(Qt.GlobalColor.green)
|
||||||
|
case ALTimerTaskStatus.ERROR:
|
||||||
|
ResultItem.setForeground(Qt.GlobalColor.red)
|
||||||
|
case ALTimerTaskStatus.OUTDATED:
|
||||||
|
ResultItem.setForeground(Qt.GlobalColor.red)
|
||||||
|
case _:
|
||||||
|
ResultItem.setForeground(Qt.GlobalColor.black)
|
||||||
|
self.HistoryTableWidget.setItem(row, 1, ResultItem)
|
||||||
|
DurationItem = QTableWidgetItem(f"{duration:.2f}")
|
||||||
|
DurationItem.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
|
self.HistoryTableWidget.setItem(row, 2, DurationItem)
|
||||||
|
self.HistoryTableWidget.setRowHeight(row, 25)
|
||||||
|
|
||||||
|
@Slot()
|
||||||
|
def onClearHistoryButtonClicked(
|
||||||
|
self
|
||||||
|
):
|
||||||
|
|
||||||
|
self.__history.clear()
|
||||||
|
self.HistoryTableWidget.setRowCount(0)
|
||||||
|
self.__task_data["repeat_history"] = self.__history
|
||||||
@@ -0,0 +1,674 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
Copyright (c) 2025 KenanZhu.
|
||||||
|
All rights reserved.
|
||||||
|
|
||||||
|
This software is provided "as is", without any warranty of any kind.
|
||||||
|
You may use, modify, and distribute this file under the terms of the MIT License.
|
||||||
|
See the LICENSE file for details.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import copy
|
||||||
|
|
||||||
|
from enum import Enum
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
from PySide6.QtCore import (
|
||||||
|
QTimer,
|
||||||
|
Qt,
|
||||||
|
Signal,
|
||||||
|
Slot
|
||||||
|
)
|
||||||
|
from PySide6.QtGui import (
|
||||||
|
QAction,
|
||||||
|
QCloseEvent
|
||||||
|
)
|
||||||
|
from PySide6.QtWidgets import (
|
||||||
|
QDialog,
|
||||||
|
QHBoxLayout,
|
||||||
|
QLabel,
|
||||||
|
QListWidgetItem,
|
||||||
|
QMenu,
|
||||||
|
QMessageBox,
|
||||||
|
QPushButton,
|
||||||
|
QVBoxLayout,
|
||||||
|
QWidget
|
||||||
|
)
|
||||||
|
|
||||||
|
import managers.config.ConfigManager as ConfigManager
|
||||||
|
|
||||||
|
from gui.ALTimerTaskAddDialog import (
|
||||||
|
ALTimerTaskAddDialog,
|
||||||
|
ALTimerTaskStatus
|
||||||
|
)
|
||||||
|
from gui.ALTimerTaskHistoryDialog import ALTimerTaskHistoryDialog
|
||||||
|
from gui.ALWidgetMixin import CenterOnParentMixin
|
||||||
|
from gui.resources.ui.Ui_ALTimerTaskManageWidget import Ui_ALTimerTaskManageWidget
|
||||||
|
from interfaces.ConfigProvider import (
|
||||||
|
CfgKey,
|
||||||
|
ConfigProvider
|
||||||
|
)
|
||||||
|
from utils.TimerUtils import TimerUtils
|
||||||
|
|
||||||
|
|
||||||
|
class ALTimerTaskItemWidget(QWidget):
|
||||||
|
|
||||||
|
editRequested = Signal(dict)
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
parent = None,
|
||||||
|
timer_task: dict = None
|
||||||
|
):
|
||||||
|
|
||||||
|
super().__init__(parent)
|
||||||
|
self.__timer_task = timer_task
|
||||||
|
self.__manage_widget = parent
|
||||||
|
|
||||||
|
self.modifyUi()
|
||||||
|
self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
|
||||||
|
self.customContextMenuRequested.connect(self.showContextMenu)
|
||||||
|
|
||||||
|
def modifyUi(
|
||||||
|
self
|
||||||
|
):
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
@Slot(object)
|
||||||
|
def showContextMenu(
|
||||||
|
self,
|
||||||
|
pos
|
||||||
|
):
|
||||||
|
|
||||||
|
Menu = QMenu(self)
|
||||||
|
EditAction = QAction("编辑", self)
|
||||||
|
EditAction.triggered.connect(
|
||||||
|
lambda: self.editRequested.emit(self.__timer_task)
|
||||||
|
)
|
||||||
|
Menu.addAction(EditAction)
|
||||||
|
if self.__timer_task["status"] != ALTimerTaskStatus.RUNNING\
|
||||||
|
and self.__timer_task["status"] != ALTimerTaskStatus.READY:
|
||||||
|
DeleteAction = QAction("删除", self)
|
||||||
|
DeleteAction.triggered.connect(
|
||||||
|
lambda: self.__manage_widget.deleteTask(self.__timer_task)
|
||||||
|
)
|
||||||
|
Menu.addAction(DeleteAction)
|
||||||
|
Menu.exec(self.mapToGlobal(pos))
|
||||||
|
|
||||||
|
|
||||||
|
class ALTimerTaskManageWidget(CenterOnParentMixin, 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: ConfigProvider = ConfigManager.instance()
|
||||||
|
self.__timer_tasks = []
|
||||||
|
self.__CheckTimer = 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.__CheckTimer = QTimer(self)
|
||||||
|
self.__CheckTimer.timeout.connect(self.checkTasks)
|
||||||
|
self.__CheckTimer.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(CfgKey.TIMERTASK.ROOT)
|
||||||
|
if timer_tasks and "timer_tasks" in timer_tasks:
|
||||||
|
for task in timer_tasks["timer_tasks"]:
|
||||||
|
task["added_time"] = datetime.strptime(task["added_time"], "%Y-%m-%d %H:%M:%S")
|
||||||
|
task["execute_time"] = datetime.strptime(task["execute_time"], "%Y-%m-%d %H:%M:%S")
|
||||||
|
task["status"] = ALTimerTaskStatus(task["status"])
|
||||||
|
if "repeat_history" in task:
|
||||||
|
for item in task["repeat_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 "repeat_history" in task:
|
||||||
|
for item in task["repeat_history"]:
|
||||||
|
item["result"] = item["result"].value
|
||||||
|
self.__cfg_mgr.set(CfgKey.TIMERTASK.ROOT, { "timer_tasks": timer_tasks })
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
QMessageBox.warning(
|
||||||
|
self,
|
||||||
|
"警告 - AutoLibrary",
|
||||||
|
f"保存定时任务配置发生错误 ! : \n{e}"
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
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)
|
||||||
|
)
|
||||||
|
Widget.editRequested.connect(self.editTask)
|
||||||
|
Item.setSizeHint(Widget.size())
|
||||||
|
self.TimerTasksListWidget.addItem(Item)
|
||||||
|
self.TimerTasksListWidget.setItemWidget(Item, Widget)
|
||||||
|
|
||||||
|
def addTask(
|
||||||
|
self
|
||||||
|
):
|
||||||
|
|
||||||
|
Dialog = ALTimerTaskAddDialog(self)
|
||||||
|
if Dialog.exec() == QDialog.DialogCode.Accepted:
|
||||||
|
timer_task = Dialog.getTimerTask()
|
||||||
|
self.__timer_tasks.append(timer_task)
|
||||||
|
self.timerTasksChanged.emit()
|
||||||
|
|
||||||
|
def editTask(
|
||||||
|
self,
|
||||||
|
timer_task: dict
|
||||||
|
):
|
||||||
|
|
||||||
|
Dialog = ALTimerTaskAddDialog(self, timer_task)
|
||||||
|
if Dialog.exec() == QDialog.DialogCode.Accepted:
|
||||||
|
updated = Dialog.getTimerTask()
|
||||||
|
for i, task in enumerate(self.__timer_tasks):
|
||||||
|
if task["uuid"] == updated["uuid"]:
|
||||||
|
self.__timer_tasks[i] = updated
|
||||||
|
break
|
||||||
|
self.timerTasksChanged.emit()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def getTimerTaskDetailMessage(
|
||||||
|
timer_task: dict
|
||||||
|
):
|
||||||
|
|
||||||
|
if "repeat_history" not in timer_task:
|
||||||
|
history = []
|
||||||
|
else:
|
||||||
|
history = timer_task["repeat_history"]
|
||||||
|
history_count = len(history)
|
||||||
|
return (
|
||||||
|
f"任务名称:{timer_task["name"]}\n"
|
||||||
|
f"添加时间:{timer_task["added_time"]}\n"
|
||||||
|
f"当前状态:{timer_task["status"].value}\n"
|
||||||
|
f"下次执行时间:{datetime.strftime(timer_task["execute_time"], "%Y-%m-%d %H:%M:%S")}\n"
|
||||||
|
f"已记录次数:{history_count}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def deleteTask(
|
||||||
|
self,
|
||||||
|
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 "repeat_history" not in timer_task:
|
||||||
|
timer_task["repeat_history"] = []
|
||||||
|
if status != ALTimerTaskStatus.OUTDATED:
|
||||||
|
executed_time = datetime.now()
|
||||||
|
duration = (executed_time - timer_task["execute_time"]).total_seconds()
|
||||||
|
timer_task["repeat_history"].append({
|
||||||
|
"execute_time": timer_task["execute_time"].strftime("%Y-%m-%d %H:%M:%S"),
|
||||||
|
"executed_time": executed_time.strftime("%Y-%m-%d %H:%M:%S"),
|
||||||
|
"result": status,
|
||||||
|
"duration": duration,
|
||||||
|
"uuid": timer_task["uuid"]
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
current_time = datetime.now()
|
||||||
|
execute_time = timer_task["execute_time"]
|
||||||
|
execute_weekday = execute_time.weekday()
|
||||||
|
delta_days = (current_time - execute_time).days
|
||||||
|
for i in range(delta_days + 1):
|
||||||
|
if (execute_weekday + i)%7 in timer_task["repeat_days"]:
|
||||||
|
timer_task["repeat_history"].append({
|
||||||
|
"execute_time": (execute_time + timedelta(days=i)).strftime("%Y-%m-%d %H:%M:%S"),
|
||||||
|
"executed_time": (execute_time + timedelta(days=i)).strftime("%Y-%m-%d %H:%M:%S"),
|
||||||
|
"result": status,
|
||||||
|
"duration": 0,
|
||||||
|
"uuid": timer_task["uuid"]
|
||||||
|
})
|
||||||
|
next_time = TimerUtils.getNextTimerRepeatTime(
|
||||||
|
timer_task["repeat_days"],
|
||||||
|
timer_task["repeat_hour"],
|
||||||
|
timer_task["repeat_minute"],
|
||||||
|
timer_task["repeat_second"]
|
||||||
|
)
|
||||||
|
if next_time:
|
||||||
|
timer_task["execute_time"] = next_time
|
||||||
|
timer_task["status"] = ALTimerTaskStatus.PENDING
|
||||||
|
timer_task["executed"] = False
|
||||||
|
else:
|
||||||
|
timer_task["status"] = status
|
||||||
|
return timer_task
|
||||||
|
|
||||||
|
@Slot(dict)
|
||||||
|
def onTimerTaskIsExecuted(
|
||||||
|
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()
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
Copyright (c) 2025 KenanZhu.
|
||||||
|
All rights reserved.
|
||||||
|
|
||||||
|
This software is provided "as is", without any warranty of any kind.
|
||||||
|
You may use, modify, and distribute this file under the terms of the MIT License.
|
||||||
|
See the LICENSE file for details.
|
||||||
|
"""
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
from PySide6.QtCore import (
|
||||||
|
Qt,
|
||||||
|
QSize,
|
||||||
|
QCoreApplication,
|
||||||
|
QRect,
|
||||||
|
QPoint
|
||||||
|
)
|
||||||
|
from PySide6.QtWidgets import (
|
||||||
|
QAbstractScrollArea,
|
||||||
|
QAbstractItemView,
|
||||||
|
QTreeWidget,
|
||||||
|
QTreeWidgetItem
|
||||||
|
)
|
||||||
|
from PySide6.QtGui import (
|
||||||
|
QDragEnterEvent,
|
||||||
|
QDragMoveEvent,
|
||||||
|
QDropEvent
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ALUserTreeItemType(Enum):
|
||||||
|
|
||||||
|
GROUP = 0
|
||||||
|
USER = 1
|
||||||
|
|
||||||
|
|
||||||
|
class ALUserTreeWidget(QTreeWidget):
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
parent = None
|
||||||
|
):
|
||||||
|
|
||||||
|
super().__init__(parent)
|
||||||
|
|
||||||
|
self.setupUi()
|
||||||
|
self.translateUi()
|
||||||
|
|
||||||
|
def setupUi(
|
||||||
|
self
|
||||||
|
):
|
||||||
|
|
||||||
|
__QTreeWidgetItem = QTreeWidgetItem()
|
||||||
|
__QTreeWidgetItem.setText(0, u"\u5206\u7ec4/\u7528\u6237");
|
||||||
|
self.setHeaderItem(__QTreeWidgetItem)
|
||||||
|
self.setObjectName(u"UserTreeWidget")
|
||||||
|
self.setMinimumSize(QSize(230, 0))
|
||||||
|
self.setMaximumSize(QSize(250, 16777215))
|
||||||
|
self.setSizeAdjustPolicy(QAbstractScrollArea.SizeAdjustPolicy.AdjustIgnored)
|
||||||
|
self.setTabKeyNavigation(True)
|
||||||
|
self.setDragEnabled(True)
|
||||||
|
self.setAcceptDrops(True)
|
||||||
|
self.setDropIndicatorShown(True)
|
||||||
|
self.setDragDropMode(QAbstractItemView.DragDropMode.InternalMove)
|
||||||
|
self.setDefaultDropAction(Qt.DropAction.IgnoreAction)
|
||||||
|
self.setAlternatingRowColors(True)
|
||||||
|
self.setSortingEnabled(True)
|
||||||
|
self.sortByColumn(0, Qt.SortOrder.AscendingOrder)
|
||||||
|
self.setAnimated(True)
|
||||||
|
self.setAllColumnsShowFocus(False)
|
||||||
|
self.setHeaderHidden(False)
|
||||||
|
self.setColumnCount(2)
|
||||||
|
self.setColumnWidth(0, 150)
|
||||||
|
self.setColumnWidth(1, 20)
|
||||||
|
self.header().setCascadingSectionResizes(False)
|
||||||
|
self.header().setHighlightSections(False)
|
||||||
|
self.header().setProperty(u"showSortIndicator", True)
|
||||||
|
|
||||||
|
def translateUi(
|
||||||
|
self
|
||||||
|
):
|
||||||
|
|
||||||
|
___QTreeWidgetItem = self.headerItem()
|
||||||
|
___QTreeWidgetItem.setText(1, QCoreApplication.translate("ALConfigWidget", u"\u72b6\u6001", None));
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def isDragPositionValid(
|
||||||
|
target_rect: QRect,
|
||||||
|
drag_pos: QPoint,
|
||||||
|
) -> bool:
|
||||||
|
|
||||||
|
y_offset = drag_pos.y() - target_rect.top()
|
||||||
|
valid = (y_offset > target_rect.height()*0.2 and
|
||||||
|
y_offset < target_rect.height()*0.8)
|
||||||
|
return valid
|
||||||
|
|
||||||
|
def dragEnterEvent(
|
||||||
|
self,
|
||||||
|
event: QDragEnterEvent
|
||||||
|
):
|
||||||
|
|
||||||
|
super().dragEnterEvent(event)
|
||||||
|
|
||||||
|
def dragMoveEvent(
|
||||||
|
self,
|
||||||
|
event: QDragMoveEvent
|
||||||
|
):
|
||||||
|
|
||||||
|
super().dragMoveEvent(event)
|
||||||
|
|
||||||
|
SourceItem = self.currentItem()
|
||||||
|
TargetItem = self.itemAt(event.position().toPoint())
|
||||||
|
if SourceItem is None:
|
||||||
|
event.ignore()
|
||||||
|
return
|
||||||
|
if SourceItem.type() == ALUserTreeItemType.GROUP.value:
|
||||||
|
if TargetItem is not None:
|
||||||
|
event.ignore()
|
||||||
|
return
|
||||||
|
elif SourceItem.type() == ALUserTreeItemType.USER.value:
|
||||||
|
if TargetItem is None:
|
||||||
|
event.ignore()
|
||||||
|
return
|
||||||
|
if TargetItem.type() != ALUserTreeItemType.GROUP.value:
|
||||||
|
event.ignore()
|
||||||
|
return
|
||||||
|
if TargetItem.checkState(1) == Qt.CheckState.Unchecked:
|
||||||
|
event.ignore()
|
||||||
|
return
|
||||||
|
if not self.isDragPositionValid(
|
||||||
|
self.visualItemRect(TargetItem),
|
||||||
|
event.position().toPoint()
|
||||||
|
):
|
||||||
|
event.ignore()
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
event.ignore()
|
||||||
|
return
|
||||||
|
event.acceptProposedAction()
|
||||||
|
|
||||||
|
def dropEvent(
|
||||||
|
self,
|
||||||
|
event: QDropEvent
|
||||||
|
):
|
||||||
|
|
||||||
|
super().dropEvent(event)
|
||||||
|
|
||||||
|
for item_index in range(self.topLevelItemCount()):
|
||||||
|
self.topLevelItem(item_index).setExpanded(True)
|
||||||
|
self.setCurrentItem(None)
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
"""
|
||||||
|
The contents of this file will automatically be updated by the
|
||||||
|
workflow process. Do not edit manually.
|
||||||
|
|
||||||
|
This file is auto-generated during the workflow process.
|
||||||
|
Last updated: 2026-05-09 06:05:13 UTC
|
||||||
|
"""
|
||||||
|
|
||||||
|
AL_VERSION = "1.3.0"
|
||||||
|
AL_TAG = "v1.3.0"
|
||||||
|
AL_COMMIT_SHA = "local"
|
||||||
|
AL_COMMIT_DATE = "null" # time zone : UTC
|
||||||
|
AL_BUILD_DATE = "null" # time zone : UTC
|
||||||
|
AL_VERSION_FULL = f"{AL_VERSION} ({AL_COMMIT_SHA})"
|
||||||
@@ -0,0 +1,546 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
Copyright (c) 2026 KenanZhu.
|
||||||
|
All rights reserved.
|
||||||
|
|
||||||
|
This software is provided "as is", without any warranty of any kind.
|
||||||
|
You may use, modify, and distribute this file under the terms of the MIT License.
|
||||||
|
See the LICENSE file for details.
|
||||||
|
"""
|
||||||
|
import threading
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from PySide6.QtCore import (
|
||||||
|
Qt,
|
||||||
|
Slot,
|
||||||
|
QThread,
|
||||||
|
Signal
|
||||||
|
)
|
||||||
|
from PySide6.QtWidgets import (
|
||||||
|
QDialog,
|
||||||
|
QLabel,
|
||||||
|
QComboBox,
|
||||||
|
QProgressBar,
|
||||||
|
QPushButton,
|
||||||
|
QVBoxLayout,
|
||||||
|
QHBoxLayout,
|
||||||
|
QMessageBox,
|
||||||
|
QFrame,
|
||||||
|
QLineEdit
|
||||||
|
)
|
||||||
|
from PySide6.QtGui import QCloseEvent
|
||||||
|
|
||||||
|
from managers.driver.WebDriverManager import (
|
||||||
|
instance as webdriverInstance,
|
||||||
|
WebDriverManager,
|
||||||
|
WebDriverInfo,
|
||||||
|
WebDriverType,
|
||||||
|
WebDriverStatus
|
||||||
|
)
|
||||||
|
from gui.ALStatusLabel import ALStatusLabel
|
||||||
|
from gui.ALWidgetMixin import CenterOnParentMixin
|
||||||
|
|
||||||
|
|
||||||
|
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(CenterOnParentMixin, 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 setupUi(
|
||||||
|
self
|
||||||
|
):
|
||||||
|
|
||||||
|
self.setModal(True)
|
||||||
|
self.setMaximumHeight(240)
|
||||||
|
self.setMinimumHeight(240)
|
||||||
|
self.setWindowTitle("浏览器驱动下载 - AutoLibrary")
|
||||||
|
self.MainLayout = QVBoxLayout(self)
|
||||||
|
self.MainLayout.setContentsMargins(5, 5, 5, 5)
|
||||||
|
self.MainLayout.setSpacing(5)
|
||||||
|
self.BrowserCountLabel = QLabel("检测到 0 个可用浏览器:")
|
||||||
|
self.MainLayout.addWidget(self.BrowserCountLabel)
|
||||||
|
self.DriverInfoLayout = QHBoxLayout()
|
||||||
|
self.DriverInfoLayout.setSpacing(5)
|
||||||
|
self.DriverComboBox = QComboBox()
|
||||||
|
self.DriverInfoLayout.addWidget(self.DriverComboBox)
|
||||||
|
self.StatusLabel = ALStatusLabel()
|
||||||
|
self.StatusLabel.setFixedSize(32, 32)
|
||||||
|
self.DriverInfoLayout.addWidget(self.StatusLabel)
|
||||||
|
self.MainLayout.addLayout(self.DriverInfoLayout)
|
||||||
|
self.DetailLayout = QVBoxLayout()
|
||||||
|
self.DetailLayout.setSpacing(5)
|
||||||
|
self.DetailLayout.setContentsMargins(5, 5, 5, 5)
|
||||||
|
self.BrowserTypeLabel = QLabel("类型:")
|
||||||
|
self.DetailLayout.addWidget(self.BrowserTypeLabel)
|
||||||
|
self.VersionLabel = QLabel("版本:")
|
||||||
|
self.DetailLayout.addWidget(self.VersionLabel)
|
||||||
|
self.PathLabel = QLineEdit()
|
||||||
|
self.PathLabel.setReadOnly(True)
|
||||||
|
self.PathLabel.setText("路径:未安装")
|
||||||
|
self.DetailLayout.addWidget(self.PathLabel)
|
||||||
|
self.MainLayout.addLayout(self.DetailLayout)
|
||||||
|
self.Line = QFrame()
|
||||||
|
self.Line.setFrameShape(QFrame.Shape.HLine)
|
||||||
|
self.Line.setFrameShadow(QFrame.Shadow.Sunken)
|
||||||
|
self.MainLayout.addWidget(self.Line)
|
||||||
|
self.ProgressBar = QProgressBar()
|
||||||
|
self.ProgressBar.setValue(0)
|
||||||
|
self.ProgressBar.setTextVisible(False)
|
||||||
|
self.MainLayout.addWidget(self.ProgressBar)
|
||||||
|
self.ProgressText = QLabel("")
|
||||||
|
self.ProgressText.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
|
self.MainLayout.addWidget(self.ProgressText)
|
||||||
|
self.ControlLayout = QHBoxLayout()
|
||||||
|
self.ControlLayout.setSpacing(8)
|
||||||
|
self.ControlLayout.setAlignment(Qt.AlignmentFlag.AlignRight)
|
||||||
|
self.RefreshButton = QPushButton("刷新")
|
||||||
|
self.RefreshButton.setFixedSize(80, 25)
|
||||||
|
self.DownloadButton = QPushButton("下载驱动")
|
||||||
|
self.DownloadButton.setFixedSize(80, 25)
|
||||||
|
self.DeleteButton = QPushButton("删除驱动")
|
||||||
|
self.DeleteButton.setFixedSize(80, 25)
|
||||||
|
self.CancelButton = QPushButton("取消")
|
||||||
|
self.CancelButton.setFixedSize(80, 25)
|
||||||
|
self.ConfirmButton = QPushButton("确认")
|
||||||
|
self.ConfirmButton.setFixedSize(80, 25)
|
||||||
|
self.ConfirmButton.setEnabled(False)
|
||||||
|
self.ControlLayout.addWidget(self.RefreshButton)
|
||||||
|
self.ControlLayout.addWidget(self.DownloadButton)
|
||||||
|
self.ControlLayout.addWidget(self.DeleteButton)
|
||||||
|
self.ControlLayout.addWidget(self.CancelButton)
|
||||||
|
self.ControlLayout.addWidget(self.ConfirmButton)
|
||||||
|
self.MainLayout.addLayout(self.ControlLayout)
|
||||||
|
|
||||||
|
def connectSignals(
|
||||||
|
self
|
||||||
|
):
|
||||||
|
|
||||||
|
self.RefreshButton.clicked.connect(self.onRefreshButtonClicked)
|
||||||
|
self.DownloadButton.clicked.connect(self.onDownloadButtonClicked)
|
||||||
|
self.DeleteButton.clicked.connect(self.onDeleteButtonClicked)
|
||||||
|
self.CancelButton.clicked.connect(self.onCancelButtonClicked)
|
||||||
|
self.ConfirmButton.clicked.connect(self.onConfirmButtonClicked)
|
||||||
|
self.DriverComboBox.currentIndexChanged.connect(self.onDriverComboBoxChanged)
|
||||||
|
|
||||||
|
def initializeDriverManager(
|
||||||
|
self
|
||||||
|
):
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.__driver_manager = webdriverInstance(self.__driver_dir)
|
||||||
|
except ValueError as e:
|
||||||
|
QMessageBox.warning(self, "初始化失败", f"WebDriverManager 初始化失败:\n{str(e)}")
|
||||||
|
self.reject()
|
||||||
|
|
||||||
|
def refreshDriverList(
|
||||||
|
self
|
||||||
|
):
|
||||||
|
|
||||||
|
if not self.__driver_manager:
|
||||||
|
return
|
||||||
|
self.__driver_manager.refresh()
|
||||||
|
self.__driver_infos = self.__driver_manager.getDriverInfos()
|
||||||
|
self.DriverComboBox.clear()
|
||||||
|
installed = 0
|
||||||
|
installed_idx = 0
|
||||||
|
for i, driver_info in enumerate(self.__driver_infos):
|
||||||
|
display_text = f"{driver_info.driver_type.value} - {driver_info.browser_version}"
|
||||||
|
if driver_info.driver_status == WebDriverStatus.INSTALLED:
|
||||||
|
installed += 1
|
||||||
|
installed_idx = i # get the installed driver index
|
||||||
|
display_text += " : 已安装"
|
||||||
|
self.DriverComboBox.addItem(display_text)
|
||||||
|
count = len(self.__driver_infos)
|
||||||
|
self.BrowserCountLabel.setText(f"检测到 {count} 个可用浏览器,{installed} 个已安装驱动:")
|
||||||
|
if self.__driver_infos:
|
||||||
|
self.DriverComboBox.setCurrentIndex(installed_idx)
|
||||||
|
|
||||||
|
def onDriverComboBoxChanged(
|
||||||
|
self,
|
||||||
|
index: int
|
||||||
|
):
|
||||||
|
|
||||||
|
if not self.__driver_infos or index < 0 or index >= len(self.__driver_infos):
|
||||||
|
return
|
||||||
|
driver_info = self.__driver_infos[index]
|
||||||
|
self.updateDriverInfoDisplay(driver_info)
|
||||||
|
self.updateProgressBarStates(driver_info)
|
||||||
|
self.updateButtonStates(driver_info)
|
||||||
|
|
||||||
|
def closeEvent(
|
||||||
|
self,
|
||||||
|
event: QCloseEvent
|
||||||
|
):
|
||||||
|
|
||||||
|
if self.__download_thread and self.__download_thread.isRunning():
|
||||||
|
reply = QMessageBox.question(
|
||||||
|
self,
|
||||||
|
"确认关闭 - AutoLibrary",
|
||||||
|
"驱动正在下载中, 确定要取消并关闭对话框吗 ?",
|
||||||
|
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
|
||||||
|
)
|
||||||
|
if reply == QMessageBox.StandardButton.No:
|
||||||
|
event.ignore()
|
||||||
|
return
|
||||||
|
self.__download_thread.stop()
|
||||||
|
if not self.__confirmed:
|
||||||
|
self.__selected_driver_info = None
|
||||||
|
event.accept()
|
||||||
|
super().closeEvent(event)
|
||||||
|
|
||||||
|
def onThreadFinished(
|
||||||
|
self
|
||||||
|
):
|
||||||
|
|
||||||
|
if self.__download_thread:
|
||||||
|
self.__download_thread.deleteLater()
|
||||||
|
self.__download_thread = None
|
||||||
|
|
||||||
|
def getSelectedDriverInfo(
|
||||||
|
self
|
||||||
|
) -> Optional[WebDriverInfo]:
|
||||||
|
|
||||||
|
return self.__selected_driver_info
|
||||||
|
|
||||||
|
def updateDriverInfoDisplay(
|
||||||
|
self,
|
||||||
|
driver_info: WebDriverInfo
|
||||||
|
):
|
||||||
|
|
||||||
|
if driver_info.driver_type == WebDriverType.CHROME:
|
||||||
|
driver_type = "Google Chrome"
|
||||||
|
elif driver_info.driver_type == WebDriverType.FIREFOX:
|
||||||
|
driver_type = "Mozilla Firefox"
|
||||||
|
elif driver_info.driver_type == WebDriverType.EDGE:
|
||||||
|
driver_type = "Microsoft Edge"
|
||||||
|
else:
|
||||||
|
driver_type = "未知"
|
||||||
|
self.BrowserTypeLabel.setText(f"类型:{driver_type}")
|
||||||
|
self.VersionLabel.setText(f"版本:{driver_info.driver_version}")
|
||||||
|
if driver_info.driver_path:
|
||||||
|
self.PathLabel.setText(str(driver_info.driver_path))
|
||||||
|
else:
|
||||||
|
self.PathLabel.setText("未安装")
|
||||||
|
match driver_info.driver_status:
|
||||||
|
case WebDriverStatus.NOT_INSTALLED:
|
||||||
|
self.StatusLabel.status = ALStatusLabel.Status.WAITING
|
||||||
|
case WebDriverStatus.INSTALLED:
|
||||||
|
self.StatusLabel.status = ALStatusLabel.Status.SUCCESS
|
||||||
|
case WebDriverStatus.DOWNLOADING:
|
||||||
|
self.StatusLabel.status = ALStatusLabel.Status.RUNNING
|
||||||
|
case WebDriverStatus.ERROR:
|
||||||
|
self.StatusLabel.status = ALStatusLabel.Status.FAILURE
|
||||||
|
|
||||||
|
def updateProgressBarStates(
|
||||||
|
self,
|
||||||
|
driver_info: WebDriverInfo
|
||||||
|
):
|
||||||
|
|
||||||
|
if driver_info.driver_status == WebDriverStatus.NOT_INSTALLED:
|
||||||
|
self.ProgressBar.setValue(0)
|
||||||
|
self.ProgressText.setText("未安装")
|
||||||
|
elif driver_info.driver_status == WebDriverStatus.INSTALLED:
|
||||||
|
self.ProgressBar.setValue(100)
|
||||||
|
self.ProgressText.setText("已安装")
|
||||||
|
elif driver_info.driver_status == WebDriverStatus.DOWNLOADING:
|
||||||
|
pass # update by worker thread
|
||||||
|
elif driver_info.driver_status == WebDriverStatus.ERROR:
|
||||||
|
self.ProgressBar.setValue(0)
|
||||||
|
self.ProgressText.setText("下载失败")
|
||||||
|
|
||||||
|
def updateButtonStates(
|
||||||
|
self,
|
||||||
|
driver_info: WebDriverInfo
|
||||||
|
):
|
||||||
|
|
||||||
|
if driver_info.driver_status == WebDriverStatus.NOT_INSTALLED:
|
||||||
|
self.RefreshButton.setEnabled(True)
|
||||||
|
self.DeleteButton.setEnabled(False)
|
||||||
|
self.DownloadButton.setEnabled(True)
|
||||||
|
self.CancelButton.setEnabled(True)
|
||||||
|
self.ConfirmButton.setEnabled(False)
|
||||||
|
elif driver_info.driver_status == WebDriverStatus.INSTALLED:
|
||||||
|
self.RefreshButton.setEnabled(True)
|
||||||
|
self.DownloadButton.setEnabled(False)
|
||||||
|
self.DeleteButton.setEnabled(True)
|
||||||
|
self.CancelButton.setEnabled(True)
|
||||||
|
self.ConfirmButton.setEnabled(True)
|
||||||
|
elif driver_info.driver_status == WebDriverStatus.DOWNLOADING:
|
||||||
|
self.RefreshButton.setEnabled(False)
|
||||||
|
self.DownloadButton.setEnabled(False)
|
||||||
|
self.DeleteButton.setEnabled(False)
|
||||||
|
self.CancelButton.setEnabled(True)
|
||||||
|
self.ConfirmButton.setEnabled(False)
|
||||||
|
elif driver_info.driver_status == WebDriverStatus.ERROR:
|
||||||
|
self.RefreshButton.setEnabled(True)
|
||||||
|
self.DownloadButton.setEnabled(True)
|
||||||
|
self.DeleteButton.setEnabled(False)
|
||||||
|
self.CancelButton.setEnabled(True)
|
||||||
|
self.ConfirmButton.setEnabled(False)
|
||||||
|
|
||||||
|
@Slot()
|
||||||
|
def onRefreshButtonClicked(
|
||||||
|
self
|
||||||
|
):
|
||||||
|
|
||||||
|
self.refreshDriverList()
|
||||||
|
|
||||||
|
@Slot()
|
||||||
|
def onDeleteButtonClicked(
|
||||||
|
self
|
||||||
|
):
|
||||||
|
|
||||||
|
index = self.DriverComboBox.currentIndex()
|
||||||
|
if index < 0 or index >= len(self.__driver_infos):
|
||||||
|
return
|
||||||
|
driver_info = self.__driver_infos[index]
|
||||||
|
if driver_info.driver_status.name != "INSTALLED":
|
||||||
|
QMessageBox.information(self, "提示 - AutoLibrary", "该驱动未安装, 无需删除")
|
||||||
|
return
|
||||||
|
reply = QMessageBox.question(
|
||||||
|
self,
|
||||||
|
"确认删除 - AutoLibrary",
|
||||||
|
f"确定要删除 {driver_info.driver_type.value} 驱动吗 ?",
|
||||||
|
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
|
||||||
|
)
|
||||||
|
if reply == QMessageBox.StandardButton.No:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
self.__driver_manager.uninstallDriver(driver_info)
|
||||||
|
self.refreshDriverList()
|
||||||
|
QMessageBox.information(self, "删除成功 - AutoLibrary", "驱动已成功删除")
|
||||||
|
except Exception as e:
|
||||||
|
QMessageBox.critical(self, "删除失败 - AutoLibrary", f"删除驱动时出错:\n{str(e)}")
|
||||||
|
|
||||||
|
@Slot()
|
||||||
|
def onDownloadButtonClicked(
|
||||||
|
self
|
||||||
|
):
|
||||||
|
|
||||||
|
self.DriverComboBox.setEnabled(False)
|
||||||
|
index = self.DriverComboBox.currentIndex()
|
||||||
|
if index < 0 or index >= len(self.__driver_infos):
|
||||||
|
return
|
||||||
|
driver_info = self.__driver_infos[index]
|
||||||
|
if driver_info.driver_status == WebDriverStatus.INSTALLED:
|
||||||
|
return
|
||||||
|
driver_info.driver_status = WebDriverStatus.DOWNLOADING # we set this only to update
|
||||||
|
# the display, and we will set to not installed in the download thread
|
||||||
|
self.updateDriverInfoDisplay(driver_info)
|
||||||
|
self.updateProgressBarStates(driver_info)
|
||||||
|
self.ProgressText.setText("准备开始下载...")
|
||||||
|
self.updateButtonStates(driver_info)
|
||||||
|
# set to not installed
|
||||||
|
driver_info.driver_status = WebDriverStatus.NOT_INSTALLED
|
||||||
|
self.__download_thread = DownloadWorker(self.__driver_manager, driver_info)
|
||||||
|
self.__download_thread.progress.connect(self.onDownloadProgress)
|
||||||
|
self.__download_thread.downloadFinished.connect(self.onDownloadFinished)
|
||||||
|
self.__download_thread.downloadError.connect(self.onDownloadError)
|
||||||
|
self.__download_thread.downloadCancelled.connect(self.onDownloadCancelled)
|
||||||
|
self.__download_thread.finished.connect(self.onThreadFinished)
|
||||||
|
self.__download_thread.start()
|
||||||
|
|
||||||
|
@Slot()
|
||||||
|
def onDownloadProgress(
|
||||||
|
self,
|
||||||
|
downloaded: float,
|
||||||
|
total: int,
|
||||||
|
speed: float,
|
||||||
|
message: str
|
||||||
|
):
|
||||||
|
|
||||||
|
progress = downloaded
|
||||||
|
self.ProgressBar.setValue(progress)
|
||||||
|
if speed >= 1024:
|
||||||
|
speed_text = f"{speed/1024:.1f} MB/s"
|
||||||
|
else:
|
||||||
|
speed_text = f"{speed:.1f} KB/s"
|
||||||
|
progress_text = f"{message}... {downloaded:.1f}% - {speed_text}"
|
||||||
|
self.ProgressText.setText(progress_text)
|
||||||
|
|
||||||
|
@Slot()
|
||||||
|
def onDownloadFinished(
|
||||||
|
self
|
||||||
|
):
|
||||||
|
|
||||||
|
self.DriverComboBox.setEnabled(True)
|
||||||
|
index = self.DriverComboBox.currentIndex()
|
||||||
|
if 0 <= index < len(self.__driver_infos):
|
||||||
|
driver_info = self.__driver_infos[index]
|
||||||
|
driver_info.driver_status = WebDriverStatus.INSTALLED
|
||||||
|
self.updateDriverInfoDisplay(driver_info)
|
||||||
|
self.updateProgressBarStates(driver_info)
|
||||||
|
self.updateButtonStates(driver_info)
|
||||||
|
|
||||||
|
@Slot()
|
||||||
|
def onDownloadError(
|
||||||
|
self,
|
||||||
|
error_message: str
|
||||||
|
):
|
||||||
|
|
||||||
|
self.DriverComboBox.setEnabled(True)
|
||||||
|
index = self.DriverComboBox.currentIndex()
|
||||||
|
if 0 <= index < len(self.__driver_infos):
|
||||||
|
driver_info = self.__driver_infos[index]
|
||||||
|
driver_info.driver_status = WebDriverStatus.ERROR
|
||||||
|
self.updateDriverInfoDisplay(driver_info)
|
||||||
|
self.updateProgressBarStates(driver_info)
|
||||||
|
self.updateButtonStates(driver_info)
|
||||||
|
QMessageBox.critical(self, "下载失败 - AutoLibrary", error_message)
|
||||||
|
|
||||||
|
@Slot()
|
||||||
|
def onDownloadCancelled(
|
||||||
|
self
|
||||||
|
):
|
||||||
|
|
||||||
|
self.DriverComboBox.setEnabled(True)
|
||||||
|
index = self.DriverComboBox.currentIndex()
|
||||||
|
if 0 <= index < len(self.__driver_infos):
|
||||||
|
driver_info = self.__driver_infos[index]
|
||||||
|
self.__driver_manager.cancelDriverDownload(driver_info)
|
||||||
|
driver_info.driver_status = WebDriverStatus.NOT_INSTALLED
|
||||||
|
self.updateDriverInfoDisplay(driver_info)
|
||||||
|
self.updateProgressBarStates(driver_info)
|
||||||
|
self.updateButtonStates(driver_info)
|
||||||
|
self.ProgressText.setText("下载已取消")
|
||||||
|
|
||||||
|
@Slot()
|
||||||
|
def onConfirmButtonClicked(
|
||||||
|
self
|
||||||
|
):
|
||||||
|
|
||||||
|
index = self.DriverComboBox.currentIndex()
|
||||||
|
if index < 0 or index >= len(self.__driver_infos):
|
||||||
|
return
|
||||||
|
driver_info = self.__driver_infos[index]
|
||||||
|
if driver_info.driver_status != WebDriverStatus.INSTALLED:
|
||||||
|
return
|
||||||
|
self.__selected_driver_info = driver_info
|
||||||
|
self.__confirmed = True
|
||||||
|
self.accept()
|
||||||
|
|
||||||
|
@Slot()
|
||||||
|
def onCancelButtonClicked(
|
||||||
|
self
|
||||||
|
):
|
||||||
|
|
||||||
|
if self.__download_thread:
|
||||||
|
reply = QMessageBox.question(
|
||||||
|
self,
|
||||||
|
"确认取消 - AutoLibrary",
|
||||||
|
"正在下载中, 确定要取消下载吗 ?",
|
||||||
|
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
|
||||||
|
)
|
||||||
|
if reply == QMessageBox.StandardButton.Yes:
|
||||||
|
self.__download_thread.cancel()
|
||||||
|
else:
|
||||||
|
self.__confirmed = False
|
||||||
|
self.__selected_driver_info = None
|
||||||
|
self.reject()
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
Copyright (c) 2026 KenanZhu.
|
||||||
|
All rights reserved.
|
||||||
|
|
||||||
|
This software is provided "as is", without any warranty of any kind.
|
||||||
|
You may use, modify, and distribute this file under the terms of the MIT License.
|
||||||
|
See the LICENSE file for details.
|
||||||
|
"""
|
||||||
|
from PySide6.QtGui import QShowEvent
|
||||||
|
|
||||||
|
|
||||||
|
class CenterOnParentMixin:
|
||||||
|
"""
|
||||||
|
Mixin that centres the widget relative to its parent on first show,
|
||||||
|
clamping the position to the screen bounds.
|
||||||
|
|
||||||
|
Usage::
|
||||||
|
|
||||||
|
class MyWidget(CenterOnParentMixin, QWidget, Ui_MyWidget):
|
||||||
|
pass
|
||||||
|
|
||||||
|
class MyDialog(CenterOnParentMixin, QDialog):
|
||||||
|
pass
|
||||||
|
|
||||||
|
The mixin must appear **before** QWidget / QDialog in the base list
|
||||||
|
so that ``super().showEvent(event)`` resolves up the MRO correctly.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def showEvent(
|
||||||
|
self,
|
||||||
|
event: QShowEvent
|
||||||
|
):
|
||||||
|
|
||||||
|
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)
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
Copyright (c) 2026 KenanZhu.
|
||||||
|
All rights reserved.
|
||||||
|
|
||||||
|
This software is provided "as is", without any warranty of any kind.
|
||||||
|
You may use, modify, and distribute this file under the terms of the MIT License.
|
||||||
|
See the LICENSE file for details.
|
||||||
|
"""
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<RCC>
|
||||||
|
<qresource prefix="/res">
|
||||||
|
<file>icons/AutoLibrary_Logo_64.svg</file>
|
||||||
|
<file>icons/AutoLibrary_Logo_128.svg</file>
|
||||||
|
|
||||||
|
<file>icons/Copy.svg</file>
|
||||||
|
<file>icons/Reset.svg</file>
|
||||||
|
|
||||||
|
<file>translators/qtbase_zh_CN.qm</file>
|
||||||
|
</qresource>
|
||||||
|
</RCC>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
Copyright (c) 2026 KenanZhu.
|
||||||
|
All rights reserved.
|
||||||
|
|
||||||
|
This software is provided "as is", without any warranty of any kind.
|
||||||
|
You may use, modify, and distribute this file under the terms of the MIT License.
|
||||||
|
See the LICENSE file for details.
|
||||||
|
"""
|
||||||
|
After Width: | Height: | Size: 173 KiB |
@@ -0,0 +1,17 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="128px" height="128px" viewBox="0 0 128 128" version="1.1">
|
||||||
|
<g id="surface1">
|
||||||
|
<path style=" stroke:none;fill-rule:nonzero;fill:rgb(15.294118%,36.862746%,56.862748%);fill-opacity:1;" d="M 14.609375 17.722656 C 14.691406 17.722656 14.773438 17.722656 14.855469 17.722656 C 15.125 17.722656 15.394531 17.722656 15.664062 17.726562 C 15.855469 17.726562 16.046875 17.730469 16.238281 17.730469 C 16.824219 17.734375 17.414062 17.742188 18 17.75 C 18.085938 17.75 18.171875 17.753906 18.261719 17.753906 C 29.371094 17.914062 40.535156 19.359375 57.875 27.625 C 57.957031 27.679688 58.039062 27.734375 58.125 27.789062 C 58.90625 28.308594 59.65625 28.855469 60.375 29.460938 C 60.671875 29.65625 60.898438 29.71875 61.25 29.75 C 61.714844 29.507812 62.042969 29.214844 62.402344 28.835938 C 62.839844 28.398438 63.300781 28.023438 63.796875 27.65625 C 63.882812 27.59375 63.96875 27.527344 64.058594 27.460938 C 69.78125 23.273438 76.863281 20.476562 83.71875 18.8125 C 83.808594 18.789062 83.894531 18.769531 83.988281 18.746094 C 85.8125 18.308594 85.8125 18.308594 86.625 18.25 C 86.667969 18.292969 86.707031 18.332031 86.75 18.375 C 86.808594 18.292969 86.867188 18.210938 86.925781 18.128906 C 87.410156 17.75 87.847656 17.804688 88.449219 17.8125 C 88.636719 17.8125 88.636719 17.8125 88.824219 17.8125 C 89.238281 17.808594 89.652344 17.8125 90.0625 17.8125 C 90.359375 17.8125 90.65625 17.8125 90.953125 17.8125 C 91.671875 17.8125 92.394531 17.8125 93.117188 17.816406 C 93.957031 17.816406 94.796875 17.816406 95.636719 17.816406 C 97.132812 17.816406 98.632812 17.820312 100.132812 17.824219 C 101.585938 17.828125 103.039062 17.828125 104.492188 17.828125 C 104.582031 17.828125 104.671875 17.828125 104.765625 17.828125 C 104.855469 17.828125 104.945312 17.828125 105.035156 17.828125 C 106.691406 17.828125 108.347656 17.828125 110 17.832031 C 110.585938 17.832031 111.171875 17.832031 111.757812 17.832031 C 112.546875 17.832031 113.335938 17.835938 114.121094 17.835938 C 114.410156 17.839844 114.703125 17.839844 114.992188 17.839844 C 115.386719 17.839844 115.78125 17.839844 116.175781 17.839844 C 116.347656 17.839844 116.347656 17.839844 116.523438 17.839844 C 116.628906 17.839844 116.734375 17.84375 116.84375 17.84375 C 116.933594 17.84375 117.027344 17.84375 117.121094 17.84375 C 117.550781 17.898438 117.808594 18.046875 118.089844 18.375 C 118.320312 18.917969 118.300781 19.414062 118.296875 20 C 118.296875 20.128906 118.296875 20.253906 118.296875 20.386719 C 118.296875 20.8125 118.296875 21.238281 118.292969 21.664062 C 118.292969 21.96875 118.292969 22.273438 118.296875 22.578125 C 118.296875 23.320312 118.292969 24.0625 118.292969 24.804688 C 118.289062 25.671875 118.289062 26.535156 118.289062 27.402344 C 118.289062 28.941406 118.289062 30.484375 118.285156 32.027344 C 118.28125 33.527344 118.28125 35.023438 118.28125 36.519531 C 118.28125 36.613281 118.28125 36.707031 118.28125 36.800781 C 118.28125 36.988281 118.28125 37.171875 118.28125 37.359375 C 118.28125 37.496094 118.28125 37.496094 118.28125 37.636719 C 118.28125 37.726562 118.28125 37.820312 118.28125 37.914062 C 118.277344 39.339844 118.277344 40.765625 118.277344 42.191406 C 118.277344 42.710938 118.273438 43.226562 118.273438 43.742188 C 118.273438 44.640625 118.273438 45.539062 118.269531 46.433594 C 118.269531 46.734375 118.269531 47.03125 118.269531 47.328125 C 118.269531 47.734375 118.269531 48.140625 118.265625 48.546875 C 118.269531 48.726562 118.269531 48.726562 118.269531 48.910156 C 118.265625 49.015625 118.265625 49.125 118.265625 49.238281 C 118.265625 49.378906 118.265625 49.378906 118.265625 49.519531 C 118.25 49.75 118.25 49.75 118.125 50 C 117.742188 50.132812 117.742188 50.132812 117.25 50.125 C 116.308594 49.523438 115.511719 48.53125 114.726562 47.742188 C 114.492188 47.507812 114.257812 47.273438 114.023438 47.039062 C 112.652344 45.671875 112.652344 45.671875 112.046875 44.945312 C 111.796875 44.664062 111.511719 44.4375 111.222656 44.199219 C 110.816406 43.835938 110.476562 43.414062 110.125 43 C 110.082031 42.957031 110.042969 42.917969 110 42.875 C 110 43.003906 110 43.128906 110 43.261719 C 109.996094 48.882812 109.992188 54.5 109.984375 60.121094 C 109.984375 60.207031 109.984375 60.292969 109.984375 60.378906 C 109.984375 62.226562 109.980469 64.074219 109.976562 65.921875 C 109.976562 66.828125 109.976562 67.734375 109.972656 68.640625 C 109.972656 68.734375 109.972656 68.824219 109.972656 68.914062 C 109.96875 71.839844 109.96875 74.765625 109.964844 77.691406 C 109.964844 80.695312 109.960938 83.699219 109.957031 86.703125 C 109.957031 87.128906 109.953125 87.554688 109.953125 87.976562 C 109.953125 88.101562 109.953125 88.101562 109.953125 88.230469 C 109.953125 89.492188 109.949219 90.753906 109.949219 92.015625 C 109.953125 93.449219 109.949219 94.886719 109.945312 96.320312 C 109.945312 97.054688 109.941406 97.785156 109.945312 98.519531 C 109.945312 99.1875 109.945312 99.859375 109.941406 100.527344 C 109.941406 100.773438 109.941406 101.015625 109.941406 101.257812 C 109.941406 101.585938 109.9414Line truncated
|
||||||
|
<path style=" stroke:none;fill-rule:nonzero;fill:rgb(99.215686%,93.333334%,87.450981%);fill-opacity:1;" d="M 88.449219 17.8125 C 88.636719 17.8125 88.636719 17.8125 88.824219 17.8125 C 89.238281 17.808594 89.652344 17.8125 90.0625 17.8125 C 90.359375 17.8125 90.65625 17.8125 90.953125 17.8125 C 91.671875 17.8125 92.394531 17.8125 93.117188 17.816406 C 93.957031 17.816406 94.796875 17.816406 95.636719 17.816406 C 97.132812 17.816406 98.632812 17.820312 100.132812 17.824219 C 101.585938 17.828125 103.039062 17.828125 104.492188 17.828125 C 104.582031 17.828125 104.671875 17.828125 104.765625 17.828125 C 104.855469 17.828125 104.945312 17.828125 105.035156 17.828125 C 106.691406 17.828125 108.347656 17.828125 110 17.832031 C 110.585938 17.832031 111.171875 17.832031 111.757812 17.832031 C 112.546875 17.832031 113.335938 17.835938 114.121094 17.835938 C 114.410156 17.839844 114.703125 17.839844 114.992188 17.839844 C 115.386719 17.839844 115.78125 17.839844 116.175781 17.839844 C 116.347656 17.839844 116.347656 17.839844 116.523438 17.839844 C 116.628906 17.839844 116.734375 17.84375 116.84375 17.84375 C 116.980469 17.84375 116.980469 17.84375 117.121094 17.84375 C 117.550781 17.898438 117.808594 18.046875 118.089844 18.375 C 118.320312 18.917969 118.300781 19.414062 118.296875 20 C 118.296875 20.191406 118.296875 20.191406 118.296875 20.386719 C 118.296875 20.8125 118.296875 21.238281 118.292969 21.664062 C 118.292969 21.96875 118.292969 22.273438 118.296875 22.578125 C 118.296875 23.320312 118.292969 24.0625 118.292969 24.804688 C 118.289062 25.671875 118.289062 26.535156 118.289062 27.402344 C 118.289062 28.941406 118.289062 30.484375 118.285156 32.027344 C 118.28125 33.527344 118.28125 35.023438 118.28125 36.519531 C 118.28125 36.613281 118.28125 36.707031 118.28125 36.800781 C 118.28125 36.988281 118.28125 37.171875 118.28125 37.359375 C 118.28125 37.449219 118.28125 37.542969 118.28125 37.636719 C 118.28125 37.773438 118.28125 37.773438 118.28125 37.914062 C 118.277344 39.339844 118.277344 40.765625 118.277344 42.191406 C 118.277344 42.710938 118.273438 43.226562 118.273438 43.742188 C 118.273438 44.640625 118.273438 45.539062 118.269531 46.433594 C 118.269531 46.734375 118.269531 47.03125 118.269531 47.328125 C 118.269531 47.734375 118.269531 48.140625 118.265625 48.546875 C 118.269531 48.726562 118.269531 48.726562 118.269531 48.910156 C 118.265625 49.015625 118.265625 49.125 118.265625 49.238281 C 118.265625 49.378906 118.265625 49.378906 118.265625 49.519531 C 118.25 49.75 118.25 49.75 118.125 50 C 117.742188 50.132812 117.742188 50.132812 117.25 50.125 C 116.308594 49.523438 115.511719 48.53125 114.726562 47.742188 C 114.492188 47.507812 114.257812 47.273438 114.023438 47.039062 C 112.652344 45.671875 112.652344 45.671875 112.046875 44.945312 C 111.796875 44.660156 111.507812 44.433594 111.214844 44.191406 C 110.992188 43.992188 110.808594 43.785156 110.613281 43.558594 C 110.054688 42.910156 109.457031 42.308594 108.851562 41.703125 C 108.742188 41.589844 108.628906 41.476562 108.515625 41.363281 C 108.40625 41.253906 108.300781 41.148438 108.191406 41.035156 C 108.09375 40.9375 107.996094 40.84375 107.898438 40.742188 C 107.566406 40.449219 107.304688 40.257812 106.855469 40.234375 C 105.742188 40.355469 105.175781 40.929688 104.414062 41.710938 C 104.070312 42.054688 103.710938 42.371094 103.339844 42.6875 C 103.160156 42.84375 102.992188 43.011719 102.828125 43.179688 C 102.625 43.375 102.625 43.375 102.375 43.375 C 102.332031 43.5 102.292969 43.621094 102.25 43.75 C 102.054688 43.925781 101.847656 44.089844 101.640625 44.25 C 100.984375 44.773438 100.40625 45.328125 99.820312 45.929688 C 99.464844 46.285156 99.09375 46.613281 98.714844 46.941406 C 98.347656 47.257812 98.011719 47.605469 97.679688 47.957031 C 97.5 48.140625 97.3125 48.320312 97.125 48.5 C 97.042969 48.5 96.960938 48.5 96.875 48.5 C 96.875 48.582031 96.875 48.664062 96.875 48.75 C 96.792969 48.75 96.710938 48.75 96.625 48.75 C 96.601562 48.816406 96.574219 48.886719 96.546875 48.953125 C 96.304688 49.371094 95.960938 49.65625 95.605469 49.972656 C 94.746094 50.757812 93.914062 51.570312 93.089844 52.394531 C 93.027344 52.457031 92.964844 52.515625 92.902344 52.582031 C 92.585938 52.898438 92.265625 53.214844 91.949219 53.535156 C 91.625 53.859375 91.300781 54.183594 90.972656 54.507812 C 90.71875 54.765625 90.464844 55.019531 90.210938 55.273438 C 90.03125 55.449219 89.855469 55.628906 89.675781 55.804688 C 89.117188 56.367188 88.570312 56.9375 88.054688 57.542969 C 87.710938 57.941406 87.328125 58.296875 86.953125 58.664062 C 86.613281 59.011719 86.296875 59.375 85.980469 59.738281 C 85.613281 60.15625 85.230469 60.554688 84.851562 60.953125 C 84.058594 61.792969 83.300781 62.652344 82.558594 63.535156 C 82.304688 63.832031 82.050781 64.128906 81.792969 64.425781 C 80.683594 65.714844 79.617188 67.035156 78.570312 68.375 C 78.359375 68.644531 78.144531 68.917969 77.933594 69.1875 C 77.78125 69.382812 77.628906 69.578125 77.47656Line truncated
|
||||||
|
<path style=" stroke:none;fill-rule:nonzero;fill:rgb(23.529412%,40.392157%,55.29412%);fill-opacity:1;" d="M 109.5 42.5 C 109.6875 42.625 109.6875 42.625 109.875 42.75 C 109.914062 43.085938 109.925781 43.390625 109.921875 43.726562 C 109.921875 43.828125 109.921875 43.929688 109.921875 44.035156 C 109.921875 44.382812 109.921875 44.730469 109.921875 45.078125 C 109.921875 45.328125 109.921875 45.578125 109.921875 45.828125 C 109.921875 46.515625 109.921875 47.203125 109.917969 47.894531 C 109.917969 48.636719 109.917969 49.378906 109.917969 50.121094 C 109.917969 51.40625 109.917969 52.695312 109.914062 53.980469 C 109.914062 55.332031 109.910156 56.679688 109.910156 58.03125 C 109.910156 58.113281 109.910156 58.199219 109.910156 58.285156 C 109.910156 58.710938 109.910156 59.136719 109.910156 59.5625 C 109.910156 62.582031 109.90625 65.597656 109.902344 68.617188 C 109.898438 71.550781 109.898438 74.484375 109.894531 77.414062 C 109.894531 77.503906 109.894531 77.597656 109.894531 77.6875 C 109.894531 78.597656 109.894531 79.503906 109.894531 80.410156 C 109.894531 82.261719 109.890625 84.109375 109.890625 85.957031 C 109.890625 86.042969 109.890625 86.125 109.890625 86.214844 C 109.886719 91.894531 109.882812 97.570312 109.875 103.25 C 109.792969 103.25 109.710938 103.25 109.625 103.25 C 109.582031 83.203125 109.542969 63.15625 109.5 42.5 Z M 109.5 42.5 "/>
|
||||||
|
<path style=" stroke:none;fill-rule:nonzero;fill:rgb(96.078432%,94.117647%,90.588236%);fill-opacity:1;" d="M 80.5 42.875 C 80.5 43.671875 79.515625 44.359375 78.96875 44.90625 C 78.757812 45.117188 78.546875 45.328125 78.332031 45.539062 C 77.90625 45.964844 77.496094 46.398438 77.105469 46.859375 C 76.789062 47.226562 76.460938 47.582031 76.132812 47.9375 C 76.039062 48.039062 76.039062 48.039062 75.945312 48.144531 C 75.796875 48.304688 75.648438 48.464844 75.5 48.625 C 75.417969 48.582031 75.335938 48.542969 75.25 48.5 C 75.359375 48.382812 75.472656 48.265625 75.585938 48.140625 C 75.734375 47.980469 75.886719 47.820312 76.039062 47.65625 C 76.117188 47.570312 76.199219 47.488281 76.28125 47.398438 C 76.769531 46.875 77.257812 46.34375 77.742188 45.8125 C 78.167969 45.347656 78.597656 44.898438 79.046875 44.457031 C 79.355469 44.140625 79.640625 43.808594 79.929688 43.476562 C 80.109375 43.265625 80.300781 43.070312 80.5 42.875 Z M 80.5 42.875 "/>
|
||||||
|
<path style=" stroke:none;fill-rule:nonzero;fill:rgb(95.294118%,93.725491%,90.980393%);fill-opacity:1;" d="M 86.625 36.875 C 86.625 37.359375 86.433594 37.488281 86.105469 37.816406 C 86.027344 37.890625 85.953125 37.964844 85.875 38.039062 C 85.800781 38.117188 85.722656 38.191406 85.644531 38.269531 C 85.484375 38.429688 85.320312 38.59375 85.15625 38.753906 C 84.90625 39 84.660156 39.246094 84.414062 39.492188 C 84.253906 39.648438 84.097656 39.804688 83.9375 39.960938 C 83.867188 40.035156 83.792969 40.109375 83.714844 40.183594 C 83.367188 40.523438 83.027344 40.847656 82.625 41.125 C 82.542969 41.082031 82.460938 41.042969 82.375 41 C 83.949219 39.402344 83.949219 39.402344 84.726562 38.746094 C 85.386719 38.152344 86 37.507812 86.625 36.875 Z M 86.625 36.875 "/>
|
||||||
|
<path style=" stroke:none;fill-rule:nonzero;fill:rgb(15.294118%,34.509805%,52.156866%);fill-opacity:1;" d="M 91.625 53.875 C 91.707031 53.917969 91.789062 53.957031 91.875 54 C 91.804688 54.070312 91.734375 54.140625 91.660156 54.210938 C 91.117188 54.75 90.59375 55.292969 90.097656 55.875 C 89.710938 56.3125 89.292969 56.71875 88.875 57.125 C 88.832031 57 88.792969 56.878906 88.75 56.75 C 89.699219 55.800781 90.648438 54.851562 91.625 53.875 Z M 91.625 53.875 "/>
|
||||||
|
<path style=" stroke:none;fill-rule:nonzero;fill:rgb(95.686275%,93.725491%,90.588236%);fill-opacity:1;" d="M 89.125 34.5 C 89.207031 34.542969 89.289062 34.582031 89.375 34.625 C 89.054688 34.953125 88.734375 35.28125 88.414062 35.609375 C 88.277344 35.75 88.277344 35.75 88.140625 35.890625 C 88.050781 35.980469 87.964844 36.070312 87.875 36.164062 C 87.792969 36.246094 87.710938 36.328125 87.628906 36.414062 C 87.351562 36.644531 87.09375 36.757812 86.75 36.875 C 86.84375 36.785156 86.9375 36.695312 87.03125 36.601562 C 87.367188 36.269531 87.367188 36.269531 87.601562 35.953125 C 87.917969 35.570312 88.273438 35.265625 88.648438 34.945312 C 88.882812 34.746094 88.882812 34.746094 89.125 34.5 Z M 89.125 34.5 "/>
|
||||||
|
<path style=" stroke:none;fill-rule:nonzero;fill:rgb(94.509804%,92.941177%,91.37255%);fill-opacity:1;" d="M 82.25 41.125 C 82.292969 41.207031 82.332031 41.289062 82.375 41.375 C 81.910156 41.925781 81.464844 42.449219 80.875 42.875 C 80.792969 42.832031 80.710938 42.792969 80.625 42.75 C 80.796875 42.5625 80.796875 42.5625 81 42.375 C 81.082031 42.375 81.164062 42.375 81.25 42.375 C 81.277344 42.308594 81.300781 42.242188 81.328125 42.171875 C 81.574219 41.75 81.898438 41.460938 82.25 41.125 Z M 82.25 41.125 "/>
|
||||||
|
<path style=" stroke:none;fill-rule:nonzero;fill:rgb(95.686275%,93.725491%,90.196079%);fill-opacity:1;" d="M 74.625 49.25 C 74.667969 49.332031 74.707031 49.414062 74.75 49.5 C 74.613281 49.667969 74.476562 49.835938 74.335938 50 C 74.21875 50.140625 74.21875 50.140625 74.101562 50.28125 C 73.875 50.5 73.875 50.5 73.5 50.5 C 73.871094 50.085938 74.242188 49.675781 74.625 49.25 Z M 74.625 49.25 "/>
|
||||||
|
<path style=" stroke:none;fill-rule:nonzero;fill:rgb(23.921569%,40.000001%,55.686277%);fill-opacity:1;" d="M 99.375 46.25 C 99.5 46.292969 99.621094 46.332031 99.75 46.375 C 99.378906 46.703125 99.007812 47.035156 98.625 47.375 C 98.542969 47.332031 98.460938 47.292969 98.375 47.25 C 98.703125 46.921875 99.035156 46.589844 99.375 46.25 Z M 99.375 46.25 "/>
|
||||||
|
<path style=" stroke:none;fill-rule:nonzero;fill:rgb(94.509804%,94.509804%,92.156863%);fill-opacity:1;" d="M 60.625 70.5 C 60.667969 70.5 60.707031 70.5 60.75 70.5 C 60.828125 71.140625 60.550781 71.585938 60.25 72.125 C 60.167969 72.125 60.085938 72.125 60 72.125 C 60.058594 71.949219 60.113281 71.769531 60.171875 71.59375 C 60.21875 71.445312 60.21875 71.445312 60.269531 71.296875 C 60.367188 71.015625 60.488281 70.761719 60.625 70.5 Z M 60.625 70.5 "/>
|
||||||
|
<path style=" stroke:none;fill-rule:nonzero;fill:rgb(20.784314%,38.431373%,55.29412%);fill-opacity:1;" d="M 101.625 44.25 C 101.707031 44.292969 101.789062 44.332031 101.875 44.375 C 101.441406 44.746094 101.441406 44.746094 101 45.125 C 100.917969 45.082031 100.835938 45.042969 100.75 45 C 101.039062 44.753906 101.328125 44.503906 101.625 44.25 Z M 101.625 44.25 "/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 178 KiB |
@@ -0,0 +1,17 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="64px" height="64px" viewBox="0 0 64 64" version="1.1">
|
||||||
|
<g id="surface1">
|
||||||
|
<path style=" stroke:none;fill-rule:nonzero;fill:rgb(15.294118%,36.862746%,56.862748%);fill-opacity:1;" d="M 7.304688 8.859375 C 7.34375 8.859375 7.386719 8.859375 7.429688 8.859375 C 7.5625 8.859375 7.699219 8.863281 7.832031 8.863281 C 7.929688 8.863281 8.023438 8.863281 8.121094 8.863281 C 8.414062 8.867188 8.707031 8.871094 9 8.875 C 9.042969 8.875 9.085938 8.875 9.128906 8.875 C 14.683594 8.957031 20.269531 9.679688 28.9375 13.8125 C 28.980469 13.839844 29.019531 13.867188 29.0625 13.894531 C 29.453125 14.15625 29.828125 14.429688 30.1875 14.730469 C 30.335938 14.828125 30.449219 14.859375 30.625 14.875 C 30.859375 14.753906 31.019531 14.609375 31.203125 14.417969 C 31.417969 14.199219 31.648438 14.011719 31.898438 13.828125 C 31.941406 13.796875 31.984375 13.765625 32.03125 13.730469 C 34.890625 11.636719 38.429688 10.238281 41.859375 9.40625 C 41.902344 9.394531 41.949219 9.382812 41.992188 9.375 C 42.90625 9.152344 42.90625 9.152344 43.3125 9.125 C 43.332031 9.144531 43.355469 9.167969 43.375 9.1875 C 43.402344 9.148438 43.433594 9.105469 43.460938 9.0625 C 43.707031 8.875 43.925781 8.902344 44.226562 8.90625 C 44.316406 8.90625 44.316406 8.90625 44.410156 8.90625 C 44.617188 8.90625 44.824219 8.90625 45.03125 8.90625 C 45.179688 8.90625 45.328125 8.90625 45.476562 8.90625 C 45.835938 8.90625 46.199219 8.90625 46.558594 8.90625 C 46.976562 8.910156 47.398438 8.910156 47.816406 8.910156 C 48.566406 8.910156 49.316406 8.910156 50.066406 8.910156 C 50.792969 8.914062 51.519531 8.914062 52.246094 8.914062 C 52.292969 8.914062 52.335938 8.914062 52.382812 8.914062 C 52.425781 8.914062 52.472656 8.914062 52.519531 8.914062 C 53.347656 8.914062 54.171875 8.914062 55 8.917969 C 55.292969 8.917969 55.585938 8.917969 55.878906 8.917969 C 56.273438 8.917969 56.667969 8.917969 57.0625 8.917969 C 57.207031 8.917969 57.351562 8.917969 57.496094 8.917969 C 57.691406 8.917969 57.890625 8.917969 58.085938 8.921875 C 58.175781 8.921875 58.175781 8.921875 58.261719 8.921875 C 58.316406 8.921875 58.367188 8.921875 58.421875 8.921875 C 58.46875 8.921875 58.511719 8.921875 58.558594 8.921875 C 58.777344 8.949219 58.90625 9.023438 59.046875 9.1875 C 59.160156 9.460938 59.152344 9.707031 59.148438 10 C 59.148438 10.0625 59.148438 10.128906 59.148438 10.191406 C 59.148438 10.40625 59.148438 10.617188 59.148438 10.832031 C 59.148438 10.984375 59.148438 11.136719 59.148438 11.289062 C 59.148438 11.660156 59.148438 12.03125 59.144531 12.402344 C 59.144531 12.835938 59.144531 13.269531 59.144531 13.699219 C 59.144531 14.472656 59.144531 15.242188 59.140625 16.015625 C 59.140625 16.761719 59.140625 17.511719 59.140625 18.261719 C 59.140625 18.304688 59.140625 18.351562 59.140625 18.398438 C 59.140625 18.492188 59.140625 18.585938 59.140625 18.679688 C 59.140625 18.746094 59.140625 18.746094 59.140625 18.816406 C 59.140625 18.863281 59.140625 18.910156 59.140625 18.957031 C 59.140625 19.667969 59.140625 20.382812 59.136719 21.097656 C 59.136719 21.355469 59.136719 21.613281 59.136719 21.871094 C 59.136719 22.320312 59.136719 22.769531 59.136719 23.21875 C 59.136719 23.367188 59.136719 23.515625 59.136719 23.664062 C 59.136719 23.867188 59.132812 24.070312 59.132812 24.273438 C 59.132812 24.363281 59.132812 24.363281 59.132812 24.453125 C 59.132812 24.507812 59.132812 24.5625 59.132812 24.617188 C 59.132812 24.6875 59.132812 24.6875 59.132812 24.761719 C 59.125 24.875 59.125 24.875 59.0625 25 C 58.871094 25.066406 58.871094 25.066406 58.625 25.0625 C 58.15625 24.761719 57.757812 24.265625 57.363281 23.871094 C 57.246094 23.753906 57.128906 23.636719 57.011719 23.519531 C 56.328125 22.835938 56.328125 22.835938 56.023438 22.472656 C 55.898438 22.332031 55.753906 22.21875 55.609375 22.097656 C 55.40625 21.917969 55.238281 21.707031 55.0625 21.5 C 55.042969 21.480469 55.019531 21.457031 55 21.4375 C 55 21.5 55 21.566406 55 21.628906 C 55 24.441406 54.996094 27.25 54.992188 30.0625 C 54.992188 30.101562 54.992188 30.144531 54.992188 30.191406 C 54.992188 31.113281 54.988281 32.039062 54.988281 32.960938 C 54.988281 33.414062 54.988281 33.867188 54.988281 34.320312 C 54.988281 34.367188 54.988281 34.410156 54.988281 34.457031 C 54.984375 35.921875 54.984375 37.382812 54.984375 38.84375 C 54.980469 40.347656 54.980469 41.851562 54.976562 43.351562 C 54.976562 43.5625 54.976562 43.777344 54.976562 43.988281 C 54.976562 44.050781 54.976562 44.050781 54.976562 44.113281 C 54.976562 44.746094 54.976562 45.378906 54.976562 46.007812 C 54.976562 46.726562 54.976562 47.441406 54.972656 48.160156 C 54.972656 48.527344 54.972656 48.894531 54.972656 49.257812 C 54.972656 49.59375 54.972656 49.929688 54.96875 50.265625 C 54.96875 50.386719 54.96875 50.507812 54.972656 50.628906 C 54.972656 50.792969 54.96875 50.957031 54.96875 51.121094 C 54.96875 51.171875 54.96875 51.21875 54.972656 51.265625 C 54.96875 51.484375 54.953125 51.601562 54.820312 51.777344 C 54.652344 51.902344 54.53125 51.917969 54.324219 51.941406 C 54.25 51.949219 54.175781 51.9Line truncated
|
||||||
|
<path style=" stroke:none;fill-rule:nonzero;fill:rgb(99.215686%,93.333334%,87.450981%);fill-opacity:1;" d="M 44.226562 8.90625 C 44.316406 8.90625 44.316406 8.90625 44.410156 8.90625 C 44.617188 8.90625 44.824219 8.90625 45.03125 8.90625 C 45.179688 8.90625 45.328125 8.90625 45.476562 8.90625 C 45.835938 8.90625 46.199219 8.90625 46.558594 8.90625 C 46.976562 8.910156 47.398438 8.910156 47.816406 8.910156 C 48.566406 8.910156 49.316406 8.910156 50.066406 8.910156 C 50.792969 8.914062 51.519531 8.914062 52.246094 8.914062 C 52.292969 8.914062 52.335938 8.914062 52.382812 8.914062 C 52.425781 8.914062 52.472656 8.914062 52.519531 8.914062 C 53.347656 8.914062 54.171875 8.914062 55 8.917969 C 55.292969 8.917969 55.585938 8.917969 55.878906 8.917969 C 56.273438 8.917969 56.667969 8.917969 57.0625 8.917969 C 57.207031 8.917969 57.351562 8.917969 57.496094 8.917969 C 57.691406 8.917969 57.890625 8.917969 58.085938 8.921875 C 58.175781 8.921875 58.175781 8.921875 58.261719 8.921875 C 58.316406 8.921875 58.367188 8.921875 58.421875 8.921875 C 58.492188 8.921875 58.492188 8.921875 58.558594 8.921875 C 58.777344 8.949219 58.90625 9.023438 59.046875 9.1875 C 59.160156 9.460938 59.152344 9.707031 59.148438 10 C 59.148438 10.09375 59.148438 10.09375 59.148438 10.191406 C 59.148438 10.40625 59.148438 10.617188 59.148438 10.832031 C 59.148438 10.984375 59.148438 11.136719 59.148438 11.289062 C 59.148438 11.660156 59.148438 12.03125 59.144531 12.402344 C 59.144531 12.835938 59.144531 13.269531 59.144531 13.699219 C 59.144531 14.472656 59.144531 15.242188 59.144531 16.015625 C 59.140625 16.761719 59.140625 17.511719 59.140625 18.261719 C 59.140625 18.304688 59.140625 18.351562 59.140625 18.398438 C 59.140625 18.492188 59.140625 18.585938 59.140625 18.679688 C 59.140625 18.726562 59.140625 18.769531 59.140625 18.816406 C 59.140625 18.886719 59.140625 18.886719 59.140625 18.957031 C 59.140625 19.667969 59.140625 20.382812 59.136719 21.097656 C 59.136719 21.355469 59.136719 21.613281 59.136719 21.871094 C 59.136719 22.320312 59.136719 22.769531 59.136719 23.21875 C 59.136719 23.367188 59.136719 23.515625 59.136719 23.664062 C 59.136719 23.867188 59.132812 24.070312 59.132812 24.273438 C 59.132812 24.363281 59.132812 24.363281 59.132812 24.453125 C 59.132812 24.507812 59.132812 24.5625 59.132812 24.617188 C 59.132812 24.6875 59.132812 24.6875 59.132812 24.761719 C 59.125 24.875 59.125 24.875 59.0625 25 C 58.871094 25.066406 58.871094 25.066406 58.625 25.0625 C 58.15625 24.761719 57.757812 24.265625 57.363281 23.871094 C 57.246094 23.753906 57.128906 23.636719 57.011719 23.519531 C 56.328125 22.835938 56.328125 22.835938 56.023438 22.472656 C 55.898438 22.332031 55.753906 22.214844 55.609375 22.097656 C 55.496094 21.996094 55.402344 21.894531 55.308594 21.777344 C 55.027344 21.457031 54.726562 21.152344 54.425781 20.851562 C 54.371094 20.796875 54.316406 20.738281 54.257812 20.679688 C 54.203125 20.628906 54.148438 20.574219 54.09375 20.519531 C 54.046875 20.46875 54 20.421875 53.949219 20.371094 C 53.78125 20.222656 53.652344 20.128906 53.429688 20.117188 C 52.871094 20.179688 52.585938 20.464844 52.207031 20.855469 C 52.035156 21.027344 51.855469 21.183594 51.671875 21.34375 C 51.582031 21.421875 51.496094 21.503906 51.414062 21.589844 C 51.3125 21.6875 51.3125 21.6875 51.1875 21.6875 C 51.167969 21.75 51.144531 21.8125 51.125 21.875 C 51.027344 21.960938 50.925781 22.042969 50.820312 22.125 C 50.492188 22.386719 50.203125 22.664062 49.910156 22.964844 C 49.734375 23.140625 49.546875 23.304688 49.355469 23.46875 C 49.171875 23.628906 49.007812 23.800781 48.839844 23.976562 C 48.75 24.070312 48.65625 24.160156 48.5625 24.25 C 48.519531 24.25 48.480469 24.25 48.4375 24.25 C 48.4375 24.292969 48.4375 24.332031 48.4375 24.375 C 48.394531 24.375 48.355469 24.375 48.3125 24.375 C 48.300781 24.410156 48.289062 24.441406 48.273438 24.476562 C 48.152344 24.683594 47.980469 24.828125 47.804688 24.988281 C 47.375 25.378906 46.957031 25.785156 46.546875 26.195312 C 46.515625 26.226562 46.484375 26.257812 46.453125 26.289062 C 46.292969 26.449219 46.132812 26.609375 45.976562 26.765625 C 45.8125 26.929688 45.648438 27.09375 45.488281 27.253906 C 45.359375 27.382812 45.230469 27.507812 45.105469 27.636719 C 45.015625 27.726562 44.925781 27.8125 44.839844 27.902344 C 44.558594 28.183594 44.285156 28.46875 44.027344 28.773438 C 43.855469 28.96875 43.664062 29.148438 43.476562 29.332031 C 43.308594 29.503906 43.148438 29.6875 42.988281 29.871094 C 42.808594 30.078125 42.617188 30.277344 42.425781 30.476562 C 42.03125 30.894531 41.652344 31.324219 41.277344 31.765625 C 41.152344 31.917969 41.023438 32.0625 40.898438 32.210938 C 40.34375 32.855469 39.808594 33.515625 39.285156 34.1875 C 39.179688 34.324219 39.074219 34.457031 38.96875 34.59375 C 38.890625 34.691406 38.8125 34.789062 38.738281 34.886719 C 38.703125 34.929688 38.667969 34.976562 38.632812 35.023438 C 38.167969 35.617188 37.734375 36.230469 37.320312 36.859375 C 37.234375 36.99218Line truncated
|
||||||
|
<path style=" stroke:none;fill-rule:nonzero;fill:rgb(23.529412%,40.392157%,55.29412%);fill-opacity:1;" d="M 54.75 21.25 C 54.84375 21.3125 54.84375 21.3125 54.9375 21.375 C 54.957031 21.542969 54.960938 21.695312 54.960938 21.863281 C 54.960938 21.914062 54.960938 21.964844 54.960938 22.019531 C 54.960938 22.191406 54.960938 22.363281 54.960938 22.539062 C 54.960938 22.664062 54.960938 22.789062 54.960938 22.914062 C 54.960938 23.257812 54.960938 23.601562 54.960938 23.945312 C 54.957031 24.316406 54.957031 24.6875 54.957031 25.058594 C 54.957031 25.703125 54.957031 26.347656 54.957031 26.992188 C 54.957031 27.664062 54.957031 28.339844 54.957031 29.015625 C 54.957031 29.058594 54.957031 29.097656 54.957031 29.140625 C 54.957031 29.355469 54.957031 29.566406 54.953125 29.78125 C 54.953125 31.289062 54.953125 32.800781 54.953125 34.308594 C 54.949219 35.773438 54.949219 37.242188 54.949219 38.707031 C 54.949219 38.753906 54.949219 38.796875 54.949219 38.84375 C 54.949219 39.296875 54.945312 39.753906 54.945312 40.207031 C 54.945312 41.128906 54.945312 42.054688 54.945312 42.976562 C 54.945312 43.019531 54.945312 43.0625 54.945312 43.105469 C 54.945312 45.945312 54.941406 48.785156 54.9375 51.625 C 54.894531 51.625 54.855469 51.625 54.8125 51.625 C 54.792969 41.601562 54.769531 31.578125 54.75 21.25 Z M 54.75 21.25 "/>
|
||||||
|
<path style=" stroke:none;fill-rule:nonzero;fill:rgb(96.078432%,94.117647%,90.588236%);fill-opacity:1;" d="M 40.25 21.4375 C 40.25 21.835938 39.757812 22.179688 39.484375 22.453125 C 39.378906 22.558594 39.273438 22.664062 39.167969 22.769531 C 38.953125 22.984375 38.746094 23.199219 38.550781 23.429688 C 38.394531 23.613281 38.230469 23.789062 38.066406 23.96875 C 38.019531 24.019531 38.019531 24.019531 37.972656 24.070312 C 37.898438 24.152344 37.824219 24.230469 37.75 24.3125 C 37.707031 24.292969 37.667969 24.269531 37.625 24.25 C 37.679688 24.191406 37.734375 24.132812 37.792969 24.070312 C 37.867188 23.992188 37.945312 23.910156 38.019531 23.828125 C 38.058594 23.785156 38.097656 23.742188 38.140625 23.699219 C 38.386719 23.4375 38.628906 23.171875 38.871094 22.90625 C 39.082031 22.675781 39.300781 22.449219 39.523438 22.226562 C 39.675781 22.070312 39.820312 21.90625 39.964844 21.738281 C 40.054688 21.632812 40.152344 21.535156 40.25 21.4375 Z M 40.25 21.4375 "/>
|
||||||
|
<path style=" stroke:none;fill-rule:nonzero;fill:rgb(95.294118%,93.725491%,90.980393%);fill-opacity:1;" d="M 43.3125 18.4375 C 43.3125 18.679688 43.214844 18.742188 43.050781 18.910156 C 43.015625 18.945312 42.976562 18.980469 42.9375 19.019531 C 42.898438 19.058594 42.863281 19.097656 42.824219 19.136719 C 42.742188 19.214844 42.660156 19.296875 42.578125 19.375 C 42.453125 19.5 42.332031 19.621094 42.207031 19.746094 C 42.128906 19.824219 42.046875 19.902344 41.96875 19.980469 C 41.933594 20.015625 41.894531 20.054688 41.859375 20.089844 C 41.683594 20.261719 41.511719 20.421875 41.3125 20.5625 C 41.269531 20.542969 41.230469 20.519531 41.1875 20.5 C 41.976562 19.703125 41.976562 19.703125 42.363281 19.371094 C 42.695312 19.074219 43 18.753906 43.3125 18.4375 Z M 43.3125 18.4375 "/>
|
||||||
|
<path style=" stroke:none;fill-rule:nonzero;fill:rgb(15.294118%,34.509805%,52.156866%);fill-opacity:1;" d="M 45.8125 26.9375 C 45.855469 26.957031 45.894531 26.980469 45.9375 27 C 45.902344 27.035156 45.867188 27.070312 45.828125 27.105469 C 45.558594 27.375 45.296875 27.648438 45.046875 27.9375 C 44.855469 28.15625 44.644531 28.359375 44.4375 28.5625 C 44.417969 28.5 44.394531 28.4375 44.375 28.375 C 44.847656 27.902344 45.324219 27.425781 45.8125 26.9375 Z M 45.8125 26.9375 "/>
|
||||||
|
<path style=" stroke:none;fill-rule:nonzero;fill:rgb(95.686275%,93.725491%,90.588236%);fill-opacity:1;" d="M 44.5625 17.25 C 44.605469 17.269531 44.644531 17.292969 44.6875 17.3125 C 44.527344 17.476562 44.367188 17.640625 44.207031 17.804688 C 44.140625 17.875 44.140625 17.875 44.070312 17.945312 C 44.027344 17.992188 43.980469 18.035156 43.9375 18.082031 C 43.898438 18.121094 43.855469 18.164062 43.816406 18.207031 C 43.675781 18.324219 43.546875 18.378906 43.375 18.4375 C 43.421875 18.390625 43.46875 18.347656 43.515625 18.300781 C 43.683594 18.136719 43.683594 18.136719 43.800781 17.976562 C 43.960938 17.785156 44.136719 17.632812 44.324219 17.472656 C 44.441406 17.375 44.441406 17.375 44.5625 17.25 Z M 44.5625 17.25 "/>
|
||||||
|
<path style=" stroke:none;fill-rule:nonzero;fill:rgb(94.509804%,92.941177%,91.37255%);fill-opacity:1;" d="M 41.125 20.5625 C 41.144531 20.605469 41.167969 20.644531 41.1875 20.6875 C 40.953125 20.964844 40.730469 21.222656 40.4375 21.4375 C 40.394531 21.417969 40.355469 21.394531 40.3125 21.375 C 40.398438 21.28125 40.398438 21.28125 40.5 21.1875 C 40.542969 21.1875 40.582031 21.1875 40.625 21.1875 C 40.636719 21.152344 40.652344 21.121094 40.664062 21.085938 C 40.785156 20.875 40.949219 20.730469 41.125 20.5625 Z M 41.125 20.5625 "/>
|
||||||
|
<path style=" stroke:none;fill-rule:nonzero;fill:rgb(95.686275%,93.725491%,90.196079%);fill-opacity:1;" d="M 37.3125 24.625 C 37.332031 24.667969 37.355469 24.707031 37.375 24.75 C 37.308594 24.832031 37.238281 24.917969 37.167969 25 C 37.109375 25.070312 37.109375 25.070312 37.050781 25.140625 C 36.9375 25.25 36.9375 25.25 36.75 25.25 C 36.9375 25.042969 37.121094 24.835938 37.3125 24.625 Z M 37.3125 24.625 "/>
|
||||||
|
<path style=" stroke:none;fill-rule:nonzero;fill:rgb(23.921569%,40.000001%,55.686277%);fill-opacity:1;" d="M 49.6875 23.125 C 49.75 23.144531 49.8125 23.167969 49.875 23.1875 C 49.6875 23.351562 49.503906 23.515625 49.3125 23.6875 C 49.269531 23.667969 49.230469 23.644531 49.1875 23.625 C 49.351562 23.460938 49.515625 23.296875 49.6875 23.125 Z M 49.6875 23.125 "/>
|
||||||
|
<path style=" stroke:none;fill-rule:nonzero;fill:rgb(94.509804%,94.509804%,92.156863%);fill-opacity:1;" d="M 30.3125 35.25 C 30.332031 35.25 30.355469 35.25 30.375 35.25 C 30.414062 35.570312 30.273438 35.792969 30.125 36.0625 C 30.082031 36.0625 30.042969 36.0625 30 36.0625 C 30.027344 35.972656 30.058594 35.886719 30.085938 35.796875 C 30.109375 35.722656 30.109375 35.722656 30.132812 35.648438 C 30.183594 35.507812 30.242188 35.378906 30.3125 35.25 Z M 30.3125 35.25 "/>
|
||||||
|
<path style=" stroke:none;fill-rule:nonzero;fill:rgb(20.784314%,38.431373%,55.29412%);fill-opacity:1;" d="M 50.8125 22.125 C 50.855469 22.144531 50.894531 22.167969 50.9375 22.1875 C 50.722656 22.375 50.722656 22.375 50.5 22.5625 C 50.457031 22.542969 50.417969 22.519531 50.375 22.5 C 50.519531 22.375 50.664062 22.253906 50.8125 22.125 Z M 50.8125 22.125 "/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 30 KiB |
@@ -0,0 +1,6 @@
|
|||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M13 7H7V5H13V7Z" fill="currentColor"/>
|
||||||
|
<path d="M13 11H7V9H13V11Z" fill="currentColor"/>
|
||||||
|
<path d="M7 15H13V13H7V15Z" fill="currentColor"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M3 19V1H17V5H21V23H7V19H3ZM15 17V3H5V17H15ZM17 7V19H9V21H19V7H17Z" fill="currentColor"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 396 B |
@@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M20.817 11.186a8.94 8.94 0 0 0-1.355-3.219 9.053 9.053 0 0 0-2.43-2.43 8.95 8.95 0 0 0-3.219-1.355 9.028 9.028 0 0 0-1.838-.18V2L8 5l3.975 3V6.002c.484-.002.968.044 1.435.14a6.961 6.961 0 0 1 2.502 1.053 7.005 7.005 0 0 1 1.892 1.892A6.967 6.967 0 0 1 19 13a7.032 7.032 0 0 1-.55 2.725 7.11 7.11 0 0 1-.644 1.188 7.2 7.2 0 0 1-.858 1.039 7.028 7.028 0 0 1-3.536 1.907 7.13 7.13 0 0 1-2.822 0 6.961 6.961 0 0 1-2.503-1.054 7.002 7.002 0 0 1-1.89-1.89A6.996 6.996 0 0 1 5 13H3a9.02 9.02 0 0 0 1.539 5.034 9.096 9.096 0 0 0 2.428 2.428A8.95 8.95 0 0 0 12 22a9.09 9.09 0 0 0 1.814-.183 9.014 9.014 0 0 0 3.218-1.355 8.886 8.886 0 0 0 1.331-1.099 9.228 9.228 0 0 0 1.1-1.332A8.952 8.952 0 0 0 21 13a9.09 9.09 0 0 0-.183-1.814z" fill="currentColor"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 866 B |
@@ -0,0 +1,539 @@
|
|||||||
|
/*
|
||||||
|
* 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.
|
||||||
|
*
|
||||||
|
*
|
||||||
|
* AutoLibrary Official Theme : BlueForest
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* ---- Global ---- */
|
||||||
|
QMainWindow::separator {
|
||||||
|
background-color: #1c2840;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Menu Bar ---- */
|
||||||
|
QMenuBar {
|
||||||
|
background-color: #0f1628;
|
||||||
|
border-bottom: 1px solid #1c2840;
|
||||||
|
padding: 2px 5px;
|
||||||
|
color: #d0daf0;
|
||||||
|
}
|
||||||
|
QMenuBar::item {
|
||||||
|
padding: 2px 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
QMenuBar::item:selected {
|
||||||
|
background-color: #1c2840;
|
||||||
|
}
|
||||||
|
QMenu {
|
||||||
|
background-color: #162038;
|
||||||
|
border-style: solid;
|
||||||
|
border-color: #253250;
|
||||||
|
border-width: 1px;
|
||||||
|
padding: 4px;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
QMenu::item {
|
||||||
|
padding: 5px 15px 5px 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
QMenu::item:selected {
|
||||||
|
background-color: #2dd4bf;
|
||||||
|
color: #0f1119;
|
||||||
|
}
|
||||||
|
QMenu::separator {
|
||||||
|
height: 1px;
|
||||||
|
background-color: #253250;
|
||||||
|
margin: 4px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Button ---- */
|
||||||
|
QPushButton {
|
||||||
|
border-style: solid;
|
||||||
|
border-color: #253250;
|
||||||
|
border-width: 1px;
|
||||||
|
border-radius: 5px;
|
||||||
|
color: #d0daf0;
|
||||||
|
padding: 4px 12px;
|
||||||
|
background-color: #1c2840;
|
||||||
|
}
|
||||||
|
QPushButton:hover {
|
||||||
|
background-color: #243458;
|
||||||
|
border-color: #334478;
|
||||||
|
}
|
||||||
|
QPushButton:pressed {
|
||||||
|
background-color: #162038;
|
||||||
|
border-color: #2dd4bf;
|
||||||
|
}
|
||||||
|
QPushButton:disabled {
|
||||||
|
background-color: #162038;
|
||||||
|
color: #5568a0;
|
||||||
|
border-color: #1c2840;
|
||||||
|
}
|
||||||
|
QPushButton[default="true"] {
|
||||||
|
background-color: #2dd4bf;
|
||||||
|
color: #0f1119;
|
||||||
|
border-color: #2dd4bf;
|
||||||
|
}
|
||||||
|
QPushButton[default="true"]:hover {
|
||||||
|
background-color: #3de0cc;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Input ---- */
|
||||||
|
QLineEdit,
|
||||||
|
QPlainTextEdit,
|
||||||
|
QTextEdit,
|
||||||
|
QSpinBox,
|
||||||
|
QDoubleSpinBox,
|
||||||
|
QDateEdit,
|
||||||
|
QTimeEdit {
|
||||||
|
background-color: #0a1020;
|
||||||
|
border-style: solid;
|
||||||
|
border-color: #253250;
|
||||||
|
border-width: 1px;
|
||||||
|
border-radius: 5px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
color: #d0daf0;
|
||||||
|
selection-background-color: #2dd4bf;
|
||||||
|
selection-color: #0f1119;
|
||||||
|
}
|
||||||
|
QLineEdit:focus,
|
||||||
|
QPlainTextEdit:focus,
|
||||||
|
QTextEdit:focus,
|
||||||
|
QSpinBox:focus,
|
||||||
|
QDoubleSpinBox:focus,
|
||||||
|
QDateEdit:focus,
|
||||||
|
QTimeEdit:focus {
|
||||||
|
border-color: #2dd4bf;
|
||||||
|
}
|
||||||
|
QPlainTextEdit,
|
||||||
|
QTextEdit {
|
||||||
|
background-color: #0a1020;
|
||||||
|
}
|
||||||
|
QLineEdit:disabled,
|
||||||
|
QPlainTextEdit:disabled,
|
||||||
|
QTextEdit:disabled,
|
||||||
|
QSpinBox:disabled,
|
||||||
|
QDoubleSpinBox:disabled,
|
||||||
|
QDateEdit:disabled,
|
||||||
|
QTimeEdit:disabled {
|
||||||
|
background-color: #162038;
|
||||||
|
color: #5568a0;
|
||||||
|
border-color: #1c2840;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Spin Button Arrows ---- */
|
||||||
|
QSpinBox::up-button,
|
||||||
|
QDoubleSpinBox::up-button,
|
||||||
|
QDateEdit::up-button,
|
||||||
|
QTimeEdit::up-button {
|
||||||
|
subcontrol-origin: border;
|
||||||
|
subcontrol-position: top right;
|
||||||
|
width: 10px;
|
||||||
|
border-left: 1px solid #253250;
|
||||||
|
border-bottom: 1px solid #253250;
|
||||||
|
border-top-right-radius: 4px;
|
||||||
|
}
|
||||||
|
QSpinBox::up-button:hover,
|
||||||
|
QDoubleSpinBox::up-button:hover,
|
||||||
|
QDateEdit::up-button:hover,
|
||||||
|
QTimeEdit::up-button:hover {
|
||||||
|
background-color: #1c2840;
|
||||||
|
}
|
||||||
|
QSpinBox::up-arrow,
|
||||||
|
QDoubleSpinBox::up-arrow,
|
||||||
|
QDateEdit::up-arrow,
|
||||||
|
QTimeEdit::up-arrow {
|
||||||
|
border-left: 4px solid transparent;
|
||||||
|
border-right: 4px solid transparent;
|
||||||
|
border-bottom: 5px solid #7888b8;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
QSpinBox::down-button,
|
||||||
|
QDoubleSpinBox::down-button,
|
||||||
|
QDateEdit::down-button,
|
||||||
|
QTimeEdit::down-button {
|
||||||
|
width: 10px;
|
||||||
|
subcontrol-origin: border;
|
||||||
|
subcontrol-position: bottom right;
|
||||||
|
border-left: 1px solid #253250;
|
||||||
|
border-bottom-right-radius: 4px;
|
||||||
|
}
|
||||||
|
QSpinBox::down-button:hover,
|
||||||
|
QDoubleSpinBox::down-button:hover,
|
||||||
|
QDateEdit::down-button:hover,
|
||||||
|
QTimeEdit::down-button:hover {
|
||||||
|
background-color: #1c2840;
|
||||||
|
}
|
||||||
|
QSpinBox::down-arrow,
|
||||||
|
QDoubleSpinBox::down-arrow,
|
||||||
|
QDateEdit::down-arrow,
|
||||||
|
QTimeEdit::down-arrow {
|
||||||
|
border-left: 4px solid transparent;
|
||||||
|
border-right: 4px solid transparent;
|
||||||
|
border-top: 5px solid #7888b8;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
QSpinBox::up-button:disabled,
|
||||||
|
QDoubleSpinBox::up-button:disabled,
|
||||||
|
QDateEdit::up-button:disabled,
|
||||||
|
QTimeEdit::up-button:disabled,
|
||||||
|
QSpinBox::down-button:disabled,
|
||||||
|
QDoubleSpinBox::down-button:disabled,
|
||||||
|
QDateEdit::down-button:disabled,
|
||||||
|
QTimeEdit::down-button:disabled {
|
||||||
|
background-color: #162038;
|
||||||
|
}
|
||||||
|
QSpinBox::up-arrow:disabled,
|
||||||
|
QDoubleSpinBox::up-arrow:disabled,
|
||||||
|
QDateEdit::up-arrow:disabled,
|
||||||
|
QTimeEdit::up-arrow:disabled {
|
||||||
|
border-bottom-color: #5568a0;
|
||||||
|
}
|
||||||
|
QSpinBox::down-arrow:disabled,
|
||||||
|
QDoubleSpinBox::down-arrow:disabled,
|
||||||
|
QDateEdit::down-arrow:disabled,
|
||||||
|
QTimeEdit::down-arrow:disabled {
|
||||||
|
border-top-color: #5568a0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Combo Box ---- */
|
||||||
|
QComboBox {
|
||||||
|
background-color: #1c2840;
|
||||||
|
border-style: solid;
|
||||||
|
border-color: #253250;
|
||||||
|
border-width: 1px;
|
||||||
|
border-radius: 5px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
color: #d0daf0;
|
||||||
|
}
|
||||||
|
QComboBox:hover {
|
||||||
|
border-color: #334478;
|
||||||
|
}
|
||||||
|
QComboBox:focus {
|
||||||
|
border-color: #2dd4bf;
|
||||||
|
}
|
||||||
|
QComboBox::drop-down {
|
||||||
|
subcontrol-origin: padding;
|
||||||
|
subcontrol-position: top right;
|
||||||
|
width: 24px;
|
||||||
|
border-left: 1px solid #253250;
|
||||||
|
border-top-right-radius: 5px;
|
||||||
|
border-bottom-right-radius: 5px;
|
||||||
|
}
|
||||||
|
QComboBox::down-arrow {
|
||||||
|
image: none;
|
||||||
|
border-left: 4px solid transparent;
|
||||||
|
border-right: 4px solid transparent;
|
||||||
|
border-top: 6px solid #7888b8;
|
||||||
|
margin-right: 6px;
|
||||||
|
}
|
||||||
|
QComboBox QAbstractItemView {
|
||||||
|
background-color: #162038;
|
||||||
|
border-style: solid;
|
||||||
|
border-color: #253250;
|
||||||
|
border-width: 1px;
|
||||||
|
border-radius: 4px;
|
||||||
|
selection-background-color: #2dd4bf;
|
||||||
|
selection-color: #0f1119;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
QComboBox:disabled {
|
||||||
|
background-color: #162038;
|
||||||
|
color: #5568a0;
|
||||||
|
border-color: #1c2840;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Check Box / Radio Button ---- */
|
||||||
|
QCheckBox,
|
||||||
|
QRadioButton {
|
||||||
|
spacing: 5px;
|
||||||
|
color: #d0daf0;
|
||||||
|
}
|
||||||
|
QCheckBox::indicator,
|
||||||
|
QRadioButton::indicator {
|
||||||
|
border-style: solid;
|
||||||
|
border-color: #334478;
|
||||||
|
border-width: 2px;
|
||||||
|
background-color: #0a1020;
|
||||||
|
}
|
||||||
|
QCheckBox::indicator {
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
QRadioButton::indicator {
|
||||||
|
border-radius: 7px;
|
||||||
|
}
|
||||||
|
QCheckBox::indicator:hover,
|
||||||
|
QRadioButton::indicator:hover {
|
||||||
|
border-color: #2dd4bf;
|
||||||
|
}
|
||||||
|
QCheckBox::indicator:checked {
|
||||||
|
background-color: #2dd4bf;
|
||||||
|
border-color: #2dd4bf;
|
||||||
|
}
|
||||||
|
QRadioButton::indicator:checked {
|
||||||
|
background-color: #2dd4bf;
|
||||||
|
border-color: #2dd4bf;
|
||||||
|
}
|
||||||
|
QCheckBox::indicator:disabled,
|
||||||
|
QRadioButton::indicator:disabled {
|
||||||
|
border-color: #253250;
|
||||||
|
background-color: #162038;
|
||||||
|
}
|
||||||
|
QCheckBox::indicator:checked:hover,
|
||||||
|
QRadioButton::indicator:checked:hover {
|
||||||
|
border-color: #a0f0e8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tree / List / Table Widget CheckBox Indicator */
|
||||||
|
QTreeWidget::indicator,
|
||||||
|
QListWidget::indicator,
|
||||||
|
QTableWidget::indicator {
|
||||||
|
border: 2px solid #5568a0;
|
||||||
|
border-radius: 3px;
|
||||||
|
background-color: #162038;
|
||||||
|
}
|
||||||
|
QTreeWidget::indicator:hover,
|
||||||
|
QListWidget::indicator:hover,
|
||||||
|
QTableWidget::indicator:hover {
|
||||||
|
border-color: #a0f0e8;
|
||||||
|
}
|
||||||
|
QTreeWidget::indicator:checked,
|
||||||
|
QListWidget::indicator:checked,
|
||||||
|
QTableWidget::indicator:checked {
|
||||||
|
background-color: #2dd4bf;
|
||||||
|
border-color: #2dd4bf;
|
||||||
|
}
|
||||||
|
QTreeWidget::indicator:checked:hover,
|
||||||
|
QListWidget::indicator:checked:hover,
|
||||||
|
QTableWidget::indicator:checked:hover {
|
||||||
|
border-color: #a0f0e8;
|
||||||
|
}
|
||||||
|
QTreeWidget::indicator:disabled,
|
||||||
|
QListWidget::indicator:disabled,
|
||||||
|
QTableWidget::indicator:disabled {
|
||||||
|
background-color: #1c2840;
|
||||||
|
border-color: #334478;
|
||||||
|
}
|
||||||
|
QTreeWidget::indicator:indeterminate,
|
||||||
|
QListWidget::indicator:indeterminate,
|
||||||
|
QTableWidget::indicator:indeterminate {
|
||||||
|
background-color: #2dd4bf;
|
||||||
|
border-color: #a0f0e8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Group Box ---- */
|
||||||
|
QGroupBox {
|
||||||
|
margin-top: 5px;
|
||||||
|
padding-top: 15px;
|
||||||
|
color: #b4c2f5;
|
||||||
|
font-weight: bold;
|
||||||
|
border-style: solid;
|
||||||
|
border-color: #253250;
|
||||||
|
border-width: 1px;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Tab ---- */
|
||||||
|
QTabWidget::pane {
|
||||||
|
border-style: solid;
|
||||||
|
border-color: #253250;
|
||||||
|
border-width: 1px;
|
||||||
|
border-radius: 5px;
|
||||||
|
background-color: #0f1a2e;
|
||||||
|
top: -1px;
|
||||||
|
}
|
||||||
|
QTabBar::tab {
|
||||||
|
background-color: #162038;
|
||||||
|
border-style: solid;
|
||||||
|
border-color: #253250;
|
||||||
|
border-width: 1px;
|
||||||
|
border-bottom: none;
|
||||||
|
border-top-left-radius: 5px;
|
||||||
|
border-top-right-radius: 5px;
|
||||||
|
padding: 6px 16px;
|
||||||
|
margin-right: 2px;
|
||||||
|
color: #7888b8;
|
||||||
|
}
|
||||||
|
QTabBar::tab:selected {
|
||||||
|
background-color: #0f1a2e;
|
||||||
|
color: #2dd4bf;
|
||||||
|
border-bottom: 2px solid #2dd4bf;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- List / Tree ---- */
|
||||||
|
QListWidget,
|
||||||
|
QTreeWidget,
|
||||||
|
QTableWidget {
|
||||||
|
background-color: #0a1020;
|
||||||
|
border-style: solid;
|
||||||
|
border-color: #253250;
|
||||||
|
border-width: 1px;
|
||||||
|
border-radius: 5px;
|
||||||
|
outline: none;
|
||||||
|
color: #d0daf0;
|
||||||
|
alternate-background-color: #101c30;
|
||||||
|
}
|
||||||
|
QListWidget::item,
|
||||||
|
QTreeWidget::item,
|
||||||
|
QTableWidget::item {
|
||||||
|
padding: 5px 5px;
|
||||||
|
}
|
||||||
|
QHeaderView::section {
|
||||||
|
background-color: #0f1628;
|
||||||
|
border-right: 1px solid #253250;
|
||||||
|
border-bottom: 1px solid #253250;
|
||||||
|
padding: 5px 10px;
|
||||||
|
color: #8b9ad0;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Scroll Bar ---- */
|
||||||
|
QScrollBar:vertical {
|
||||||
|
background-color: #0f1a2e;
|
||||||
|
width: 10px;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
QScrollBar::handle:vertical {
|
||||||
|
background-color: #334478;
|
||||||
|
min-height: 30px;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
QScrollBar::handle:vertical:hover {
|
||||||
|
background-color: #5568a0;
|
||||||
|
}
|
||||||
|
QScrollBar::add-line:vertical,
|
||||||
|
QScrollBar::sub-line:vertical {
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
QScrollBar:horizontal {
|
||||||
|
background-color: #0f1a2e;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
QScrollBar::handle:horizontal {
|
||||||
|
background-color: #334478;
|
||||||
|
min-width: 30px;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
QScrollBar::handle:horizontal:hover {
|
||||||
|
background-color: #5568a0;
|
||||||
|
}
|
||||||
|
QScrollBar::add-line:horizontal,
|
||||||
|
QScrollBar::sub-line:horizontal {
|
||||||
|
width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Progress Bar ---- */
|
||||||
|
QProgressBar {
|
||||||
|
background-color: #0a1020;
|
||||||
|
border-style: solid;
|
||||||
|
border-color: #253250;
|
||||||
|
border-width: 1px;
|
||||||
|
border-radius: 5px;
|
||||||
|
height: 10px;
|
||||||
|
text-align: center;
|
||||||
|
color: #d0daf0;
|
||||||
|
}
|
||||||
|
QProgressBar::chunk {
|
||||||
|
background-color: #2dd4bf;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Slider ---- */
|
||||||
|
QSlider::groove:horizontal {
|
||||||
|
background-color: #1c2840;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
QSlider::handle:horizontal {
|
||||||
|
background-color: #2dd4bf;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
margin: -5px 0;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
QSlider::sub-page:horizontal {
|
||||||
|
background-color: #2dd4bf;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
QSlider::handle:horizontal:disabled {
|
||||||
|
background-color: #5568a0;
|
||||||
|
}
|
||||||
|
QSlider::sub-page:horizontal:disabled {
|
||||||
|
background-color: #5568a0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Tool Tip ---- */
|
||||||
|
QToolTip {
|
||||||
|
background-color: #1c2840;
|
||||||
|
border-style: solid;
|
||||||
|
border-color: #2dd4bf;
|
||||||
|
border-width: 1px;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
color: #d0daf0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Status Bar ---- */
|
||||||
|
QStatusBar {
|
||||||
|
background-color: #0f1628;
|
||||||
|
border-top: 1px solid #1c2840;
|
||||||
|
color: #7888b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Splitter ---- */
|
||||||
|
QSplitter::handle {
|
||||||
|
background-color: #253250;
|
||||||
|
margin: 1px;
|
||||||
|
}
|
||||||
|
QSplitter::handle:horizontal {
|
||||||
|
width: 2px;
|
||||||
|
}
|
||||||
|
QSplitter::handle:vertical {
|
||||||
|
height: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Dialog ---- */
|
||||||
|
QDialog {
|
||||||
|
background-color: #0f1a2e;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Date / Time Editor Drop-down ---- */
|
||||||
|
QDateEdit::drop-down,
|
||||||
|
QTimeEdit::drop-down {
|
||||||
|
subcontrol-origin: padding;
|
||||||
|
subcontrol-position: top right;
|
||||||
|
width: 24px;
|
||||||
|
border-left: 1px solid #253250;
|
||||||
|
}
|
||||||
|
QCalendarWidget {
|
||||||
|
background-color: #162038;
|
||||||
|
border-style: solid;
|
||||||
|
border-color: #253250;
|
||||||
|
border-width: 1px;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
QCalendarWidget QToolButton {
|
||||||
|
color: #d0daf0;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
}
|
||||||
|
QCalendarWidget QToolButton:hover {
|
||||||
|
background-color: #1c2840;
|
||||||
|
}
|
||||||
|
QCalendarWidget QMenu {
|
||||||
|
background-color: #162038;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Frame ---- */
|
||||||
|
QFrame[frameShape="4"], /* HLine */
|
||||||
|
QFrame[frameShape="5"] /* VLine */ {
|
||||||
|
background-color: #253250;
|
||||||
|
}
|
||||||
@@ -0,0 +1,539 @@
|
|||||||
|
/*
|
||||||
|
* 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.
|
||||||
|
*
|
||||||
|
*
|
||||||
|
* AutoLibrary Official Theme : LightLake
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* ---- Global ---- */
|
||||||
|
QMainWindow::separator {
|
||||||
|
background-color: #c0cdda;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Menu Bar ---- */
|
||||||
|
QMenuBar {
|
||||||
|
background-color: #dce4ee;
|
||||||
|
border-bottom: 1px solid #c0cdda;
|
||||||
|
padding: 2px 5px;
|
||||||
|
color: #1a2740;
|
||||||
|
}
|
||||||
|
QMenuBar::item {
|
||||||
|
padding: 2px 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
QMenuBar::item:selected {
|
||||||
|
background-color: #d5dde8;
|
||||||
|
}
|
||||||
|
QMenu {
|
||||||
|
background-color: #ffffff;
|
||||||
|
border-style: solid;
|
||||||
|
border-color: #d0d8e4;
|
||||||
|
border-width: 1px;
|
||||||
|
padding: 4px;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
QMenu::item {
|
||||||
|
padding: 5px 15px 5px 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
QMenu::item:selected {
|
||||||
|
background-color: #0ea58a;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
QMenu::separator {
|
||||||
|
height: 1px;
|
||||||
|
background-color: #d0d8e4;
|
||||||
|
margin: 4px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Button ---- */
|
||||||
|
QPushButton {
|
||||||
|
border-style: solid;
|
||||||
|
border-color: #c0cdda;
|
||||||
|
border-width: 1px;
|
||||||
|
border-radius: 5px;
|
||||||
|
color: #1a2740;
|
||||||
|
padding: 4px 12px;
|
||||||
|
background-color: #d5dde8;
|
||||||
|
}
|
||||||
|
QPushButton:hover {
|
||||||
|
background-color: #c8d4e2;
|
||||||
|
border-color: #90a4c4;
|
||||||
|
}
|
||||||
|
QPushButton:pressed {
|
||||||
|
background-color: #e2e8f0;
|
||||||
|
border-color: #0ea58a;
|
||||||
|
}
|
||||||
|
QPushButton:disabled {
|
||||||
|
background-color: #e8ecf2;
|
||||||
|
color: #98a8c0;
|
||||||
|
border-color: #d5dde8;
|
||||||
|
}
|
||||||
|
QPushButton[default="true"] {
|
||||||
|
background-color: #0ea58a;
|
||||||
|
color: #ffffff;
|
||||||
|
border-color: #0ea58a;
|
||||||
|
}
|
||||||
|
QPushButton[default="true"]:hover {
|
||||||
|
background-color: #14c7a4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Input ---- */
|
||||||
|
QLineEdit,
|
||||||
|
QPlainTextEdit,
|
||||||
|
QTextEdit,
|
||||||
|
QSpinBox,
|
||||||
|
QDoubleSpinBox,
|
||||||
|
QDateEdit,
|
||||||
|
QTimeEdit {
|
||||||
|
background-color: #ffffff;
|
||||||
|
border-style: solid;
|
||||||
|
border-color: #c0cdda;
|
||||||
|
border-width: 1px;
|
||||||
|
border-radius: 5px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
color: #1a2740;
|
||||||
|
selection-background-color: #0ea58a;
|
||||||
|
selection-color: #ffffff;
|
||||||
|
}
|
||||||
|
QLineEdit:focus,
|
||||||
|
QPlainTextEdit:focus,
|
||||||
|
QTextEdit:focus,
|
||||||
|
QSpinBox:focus,
|
||||||
|
QDoubleSpinBox:focus,
|
||||||
|
QDateEdit:focus,
|
||||||
|
QTimeEdit:focus {
|
||||||
|
border-color: #0ea58a;
|
||||||
|
}
|
||||||
|
QPlainTextEdit,
|
||||||
|
QTextEdit {
|
||||||
|
background-color: #ffffff;
|
||||||
|
}
|
||||||
|
QLineEdit:disabled,
|
||||||
|
QPlainTextEdit:disabled,
|
||||||
|
QTextEdit:disabled,
|
||||||
|
QSpinBox:disabled,
|
||||||
|
QDoubleSpinBox:disabled,
|
||||||
|
QDateEdit:disabled,
|
||||||
|
QTimeEdit:disabled {
|
||||||
|
background-color: #e8ecf2;
|
||||||
|
color: #98a8c0;
|
||||||
|
border-color: #d5dde8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Spin Button Arrows ---- */
|
||||||
|
QSpinBox::up-button,
|
||||||
|
QDoubleSpinBox::up-button,
|
||||||
|
QDateEdit::up-button,
|
||||||
|
QTimeEdit::up-button {
|
||||||
|
subcontrol-origin: border;
|
||||||
|
subcontrol-position: top right;
|
||||||
|
width: 10px;
|
||||||
|
border-left: 1px solid #c0cdda;
|
||||||
|
border-bottom: 1px solid #c0cdda;
|
||||||
|
border-top-right-radius: 4px;
|
||||||
|
}
|
||||||
|
QSpinBox::up-button:hover,
|
||||||
|
QDoubleSpinBox::up-button:hover,
|
||||||
|
QDateEdit::up-button:hover,
|
||||||
|
QTimeEdit::up-button:hover {
|
||||||
|
background-color: #d5dde8;
|
||||||
|
}
|
||||||
|
QSpinBox::up-arrow,
|
||||||
|
QDoubleSpinBox::up-arrow,
|
||||||
|
QDateEdit::up-arrow,
|
||||||
|
QTimeEdit::up-arrow {
|
||||||
|
border-left: 4px solid transparent;
|
||||||
|
border-right: 4px solid transparent;
|
||||||
|
border-bottom: 5px solid #6a7898;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
QSpinBox::down-button,
|
||||||
|
QDoubleSpinBox::down-button,
|
||||||
|
QDateEdit::down-button,
|
||||||
|
QTimeEdit::down-button {
|
||||||
|
width: 10px;
|
||||||
|
subcontrol-origin: border;
|
||||||
|
subcontrol-position: bottom right;
|
||||||
|
border-left: 1px solid #c0cdda;
|
||||||
|
border-bottom-right-radius: 4px;
|
||||||
|
}
|
||||||
|
QSpinBox::down-button:hover,
|
||||||
|
QDoubleSpinBox::down-button:hover,
|
||||||
|
QDateEdit::down-button:hover,
|
||||||
|
QTimeEdit::down-button:hover {
|
||||||
|
background-color: #d5dde8;
|
||||||
|
}
|
||||||
|
QSpinBox::down-arrow,
|
||||||
|
QDoubleSpinBox::down-arrow,
|
||||||
|
QDateEdit::down-arrow,
|
||||||
|
QTimeEdit::down-arrow {
|
||||||
|
border-left: 4px solid transparent;
|
||||||
|
border-right: 4px solid transparent;
|
||||||
|
border-top: 5px solid #6a7898;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
QSpinBox::up-button:disabled,
|
||||||
|
QDoubleSpinBox::up-button:disabled,
|
||||||
|
QDateEdit::up-button:disabled,
|
||||||
|
QTimeEdit::up-button:disabled,
|
||||||
|
QSpinBox::down-button:disabled,
|
||||||
|
QDoubleSpinBox::down-button:disabled,
|
||||||
|
QDateEdit::down-button:disabled,
|
||||||
|
QTimeEdit::down-button:disabled {
|
||||||
|
background-color: #e8ecf2;
|
||||||
|
}
|
||||||
|
QSpinBox::up-arrow:disabled,
|
||||||
|
QDoubleSpinBox::up-arrow:disabled,
|
||||||
|
QDateEdit::up-arrow:disabled,
|
||||||
|
QTimeEdit::up-arrow:disabled {
|
||||||
|
border-bottom-color: #98a8c0;
|
||||||
|
}
|
||||||
|
QSpinBox::down-arrow:disabled,
|
||||||
|
QDoubleSpinBox::down-arrow:disabled,
|
||||||
|
QDateEdit::down-arrow:disabled,
|
||||||
|
QTimeEdit::down-arrow:disabled {
|
||||||
|
border-top-color: #98a8c0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Combo Box ---- */
|
||||||
|
QComboBox {
|
||||||
|
background-color: #d5dde8;
|
||||||
|
border-style: solid;
|
||||||
|
border-color: #c0cdda;
|
||||||
|
border-width: 1px;
|
||||||
|
border-radius: 5px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
color: #1a2740;
|
||||||
|
}
|
||||||
|
QComboBox:hover {
|
||||||
|
border-color: #90a4c4;
|
||||||
|
}
|
||||||
|
QComboBox:focus {
|
||||||
|
border-color: #0ea58a;
|
||||||
|
}
|
||||||
|
QComboBox::drop-down {
|
||||||
|
subcontrol-origin: padding;
|
||||||
|
subcontrol-position: top right;
|
||||||
|
width: 24px;
|
||||||
|
border-left: 1px solid #c0cdda;
|
||||||
|
border-top-right-radius: 5px;
|
||||||
|
border-bottom-right-radius: 5px;
|
||||||
|
}
|
||||||
|
QComboBox::down-arrow {
|
||||||
|
image: none;
|
||||||
|
border-left: 4px solid transparent;
|
||||||
|
border-right: 4px solid transparent;
|
||||||
|
border-top: 6px solid #6a7898;
|
||||||
|
margin-right: 6px;
|
||||||
|
}
|
||||||
|
QComboBox QAbstractItemView {
|
||||||
|
background-color: #ffffff;
|
||||||
|
border-style: solid;
|
||||||
|
border-color: #d0d8e4;
|
||||||
|
border-width: 1px;
|
||||||
|
border-radius: 4px;
|
||||||
|
selection-background-color: #0ea58a;
|
||||||
|
selection-color: #ffffff;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
QComboBox:disabled {
|
||||||
|
background-color: #e8ecf2;
|
||||||
|
color: #98a8c0;
|
||||||
|
border-color: #d5dde8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Check Box / Radio Button ---- */
|
||||||
|
QCheckBox,
|
||||||
|
QRadioButton {
|
||||||
|
spacing: 5px;
|
||||||
|
color: #1a2740;
|
||||||
|
}
|
||||||
|
QCheckBox::indicator,
|
||||||
|
QRadioButton::indicator {
|
||||||
|
border-style: solid;
|
||||||
|
border-color: #90a4c4;
|
||||||
|
border-width: 2px;
|
||||||
|
background-color: #ffffff;
|
||||||
|
}
|
||||||
|
QCheckBox::indicator {
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
QRadioButton::indicator {
|
||||||
|
border-radius: 7px;
|
||||||
|
}
|
||||||
|
QCheckBox::indicator:hover,
|
||||||
|
QRadioButton::indicator:hover {
|
||||||
|
border-color: #0ea58a;
|
||||||
|
}
|
||||||
|
QCheckBox::indicator:checked {
|
||||||
|
background-color: #0ea58a;
|
||||||
|
border-color: #0ea58a;
|
||||||
|
}
|
||||||
|
QRadioButton::indicator:checked {
|
||||||
|
background-color: #0ea58a;
|
||||||
|
border-color: #0ea58a;
|
||||||
|
}
|
||||||
|
QCheckBox::indicator:disabled,
|
||||||
|
QRadioButton::indicator:disabled {
|
||||||
|
border-color: #c0cdda;
|
||||||
|
background-color: #e8ecf2;
|
||||||
|
}
|
||||||
|
QCheckBox::indicator:checked:hover,
|
||||||
|
QRadioButton::indicator:checked:hover {
|
||||||
|
border-color: #14c7a4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tree / List / Table Widget CheckBox Indicator */
|
||||||
|
QTreeWidget::indicator,
|
||||||
|
QListWidget::indicator,
|
||||||
|
QTableWidget::indicator {
|
||||||
|
border: 2px solid #a0b4cc;
|
||||||
|
border-radius: 3px;
|
||||||
|
background-color: #e8ecf2;
|
||||||
|
}
|
||||||
|
QTreeWidget::indicator:hover,
|
||||||
|
QListWidget::indicator:hover,
|
||||||
|
QTableWidget::indicator:hover {
|
||||||
|
border-color: #14c7a4;
|
||||||
|
}
|
||||||
|
QTreeWidget::indicator:checked,
|
||||||
|
QListWidget::indicator:checked,
|
||||||
|
QTableWidget::indicator:checked {
|
||||||
|
background-color: #0ea58a;
|
||||||
|
border-color: #0ea58a;
|
||||||
|
}
|
||||||
|
QTreeWidget::indicator:checked:hover,
|
||||||
|
QListWidget::indicator:checked:hover,
|
||||||
|
QTableWidget::indicator:checked:hover {
|
||||||
|
border-color: #14c7a4;
|
||||||
|
}
|
||||||
|
QTreeWidget::indicator:disabled,
|
||||||
|
QListWidget::indicator:disabled,
|
||||||
|
QTableWidget::indicator:disabled {
|
||||||
|
background-color: #d5dde8;
|
||||||
|
border-color: #c0cdda;
|
||||||
|
}
|
||||||
|
QTreeWidget::indicator:indeterminate,
|
||||||
|
QListWidget::indicator:indeterminate,
|
||||||
|
QTableWidget::indicator:indeterminate {
|
||||||
|
background-color: #0ea58a;
|
||||||
|
border-color: #14c7a4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Group Box ---- */
|
||||||
|
QGroupBox {
|
||||||
|
margin-top: 5px;
|
||||||
|
padding-top: 15px;
|
||||||
|
color: #4a6080;
|
||||||
|
font-weight: bold;
|
||||||
|
border-style: solid;
|
||||||
|
border-color: #c0cdda;
|
||||||
|
border-width: 1px;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Tab ---- */
|
||||||
|
QTabWidget::pane {
|
||||||
|
border-style: solid;
|
||||||
|
border-color: #c0cdda;
|
||||||
|
border-width: 1px;
|
||||||
|
border-radius: 5px;
|
||||||
|
background-color: #f0f4f8;
|
||||||
|
top: -1px;
|
||||||
|
}
|
||||||
|
QTabBar::tab {
|
||||||
|
background-color: #e0e6ee;
|
||||||
|
border-style: solid;
|
||||||
|
border-color: #c0cdda;
|
||||||
|
border-width: 1px;
|
||||||
|
border-bottom: none;
|
||||||
|
border-top-left-radius: 5px;
|
||||||
|
border-top-right-radius: 5px;
|
||||||
|
padding: 6px 16px;
|
||||||
|
margin-right: 2px;
|
||||||
|
color: #6a7898;
|
||||||
|
}
|
||||||
|
QTabBar::tab:selected {
|
||||||
|
background-color: #f0f4f8;
|
||||||
|
color: #0ea58a;
|
||||||
|
border-bottom: 2px solid #0ea58a;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- List / Tree ---- */
|
||||||
|
QListWidget,
|
||||||
|
QTreeWidget,
|
||||||
|
QTableWidget {
|
||||||
|
background-color: #ffffff;
|
||||||
|
border-style: solid;
|
||||||
|
border-color: #c0cdda;
|
||||||
|
border-width: 1px;
|
||||||
|
border-radius: 5px;
|
||||||
|
outline: none;
|
||||||
|
color: #1a2740;
|
||||||
|
alternate-background-color: #f4f7fa;
|
||||||
|
}
|
||||||
|
QListWidget::item,
|
||||||
|
QTreeWidget::item,
|
||||||
|
QTableWidget::item {
|
||||||
|
padding: 5px 5px;
|
||||||
|
}
|
||||||
|
QHeaderView::section {
|
||||||
|
background-color: #dce4ee;
|
||||||
|
border-right: 1px solid #c0cdda;
|
||||||
|
border-bottom: 1px solid #c0cdda;
|
||||||
|
padding: 5px 10px;
|
||||||
|
color: #4a6080;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Scroll Bar ---- */
|
||||||
|
QScrollBar:vertical {
|
||||||
|
background-color: #eef2f6;
|
||||||
|
width: 10px;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
QScrollBar::handle:vertical {
|
||||||
|
background-color: #a0b4cc;
|
||||||
|
min-height: 30px;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
QScrollBar::handle:vertical:hover {
|
||||||
|
background-color: #8098b8;
|
||||||
|
}
|
||||||
|
QScrollBar::add-line:vertical,
|
||||||
|
QScrollBar::sub-line:vertical {
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
QScrollBar:horizontal {
|
||||||
|
background-color: #eef2f6;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
QScrollBar::handle:horizontal {
|
||||||
|
background-color: #a0b4cc;
|
||||||
|
min-width: 30px;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
QScrollBar::handle:horizontal:hover {
|
||||||
|
background-color: #8098b8;
|
||||||
|
}
|
||||||
|
QScrollBar::add-line:horizontal,
|
||||||
|
QScrollBar::sub-line:horizontal {
|
||||||
|
width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Progress Bar ---- */
|
||||||
|
QProgressBar {
|
||||||
|
background-color: #ffffff;
|
||||||
|
border-style: solid;
|
||||||
|
border-color: #c0cdda;
|
||||||
|
border-width: 1px;
|
||||||
|
border-radius: 5px;
|
||||||
|
height: 10px;
|
||||||
|
text-align: center;
|
||||||
|
color: #1a2740;
|
||||||
|
}
|
||||||
|
QProgressBar::chunk {
|
||||||
|
background-color: #0ea58a;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Slider ---- */
|
||||||
|
QSlider::groove:horizontal {
|
||||||
|
background-color: #d5dde8;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
QSlider::handle:horizontal {
|
||||||
|
background-color: #0ea58a;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
margin: -5px 0;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
QSlider::sub-page:horizontal {
|
||||||
|
background-color: #0ea58a;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
QSlider::handle:horizontal:disabled {
|
||||||
|
background-color: #98a8c0;
|
||||||
|
}
|
||||||
|
QSlider::sub-page:horizontal:disabled {
|
||||||
|
background-color: #98a8c0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Tool Tip ---- */
|
||||||
|
QToolTip {
|
||||||
|
background-color: #d5dde8;
|
||||||
|
border-style: solid;
|
||||||
|
border-color: #0ea58a;
|
||||||
|
border-width: 1px;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
color: #1a2740;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Status Bar ---- */
|
||||||
|
QStatusBar {
|
||||||
|
background-color: #e8ecf2;
|
||||||
|
border-top: 1px solid #c0cdda;
|
||||||
|
color: #6a7898;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Splitter ---- */
|
||||||
|
QSplitter::handle {
|
||||||
|
background-color: #c0cdda;
|
||||||
|
margin: 1px;
|
||||||
|
}
|
||||||
|
QSplitter::handle:horizontal {
|
||||||
|
width: 2px;
|
||||||
|
}
|
||||||
|
QSplitter::handle:vertical {
|
||||||
|
height: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Dialog ---- */
|
||||||
|
QDialog {
|
||||||
|
background-color: #f0f4f8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Date / Time Editor Drop-down ---- */
|
||||||
|
QDateEdit::drop-down,
|
||||||
|
QTimeEdit::drop-down {
|
||||||
|
subcontrol-origin: padding;
|
||||||
|
subcontrol-position: top right;
|
||||||
|
width: 24px;
|
||||||
|
border-left: 1px solid #c0cdda;
|
||||||
|
}
|
||||||
|
QCalendarWidget {
|
||||||
|
background-color: #ffffff;
|
||||||
|
border-style: solid;
|
||||||
|
border-color: #c0cdda;
|
||||||
|
border-width: 1px;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
QCalendarWidget QToolButton {
|
||||||
|
color: #1a2740;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
}
|
||||||
|
QCalendarWidget QToolButton:hover {
|
||||||
|
background-color: #d5dde8;
|
||||||
|
}
|
||||||
|
QCalendarWidget QMenu {
|
||||||
|
background-color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Frame ---- */
|
||||||
|
QFrame[frameShape="4"], /* HLine */
|
||||||
|
QFrame[frameShape="5"] /* VLine */ {
|
||||||
|
background-color: #c0cdda;
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<ui version="4.0">
|
||||||
|
<class>ALAboutDialog</class>
|
||||||
|
<widget class="QDialog" name="ALAboutDialog">
|
||||||
|
<property name="geometry">
|
||||||
|
<rect>
|
||||||
|
<x>0</x>
|
||||||
|
<y>0</y>
|
||||||
|
<width>400</width>
|
||||||
|
<height>400</height>
|
||||||
|
</rect>
|
||||||
|
</property>
|
||||||
|
<property name="minimumSize">
|
||||||
|
<size>
|
||||||
|
<width>400</width>
|
||||||
|
<height>400</height>
|
||||||
|
</size>
|
||||||
|
</property>
|
||||||
|
<property name="maximumSize">
|
||||||
|
<size>
|
||||||
|
<width>800</width>
|
||||||
|
<height>600</height>
|
||||||
|
</size>
|
||||||
|
</property>
|
||||||
|
<property name="windowTitle">
|
||||||
|
<string>关于 - AutoLibrary</string>
|
||||||
|
</property>
|
||||||
|
<property name="sizeGripEnabled">
|
||||||
|
<bool>false</bool>
|
||||||
|
</property>
|
||||||
|
<layout class="QVBoxLayout" name="ALAboutDialogLayout">
|
||||||
|
<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="LogoLayout">
|
||||||
|
<property name="spacing">
|
||||||
|
<number>5</number>
|
||||||
|
</property>
|
||||||
|
<item>
|
||||||
|
<widget class="QLabel" name="LogoIconLabel">
|
||||||
|
<property name="minimumSize">
|
||||||
|
<size>
|
||||||
|
<width>56</width>
|
||||||
|
<height>56</height>
|
||||||
|
</size>
|
||||||
|
</property>
|
||||||
|
<property name="maximumSize">
|
||||||
|
<size>
|
||||||
|
<width>56</width>
|
||||||
|
<height>56</height>
|
||||||
|
</size>
|
||||||
|
</property>
|
||||||
|
<property name="text">
|
||||||
|
<string/>
|
||||||
|
</property>
|
||||||
|
<property name="scaledContents">
|
||||||
|
<bool>true</bool>
|
||||||
|
</property>
|
||||||
|
<property name="indent">
|
||||||
|
<number>0</number>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<widget class="QLabel" name="LogoTextLabel">
|
||||||
|
<property name="font">
|
||||||
|
<font>
|
||||||
|
<pointsize>24</pointsize>
|
||||||
|
<bold>true</bold>
|
||||||
|
</font>
|
||||||
|
</property>
|
||||||
|
<property name="lineWidth">
|
||||||
|
<number>0</number>
|
||||||
|
</property>
|
||||||
|
<property name="text">
|
||||||
|
<string>AutoLibrary</string>
|
||||||
|
</property>
|
||||||
|
<property name="margin">
|
||||||
|
<number>0</number>
|
||||||
|
</property>
|
||||||
|
<property name="indent">
|
||||||
|
<number>0</number>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
</layout>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<layout class="QHBoxLayout" name="AboutInfoLayout">
|
||||||
|
<property name="spacing">
|
||||||
|
<number>0</number>
|
||||||
|
</property>
|
||||||
|
</layout>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<widget class="QPushButton" name="CopyButton">
|
||||||
|
<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>
|
||||||
|
</layout>
|
||||||
|
</widget>
|
||||||
|
<resources/>
|
||||||
|
<connections/>
|
||||||
|
</ui>
|
||||||
@@ -34,13 +34,13 @@
|
|||||||
<number>5</number>
|
<number>5</number>
|
||||||
</property>
|
</property>
|
||||||
<property name="leftMargin">
|
<property name="leftMargin">
|
||||||
<number>3</number>
|
<number>5</number>
|
||||||
</property>
|
</property>
|
||||||
<property name="topMargin">
|
<property name="topMargin">
|
||||||
<number>0</number>
|
<number>0</number>
|
||||||
</property>
|
</property>
|
||||||
<property name="rightMargin">
|
<property name="rightMargin">
|
||||||
<number>3</number>
|
<number>5</number>
|
||||||
</property>
|
</property>
|
||||||
<property name="bottomMargin">
|
<property name="bottomMargin">
|
||||||
<number>0</number>
|
<number>0</number>
|
||||||
@@ -50,11 +50,33 @@
|
|||||||
<property name="spacing">
|
<property name="spacing">
|
||||||
<number>5</number>
|
<number>5</number>
|
||||||
</property>
|
</property>
|
||||||
|
<item>
|
||||||
|
<widget class="QPushButton" name="TimerTaskManageWidgetButton">
|
||||||
|
<property name="minimumSize">
|
||||||
|
<size>
|
||||||
|
<width>25</width>
|
||||||
|
<height>25</height>
|
||||||
|
</size>
|
||||||
|
</property>
|
||||||
|
<property name="maximumSize">
|
||||||
|
<size>
|
||||||
|
<width>25</width>
|
||||||
|
<height>25</height>
|
||||||
|
</size>
|
||||||
|
</property>
|
||||||
|
<property name="text">
|
||||||
|
<string/>
|
||||||
|
</property>
|
||||||
|
<property name="icon">
|
||||||
|
<iconset theme="document-open-recent"/>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
<item>
|
<item>
|
||||||
<widget class="QFrame" name="ControlSpaceFrame">
|
<widget class="QFrame" name="ControlSpaceFrame">
|
||||||
<property name="minimumSize">
|
<property name="minimumSize">
|
||||||
<size>
|
<size>
|
||||||
<width>1280</width>
|
<width>1000</width>
|
||||||
<height>0</height>
|
<height>0</height>
|
||||||
</size>
|
</size>
|
||||||
</property>
|
</property>
|
||||||
@@ -134,11 +156,9 @@
|
|||||||
<string><html><head/><body><p><br/></p></body></html></string>
|
<string><html><head/><body><p><br/></p></body></html></string>
|
||||||
</property>
|
</property>
|
||||||
<property name="styleSheet">
|
<property name="styleSheet">
|
||||||
<string notr="true">background-color: rgb(10, 170, 10);
|
<string notr="true">background-color: #0AAA0A;
|
||||||
font: 12pt "Microsoft YaHei UI";
|
color: #FFFFFF;
|
||||||
color: rgb(255, 255, 255);
|
font: 700 9pt;</string>
|
||||||
font: 9pt "Segoe UI";
|
|
||||||
font: 700 9pt "Microsoft YaHei UI";</string>
|
|
||||||
</property>
|
</property>
|
||||||
<property name="text">
|
<property name="text">
|
||||||
<string>启动脚本</string>
|
<string>启动脚本</string>
|
||||||
@@ -237,6 +257,9 @@ font: 700 9pt "Microsoft YaHei UI";</string>
|
|||||||
<property name="text">
|
<property name="text">
|
||||||
<string>发送</string>
|
<string>发送</string>
|
||||||
</property>
|
</property>
|
||||||
|
<property name="icon">
|
||||||
|
<iconset theme="document-send"/>
|
||||||
|
</property>
|
||||||
</widget>
|
</widget>
|
||||||
</item>
|
</item>
|
||||||
</layout>
|
</layout>
|
||||||
@@ -245,7 +268,7 @@ font: 700 9pt "Microsoft YaHei UI";</string>
|
|||||||
</widget>
|
</widget>
|
||||||
<widget class="QMenuBar" name="MenuBar">
|
<widget class="QMenuBar" name="MenuBar">
|
||||||
<property name="enabled">
|
<property name="enabled">
|
||||||
<bool>false</bool>
|
<bool>true</bool>
|
||||||
</property>
|
</property>
|
||||||
<property name="geometry">
|
<property name="geometry">
|
||||||
<rect>
|
<rect>
|
||||||
@@ -258,12 +281,45 @@ font: 700 9pt "Microsoft YaHei UI";</string>
|
|||||||
<property name="nativeMenuBar">
|
<property name="nativeMenuBar">
|
||||||
<bool>true</bool>
|
<bool>true</bool>
|
||||||
</property>
|
</property>
|
||||||
|
<widget class="QMenu" name="ToolsMenu">
|
||||||
|
<property name="title">
|
||||||
|
<string>工具</string>
|
||||||
|
</property>
|
||||||
|
<addaction name="SettingsAction"/>
|
||||||
|
</widget>
|
||||||
|
<widget class="QMenu" name="HelpMenu">
|
||||||
|
<property name="mouseTracking">
|
||||||
|
<bool>true</bool>
|
||||||
|
</property>
|
||||||
|
<property name="title">
|
||||||
|
<string>帮助</string>
|
||||||
|
</property>
|
||||||
|
<addaction name="ManualAction"/>
|
||||||
|
<addaction name="AboutAction"/>
|
||||||
|
</widget>
|
||||||
|
<addaction name="ToolsMenu"/>
|
||||||
|
<addaction name="HelpMenu"/>
|
||||||
</widget>
|
</widget>
|
||||||
<widget class="QStatusBar" name="StatusBar">
|
<widget class="QStatusBar" name="StatusBar">
|
||||||
<property name="enabled">
|
<property name="enabled">
|
||||||
<bool>false</bool>
|
<bool>false</bool>
|
||||||
</property>
|
</property>
|
||||||
</widget>
|
</widget>
|
||||||
|
<action name="ManualAction">
|
||||||
|
<property name="text">
|
||||||
|
<string>在线手册</string>
|
||||||
|
</property>
|
||||||
|
</action>
|
||||||
|
<action name="AboutAction">
|
||||||
|
<property name="text">
|
||||||
|
<string>关于</string>
|
||||||
|
</property>
|
||||||
|
</action>
|
||||||
|
<action name="SettingsAction">
|
||||||
|
<property name="text">
|
||||||
|
<string>全局设置</string>
|
||||||
|
</property>
|
||||||
|
</action>
|
||||||
</widget>
|
</widget>
|
||||||
<resources/>
|
<resources/>
|
||||||
<connections/>
|
<connections/>
|
||||||
@@ -0,0 +1,539 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<ui version="4.0">
|
||||||
|
<class>ALSettingsWidget</class>
|
||||||
|
<widget class="QWidget" name="ALSettingsWidget">
|
||||||
|
<property name="geometry">
|
||||||
|
<rect>
|
||||||
|
<x>0</x>
|
||||||
|
<y>0</y>
|
||||||
|
<width>520</width>
|
||||||
|
<height>420</height>
|
||||||
|
</rect>
|
||||||
|
</property>
|
||||||
|
<property name="minimumSize">
|
||||||
|
<size>
|
||||||
|
<width>480</width>
|
||||||
|
<height>420</height>
|
||||||
|
</size>
|
||||||
|
</property>
|
||||||
|
<property name="maximumSize">
|
||||||
|
<size>
|
||||||
|
<width>580</width>
|
||||||
|
<height>420</height>
|
||||||
|
</size>
|
||||||
|
</property>
|
||||||
|
<property name="windowTitle">
|
||||||
|
<string>全局设置 - AutoLibrary</string>
|
||||||
|
</property>
|
||||||
|
<layout class="QVBoxLayout" name="ALSettingsWidgetLayout">
|
||||||
|
<property name="spacing">
|
||||||
|
<number>0</number>
|
||||||
|
</property>
|
||||||
|
<property name="leftMargin">
|
||||||
|
<number>0</number>
|
||||||
|
</property>
|
||||||
|
<property name="topMargin">
|
||||||
|
<number>0</number>
|
||||||
|
</property>
|
||||||
|
<property name="rightMargin">
|
||||||
|
<number>0</number>
|
||||||
|
</property>
|
||||||
|
<property name="bottomMargin">
|
||||||
|
<number>0</number>
|
||||||
|
</property>
|
||||||
|
<item>
|
||||||
|
<layout class="QHBoxLayout" name="ContentLayout">
|
||||||
|
<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="QListWidget" name="NavigationList">
|
||||||
|
<property name="minimumSize">
|
||||||
|
<size>
|
||||||
|
<width>100</width>
|
||||||
|
<height>0</height>
|
||||||
|
</size>
|
||||||
|
</property>
|
||||||
|
<property name="maximumSize">
|
||||||
|
<size>
|
||||||
|
<width>100</width>
|
||||||
|
<height>16777215</height>
|
||||||
|
</size>
|
||||||
|
</property>
|
||||||
|
<property name="focusPolicy">
|
||||||
|
<enum>Qt::FocusPolicy::StrongFocus</enum>
|
||||||
|
</property>
|
||||||
|
<property name="frameShape">
|
||||||
|
<enum>QFrame::Shape::NoFrame</enum>
|
||||||
|
</property>
|
||||||
|
<property name="editTriggers">
|
||||||
|
<set>QAbstractItemView::EditTrigger::NoEditTriggers</set>
|
||||||
|
</property>
|
||||||
|
<property name="selectionMode">
|
||||||
|
<enum>QAbstractItemView::SelectionMode::SingleSelection</enum>
|
||||||
|
</property>
|
||||||
|
<property name="iconSize">
|
||||||
|
<size>
|
||||||
|
<width>20</width>
|
||||||
|
<height>20</height>
|
||||||
|
</size>
|
||||||
|
</property>
|
||||||
|
<property name="currentRow">
|
||||||
|
<number>0</number>
|
||||||
|
</property>
|
||||||
|
<item>
|
||||||
|
<property name="text">
|
||||||
|
<string>外观</string>
|
||||||
|
</property>
|
||||||
|
<property name="icon">
|
||||||
|
<iconset theme="preferences-desktop-color"/>
|
||||||
|
</property>
|
||||||
|
</item>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<widget class="QScrollArea" name="AppearanceScrollArea">
|
||||||
|
<property name="frameShape">
|
||||||
|
<enum>QFrame::Shape::NoFrame</enum>
|
||||||
|
</property>
|
||||||
|
<property name="widgetResizable">
|
||||||
|
<bool>true</bool>
|
||||||
|
</property>
|
||||||
|
<widget class="QWidget" name="AppearancePageContent">
|
||||||
|
<property name="geometry">
|
||||||
|
<rect>
|
||||||
|
<x>0</x>
|
||||||
|
<y>-51</y>
|
||||||
|
<width>397</width>
|
||||||
|
<height>434</height>
|
||||||
|
</rect>
|
||||||
|
</property>
|
||||||
|
<layout class="QVBoxLayout" name="AppearancePageLayout">
|
||||||
|
<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="QGroupBox" name="AppearanceGroupBox">
|
||||||
|
<property name="title">
|
||||||
|
<string>主题模式</string>
|
||||||
|
</property>
|
||||||
|
<layout class="QVBoxLayout" name="AppearanceGroupBoxLayout">
|
||||||
|
<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="QRadioButton" name="LightThemeRadio">
|
||||||
|
<property name="text">
|
||||||
|
<string>浅色</string>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<widget class="QRadioButton" name="DarkThemeRadio">
|
||||||
|
<property name="text">
|
||||||
|
<string>深色</string>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<widget class="QRadioButton" name="SystemThemeRadio">
|
||||||
|
<property name="text">
|
||||||
|
<string>跟随系统</string>
|
||||||
|
</property>
|
||||||
|
<property name="checked">
|
||||||
|
<bool>true</bool>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
</layout>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<widget class="QGroupBox" name="InterfaceGroupBox">
|
||||||
|
<property name="title">
|
||||||
|
<string>界面风格</string>
|
||||||
|
</property>
|
||||||
|
<layout class="QVBoxLayout" name="InterfaceGroupBoxLayout">
|
||||||
|
<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="StyleSelectLayout">
|
||||||
|
<property name="spacing">
|
||||||
|
<number>5</number>
|
||||||
|
</property>
|
||||||
|
<item>
|
||||||
|
<widget class="QLabel" name="StyleSelectLabel">
|
||||||
|
<property name="minimumSize">
|
||||||
|
<size>
|
||||||
|
<width>100</width>
|
||||||
|
<height>25</height>
|
||||||
|
</size>
|
||||||
|
</property>
|
||||||
|
<property name="maximumSize">
|
||||||
|
<size>
|
||||||
|
<width>100</width>
|
||||||
|
<height>25</height>
|
||||||
|
</size>
|
||||||
|
</property>
|
||||||
|
<property name="text">
|
||||||
|
<string>应用程序样式:</string>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<widget class="QComboBox" name="StyleComboBox">
|
||||||
|
<property name="minimumSize">
|
||||||
|
<size>
|
||||||
|
<width>160</width>
|
||||||
|
<height>25</height>
|
||||||
|
</size>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
</layout>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<widget class="QLabel" name="StyleHintLabel">
|
||||||
|
<property name="text">
|
||||||
|
<string>更改样式将在下次启动应用程序时生效。</string>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
</layout>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<widget class="QGroupBox" name="CustomThemeGroupBox">
|
||||||
|
<property name="title">
|
||||||
|
<string>自定义外观</string>
|
||||||
|
</property>
|
||||||
|
<layout class="QVBoxLayout" name="CustomQssGroupBoxLayout">
|
||||||
|
<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="QLabel" name="CustomThemeHintLabel">
|
||||||
|
<property name="text">
|
||||||
|
<string>选择一个主题,或导入新的主题文件:</string>
|
||||||
|
</property>
|
||||||
|
<property name="wordWrap">
|
||||||
|
<bool>true</bool>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<layout class="QHBoxLayout" name="CustomThemePathLayout">
|
||||||
|
<property name="spacing">
|
||||||
|
<number>5</number>
|
||||||
|
</property>
|
||||||
|
<item>
|
||||||
|
<widget class="QComboBox" name="CustomThemeComboBox">
|
||||||
|
<property name="minimumSize">
|
||||||
|
<size>
|
||||||
|
<width>160</width>
|
||||||
|
<height>25</height>
|
||||||
|
</size>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<widget class="QLineEdit" name="CustomThemePathEdit">
|
||||||
|
<property name="minimumSize">
|
||||||
|
<size>
|
||||||
|
<width>0</width>
|
||||||
|
<height>25</height>
|
||||||
|
</size>
|
||||||
|
</property>
|
||||||
|
<property name="visible">
|
||||||
|
<bool>false</bool>
|
||||||
|
</property>
|
||||||
|
<property name="placeholderText">
|
||||||
|
<string>选择或输入 QSS 样式表文件路径...</string>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<widget class="QPushButton" name="ImportCustomThemeButton">
|
||||||
|
<property name="minimumSize">
|
||||||
|
<size>
|
||||||
|
<width>25</width>
|
||||||
|
<height>25</height>
|
||||||
|
</size>
|
||||||
|
</property>
|
||||||
|
<property name="maximumSize">
|
||||||
|
<size>
|
||||||
|
<width>25</width>
|
||||||
|
<height>25</height>
|
||||||
|
</size>
|
||||||
|
</property>
|
||||||
|
<property name="text">
|
||||||
|
<string>+</string>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<widget class="QPushButton" name="RemoveCustomThemeButton">
|
||||||
|
<property name="minimumSize">
|
||||||
|
<size>
|
||||||
|
<width>25</width>
|
||||||
|
<height>25</height>
|
||||||
|
</size>
|
||||||
|
</property>
|
||||||
|
<property name="maximumSize">
|
||||||
|
<size>
|
||||||
|
<width>25</width>
|
||||||
|
<height>25</height>
|
||||||
|
</size>
|
||||||
|
</property>
|
||||||
|
<property name="text">
|
||||||
|
<string>-</string>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
</layout>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<widget class="QLabel" name="CustomThemeInfoLabel">
|
||||||
|
<property name="minimumSize">
|
||||||
|
<size>
|
||||||
|
<width>0</width>
|
||||||
|
<height>60</height>
|
||||||
|
</size>
|
||||||
|
</property>
|
||||||
|
<property name="text">
|
||||||
|
<string/>
|
||||||
|
</property>
|
||||||
|
<property name="textFormat">
|
||||||
|
<enum>Qt::TextFormat::RichText</enum>
|
||||||
|
</property>
|
||||||
|
<property name="alignment">
|
||||||
|
<set>Qt::AlignmentFlag::AlignLeading|Qt::AlignmentFlag::AlignLeft|Qt::AlignmentFlag::AlignTop</set>
|
||||||
|
</property>
|
||||||
|
<property name="wordWrap">
|
||||||
|
<bool>true</bool>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<layout class="QHBoxLayout" name="CustomThemeActionLayout">
|
||||||
|
<property name="spacing">
|
||||||
|
<number>5</number>
|
||||||
|
</property>
|
||||||
|
<item>
|
||||||
|
<widget class="QPushButton" name="ResetCustomThemeButton">
|
||||||
|
<property name="minimumSize">
|
||||||
|
<size>
|
||||||
|
<width>80</width>
|
||||||
|
<height>25</height>
|
||||||
|
</size>
|
||||||
|
</property>
|
||||||
|
<property name="text">
|
||||||
|
<string>重置主题</string>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<spacer name="CustomThemeActionSpacer">
|
||||||
|
<property name="orientation">
|
||||||
|
<enum>Qt::Orientation::Horizontal</enum>
|
||||||
|
</property>
|
||||||
|
<property name="sizeHint" stdset="0">
|
||||||
|
<size>
|
||||||
|
<width>40</width>
|
||||||
|
<height>20</height>
|
||||||
|
</size>
|
||||||
|
</property>
|
||||||
|
</spacer>
|
||||||
|
</item>
|
||||||
|
</layout>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<widget class="QLabel" name="CustomThemeStatusLabel">
|
||||||
|
<property name="text">
|
||||||
|
<string>当前使用程序 默认 外观。</string>
|
||||||
|
</property>
|
||||||
|
<property name="wordWrap">
|
||||||
|
<bool>true</bool>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
</layout>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<spacer name="AppearancePageSpacer">
|
||||||
|
<property name="orientation">
|
||||||
|
<enum>Qt::Orientation::Vertical</enum>
|
||||||
|
</property>
|
||||||
|
<property name="sizeHint" stdset="0">
|
||||||
|
<size>
|
||||||
|
<width>20</width>
|
||||||
|
<height>40</height>
|
||||||
|
</size>
|
||||||
|
</property>
|
||||||
|
</spacer>
|
||||||
|
</item>
|
||||||
|
</layout>
|
||||||
|
</widget>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
</layout>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<layout class="QHBoxLayout" name="ButtonLayout">
|
||||||
|
<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>
|
||||||
|
<spacer name="ButtonLeftSpacer">
|
||||||
|
<property name="orientation">
|
||||||
|
<enum>Qt::Orientation::Horizontal</enum>
|
||||||
|
</property>
|
||||||
|
<property name="sizeHint" stdset="0">
|
||||||
|
<size>
|
||||||
|
<width>40</width>
|
||||||
|
<height>20</height>
|
||||||
|
</size>
|
||||||
|
</property>
|
||||||
|
</spacer>
|
||||||
|
</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="ApplyButton">
|
||||||
|
<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="default">
|
||||||
|
<bool>true</bool>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
</layout>
|
||||||
|
</item>
|
||||||
|
</layout>
|
||||||
|
</widget>
|
||||||
|
<resources/>
|
||||||
|
<connections/>
|
||||||
|
</ui>
|
||||||
@@ -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>500</height>
|
||||||
|
</rect>
|
||||||
|
</property>
|
||||||
|
<property name="minimumSize">
|
||||||
|
<size>
|
||||||
|
<width>350</width>
|
||||||
|
<height>500</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>
|
||||||
@@ -0,0 +1,363 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<ui version="4.0">
|
||||||
|
<class>ALTimerTaskManageWidget</class>
|
||||||
|
<widget class="QWidget" name="ALTimerTaskManageWidget">
|
||||||
|
<property name="geometry">
|
||||||
|
<rect>
|
||||||
|
<x>0</x>
|
||||||
|
<y>0</y>
|
||||||
|
<width>500</width>
|
||||||
|
<height>400</height>
|
||||||
|
</rect>
|
||||||
|
</property>
|
||||||
|
<property name="minimumSize">
|
||||||
|
<size>
|
||||||
|
<width>500</width>
|
||||||
|
<height>400</height>
|
||||||
|
</size>
|
||||||
|
</property>
|
||||||
|
<property name="maximumSize">
|
||||||
|
<size>
|
||||||
|
<width>800</width>
|
||||||
|
<height>400</height>
|
||||||
|
</size>
|
||||||
|
</property>
|
||||||
|
<property name="windowTitle">
|
||||||
|
<string>定时任务管理 - AutoLibrary</string>
|
||||||
|
</property>
|
||||||
|
<layout class="QVBoxLayout" name="ALTimerTaskManageWidgetLayout">
|
||||||
|
<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="TimerTaskStatusLayout">
|
||||||
|
<property name="spacing">
|
||||||
|
<number>5</number>
|
||||||
|
</property>
|
||||||
|
<item>
|
||||||
|
<widget class="QLabel" name="TotalTaskLabel">
|
||||||
|
<property name="minimumSize">
|
||||||
|
<size>
|
||||||
|
<width>70</width>
|
||||||
|
<height>25</height>
|
||||||
|
</size>
|
||||||
|
</property>
|
||||||
|
<property name="maximumSize">
|
||||||
|
<size>
|
||||||
|
<width>70</width>
|
||||||
|
<height>25</height>
|
||||||
|
</size>
|
||||||
|
</property>
|
||||||
|
<property name="text">
|
||||||
|
<string>总任务:0</string>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<widget class="QLabel" name="PendingTaskLabel">
|
||||||
|
<property name="minimumSize">
|
||||||
|
<size>
|
||||||
|
<width>70</width>
|
||||||
|
<height>25</height>
|
||||||
|
</size>
|
||||||
|
</property>
|
||||||
|
<property name="maximumSize">
|
||||||
|
<size>
|
||||||
|
<width>70</width>
|
||||||
|
<height>25</height>
|
||||||
|
</size>
|
||||||
|
</property>
|
||||||
|
<property name="styleSheet">
|
||||||
|
<string notr="true">QLabel {
|
||||||
|
color: #FF9800
|
||||||
|
}</string>
|
||||||
|
</property>
|
||||||
|
<property name="text">
|
||||||
|
<string>待执行:0</string>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<widget class="QLabel" name="InQueueTaskLabel">
|
||||||
|
<property name="minimumSize">
|
||||||
|
<size>
|
||||||
|
<width>70</width>
|
||||||
|
<height>25</height>
|
||||||
|
</size>
|
||||||
|
</property>
|
||||||
|
<property name="maximumSize">
|
||||||
|
<size>
|
||||||
|
<width>70</width>
|
||||||
|
<height>25</height>
|
||||||
|
</size>
|
||||||
|
</property>
|
||||||
|
<property name="styleSheet">
|
||||||
|
<string notr="true">QLabel {
|
||||||
|
color: #2294FF
|
||||||
|
}</string>
|
||||||
|
</property>
|
||||||
|
<property name="text">
|
||||||
|
<string>队列中:0</string>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<widget class="QLabel" name="ExecutedTaskLabel">
|
||||||
|
<property name="minimumSize">
|
||||||
|
<size>
|
||||||
|
<width>70</width>
|
||||||
|
<height>25</height>
|
||||||
|
</size>
|
||||||
|
</property>
|
||||||
|
<property name="maximumSize">
|
||||||
|
<size>
|
||||||
|
<width>70</width>
|
||||||
|
<height>25</height>
|
||||||
|
</size>
|
||||||
|
</property>
|
||||||
|
<property name="styleSheet">
|
||||||
|
<string notr="true">QLabel {
|
||||||
|
color: #4CAF50
|
||||||
|
}</string>
|
||||||
|
</property>
|
||||||
|
<property name="text">
|
||||||
|
<string>已执行:0</string>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<widget class="QLabel" name="InvalidTaskLabel">
|
||||||
|
<property name="minimumSize">
|
||||||
|
<size>
|
||||||
|
<width>70</width>
|
||||||
|
<height>25</height>
|
||||||
|
</size>
|
||||||
|
</property>
|
||||||
|
<property name="maximumSize">
|
||||||
|
<size>
|
||||||
|
<width>25</width>
|
||||||
|
<height>70</height>
|
||||||
|
</size>
|
||||||
|
</property>
|
||||||
|
<property name="styleSheet">
|
||||||
|
<string notr="true">QLabel {
|
||||||
|
color: #DC0000
|
||||||
|
}</string>
|
||||||
|
</property>
|
||||||
|
<property name="text">
|
||||||
|
<string>无效的:0</string>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<widget class="QFrame" name="TimerTaskSpaceFrame">
|
||||||
|
<property name="minimumSize">
|
||||||
|
<size>
|
||||||
|
<width>0</width>
|
||||||
|
<height>0</height>
|
||||||
|
</size>
|
||||||
|
</property>
|
||||||
|
<property name="maximumSize">
|
||||||
|
<size>
|
||||||
|
<width>600</width>
|
||||||
|
<height>16777215</height>
|
||||||
|
</size>
|
||||||
|
</property>
|
||||||
|
<property name="frameShape">
|
||||||
|
<enum>QFrame::Shape::NoFrame</enum>
|
||||||
|
</property>
|
||||||
|
<property name="frameShadow">
|
||||||
|
<enum>QFrame::Shadow::Plain</enum>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
</layout>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<layout class="QHBoxLayout" name="TimerTaskSortLayout">
|
||||||
|
<property name="spacing">
|
||||||
|
<number>0</number>
|
||||||
|
</property>
|
||||||
|
<item>
|
||||||
|
<widget class="QFrame" name="TimerTaskSortSpaceFrame">
|
||||||
|
<property name="maximumSize">
|
||||||
|
<size>
|
||||||
|
<width>16777215</width>
|
||||||
|
<height>16777215</height>
|
||||||
|
</size>
|
||||||
|
</property>
|
||||||
|
<property name="frameShape">
|
||||||
|
<enum>QFrame::Shape::NoFrame</enum>
|
||||||
|
</property>
|
||||||
|
<property name="frameShadow">
|
||||||
|
<enum>QFrame::Shadow::Plain</enum>
|
||||||
|
</property>
|
||||||
|
<property name="lineWidth">
|
||||||
|
<number>0</number>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<widget class="QLabel" name="TimerTaskSortLabel">
|
||||||
|
<property name="minimumSize">
|
||||||
|
<size>
|
||||||
|
<width>40</width>
|
||||||
|
<height>25</height>
|
||||||
|
</size>
|
||||||
|
</property>
|
||||||
|
<property name="maximumSize">
|
||||||
|
<size>
|
||||||
|
<width>40</width>
|
||||||
|
<height>25</height>
|
||||||
|
</size>
|
||||||
|
</property>
|
||||||
|
<property name="text">
|
||||||
|
<string>排序:</string>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<widget class="QComboBox" name="TimerTaskSortTypeComboBox">
|
||||||
|
<property name="minimumSize">
|
||||||
|
<size>
|
||||||
|
<width>90</width>
|
||||||
|
<height>25</height>
|
||||||
|
</size>
|
||||||
|
</property>
|
||||||
|
<property name="maximumSize">
|
||||||
|
<size>
|
||||||
|
<width>90</width>
|
||||||
|
<height>25</height>
|
||||||
|
</size>
|
||||||
|
</property>
|
||||||
|
<item>
|
||||||
|
<property name="text">
|
||||||
|
<string>按名称</string>
|
||||||
|
</property>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<property name="text">
|
||||||
|
<string>按添加时间</string>
|
||||||
|
</property>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<property name="text">
|
||||||
|
<string>按执行时间</string>
|
||||||
|
</property>
|
||||||
|
</item>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<widget class="QPushButton" name="TimerTaskSortOrderToggleButton">
|
||||||
|
<property name="minimumSize">
|
||||||
|
<size>
|
||||||
|
<width>25</width>
|
||||||
|
<height>25</height>
|
||||||
|
</size>
|
||||||
|
</property>
|
||||||
|
<property name="maximumSize">
|
||||||
|
<size>
|
||||||
|
<width>25</width>
|
||||||
|
<height>25</height>
|
||||||
|
</size>
|
||||||
|
</property>
|
||||||
|
<property name="text">
|
||||||
|
<string>↑</string>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
</layout>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<widget class="QListWidget" name="TimerTasksListWidget">
|
||||||
|
<property name="enabled">
|
||||||
|
<bool>true</bool>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<layout class="QHBoxLayout" name="TimerTaskEditLayout">
|
||||||
|
<property name="spacing">
|
||||||
|
<number>5</number>
|
||||||
|
</property>
|
||||||
|
<item>
|
||||||
|
<widget class="QPushButton" name="ClearAllTimerTasksButton">
|
||||||
|
<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="styleSheet">
|
||||||
|
<string notr="true">QPushButton {
|
||||||
|
color: #DC0000;
|
||||||
|
}</string>
|
||||||
|
</property>
|
||||||
|
<property name="text">
|
||||||
|
<string>清除全部</string>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<widget class="QPushButton" name="AddTimerTaskButton">
|
||||||
|
<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="QFrame" name="TimerTaskEditSpaceFrame">
|
||||||
|
<property name="maximumSize">
|
||||||
|
<size>
|
||||||
|
<width>16777215</width>
|
||||||
|
<height>16777215</height>
|
||||||
|
</size>
|
||||||
|
</property>
|
||||||
|
<property name="frameShape">
|
||||||
|
<enum>QFrame::Shape::NoFrame</enum>
|
||||||
|
</property>
|
||||||
|
<property name="frameShadow">
|
||||||
|
<enum>QFrame::Shadow::Plain</enum>
|
||||||
|
</property>
|
||||||
|
<property name="lineWidth">
|
||||||
|
<number>0</number>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
</layout>
|
||||||
|
</item>
|
||||||
|
</layout>
|
||||||
|
</widget>
|
||||||
|
<resources/>
|
||||||
|
<connections/>
|
||||||
|
</ui>
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
Copyright (c) 2026 KenanZhu.
|
||||||
|
All rights reserved.
|
||||||
|
|
||||||
|
This software is provided "as is", without any warranty of any kind.
|
||||||
|
You may use, modify, and distribute this file under the terms of the MIT License.
|
||||||
|
See the LICENSE file for details.
|
||||||
|
"""
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Any, Optional, Protocol
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigType(Enum):
|
||||||
|
"""
|
||||||
|
Config type enum. Values represent the default filename.
|
||||||
|
"""
|
||||||
|
|
||||||
|
GLOBAL = "autolibrary.json"
|
||||||
|
BULLETIN = "bulletin.json"
|
||||||
|
TIMERTASK = "timer_task.json"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ConfigPath:
|
||||||
|
"""
|
||||||
|
A typed configuration path that carries both the config file
|
||||||
|
and the dot-separated key in a single object.
|
||||||
|
|
||||||
|
Consumers pass this directly to ConfigProvider.get/set,
|
||||||
|
eliminating the need to import ConfigType separately.
|
||||||
|
"""
|
||||||
|
|
||||||
|
config_type: ConfigType
|
||||||
|
key: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class CfgKey:
|
||||||
|
"""
|
||||||
|
Type-safe hierarchical configuration key constants.
|
||||||
|
|
||||||
|
Each leaf is a ConfigPath that can be passed directly to
|
||||||
|
``ConfigProvider.get()`` or ``ConfigProvider.set()``.
|
||||||
|
|
||||||
|
Usage::
|
||||||
|
|
||||||
|
CfgKey.GLOBAL.AUTOMATION.RUN_PATH.PATHS
|
||||||
|
# -> ConfigPath(ConfigType.GLOBAL, "automation.run_path.paths")
|
||||||
|
|
||||||
|
config.get(CfgKey.GLOBAL.AUTOMATION.RUN_PATH.PATHS, [])
|
||||||
|
config.set(CfgKey.GLOBAL.AUTOMATION.RUN_PATH.PATHS, value)
|
||||||
|
"""
|
||||||
|
|
||||||
|
class GLOBAL:
|
||||||
|
class AUTOMATION:
|
||||||
|
ROOT = ConfigPath(ConfigType.GLOBAL, "automation")
|
||||||
|
|
||||||
|
class RUN_PATH:
|
||||||
|
ROOT = ConfigPath(ConfigType.GLOBAL, "automation.run_path")
|
||||||
|
CURRENT = ConfigPath(ConfigType.GLOBAL, "automation.run_path.current")
|
||||||
|
PATHS = ConfigPath(ConfigType.GLOBAL, "automation.run_path.paths")
|
||||||
|
|
||||||
|
class USER_PATH:
|
||||||
|
ROOT = ConfigPath(ConfigType.GLOBAL, "automation.user_path")
|
||||||
|
CURRENT = ConfigPath(ConfigType.GLOBAL, "automation.user_path.current")
|
||||||
|
PATHS = ConfigPath(ConfigType.GLOBAL, "automation.user_path.paths")
|
||||||
|
|
||||||
|
class APPEARANCE:
|
||||||
|
ROOT = ConfigPath(ConfigType.GLOBAL, "appearance")
|
||||||
|
THEME = ConfigPath(ConfigType.GLOBAL, "appearance.theme")
|
||||||
|
STYLE = ConfigPath(ConfigType.GLOBAL, "appearance.style")
|
||||||
|
CUSTOM_THEME = ConfigPath(ConfigType.GLOBAL, "appearance.custom_theme")
|
||||||
|
|
||||||
|
class TIMERTASK:
|
||||||
|
ROOT = ConfigPath(ConfigType.TIMERTASK, "")
|
||||||
|
TIMER_TASKS = ConfigPath(ConfigType.TIMERTASK, "timer_tasks")
|
||||||
|
|
||||||
|
class BULLETIN:
|
||||||
|
ROOT = ConfigPath(ConfigType.BULLETIN, "")
|
||||||
|
BULLETIN = ConfigPath(ConfigType.BULLETIN, "bulletin")
|
||||||
|
LAST_SYNC_TIME = ConfigPath(ConfigType.BULLETIN, "last_sync_time")
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigProvider(Protocol):
|
||||||
|
"""
|
||||||
|
Abstract interface for configuration storage access.
|
||||||
|
|
||||||
|
Concrete implementations (e.g. ConfigManager) conform to
|
||||||
|
this protocol structurally rather than through explicit
|
||||||
|
inheritance.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def get(
|
||||||
|
self,
|
||||||
|
key: ConfigPath,
|
||||||
|
default: Optional[Any] = None
|
||||||
|
) -> Any:
|
||||||
|
"""
|
||||||
|
Retrieve a configuration value.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key: A ConfigPath object specifying which config file
|
||||||
|
and key to read from.
|
||||||
|
default: Fallback value if the key is not found.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The configuration value at the given key path.
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
def set(
|
||||||
|
self,
|
||||||
|
key: ConfigPath,
|
||||||
|
value: Any = None
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Set a configuration value and persist to disk.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key: A ConfigPath object specifying which config file
|
||||||
|
and key to write to.
|
||||||
|
value: The value to store.
|
||||||
|
"""
|
||||||
|
...
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
Copyright (c) 2026 KenanZhu.
|
||||||
|
All rights reserved.
|
||||||
|
|
||||||
|
This software is provided "as is", without any warranty of any kind.
|
||||||
|
You may use, modify, and distribute this file under the terms of the MIT License.
|
||||||
|
See the LICENSE file for details.
|
||||||
|
"""
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
Copyright (c) 2026 KenanZhu.
|
||||||
|
All rights reserved.
|
||||||
|
|
||||||
|
This software is provided "as is", without any warranty of any kind.
|
||||||
|
You may use, modify, and distribute this file under the terms of the MIT License.
|
||||||
|
See the LICENSE file for details.
|
||||||
|
"""
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
Copyright (c) 2026 KenanZhu.
|
||||||
|
All rights reserved.
|
||||||
|
|
||||||
|
This software is provided "as is", without any warranty of any kind.
|
||||||
|
You may use, modify, and distribute this file under the terms of the MIT License.
|
||||||
|
See the LICENSE file for details.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import threading
|
||||||
|
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
from utils.JSONReader import JSONReader
|
||||||
|
from utils.JSONWriter import JSONWriter
|
||||||
|
from interfaces.ConfigProvider import ConfigType, ConfigPath
|
||||||
|
|
||||||
|
|
||||||
|
# This config manager class only responsible for global and other
|
||||||
|
# unconfigurable config files.
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigTemplate:
|
||||||
|
"""
|
||||||
|
Config template class.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
config_type: ConfigType
|
||||||
|
):
|
||||||
|
|
||||||
|
self.__config_type = config_type
|
||||||
|
|
||||||
|
def template(
|
||||||
|
self
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
Get config template.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: Config template.
|
||||||
|
"""
|
||||||
|
match self.__config_type:
|
||||||
|
case ConfigType.GLOBAL:
|
||||||
|
return {
|
||||||
|
"automation": {
|
||||||
|
"run_path": {
|
||||||
|
"current": 0,
|
||||||
|
"paths": []
|
||||||
|
},
|
||||||
|
"user_path": {
|
||||||
|
"current": 0,
|
||||||
|
"paths": []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"appearance": {
|
||||||
|
"theme": "system",
|
||||||
|
"style": "Fusion",
|
||||||
|
"custom_theme": ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case ConfigType.BULLETIN:
|
||||||
|
return {
|
||||||
|
"bulletin": [],
|
||||||
|
"last_sync_time": None
|
||||||
|
}
|
||||||
|
case ConfigType.TIMERTASK:
|
||||||
|
return {
|
||||||
|
"timer_tasks": []
|
||||||
|
}
|
||||||
|
case _:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigManager:
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
config_dir: str
|
||||||
|
):
|
||||||
|
|
||||||
|
self.__config_dir = os.path.abspath(config_dir)
|
||||||
|
self.__config_lock = threading.Lock()
|
||||||
|
self.__config_data = {}
|
||||||
|
|
||||||
|
self.initialize()
|
||||||
|
|
||||||
|
def initialize(
|
||||||
|
self
|
||||||
|
):
|
||||||
|
|
||||||
|
for config_type in ConfigType:
|
||||||
|
self.load(config_type)
|
||||||
|
|
||||||
|
def load(
|
||||||
|
self,
|
||||||
|
config_type: ConfigType
|
||||||
|
):
|
||||||
|
|
||||||
|
config_path = os.path.join(self.__config_dir, config_type.value)
|
||||||
|
if os.path.exists(config_path):
|
||||||
|
try:
|
||||||
|
config_data = JSONReader(config_path).data()
|
||||||
|
self.__config_data[config_type.value] = config_data
|
||||||
|
return
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
self.__config_data[config_type.value] = ConfigTemplate(config_type).template()
|
||||||
|
JSONWriter(config_path, self.__config_data[config_type.value])
|
||||||
|
|
||||||
|
def get(
|
||||||
|
self,
|
||||||
|
key: ConfigPath,
|
||||||
|
default: Optional[Any] = None
|
||||||
|
) -> Any:
|
||||||
|
|
||||||
|
with self.__config_lock:
|
||||||
|
config_data = self.__config_data[key.config_type.value]
|
||||||
|
if key.key == "":
|
||||||
|
return config_data
|
||||||
|
keys = key.key.split('.')
|
||||||
|
for k in keys[:-1]:
|
||||||
|
config_data = config_data.get(k, None)
|
||||||
|
if config_data is None:
|
||||||
|
return default
|
||||||
|
return config_data.get(keys[-1], default)
|
||||||
|
|
||||||
|
def set(
|
||||||
|
self,
|
||||||
|
key: ConfigPath,
|
||||||
|
value: Any = None
|
||||||
|
):
|
||||||
|
|
||||||
|
with self.__config_lock:
|
||||||
|
root_data = self.__config_data[key.config_type.value]
|
||||||
|
if key.key == "":
|
||||||
|
self.__config_data[key.config_type.value] = value
|
||||||
|
else:
|
||||||
|
keys = key.key.split('.')
|
||||||
|
config_data = root_data
|
||||||
|
for k in keys[:-1]:
|
||||||
|
if k not in config_data:
|
||||||
|
config_data[k] = {}
|
||||||
|
config_data = config_data[k]
|
||||||
|
config_data[keys[-1]] = value
|
||||||
|
self.save(key.config_type)
|
||||||
|
|
||||||
|
def save(
|
||||||
|
self,
|
||||||
|
config_type: ConfigType
|
||||||
|
):
|
||||||
|
|
||||||
|
config_path = os.path.join(self.__config_dir, config_type.value)
|
||||||
|
JSONWriter(config_path, self.__config_data[config_type.value])
|
||||||
|
|
||||||
|
def configDir(
|
||||||
|
self
|
||||||
|
) -> str:
|
||||||
|
|
||||||
|
return self.__config_dir
|
||||||
|
|
||||||
|
|
||||||
|
# ConfigManager singleton instance.
|
||||||
|
_config_manager_instance : ConfigManager | None = None
|
||||||
|
|
||||||
|
# Singleton instance of ConfigManager.
|
||||||
|
_instance_lock = threading.Lock()
|
||||||
|
|
||||||
|
def instance(
|
||||||
|
config_dir: str = ""
|
||||||
|
) -> ConfigManager:
|
||||||
|
"""
|
||||||
|
Initialize ConfigManager singleton instance.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
config_dir (str): Config directory.
|
||||||
|
"""
|
||||||
|
global _config_manager_instance
|
||||||
|
with _instance_lock:
|
||||||
|
if _config_manager_instance is None:
|
||||||
|
if not config_dir:
|
||||||
|
raise ValueError("ConfigManager 需要配置目录参数")
|
||||||
|
_config_manager_instance = ConfigManager(config_dir)
|
||||||
|
else:
|
||||||
|
if config_dir == "":
|
||||||
|
return _config_manager_instance
|
||||||
|
if _config_manager_instance.configDir() != config_dir:
|
||||||
|
raise ValueError("ConfigManager 的实例已初始化,不能使用不同的配置目录。")
|
||||||
|
return _config_manager_instance
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
Copyright (c) 2026 KenanZhu.
|
||||||
|
All rights reserved.
|
||||||
|
|
||||||
|
This software is provided "as is", without any warranty of any kind.
|
||||||
|
You may use, modify, and distribute this file under the terms of the MIT License.
|
||||||
|
See the LICENSE file for details.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
|
||||||
|
import managers.config.ConfigManager as ConfigManager
|
||||||
|
|
||||||
|
from interfaces.ConfigProvider import CfgKey
|
||||||
|
|
||||||
|
class ConfigUtils:
|
||||||
|
"""
|
||||||
|
Config utilities class.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def getAutomationConfigPaths(
|
||||||
|
) -> dict[str]:
|
||||||
|
"""
|
||||||
|
Get validated automation config paths from ConfigManager instance.
|
||||||
|
These function will validate the config paths and return the validated paths in a dict.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict[str]: Validated automation config paths (include user and run config paths).
|
||||||
|
"""
|
||||||
|
cfg_mgr = ConfigManager.instance() # config manager instance
|
||||||
|
|
||||||
|
config_paths = {"run": "", "user": ""}
|
||||||
|
auto_config = cfg_mgr.get(CfgKey.GLOBAL.AUTOMATION.ROOT, {})
|
||||||
|
for cfg_type in ["run", "user"]:
|
||||||
|
paths = auto_config.get(f"{cfg_type}_path", {}).get("paths", [])
|
||||||
|
index = auto_config.get(f"{cfg_type}_path", {}).get("current", 0)
|
||||||
|
if paths == []:
|
||||||
|
paths.append(os.path.join(cfg_mgr.configDir(), f"{cfg_type}.json"))
|
||||||
|
if index < 0:
|
||||||
|
index = 0
|
||||||
|
if index >= len(paths):
|
||||||
|
index = len(paths) - 1
|
||||||
|
config_paths[cfg_type] = paths[index]
|
||||||
|
data = {"current": index, "paths": paths}
|
||||||
|
auto_config[f"{cfg_type}_path"] = data
|
||||||
|
cfg_mgr.set(CfgKey.GLOBAL.AUTOMATION.ROOT, auto_config)
|
||||||
|
return config_paths
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
Copyright (c) 2026 KenanZhu.
|
||||||
|
All rights reserved.
|
||||||
|
|
||||||
|
This software is provided "as is", without any warranty of any kind.
|
||||||
|
You may use, modify, and distribute this file under the terms of the MIT License.
|
||||||
|
See the LICENSE file for details.
|
||||||
|
"""
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
Copyright (c) 2026 KenanZhu.
|
||||||
|
All rights reserved.
|
||||||
|
|
||||||
|
This software is provided "as is", without any warranty of any kind.
|
||||||
|
You may use, modify, and distribute this file under the terms of the MIT License.
|
||||||
|
See the LICENSE file for details.
|
||||||
|
"""
|
||||||
|
import platform
|
||||||
|
import browsers
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from enum import Enum
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
|
class WebBrowserType(Enum):
|
||||||
|
"""
|
||||||
|
Web browser type
|
||||||
|
"""
|
||||||
|
|
||||||
|
CHROME = "chrome"
|
||||||
|
FIREFOX = "firefox"
|
||||||
|
EDGE = "edge"
|
||||||
|
|
||||||
|
|
||||||
|
class WebBrowserArch(Enum):
|
||||||
|
"""
|
||||||
|
Web browser architecture
|
||||||
|
"""
|
||||||
|
|
||||||
|
WINX86_32 = 0
|
||||||
|
WINX86_64 = 1
|
||||||
|
WINARM = 2
|
||||||
|
|
||||||
|
LINUXX86_32 = 3
|
||||||
|
LINUXX86_64 = 4
|
||||||
|
LINUXARM = 5
|
||||||
|
|
||||||
|
MACX86_64 = 6
|
||||||
|
MACARM = 7
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class WebBrowserInfo:
|
||||||
|
"""
|
||||||
|
Web browser information
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
browser_arch (WebBrowserArch): Web browser architecture
|
||||||
|
browser_type (WebBrowserType): Web browser type
|
||||||
|
browser_version (str): Web browser version
|
||||||
|
browser_path (Path): Web browser executable file path
|
||||||
|
"""
|
||||||
|
|
||||||
|
browser_arch: WebBrowserArch
|
||||||
|
browser_type: WebBrowserType
|
||||||
|
browser_version: str
|
||||||
|
browser_path: Path
|
||||||
|
|
||||||
|
|
||||||
|
class WebBrowserArchDetector:
|
||||||
|
"""
|
||||||
|
Web browser architecture detector
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self
|
||||||
|
):
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
def detect(
|
||||||
|
self
|
||||||
|
) -> WebBrowserArch:
|
||||||
|
"""
|
||||||
|
Detect system architecture
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
WebBrowserArch: System architecture
|
||||||
|
"""
|
||||||
|
|
||||||
|
system = platform.system()
|
||||||
|
machine = platform.machine().lower()
|
||||||
|
if system == "Windows":
|
||||||
|
if machine in ["amd64", "x86_64"]:
|
||||||
|
return WebBrowserArch.WINX86_64
|
||||||
|
elif machine in ["i386", "i686", "x86"]:
|
||||||
|
return WebBrowserArch.WINX86_32
|
||||||
|
elif machine in ["arm64", "aarch64"]:
|
||||||
|
return WebBrowserArch.WINARM
|
||||||
|
else:
|
||||||
|
return WebBrowserArch.WINX86_64
|
||||||
|
elif system == "Darwin":
|
||||||
|
if machine in ["arm64", "aarch64"]:
|
||||||
|
return WebBrowserArch.MACARM
|
||||||
|
else:
|
||||||
|
return WebBrowserArch.MACX86_64
|
||||||
|
elif system == "Linux":
|
||||||
|
if machine in ["amd64", "x86_64"]:
|
||||||
|
return WebBrowserArch.LINUXX86_64
|
||||||
|
elif machine in ["i386", "i686", "x86"]:
|
||||||
|
return WebBrowserArch.LINUXX86_32
|
||||||
|
elif machine in ["arm64", "aarch64"]:
|
||||||
|
return WebBrowserArch.LINUXARM
|
||||||
|
elif machine.startswith("arm"):
|
||||||
|
return WebBrowserArch.LINUXARM
|
||||||
|
else:
|
||||||
|
return WebBrowserArch.LINUXX86_64
|
||||||
|
raise ValueError(f"不支持的系统架构 : {system} {machine}")
|
||||||
|
|
||||||
|
|
||||||
|
class WebBrowserDetector:
|
||||||
|
"""
|
||||||
|
Web browser detector
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self
|
||||||
|
):
|
||||||
|
|
||||||
|
self.browser_arch = WebBrowserArchDetector().detect()
|
||||||
|
self.browser_infos : list[WebBrowserInfo] = []
|
||||||
|
|
||||||
|
def detect(
|
||||||
|
self
|
||||||
|
) -> list[WebBrowserInfo]:
|
||||||
|
|
||||||
|
"""
|
||||||
|
Detect installed web browsers on the system.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list[WebBrowserInfo]: List of detected browser information objects.
|
||||||
|
"""
|
||||||
|
|
||||||
|
self.browser_infos = []
|
||||||
|
try:
|
||||||
|
all_browsers = list(browsers.browsers())
|
||||||
|
except Exception as e:
|
||||||
|
self.browser_infos = []
|
||||||
|
return self.browser_infos
|
||||||
|
|
||||||
|
# Mapping from internal library name to our enum
|
||||||
|
type_map = {
|
||||||
|
'chrome': WebBrowserType.CHROME,
|
||||||
|
'firefox': WebBrowserType.FIREFOX,
|
||||||
|
'msedge': WebBrowserType.EDGE,
|
||||||
|
}
|
||||||
|
for browser in all_browsers:
|
||||||
|
internal_name = browser.get("browser_type", "").lower()
|
||||||
|
if internal_name not in type_map:
|
||||||
|
continue # Not one of the browsers we care about
|
||||||
|
version = browser.get("version", "")
|
||||||
|
if not version:
|
||||||
|
# Skip browsers with no version info (unlikely, but defensive)
|
||||||
|
continue
|
||||||
|
exe_path = browser.get("path", "")
|
||||||
|
if not exe_path:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
path = Path(exe_path)
|
||||||
|
if not path.is_file():
|
||||||
|
continue
|
||||||
|
except Exception:
|
||||||
|
continue # Invalid path
|
||||||
|
info = WebBrowserInfo(
|
||||||
|
browser_arch=self.browser_arch, # Use system architecture as fallback
|
||||||
|
browser_type=type_map[internal_name],
|
||||||
|
browser_version=version,
|
||||||
|
browser_path=path,
|
||||||
|
)
|
||||||
|
self.browser_infos.append(info)
|
||||||
|
# Deduplicate: keep only one entry per (type, version)
|
||||||
|
seen = set()
|
||||||
|
unique = []
|
||||||
|
for info in self.browser_infos:
|
||||||
|
key = (info.browser_type, info.browser_version)
|
||||||
|
if key not in seen:
|
||||||
|
seen.add(key)
|
||||||
|
unique.append(info)
|
||||||
|
self.browser_infos = unique
|
||||||
|
return self.browser_infos
|
||||||
@@ -0,0 +1,457 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
Copyright (c) 2026 KenanZhu.
|
||||||
|
All rights reserved.
|
||||||
|
|
||||||
|
This software is provided "as is", without any warranty of any kind.
|
||||||
|
You may use, modify, and distribute this file under the terms of the MIT License.
|
||||||
|
See the LICENSE file for details.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
import shutil
|
||||||
|
import threading
|
||||||
|
import requests
|
||||||
|
import zipfile
|
||||||
|
import tarfile
|
||||||
|
|
||||||
|
from enum import Enum
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional, Callable
|
||||||
|
|
||||||
|
|
||||||
|
class WebDriverType(Enum):
|
||||||
|
"""
|
||||||
|
Web driver type
|
||||||
|
"""
|
||||||
|
|
||||||
|
CHROME = "chrome"
|
||||||
|
FIREFOX = "firefox"
|
||||||
|
EDGE = "edge"
|
||||||
|
|
||||||
|
|
||||||
|
class WebDriverArch(Enum):
|
||||||
|
"""
|
||||||
|
Web driver architecture
|
||||||
|
"""
|
||||||
|
|
||||||
|
class Chrome(Enum):
|
||||||
|
"""
|
||||||
|
Chrome web driver architecture
|
||||||
|
"""
|
||||||
|
|
||||||
|
WINX86_32 = "win32"
|
||||||
|
WINX86_64 = "win64"
|
||||||
|
|
||||||
|
# LINUX86_32 : no support for linux 32bit
|
||||||
|
LINUX86_64 = "linux64"
|
||||||
|
# LINUXARM : no support for linux arm64
|
||||||
|
|
||||||
|
MACX86_64 = "mac-x64"
|
||||||
|
MACARM = "mac-arm64"
|
||||||
|
|
||||||
|
class Firefox(Enum):
|
||||||
|
"""
|
||||||
|
Firefox web driver architecture
|
||||||
|
"""
|
||||||
|
|
||||||
|
WINX86_32 = "win32"
|
||||||
|
WINX86_64 = "win64"
|
||||||
|
WINARM = "win-aarch64"
|
||||||
|
|
||||||
|
LINUXX86_32 = "linux32"
|
||||||
|
LINUXX86_64 = "linux64"
|
||||||
|
LINUXARM = "linux-aarch64"
|
||||||
|
|
||||||
|
MACX86_64 = "macos"
|
||||||
|
MACARM = "macos-aarch64"
|
||||||
|
|
||||||
|
class Edge(Enum):
|
||||||
|
"""
|
||||||
|
Edge web driver architecture
|
||||||
|
"""
|
||||||
|
|
||||||
|
WINX86_32 = "win32"
|
||||||
|
WINX86_64 = "win64"
|
||||||
|
WINARM = "arm64"
|
||||||
|
|
||||||
|
# LINUX86_32 : no support for linux 32bit
|
||||||
|
LINUXX86_64 = "linux64"
|
||||||
|
# LINUXARM : no support for linux arm64
|
||||||
|
|
||||||
|
MACX86_64 = "mac64"
|
||||||
|
MACARM = "mac64_m1"
|
||||||
|
|
||||||
|
|
||||||
|
class WebDriverName:
|
||||||
|
"""
|
||||||
|
Web driver name
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
driver_type: WebDriverType
|
||||||
|
):
|
||||||
|
|
||||||
|
self.driver_type = driver_type
|
||||||
|
|
||||||
|
def __str__(
|
||||||
|
self
|
||||||
|
) -> str:
|
||||||
|
|
||||||
|
match self.driver_type:
|
||||||
|
case WebDriverType.CHROME:
|
||||||
|
return "chromedriver"
|
||||||
|
case WebDriverType.FIREFOX:
|
||||||
|
return "geckodriver"
|
||||||
|
case WebDriverType.EDGE:
|
||||||
|
return "msedgedriver"
|
||||||
|
case _:
|
||||||
|
raise ValueError(f"不受支持的 web driver 类型 : {self.driver_type}")
|
||||||
|
|
||||||
|
|
||||||
|
class WebDriverExecName:
|
||||||
|
"""
|
||||||
|
Web driver executable file name
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
driver_type: WebDriverType,
|
||||||
|
arch: WebDriverArch
|
||||||
|
):
|
||||||
|
|
||||||
|
self.driver_type = driver_type
|
||||||
|
self.arch = arch
|
||||||
|
|
||||||
|
def __str__(
|
||||||
|
self
|
||||||
|
) -> str:
|
||||||
|
|
||||||
|
is_win = True if self.arch is WebDriverArch.Chrome.WINX86_32 or\
|
||||||
|
self.arch is WebDriverArch.Chrome.WINX86_64 or\
|
||||||
|
self.arch is WebDriverArch.Firefox.WINX86_32 or\
|
||||||
|
self.arch is WebDriverArch.Firefox.WINX86_64 or\
|
||||||
|
self.arch is WebDriverArch.Edge.WINX86_32 or\
|
||||||
|
self.arch is WebDriverArch.Edge.WINX86_64 else False
|
||||||
|
match self.driver_type:
|
||||||
|
case WebDriverType.CHROME:
|
||||||
|
return f"{WebDriverName(self.driver_type)}" + (".exe" if is_win else "")
|
||||||
|
case WebDriverType.FIREFOX:
|
||||||
|
return f"{WebDriverName(self.driver_type)}" + (".exe" if is_win else "")
|
||||||
|
case WebDriverType.EDGE:
|
||||||
|
return f"{WebDriverName(self.driver_type)}" + (".exe" if is_win else "")
|
||||||
|
case _:
|
||||||
|
raise ValueError(f"不受支持的 web driver 类型 : {self.driver_type}")
|
||||||
|
|
||||||
|
|
||||||
|
class WebDriverFileName:
|
||||||
|
"""\
|
||||||
|
Web driver compressed file name
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
version: str,
|
||||||
|
driver_type: WebDriverType,
|
||||||
|
arch: WebDriverArch
|
||||||
|
):
|
||||||
|
|
||||||
|
self.version = version
|
||||||
|
self.driver_type = driver_type
|
||||||
|
self.arch = arch
|
||||||
|
|
||||||
|
def __str__(
|
||||||
|
self
|
||||||
|
) -> str:
|
||||||
|
|
||||||
|
match self.driver_type:
|
||||||
|
case WebDriverType.CHROME:
|
||||||
|
return f"{WebDriverName(self.driver_type)}-{self.arch.value}.zip"
|
||||||
|
case WebDriverType.FIREFOX:
|
||||||
|
if self.arch is WebDriverArch.Firefox.WINX86_32 or\
|
||||||
|
self.arch is WebDriverArch.Firefox.WINX86_64:
|
||||||
|
suffix = "zip"
|
||||||
|
else:
|
||||||
|
suffix = "tar.gz"
|
||||||
|
return f"{WebDriverName(self.driver_type)}-v{self.version}-{self.arch.value}.{suffix}"
|
||||||
|
case WebDriverType.EDGE:
|
||||||
|
return f"edgedriver_{self.arch.value}.zip" # Edge web driver file name is different
|
||||||
|
case _:
|
||||||
|
raise ValueError(f"不受支持的 web driver 类型 : {self.driver_type}")
|
||||||
|
|
||||||
|
|
||||||
|
class WebDriverURL:
|
||||||
|
"""
|
||||||
|
Web driver download URL
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
version: str,
|
||||||
|
driver_type: WebDriverType,
|
||||||
|
arch: WebDriverArch
|
||||||
|
):
|
||||||
|
|
||||||
|
self.version = version
|
||||||
|
self.driver_type = driver_type
|
||||||
|
self.arch = arch
|
||||||
|
self.file_name = str(WebDriverFileName(self.version, self.driver_type, self.arch))
|
||||||
|
|
||||||
|
def __str__(
|
||||||
|
self
|
||||||
|
) -> str:
|
||||||
|
|
||||||
|
match self.driver_type:
|
||||||
|
case WebDriverType.CHROME:
|
||||||
|
return f"https://storage.googleapis.com/chrome-for-testing-public/"\
|
||||||
|
f"{self.version}/"\
|
||||||
|
f"{self.arch.value}/"\
|
||||||
|
f"{self.file_name}"
|
||||||
|
case WebDriverType.FIREFOX:
|
||||||
|
return f"https://github.com/mozilla/geckodriver/releases/download/"\
|
||||||
|
f"v{self.version}/"\
|
||||||
|
f"{self.file_name}"
|
||||||
|
case WebDriverType.EDGE:
|
||||||
|
return f"https://msedgedriver.microsoft.com/"\
|
||||||
|
f"{self.version}/"\
|
||||||
|
f"{self.file_name}"
|
||||||
|
case _:
|
||||||
|
raise ValueError(f"不受支持的 web driver 类型 : {self.driver_type}")
|
||||||
|
|
||||||
|
|
||||||
|
class WebDriverDownloader:
|
||||||
|
"""
|
||||||
|
Base class for WebDriver downloaders
|
||||||
|
|
||||||
|
Args:
|
||||||
|
driver_type (WebDriverType): Web driver type
|
||||||
|
version (str): WebDriver version
|
||||||
|
arch (WebDriverArch): WebDriver architecture
|
||||||
|
download_dir (str): Download directory
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
driver_type: WebDriverType,
|
||||||
|
driver_version: str,
|
||||||
|
driver_arch: WebDriverArch,
|
||||||
|
download_dir: str
|
||||||
|
):
|
||||||
|
|
||||||
|
self.driver_type = driver_type
|
||||||
|
self.arch = driver_arch
|
||||||
|
self.version = driver_version
|
||||||
|
self.download_url = str(WebDriverURL(self.version, self.driver_type, self.arch))
|
||||||
|
self.download_dir = Path(download_dir)/self.driver_type.value/self.version/self.arch.value
|
||||||
|
self.download_dir.mkdir(mode=0o0755, parents=True, exist_ok=True)
|
||||||
|
self.download_path = self.download_dir/str(WebDriverFileName(self.version, self.driver_type, self.arch))
|
||||||
|
|
||||||
|
def _download(
|
||||||
|
self,
|
||||||
|
progress_callback: Optional[Callable[[float, int, float, str], None]] = None,
|
||||||
|
max_retries: int = 3,
|
||||||
|
cancel_event: Optional[threading.Event] = None
|
||||||
|
) -> bool:
|
||||||
|
|
||||||
|
CHUNK_SIZE = 8192*8 # 64KB chunk
|
||||||
|
headers = {
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
|
||||||
|
'Accept-Encoding': 'gzip, deflate'
|
||||||
|
}
|
||||||
|
|
||||||
|
for attempt in range(max_retries):
|
||||||
|
try:
|
||||||
|
if cancel_event and cancel_event.is_set():
|
||||||
|
return False
|
||||||
|
# resume download if file exists
|
||||||
|
if self.download_path.exists():
|
||||||
|
downloaded_size = self.download_path.stat().st_size
|
||||||
|
headers_ = headers.copy()
|
||||||
|
headers_['Range'] = f"bytes={downloaded_size}-"
|
||||||
|
mode = 'ab'
|
||||||
|
else:
|
||||||
|
downloaded_size = 0
|
||||||
|
headers_ = headers
|
||||||
|
mode = 'wb'
|
||||||
|
# get response
|
||||||
|
response = requests.get(str(self.download_url), headers=headers_, stream=True, timeout=10)
|
||||||
|
if response.status_code not in [200, 206]:
|
||||||
|
if self.download_path.exists():
|
||||||
|
self.download_path.unlink()
|
||||||
|
downloaded_size = 0
|
||||||
|
mode = 'wb'
|
||||||
|
response = requests.get(str(self.download_url), headers=headers, stream=True)
|
||||||
|
response.raise_for_status()
|
||||||
|
# get total size
|
||||||
|
total_size = int(response.headers.get('Content-Length', 0))
|
||||||
|
if response.status_code == 206: # Partial Content - server supports Range
|
||||||
|
total_size += downloaded_size
|
||||||
|
last_callback_time = time.time()
|
||||||
|
last_callback_size = downloaded_size
|
||||||
|
callback_interval = 0.1
|
||||||
|
with open(self.download_path, mode) as f:
|
||||||
|
for chunk in response.iter_content(CHUNK_SIZE):
|
||||||
|
current_time = time.time()
|
||||||
|
if cancel_event and cancel_event.is_set():
|
||||||
|
response.close()
|
||||||
|
return False
|
||||||
|
if not chunk:
|
||||||
|
continue
|
||||||
|
f.write(chunk)
|
||||||
|
downloaded_size += len(chunk)
|
||||||
|
if not progress_callback or total_size <= 0:
|
||||||
|
continue
|
||||||
|
current_progress = (downloaded_size/total_size)*98.0
|
||||||
|
if current_time - last_callback_time >= callback_interval or current_progress >= 98.0:
|
||||||
|
elapsed = current_time - last_callback_time
|
||||||
|
if elapsed > 0:
|
||||||
|
speed = (downloaded_size - last_callback_size)/(elapsed*1024.0)
|
||||||
|
else:
|
||||||
|
speed = 0.0
|
||||||
|
progress_callback(current_progress, 100, speed, "下载中...")
|
||||||
|
last_callback_time = current_time
|
||||||
|
last_callback_size = downloaded_size
|
||||||
|
if total_size > 0 and self.download_path.stat().st_size < total_size:
|
||||||
|
raise Exception(f"下载不完整 : {self.download_path.stat().st_size}/{total_size} 字节")
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
if cancel_event and cancel_event.is_set():
|
||||||
|
return False
|
||||||
|
if attempt < max_retries - 1:
|
||||||
|
progress_callback(0, 100, 0.0, f"第 {attempt+1} 次重试...")
|
||||||
|
time.sleep(1)
|
||||||
|
continue
|
||||||
|
raise e
|
||||||
|
|
||||||
|
def _verify(
|
||||||
|
self,
|
||||||
|
progress_callback: Optional[Callable[[float, int, float, str], None]] = None
|
||||||
|
) -> bool:
|
||||||
|
|
||||||
|
progress_callback(98, 100, 0.0, "验证完成")
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _extract(
|
||||||
|
self,
|
||||||
|
progress_callback: Optional[Callable[[float, int, float, str], None]] = None
|
||||||
|
) -> Optional[Path]:
|
||||||
|
|
||||||
|
try:
|
||||||
|
progress_callback(98, 100, 0.0, "解压中...")
|
||||||
|
file_path_str = str(self.download_path)
|
||||||
|
if file_path_str.endswith('.tar.gz'):
|
||||||
|
with tarfile.open(self.download_path, 'r:gz') as tar_ref:
|
||||||
|
tar_ref.extractall(self.download_dir)
|
||||||
|
else:
|
||||||
|
with zipfile.ZipFile(self.download_path, 'r') as zip_ref:
|
||||||
|
zip_ref.extractall(self.download_dir)
|
||||||
|
driver_file = None
|
||||||
|
for root, _, files in os.walk(self.download_dir):
|
||||||
|
for file in files:
|
||||||
|
expected_name = str(WebDriverExecName(self.driver_type, self.arch))
|
||||||
|
if file == str(expected_name):
|
||||||
|
src_path = Path(root, file)
|
||||||
|
dst_path = self.download_dir/file
|
||||||
|
src_path.rename(dst_path)
|
||||||
|
driver_file = dst_path
|
||||||
|
break
|
||||||
|
if driver_file:
|
||||||
|
break
|
||||||
|
if not driver_file:
|
||||||
|
raise FileNotFoundError(f"未找到 web driver 文件 : {expected_name}")
|
||||||
|
# Ensure executable permissions on Unix systems (zipfile
|
||||||
|
# extraction does not preserve the execute bit).
|
||||||
|
if os.name != 'nt':
|
||||||
|
os.chmod(driver_file, 0o755)
|
||||||
|
progress_callback(100, 100, 0.0, "解压完成")
|
||||||
|
self.download_path.unlink()
|
||||||
|
self._cleanup(driver_file)
|
||||||
|
return driver_file
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _cleanup(
|
||||||
|
self,
|
||||||
|
driver_file: Path
|
||||||
|
) -> None:
|
||||||
|
|
||||||
|
for item in self.download_dir.iterdir():
|
||||||
|
if item != driver_file:
|
||||||
|
if item.is_dir():
|
||||||
|
shutil.rmtree(item)
|
||||||
|
else:
|
||||||
|
item.unlink()
|
||||||
|
|
||||||
|
def download(
|
||||||
|
self,
|
||||||
|
progress_callback: Optional[Callable[[float, int, float, str], None]] = None,
|
||||||
|
cancel_event: Optional[threading.Event] = None
|
||||||
|
) -> Optional[Path]:
|
||||||
|
|
||||||
|
try:
|
||||||
|
# downlaod file : 0% - 98%
|
||||||
|
if not self._download(progress_callback, cancel_event=cancel_event):
|
||||||
|
return None
|
||||||
|
# verify file : 98% - 99%
|
||||||
|
if not self._verify(progress_callback):
|
||||||
|
progress_callback(0, 100, 0.0, "验证失败")
|
||||||
|
return None
|
||||||
|
# extract file : 99% - 100%
|
||||||
|
driver_path = self._extract(progress_callback)
|
||||||
|
if not driver_path:
|
||||||
|
progress_callback(0, 100, 0.0, "解压失败")
|
||||||
|
return None
|
||||||
|
return driver_path
|
||||||
|
except Exception as e:
|
||||||
|
raise e
|
||||||
|
|
||||||
|
|
||||||
|
class ChromeDriverDownloader(WebDriverDownloader):
|
||||||
|
"""
|
||||||
|
Chrome web driver downloader
|
||||||
|
|
||||||
|
Only support version higher than 114
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
version: str,
|
||||||
|
arch: WebDriverArch,
|
||||||
|
download_dir: str
|
||||||
|
):
|
||||||
|
|
||||||
|
super().__init__(WebDriverType.CHROME, version, arch, download_dir)
|
||||||
|
|
||||||
|
|
||||||
|
class FirefoxDriverDownloader(WebDriverDownloader):
|
||||||
|
"""
|
||||||
|
Firefox web driver downloader
|
||||||
|
|
||||||
|
This class do not resolve version mapping,
|
||||||
|
only support driver version higher than 0.17.0
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
version: str,
|
||||||
|
arch: WebDriverArch,
|
||||||
|
download_dir: str
|
||||||
|
):
|
||||||
|
|
||||||
|
super().__init__(WebDriverType.FIREFOX, version, arch, download_dir)
|
||||||
|
|
||||||
|
|
||||||
|
class EdgeDriverDownloader(WebDriverDownloader):
|
||||||
|
"""
|
||||||
|
Edge web driver downloader
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
version: str,
|
||||||
|
arch: WebDriverArch,
|
||||||
|
download_dir: str
|
||||||
|
):
|
||||||
|
|
||||||
|
super().__init__(WebDriverType.EDGE, version, arch, download_dir)
|
||||||
@@ -0,0 +1,459 @@
|
|||||||
|
# -*- 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():
|
||||||
|
# Repair missing execute permission on Unix
|
||||||
|
# (zip-extracted drivers from older versions).
|
||||||
|
if os.name != 'nt' and not os.access(str(driver_path), os.X_OK):
|
||||||
|
os.chmod(str(driver_path), 0o755)
|
||||||
|
driver_info.driver_path = driver_path
|
||||||
|
driver_info.driver_status = WebDriverStatus.INSTALLED
|
||||||
|
|
||||||
|
def _mapWebBrowserTypeToDriver(
|
||||||
|
self,
|
||||||
|
browser_type: WebBrowserType
|
||||||
|
) -> WebDriverType:
|
||||||
|
|
||||||
|
if browser_type == WebBrowserType.CHROME:
|
||||||
|
return WebDriverType.CHROME
|
||||||
|
elif browser_type == WebBrowserType.FIREFOX:
|
||||||
|
return WebDriverType.FIREFOX
|
||||||
|
elif browser_type == WebBrowserType.EDGE:
|
||||||
|
return WebDriverType.EDGE
|
||||||
|
else:
|
||||||
|
raise ValueError(f"不支持的 Web 浏览器类型 : {browser_type}")
|
||||||
|
|
||||||
|
def _mapWebBrowserArchToDriver(
|
||||||
|
self,
|
||||||
|
browser_type: WebBrowserType,
|
||||||
|
browser_arch: WebBrowserArch
|
||||||
|
) -> WebDriverArch:
|
||||||
|
|
||||||
|
if browser_type == WebBrowserType.CHROME:
|
||||||
|
if browser_arch == WebBrowserArch.WINX86_32:
|
||||||
|
return WebDriverArch.Chrome.WINX86_32
|
||||||
|
elif browser_arch == WebBrowserArch.WINX86_64:
|
||||||
|
return WebDriverArch.Chrome.WINX86_64
|
||||||
|
elif browser_arch == WebBrowserArch.WINARM:
|
||||||
|
raise ValueError("Chrome 不支持 Windows ARM 架构")
|
||||||
|
elif browser_arch == WebBrowserArch.LINUXX86_32:
|
||||||
|
raise ValueError("Chrome 不支持 Linux x86_32 架构")
|
||||||
|
elif browser_arch == WebBrowserArch.LINUXX86_64:
|
||||||
|
return WebDriverArch.Chrome.LINUXX86_64
|
||||||
|
elif browser_arch == WebBrowserArch.LINUXARM:
|
||||||
|
raise ValueError("Chrome 不支持 Linux ARM 架构")
|
||||||
|
elif browser_arch == WebBrowserArch.MACX86_64:
|
||||||
|
return WebDriverArch.Chrome.MACX86_64
|
||||||
|
elif browser_arch == WebBrowserArch.MACARM:
|
||||||
|
return WebDriverArch.Chrome.MACARM
|
||||||
|
else:
|
||||||
|
raise ValueError(f"不支持的 Chrome 浏览器架构 : {browser_arch}")
|
||||||
|
elif browser_type == WebBrowserType.FIREFOX:
|
||||||
|
if browser_arch == WebBrowserArch.WINX86_32:
|
||||||
|
return WebDriverArch.Firefox.WINX86_32
|
||||||
|
elif browser_arch == WebBrowserArch.WINX86_64:
|
||||||
|
return WebDriverArch.Firefox.WINX86_64
|
||||||
|
elif browser_arch == WebBrowserArch.WINARM:
|
||||||
|
return WebDriverArch.Firefox.WINARM
|
||||||
|
elif browser_arch == WebBrowserArch.LINUXX86_32:
|
||||||
|
return WebDriverArch.Firefox.LINUXX86_32
|
||||||
|
elif browser_arch == WebBrowserArch.LINUXX86_64:
|
||||||
|
return WebDriverArch.Firefox.LINUXX86_64
|
||||||
|
elif browser_arch == WebBrowserArch.LINUXARM:
|
||||||
|
return WebDriverArch.Firefox.LINUXARM
|
||||||
|
elif browser_arch == WebBrowserArch.MACX86_64:
|
||||||
|
return WebDriverArch.Firefox.MACX86_64
|
||||||
|
elif browser_arch == WebBrowserArch.MACARM:
|
||||||
|
return WebDriverArch.Firefox.MACARM
|
||||||
|
else:
|
||||||
|
raise ValueError(f"不支持的 Firefox 浏览器架构 : {browser_arch}")
|
||||||
|
elif browser_type == WebBrowserType.EDGE:
|
||||||
|
if browser_arch == WebBrowserArch.WINX86_32:
|
||||||
|
return WebDriverArch.Edge.WINX86_32
|
||||||
|
elif browser_arch == WebBrowserArch.WINX86_64:
|
||||||
|
return WebDriverArch.Edge.WINX86_64
|
||||||
|
elif browser_arch == WebBrowserArch.WINARM:
|
||||||
|
return WebDriverArch.Edge.WINARM
|
||||||
|
elif browser_arch == WebBrowserArch.LINUXX86_32:
|
||||||
|
raise ValueError("Edge 不支持 Linux x86_32 架构")
|
||||||
|
elif browser_arch == WebBrowserArch.LINUXX86_64:
|
||||||
|
return WebDriverArch.Edge.LINUXX86_64
|
||||||
|
elif browser_arch == WebBrowserArch.LINUXARM:
|
||||||
|
raise ValueError("Edge 不支持 Linux ARM 架构")
|
||||||
|
elif browser_arch == WebBrowserArch.MACX86_64:
|
||||||
|
return WebDriverArch.Edge.MACX86_64
|
||||||
|
elif browser_arch == WebBrowserArch.MACARM:
|
||||||
|
return WebDriverArch.Edge.MACARM
|
||||||
|
else:
|
||||||
|
raise ValueError(f"不支持的 Edge 浏览器架构 : {browser_arch}")
|
||||||
|
else:
|
||||||
|
raise ValueError(f"不支持的 Web 浏览器类型 : {browser_type}")
|
||||||
|
|
||||||
|
def _mapFirefoxDriverVersion(
|
||||||
|
self,
|
||||||
|
version: str
|
||||||
|
) -> str:
|
||||||
|
|
||||||
|
version_mapping = [
|
||||||
|
(ver.Version("128.0"), ver.Version("999.0"), "0.36.0"),
|
||||||
|
(ver.Version("115.0"), ver.Version("127.0"), "0.35.0"),
|
||||||
|
(ver.Version("91.0"), ver.Version("114.0"), "0.34.0"),
|
||||||
|
(ver.Version("91.0"), ver.Version("120.0"), "0.33.0"),
|
||||||
|
(ver.Version("91.0"), ver.Version("120.0"), "0.32.0"),
|
||||||
|
(ver.Version("91.0"), ver.Version("120.0"), "0.31.0"),
|
||||||
|
(ver.Version("78.0"), ver.Version("90.0"), "0.30.0"),
|
||||||
|
(ver.Version("60.0"), ver.Version("90.0"), "0.29.0"),
|
||||||
|
(ver.Version("60.0"), ver.Version("90.0"), "0.28.0"),
|
||||||
|
(ver.Version("60.0"), ver.Version("90.0"), "0.27.0"),
|
||||||
|
(ver.Version("57.0"), ver.Version("90.0"), "0.26.0"),
|
||||||
|
(ver.Version("55.0"), ver.Version("62.0"), "0.25.0"),
|
||||||
|
(ver.Version("55.0"), ver.Version("62.0"), "0.24.0"),
|
||||||
|
(ver.Version("57.0"), ver.Version("79.0"), "0.23.0"),
|
||||||
|
(ver.Version("57.0"), ver.Version("79.0"), "0.22.0"),
|
||||||
|
(ver.Version("57.0"), ver.Version("79.0"), "0.21.0"),
|
||||||
|
(ver.Version("55.0"), ver.Version("62.0"), "0.20.0"),
|
||||||
|
(ver.Version("55.0"), ver.Version("62.0"), "0.19.0"),
|
||||||
|
(ver.Version("53.0"), ver.Version("62.0"), "0.18.0"),
|
||||||
|
(ver.Version("52.0"), ver.Version("62.0"), "0.17.0"),
|
||||||
|
]
|
||||||
|
|
||||||
|
try:
|
||||||
|
firefox_version = ver.Version(version)
|
||||||
|
for min_ver, max_ver, gecko_ver in version_mapping:
|
||||||
|
if min_ver <= firefox_version <= max_ver:
|
||||||
|
return gecko_ver
|
||||||
|
raise ValueError(
|
||||||
|
f"不支持的 Firefox 版本 : {version}"
|
||||||
|
f"Firefox 版本 52 及以上受支持"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
raise ValueError(f"无效的 Firefox 版本格式 : {version}") from e
|
||||||
|
|
||||||
|
def _getDriverInfo(
|
||||||
|
self,
|
||||||
|
browser_info: WebBrowserInfo
|
||||||
|
) -> WebDriverInfo:
|
||||||
|
|
||||||
|
driver_info = WebDriverInfo()
|
||||||
|
driver_info.driver_type = self._mapWebBrowserTypeToDriver(browser_info.browser_type)
|
||||||
|
driver_info.driver_arch = self._mapWebBrowserArchToDriver(browser_info.browser_type, browser_info.browser_arch)
|
||||||
|
if browser_info.browser_type == WebBrowserType.FIREFOX:
|
||||||
|
driver_info.driver_version = self._mapFirefoxDriverVersion(browser_info.browser_version)
|
||||||
|
else:
|
||||||
|
driver_info.driver_version = browser_info.browser_version
|
||||||
|
driver_info.browser_version = browser_info.browser_version
|
||||||
|
return driver_info
|
||||||
|
|
||||||
|
def _getDriverPath(
|
||||||
|
self,
|
||||||
|
driver_info: WebDriverInfo
|
||||||
|
) -> Optional[Path]:
|
||||||
|
|
||||||
|
driver_type = driver_info.driver_type
|
||||||
|
driver_arch = driver_info.driver_arch
|
||||||
|
driver_version = driver_info.driver_version
|
||||||
|
if driver_type == WebDriverType.CHROME:
|
||||||
|
driver_name = "chromedriver"
|
||||||
|
elif driver_type == WebDriverType.FIREFOX:
|
||||||
|
driver_name = "geckodriver"
|
||||||
|
elif driver_type == WebDriverType.EDGE:
|
||||||
|
driver_name = "msedgedriver"
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
is_win = driver_arch in [
|
||||||
|
WebDriverArch.Chrome.WINX86_32,
|
||||||
|
WebDriverArch.Chrome.WINX86_64,
|
||||||
|
WebDriverArch.Firefox.WINX86_32,
|
||||||
|
WebDriverArch.Firefox.WINX86_64,
|
||||||
|
WebDriverArch.Edge.WINX86_32,
|
||||||
|
WebDriverArch.Edge.WINX86_64,
|
||||||
|
]
|
||||||
|
exe_name = f"{driver_name}.exe" if is_win else driver_name
|
||||||
|
driver_dir = Path(self.__driver_dir)/driver_type.value/driver_version/driver_arch.value
|
||||||
|
driver_path = driver_dir/exe_name
|
||||||
|
return driver_path
|
||||||
|
|
||||||
|
def refresh(
|
||||||
|
self
|
||||||
|
):
|
||||||
|
|
||||||
|
self._detectBrowsers()
|
||||||
|
self._checkDriverStatus()
|
||||||
|
|
||||||
|
def getDriverInfos(
|
||||||
|
self
|
||||||
|
) -> list[WebDriverInfo]:
|
||||||
|
|
||||||
|
with self.__lock:
|
||||||
|
return self.__driver_infos.copy()
|
||||||
|
|
||||||
|
def getDriverInfo(
|
||||||
|
self,
|
||||||
|
driver_type: WebDriverType
|
||||||
|
) -> list[WebDriverInfo]:
|
||||||
|
|
||||||
|
with self.__lock:
|
||||||
|
return [
|
||||||
|
info
|
||||||
|
for info in self.__driver_infos
|
||||||
|
if info.driver_type == driver_type
|
||||||
|
]
|
||||||
|
|
||||||
|
def getDriverPath(
|
||||||
|
self,
|
||||||
|
driver_info: WebDriverInfo
|
||||||
|
) -> Optional[Path]:
|
||||||
|
|
||||||
|
if driver_info and driver_info.driver_status == WebDriverStatus.INSTALLED:
|
||||||
|
return driver_info.driver_path
|
||||||
|
return None
|
||||||
|
|
||||||
|
def installDriver(
|
||||||
|
self,
|
||||||
|
driver_info: WebDriverInfo,
|
||||||
|
progress_callback: Optional[Callable[[float, int, float, str], None]] = None,
|
||||||
|
cancel_event: Optional[threading.Event] = None
|
||||||
|
) -> Optional[Path]:
|
||||||
|
|
||||||
|
with self.__lock:
|
||||||
|
if not driver_info:
|
||||||
|
if progress_callback:
|
||||||
|
progress_callback(0, 0, 0, "未找到浏览器信息")
|
||||||
|
else:
|
||||||
|
raise ValueError("未找到浏览器信息")
|
||||||
|
if driver_info and driver_info.driver_status == WebDriverStatus.DOWNLOADING:
|
||||||
|
if progress_callback:
|
||||||
|
progress_callback(0, 0, 0, f"{driver_info.driver_type} 驱动正在下载中")
|
||||||
|
else:
|
||||||
|
raise ValueError(f"{driver_info.driver_type} 驱动正在下载中")
|
||||||
|
try:
|
||||||
|
if not driver_info:
|
||||||
|
raise ValueError("未找到浏览器信息")
|
||||||
|
driver_arch = driver_info.driver_arch
|
||||||
|
driver_type = driver_info.driver_type
|
||||||
|
driver_version = driver_info.driver_version
|
||||||
|
downloader = None
|
||||||
|
if driver_type == WebDriverType.CHROME:
|
||||||
|
downloader = ChromeDriverDownloader(
|
||||||
|
version=driver_version,
|
||||||
|
arch=driver_arch,
|
||||||
|
download_dir=self.__driver_dir
|
||||||
|
)
|
||||||
|
elif driver_type == WebDriverType.FIREFOX:
|
||||||
|
downloader = FirefoxDriverDownloader(
|
||||||
|
version=driver_version,
|
||||||
|
arch=driver_arch,
|
||||||
|
download_dir=self.__driver_dir
|
||||||
|
)
|
||||||
|
elif driver_type == WebDriverType.EDGE:
|
||||||
|
downloader = EdgeDriverDownloader(
|
||||||
|
version=driver_version,
|
||||||
|
arch=driver_arch,
|
||||||
|
download_dir=self.__driver_dir
|
||||||
|
)
|
||||||
|
if downloader is None:
|
||||||
|
if progress_callback:
|
||||||
|
progress_callback(0, 0, 0, f"不支持的 Web Driver 类型")
|
||||||
|
else:
|
||||||
|
raise ValueError(f"不支持的 Web Driver 类型")
|
||||||
|
with self.__lock:
|
||||||
|
driver_info.driver_status = WebDriverStatus.DOWNLOADING
|
||||||
|
driver_path = downloader.download(progress_callback=progress_callback, cancel_event=cancel_event)
|
||||||
|
with self.__lock:
|
||||||
|
if driver_path:
|
||||||
|
driver_info.driver_path = driver_path
|
||||||
|
driver_info.driver_version = driver_version
|
||||||
|
driver_info.driver_status = WebDriverStatus.INSTALLED
|
||||||
|
else:
|
||||||
|
driver_info.driver_status = WebDriverStatus.ERROR
|
||||||
|
return driver_path
|
||||||
|
except Exception as e:
|
||||||
|
with self.__lock:
|
||||||
|
driver_info.driver_status = WebDriverStatus.ERROR
|
||||||
|
raise e
|
||||||
|
|
||||||
|
def cancelDriverDownload(
|
||||||
|
self,
|
||||||
|
driver_info: WebDriverInfo
|
||||||
|
) -> bool:
|
||||||
|
|
||||||
|
import shutil
|
||||||
|
|
||||||
|
try:
|
||||||
|
driver_path = self._getDriverPath(driver_info)
|
||||||
|
if driver_path:
|
||||||
|
download_dir = driver_path.parent
|
||||||
|
if download_dir.exists():
|
||||||
|
shutil.rmtree(download_dir, ignore_errors=True)
|
||||||
|
with self.__lock:
|
||||||
|
driver_info.driver_path = None
|
||||||
|
driver_info.driver_status = WebDriverStatus.NOT_INSTALLED
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def uninstallDriver(
|
||||||
|
self,
|
||||||
|
driver_info: WebDriverInfo,
|
||||||
|
progress_callback: Optional[Callable[[int, int, float, str], None]] = None
|
||||||
|
) -> bool:
|
||||||
|
|
||||||
|
with self.__lock:
|
||||||
|
if not driver_info:
|
||||||
|
if progress_callback:
|
||||||
|
progress_callback(0, 0, 0, "未找到浏览器信息")
|
||||||
|
else:
|
||||||
|
raise ValueError("未找到浏览器信息")
|
||||||
|
if driver_info.driver_status != WebDriverStatus.INSTALLED:
|
||||||
|
if progress_callback:
|
||||||
|
progress_callback(0, 0, 0, f"{driver_info.driver_type} 驱动未安装")
|
||||||
|
else:
|
||||||
|
raise ValueError(f"{driver_info.driver_type} 驱动未安装")
|
||||||
|
try:
|
||||||
|
driver_path = driver_info.driver_path
|
||||||
|
driver_path.unlink()
|
||||||
|
with self.__lock:
|
||||||
|
driver_info.driver_path = None
|
||||||
|
driver_info.driver_status = WebDriverStatus.NOT_INSTALLED
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
with self.__lock:
|
||||||
|
driver_info.driver_status = WebDriverStatus.ERROR
|
||||||
|
raise
|
||||||
|
|
||||||
|
def driverDir(
|
||||||
|
self
|
||||||
|
) -> str:
|
||||||
|
|
||||||
|
return self.__driver_dir
|
||||||
|
|
||||||
|
|
||||||
|
# WebDriverManager singleton instance.
|
||||||
|
_webdriver_manager_instance = None
|
||||||
|
|
||||||
|
# Singleton instance lock.
|
||||||
|
_instance_lock = threading.Lock()
|
||||||
|
|
||||||
|
def instance(
|
||||||
|
driver_dir: str = ""
|
||||||
|
) -> WebDriverManager:
|
||||||
|
|
||||||
|
global _webdriver_manager_instance
|
||||||
|
with _instance_lock:
|
||||||
|
if _webdriver_manager_instance is None:
|
||||||
|
if not driver_dir:
|
||||||
|
raise ValueError("WebDriverManager 需要驱动目录参数")
|
||||||
|
_webdriver_manager_instance = WebDriverManager(driver_dir)
|
||||||
|
else:
|
||||||
|
if driver_dir and _webdriver_manager_instance.driverDir() != os.path.abspath(driver_dir):
|
||||||
|
raise ValueError("WebDriverManager 的实例已初始化, 不能使用不同的驱动目录")
|
||||||
|
return _webdriver_manager_instance
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
Copyright (c) 2026 KenanZhu.
|
||||||
|
All rights reserved.
|
||||||
|
|
||||||
|
This software is provided "as is", without any warranty of any kind.
|
||||||
|
You may use, modify, and distribute this file under the terms of the MIT License.
|
||||||
|
See the LICENSE file for details.
|
||||||
|
"""
|
||||||