1
1
mirror of https://github.com/KenanZhu/AutoLibrary.git synced 2026-06-21 17:03:03 +08:00

Compare commits

..

17 Commits

Author SHA1 Message Date
KenanZhu 0ba76babc6 docs(readme): 优化自定义主题描述,补充主题市场链接与注意事项表述修正 2026-06-21 10:19:12 +08:00
KenanZhu dc7e5d8cd8 docs(readme): 重新分类功能列表,补充自定义主题与 macOS 平台支持说明 2026-06-21 09:54:41 +08:00
Kenan Zhu 818acd4efc feat(theme): 全局设置窗口、.altheme 主题系统与 GUI 重构 (#13)
feat(theme): 全局设置窗口、.altheme 主题系统与 GUI 重构
2026-06-21 09:32:36 +08:00
Kenan Zhu 308a1dfcf3 Merge branch 'main' into feature/global-settings 2026-06-21 09:31:34 +08:00
KenanZhu c250fa4a6e refactor(theme): 将重复的主题逻辑下沉至 ThemeUtils,消除 validateTheme 职责过重 2026-06-19 11:21:50 +08:00
KenanZhu 8f8e3e4ba7 refactor(gui): 自定义主题控件及函数重命名,统一 CustomTheme 前缀 2026-06-19 10:22:36 +08:00
KenanZhu 88a74a7a47 refactor(gui): 提取窗口居中逻辑至 CenterOnParentMixin,消除5处重复 showEvent 2026-06-19 10:20:35 +08:00
KenanZhu 5552af1345 refactor(gui): 消除确认按钮重复逻辑,重置按钮不再提前应用主题 2026-06-19 09:36:18 +08:00
KenanZhu 6d05d4c7cb ci: main 分支 push 事件触发 BuildTest 工作流 2026-06-17 10:29:54 +08:00
KenanZhu 01f4ccaa0e fix(driver): macOS/Linux 下载的 Chrome/Edge 驱动缺少执行权限导致运行失败
zipfile 解压不保留 Unix 执行位,extract 后 chmod 755;同时在启动检查
时对已下载但不可执行的旧驱动自动修复权限。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-17 10:22:33 +08:00
Kenan Zhu 0bad34d7a8 refactor(*): LoginPage 消息追踪统一与 Flow 长方法拆分 (#12)
refactor: LoginPage 消息追踪统一与 Flow 长方法拆分
2026-06-13 09:30:02 +08:00
KenanZhu 61d1b44402 refactor: 提取 Flow 类 execute 长方法为私有子方法,降低嵌套深度 2026-06-13 08:59:45 +08:00
KenanZhu 72301c63fd refactor: LoginPage 继承 MsgBase,统一页面消息追踪机制 2026-06-13 08:59:34 +08:00
Kenan Zhu 609850ab60 ci(workflows): 新增 macOS .app 与 .dmg 打包流程 (#11)
新增 macOS .app 与 .dmg 打包流程
2026-06-10 09:49:54 +08:00
KenanZhu 8e14d45b71 fix(ci): 修复 Windows summary 因 Write-Host 管道失效导致空白输出
pwsh 中 Write-Host 写入信息流(stream 6)不进入管道,Write-Host | Out-File
实际写入空文件。改为直接输出字符串(stream 1)进入管道,与 macOS 的
echo >> GITHUB_STEP_SUMMARY 行为一致。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 09:17:49 +08:00
KenanZhu f6ef9af39d fix(ci): 修复 macOS 构建流程 6 项缺陷
1. requirements.txt 从 UTF-16 LE 转码为 UTF-8,修复 macOS pip install 解析失败
2. 新增 AutoLibrary_Logo.icns 图标文件,替换 macOS BUNDLE 中不兼容的 .ico 格式
3. PyInstaller 调用改为 python -m PyInstaller,确保 PATH 无关健壮性
4. ddddocr ONNX 模型路径增加多级 fallback 搜索,提升版本兼容性
5. info_plist 移除过时的 CFBundlePackageType,添加 NSHumanReadableCopyright
6. 为 macOS 专属的 sed -i '' 语法添加注释说明

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 09:05:01 +08:00
KenanZhu 72dce83c55 ci(workflows): 新增 macOS .app 与 .dmg 打包流程 2026-06-09 16:24:37 +08:00
21 changed files with 1333 additions and 705 deletions
+282 -9
View File
@@ -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
View File
@@ -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
+12 -3
View File
@@ -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
+15 -6
View File
@@ -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 和 macOSApple 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):
当前版本的功能对于正常使用已经足够,不过后续会着重完善预约时的使用体验,暂时有以下构想: 当前版本的功能对于正常使用已经足够,不过后续会着重完善预约时的使用体验,暂时有以下构想:
- 引入交互预约面板功能,预约时直接在座位分布图中选择可用座位,并按用户分配,无需事先配置预约信息。 - ~~引入交互预约面板功能,预约时直接在座位分布图中选择可用座位,并按用户分配,无需事先配置预约信息。~~ (不计划)
- ~~优化定时任务管理功能,用户可以在定时任务管理界面设置重复运行的定时任务,如每日预约、每周预约等。~~ (已完成) - ~~优化定时任务管理功能,用户可以在定时任务管理界面设置重复运行的定时任务,如每日预约、每周预约等。~~ (已完成)
- 软件的自动更新以及公告栏功能,用户可以自动更新最新版本并获取最新公告事项。 - 软件的自动更新以及公告栏功能,用户可以自动更新最新版本并获取最新公告事项。
+2 -24
View File
@@ -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
+2 -25
View File
@@ -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
+91 -116
View File
@@ -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,27 +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()
color = QApplication.instance().palette().color( color = QApplication.instance().palette().color(
QApplication.instance().palette().ColorRole.WindowText QApplication.instance().palette().ColorRole.WindowText
).name() ).name()
self.BrowseQssButton.setIcon(qta.icon("fa6s.plus", color=color)) self.ImportCustomThemeButton.setIcon(qta.icon("fa6s.plus", color=color))
self.BrowseQssButton.setText("") self.ImportCustomThemeButton.setText("")
self.RemoveThemeButton.setIcon(qta.icon("fa6s.minus", color=color)) self.RemoveCustomThemeButton.setIcon(qta.icon("fa6s.minus", color=color))
self.RemoveThemeButton.setText("") self.RemoveCustomThemeButton.setText("")
self.ThemeInfoLabel.setTextFormat(Qt.TextFormat.RichText) self.CustomThemeInfoLabel.setTextFormat(Qt.TextFormat.RichText)
self.ThemeInfoLabel.setStyleSheet( 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
@@ -141,47 +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.RemoveThemeButton.clicked.connect(self.onRemoveThemeButtonClicked) self.RemoveCustomThemeButton.clicked.connect(self.onRemoveCustomThemeButtonClicked)
self.ThemeComboBox.currentIndexChanged.connect(self.onThemeComboBoxChanged) self.CustomThemeComboBox.currentIndexChanged.connect(self.onCustomThemeComboBoxChanged)
self.ResetThemeButton.clicked.connect(self.onResetThemeButtonClicked) 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
): ):
@@ -202,33 +200,20 @@ 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.findData(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
): ):
file = self.ThemeComboBox.currentData() file = self.CustomThemeComboBox.currentData()
t = self.__theme_cache.get(file) if file else None
name = t.get("name", "") if t else ""
if name:
self.QssStatusLabel.setText(f"当前使用 {name} 主题。")
else:
self.QssStatusLabel.setText("当前使用 默认 主题。")
def updateThemeInfo(
self
):
file = self.ThemeComboBox.currentData()
if not file: if not file:
self.ThemeInfoLabel.setText("") self.CustomThemeInfoLabel.setText("")
return return
t = self.__theme_cache.get(file) t = self.__theme_cache.get(file)
if t: if t:
@@ -236,13 +221,25 @@ class ALSettingsWidget(QWidget, Ui_ALSettingsWidget):
author = t.get("author", "未知作者") 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,
@@ -268,7 +265,7 @@ class ALSettingsWidget(QWidget, Ui_ALSettingsWidget):
else: else:
theme = "system" theme = "system"
style = self.StyleComboBox.currentText() style = self.StyleComboBox.currentText()
custom_theme = self.ThemeComboBox.currentData() or "" custom_theme = self.CustomThemeComboBox.currentData() or ""
if not custom_theme: if not custom_theme:
custom_theme = "" custom_theme = ""
return theme, style, custom_theme return theme, style, custom_theme
@@ -289,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()
@@ -311,30 +308,12 @@ class ALSettingsWidget(QWidget, Ui_ALSettingsWidget):
return True return True
return False return False
def populateThemeList(
self
):
self.ThemeComboBox.blockSignals(True)
self.ThemeComboBox.clear()
self.ThemeComboBox.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.ThemeComboBox.addItem(name, file)
self.ThemeComboBox.blockSignals(False)
@Slot() @Slot()
def onRemoveThemeButtonClicked( def onRemoveCustomThemeButtonClicked(
self self
): ):
file = self.ThemeComboBox.currentData() file = self.CustomThemeComboBox.currentData()
if not file: if not file:
QMessageBox.information( QMessageBox.information(
self, self,
@@ -355,10 +334,10 @@ class ALSettingsWidget(QWidget, Ui_ALSettingsWidget):
return return
try: try:
themeInstance().removeTheme(file) themeInstance().removeTheme(file)
self.populateThemeList() self.populateCustomThemes()
self.ThemeComboBox.setCurrentIndex(0) self.CustomThemeComboBox.setCurrentIndex(0)
self.updateThemeStatus() self.updateCustomThemeStatus()
self.updateThemeInfo() self.updateCustomThemeInfo()
except Exception as e: except Exception as e:
QMessageBox.warning( QMessageBox.warning(
self, self,
@@ -367,7 +346,7 @@ class ALSettingsWidget(QWidget, Ui_ALSettingsWidget):
) )
@Slot() @Slot()
def onImportThemeButtonClicked( def onImportCustomThemeButtonClicked(
self self
): ):
@@ -381,12 +360,12 @@ class ALSettingsWidget(QWidget, Ui_ALSettingsWidget):
return return
try: try:
file_id = themeInstance().importTheme(file_path) file_id = themeInstance().importTheme(file_path)
self.populateThemeList() self.populateCustomThemes()
idx = self.ThemeComboBox.findData(file_id) 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,
@@ -395,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.findData(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(
@@ -450,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()
+2 -24
View File
@@ -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
+2 -23
View File
@@ -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
): ):
+49
View File
@@ -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.
+16 -32
View File
@@ -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">
@@ -252,7 +252,7 @@
</widget> </widget>
</item> </item>
<item> <item>
<widget class="QGroupBox" name="CustomQssGroupBox"> <widget class="QGroupBox" name="CustomThemeGroupBox">
<property name="title"> <property name="title">
<string>自定义外观</string> <string>自定义外观</string>
</property> </property>
@@ -273,7 +273,7 @@
<number>3</number> <number>3</number>
</property> </property>
<item> <item>
<widget class="QLabel" name="CustomQssHintLabel"> <widget class="QLabel" name="CustomThemeHintLabel">
<property name="text"> <property name="text">
<string>选择一个主题,或导入新的主题文件:</string> <string>选择一个主题,或导入新的主题文件:</string>
</property> </property>
@@ -283,12 +283,12 @@
</widget> </widget>
</item> </item>
<item> <item>
<layout class="QHBoxLayout" name="QssPathLayout"> <layout class="QHBoxLayout" name="CustomThemePathLayout">
<property name="spacing"> <property name="spacing">
<number>5</number> <number>5</number>
</property> </property>
<item> <item>
<widget class="QComboBox" name="ThemeComboBox"> <widget class="QComboBox" name="CustomThemeComboBox">
<property name="minimumSize"> <property name="minimumSize">
<size> <size>
<width>160</width> <width>160</width>
@@ -298,7 +298,7 @@
</widget> </widget>
</item> </item>
<item> <item>
<widget class="QLineEdit" name="QssPathEdit"> <widget class="QLineEdit" name="CustomThemePathEdit">
<property name="minimumSize"> <property name="minimumSize">
<size> <size>
<width>0</width> <width>0</width>
@@ -314,7 +314,7 @@
</widget> </widget>
</item> </item>
<item> <item>
<widget class="QPushButton" name="BrowseQssButton"> <widget class="QPushButton" name="ImportCustomThemeButton">
<property name="minimumSize"> <property name="minimumSize">
<size> <size>
<width>25</width> <width>25</width>
@@ -333,7 +333,7 @@
</widget> </widget>
</item> </item>
<item> <item>
<widget class="QPushButton" name="RemoveThemeButton"> <widget class="QPushButton" name="RemoveCustomThemeButton">
<property name="minimumSize"> <property name="minimumSize">
<size> <size>
<width>25</width> <width>25</width>
@@ -354,7 +354,7 @@
</layout> </layout>
</item> </item>
<item> <item>
<widget class="QLabel" name="ThemeInfoLabel"> <widget class="QLabel" name="CustomThemeInfoLabel">
<property name="minimumSize"> <property name="minimumSize">
<size> <size>
<width>0</width> <width>0</width>
@@ -368,7 +368,7 @@
<enum>Qt::TextFormat::RichText</enum> <enum>Qt::TextFormat::RichText</enum>
</property> </property>
<property name="alignment"> <property name="alignment">
<set>Qt::AlignmentFlag::AlignLeading|Qt::AlignmentFlag::AlignTop</set> <set>Qt::AlignmentFlag::AlignLeading|Qt::AlignmentFlag::AlignLeft|Qt::AlignmentFlag::AlignTop</set>
</property> </property>
<property name="wordWrap"> <property name="wordWrap">
<bool>true</bool> <bool>true</bool>
@@ -376,28 +376,12 @@
</widget> </widget>
</item> </item>
<item> <item>
<layout class="QHBoxLayout" name="QssActionLayout"> <layout class="QHBoxLayout" name="CustomThemeActionLayout">
<property name="spacing"> <property name="spacing">
<number>5</number> <number>5</number>
</property> </property>
<item> <item>
<widget class="QPushButton" name="ApplyQssButton"> <widget class="QPushButton" name="ResetCustomThemeButton">
<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"> <property name="minimumSize">
<size> <size>
<width>80</width> <width>80</width>
@@ -410,7 +394,7 @@
</widget> </widget>
</item> </item>
<item> <item>
<spacer name="QssActionSpacer"> <spacer name="CustomThemeActionSpacer">
<property name="orientation"> <property name="orientation">
<enum>Qt::Orientation::Horizontal</enum> <enum>Qt::Orientation::Horizontal</enum>
</property> </property>
@@ -425,7 +409,7 @@
</layout> </layout>
</item> </item>
<item> <item>
<widget class="QLabel" name="QssStatusLabel"> <widget class="QLabel" name="CustomThemeStatusLabel">
<property name="text"> <property name="text">
<string>当前使用程序 默认 外观。</string> <string>当前使用程序 默认 外观。</string>
</property> </property>
@@ -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)
+4
View 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
+40 -36
View File
@@ -21,6 +21,7 @@ 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 (
readThemeInfo,
readThemeQss, readThemeQss,
validateTheme, validateTheme,
wrapQssToAtheme wrapQssToAtheme
@@ -79,17 +80,21 @@ class ThemeManager:
else: else:
return Qt.ColorScheme.Unknown return Qt.ColorScheme.Unknown
def themesDir( def _deleteThemeFile(
self self,
) -> str: name: str
):
""" """
Get the themes directory path. Delete a theme file in the themes storage directory.
Returns: The caller must hold self.__lock before invoking this method.
str: The absolute path to the themes storage directory.
**This method ONLY deletes the file**.
""" """
return self.__themes_dir filepath = os.path.join(self.__themes_dir, name + ".altheme")
if os.path.isfile(filepath):
os.remove(filepath)
def _resolveDestPath( def _resolveDestPath(
self, self,
@@ -121,7 +126,7 @@ class ThemeManager:
existing_info = validateTheme(default_path) existing_info = validateTheme(default_path)
existing_author = existing_info.get("author", "") existing_author = existing_info.get("author", "")
except Exception: except Exception:
self._removeThemeFile(theme_name) # caller holds the lock self._deleteThemeFile(theme_name) # caller holds the lock
raise ValueError( raise ValueError(
f"主题 '{theme_name}' 已存在但无法通过验证, 已清理该主题文件" f"主题 '{theme_name}' 已存在但无法通过验证, 已清理该主题文件"
) )
@@ -139,6 +144,18 @@ class ThemeManager:
) )
return alt_path return alt_path
def themesDir(
self
) -> str:
"""
Get the themes directory path.
Returns:
str: The absolute path to the themes storage directory.
"""
return self.__themes_dir
def importTheme( def importTheme(
self, self,
source_path: str source_path: str
@@ -164,16 +181,16 @@ 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 = self._resolveDestPath(name, "未知作者")
wrapQssToAtheme(source_path, dest_path, "both") wrapQssToAtheme(source_path, dest_path, "both")
return os.path.splitext(os.path.basename(dest_path))[0] return os.path.splitext(os.path.basename(dest_path))[0]
elif ext == ".altheme": elif ext == ".altheme":
info = validateTheme(source_path) info = validateTheme(source_path)
name = info.get("name", os.path.splitext(os.path.basename(source_path))[0]) name = info.get("name", base_name)
safe_name = os.path.basename(name) safe_name = os.path.basename(name)
new_author = info.get("author", "") new_author = info.get("author", "")
dest_path = self._resolveDestPath(safe_name, new_author) dest_path = self._resolveDestPath(safe_name, new_author)
@@ -203,7 +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 = validateTheme(filepath, check_qss=False) # skip QSS read for list scan info = validateTheme(filepath)
name = info.get("name", "") name = info.get("name", "")
author = info.get("author", "") author = info.get("author", "")
key = (name, author) key = (name, author)
@@ -225,26 +242,6 @@ class ThemeManager:
) )
return themes return themes
def _removeThemeFile(
self,
name: str
):
"""
Remove a theme file without locking.
The caller must hold self.__lock before invoking this method.
"""
filepath = os.path.join(self.__themes_dir, name + ".altheme")
if os.path.isfile(filepath):
os.remove(filepath)
if self.__current_theme_name == name:
self.__current_theme_name = ""
saved_theme = configInstance().get(
CfgKey.GLOBAL.APPEARANCE.THEME, "system"
)
self.clearTheme(saved_theme)
def removeTheme( def removeTheme(
self, self,
name: str name: str
@@ -253,14 +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.
""" """
with self.__lock: with self.__lock:
self._removeThemeFile(name) self._deleteThemeFile(name)
if self.__current_theme_name == name:
self.__current_theme_name = ""
saved_theme = configInstance().get(
CfgKey.GLOBAL.APPEARANCE.THEME, "system"
)
self.clearTheme(saved_theme)
def applyTheme( def applyTheme(
self, self,
@@ -284,7 +288,7 @@ class ThemeManager:
if not os.path.isfile(filepath): if not os.path.isfile(filepath):
raise FileNotFoundError(filepath) raise FileNotFoundError(filepath)
with self.__lock: with self.__lock:
info = validateTheme(filepath) info = readThemeInfo(filepath)
qss = readThemeQss(filepath) qss = readThemeQss(filepath)
app = QApplication.instance() app = QApplication.instance()
if app: if app:
+15 -13
View File
@@ -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
View File
@@ -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
+18 -2
View File
@@ -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)
+40 -7
View File
@@ -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,
)
+100 -32
View File
@@ -58,59 +58,70 @@ 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
seat_status = seat_map.selectSeat(ctx.seat_id)
if seat_status is None: def _processReserveResult(
self._showTrace( self,
f"座位 {ctx.seat_id} 在该楼层区域中不存在, 请检查座位号是否正确", ) -> bool:
self.TraceLevel.WARNING,
)
else:
self._showTrace(f"座位 {ctx.seat_id} 选择成功 ! : 当前状态 - '{seat_status}'")
try:
time_dialog = TimeSelectDialog(self._driver, tracer=self._showTrace)
except TimeoutException:
self._showTrace("时间选择面板未出现 !", self.TraceLevel.ERROR)
else:
if not time_dialog.selectSeatTime(ctx):
self._showTrace("选择时间失败 !", self.TraceLevel.ERROR)
else:
try:
view.submitReserve()
submit_reserve = True
with ReserveResultDialog(self._driver) as result: with ReserveResultDialog(self._driver) as result:
if result.isFailure(): if result.isFailure():
self._showTrace("预约失败", self.TraceLevel.ERROR) self._showTrace("预约失败", self.TraceLevel.ERROR)
@@ -131,11 +142,68 @@ class ReserveFlow(MsgBase):
" 预约成功 !\n" " 预约成功 !\n"
" 未找获取到详细信息" " 未找获取到详细信息"
) )
reserve_success = True return True
else: else:
self._showTrace("预约结果加载失败 !", self.TraceLevel.ERROR) self._showTrace("预约结果加载失败 !", self.TraceLevel.ERROR)
return False
def _selectSeatAndSubmit(
self,
view: ReserveView,
seat_map,
ctx: ReserveContext,
) -> tuple[bool, bool]:
submit_reserve = False
reserve_success = False
seat_status = seat_map.selectSeat(ctx.seat_id)
if seat_status is None:
self._showTrace(
f"座位 {ctx.seat_id} 在该楼层区域中不存在, 请检查座位号是否正确",
self.TraceLevel.WARNING,
)
else:
self._showTrace(f"座位 {ctx.seat_id} 选择成功 ! : 当前状态 - '{seat_status}'")
try:
time_dialog = TimeSelectDialog(self._driver, tracer=self._showTrace)
except TimeoutException:
self._showTrace("时间选择面板未出现 !", self.TraceLevel.ERROR)
else:
if not time_dialog.selectSeatTime(ctx):
self._showTrace("选择时间失败 !", self.TraceLevel.ERROR)
else:
try:
view.submitReserve()
submit_reserve = True
reserve_success = self._processReserveResult()
except (TimeoutException, ElementNotInteractableException): 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:
+60 -69
View File
@@ -68,17 +68,20 @@ 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):
@@ -87,76 +90,14 @@ 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"))
if "name" not in info:
raise ValueError("无效的 .altheme: info.json 缺少 'name' 字段")
return info
def readThemeQss(
altheme_path: str
) -> str:
"""
Read the theme.qss content directly from a .altheme archive.
Args:
altheme_path (str): Path to the .altheme file.
Returns:
str: The QSS stylesheet content.
Raises:
FileNotFoundError: If altheme_path does not exist.
ValueError: If theme.qss is missing from the archive.
"""
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")
return zf.read("theme.qss").decode("utf-8")
def validateTheme(
altheme_path: str,
check_qss: bool = True
) -> dict:
"""
Validate a .altheme file and return its metadata.
Checks that info.json and theme.qss both exist, info.json
contains all required fields with valid values, and theme.qss
is a non-empty entry in the archive.
Args:
altheme_path (str): Path to the .altheme file.
check_qss (bool): If False, skip theme.qss existence and
content checks (for list-only operations).
Returns:
dict: The validated theme metadata dictionary.
Raises:
FileNotFoundError: If altheme_path does not exist.
ValueError: If validation fails for any reason.
"""
if not os.path.isfile(altheme_path):
raise FileNotFoundError(altheme_path)
with zipfile.ZipFile(altheme_path, "r") as zf:
names = zf.namelist()
if "info.json" not in names:
raise ValueError("无效的 .altheme: 缺少 info.json")
if "theme.qss" not in names:
raise ValueError("无效的 .altheme: 缺少 theme.qss")
info_bytes = zf.read("info.json")
qss_bytes = zf.read("theme.qss") if check_qss else None # skip QSS read when only listing
try: try:
info = json.loads(info_bytes.decode("utf-8")) info = json.loads(fh.read().decode("utf-8"))
except (json.JSONDecodeError, UnicodeDecodeError) as e: except (json.JSONDecodeError, UnicodeDecodeError) as e:
raise ValueError(f"无效的 .altheme: info.json 解析失败 — {e}") raise ValueError(f"无效的 .altheme: info.json 解析失败 — {e}")
if "name" not in info or not isinstance(info.get("name"), str) or not info["name"].strip(): if "name" not in info or not isinstance(info.get("name"), str) or not info["name"].strip():
raise ValueError("无效的 .altheme: info.json 缺少有效的 'name' 字段") raise ValueError("无效的 .altheme: info.json 缺少有效的 'name' 字段")
# reject blank author so info.json does not drift from the "未知作者" filename fallback # 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) if ("author" not in info or not isinstance(info.get("author"), str)
or not info["author"].strip()): or not info["author"].strip()):
raise ValueError("无效的 .altheme: info.json 缺少有效的 'author' 字段") raise ValueError("无效的 .altheme: info.json 缺少有效的 'author' 字段")
@@ -168,8 +109,58 @@ def validateTheme(
) )
if "brief" not in info or not isinstance(info.get("brief"), str): if "brief" not in info or not isinstance(info.get("brief"), str):
raise ValueError("无效的 .altheme: info.json 缺少有效的 'brief' 字段") raise ValueError("无效的 .altheme: info.json 缺少有效的 'brief' 字段")
if check_qss and not qss_bytes.strip(): 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 为空") 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 return info
def wrapQssToAtheme( def wrapQssToAtheme(