mirror of
https://github.com/KenanZhu/AutoLibrary.git
synced 2026-06-21 17:03:03 +08:00
Compare commits
21 Commits
67f297b434
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 0ba76babc6 | |||
| dc7e5d8cd8 | |||
| 818acd4efc | |||
| 308a1dfcf3 | |||
| c250fa4a6e | |||
| 8f8e3e4ba7 | |||
| 88a74a7a47 | |||
| 5552af1345 | |||
| 6d05d4c7cb | |||
| 01f4ccaa0e | |||
| f9175371dc | |||
| 8e1b28f3fe | |||
| 57f1cfb3f2 | |||
| 007b4dc2ef | |||
| 0bad34d7a8 | |||
| 61d1b44402 | |||
| 72301c63fd | |||
| 609850ab60 | |||
| 8e14d45b71 | |||
| f6ef9af39d | |||
| 72dce83c55 |
@@ -4,6 +4,9 @@ name: Build Test
|
|||||||
# It is triggered when a pull request is opened, synchronized, or reopened against the main branch.
|
# It is triggered when a pull request is opened, synchronized, or reopened against the main branch.
|
||||||
|
|
||||||
on:
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
pull_request:
|
pull_request:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
@@ -209,13 +212,283 @@ jobs:
|
|||||||
|
|
||||||
- name: Upload build summary
|
- name: Upload build summary
|
||||||
run: |
|
run: |
|
||||||
Write-Host "## Build Test Summary" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8
|
"## Build Test Summary" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8
|
||||||
Write-Host "" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
|
"" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
|
||||||
Write-Host "========================================" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
|
"========================================" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
|
||||||
Write-Host "✓ Pull request build test completed successfully!" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
|
"✓ Pull request build test completed successfully!" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
|
||||||
Write-Host "- Version: ${{ steps.get_version.outputs.VERSION }}" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
|
"- Version: ${{ steps.get_version.outputs.VERSION }}" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
|
||||||
Write-Host "- Tag: ${{ steps.get_version.outputs.TAG_NAME }}" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
|
"- Tag: ${{ steps.get_version.outputs.TAG_NAME }}" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
|
||||||
Write-Host "- Pull Request #${{ github.event.pull_request.number || 'N/A' }}" | 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
|
||||||
Write-Host "- Branch: ${{ github.event.pull_request.head.ref || github.ref }}" | 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
|
||||||
Write-Host "- Event: ${{ github.event_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
|
||||||
shell: pwsh
|
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
|
||||||
|
|||||||
+311
-10
@@ -1,7 +1,10 @@
|
|||||||
name: Build
|
name: Build
|
||||||
|
|
||||||
# This workflow compiles the application for Windows platform using PyInstaller, and
|
# This workflow compiles the application for Windows and macOS platforms using
|
||||||
# archives the built artifacts as 'AutoLibrary.<tag_name>-windows-x86_64.zip'.
|
# 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.
|
# It is triggered when called by the release workflow.
|
||||||
|
|
||||||
@@ -251,12 +254,310 @@ jobs:
|
|||||||
- name: Upload build summary
|
- name: Upload build summary
|
||||||
if: ${{ inputs.is_test == 'true' }}
|
if: ${{ inputs.is_test == 'true' }}
|
||||||
run: |
|
run: |
|
||||||
Write-Host "## Build Summary" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8
|
"## Build Summary" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8
|
||||||
Write-Host "" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
|
"" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
|
||||||
Write-Host "========================================" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
|
"========================================" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
|
||||||
Write-Host "✓ Build test completed successfully!" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
|
"✓ Build test completed successfully!" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
|
||||||
Write-Host "- Version: ${{ steps.get_version.outputs.VERSION }}" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
|
"- Version: ${{ steps.get_version.outputs.VERSION }}" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
|
||||||
Write-Host "- Tag: ${{ steps.get_version.outputs.TAG_NAME }}" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
|
"- Tag: ${{ steps.get_version.outputs.TAG_NAME }}" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
|
||||||
Write-Host "- Event: ${{ github.event_name }}" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
|
"- Event: ${{ github.event_name }}" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
|
||||||
Write-Host "- Ref: ${{ github.ref }}" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
|
"- Ref: ${{ github.ref }}" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
|
||||||
shell: pwsh
|
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
|
||||||
|
|||||||
@@ -21,8 +21,10 @@ name: Release
|
|||||||
# Commits version changes to release branch and creates the release tag.
|
# Commits version changes to release branch and creates the release tag.
|
||||||
|
|
||||||
# 4. Build:
|
# 4. Build:
|
||||||
# Compiles the application for Windows platform using PyInstaller, and
|
# Compiles the application for Windows and macOS platforms using PyInstaller, and
|
||||||
# archives the built artifacts as 'AutoLibrary.<tag_name>-windows-x86_64.zip'.
|
# archives the built artifacts.
|
||||||
|
# - Windows: AutoLibrary.<tag_name>-windows-x86_64.zip
|
||||||
|
# - macOS: AutoLibrary.<tag_name>-macos-arm64.dmg
|
||||||
|
|
||||||
# 5. Release:
|
# 5. Release:
|
||||||
# Creates GitHub release with generated artifacts and release notes
|
# Creates GitHub release with generated artifacts and release notes
|
||||||
@@ -181,12 +183,18 @@ jobs:
|
|||||||
contents: write
|
contents: write
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Download artifacts
|
- name: Download Windows artifacts
|
||||||
uses: actions/download-artifact@v6
|
uses: actions/download-artifact@v6
|
||||||
with:
|
with:
|
||||||
name: AutoLibrary.${{ needs.extract-version.outputs.tag_name }}-windows-x86_64
|
name: AutoLibrary.${{ needs.extract-version.outputs.tag_name }}-windows-x86_64
|
||||||
path: artifacts/
|
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
|
- name: Create release
|
||||||
id: create_release
|
id: create_release
|
||||||
uses: softprops/action-gh-release@v2
|
uses: softprops/action-gh-release@v2
|
||||||
@@ -195,6 +203,7 @@ jobs:
|
|||||||
name: AutoLibrary ${{ needs.extract-version.outputs.tag_name }}
|
name: AutoLibrary ${{ needs.extract-version.outputs.tag_name }}
|
||||||
files: |
|
files: |
|
||||||
artifacts/AutoLibrary.${{ needs.extract-version.outputs.tag_name }}-windows-x86_64.zip
|
artifacts/AutoLibrary.${{ needs.extract-version.outputs.tag_name }}-windows-x86_64.zip
|
||||||
|
artifacts/AutoLibrary.${{ needs.extract-version.outputs.tag_name }}-macos-arm64.dmg
|
||||||
draft: false
|
draft: false
|
||||||
prerelease: ${{ needs.extract-version.outputs.is_rc == 'true' }}
|
prerelease: ${{ needs.extract-version.outputs.is_rc == 'true' }}
|
||||||
generate_release_notes: true
|
generate_release_notes: true
|
||||||
|
|||||||
@@ -17,13 +17,22 @@
|
|||||||
|
|
||||||
### 功能
|
### 功能
|
||||||
|
|
||||||
|
#### 核心特性
|
||||||
|
|
||||||
1. 自动预约 - 支持自动预约
|
1. 自动预约 - 支持自动预约
|
||||||
2. 自动续约 - 支持自动续约
|
2. 自动续约 - 支持自动续约
|
||||||
3. 自动签到 - 支持自动签到
|
3. 自动签到 - 支持自动签到
|
||||||
4. 远程签到 - 支持远程签到,无需在图书馆网络环境下即可签到
|
4. 远程签到 - 支持远程签到,无需在图书馆网络环境下即可签到
|
||||||
5. 批量操作 - 支持同时预约多个用户,可以指定当前需要跳过的用户,并将用户分成多个组
|
|
||||||
6. 定时任务 - 使用内置定时任务管理,添加定时任务,指定时间后按当前预约信息自动运行,支持设置重复任务
|
#### 辅助特性
|
||||||
7. 驱动管理 - 内置浏览器驱动自动管理,支持自动检测浏览器版本并下载对应驱动,无需手动下载
|
|
||||||
|
1. 批量操作 - 支持同时预约多个用户,可以指定当前需要跳过的用户,并将用户分成多个组
|
||||||
|
2. 定时任务 - 使用内置定时任务管理,添加定时任务,指定时间后按当前预约信息自动运行,支持设置重复任务
|
||||||
|
3. 驱动管理 - 内置浏览器驱动自动管理,支持自动检测浏览器版本并下载对应驱动,无需手动下载
|
||||||
|
4. 自定义主题 - 支持深浅色主题切换,多款应用样式切换,可导入 .altheme 自定义主题一键换肤
|
||||||
|
|
||||||
|
> [!TIP]
|
||||||
|
> 前往 [主题市场](https://www.autolibrary.kenanzhu.com/marketplace) 获取和分享更多自定义主题。
|
||||||
|
|
||||||
*具体操作方法和注意事项请访问我们的 [帮助手册](https://manuals.autolibrary.kenanzhu.com)*
|
*具体操作方法和注意事项请访问我们的 [帮助手册](https://manuals.autolibrary.kenanzhu.com)*
|
||||||
|
|
||||||
@@ -39,7 +48,7 @@
|
|||||||
|
|
||||||
#### 平台支持 & 编译步骤
|
#### 平台支持 & 编译步骤
|
||||||
|
|
||||||
本工具目前仅支持 Windows 平台,由于使用 PySide6 库开发,理论上是可以自行编译并在 Linux 和 macOS 上运行,这里提供简单的编译步骤:
|
本工具目前支持 Windows 和 macOS(Apple Silicon)平台,由于使用 PySide6 库开发,理论上是可以自行编译并在 Linux 和旧架构 macOS 上运行,这里提供简单的编译步骤:
|
||||||
|
|
||||||
1. 确保系统安装了 Python 3.13 版本 (推荐,过低或高版本会导致兼容问题)。
|
1. 确保系统安装了 Python 3.13 版本 (推荐,过低或高版本会导致兼容问题)。
|
||||||
2. 安装所有依赖库,命令为 `pip install -r requirements.txt` (建议在虚拟环境下操作)。
|
2. 安装所有依赖库,命令为 `pip install -r requirements.txt` (建议在虚拟环境下操作)。
|
||||||
@@ -71,7 +80,7 @@ def classification(self, img: bytes):
|
|||||||
只要确保处于校园网网络环境内,工具都是可以正常运行的。操作处理速度基本上取决于校园网的网络环境,一般情况下在 3-4 秒(不考虑硬件差异)左右即可完成一个用户的操作,完全满足正常使用需求。
|
只要确保处于校园网网络环境内,工具都是可以正常运行的。操作处理速度基本上取决于校园网的网络环境,一般情况下在 3-4 秒(不考虑硬件差异)左右即可完成一个用户的操作,完全满足正常使用需求。
|
||||||
|
|
||||||
> [!NOTE]
|
> [!NOTE]
|
||||||
> 工具仅作为正常的预约,签到和续约的图书馆辅助工具,请勿干扰图书馆的正常运行(如故意预约多个座位,或同时预约大量的用户等,对此影响图书馆正常运行本工具概不负责,请在善用工具方便自己的情况下尽量不用影响其他同学的使用)。
|
> 工具仅作为正常的预约,签到和续约的图书馆辅助工具,请勿干扰图书馆的正常运行(如故意预约多个座位,或同时预约大量的用户等)由此影响图书馆正常运行的情况本工具概不负责,请在善用工具方便自己的情况下尽量不用影响其他同学的使用。
|
||||||
|
|
||||||
#### 关于批量操作
|
#### 关于批量操作
|
||||||
|
|
||||||
@@ -103,7 +112,7 @@ def classification(self, img: bytes):
|
|||||||
|
|
||||||
当前版本的功能对于正常使用已经足够,不过后续会着重完善预约时的使用体验,暂时有以下构想:
|
当前版本的功能对于正常使用已经足够,不过后续会着重完善预约时的使用体验,暂时有以下构想:
|
||||||
|
|
||||||
- 引入交互预约面板功能,预约时直接在座位分布图中选择可用座位,并按用户分配,无需事先配置预约信息。
|
- ~~引入交互预约面板功能,预约时直接在座位分布图中选择可用座位,并按用户分配,无需事先配置预约信息。~~ (不计划)
|
||||||
- ~~优化定时任务管理功能,用户可以在定时任务管理界面设置重复运行的定时任务,如每日预约、每周预约等。~~ (已完成)
|
- ~~优化定时任务管理功能,用户可以在定时任务管理界面设置重复运行的定时任务,如每日预约、每周预约等。~~ (已完成)
|
||||||
- 软件的自动更新以及公告栏功能,用户可以自动更新最新版本并获取最新公告事项。
|
- 软件的自动更新以及公告栏功能,用户可以自动更新最新版本并获取最新公告事项。
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
@@ -211,12 +211,16 @@ class ALAutoScriptEditDialog(QDialog):
|
|||||||
Layout.setSpacing(3)
|
Layout.setSpacing(3)
|
||||||
Layout.setContentsMargins(3, 3, 3, 3)
|
Layout.setContentsMargins(3, 3, 3, 3)
|
||||||
ToolbarLayout = QHBoxLayout()
|
ToolbarLayout = QHBoxLayout()
|
||||||
self.ZoomInBtn = QPushButton("+")
|
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.ZoomInBtn.setFixedSize(25, 25)
|
||||||
self.ZoomOutBtn = QPushButton("-")
|
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.ZoomOutBtn.setFixedSize(25, 25)
|
||||||
self.ZoomResetBtn = QPushButton("")
|
self.ZoomResetBtn = QPushButton("")
|
||||||
self.ZoomResetBtn.setIcon(qta.icon("fa5s.undo", color=self._iconColor()))
|
self.ZoomResetBtn.setIcon(qta.icon("fa6s.rotate-left", color=self._iconColor()))
|
||||||
self.ZoomResetBtn.setIconSize(QSize(14, 14))
|
self.ZoomResetBtn.setIconSize(QSize(14, 14))
|
||||||
self.ZoomResetBtn.setFixedSize(25, 25)
|
self.ZoomResetBtn.setFixedSize(25, 25)
|
||||||
self.ZoomResetBtn.setToolTip("重置缩放")
|
self.ZoomResetBtn.setToolTip("重置缩放")
|
||||||
@@ -241,7 +245,7 @@ class ALAutoScriptEditDialog(QDialog):
|
|||||||
ToolbarLayout.addWidget(self.ZoomLabel)
|
ToolbarLayout.addWidget(self.ZoomLabel)
|
||||||
ToolbarLayout.addStretch()
|
ToolbarLayout.addStretch()
|
||||||
self.CopyBtn = QPushButton("")
|
self.CopyBtn = QPushButton("")
|
||||||
self.CopyBtn.setIcon(qta.icon("fa5s.copy", color=self._iconColor()))
|
self.CopyBtn.setIcon(qta.icon("fa6s.copy", color=self._iconColor()))
|
||||||
self.CopyBtn.setIconSize(QSize(14, 14))
|
self.CopyBtn.setIconSize(QSize(14, 14))
|
||||||
self.CopyBtn.setFixedSize(25, 25)
|
self.CopyBtn.setFixedSize(25, 25)
|
||||||
self.CopyBtn.setToolTip("复制脚本")
|
self.CopyBtn.setToolTip("复制脚本")
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ from gui.ALUserTreeWidget import (
|
|||||||
ALUserTreeWidget
|
ALUserTreeWidget
|
||||||
)
|
)
|
||||||
from gui.ALWebDriverDownloadDialog import ALWebDriverDownloadDialog
|
from gui.ALWebDriverDownloadDialog import ALWebDriverDownloadDialog
|
||||||
|
from gui.ALWidgetMixin import CenterOnParentMixin
|
||||||
from gui.resources.ui.Ui_ALConfigWidget import Ui_ALConfigWidget
|
from gui.resources.ui.Ui_ALConfigWidget import Ui_ALConfigWidget
|
||||||
from interfaces.ConfigProvider import (
|
from interfaces.ConfigProvider import (
|
||||||
CfgKey,
|
CfgKey,
|
||||||
@@ -52,7 +53,7 @@ from utils.JSONReader import JSONReader
|
|||||||
from utils.JSONWriter import JSONWriter
|
from utils.JSONWriter import JSONWriter
|
||||||
|
|
||||||
|
|
||||||
class ALConfigWidget(QWidget, Ui_ALConfigWidget):
|
class ALConfigWidget(CenterOnParentMixin, QWidget, Ui_ALConfigWidget):
|
||||||
|
|
||||||
configWidgetIsClosed = Signal()
|
configWidgetIsClosed = Signal()
|
||||||
|
|
||||||
@@ -110,29 +111,6 @@ class ALConfigWidget(QWidget, Ui_ALConfigWidget):
|
|||||||
self.ConfirmButton.clicked.connect(self.onConfirmButtonClicked)
|
self.ConfirmButton.clicked.connect(self.onConfirmButtonClicked)
|
||||||
self.CancelButton.clicked.connect(self.onCancelButtonClicked)
|
self.CancelButton.clicked.connect(self.onCancelButtonClicked)
|
||||||
|
|
||||||
def showEvent(
|
|
||||||
self,
|
|
||||||
event
|
|
||||||
):
|
|
||||||
|
|
||||||
result = super().showEvent(event)
|
|
||||||
|
|
||||||
screen_rect = self.screen().geometry()
|
|
||||||
target_pos = self.parent().geometry().center()
|
|
||||||
target_pos.setX(target_pos.x() - self.width()//2)
|
|
||||||
target_pos.setY(target_pos.y() - self.height()//2)
|
|
||||||
if target_pos.x() < 0:
|
|
||||||
target_pos.setX(0)
|
|
||||||
if target_pos.x() + self.width() > screen_rect.width():
|
|
||||||
target_pos.setX(screen_rect.width() - self.width())
|
|
||||||
if target_pos.y() < 0:
|
|
||||||
target_pos.setY(0)
|
|
||||||
if target_pos.y() + self.height() > screen_rect.height():
|
|
||||||
target_pos.setY(screen_rect.height() - self.height())
|
|
||||||
self.move(target_pos)
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
def closeEvent(
|
def closeEvent(
|
||||||
self,
|
self,
|
||||||
event: QCloseEvent
|
event: QCloseEvent
|
||||||
|
|||||||
@@ -24,9 +24,9 @@ from PySide6.QtWidgets import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
from gui.ALSeatMapView import ALSeatMapView
|
from gui.ALSeatMapView import ALSeatMapView
|
||||||
|
from gui.ALWidgetMixin import CenterOnParentMixin
|
||||||
|
|
||||||
|
class ALSeatMapSelectDialog(CenterOnParentMixin, QDialog):
|
||||||
class ALSeatMapSelectDialog(QDialog):
|
|
||||||
|
|
||||||
seatMapSelectDialogIsClosed = Signal(list)
|
seatMapSelectDialogIsClosed = Signal(list)
|
||||||
|
|
||||||
@@ -96,29 +96,6 @@ class ALSeatMapSelectDialog(QDialog):
|
|||||||
self.ConfirmButton.clicked.connect(self.onConfirmButtonClicked)
|
self.ConfirmButton.clicked.connect(self.onConfirmButtonClicked)
|
||||||
self.CancelButton.clicked.connect(self.onCancelButtonClicked)
|
self.CancelButton.clicked.connect(self.onCancelButtonClicked)
|
||||||
|
|
||||||
def showEvent(
|
|
||||||
self,
|
|
||||||
event
|
|
||||||
):
|
|
||||||
|
|
||||||
result = super().showEvent(event)
|
|
||||||
|
|
||||||
screen_rect = self.screen().geometry()
|
|
||||||
target_pos = self.parent().geometry().center()
|
|
||||||
target_pos.setX(target_pos.x() - self.width()//2)
|
|
||||||
target_pos.setY(target_pos.y() - self.height()//2)
|
|
||||||
if target_pos.x() < 0:
|
|
||||||
target_pos.setX(0)
|
|
||||||
if target_pos.x() + self.width() > screen_rect.width():
|
|
||||||
target_pos.setX(screen_rect.width() - self.width())
|
|
||||||
if target_pos.y() < 0:
|
|
||||||
target_pos.setY(0)
|
|
||||||
if target_pos.y() + self.height() > screen_rect.height():
|
|
||||||
target_pos.setY(screen_rect.height() - self.height())
|
|
||||||
self.move(target_pos)
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
def closeEvent(
|
def closeEvent(
|
||||||
self,
|
self,
|
||||||
event: QCloseEvent
|
event: QCloseEvent
|
||||||
|
|||||||
+128
-103
@@ -19,8 +19,7 @@ from PySide6.QtCore import (
|
|||||||
Slot
|
Slot
|
||||||
)
|
)
|
||||||
from PySide6.QtGui import (
|
from PySide6.QtGui import (
|
||||||
QCloseEvent,
|
QCloseEvent
|
||||||
QShowEvent
|
|
||||||
)
|
)
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
QApplication,
|
QApplication,
|
||||||
@@ -38,6 +37,7 @@ from managers.theme.ThemeManager import(
|
|||||||
instance as themeInstance
|
instance as themeInstance
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from gui.ALWidgetMixin import CenterOnParentMixin
|
||||||
from gui.resources.ui.Ui_ALSettingsWidget import Ui_ALSettingsWidget
|
from gui.resources.ui.Ui_ALSettingsWidget import Ui_ALSettingsWidget
|
||||||
from interfaces.ConfigProvider import (
|
from interfaces.ConfigProvider import (
|
||||||
CfgKey,
|
CfgKey,
|
||||||
@@ -83,7 +83,7 @@ def _restartApp(
|
|||||||
QProcess.startDetached(sys.executable, sys.argv)
|
QProcess.startDetached(sys.executable, sys.argv)
|
||||||
|
|
||||||
|
|
||||||
class ALSettingsWidget(QWidget, Ui_ALSettingsWidget):
|
class ALSettingsWidget(CenterOnParentMixin, QWidget, Ui_ALSettingsWidget):
|
||||||
|
|
||||||
settingsWidgetIsClosed = Signal()
|
settingsWidgetIsClosed = Signal()
|
||||||
|
|
||||||
@@ -102,20 +102,36 @@ class ALSettingsWidget(QWidget, Ui_ALSettingsWidget):
|
|||||||
self.connectSignals()
|
self.connectSignals()
|
||||||
self.loadSettings()
|
self.loadSettings()
|
||||||
|
|
||||||
|
def closeEvent(
|
||||||
|
self,
|
||||||
|
event: QCloseEvent
|
||||||
|
):
|
||||||
|
|
||||||
|
self.settingsWidgetIsClosed.emit()
|
||||||
|
super().closeEvent(event)
|
||||||
|
|
||||||
def modifyUi(
|
def modifyUi(
|
||||||
self
|
self
|
||||||
):
|
):
|
||||||
|
|
||||||
self.setWindowFlags(Qt.WindowType.Window)
|
self.setWindowFlags(Qt.WindowType.Window)
|
||||||
self.NavigationList.setCurrentRow(0)
|
|
||||||
self.populateStyles()
|
|
||||||
self.setNavigationIcons()
|
self.setNavigationIcons()
|
||||||
self.ThemeInfoLabel.setTextFormat(Qt.TextFormat.RichText)
|
color = QApplication.instance().palette().color(
|
||||||
self.ThemeInfoLabel.setStyleSheet(
|
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: 1px solid palette(mid);"\
|
||||||
"border-radius: 2px;"\
|
"border-radius: 2px;"\
|
||||||
"padding: 5px;"
|
"padding: 5px;"
|
||||||
)
|
)
|
||||||
|
self.NavigationList.setCurrentRow(0)
|
||||||
|
self.populateStyles()
|
||||||
|
self.populateCustomThemes()
|
||||||
|
|
||||||
def setNavigationIcons(
|
def setNavigationIcons(
|
||||||
self
|
self
|
||||||
@@ -125,7 +141,7 @@ class ALSettingsWidget(QWidget, Ui_ALSettingsWidget):
|
|||||||
color = app.palette().color(app.palette().ColorRole.WindowText).name()
|
color = app.palette().color(app.palette().ColorRole.WindowText).name()
|
||||||
item = self.NavigationList.item(0)
|
item = self.NavigationList.item(0)
|
||||||
if item:
|
if item:
|
||||||
item.setIcon(qta.icon("fa5s.palette", color=color))
|
item.setIcon(qta.icon("fa6s.palette", color=color))
|
||||||
|
|
||||||
def populateStyles(
|
def populateStyles(
|
||||||
self
|
self
|
||||||
@@ -134,46 +150,36 @@ class ALSettingsWidget(QWidget, Ui_ALSettingsWidget):
|
|||||||
self.StyleComboBox.clear()
|
self.StyleComboBox.clear()
|
||||||
self.StyleComboBox.addItems(QStyleFactory.keys())
|
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(
|
def connectSignals(
|
||||||
self
|
self
|
||||||
):
|
):
|
||||||
|
|
||||||
self.BrowseQssButton.clicked.connect(self.onImportThemeButtonClicked)
|
self.ImportCustomThemeButton.clicked.connect(self.onImportCustomThemeButtonClicked)
|
||||||
self.ThemeComboBox.currentIndexChanged.connect(self.onThemeComboBoxChanged)
|
self.RemoveCustomThemeButton.clicked.connect(self.onRemoveCustomThemeButtonClicked)
|
||||||
self.ResetThemeButton.clicked.connect(self.onResetThemeButtonClicked)
|
self.CustomThemeComboBox.currentIndexChanged.connect(self.onCustomThemeComboBoxChanged)
|
||||||
|
self.ResetCustomThemeButton.clicked.connect(self.onResetCustomThemeButtonClicked)
|
||||||
self.CancelButton.clicked.connect(self.onCancelButtonClicked)
|
self.CancelButton.clicked.connect(self.onCancelButtonClicked)
|
||||||
self.ApplyButton.clicked.connect(self.onApplyButtonClicked)
|
self.ApplyButton.clicked.connect(self.onApplyButtonClicked)
|
||||||
self.ConfirmButton.clicked.connect(self.onConfirmButtonClicked)
|
self.ConfirmButton.clicked.connect(self.onConfirmButtonClicked)
|
||||||
|
|
||||||
def showEvent(
|
|
||||||
self,
|
|
||||||
event: QShowEvent
|
|
||||||
):
|
|
||||||
|
|
||||||
result = super().showEvent(event)
|
|
||||||
screen_rect = self.screen().geometry()
|
|
||||||
target_pos = self.parent().geometry().center()
|
|
||||||
target_pos.setX(target_pos.x() - self.width()//2)
|
|
||||||
target_pos.setY(target_pos.y() - self.height()//2)
|
|
||||||
if target_pos.x() < 0:
|
|
||||||
target_pos.setX(0)
|
|
||||||
if target_pos.x() + self.width() > screen_rect.width():
|
|
||||||
target_pos.setX(screen_rect.width() - self.width())
|
|
||||||
if target_pos.y() < 0:
|
|
||||||
target_pos.setY(0)
|
|
||||||
if target_pos.y() + self.height() > screen_rect.height():
|
|
||||||
target_pos.setY(screen_rect.height() - self.height())
|
|
||||||
self.move(target_pos)
|
|
||||||
return result
|
|
||||||
|
|
||||||
def closeEvent(
|
|
||||||
self,
|
|
||||||
event: QCloseEvent
|
|
||||||
):
|
|
||||||
|
|
||||||
self.settingsWidgetIsClosed.emit()
|
|
||||||
super().closeEvent(event)
|
|
||||||
|
|
||||||
def loadSettings(
|
def loadSettings(
|
||||||
self
|
self
|
||||||
):
|
):
|
||||||
@@ -194,44 +200,46 @@ class ALSettingsWidget(QWidget, Ui_ALSettingsWidget):
|
|||||||
if index < 0:
|
if index < 0:
|
||||||
index = 0
|
index = 0
|
||||||
self.StyleComboBox.setCurrentIndex(index)
|
self.StyleComboBox.setCurrentIndex(index)
|
||||||
self.populateThemeList()
|
|
||||||
if custom_theme:
|
if custom_theme:
|
||||||
idx = self.ThemeComboBox.findText(custom_theme)
|
idx = self.CustomThemeComboBox.findData(custom_theme)
|
||||||
if idx >= 0:
|
if idx >= 0:
|
||||||
self.ThemeComboBox.setCurrentIndex(idx)
|
self.CustomThemeComboBox.setCurrentIndex(idx)
|
||||||
self.updateThemeStatus()
|
self.updateCustomThemeInfo()
|
||||||
self.updateThemeInfo()
|
self.updateCustomThemeStatus()
|
||||||
|
|
||||||
def updateThemeStatus(
|
def updateCustomThemeInfo(
|
||||||
self
|
self
|
||||||
):
|
):
|
||||||
|
|
||||||
name = self.ThemeComboBox.currentText()
|
file = self.CustomThemeComboBox.currentData()
|
||||||
if name and name != "默认":
|
if not file:
|
||||||
self.QssStatusLabel.setText(f"当前使用 {name} 主题。")
|
self.CustomThemeInfoLabel.setText("")
|
||||||
else:
|
|
||||||
self.QssStatusLabel.setText("当前使用 默认 主题。")
|
|
||||||
|
|
||||||
def updateThemeInfo(
|
|
||||||
self
|
|
||||||
):
|
|
||||||
|
|
||||||
name = self.ThemeComboBox.currentText()
|
|
||||||
if not name or name == "默认":
|
|
||||||
self.ThemeInfoLabel.setText("")
|
|
||||||
return
|
return
|
||||||
t = self.__theme_cache.get(name)
|
t = self.__theme_cache.get(file)
|
||||||
if t:
|
if t:
|
||||||
author = t.get("author", "未知")
|
name = t.get("name", "未知")
|
||||||
|
author = t.get("author", "未知作者")
|
||||||
need_theme = t.get("need_theme", "both")
|
need_theme = t.get("need_theme", "both")
|
||||||
brief = t.get("brief", "没有相关简介")
|
brief = t.get("brief", "没有相关简介")
|
||||||
self.ThemeInfoLabel.setText(
|
self.CustomThemeInfoLabel.setText(
|
||||||
f"<b>{name}</b> - 适用于 <i>{_themeToReadable(need_theme)}</i> 主题<br>"
|
f"<b>{name}</b> - 适用于 <i>{_themeToReadable(need_theme)}</i> 主题<br>"
|
||||||
f"作者:{author}<br><br>"
|
f"作者:{author}<br><br>"
|
||||||
f"{brief}"
|
f"{brief}"
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
self.ThemeInfoLabel.setText("")
|
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(
|
def syncRadioFromNeedTheme(
|
||||||
self,
|
self,
|
||||||
@@ -257,8 +265,8 @@ class ALSettingsWidget(QWidget, Ui_ALSettingsWidget):
|
|||||||
else:
|
else:
|
||||||
theme = "system"
|
theme = "system"
|
||||||
style = self.StyleComboBox.currentText()
|
style = self.StyleComboBox.currentText()
|
||||||
custom_theme = self.ThemeComboBox.currentText()
|
custom_theme = self.CustomThemeComboBox.currentData() or ""
|
||||||
if custom_theme == "默认":
|
if not custom_theme:
|
||||||
custom_theme = ""
|
custom_theme = ""
|
||||||
return theme, style, custom_theme
|
return theme, style, custom_theme
|
||||||
|
|
||||||
@@ -278,8 +286,8 @@ class ALSettingsWidget(QWidget, Ui_ALSettingsWidget):
|
|||||||
theme, _, _ = self.collectSettings()
|
theme, _, _ = self.collectSettings()
|
||||||
self.__cfg_mgr.set(CfgKey.GLOBAL.APPEARANCE.THEME, theme)
|
self.__cfg_mgr.set(CfgKey.GLOBAL.APPEARANCE.THEME, theme)
|
||||||
self.setNavigationIcons()
|
self.setNavigationIcons()
|
||||||
self.updateThemeStatus()
|
self.updateCustomThemeStatus()
|
||||||
self.updateThemeInfo()
|
self.updateCustomThemeInfo()
|
||||||
self.__original_theme = theme
|
self.__original_theme = theme
|
||||||
self.__original_custom_theme = custom_theme if custom_theme else ""
|
self.__original_custom_theme = custom_theme if custom_theme else ""
|
||||||
self.__original_style = getActiveStyle()
|
self.__original_style = getActiveStyle()
|
||||||
@@ -300,24 +308,45 @@ class ALSettingsWidget(QWidget, Ui_ALSettingsWidget):
|
|||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def populateThemeList(
|
@Slot()
|
||||||
|
def onRemoveCustomThemeButtonClicked(
|
||||||
self
|
self
|
||||||
):
|
):
|
||||||
|
|
||||||
self.ThemeComboBox.blockSignals(True)
|
file = self.CustomThemeComboBox.currentData()
|
||||||
self.ThemeComboBox.clear()
|
if not file:
|
||||||
self.ThemeComboBox.addItem("默认")
|
QMessageBox.information(
|
||||||
self.__theme_cache = {}
|
self,
|
||||||
themes = themeInstance().listThemes()
|
"提示 - AutoLibrary",
|
||||||
for t in themes:
|
"请先选择一个主题。"
|
||||||
name = t.get("name", "")
|
)
|
||||||
if name:
|
return
|
||||||
self.__theme_cache[name] = t
|
t = self.__theme_cache.get(file)
|
||||||
self.ThemeComboBox.addItem(name)
|
name = t.get("name", file) if t else file
|
||||||
self.ThemeComboBox.blockSignals(False)
|
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()
|
@Slot()
|
||||||
def onImportThemeButtonClicked(
|
def onImportCustomThemeButtonClicked(
|
||||||
self
|
self
|
||||||
):
|
):
|
||||||
|
|
||||||
@@ -330,13 +359,13 @@ class ALSettingsWidget(QWidget, Ui_ALSettingsWidget):
|
|||||||
if not file_path:
|
if not file_path:
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
name = themeInstance().importTheme(file_path)
|
file_id = themeInstance().importTheme(file_path)
|
||||||
self.populateThemeList()
|
self.populateCustomThemes()
|
||||||
idx = self.ThemeComboBox.findText(name)
|
idx = self.CustomThemeComboBox.findData(file_id)
|
||||||
if idx >= 0:
|
if idx >= 0:
|
||||||
self.ThemeComboBox.setCurrentIndex(idx)
|
self.CustomThemeComboBox.setCurrentIndex(idx)
|
||||||
self.updateThemeStatus()
|
self.updateCustomThemeStatus()
|
||||||
self.updateThemeInfo()
|
self.updateCustomThemeInfo()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
QMessageBox.warning(
|
QMessageBox.warning(
|
||||||
self,
|
self,
|
||||||
@@ -345,37 +374,37 @@ class ALSettingsWidget(QWidget, Ui_ALSettingsWidget):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@Slot()
|
@Slot()
|
||||||
def onThemeComboBoxChanged(
|
def onCustomThemeComboBoxChanged(
|
||||||
self,
|
self,
|
||||||
index: int
|
index: int
|
||||||
):
|
):
|
||||||
|
|
||||||
self.updateThemeInfo()
|
self.updateCustomThemeInfo()
|
||||||
|
# no status update, because custom theme is not applied yet.
|
||||||
|
|
||||||
@Slot()
|
@Slot()
|
||||||
def onResetThemeButtonClicked(
|
def onResetCustomThemeButtonClicked(
|
||||||
self
|
self
|
||||||
):
|
):
|
||||||
|
|
||||||
self.ThemeComboBox.blockSignals(True)
|
self.CustomThemeComboBox.blockSignals(True)
|
||||||
if self.__original_custom_theme:
|
if self.__original_custom_theme:
|
||||||
idx = self.ThemeComboBox.findText(self.__original_custom_theme)
|
idx = self.CustomThemeComboBox.findData(self.__original_custom_theme)
|
||||||
if idx >= 0:
|
if idx >= 0:
|
||||||
self.ThemeComboBox.setCurrentIndex(idx)
|
self.CustomThemeComboBox.setCurrentIndex(idx)
|
||||||
else:
|
else:
|
||||||
self.ThemeComboBox.setCurrentIndex(0)
|
self.CustomThemeComboBox.setCurrentIndex(0)
|
||||||
else:
|
else:
|
||||||
self.ThemeComboBox.setCurrentIndex(0)
|
self.CustomThemeComboBox.setCurrentIndex(0)
|
||||||
self.ThemeComboBox.blockSignals(False)
|
self.CustomThemeComboBox.blockSignals(False)
|
||||||
if self.__original_theme == "light":
|
if self.__original_theme == "light":
|
||||||
self.LightThemeRadio.setChecked(True)
|
self.LightThemeRadio.setChecked(True)
|
||||||
elif self.__original_theme == "dark":
|
elif self.__original_theme == "dark":
|
||||||
self.DarkThemeRadio.setChecked(True)
|
self.DarkThemeRadio.setChecked(True)
|
||||||
else:
|
else:
|
||||||
self.SystemThemeRadio.setChecked(True)
|
self.SystemThemeRadio.setChecked(True)
|
||||||
_applyCustomTheme(self.__original_custom_theme, self.__original_theme)
|
self.updateCustomThemeInfo()
|
||||||
self.updateThemeStatus()
|
self.updateCustomThemeStatus()
|
||||||
self.updateThemeInfo()
|
|
||||||
|
|
||||||
@Slot()
|
@Slot()
|
||||||
def onCancelButtonClicked(
|
def onCancelButtonClicked(
|
||||||
@@ -400,9 +429,5 @@ class ALSettingsWidget(QWidget, Ui_ALSettingsWidget):
|
|||||||
self
|
self
|
||||||
):
|
):
|
||||||
|
|
||||||
_, style, _ = self.collectSettings()
|
self.onApplyButtonClicked() # virtually call apply button clicked
|
||||||
style_changed = self.__original_style != style
|
|
||||||
self.saveAndApply()
|
|
||||||
if style_changed:
|
|
||||||
self.maybeRestart()
|
|
||||||
self.close()
|
self.close()
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ from gui.ALTimerTaskAddDialog import (
|
|||||||
ALTimerTaskStatus
|
ALTimerTaskStatus
|
||||||
)
|
)
|
||||||
from gui.ALTimerTaskHistoryDialog import ALTimerTaskHistoryDialog
|
from gui.ALTimerTaskHistoryDialog import ALTimerTaskHistoryDialog
|
||||||
|
from gui.ALWidgetMixin import CenterOnParentMixin
|
||||||
from gui.resources.ui.Ui_ALTimerTaskManageWidget import Ui_ALTimerTaskManageWidget
|
from gui.resources.ui.Ui_ALTimerTaskManageWidget import Ui_ALTimerTaskManageWidget
|
||||||
from interfaces.ConfigProvider import (
|
from interfaces.ConfigProvider import (
|
||||||
CfgKey,
|
CfgKey,
|
||||||
@@ -189,7 +190,7 @@ class ALTimerTaskItemWidget(QWidget):
|
|||||||
Menu.exec(self.mapToGlobal(pos))
|
Menu.exec(self.mapToGlobal(pos))
|
||||||
|
|
||||||
|
|
||||||
class ALTimerTaskManageWidget(QWidget, Ui_ALTimerTaskManageWidget):
|
class ALTimerTaskManageWidget(CenterOnParentMixin, QWidget, Ui_ALTimerTaskManageWidget):
|
||||||
|
|
||||||
class SortPolicy(Enum):
|
class SortPolicy(Enum):
|
||||||
|
|
||||||
@@ -299,29 +300,6 @@ class ALTimerTaskManageWidget(QWidget, Ui_ALTimerTaskManageWidget):
|
|||||||
)
|
)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def showEvent(
|
|
||||||
self,
|
|
||||||
event
|
|
||||||
):
|
|
||||||
|
|
||||||
result = super().showEvent(event)
|
|
||||||
|
|
||||||
screen_rect = self.screen().geometry()
|
|
||||||
target_pos = self.parent().geometry().center()
|
|
||||||
target_pos.setX(target_pos.x() - self.width()//2)
|
|
||||||
target_pos.setY(target_pos.y() - self.height()//2)
|
|
||||||
if target_pos.x() < 0:
|
|
||||||
target_pos.setX(0)
|
|
||||||
if target_pos.x() + self.width() > screen_rect.width():
|
|
||||||
target_pos.setX(screen_rect.width() - self.width())
|
|
||||||
if target_pos.y() < 0:
|
|
||||||
target_pos.setY(0)
|
|
||||||
if target_pos.y() + self.height() > screen_rect.height():
|
|
||||||
target_pos.setY(screen_rect.height() - self.height())
|
|
||||||
self.move(target_pos)
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
def closeEvent(
|
def closeEvent(
|
||||||
self,
|
self,
|
||||||
event: QCloseEvent
|
event: QCloseEvent
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ from managers.driver.WebDriverManager import (
|
|||||||
WebDriverStatus
|
WebDriverStatus
|
||||||
)
|
)
|
||||||
from gui.ALStatusLabel import ALStatusLabel
|
from gui.ALStatusLabel import ALStatusLabel
|
||||||
|
from gui.ALWidgetMixin import CenterOnParentMixin
|
||||||
|
|
||||||
|
|
||||||
class DownloadWorker(QThread):
|
class DownloadWorker(QThread):
|
||||||
@@ -123,7 +124,7 @@ class DownloadWorker(QThread):
|
|||||||
self.wait()
|
self.wait()
|
||||||
|
|
||||||
|
|
||||||
class ALWebDriverDownloadDialog(QDialog):
|
class ALWebDriverDownloadDialog(CenterOnParentMixin, QDialog):
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -152,28 +153,6 @@ class ALWebDriverDownloadDialog(QDialog):
|
|||||||
self.initializeDriverManager()
|
self.initializeDriverManager()
|
||||||
self.refreshDriverList()
|
self.refreshDriverList()
|
||||||
|
|
||||||
def showEvent(
|
|
||||||
self,
|
|
||||||
event
|
|
||||||
):
|
|
||||||
|
|
||||||
result = super().showEvent(event)
|
|
||||||
if self.parent():
|
|
||||||
screen_rect = self.screen().geometry()
|
|
||||||
target_pos = self.parent().geometry().center()
|
|
||||||
target_pos.setX(target_pos.x() - self.width()//2)
|
|
||||||
target_pos.setY(target_pos.y() - self.height()//2)
|
|
||||||
if target_pos.x() < 0:
|
|
||||||
target_pos.setX(0)
|
|
||||||
if target_pos.x() + self.width() > screen_rect.width():
|
|
||||||
target_pos.setX(screen_rect.width() - self.width())
|
|
||||||
if target_pos.y() < 0:
|
|
||||||
target_pos.setY(0)
|
|
||||||
if target_pos.y() + self.height() > screen_rect.height():
|
|
||||||
target_pos.setY(screen_rect.height() - self.height())
|
|
||||||
self.move(target_pos)
|
|
||||||
return result
|
|
||||||
|
|
||||||
def setupUi(
|
def setupUi(
|
||||||
self
|
self
|
||||||
):
|
):
|
||||||
|
|||||||
@@ -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)
|
||||||
Binary file not shown.
@@ -115,9 +115,9 @@
|
|||||||
<property name="geometry">
|
<property name="geometry">
|
||||||
<rect>
|
<rect>
|
||||||
<x>0</x>
|
<x>0</x>
|
||||||
<y>0</y>
|
<y>-51</y>
|
||||||
<width>450</width>
|
<width>397</width>
|
||||||
<height>380</height>
|
<height>434</height>
|
||||||
</rect>
|
</rect>
|
||||||
</property>
|
</property>
|
||||||
<layout class="QVBoxLayout" name="AppearancePageLayout">
|
<layout class="QVBoxLayout" name="AppearancePageLayout">
|
||||||
@@ -138,305 +138,308 @@
|
|||||||
</property>
|
</property>
|
||||||
<item>
|
<item>
|
||||||
<widget class="QGroupBox" name="AppearanceGroupBox">
|
<widget class="QGroupBox" name="AppearanceGroupBox">
|
||||||
<property name="title">
|
<property name="title">
|
||||||
<string>主题模式</string>
|
<string>主题模式</string>
|
||||||
</property>
|
</property>
|
||||||
<layout class="QVBoxLayout" name="AppearanceGroupBoxLayout">
|
<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">
|
<property name="spacing">
|
||||||
<number>5</number>
|
<number>5</number>
|
||||||
</property>
|
</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>
|
<item>
|
||||||
<widget class="QLabel" name="StyleSelectLabel">
|
<widget class="QRadioButton" name="LightThemeRadio">
|
||||||
<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">
|
<property name="text">
|
||||||
<string>应用程序样式:</string>
|
<string>浅色</string>
|
||||||
</property>
|
</property>
|
||||||
</widget>
|
</widget>
|
||||||
</item>
|
</item>
|
||||||
<item>
|
<item>
|
||||||
<widget class="QComboBox" name="StyleComboBox">
|
<widget class="QRadioButton" name="DarkThemeRadio">
|
||||||
<property name="minimumSize">
|
<property name="text">
|
||||||
<size>
|
<string>深色</string>
|
||||||
<width>160</width>
|
</property>
|
||||||
<height>25</height>
|
</widget>
|
||||||
</size>
|
</item>
|
||||||
|
<item>
|
||||||
|
<widget class="QRadioButton" name="SystemThemeRadio">
|
||||||
|
<property name="text">
|
||||||
|
<string>跟随系统</string>
|
||||||
|
</property>
|
||||||
|
<property name="checked">
|
||||||
|
<bool>true</bool>
|
||||||
</property>
|
</property>
|
||||||
</widget>
|
</widget>
|
||||||
</item>
|
</item>
|
||||||
</layout>
|
</layout>
|
||||||
</item>
|
</widget>
|
||||||
<item>
|
</item>
|
||||||
<widget class="QLabel" name="StyleHintLabel">
|
<item>
|
||||||
<property name="text">
|
<widget class="QGroupBox" name="InterfaceGroupBox">
|
||||||
<string>更改样式将在下次启动应用程序时生效。</string>
|
<property name="title">
|
||||||
</property>
|
<string>界面风格</string>
|
||||||
</widget>
|
</property>
|
||||||
</item>
|
<layout class="QVBoxLayout" name="InterfaceGroupBoxLayout">
|
||||||
</layout>
|
|
||||||
</widget>
|
|
||||||
</item>
|
|
||||||
<item>
|
|
||||||
<widget class="QGroupBox" name="CustomQssGroupBox">
|
|
||||||
<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="CustomQssHintLabel">
|
|
||||||
<property name="text">
|
|
||||||
<string>选择一个主题,或导入新的主题文件:</string>
|
|
||||||
</property>
|
|
||||||
<property name="wordWrap">
|
|
||||||
<bool>true</bool>
|
|
||||||
</property>
|
|
||||||
</widget>
|
|
||||||
</item>
|
|
||||||
<item>
|
|
||||||
<layout class="QHBoxLayout" name="QssPathLayout">
|
|
||||||
<property name="spacing">
|
<property name="spacing">
|
||||||
<number>5</number>
|
<number>5</number>
|
||||||
</property>
|
</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>
|
<item>
|
||||||
<widget class="QComboBox" name="ThemeComboBox">
|
<layout class="QHBoxLayout" name="StyleSelectLayout">
|
||||||
<property name="minimumSize">
|
<property name="spacing">
|
||||||
<size>
|
<number>5</number>
|
||||||
<width>160</width>
|
</property>
|
||||||
<height>25</height>
|
<item>
|
||||||
</size>
|
<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>
|
</property>
|
||||||
</widget>
|
</widget>
|
||||||
</item>
|
</item>
|
||||||
<item>
|
<item>
|
||||||
<widget class="QLineEdit" name="QssPathEdit">
|
<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">
|
<property name="minimumSize">
|
||||||
<size>
|
<size>
|
||||||
<width>0</width>
|
<width>0</width>
|
||||||
<height>25</height>
|
<height>60</height>
|
||||||
</size>
|
</size>
|
||||||
</property>
|
</property>
|
||||||
<property name="visible">
|
<property name="text">
|
||||||
<bool>false</bool>
|
<string/>
|
||||||
</property>
|
</property>
|
||||||
<property name="placeholderText">
|
<property name="textFormat">
|
||||||
<string>选择或输入 QSS 样式表文件路径...</string>
|
<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>
|
</property>
|
||||||
</widget>
|
</widget>
|
||||||
</item>
|
</item>
|
||||||
<item>
|
<item>
|
||||||
<widget class="QPushButton" name="BrowseQssButton">
|
<layout class="QHBoxLayout" name="CustomThemeActionLayout">
|
||||||
<property name="minimumSize">
|
<property name="spacing">
|
||||||
<size>
|
<number>5</number>
|
||||||
<width>25</width>
|
|
||||||
<height>25</height>
|
|
||||||
</size>
|
|
||||||
</property>
|
|
||||||
<property name="maximumSize">
|
|
||||||
<size>
|
|
||||||
<width>25</width>
|
|
||||||
<height>25</height>
|
|
||||||
</size>
|
|
||||||
</property>
|
</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">
|
<property name="text">
|
||||||
<string>...</string>
|
<string>当前使用程序 默认 外观。</string>
|
||||||
|
</property>
|
||||||
|
<property name="wordWrap">
|
||||||
|
<bool>true</bool>
|
||||||
</property>
|
</property>
|
||||||
</widget>
|
</widget>
|
||||||
</item>
|
</item>
|
||||||
</layout>
|
</layout>
|
||||||
</item>
|
</widget>
|
||||||
<item>
|
</item>
|
||||||
<widget class="QLabel" name="ThemeInfoLabel">
|
<item>
|
||||||
<property name="minimumSize">
|
<spacer name="AppearancePageSpacer">
|
||||||
<size>
|
<property name="orientation">
|
||||||
<width>0</width>
|
<enum>Qt::Orientation::Vertical</enum>
|
||||||
<height>60</height>
|
</property>
|
||||||
</size>
|
<property name="sizeHint" stdset="0">
|
||||||
</property>
|
<size>
|
||||||
<property name="text">
|
<width>20</width>
|
||||||
<string/>
|
<height>40</height>
|
||||||
</property>
|
</size>
|
||||||
<property name="textFormat">
|
</property>
|
||||||
<enum>Qt::TextFormat::RichText</enum>
|
</spacer>
|
||||||
</property>
|
</item>
|
||||||
<property name="alignment">
|
</layout>
|
||||||
<set>Qt::AlignmentFlag::AlignLeading|Qt::AlignmentFlag::AlignTop</set>
|
</widget>
|
||||||
</property>
|
|
||||||
<property name="wordWrap">
|
|
||||||
<bool>true</bool>
|
|
||||||
</property>
|
|
||||||
</widget>
|
|
||||||
</item>
|
|
||||||
<item>
|
|
||||||
<layout class="QHBoxLayout" name="QssActionLayout">
|
|
||||||
<property name="spacing">
|
|
||||||
<number>5</number>
|
|
||||||
</property>
|
|
||||||
<item>
|
|
||||||
<widget class="QPushButton" name="ApplyQssButton">
|
|
||||||
<property name="minimumSize">
|
|
||||||
<size>
|
|
||||||
<width>80</width>
|
|
||||||
<height>25</height>
|
|
||||||
</size>
|
|
||||||
</property>
|
|
||||||
<property name="visible">
|
|
||||||
<bool>false</bool>
|
|
||||||
</property>
|
|
||||||
<property name="text">
|
|
||||||
<string>应用样式</string>
|
|
||||||
</property>
|
|
||||||
</widget>
|
|
||||||
</item>
|
|
||||||
<item>
|
|
||||||
<widget class="QPushButton" name="ResetThemeButton">
|
|
||||||
<property name="minimumSize">
|
|
||||||
<size>
|
|
||||||
<width>80</width>
|
|
||||||
<height>25</height>
|
|
||||||
</size>
|
|
||||||
</property>
|
|
||||||
<property name="text">
|
|
||||||
<string>重置主题</string>
|
|
||||||
</property>
|
|
||||||
</widget>
|
|
||||||
</item>
|
|
||||||
<item>
|
|
||||||
<spacer name="QssActionSpacer">
|
|
||||||
<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="QssStatusLabel">
|
|
||||||
<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>
|
||||||
</widget>
|
</item>
|
||||||
</item>
|
</layout>
|
||||||
</layout>
|
</item>
|
||||||
</item>
|
|
||||||
<item>
|
<item>
|
||||||
<layout class="QHBoxLayout" name="ButtonLayout">
|
<layout class="QHBoxLayout" name="ButtonLayout">
|
||||||
<property name="spacing">
|
<property name="spacing">
|
||||||
|
|||||||
@@ -360,6 +360,10 @@ class WebDriverDownloader:
|
|||||||
break
|
break
|
||||||
if not driver_file:
|
if not driver_file:
|
||||||
raise FileNotFoundError(f"未找到 web driver 文件 : {expected_name}")
|
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, "解压完成")
|
progress_callback(100, 100, 0.0, "解压完成")
|
||||||
self.download_path.unlink()
|
self.download_path.unlink()
|
||||||
self._cleanup(driver_file)
|
self._cleanup(driver_file)
|
||||||
|
|||||||
@@ -111,6 +111,10 @@ class WebDriverManager:
|
|||||||
for driver_info in self.__driver_infos:
|
for driver_info in self.__driver_infos:
|
||||||
driver_path = self._getDriverPath(driver_info)
|
driver_path = self._getDriverPath(driver_info)
|
||||||
if driver_path and driver_path.exists() and driver_path.is_file():
|
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_path = driver_path
|
||||||
driver_info.driver_status = WebDriverStatus.INSTALLED
|
driver_info.driver_status = WebDriverStatus.INSTALLED
|
||||||
|
|
||||||
|
|||||||
@@ -9,9 +9,7 @@ See the LICENSE file for details.
|
|||||||
"""
|
"""
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
import tempfile
|
|
||||||
import threading
|
import threading
|
||||||
import zipfile
|
|
||||||
|
|
||||||
from PySide6.QtCore import Qt
|
from PySide6.QtCore import Qt
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
@@ -23,9 +21,9 @@ from interfaces.ConfigProvider import CfgKey
|
|||||||
from managers.config.ConfigManager import instance as configInstance
|
from managers.config.ConfigManager import instance as configInstance
|
||||||
from managers.log.LogManager import instance as logInstance
|
from managers.log.LogManager import instance as logInstance
|
||||||
from utils.ThemeUtils import (
|
from utils.ThemeUtils import (
|
||||||
packTheme,
|
|
||||||
readThemeInfo,
|
readThemeInfo,
|
||||||
unpackTheme,
|
readThemeQss,
|
||||||
|
validateTheme,
|
||||||
wrapQssToAtheme
|
wrapQssToAtheme
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -82,6 +80,70 @@ class ThemeManager:
|
|||||||
else:
|
else:
|
||||||
return Qt.ColorScheme.Unknown
|
return Qt.ColorScheme.Unknown
|
||||||
|
|
||||||
|
def _deleteThemeFile(
|
||||||
|
self,
|
||||||
|
name: str
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Delete a theme file in the themes storage directory.
|
||||||
|
|
||||||
|
The caller must hold self.__lock before invoking this method.
|
||||||
|
|
||||||
|
**This method ONLY deletes the file**.
|
||||||
|
"""
|
||||||
|
|
||||||
|
filepath = os.path.join(self.__themes_dir, name + ".altheme")
|
||||||
|
if os.path.isfile(filepath):
|
||||||
|
os.remove(filepath)
|
||||||
|
|
||||||
|
def _resolveDestPath(
|
||||||
|
self,
|
||||||
|
theme_name: str,
|
||||||
|
author: str
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Resolve the destination path for an imported theme.
|
||||||
|
|
||||||
|
If the default {name}.altheme path does not exist, use it directly.
|
||||||
|
If it exists and has a different author, use {name}_{author}.altheme.
|
||||||
|
If it exists and has the same author, raise ValueError.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
theme_name (str): Sanitised theme name.
|
||||||
|
author (str): Theme author string.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: The resolved destination file path.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If a theme with the same name and author already exists.
|
||||||
|
"""
|
||||||
|
|
||||||
|
default_path = os.path.join(self.__themes_dir, theme_name + ".altheme")
|
||||||
|
if not os.path.exists(default_path):
|
||||||
|
return default_path
|
||||||
|
try:
|
||||||
|
existing_info = validateTheme(default_path)
|
||||||
|
existing_author = existing_info.get("author", "")
|
||||||
|
except Exception:
|
||||||
|
self._deleteThemeFile(theme_name) # caller holds the lock
|
||||||
|
raise ValueError(
|
||||||
|
f"主题 '{theme_name}' 已存在但无法通过验证, 已清理该主题文件"
|
||||||
|
)
|
||||||
|
if existing_author == author:
|
||||||
|
raise ValueError(
|
||||||
|
f"主题名称 '{theme_name}' (作者 '{author}') 已存在"
|
||||||
|
)
|
||||||
|
safe_author = os.path.basename(author) if author else "未知作者"
|
||||||
|
alt_path = os.path.join(
|
||||||
|
self.__themes_dir, f"{theme_name}_{safe_author}.altheme"
|
||||||
|
)
|
||||||
|
if os.path.exists(alt_path):
|
||||||
|
raise ValueError(
|
||||||
|
f"主题名称 '{theme_name}' (作者 '{author}') 已存在"
|
||||||
|
)
|
||||||
|
return alt_path
|
||||||
|
|
||||||
def themesDir(
|
def themesDir(
|
||||||
self
|
self
|
||||||
) -> str:
|
) -> str:
|
||||||
@@ -119,35 +181,21 @@ class ThemeManager:
|
|||||||
|
|
||||||
if not os.path.isfile(source_path):
|
if not os.path.isfile(source_path):
|
||||||
raise FileNotFoundError(source_path)
|
raise FileNotFoundError(source_path)
|
||||||
ext = os.path.splitext(source_path)[1].lower()
|
base_name, ext = os.path.splitext(os.path.basename(source_path))
|
||||||
|
ext = ext.lower()
|
||||||
with self.__lock:
|
with self.__lock:
|
||||||
if ext == ".qss":
|
if ext == ".qss":
|
||||||
name = os.path.splitext(os.path.basename(source_path))[0]
|
dest_path = self._resolveDestPath(base_name, "未知作者")
|
||||||
dest_path = os.path.join(self.__themes_dir, name + ".altheme")
|
|
||||||
if os.path.exists(dest_path):
|
|
||||||
raise ValueError(f"主题 '{name}' 已存在")
|
|
||||||
wrapQssToAtheme(source_path, dest_path, "both")
|
wrapQssToAtheme(source_path, dest_path, "both")
|
||||||
return name
|
return os.path.splitext(os.path.basename(dest_path))[0]
|
||||||
elif ext == ".altheme":
|
elif ext == ".altheme":
|
||||||
with zipfile.ZipFile(source_path, "r") as zf:
|
info = validateTheme(source_path)
|
||||||
if "theme.qss" not in zf.namelist():
|
name = info.get("name", base_name)
|
||||||
raise ValueError("无效的 .altheme: 缺少 theme.qss")
|
|
||||||
info = readThemeInfo(source_path)
|
|
||||||
name = info.get("name", os.path.splitext(os.path.basename(source_path))[0])
|
|
||||||
safe_name = os.path.basename(name)
|
safe_name = os.path.basename(name)
|
||||||
dest_path = os.path.join(self.__themes_dir, safe_name + ".altheme")
|
|
||||||
if os.path.exists(dest_path):
|
|
||||||
raise ValueError(f"主题 '{safe_name}' 已存在")
|
|
||||||
# Check for name collision with existing themes by the same author
|
|
||||||
new_author = info.get("author", "")
|
new_author = info.get("author", "")
|
||||||
for existing in self.listThemes():
|
dest_path = self._resolveDestPath(safe_name, new_author)
|
||||||
if (existing.get("name", "") == safe_name
|
|
||||||
and existing.get("author", "") == new_author):
|
|
||||||
raise ValueError(
|
|
||||||
f"主题名称 '{safe_name}' (作者 '{new_author}') 已存在"
|
|
||||||
)
|
|
||||||
shutil.copy2(source_path, dest_path)
|
shutil.copy2(source_path, dest_path)
|
||||||
return safe_name
|
return os.path.splitext(os.path.basename(dest_path))[0]
|
||||||
else:
|
else:
|
||||||
raise ValueError(f"不支持的文件类型: {ext}")
|
raise ValueError(f"不支持的文件类型: {ext}")
|
||||||
|
|
||||||
@@ -172,10 +220,7 @@ class ThemeManager:
|
|||||||
if filename.endswith(".altheme"):
|
if filename.endswith(".altheme"):
|
||||||
filepath = os.path.join(self.__themes_dir, filename)
|
filepath = os.path.join(self.__themes_dir, filename)
|
||||||
try:
|
try:
|
||||||
info = readThemeInfo(filepath)
|
info = validateTheme(filepath)
|
||||||
with zipfile.ZipFile(filepath, "r") as zf:
|
|
||||||
if "theme.qss" not in zf.namelist():
|
|
||||||
raise ValueError("缺少 theme.qss")
|
|
||||||
name = info.get("name", "")
|
name = info.get("name", "")
|
||||||
author = info.get("author", "")
|
author = info.get("author", "")
|
||||||
key = (name, author)
|
key = (name, author)
|
||||||
@@ -185,6 +230,7 @@ class ThemeManager:
|
|||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
seen_keys.add(key)
|
seen_keys.add(key)
|
||||||
|
info["file"] = os.path.splitext(filename)[0]
|
||||||
themes.append(info)
|
themes.append(info)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logInstance().getLogger("ThemeManager").warning(
|
logInstance().getLogger("ThemeManager").warning(
|
||||||
@@ -204,22 +250,21 @@ class ThemeManager:
|
|||||||
Remove a theme by name.
|
Remove a theme by name.
|
||||||
|
|
||||||
If the removed theme is currently active, clears the QSS
|
If the removed theme is currently active, clears the QSS
|
||||||
stylesheet from the application.
|
stylesheet from the application and reverts to the saved
|
||||||
|
colour scheme.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
name (str): The theme name to remove.
|
name (str): The theme name to remove.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
filepath = os.path.join(self.__themes_dir, name + ".altheme")
|
|
||||||
with self.__lock:
|
with self.__lock:
|
||||||
if os.path.isfile(filepath):
|
self._deleteThemeFile(name)
|
||||||
os.remove(filepath)
|
if self.__current_theme_name == name:
|
||||||
if self.__current_theme_name == name:
|
self.__current_theme_name = ""
|
||||||
self.__current_theme_name = ""
|
saved_theme = configInstance().get(
|
||||||
saved_theme = configInstance().get(
|
CfgKey.GLOBAL.APPEARANCE.THEME, "system"
|
||||||
CfgKey.GLOBAL.APPEARANCE.THEME, "system"
|
)
|
||||||
)
|
self.clearTheme(saved_theme)
|
||||||
self.clearTheme(saved_theme)
|
|
||||||
|
|
||||||
def applyTheme(
|
def applyTheme(
|
||||||
self,
|
self,
|
||||||
@@ -244,21 +289,10 @@ class ThemeManager:
|
|||||||
raise FileNotFoundError(filepath)
|
raise FileNotFoundError(filepath)
|
||||||
with self.__lock:
|
with self.__lock:
|
||||||
info = readThemeInfo(filepath)
|
info = readThemeInfo(filepath)
|
||||||
with tempfile.TemporaryDirectory() as tmpdir:
|
qss = readThemeQss(filepath)
|
||||||
unpackTheme(filepath, tmpdir)
|
|
||||||
qss_path = os.path.join(tmpdir, "theme.qss")
|
|
||||||
if os.path.isfile(qss_path):
|
|
||||||
with open(qss_path, "r", encoding="utf-8") as fh:
|
|
||||||
qss = fh.read()
|
|
||||||
app = QApplication.instance()
|
|
||||||
if app:
|
|
||||||
app.setStyleSheet(qss)
|
|
||||||
else:
|
|
||||||
raise ValueError(
|
|
||||||
f"主题 '{name}' 的 .altheme 文件中缺少 theme.qss"
|
|
||||||
)
|
|
||||||
app = QApplication.instance()
|
app = QApplication.instance()
|
||||||
if app:
|
if app:
|
||||||
|
app.setStyleSheet(qss)
|
||||||
need_theme = info.get("need_theme", "both")
|
need_theme = info.get("need_theme", "both")
|
||||||
app.styleHints().setColorScheme(
|
app.styleHints().setColorScheme(
|
||||||
ThemeManager._colorSchemeFor(need_theme)
|
ThemeManager._colorSchemeFor(need_theme)
|
||||||
|
|||||||
+15
-13
@@ -10,6 +10,7 @@ See the LICENSE file for details.
|
|||||||
import os
|
import os
|
||||||
import queue
|
import queue
|
||||||
from selenium import webdriver
|
from selenium import webdriver
|
||||||
|
from selenium.webdriver.remote.webdriver import WebDriver
|
||||||
from selenium.common.exceptions import (
|
from selenium.common.exceptions import (
|
||||||
TimeoutException,
|
TimeoutException,
|
||||||
WebDriverException,
|
WebDriverException,
|
||||||
@@ -37,11 +38,11 @@ class AutoLib(MsgBase):
|
|||||||
output_queue: queue.Queue,
|
output_queue: queue.Queue,
|
||||||
run_config: dict,
|
run_config: dict,
|
||||||
) -> None:
|
) -> None:
|
||||||
super().__init__(input_queue, output_queue)
|
|
||||||
|
|
||||||
|
super().__init__(input_queue, output_queue)
|
||||||
self.__run_config: dict = run_config
|
self.__run_config: dict = run_config
|
||||||
self.__user_config: dict | None = None
|
self.__user_config: dict | None = None
|
||||||
self.__driver = None
|
self.__driver: WebDriver | None = None
|
||||||
self.__driver_type: str = ""
|
self.__driver_type: str = ""
|
||||||
self.__driver_path: str = ""
|
self.__driver_path: str = ""
|
||||||
self.__login_page: LoginPage = None
|
self.__login_page: LoginPage = None
|
||||||
@@ -58,7 +59,7 @@ class AutoLib(MsgBase):
|
|||||||
else:
|
else:
|
||||||
if not self.__initDriverUrl():
|
if not self.__initDriverUrl():
|
||||||
self.close()
|
self.close()
|
||||||
raise Exception("浏览器驱动URL初始化失败 !")
|
raise Exception("浏览器驱动 URL 初始化失败 !")
|
||||||
self.__initPagesServices()
|
self.__initPagesServices()
|
||||||
self.__initPagesFlows()
|
self.__initPagesFlows()
|
||||||
|
|
||||||
@@ -67,9 +68,10 @@ class AutoLib(MsgBase):
|
|||||||
) -> bool:
|
) -> bool:
|
||||||
|
|
||||||
self._showTrace("正在初始化浏览器驱动......", no_log=True)
|
self._showTrace("正在初始化浏览器驱动......", no_log=True)
|
||||||
web_driver_config: dict = self.__run_config.get("web_driver", None)
|
driver_config: dict = self.__run_config.get("web_driver", None)
|
||||||
self.__driver_type = web_driver_config.get("driver_type", "none")
|
self.__driver_type = driver_config.get("driver_type", "none")
|
||||||
match self.__driver_type.lower():
|
self.__driver_type = self.__driver_type.lower()
|
||||||
|
match self.__driver_type:
|
||||||
case "edge":
|
case "edge":
|
||||||
driver_options = webdriver.EdgeOptions()
|
driver_options = webdriver.EdgeOptions()
|
||||||
case "chrome":
|
case "chrome":
|
||||||
@@ -82,10 +84,10 @@ class AutoLib(MsgBase):
|
|||||||
self.TraceLevel.WARNING,
|
self.TraceLevel.WARNING,
|
||||||
)
|
)
|
||||||
return False
|
return False
|
||||||
if not web_driver_config:
|
if not driver_config:
|
||||||
self._showTrace("未配置浏览器驱动参数 !", self.TraceLevel.ERROR)
|
self._showTrace("未配置浏览器驱动参数 !", self.TraceLevel.ERROR)
|
||||||
return False
|
return False
|
||||||
if web_driver_config.get("headless", False):
|
if driver_config.get("headless", False):
|
||||||
driver_options.add_argument("--headless")
|
driver_options.add_argument("--headless")
|
||||||
driver_options.add_argument("--disable-gpu")
|
driver_options.add_argument("--disable-gpu")
|
||||||
driver_options.add_argument("--no-sandbox")
|
driver_options.add_argument("--no-sandbox")
|
||||||
@@ -110,11 +112,11 @@ class AutoLib(MsgBase):
|
|||||||
"AppleWebKit/537.36 (KHTML, like Gecko) "\
|
"AppleWebKit/537.36 (KHTML, like Gecko) "\
|
||||||
"Chrome/120.0.0.0 "\
|
"Chrome/120.0.0.0 "\
|
||||||
"Safari/537.36"
|
"Safari/537.36"
|
||||||
if self.__driver_type.lower() == "edge":
|
if self.__driver_type == "edge":
|
||||||
user_agent += " Edg/120.0.0.0"
|
user_agent += " Edg/120.0.0.0"
|
||||||
|
|
||||||
# set options for firefox
|
# set options for firefox
|
||||||
elif self.__driver_type.lower() == "firefox":
|
elif self.__driver_type == "firefox":
|
||||||
driver_options.set_preference("dom.webdriver.enabled", False)
|
driver_options.set_preference("dom.webdriver.enabled", False)
|
||||||
driver_options.set_preference("useAutomationExtension", False)
|
driver_options.set_preference("useAutomationExtension", False)
|
||||||
user_agent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:120.0) "\
|
user_agent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:120.0) "\
|
||||||
@@ -122,14 +124,14 @@ class AutoLib(MsgBase):
|
|||||||
driver_options.add_argument(f"user-agent={user_agent}")
|
driver_options.add_argument(f"user-agent={user_agent}")
|
||||||
|
|
||||||
# init browser driver
|
# init browser driver
|
||||||
self.__driver_path = web_driver_config.get("driver_path", "")
|
self.__driver_path = driver_config.get("driver_path", "")
|
||||||
if not self.__driver_path:
|
if not self.__driver_path:
|
||||||
self._showTrace("未配置浏览器驱动路径 !", self.TraceLevel.WARNING)
|
self._showTrace("未配置浏览器驱动路径 !", self.TraceLevel.WARNING)
|
||||||
return False
|
return False
|
||||||
try:
|
try:
|
||||||
self.__driver_path = os.path.abspath(self.__driver_path)
|
self.__driver_path = os.path.abspath(self.__driver_path)
|
||||||
service = None
|
service = None
|
||||||
match self.__driver_type.lower():
|
match self.__driver_type:
|
||||||
case "edge":
|
case "edge":
|
||||||
service = EdgeService(executable_path=self.__driver_path)
|
service = EdgeService(executable_path=self.__driver_path)
|
||||||
self.__driver = webdriver.Edge(service=service, options=driver_options)
|
self.__driver = webdriver.Edge(service=service, options=driver_options)
|
||||||
@@ -161,7 +163,7 @@ class AutoLib(MsgBase):
|
|||||||
self._showTrace("未配置图书馆参数 !", self.TraceLevel.ERROR)
|
self._showTrace("未配置图书馆参数 !", self.TraceLevel.ERROR)
|
||||||
return False
|
return False
|
||||||
url: str = lib_config.get("host_url") + lib_config.get("login_url")
|
url: str = lib_config.get("host_url") + lib_config.get("login_url")
|
||||||
self.__login_page = LoginPage(self.__driver, tracer=self._showTrace)
|
self.__login_page = LoginPage(self._input_queue, self._output_queue, self.__driver)
|
||||||
self.__driver.set_page_load_timeout(5)
|
self.__driver.set_page_load_timeout(5)
|
||||||
try:
|
try:
|
||||||
self.__driver.get(url)
|
self.__driver.get(url)
|
||||||
|
|||||||
+13
-19
@@ -7,7 +7,8 @@ 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.
|
You may use, modify, and distribute this file under the terms of the MIT License.
|
||||||
See the LICENSE file for details.
|
See the LICENSE file for details.
|
||||||
"""
|
"""
|
||||||
from typing import Callable, Optional
|
import queue
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
from selenium.common.exceptions import (
|
from selenium.common.exceptions import (
|
||||||
ElementNotInteractableException,
|
ElementNotInteractableException,
|
||||||
@@ -19,8 +20,10 @@ from selenium.webdriver.remote.webdriver import WebDriver
|
|||||||
from selenium.webdriver.support.ui import WebDriverWait
|
from selenium.webdriver.support.ui import WebDriverWait
|
||||||
from selenium.webdriver.support import expected_conditions as EC
|
from selenium.webdriver.support import expected_conditions as EC
|
||||||
|
|
||||||
|
from base.MsgBase import MsgBase
|
||||||
|
|
||||||
class LoginPage:
|
|
||||||
|
class LoginPage(MsgBase):
|
||||||
|
|
||||||
USERNAME_INPUT = (By.NAME, "username")
|
USERNAME_INPUT = (By.NAME, "username")
|
||||||
PASSWORD_INPUT = (By.NAME, "password")
|
PASSWORD_INPUT = (By.NAME, "password")
|
||||||
@@ -36,22 +39,13 @@ class LoginPage:
|
|||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
|
input_queue: queue.Queue,
|
||||||
|
output_queue: queue.Queue,
|
||||||
driver: WebDriver,
|
driver: WebDriver,
|
||||||
tracer: Optional[Callable[..., None]] = None,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|
||||||
|
super().__init__(input_queue, output_queue)
|
||||||
self._driver: WebDriver = driver
|
self._driver: WebDriver = driver
|
||||||
self._tracer: Optional[Callable[..., None]] = tracer
|
|
||||||
|
|
||||||
def _trace(
|
|
||||||
self,
|
|
||||||
msg: str,
|
|
||||||
level: int = 20,
|
|
||||||
no_log: bool = False,
|
|
||||||
) -> None:
|
|
||||||
|
|
||||||
if self._tracer:
|
|
||||||
self._tracer(msg, level, no_log)
|
|
||||||
|
|
||||||
def navigate(
|
def navigate(
|
||||||
self,
|
self,
|
||||||
@@ -185,7 +179,7 @@ class LoginPage:
|
|||||||
) -> bool:
|
) -> bool:
|
||||||
|
|
||||||
for attempt in range(max_attempts):
|
for attempt in range(max_attempts):
|
||||||
self._trace(
|
self._showTrace(
|
||||||
f"用户 {username} 第 {attempt + 1} 次尝试登录......",
|
f"用户 {username} 第 {attempt + 1} 次尝试登录......",
|
||||||
no_log=True,
|
no_log=True,
|
||||||
)
|
)
|
||||||
@@ -196,16 +190,16 @@ class LoginPage:
|
|||||||
continue
|
continue
|
||||||
if not self.fillCaptcha(captcha_text):
|
if not self.fillCaptcha(captcha_text):
|
||||||
continue
|
continue
|
||||||
self._trace("尝试登录...", no_log=True)
|
self._showTrace("尝试登录...", no_log=True)
|
||||||
if not self.clickLogin():
|
if not self.clickLogin():
|
||||||
continue
|
continue
|
||||||
if self.waitLoginSuccess():
|
if self.waitLoginSuccess():
|
||||||
self._trace(f"用户 {username} 第 {attempt + 1} 次登录成功 !")
|
self._showTrace(f"用户 {username} 第 {attempt + 1} 次登录成功 !")
|
||||||
return True
|
return True
|
||||||
else:
|
else:
|
||||||
self._trace(
|
self._showTrace(
|
||||||
"登录页面加载失败 ! : "
|
"登录页面加载失败 ! : "
|
||||||
"用户账号或者密码错误/验证码错误, 具体以页面提示为准",
|
"用户账号或者密码错误/验证码错误, 具体以页面提示为准",
|
||||||
level=40,
|
level=self.TraceLevel.ERROR,
|
||||||
)
|
)
|
||||||
return False
|
return False
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ class CheckinFlow(MsgBase):
|
|||||||
self._driver: WebDriver = driver
|
self._driver: WebDriver = driver
|
||||||
self._shell: MainShell = shell
|
self._shell: MainShell = shell
|
||||||
|
|
||||||
def execute(
|
def _ensureCheckinButton(
|
||||||
self,
|
self,
|
||||||
username: str,
|
username: str,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
@@ -49,7 +49,13 @@ class CheckinFlow(MsgBase):
|
|||||||
self._showTrace(f"签到按钮启用失败 !", self.TraceLevel.ERROR)
|
self._showTrace(f"签到按钮启用失败 !", self.TraceLevel.ERROR)
|
||||||
return False
|
return False
|
||||||
self._showTrace("签到按钮已启用")
|
self._showTrace("签到按钮已启用")
|
||||||
self._shell.clickCheckinButton()
|
return True
|
||||||
|
|
||||||
|
def _processCheckinDialog(
|
||||||
|
self,
|
||||||
|
username: str,
|
||||||
|
) -> bool:
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with CheckinResultDialog(self._driver) as dialog:
|
with CheckinResultDialog(self._driver) as dialog:
|
||||||
result_msg = dialog.getResultMessage()
|
result_msg = dialog.getResultMessage()
|
||||||
@@ -87,3 +93,13 @@ class CheckinFlow(MsgBase):
|
|||||||
except (TimeoutException, NoSuchElementException, ElementNotInteractableException):
|
except (TimeoutException, NoSuchElementException, ElementNotInteractableException):
|
||||||
self._showTrace("签到时发生未知错误 !", self.TraceLevel.ERROR)
|
self._showTrace("签到时发生未知错误 !", self.TraceLevel.ERROR)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def execute(
|
||||||
|
self,
|
||||||
|
username: str,
|
||||||
|
) -> bool:
|
||||||
|
|
||||||
|
if not self._ensureCheckinButton(username):
|
||||||
|
return False
|
||||||
|
self._shell.clickCheckinButton()
|
||||||
|
return self._processCheckinDialog(username)
|
||||||
|
|||||||
@@ -61,19 +61,23 @@ class RenewFlow(MsgBase):
|
|||||||
)
|
)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def execute(
|
def _computeRenewTarget(
|
||||||
self,
|
self,
|
||||||
username: str,
|
|
||||||
record: dict,
|
record: dict,
|
||||||
renew_info: dict,
|
renew_info: dict,
|
||||||
) -> bool:
|
):
|
||||||
|
|
||||||
max_diff = renew_info.get("max_diff", 30)
|
|
||||||
prefer_earlier = renew_info.get("prefer_early", True)
|
|
||||||
end_time = record["time"]["end"]
|
end_time = record["time"]["end"]
|
||||||
target_renew_mins = timeStrToMins(end_time) + renew_info.get("expect_duration", 2) * 60
|
target_renew_mins = timeStrToMins(end_time) + renew_info.get("expect_duration", 2) * 60
|
||||||
if not self._validateRenewTime(end_time, target_renew_mins):
|
if not self._validateRenewTime(end_time, target_renew_mins):
|
||||||
return False
|
return None
|
||||||
|
return target_renew_mins
|
||||||
|
|
||||||
|
def _ensureExtendButton(
|
||||||
|
self,
|
||||||
|
username: str,
|
||||||
|
) -> bool:
|
||||||
|
|
||||||
if not self._shell.waitExtendButton():
|
if not self._shell.waitExtendButton():
|
||||||
self._showTrace(f"用户 {username} 续约界面加载失败 !", self.TraceLevel.ERROR)
|
self._showTrace(f"用户 {username} 续约界面加载失败 !", self.TraceLevel.ERROR)
|
||||||
return False
|
return False
|
||||||
@@ -83,7 +87,17 @@ class RenewFlow(MsgBase):
|
|||||||
f"请连接图书馆网络后重试"
|
f"请连接图书馆网络后重试"
|
||||||
)
|
)
|
||||||
return False
|
return False
|
||||||
self._shell.clickExtendButton()
|
return True
|
||||||
|
|
||||||
|
def _processRenewDialog(
|
||||||
|
self,
|
||||||
|
username: str,
|
||||||
|
record: dict,
|
||||||
|
target_renew_mins: int,
|
||||||
|
max_diff: int,
|
||||||
|
prefer_earlier: bool,
|
||||||
|
) -> bool:
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with RenewDialog(self._driver) as dialog:
|
with RenewDialog(self._driver) as dialog:
|
||||||
if not dialog.waitUntilReady():
|
if not dialog.waitUntilReady():
|
||||||
@@ -132,3 +146,22 @@ class RenewFlow(MsgBase):
|
|||||||
self._showTrace(f"用户 {username} 续约失败 ! : {e}", self.TraceLevel.ERROR)
|
self._showTrace(f"用户 {username} 续约失败 ! : {e}", self.TraceLevel.ERROR)
|
||||||
self._shell.refresh()
|
self._shell.refresh()
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def execute(
|
||||||
|
self,
|
||||||
|
username: str,
|
||||||
|
record: dict,
|
||||||
|
renew_info: dict,
|
||||||
|
) -> bool:
|
||||||
|
|
||||||
|
max_diff = renew_info.get("max_diff", 30)
|
||||||
|
prefer_earlier = renew_info.get("prefer_early", True)
|
||||||
|
target_renew_mins = self._computeRenewTarget(record, renew_info)
|
||||||
|
if target_renew_mins is None:
|
||||||
|
return False
|
||||||
|
if not self._ensureExtendButton(username):
|
||||||
|
return False
|
||||||
|
self._shell.clickExtendButton()
|
||||||
|
return self._processRenewDialog(
|
||||||
|
username, record, target_renew_mins, max_diff, prefer_earlier,
|
||||||
|
)
|
||||||
|
|||||||
+103
-35
@@ -58,40 +58,105 @@ class ReserveFlow(MsgBase):
|
|||||||
self._driver: WebDriver = driver
|
self._driver: WebDriver = driver
|
||||||
self._shell: MainShell = shell
|
self._shell: MainShell = shell
|
||||||
|
|
||||||
def execute(
|
def _loadReserveView(
|
||||||
self,
|
self,
|
||||||
|
) -> ReserveView | None:
|
||||||
|
|
||||||
|
try:
|
||||||
|
return self._shell.gotoReserveView()
|
||||||
|
except (TimeoutException, ElementNotInteractableException) as e:
|
||||||
|
self._showTrace(f"加载预约选座页面失败 ! : {e}", self.TraceLevel.ERROR)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _selectDate(
|
||||||
|
self,
|
||||||
|
view: ReserveView,
|
||||||
ctx: ReserveContext,
|
ctx: ReserveContext,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
|
|
||||||
submit_reserve = False
|
|
||||||
reserve_success = False
|
|
||||||
have_hover_on_page = False
|
|
||||||
|
|
||||||
try:
|
|
||||||
view = self._shell.gotoReserveView()
|
|
||||||
except (TimeoutException, ElementNotInteractableException) as e:
|
|
||||||
self._showTrace(f"加载预约选座页面失败 ! : {e}", self.TraceLevel.ERROR)
|
|
||||||
return False
|
|
||||||
if not view.selectDate(ctx.date):
|
if not view.selectDate(ctx.date):
|
||||||
self._showTrace(f"选择日期失败 ! : {ctx.date} 不可用", self.TraceLevel.ERROR)
|
self._showTrace(f"选择日期失败 ! : {ctx.date} 不可用", self.TraceLevel.ERROR)
|
||||||
return False
|
return False
|
||||||
self._showTrace(f"日期 {ctx.date} 选择成功 !")
|
self._showTrace(f"日期 {ctx.date} 选择成功 !")
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _selectPlace(
|
||||||
|
self,
|
||||||
|
view: ReserveView,
|
||||||
|
) -> bool:
|
||||||
|
|
||||||
if not view.selectPlace("1"):
|
if not view.selectPlace("1"):
|
||||||
self._showTrace("选择预约场所失败 ! : 图书馆 不可用", self.TraceLevel.ERROR)
|
self._showTrace("选择预约场所失败 ! : 图书馆 不可用", self.TraceLevel.ERROR)
|
||||||
return False
|
return False
|
||||||
self._showTrace("预约场所 图书馆 选择成功 !")
|
self._showTrace("预约场所 图书馆 选择成功 !")
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _selectFloor(
|
||||||
|
self,
|
||||||
|
view: ReserveView,
|
||||||
|
ctx: ReserveContext,
|
||||||
|
) -> bool:
|
||||||
|
|
||||||
if not view.selectFloor(ctx.floor):
|
if not view.selectFloor(ctx.floor):
|
||||||
display_floor = ReserveView.FLOOR_MAP.get(ctx.floor, ctx.floor)
|
display_floor = ReserveView.FLOOR_MAP.get(ctx.floor, ctx.floor)
|
||||||
self._showTrace(f"选择楼层失败 ! : {display_floor} 不可用", self.TraceLevel.ERROR)
|
self._showTrace(f"选择楼层失败 ! : {display_floor} 不可用", self.TraceLevel.ERROR)
|
||||||
return False
|
return False
|
||||||
self._showTrace(f"楼层 {ReserveView.FLOOR_MAP.get(ctx.floor)} 选择成功 !")
|
self._showTrace(f"楼层 {ReserveView.FLOOR_MAP.get(ctx.floor)} 选择成功 !")
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _selectRoom(
|
||||||
|
self,
|
||||||
|
view: ReserveView,
|
||||||
|
ctx: ReserveContext,
|
||||||
|
):
|
||||||
|
|
||||||
seat_map = view.selectRoom(ctx.room)
|
seat_map = view.selectRoom(ctx.room)
|
||||||
if seat_map is None:
|
if seat_map is None:
|
||||||
display_room = ReserveView.ROOM_MAP.get(ctx.room, ctx.room)
|
display_room = ReserveView.ROOM_MAP.get(ctx.room, ctx.room)
|
||||||
self._showTrace(f"选择房间失败 ! : {display_room} 不可用", self.TraceLevel.ERROR)
|
self._showTrace(f"选择房间失败 ! : {display_room} 不可用", self.TraceLevel.ERROR)
|
||||||
return False
|
return None
|
||||||
self._showTrace(f"房间 {ReserveView.ROOM_MAP.get(ctx.room)} 选择成功 !")
|
self._showTrace(f"房间 {ReserveView.ROOM_MAP.get(ctx.room)} 选择成功 !")
|
||||||
have_hover_on_page = True
|
return seat_map
|
||||||
|
|
||||||
|
def _processReserveResult(
|
||||||
|
self,
|
||||||
|
) -> bool:
|
||||||
|
|
||||||
|
with ReserveResultDialog(self._driver) as result:
|
||||||
|
if result.isFailure():
|
||||||
|
self._showTrace("预约失败", self.TraceLevel.ERROR)
|
||||||
|
elif result.isSuccess():
|
||||||
|
details = result.getDetailTexts()
|
||||||
|
if len(details) >= 6:
|
||||||
|
self._showTrace(
|
||||||
|
f"\n"
|
||||||
|
f" 预约成功 !\n"
|
||||||
|
f" {details[1]}\n"
|
||||||
|
f" {details[2]}\n"
|
||||||
|
f" {details[3]}\n"
|
||||||
|
f" 签到时间 :{details[5]}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self._showTrace(
|
||||||
|
"\n"
|
||||||
|
" 预约成功 !\n"
|
||||||
|
" 未找获取到详细信息"
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
self._showTrace("预约结果加载失败 !", self.TraceLevel.ERROR)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _selectSeatAndSubmit(
|
||||||
|
self,
|
||||||
|
view: ReserveView,
|
||||||
|
seat_map,
|
||||||
|
ctx: ReserveContext,
|
||||||
|
) -> tuple[bool, bool]:
|
||||||
|
|
||||||
|
submit_reserve = False
|
||||||
|
reserve_success = False
|
||||||
|
|
||||||
seat_status = seat_map.selectSeat(ctx.seat_id)
|
seat_status = seat_map.selectSeat(ctx.seat_id)
|
||||||
if seat_status is None:
|
if seat_status is None:
|
||||||
self._showTrace(
|
self._showTrace(
|
||||||
@@ -111,31 +176,34 @@ class ReserveFlow(MsgBase):
|
|||||||
try:
|
try:
|
||||||
view.submitReserve()
|
view.submitReserve()
|
||||||
submit_reserve = True
|
submit_reserve = True
|
||||||
with ReserveResultDialog(self._driver) as result:
|
reserve_success = self._processReserveResult()
|
||||||
if result.isFailure():
|
|
||||||
self._showTrace("预约失败", self.TraceLevel.ERROR)
|
|
||||||
elif result.isSuccess():
|
|
||||||
details = result.getDetailTexts()
|
|
||||||
if len(details) >= 6:
|
|
||||||
self._showTrace(
|
|
||||||
f"\n"
|
|
||||||
f" 预约成功 !\n"
|
|
||||||
f" {details[1]}\n"
|
|
||||||
f" {details[2]}\n"
|
|
||||||
f" {details[3]}\n"
|
|
||||||
f" 签到时间 :{details[5]}"
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
self._showTrace(
|
|
||||||
"\n"
|
|
||||||
" 预约成功 !\n"
|
|
||||||
" 未找获取到详细信息"
|
|
||||||
)
|
|
||||||
reserve_success = True
|
|
||||||
else:
|
|
||||||
self._showTrace("预约结果加载失败 !", self.TraceLevel.ERROR)
|
|
||||||
except (TimeoutException, ElementNotInteractableException):
|
except (TimeoutException, ElementNotInteractableException):
|
||||||
self._showTrace("预约提交失败 !", self.TraceLevel.ERROR)
|
self._showTrace("预约提交失败 !", self.TraceLevel.ERROR)
|
||||||
|
return submit_reserve, reserve_success
|
||||||
|
|
||||||
|
def execute(
|
||||||
|
self,
|
||||||
|
ctx: ReserveContext,
|
||||||
|
) -> bool:
|
||||||
|
|
||||||
|
# reserve flow pipeline:
|
||||||
|
# date > place > floor > room > seat (begin/end time) > submit > result
|
||||||
|
view = self._loadReserveView()
|
||||||
|
if view is None:
|
||||||
|
return False
|
||||||
|
if not self._selectDate(view, ctx):
|
||||||
|
return False
|
||||||
|
if not self._selectPlace(view):
|
||||||
|
return False
|
||||||
|
if not self._selectFloor(view, ctx):
|
||||||
|
return False
|
||||||
|
seat_map = self._selectRoom(view, ctx)
|
||||||
|
if seat_map is None:
|
||||||
|
return False
|
||||||
|
have_hover_on_page = True
|
||||||
|
submit_reserve, reserve_success = self._selectSeatAndSubmit(
|
||||||
|
view, seat_map, ctx,
|
||||||
|
)
|
||||||
if not submit_reserve and have_hover_on_page:
|
if not submit_reserve and have_hover_on_page:
|
||||||
view.refresh()
|
view.refresh()
|
||||||
if reserve_success:
|
if reserve_success:
|
||||||
|
|||||||
+78
-10
@@ -37,7 +37,6 @@ def packTheme(
|
|||||||
zf.writestr("info.json", json.dumps(info, ensure_ascii=False, indent=4))
|
zf.writestr("info.json", json.dumps(info, ensure_ascii=False, indent=4))
|
||||||
zf.write(qss_path, "theme.qss")
|
zf.write(qss_path, "theme.qss")
|
||||||
|
|
||||||
|
|
||||||
def unpackTheme(
|
def unpackTheme(
|
||||||
altheme_path: str,
|
altheme_path: str,
|
||||||
output_dir: str
|
output_dir: str
|
||||||
@@ -65,22 +64,24 @@ def unpackTheme(
|
|||||||
raise ValueError(f"不安全的 .altheme 入口: {name}")
|
raise ValueError(f"不安全的 .altheme 入口: {name}")
|
||||||
zf.extractall(output_dir)
|
zf.extractall(output_dir)
|
||||||
|
|
||||||
|
|
||||||
def readThemeInfo(
|
def readThemeInfo(
|
||||||
altheme_path: str
|
altheme_path: str
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""
|
"""
|
||||||
Read only the info.json metadata from a .altheme file.
|
Read and validate the info.json metadata from a .altheme file.
|
||||||
|
|
||||||
|
Verifies that all required fields (name, author, need_theme, brief)
|
||||||
|
are present with valid values.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
altheme_path (str): Path to the .altheme file.
|
altheme_path (str): Path to the .altheme file.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict: The theme metadata dictionary.
|
dict: The validated theme metadata dictionary.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
FileNotFoundError: If altheme_path does not exist.
|
FileNotFoundError: If altheme_path does not exist.
|
||||||
ValueError: If the .altheme does not contain info.json.
|
ValueError: If info.json is missing or any field is invalid.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if not os.path.isfile(altheme_path):
|
if not os.path.isfile(altheme_path):
|
||||||
@@ -89,11 +90,78 @@ def readThemeInfo(
|
|||||||
if "info.json" not in zf.namelist():
|
if "info.json" not in zf.namelist():
|
||||||
raise ValueError("无效的 .altheme: 缺少 info.json")
|
raise ValueError("无效的 .altheme: 缺少 info.json")
|
||||||
with zf.open("info.json") as fh:
|
with zf.open("info.json") as fh:
|
||||||
info = json.loads(fh.read().decode("utf-8"))
|
try:
|
||||||
if "name" not in info:
|
info = json.loads(fh.read().decode("utf-8"))
|
||||||
raise ValueError("无效的 .altheme: info.json 缺少 'name' 字段")
|
except (json.JSONDecodeError, UnicodeDecodeError) as e:
|
||||||
return info
|
raise ValueError(f"无效的 .altheme: info.json 解析失败 — {e}")
|
||||||
|
if "name" not in info or not isinstance(info.get("name"), str) or not info["name"].strip():
|
||||||
|
raise ValueError("无效的 .altheme: info.json 缺少有效的 'name' 字段")
|
||||||
|
# reject blank author so that info.json does not drift from the
|
||||||
|
# "未知作者" filename fallback used by wrapQssToAtheme
|
||||||
|
if ("author" not in info or not isinstance(info.get("author"), str)
|
||||||
|
or not info["author"].strip()):
|
||||||
|
raise ValueError("无效的 .altheme: info.json 缺少有效的 'author' 字段")
|
||||||
|
need_theme = info.get("need_theme", "both")
|
||||||
|
if need_theme not in ("light", "dark", "both"):
|
||||||
|
raise ValueError(
|
||||||
|
f"无效的 .altheme: need_theme 值 '{need_theme}' 无效, "
|
||||||
|
f"应为 'light'、'dark' 或 'both'"
|
||||||
|
)
|
||||||
|
if "brief" not in info or not isinstance(info.get("brief"), str):
|
||||||
|
raise ValueError("无效的 .altheme: info.json 缺少有效的 'brief' 字段")
|
||||||
|
return info
|
||||||
|
|
||||||
|
def readThemeQss(
|
||||||
|
altheme_path: str
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Read the theme.qss content from a .altheme archive.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
altheme_path (str): Path to the .altheme file.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: The non-empty QSS stylesheet content.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
FileNotFoundError: If altheme_path does not exist.
|
||||||
|
ValueError: If theme.qss is missing or empty.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if not os.path.isfile(altheme_path):
|
||||||
|
raise FileNotFoundError(altheme_path)
|
||||||
|
with zipfile.ZipFile(altheme_path, "r") as zf:
|
||||||
|
if "theme.qss" not in zf.namelist():
|
||||||
|
raise ValueError("无效的 .altheme: 缺少 theme.qss")
|
||||||
|
qss = zf.read("theme.qss").decode("utf-8")
|
||||||
|
if not qss.strip():
|
||||||
|
raise ValueError("无效的 .altheme: theme.qss 为空")
|
||||||
|
return qss
|
||||||
|
|
||||||
|
def validateTheme(
|
||||||
|
altheme_path: str
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
Fully validate a .altheme file and return its metadata.
|
||||||
|
|
||||||
|
Delegates info validation to readThemeInfo and QSS validation
|
||||||
|
to readThemeQss, then additionally checks that theme.qss is
|
||||||
|
non-empty.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
altheme_path (str): Path to the .altheme file.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: The validated theme metadata dictionary.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
FileNotFoundError: If altheme_path does not exist.
|
||||||
|
ValueError: If validation fails for any reason.
|
||||||
|
"""
|
||||||
|
|
||||||
|
info = readThemeInfo(altheme_path)
|
||||||
|
readThemeQss(altheme_path) # validates existence and non-empty
|
||||||
|
return info
|
||||||
|
|
||||||
def wrapQssToAtheme(
|
def wrapQssToAtheme(
|
||||||
qss_path: str,
|
qss_path: str,
|
||||||
@@ -119,7 +187,7 @@ def wrapQssToAtheme(
|
|||||||
filename = os.path.splitext(os.path.basename(qss_path))[0]
|
filename = os.path.splitext(os.path.basename(qss_path))[0]
|
||||||
info = {
|
info = {
|
||||||
"name": filename,
|
"name": filename,
|
||||||
"author": "未知",
|
"author": "未知作者",
|
||||||
"need_theme": current_theme,
|
"need_theme": current_theme,
|
||||||
"brief": "没有相关简介"
|
"brief": "没有相关简介"
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user