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

Compare commits

..

9 Commits

Author SHA1 Message Date
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
27 changed files with 815 additions and 2713 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.
on:
push:
branches:
- main
pull_request:
branches:
- main
@@ -209,13 +212,283 @@ jobs:
- name: Upload build summary
run: |
Write-Host "## Build Test Summary" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8
Write-Host "" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
Write-Host "========================================" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
Write-Host "✓ Pull request build test completed successfully!" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
Write-Host "- Version: ${{ steps.get_version.outputs.VERSION }}" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
Write-Host "- Tag: ${{ steps.get_version.outputs.TAG_NAME }}" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
Write-Host "- Pull Request #${{ github.event.pull_request.number || '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
Write-Host "- Event: ${{ github.event_name }}" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
"## Build Test Summary" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8
"" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
"========================================" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
"✓ Pull request build test completed successfully!" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
"- Version: ${{ steps.get_version.outputs.VERSION }}" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
"- Tag: ${{ steps.get_version.outputs.TAG_NAME }}" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
"- Pull Request #${{ github.event.pull_request.number || 'N/A' }}" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
"- Branch: ${{ github.event.pull_request.head.ref || github.ref }}" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
"- Event: ${{ github.event_name }}" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
shell: pwsh
#
# Build macOS
#
build-macos:
runs-on: macos-latest
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
ref: ${{ github.ref }}
- name: Get version info
id: get_version
run: |
version="pr-test"
tag_name="pr-test"
echo "✓ Mode: Pull Request Test Build"
echo "✓ Tag: $tag_name"
echo "✓ Version: $version"
echo "VERSION=$version" >> $GITHUB_OUTPUT
echo "TAG_NAME=$tag_name" >> $GITHUB_OUTPUT
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: '3.13'
cache: 'pip'
cache-dependency-path: requirements.txt
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
- name: Solve ddddocr compatibility and copy model files
run: |
ddddocr_path=$(python -c "import ddddocr, os; print(os.path.dirname(ddddocr.__file__))")
echo "ddddocr package location: $ddddocr_path"
init_file="$ddddocr_path/__init__.py"
if [ -f "$init_file" ]; then
echo "Fixing ddddocr compatibility in: $init_file"
# macOS 使用 BSD sed,要求 -i 后跟备份后缀名(空字符串 = 不备份)
sed -i '' 's/Image\.ANTIALIAS/Image.Resampling.LANCZOS/g' "$init_file"
echo "✓ Fixed: Image.ANTIALIAS -> Image.Resampling.LANCZOS"
else
echo "✗ ddddocr __init__.py not found"
exit 1
fi
mkdir -p models
echo "✓ Created models directory"
# 多路径 fallback 搜索 ONNX 模型,提升 ddddocr 版本兼容性
onnx_source=""
onnx_dest="models/common.onnx"
candidate_paths=(
"$ddddocr_path/common.onnx"
"$ddddocr_path/models/common.onnx"
"$ddddocr_path/ddddocr/common.onnx"
)
for candidate in "${candidate_paths[@]}"; do
if [ -f "$candidate" ]; then
onnx_source="$candidate"
break
fi
done
# 若以上候选均未命中,回退到全包搜索
if [ -z "$onnx_source" ]; then
echo "⚠ 未在已知路径找到 ONNX 模型,回退到全包搜索..."
onnx_source=$(find "$ddddocr_path" -name "*.onnx" -type f 2>/dev/null | head -1)
fi
if [ -n "$onnx_source" ] && [ -f "$onnx_source" ]; then
cp "$onnx_source" "$onnx_dest"
echo "✓ ONNX model copied: $onnx_source -> $onnx_dest"
else
echo "✗ ONNX model not found in ddddocr package"
echo " Searched directory: $ddddocr_path"
ls -la "$ddddocr_path/" || true
exit 1
fi
if [ -f "$onnx_dest" ]; then
file_size=$(du -h "$onnx_dest" | cut -f1)
echo "✓ Model file verified: $onnx_dest (Size: $file_size)"
else
echo "✗ Failed to copy model file"
exit 1
fi
- name: Compile Qt Resource files
run: |
cd batchs
bash compile_rc.sh
- name: Compile Qt UI files
run: |
cd batchs
bash compile_ui.sh
- name: Generate 'Main.spec'
env:
APP_VERSION: ${{ steps.get_version.outputs.VERSION }}
run: |
version="$APP_VERSION"
exe_name="AutoLibrary-$version"
echo "Generating Main.spec for version: $version"
echo "App name: $exe_name"
printf '%s\n' \
'# -*- mode: python ; coding: utf-8 -*-' \
'' \
'a = Analysis(' \
" ['src/Main.py']," \
' pathex=[],' \
' binaries=[],' \
' datas=[' \
" ('models/common.onnx', 'ddddocr')," \
" ('src/gui/resources/icons/AutoLibrary_Logo_64.ico', 'gui/resources/icons')," \
' ],' \
' hiddenimports=[],' \
' hookspath=[],' \
' hooksconfig={},' \
' runtime_hooks=[],' \
' excludes=[],' \
' noarchive=False,' \
' optimize=0,' \
')' \
'pyz = PYZ(a.pure)' \
'' \
'exe = EXE(' \
' pyz,' \
' a.scripts,' \
' [],' \
' exclude_binaries=True,' \
" name='AutoLibrary'," \
' debug=False,' \
' bootloader_ignore_signals=False,' \
' strip=False,' \
' upx=True,' \
' upx_exclude=[],' \
' runtime_tmpdir=None,' \
' console=False,' \
' disable_windowed_traceback=False,' \
' argv_emulation=False,' \
' target_arch=None,' \
' codesign_identity=None,' \
' entitlements_file=None,' \
" icon=['src/gui/resources/icons/AutoLibrary_Logo_64.ico']," \
')' \
'' \
'coll = COLLECT(' \
' exe,' \
' a.binaries,' \
' a.zipfiles,' \
' a.datas,' \
' strip=False,' \
' upx=True,' \
' upx_exclude=[],' \
" name='${exe_name}'," \
')' \
'' \
'app = BUNDLE(' \
' coll,' \
" name='AutoLibrary.app'," \
" icon='src/gui/resources/icons/AutoLibrary_Logo.icns'," \
" bundle_identifier='com.kenanzhu.autolibrary'," \
' info_plist={' \
" 'NSHighResolutionCapable': 'True'," \
" 'CFBundleName': 'AutoLibrary'," \
" 'CFBundleDisplayName': 'AutoLibrary'," \
" 'CFBundleShortVersionString': '${version}'," \
" 'CFBundleVersion': '${version}'," \
" 'CFBundleExecutable': 'AutoLibrary'," \
" 'NSPrincipalClass': 'NSApplication'," \
" 'LSMinimumSystemVersion': '11.0'," \
" 'NSRequiresAquaSystemAppearance': 'False'," \
" 'NSHumanReadableCopyright': 'Copyright 2025 - 2026 KenanZhu. All rights reserved.'," \
' },' \
')' \
> Main.spec
echo "✓ Main.spec generated successfully"
echo ""
echo "========================================"
echo "Generated Main.spec"
echo "========================================"
cat Main.spec
echo "========================================"
- name: Build with PyInstaller
run: |
python -m PyInstaller Main.spec
- name: Create DMG
run: |
tag_name="${{ steps.get_version.outputs.TAG_NAME }}"
dmg_name="AutoLibrary.$tag_name-macos-arm64.dmg"
app_path="dist/AutoLibrary.app"
if [ ! -d "$app_path" ]; then
echo "✗ App bundle not found: $app_path"
echo "Files in dist directory:"
ls -la dist/
exit 1
fi
echo "Creating DMG from: $app_path"
xattr -cr "$app_path" 2>/dev/null || true
tmp_dmg_dir=$(mktemp -d)
cp -R "$app_path" "$tmp_dmg_dir/"
ln -s /Applications "$tmp_dmg_dir/Applications"
hdiutil create \
-volname "AutoLibrary $tag_name" \
-srcfolder "$tmp_dmg_dir" \
-ov \
-format UDZO \
"$dmg_name"
rm -rf "$tmp_dmg_dir"
if [ -f "$dmg_name" ]; then
dmg_size=$(du -h "$dmg_name" | cut -f1)
echo "✓ DMG created: $dmg_name (Size: $dmg_size)"
else
echo "✗ Failed to create DMG"
exit 1
fi
echo "✓ Artifacts ready:"
echo " - $dmg_name"
echo " - $app_path"
- name: Archive artifacts
uses: actions/upload-artifact@v6
with:
name: AutoLibrary.${{ steps.get_version.outputs.TAG_NAME }}-macos-arm64
path: |
AutoLibrary.${{ steps.get_version.outputs.TAG_NAME }}-macos-arm64.dmg
dist/AutoLibrary.app/**
retention-days: 7
- name: Upload build summary
run: |
tag_name="${{ steps.get_version.outputs.TAG_NAME }}"
version="${{ steps.get_version.outputs.VERSION }}"
dmg_name="AutoLibrary.$tag_name-macos-arm64.dmg"
{
echo "## Build Test Summary (macOS)"
echo ""
echo "========================================"
echo "✓ Pull request build test completed successfully!"
echo "- Version: $version"
echo "- Tag: $tag_name"
echo "- Pull Request #${{ github.event.pull_request.number || 'N/A' }}"
echo "- Branch: ${{ github.event.pull_request.head.ref || github.ref }}"
echo "- Event: ${{ github.event_name }}"
echo "- DMG: $dmg_name"
echo ""
echo "### How to test on macOS"
echo '1. Download the DMG artifact'
echo '2. Open the DMG and drag AutoLibrary.app to /Applications'
echo '3. Right-click → Open the app (to bypass Gatekeeper)'
} >> $GITHUB_STEP_SUMMARY
+311 -10
View File
@@ -1,7 +1,10 @@
name: Build
# This workflow compiles the application for Windows platform using PyInstaller, and
# archives the built artifacts as 'AutoLibrary.<tag_name>-windows-x86_64.zip'.
# This workflow compiles the application for Windows and macOS platforms using
# PyInstaller, and archives the built artifacts.
#
# - Windows: AutoLibrary.<tag_name>-windows-x86_64.zip
# - macOS: AutoLibrary.<tag_name>-macos-arm64.dmg
#
# It is triggered when called by the release workflow.
@@ -251,12 +254,310 @@ jobs:
- name: Upload build summary
if: ${{ inputs.is_test == 'true' }}
run: |
Write-Host "## Build Summary" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8
Write-Host "" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
Write-Host "========================================" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
Write-Host "✓ Build test completed successfully!" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
Write-Host "- Version: ${{ steps.get_version.outputs.VERSION }}" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
Write-Host "- Tag: ${{ steps.get_version.outputs.TAG_NAME }}" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
Write-Host "- Event: ${{ github.event_name }}" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
Write-Host "- Ref: ${{ github.ref }}" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
"## Build Summary" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8
"" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
"========================================" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
"✓ Build test completed successfully!" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
"- Version: ${{ steps.get_version.outputs.VERSION }}" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
"- Tag: ${{ steps.get_version.outputs.TAG_NAME }}" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
"- Event: ${{ github.event_name }}" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
"- Ref: ${{ github.ref }}" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
shell: pwsh
#
# Build macOS
#
build-macos:
runs-on: macos-latest
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
ref: ${{ github.ref }}
- name: Download build version of ALVersionInfo.py
uses: actions/download-artifact@v6
with:
name: updated-version-info-for-build
path: src/gui/
- name: Get version info
id: get_version
run: |
is_test="${{ inputs.is_test }}"
if [ "$is_test" = "true" ]; then
version="test"
tag_name="test"
echo "✓ Mode: Test Build"
else
version="${{ inputs.version }}"
tag_name="${{ inputs.tag_name }}"
if [ -z "$version" ]; then
version="test"
tag_name="test"
echo "✓ Mode: Independent Build (No inputs provided)"
fi
fi
echo "✓ Tag: $tag_name"
echo "✓ Version: $version"
echo "VERSION=$version" >> $GITHUB_OUTPUT
echo "TAG_NAME=$tag_name" >> $GITHUB_OUTPUT
- name: Verify 'ALVersionInfo.py' was updated
run: |
version_info_file="src/gui/ALVersionInfo.py"
echo "Verifying $version_info_file content:"
echo "========================================"
cat "$version_info_file"
echo "========================================"
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: '3.13'
cache: 'pip'
cache-dependency-path: requirements.txt
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
- name: Solve ddddocr compatibility and copy model files
run: |
ddddocr_path=$(python -c "import ddddocr, os; print(os.path.dirname(ddddocr.__file__))")
echo "ddddocr package location: $ddddocr_path"
init_file="$ddddocr_path/__init__.py"
if [ -f "$init_file" ]; then
echo "Fixing ddddocr compatibility in: $init_file"
# macOS 使用 BSD sed,要求 -i 后跟备份后缀名(空字符串 = 不备份)
sed -i '' 's/Image\.ANTIALIAS/Image.Resampling.LANCZOS/g' "$init_file"
echo "✓ Fixed: Image.ANTIALIAS -> Image.Resampling.LANCZOS"
else
echo "✗ ddddocr __init__.py not found"
exit 1
fi
mkdir -p models
echo "✓ Created models directory"
# 多路径 fallback 搜索 ONNX 模型,提升 ddddocr 版本兼容性
onnx_source=""
onnx_dest="models/common.onnx"
candidate_paths=(
"$ddddocr_path/common.onnx"
"$ddddocr_path/models/common.onnx"
"$ddddocr_path/ddddocr/common.onnx"
)
for candidate in "${candidate_paths[@]}"; do
if [ -f "$candidate" ]; then
onnx_source="$candidate"
break
fi
done
# 若以上候选均未命中,回退到全包搜索
if [ -z "$onnx_source" ]; then
echo "⚠ 未在已知路径找到 ONNX 模型,回退到全包搜索..."
onnx_source=$(find "$ddddocr_path" -name "*.onnx" -type f 2>/dev/null | head -1)
fi
if [ -n "$onnx_source" ] && [ -f "$onnx_source" ]; then
cp "$onnx_source" "$onnx_dest"
echo "✓ ONNX model copied: $onnx_source -> $onnx_dest"
else
echo "✗ ONNX model not found in ddddocr package"
echo " Searched directory: $ddddocr_path"
ls -la "$ddddocr_path/" || true
exit 1
fi
if [ -f "$onnx_dest" ]; then
file_size=$(du -h "$onnx_dest" | cut -f1)
echo "✓ Model file verified: $onnx_dest (Size: $file_size)"
else
echo "✗ Failed to copy model file"
exit 1
fi
- name: Compile Qt Resource files
run: |
cd batchs
bash compile_rc.sh
- name: Compile Qt UI files
run: |
cd batchs
bash compile_ui.sh
- name: Generate 'Main.spec'
env:
APP_VERSION: ${{ steps.get_version.outputs.VERSION }}
run: |
version="$APP_VERSION"
exe_name="AutoLibrary-$version"
echo "Generating Main.spec for version: $version"
echo "App name: $exe_name"
printf '%s\n' \
'# -*- mode: python ; coding: utf-8 -*-' \
'' \
'a = Analysis(' \
" ['src/Main.py']," \
' pathex=[],' \
' binaries=[],' \
' datas=[' \
" ('models/common.onnx', 'ddddocr')," \
" ('src/gui/resources/icons/AutoLibrary_Logo_64.ico', 'gui/resources/icons')," \
' ],' \
' hiddenimports=[],' \
' hookspath=[],' \
' hooksconfig={},' \
' runtime_hooks=[],' \
' excludes=[],' \
' noarchive=False,' \
' optimize=0,' \
')' \
'pyz = PYZ(a.pure)' \
'' \
'exe = EXE(' \
' pyz,' \
' a.scripts,' \
' [],' \
' exclude_binaries=True,' \
" name='AutoLibrary'," \
' debug=False,' \
' bootloader_ignore_signals=False,' \
' strip=False,' \
' upx=True,' \
' upx_exclude=[],' \
' runtime_tmpdir=None,' \
' console=False,' \
' disable_windowed_traceback=False,' \
' argv_emulation=False,' \
' target_arch=None,' \
' codesign_identity=None,' \
' entitlements_file=None,' \
" icon=['src/gui/resources/icons/AutoLibrary_Logo_64.ico']," \
')' \
'' \
'coll = COLLECT(' \
' exe,' \
' a.binaries,' \
' a.zipfiles,' \
' a.datas,' \
' strip=False,' \
' upx=True,' \
' upx_exclude=[],' \
" name='${exe_name}'," \
')' \
'' \
'app = BUNDLE(' \
' coll,' \
" name='AutoLibrary.app'," \
" icon='src/gui/resources/icons/AutoLibrary_Logo.icns'," \
" bundle_identifier='com.kenanzhu.autolibrary'," \
' info_plist={' \
" 'NSHighResolutionCapable': 'True'," \
" 'CFBundleName': 'AutoLibrary'," \
" 'CFBundleDisplayName': 'AutoLibrary'," \
" 'CFBundleShortVersionString': '${version}'," \
" 'CFBundleVersion': '${version}'," \
" 'CFBundleExecutable': 'AutoLibrary'," \
" 'NSPrincipalClass': 'NSApplication'," \
" 'LSMinimumSystemVersion': '11.0'," \
" 'NSRequiresAquaSystemAppearance': 'False'," \
" 'NSHumanReadableCopyright': 'Copyright 2025 - 2026 KenanZhu. All rights reserved.'," \
' },' \
')' \
> Main.spec
echo "✓ Main.spec generated successfully"
echo ""
echo "========================================"
echo "Generated Main.spec"
echo "========================================"
cat Main.spec
echo "========================================"
- name: Build with PyInstaller
run: |
python -m PyInstaller Main.spec
- name: Create DMG
run: |
tag_name="${{ steps.get_version.outputs.TAG_NAME }}"
dmg_name="AutoLibrary.$tag_name-macos-arm64.dmg"
app_path="dist/AutoLibrary.app"
if [ ! -d "$app_path" ]; then
echo "✗ App bundle not found: $app_path"
echo "Files in dist directory:"
ls -la dist/
exit 1
fi
echo "Creating DMG from: $app_path"
xattr -cr "$app_path" 2>/dev/null || true
tmp_dmg_dir=$(mktemp -d)
cp -R "$app_path" "$tmp_dmg_dir/"
ln -s /Applications "$tmp_dmg_dir/Applications"
hdiutil create \
-volname "AutoLibrary $tag_name" \
-srcfolder "$tmp_dmg_dir" \
-ov \
-format UDZO \
"$dmg_name"
rm -rf "$tmp_dmg_dir"
if [ -f "$dmg_name" ]; then
dmg_size=$(du -h "$dmg_name" | cut -f1)
echo "✓ DMG created: $dmg_name (Size: $dmg_size)"
else
echo "✗ Failed to create DMG"
exit 1
fi
echo "✓ Artifacts ready:"
echo " - $dmg_name"
echo " - $app_path"
- name: Archive artifacts
uses: actions/upload-artifact@v6
with:
name: AutoLibrary.${{ steps.get_version.outputs.TAG_NAME }}-macos-arm64
path: |
AutoLibrary.${{ steps.get_version.outputs.TAG_NAME }}-macos-arm64.dmg
dist/AutoLibrary.app/**
retention-days: ${{ inputs.is_test == 'true' && 7 || 90 }}
- name: Upload build summary
if: ${{ inputs.is_test == 'true' }}
run: |
tag_name="${{ steps.get_version.outputs.TAG_NAME }}"
version="${{ steps.get_version.outputs.VERSION }}"
dmg_name="AutoLibrary.$tag_name-macos-arm64.dmg"
{
echo "## Build Summary (macOS)"
echo ""
echo "========================================"
echo "✓ Build test completed successfully!"
echo "- Version: $version"
echo "- Tag: $tag_name"
echo "- Event: ${{ github.event_name }}"
echo "- Ref: ${{ github.ref }}"
echo "- DMG: $dmg_name"
echo ""
echo "### How to test on macOS"
echo '1. Download the DMG artifact'
echo '2. Open the DMG and drag AutoLibrary.app to /Applications'
echo '3. Right-click → Open the app (to bypass Gatekeeper)'
} >> $GITHUB_STEP_SUMMARY
+12 -3
View File
@@ -21,8 +21,10 @@ name: Release
# Commits version changes to release branch and creates the release tag.
# 4. Build:
# Compiles the application for Windows platform using PyInstaller, and
# archives the built artifacts as 'AutoLibrary.<tag_name>-windows-x86_64.zip'.
# Compiles the application for Windows and macOS platforms using PyInstaller, and
# archives the built artifacts.
# - Windows: AutoLibrary.<tag_name>-windows-x86_64.zip
# - macOS: AutoLibrary.<tag_name>-macos-arm64.dmg
# 5. Release:
# Creates GitHub release with generated artifacts and release notes
@@ -181,12 +183,18 @@ jobs:
contents: write
steps:
- name: Download artifacts
- name: Download Windows artifacts
uses: actions/download-artifact@v6
with:
name: AutoLibrary.${{ needs.extract-version.outputs.tag_name }}-windows-x86_64
path: artifacts/
- name: Download macOS artifacts
uses: actions/download-artifact@v6
with:
name: AutoLibrary.${{ needs.extract-version.outputs.tag_name }}-macos-arm64
path: artifacts/
- name: Create release
id: create_release
uses: softprops/action-gh-release@v2
@@ -195,6 +203,7 @@ jobs:
name: AutoLibrary ${{ needs.extract-version.outputs.tag_name }}
files: |
artifacts/AutoLibrary.${{ needs.extract-version.outputs.tag_name }}-windows-x86_64.zip
artifacts/AutoLibrary.${{ needs.extract-version.outputs.tag_name }}-macos-arm64.dmg
draft: false
prerelease: ${{ needs.extract-version.outputs.is_rc == 'true' }}
generate_release_notes: true
BIN
View File
Binary file not shown.
+1
View File
@@ -24,6 +24,7 @@ def main():
translator = QTranslator()
if translator.load(":/res/translators/qtbase_zh_CN.ts"):
app.installTranslator(translator)
app.setStyle("Fusion")
app.setApplicationName("AutoLibrary")
if not initializeApp():
sys.exit(-1)
+2 -31
View File
@@ -10,16 +10,10 @@ See the LICENSE file for details.
import os
from PySide6.QtCore import QStandardPaths, QDir
from PySide6.QtWidgets import QApplication
from interfaces.ConfigProvider import CfgKey
from managers.log.LogManager import instance as logInstance
from managers.config.ConfigManager import instance as configInstance
from managers.driver.WebDriverManager import instance as webdriverInstance
from managers.log.LogManager import instance as logInstance
from managers.theme.ThemeManager import(
setActiveStyle,
instance as themeInstance
)
def _initializeLogManager(
@@ -70,35 +64,13 @@ def _initializeWebDriverManager(
webdriverInstance(driver_dir)
return True
def _initializeAppearance(
):
app = QApplication.instance()
if not app:
return
cfg = configInstance()
saved_style = cfg.get(CfgKey.GLOBAL.APPEARANCE.STYLE, "Fusion")
saved_theme = cfg.get(CfgKey.GLOBAL.APPEARANCE.THEME, "system")
saved_custom_theme = cfg.get(CfgKey.GLOBAL.APPEARANCE.CUSTOM_THEME, "")
app.setStyle(saved_style)
setActiveStyle(saved_style)
logger = logInstance().getLogger("AppInitializer")
if saved_custom_theme:
try:
themeInstance().applyTheme(saved_custom_theme)
except Exception:
logger.warning("无法应用自定义主题 '%s',回退到默认外观", saved_custom_theme)
themeInstance().clearTheme(saved_theme)
return
themeInstance().clearTheme(saved_theme)
def initializeApp(
) -> bool:
"""
Initialize the application components
Order:
LogManager -> ConfigManager -> WebDriverManager -> Appearance
LogManager -> ConfigManager -> WebDriverManager
"""
if not _initializeLogManager():
@@ -107,5 +79,4 @@ def initializeApp(
return False
if not _initializeWebDriverManager():
return False
_initializeAppearance()
return True
+7 -18
View File
@@ -9,8 +9,6 @@ See the LICENSE file for details.
"""
from copy import deepcopy
import qtawesome as qta
from PySide6.QtCore import (
QDate,
QSize,
@@ -22,6 +20,7 @@ from PySide6.QtCore import (
from PySide6.QtGui import (
QColor,
QFont,
QIcon,
QSyntaxHighlighter,
QTextCharFormat,
)
@@ -216,18 +215,18 @@ class ALAutoScriptEditDialog(QDialog):
self.ZoomOutBtn = QPushButton("")
self.ZoomOutBtn.setFixedSize(25, 25)
self.ZoomResetBtn = QPushButton("")
self.ZoomResetBtn.setIcon(qta.icon("fa5s.undo", color=self._iconColor()))
self.ZoomResetBtn.setIconSize(QSize(14, 14))
self.ZoomResetBtn.setIcon(QIcon(":/res/icons/Reset.svg"))
self.ZoomResetBtn.setIconSize(QSize(20, 20))
self.ZoomResetBtn.setFixedSize(25, 25)
self.ZoomResetBtn.setToolTip("重置缩放")
self.ZoomLabel = QLabel(f"{self._fontSize}px")
self.ZoomLabel.setFixedHeight(25)
self.OrchBtn = QPushButton("编排")
self.OrchBtn.setFixedSize(80, 25)
self.OrchBtn.setFixedHeight(25)
self.OrchBtn.setToolTip("可视化生成 AutoScript 代码并插入到光标位置")
ToolbarLayout.addWidget(self.OrchBtn)
self.DebugBtn = QPushButton("▶ 调试运行")
self.DebugBtn.setFixedSize(80, 25)
self.DebugBtn.setFixedHeight(25)
self.DebugBtn.setToolTip("使用右侧模拟数据执行脚本,查看目标变量变化")
ToolbarLayout.addWidget(self.DebugBtn)
Sep = QFrame()
@@ -241,8 +240,8 @@ class ALAutoScriptEditDialog(QDialog):
ToolbarLayout.addWidget(self.ZoomLabel)
ToolbarLayout.addStretch()
self.CopyBtn = QPushButton("")
self.CopyBtn.setIcon(qta.icon("fa5s.copy", color=self._iconColor()))
self.CopyBtn.setIconSize(QSize(14, 14))
self.CopyBtn.setIcon(QIcon(":/res/icons/Copy.svg"))
self.CopyBtn.setIconSize(QSize(20, 20))
self.CopyBtn.setFixedSize(25, 25)
self.CopyBtn.setToolTip("复制脚本")
ToolbarLayout.addWidget(self.CopyBtn)
@@ -265,9 +264,7 @@ class ALAutoScriptEditDialog(QDialog):
QDialogButtonBox.StandardButton.Cancel
)
self.BtnBox.button(QDialogButtonBox.StandardButton.Ok).setText("确定")
self.BtnBox.button(QDialogButtonBox.StandardButton.Ok).setFixedSize(80, 25)
self.BtnBox.button(QDialogButtonBox.StandardButton.Cancel).setText("取消")
self.BtnBox.button(QDialogButtonBox.StandardButton.Cancel).setFixedSize(80, 25)
Layout.addWidget(self.BtnBox)
def createButtonPanel(
@@ -540,14 +537,6 @@ class ALAutoScriptEditDialog(QDialog):
else:
widget.setText(str(value))
def _iconColor(
self
) -> str:
return QApplication.instance().palette().color(
QApplication.instance().palette().ColorRole.WindowText
).name()
def connectSignals(
self
):
+1 -31
View File
@@ -33,7 +33,6 @@ from PySide6.QtWidgets import (
from base.MsgBase import MsgBase
from gui.ALAboutDialog import ALAboutDialog
from gui.ALConfigWidget import ALConfigWidget
from gui.ALSettingsWidget import ALSettingsWidget
from gui.ALMainWorkers import (
AutoLibWorker,
TimerTaskWorker
@@ -61,7 +60,6 @@ class ALMainWindow(MsgBase, QMainWindow, Ui_ALMainWindow):
self.__config_paths = ConfigUtils.getAutomationConfigPaths()
self.__alTimerTaskManageWidget = None
self.__alConfigWidget = None
self.__alSettingsWidget = None
self.__auto_lib_thread = None
self.__current_timer_task_thread = None
self.__is_running_timer_task = False
@@ -83,7 +81,6 @@ class ALMainWindow(MsgBase, QMainWindow, Ui_ALMainWindow):
self.MessageIOTextEdit.setFont(QFont("Courier New", 10))
self.ManualAction.triggered.connect(self.onManualActionTriggered)
self.AboutAction.triggered.connect(self.onAboutActionTriggered)
self.SettingsAction.triggered.connect(self.onSettingsActionTriggered)
# initialize timer task widget, but not show it
try:
@@ -128,6 +125,7 @@ class ALMainWindow(MsgBase, QMainWindow, Ui_ALMainWindow):
return
self.TrayIcon = QSystemTrayIcon(self.Icon, self)
self.TrayIcon.setToolTip("AutoLibrary")
self.TrayMenu = QMenu()
self.TrayMenu.addAction("显示主窗口", self.showNormal)
self.TrayMenu.addAction("显示定时窗口", self.onTimerTaskManageWidgetButtonClicked)
@@ -192,9 +190,6 @@ class ALMainWindow(MsgBase, QMainWindow, Ui_ALMainWindow):
if self.__alConfigWidget:
self.__alConfigWidget.close()
# the config widget is already deleted in the 'self.onConfigWidgetClosed'
if self.__alSettingsWidget:
self.__alSettingsWidget.close()
# the settings widget is already deleted in the 'self.onSettingsWidgetClosed'
self._showLog("主窗口关闭")
QMainWindow.closeEvent(self, event)
@@ -307,31 +302,6 @@ class ALMainWindow(MsgBase, QMainWindow, Ui_ALMainWindow):
self.setControlButtons(True, None, None)
self._showLog("配置窗口已关闭,配置文件路径已更新")
@Slot()
def onSettingsWidgetClosed(
self
):
if self.__alSettingsWidget:
self.__alSettingsWidget.settingsWidgetIsClosed.disconnect(self.onSettingsWidgetClosed)
self.__alSettingsWidget.deleteLater()
self.__alSettingsWidget = None
self.SettingsAction.setEnabled(True)
@Slot()
def onSettingsActionTriggered(
self
):
if self.__alSettingsWidget is None:
self.__alSettingsWidget = ALSettingsWidget(self)
self.__alSettingsWidget.settingsWidgetIsClosed.connect(self.onSettingsWidgetClosed)
self.__alSettingsWidget.show()
self.__alSettingsWidget.raise_()
self.__alSettingsWidget.activateWindow()
self.SettingsAction.setEnabled(False)
self._showLog("打开全局设置窗口")
@Slot(dict)
def onTimerTaskIsReady(
self,
-408
View File
@@ -1,408 +0,0 @@
# -*- coding: utf-8 -*-
"""
Copyright (c) 2026 KenanZhu.
All rights reserved.
This software is provided "as is", without any warranty of any kind.
You may use, modify, and distribute this file under the terms of the MIT License.
See the LICENSE file for details.
"""
import os
import sys
import qtawesome as qta
from PySide6.QtCore import (
QProcess,
Qt,
Signal,
Slot
)
from PySide6.QtGui import (
QCloseEvent,
QShowEvent
)
from PySide6.QtWidgets import (
QApplication,
QFileDialog,
QMessageBox,
QStyleFactory,
QWidget
)
import managers.config.ConfigManager as ConfigManager
from managers.log.LogManager import instance as logInstance
from managers.theme.ThemeManager import(
getActiveStyle,
setActiveStyle,
instance as themeInstance
)
from gui.resources.ui.Ui_ALSettingsWidget import Ui_ALSettingsWidget
from interfaces.ConfigProvider import (
CfgKey,
ConfigProvider
)
def _applyCustomTheme(
name: str,
fallback_theme: str = "system"
) -> bool:
if not name:
themeInstance().clearTheme(fallback_theme)
return True
try:
themeInstance().applyTheme(name)
return True
except Exception as e:
logInstance().getLogger("ALSettingsWidget").warning(
f"无法应用自定义主题 '{name}',回退到 {fallback_theme} 外观: {e}"
)
themeInstance().clearTheme(fallback_theme)
return False
def _themeToReadable(
need_theme: str
) -> str:
if need_theme == "dark":
return "深色"
elif need_theme == "light":
return "浅色"
elif need_theme == "both":
return "所有"
else:
return "未知"
def _restartApp(
):
QApplication.instance().quit()
QProcess.startDetached(sys.executable, sys.argv)
class ALSettingsWidget(QWidget, Ui_ALSettingsWidget):
settingsWidgetIsClosed = Signal()
def __init__(
self,
parent=None
):
super().__init__(parent)
self.__cfg_mgr: ConfigProvider = ConfigManager.instance()
self.__original_theme: str = ""
self.__original_custom_theme: str = ""
self.__original_style: str = ""
self.setupUi(self)
self.modifyUi()
self.connectSignals()
self.loadSettings()
def modifyUi(
self
):
self.setWindowFlags(Qt.WindowType.Window)
self.NavigationList.setCurrentRow(0)
self.populateStyles()
self.setNavigationIcons()
self.ThemeInfoLabel.setTextFormat(Qt.TextFormat.RichText)
self.ThemeInfoLabel.setStyleSheet(
"border: 1px solid palette(mid);"\
"border-radius: 2px;"\
"padding: 5px;"
)
def setNavigationIcons(
self
):
app : QApplication | None = QApplication.instance()
color = app.palette().color(app.palette().ColorRole.WindowText).name()
item = self.NavigationList.item(0)
if item:
item.setIcon(qta.icon("fa5s.palette", color=color))
def populateStyles(
self
):
self.StyleComboBox.clear()
self.StyleComboBox.addItems(QStyleFactory.keys())
def connectSignals(
self
):
self.BrowseQssButton.clicked.connect(self.onImportThemeButtonClicked)
self.ThemeComboBox.currentIndexChanged.connect(self.onThemeComboBoxChanged)
self.ResetThemeButton.clicked.connect(self.onResetThemeButtonClicked)
self.CancelButton.clicked.connect(self.onCancelButtonClicked)
self.ApplyButton.clicked.connect(self.onApplyButtonClicked)
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(
self
):
theme = self.__cfg_mgr.get(CfgKey.GLOBAL.APPEARANCE.THEME, "system")
style = self.__cfg_mgr.get(CfgKey.GLOBAL.APPEARANCE.STYLE, "Fusion")
custom_theme = self.__cfg_mgr.get(CfgKey.GLOBAL.APPEARANCE.CUSTOM_THEME, "")
self.__original_theme = theme
self.__original_custom_theme = custom_theme
self.__original_style = getActiveStyle()
if theme == "light":
self.LightThemeRadio.setChecked(True)
elif theme == "dark":
self.DarkThemeRadio.setChecked(True)
else:
self.SystemThemeRadio.setChecked(True)
index = self.StyleComboBox.findText(style)
if index < 0:
index = 0
self.StyleComboBox.setCurrentIndex(index)
self.populateThemeList()
if custom_theme:
idx = self.ThemeComboBox.findText(custom_theme)
if idx >= 0:
self.ThemeComboBox.setCurrentIndex(idx)
self.updateThemeStatus()
self.updateThemeInfo()
def updateThemeStatus(
self
):
name = self.ThemeComboBox.currentText()
if name and name != "默认":
self.QssStatusLabel.setText(f"当前使用 {name} 主题。")
else:
self.QssStatusLabel.setText("当前使用 默认 主题。")
def updateThemeInfo(
self
):
name = self.ThemeComboBox.currentText()
if not name or name == "默认":
self.ThemeInfoLabel.setText("")
return
t = self.__theme_cache.get(name)
if t:
author = t.get("author", "未知")
need_theme = t.get("need_theme", "both")
brief = t.get("brief", "没有相关简介")
self.ThemeInfoLabel.setText(
f"<b>{name}</b> - 适用于 <i>{_themeToReadable(need_theme)}</i> 主题<br>"
f"作者:{author}<br><br>"
f"{brief}"
)
else:
self.ThemeInfoLabel.setText("")
def syncRadioFromNeedTheme(
self,
name: str
):
t = self.__theme_cache.get(name)
if t:
need_theme = t.get("need_theme", "both")
if need_theme == "light":
self.LightThemeRadio.setChecked(True)
elif need_theme == "dark":
self.DarkThemeRadio.setChecked(True)
def collectSettings(
self
):
if self.LightThemeRadio.isChecked():
theme = "light"
elif self.DarkThemeRadio.isChecked():
theme = "dark"
else:
theme = "system"
style = self.StyleComboBox.currentText()
custom_theme = self.ThemeComboBox.currentText()
if custom_theme == "默认":
custom_theme = ""
return theme, style, custom_theme
def saveAndApply(
self
):
theme, style, custom_theme = self.collectSettings()
self.__cfg_mgr.set(CfgKey.GLOBAL.APPEARANCE.STYLE, style)
self.__cfg_mgr.set(CfgKey.GLOBAL.APPEARANCE.CUSTOM_THEME, custom_theme)
setActiveStyle(style)
if not _applyCustomTheme(custom_theme, theme):
self.__cfg_mgr.set(CfgKey.GLOBAL.APPEARANCE.CUSTOM_THEME, "")
self.syncRadioFromNeedTheme(custom_theme)
# Re-read theme after syncRadioFromNeedTheme — the radio may have
# changed to match the custom theme's need_theme
theme, _, _ = self.collectSettings()
self.__cfg_mgr.set(CfgKey.GLOBAL.APPEARANCE.THEME, theme)
self.setNavigationIcons()
self.updateThemeStatus()
self.updateThemeInfo()
self.__original_theme = theme
self.__original_custom_theme = custom_theme if custom_theme else ""
self.__original_style = getActiveStyle()
def maybeRestart(
self
) -> bool:
reply = QMessageBox.question(
self,
"提示 - AutoLibrary",
"界面风格已修改,需要重启程序才能生效。是否立即重启?",
QMessageBox.Yes | QMessageBox.No,
QMessageBox.Yes
)
if reply == QMessageBox.Yes:
_restartApp()
return True
return False
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", "")
if name:
self.__theme_cache[name] = t
self.ThemeComboBox.addItem(name)
self.ThemeComboBox.blockSignals(False)
@Slot()
def onImportThemeButtonClicked(
self
):
file_path, _ = QFileDialog.getOpenFileName(
self,
"导入主题 - AutoLibrary",
"",
"主题文件 (*.altheme *.qss);;所有文件 (*)"
)
if not file_path:
return
try:
name = themeInstance().importTheme(file_path)
self.populateThemeList()
idx = self.ThemeComboBox.findText(name)
if idx >= 0:
self.ThemeComboBox.setCurrentIndex(idx)
self.updateThemeStatus()
self.updateThemeInfo()
except Exception as e:
QMessageBox.warning(
self,
"导入失败 - AutoLibrary",
f"无法导入主题文件:{e}"
)
@Slot()
def onThemeComboBoxChanged(
self,
index: int
):
self.updateThemeInfo()
@Slot()
def onResetThemeButtonClicked(
self
):
self.ThemeComboBox.blockSignals(True)
if self.__original_custom_theme:
idx = self.ThemeComboBox.findText(self.__original_custom_theme)
if idx >= 0:
self.ThemeComboBox.setCurrentIndex(idx)
else:
self.ThemeComboBox.setCurrentIndex(0)
else:
self.ThemeComboBox.setCurrentIndex(0)
self.ThemeComboBox.blockSignals(False)
if self.__original_theme == "light":
self.LightThemeRadio.setChecked(True)
elif self.__original_theme == "dark":
self.DarkThemeRadio.setChecked(True)
else:
self.SystemThemeRadio.setChecked(True)
_applyCustomTheme(self.__original_custom_theme, self.__original_theme)
self.updateThemeStatus()
self.updateThemeInfo()
@Slot()
def onCancelButtonClicked(
self
):
self.close()
@Slot()
def onApplyButtonClicked(
self
):
_, style, _ = self.collectSettings()
style_changed = self.__original_style != style
self.saveAndApply()
if style_changed:
self.maybeRestart()
@Slot()
def onConfirmButtonClicked(
self
):
_, style, _ = self.collectSettings()
style_changed = self.__original_style != style
self.saveAndApply()
if style_changed:
self.maybeRestart()
self.close()
Binary file not shown.
-553
View File
@@ -1,553 +0,0 @@
/*
* Copyright (c) 2026 KenanZhu.
* All rights reserved.
*
* This software is provided "as is", without any warranty of any kind.
* You may use, modify, and distribute this file under the terms of the MIT License.
* See the LICENSE file for details.
*
*
* AutoLibrary Official Theme : BlueForest
*/
/* ---- Global ---- */
QMainWindow::separator {
background-color: #1c2840;
width: 1px;
height: 1px;
}
/* ---- Menu Bar ---- */
QMenuBar {
background-color: #0f1628;
border-bottom: 1px solid #1c2840;
padding: 2px 6px;
color: #d0daf0;
}
QMenuBar::item {
padding: 4px 10px;
border-radius: 4px;
}
QMenuBar::item:selected {
background-color: #1c2840;
}
QMenu {
background-color: #162038;
border-style: solid;
border-color: #253250;
border-width: 1px;
padding: 4px;
border-radius: 6px;
}
QMenu::item {
padding: 5px 15px 5px 10px;
border-radius: 4px;
}
QMenu::item:selected {
background-color: #2dd4bf;
color: #0f1119;
}
QMenu::separator {
height: 1px;
background-color: #253250;
margin: 4px 8px;
}
/* ---- Button ---- */
QPushButton {
border-style: solid;
border-color: #253250;
border-width: 1px;
border-radius: 5px;
color: #d0daf0;
padding: 4px 12px;
background-color: #1c2840;
}
QPushButton:hover {
background-color: #243458;
border-color: #334478;
}
QPushButton:pressed {
background-color: #162038;
border-color: #2dd4bf;
}
QPushButton:disabled {
background-color: #162038;
color: #5568a0;
border-color: #1c2840;
}
QPushButton[default="true"] {
background-color: #2dd4bf;
color: #0f1119;
border-color: #2dd4bf;
}
QPushButton[default="true"]:hover {
background-color: #3de0cc;
}
/* ---- Input ---- */
QLineEdit,
QPlainTextEdit,
QTextEdit,
QSpinBox,
QDoubleSpinBox,
QDateEdit,
QTimeEdit {
background-color: #0a1020;
border-style: solid;
border-color: #253250;
border-width: 1px;
border-radius: 5px;
padding: 4px 8px;
color: #d0daf0;
selection-background-color: #2dd4bf;
selection-color: #0f1119;
}
QLineEdit:focus,
QPlainTextEdit:focus,
QTextEdit:focus,
QSpinBox:focus,
QDoubleSpinBox:focus,
QDateEdit:focus,
QTimeEdit:focus {
border-color: #2dd4bf;
}
QPlainTextEdit,
QTextEdit {
background-color: #0a1020;
}
QLineEdit:disabled,
QPlainTextEdit:disabled,
QTextEdit:disabled,
QSpinBox:disabled,
QDoubleSpinBox:disabled,
QDateEdit:disabled,
QTimeEdit:disabled {
background-color: #162038;
color: #5568a0;
border-color: #1c2840;
}
/* ---- Spin Button Arrows ---- */
QSpinBox::up-button,
QDoubleSpinBox::up-button,
QDateEdit::up-button,
QTimeEdit::up-button {
subcontrol-origin: border;
subcontrol-position: top right;
width: 10px;
border-left: 1px solid #253250;
border-bottom: 1px solid #253250;
border-top-right-radius: 4px;
}
QSpinBox::up-button:hover,
QDoubleSpinBox::up-button:hover,
QDateEdit::up-button:hover,
QTimeEdit::up-button:hover {
background-color: #1c2840;
}
QSpinBox::up-arrow,
QDoubleSpinBox::up-arrow,
QDateEdit::up-arrow,
QTimeEdit::up-arrow {
/* image: none; */
border-left: 4px solid transparent;
border-right: 4px solid transparent;
border-bottom: 5px solid #7888b8;
margin-top: 2px;
}
QSpinBox::down-button,
QDoubleSpinBox::down-button,
QDateEdit::down-button,
QTimeEdit::down-button {
width: 10px;
subcontrol-origin: border;
subcontrol-position: bottom right;
border-left: 1px solid #253250;
border-bottom-right-radius: 4px;
}
QSpinBox::down-button:hover,
QDoubleSpinBox::down-button:hover,
QDateEdit::down-button:hover,
QTimeEdit::down-button:hover {
background-color: #1c2840;
}
QSpinBox::down-arrow,
QDoubleSpinBox::down-arrow,
QDateEdit::down-arrow,
QTimeEdit::down-arrow {
image: none;
border-left: 4px solid transparent;
border-right: 4px solid transparent;
border-top: 5px solid #7888b8;
margin-bottom: 2px;
}
QSpinBox::up-button:disabled,
QDoubleSpinBox::up-button:disabled,
QDateEdit::up-button:disabled,
QTimeEdit::up-button:disabled,
QSpinBox::down-button:disabled,
QDoubleSpinBox::down-button:disabled,
QDateEdit::down-button:disabled,
QTimeEdit::down-button:disabled {
background-color: #162038;
}
QSpinBox::up-arrow:disabled,
QDoubleSpinBox::up-arrow:disabled,
QDateEdit::up-arrow:disabled,
QTimeEdit::up-arrow:disabled {
border-bottom-color: #5568a0;
}
QSpinBox::down-arrow:disabled,
QDoubleSpinBox::down-arrow:disabled,
QDateEdit::down-arrow:disabled,
QTimeEdit::down-arrow:disabled {
border-top-color: #5568a0;
}
/* ---- Combo Box ---- */
QComboBox {
background-color: #1c2840;
border-style: solid;
border-color: #253250;
border-width: 1px;
border-radius: 5px;
padding: 4px 10px;
color: #d0daf0;
}
QComboBox:hover {
border-color: #334478;
}
QComboBox:focus {
border-color: #2dd4bf;
}
QComboBox::drop-down {
subcontrol-origin: padding;
subcontrol-position: top right;
width: 24px;
border-left: 1px solid #253250;
border-top-right-radius: 5px;
border-bottom-right-radius: 5px;
}
QComboBox::down-arrow {
image: none;
border-left: 4px solid transparent;
border-right: 4px solid transparent;
border-top: 6px solid #7888b8;
margin-right: 6px;
}
QComboBox QAbstractItemView {
background-color: #162038;
border-style: solid;
border-color: #253250;
border-width: 1px;
border-radius: 4px;
selection-background-color: #2dd4bf;
selection-color: #0f1119;
outline: none;
}
QComboBox:disabled {
background-color: #162038;
color: #5568a0;
border-color: #1c2840;
}
/* ---- Check Box / Radio Button ---- */
QCheckBox,
QRadioButton {
spacing: 8px;
color: #d0daf0;
}
QCheckBox::indicator,
QRadioButton::indicator {
border-style: solid;
border-color: #334478;
border-width: 2px;
border-radius: 3px;
background-color: #0a1020;
}
QCheckBox::indicator:hover,
QRadioButton::indicator:hover {
border-color: #2dd4bf;
}
QCheckBox::indicator:checked {
background-color: #2dd4bf;
border-color: #2dd4bf;
}
QRadioButton::indicator {
border-radius: 10px;
}
QRadioButton::indicator:checked {
background-color: #2dd4bf;
border-color: #2dd4bf;
}
QCheckBox::indicator:disabled,
QRadioButton::indicator:disabled {
border-color: #253250;
background-color: #162038;
}
QCheckBox::indicator:checked:hover,
QRadioButton::indicator:checked:hover {
border-color: #a0f0e8;
}
/* Tree / List / Table Widget CheckBox Indicator */
QTreeWidget::indicator,
QListWidget::indicator,
QTableWidget::indicator {
border: 2px solid #5568a0;
border-radius: 3px;
background-color: #162038;
}
QTreeWidget::indicator:hover,
QListWidget::indicator:hover,
QTableWidget::indicator:hover {
border-color: #a0f0e8;
}
QTreeWidget::indicator:checked,
QListWidget::indicator:checked,
QTableWidget::indicator:checked {
background-color: #2dd4bf;
border-color: #2dd4bf;
}
QTreeWidget::indicator:checked:hover,
QListWidget::indicator:checked:hover,
QTableWidget::indicator:checked:hover {
border-color: #a0f0e8;
}
QTreeWidget::indicator:disabled,
QListWidget::indicator:disabled,
QTableWidget::indicator:disabled {
background-color: #1c2840;
border-color: #334478;
}
QTreeWidget::indicator:indeterminate,
QListWidget::indicator:indeterminate,
QTableWidget::indicator:indeterminate {
background-color: #2dd4bf;
border-color: #a0f0e8;
}
/* ---- Group Box ---- */
QGroupBox {
border-style: solid;
border-color: #253250;
border-width: 1px;
border-radius: 6px;
margin-top: 12px;
padding-top: 14px;
color: #d0daf0;
font-weight: bold;
}
QGroupBox::title {
subcontrol-origin: margin;
left: 12px;
padding: 0 6px;
color: #8b9ad0;
}
/* ---- Tab ---- */
QTabWidget::pane {
border-style: solid;
border-color: #253250;
border-width: 1px;
border-radius: 5px;
background-color: #0f1a2e;
top: -1px;
}
QTabBar::tab {
background-color: #162038;
border-style: solid;
border-color: #253250;
border-width: 1px;
border-bottom: none;
border-top-left-radius: 5px;
border-top-right-radius: 5px;
padding: 6px 16px;
margin-right: 2px;
color: #7888b8;
}
QTabBar::tab:selected {
background-color: #0f1a2e;
color: #2dd4bf;
border-bottom: 2px solid #2dd4bf;
}
/* ---- List / Tree ---- */
QListWidget,
QTreeWidget,
QTableWidget {
background-color: #0a1020;
border-style: solid;
border-color: #253250;
border-width: 1px;
border-radius: 5px;
outline: none;
color: #d0daf0;
alternate-background-color: #101c30;
}
QListWidget::item,
QTreeWidget::item,
QTableWidget::item {
padding: 5px 10px;
border: none;
}
QListWidget::item:selected,
QTreeWidget::item:selected,
QTableWidget::item:selected {
background-color: #2dd4bf;
color: #0f1119;
}
QHeaderView::section {
background-color: #0f1628;
border: none;
border-right: 1px solid #253250;
border-bottom: 1px solid #253250;
padding: 5px 10px;
color: #8b9ad0;
font-weight: bold;
}
/* ---- Scroll Bar ---- */
QScrollBar:vertical {
background-color: #0f1a2e;
width: 10px;
border-radius: 5px;
}
QScrollBar::handle:vertical {
background-color: #334478;
min-height: 30px;
border-radius: 5px;
}
QScrollBar::handle:vertical:hover {
background-color: #5568a0;
}
QScrollBar::add-line:vertical,
QScrollBar::sub-line:vertical {
height: 0;
}
QScrollBar:horizontal {
background-color: #0f1a2e;
height: 10px;
border-radius: 5px;
}
QScrollBar::handle:horizontal {
background-color: #334478;
min-width: 30px;
border-radius: 5px;
}
QScrollBar::handle:horizontal:hover {
background-color: #5568a0;
}
QScrollBar::add-line:horizontal,
QScrollBar::sub-line:horizontal {
width: 0;
}
/* ---- Progress Bar ---- */
QProgressBar {
background-color: #0a1020;
border-style: solid;
border-color: #253250;
border-width: 1px;
border-radius: 5px;
height: 10px;
text-align: center;
color: #d0daf0;
}
QProgressBar::chunk {
background-color: #2dd4bf;
border-radius: 4px;
}
/* ---- Slider ---- */
QSlider::groove:horizontal {
background-color: #1c2840;
height: 6px;
border-radius: 3px;
}
QSlider::handle:horizontal {
background-color: #2dd4bf;
width: 16px;
height: 16px;
margin: -5px 0;
border-radius: 8px;
}
QSlider::sub-page:horizontal {
background-color: #2dd4bf;
border-radius: 3px;
}
QSlider::handle:horizontal:disabled {
background-color: #5568a0;
}
QSlider::sub-page:horizontal:disabled {
background-color: #5568a0;
}
/* ---- Tool Tip ---- */
QToolTip {
background-color: #1c2840;
border-style: solid;
border-color: #2dd4bf;
border-width: 1px;
border-radius: 4px;
padding: 4px 8px;
color: #d0daf0;
}
/* ---- Status Bar ---- */
QStatusBar {
background-color: #0f1628;
border-top: 1px solid #1c2840;
color: #7888b8;
}
/* ---- Splitter ---- */
QSplitter::handle {
background-color: #253250;
margin: 1px;
}
QSplitter::handle:horizontal {
width: 2px;
}
QSplitter::handle:vertical {
height: 2px;
}
/* ---- Dialog ---- */
QDialog {
background-color: #0f1a2e;
}
/* ---- Date / Time Editor Drop-down ---- */
QDateEdit::drop-down,
QTimeEdit::drop-down {
subcontrol-origin: padding;
subcontrol-position: top right;
width: 24px;
border-left: 1px solid #253250;
}
QCalendarWidget {
background-color: #162038;
border-style: solid;
border-color: #253250;
border-width: 1px;
border-radius: 6px;
}
QCalendarWidget QToolButton {
color: #d0daf0;
border-radius: 4px;
padding: 4px 8px;
}
QCalendarWidget QToolButton:hover {
background-color: #1c2840;
}
QCalendarWidget QMenu {
background-color: #162038;
}
/* ---- Frame ---- */
QFrame[frameShape="4"], /* HLine */
QFrame[frameShape="5"] /* VLine */ {
background-color: #253250;
}
-557
View File
@@ -1,557 +0,0 @@
/*
* Copyright (c) 2026 KenanZhu.
* All rights reserved.
*
* This software is provided "as is", without any warranty of any kind.
* You may use, modify, and distribute this file under the terms of the MIT License.
* See the LICENSE file for details.
*
*
* AutoLibrary Official Theme : LightLake
*/
/* ---- Global ---- */
QMainWindow::separator {
background-color: #c0cdda;
width: 1px;
height: 1px;
}
/* ---- Menu Bar ---- */
QMenuBar {
background-color: #dce4ee;
border-bottom: 1px solid #c0cdda;
padding: 2px 6px;
color: #1a2740;
}
QMenuBar::item {
padding: 4px 10px;
border-radius: 4px;
}
QMenuBar::item:selected {
background-color: #d5dde8;
}
QMenu {
background-color: #ffffff;
border-style: solid;
border-color: #d0d8e4;
border-width: 1px;
padding: 4px;
border-radius: 6px;
}
QMenu::item {
padding: 5px 15px 5px 10px;
border-radius: 4px;
}
QMenu::item:selected {
background-color: #0ea58a;
color: #ffffff;
}
QMenu::separator {
height: 1px;
background-color: #d0d8e4;
margin: 4px 8px;
}
/* ---- Button ---- */
QPushButton {
border-style: solid;
border-color: #c0cdda;
border-width: 1px;
border-radius: 5px;
color: #1a2740;
padding: 4px 12px;
background-color: #d5dde8;
}
QPushButton:hover {
background-color: #c8d4e2;
border-color: #90a4c4;
}
QPushButton:pressed {
background-color: #e2e8f0;
border-color: #0ea58a;
}
QPushButton:disabled {
background-color: #e8ecf2;
color: #98a8c0;
border-color: #d5dde8;
}
QPushButton[default="true"] {
background-color: #0ea58a;
color: #ffffff;
border-color: #0ea58a;
}
QPushButton[default="true"]:hover {
background-color: #14c7a4;
}
/* ---- Input ---- */
QLineEdit,
QPlainTextEdit,
QTextEdit,
QSpinBox,
QDoubleSpinBox,
QDateEdit,
QTimeEdit {
background-color: #ffffff;
border-style: solid;
border-color: #c0cdda;
border-width: 1px;
border-radius: 5px;
padding: 4px 8px;
color: #1a2740;
selection-background-color: #0ea58a;
selection-color: #ffffff;
}
QLineEdit:focus,
QPlainTextEdit:focus,
QTextEdit:focus,
QSpinBox:focus,
QDoubleSpinBox:focus,
QDateEdit:focus,
QTimeEdit:focus {
border-color: #0ea58a;
}
QPlainTextEdit,
QTextEdit {
background-color: #ffffff;
}
QLineEdit:disabled,
QPlainTextEdit:disabled,
QTextEdit:disabled,
QSpinBox:disabled,
QDoubleSpinBox:disabled,
QDateEdit:disabled,
QTimeEdit:disabled {
background-color: #e8ecf2;
color: #98a8c0;
border-color: #d5dde8;
}
/* ---- Spin Button Arrows ---- */
QSpinBox::up-button,
QDoubleSpinBox::up-button,
QDateEdit::up-button,
QTimeEdit::up-button {
subcontrol-origin: border;
subcontrol-position: top right;
width: 10px;
border-left: 1px solid #c0cdda;
border-bottom: 1px solid #c0cdda;
border-top-right-radius: 4px;
}
QSpinBox::up-button:hover,
QDoubleSpinBox::up-button:hover,
QDateEdit::up-button:hover,
QTimeEdit::up-button:hover {
background-color: #d5dde8;
}
QSpinBox::up-arrow,
QDoubleSpinBox::up-arrow,
QDateEdit::up-arrow,
QTimeEdit::up-arrow {
/* image: none; */
border-left: 4px solid transparent;
border-right: 4px solid transparent;
border-bottom: 5px solid #6a7898;
margin-top: 2px;
}
QSpinBox::down-button,
QDoubleSpinBox::down-button,
QDateEdit::down-button,
QTimeEdit::down-button {
width: 10px;
subcontrol-origin: border;
subcontrol-position: bottom right;
border-left: 1px solid #c0cdda;
border-bottom-right-radius: 4px;
}
QSpinBox::down-button:hover,
QDoubleSpinBox::down-button:hover,
QDateEdit::down-button:hover,
QTimeEdit::down-button:hover {
background-color: #d5dde8;
}
QSpinBox::down-arrow,
QDoubleSpinBox::down-arrow,
QDateEdit::down-arrow,
QTimeEdit::down-arrow {
image: none;
border-left: 4px solid transparent;
border-right: 4px solid transparent;
border-top: 5px solid #6a7898;
margin-bottom: 2px;
}
QSpinBox::up-button:disabled,
QDoubleSpinBox::up-button:disabled,
QDateEdit::up-button:disabled,
QTimeEdit::up-button:disabled,
QSpinBox::down-button:disabled,
QDoubleSpinBox::down-button:disabled,
QDateEdit::down-button:disabled,
QTimeEdit::down-button:disabled {
background-color: #e8ecf2;
}
QSpinBox::up-arrow:disabled,
QDoubleSpinBox::up-arrow:disabled,
QDateEdit::up-arrow:disabled,
QTimeEdit::up-arrow:disabled {
border-bottom-color: #98a8c0;
}
QSpinBox::down-arrow:disabled,
QDoubleSpinBox::down-arrow:disabled,
QDateEdit::down-arrow:disabled,
QTimeEdit::down-arrow:disabled {
border-top-color: #98a8c0;
}
/* ---- Combo Box ---- */
QComboBox {
background-color: #d5dde8;
border-style: solid;
border-color: #c0cdda;
border-width: 1px;
border-radius: 5px;
padding: 4px 10px;
color: #1a2740;
}
QComboBox:hover {
border-color: #90a4c4;
}
QComboBox:focus {
border-color: #0ea58a;
}
QComboBox::drop-down {
subcontrol-origin: padding;
subcontrol-position: top right;
width: 24px;
border-left: 1px solid #c0cdda;
border-top-right-radius: 5px;
border-bottom-right-radius: 5px;
}
QComboBox::down-arrow {
image: none;
border-left: 4px solid transparent;
border-right: 4px solid transparent;
border-top: 6px solid #6a7898;
margin-right: 6px;
}
QComboBox QAbstractItemView {
background-color: #ffffff;
border-style: solid;
border-color: #d0d8e4;
border-width: 1px;
border-radius: 4px;
selection-background-color: #0ea58a;
selection-color: #ffffff;
outline: none;
}
QComboBox:disabled {
background-color: #e8ecf2;
color: #98a8c0;
border-color: #d5dde8;
}
/* ---- Check Box / Radio Button ---- */
QCheckBox,
QRadioButton {
spacing: 8px;
color: #1a2740;
}
QCheckBox::indicator,
QRadioButton::indicator {
border-style: solid;
border-color: #90a4c4;
border-width: 2px;
border-radius: 3px;
background-color: #ffffff;
}
QCheckBox::indicator:hover,
QRadioButton::indicator:hover {
border-color: #0ea58a;
}
QCheckBox::indicator:checked {
background-color: #0ea58a;
border-color: #0ea58a;
}
QRadioButton::indicator {
border-radius: 10px;
}
QRadioButton::indicator:checked {
background-color: #0ea58a;
border-color: #0ea58a;
}
QCheckBox::indicator:disabled,
QRadioButton::indicator:disabled {
border-color: #c0cdda;
background-color: #e8ecf2;
}
QCheckBox::indicator:checked:hover,
QRadioButton::indicator:checked:hover {
border-color: #14c7a4;
}
/* Tree / List / Table Widget CheckBox Indicator */
QTreeWidget::indicator,
QListWidget::indicator,
QTableWidget::indicator {
border: 2px solid #a0b4cc;
border-radius: 3px;
background-color: #e8ecf2;
}
QTreeWidget::indicator:hover,
QListWidget::indicator:hover,
QTableWidget::indicator:hover {
border-color: #14c7a4;
}
QTreeWidget::indicator:checked,
QListWidget::indicator:checked,
QTableWidget::indicator:checked {
background-color: #0ea58a;
border-color: #0ea58a;
}
QTreeWidget::indicator:checked:hover,
QListWidget::indicator:checked:hover,
QTableWidget::indicator:checked:hover {
border-color: #14c7a4;
}
QTreeWidget::indicator:disabled,
QListWidget::indicator:disabled,
QTableWidget::indicator:disabled {
background-color: #d5dde8;
border-color: #c0cdda;
}
QTreeWidget::indicator:indeterminate,
QListWidget::indicator:indeterminate,
QTableWidget::indicator:indeterminate {
background-color: #0ea58a;
border-color: #14c7a4;
}
/* ---- Group Box ---- */
QGroupBox {
border-style: solid;
border-color: #c0cdda;
border-width: 1px;
border-radius: 6px;
margin-top: 12px;
padding-top: 14px;
color: #1a2740;
font-weight: bold;
}
QGroupBox::title {
subcontrol-origin: margin;
left: 12px;
padding: 0 6px;
color: #4a6080;
}
/* ---- Tab ---- */
QTabWidget::pane {
border-style: solid;
border-color: #c0cdda;
border-width: 1px;
border-radius: 5px;
background-color: #f0f4f8;
top: -1px;
}
QTabBar::tab {
background-color: #e0e6ee;
border-style: solid;
border-color: #c0cdda;
border-width: 1px;
border-bottom: none;
border-top-left-radius: 5px;
border-top-right-radius: 5px;
padding: 6px 16px;
margin-right: 2px;
color: #6a7898;
}
QTabBar::tab:selected {
background-color: #f0f4f8;
color: #0ea58a;
border-bottom: 2px solid #0ea58a;
}
QTabBar::tab:hover:!selected {
background-color: #d5dde8;
color: #1a2740;
}
/* ---- List / Tree ---- */
QListWidget,
QTreeWidget,
QTableWidget {
background-color: #ffffff;
border-style: solid;
border-color: #c0cdda;
border-width: 1px;
border-radius: 5px;
outline: none;
color: #1a2740;
alternate-background-color: #f4f7fa;
}
QListWidget::item,
QTreeWidget::item,
QTableWidget::item {
padding: 5px 10px;
border: none;
}
QListWidget::item:selected,
QTreeWidget::item:selected,
QTableWidget::item:selected {
background-color: #0ea58a;
color: #ffffff;
}
QHeaderView::section {
background-color: #dce4ee;
border: none;
border-right: 1px solid #c0cdda;
border-bottom: 1px solid #c0cdda;
padding: 5px 10px;
color: #4a6080;
font-weight: bold;
}
/* ---- Scroll Bar ---- */
QScrollBar:vertical {
background-color: #eef2f6;
width: 10px;
border-radius: 5px;
}
QScrollBar::handle:vertical {
background-color: #a0b4cc;
min-height: 30px;
border-radius: 5px;
}
QScrollBar::handle:vertical:hover {
background-color: #8098b8;
}
QScrollBar::add-line:vertical,
QScrollBar::sub-line:vertical {
height: 0;
}
QScrollBar:horizontal {
background-color: #eef2f6;
height: 10px;
border-radius: 5px;
}
QScrollBar::handle:horizontal {
background-color: #a0b4cc;
min-width: 30px;
border-radius: 5px;
}
QScrollBar::handle:horizontal:hover {
background-color: #8098b8;
}
QScrollBar::add-line:horizontal,
QScrollBar::sub-line:horizontal {
width: 0;
}
/* ---- Progress Bar ---- */
QProgressBar {
background-color: #ffffff;
border-style: solid;
border-color: #c0cdda;
border-width: 1px;
border-radius: 5px;
height: 10px;
text-align: center;
color: #1a2740;
}
QProgressBar::chunk {
background-color: #0ea58a;
border-radius: 4px;
}
/* ---- Slider ---- */
QSlider::groove:horizontal {
background-color: #d5dde8;
height: 6px;
border-radius: 3px;
}
QSlider::handle:horizontal {
background-color: #0ea58a;
width: 16px;
height: 16px;
margin: -5px 0;
border-radius: 8px;
}
QSlider::sub-page:horizontal {
background-color: #0ea58a;
border-radius: 3px;
}
QSlider::handle:horizontal:disabled {
background-color: #98a8c0;
}
QSlider::sub-page:horizontal:disabled {
background-color: #98a8c0;
}
/* ---- Tool Tip ---- */
QToolTip {
background-color: #d5dde8;
border-style: solid;
border-color: #0ea58a;
border-width: 1px;
border-radius: 4px;
padding: 4px 8px;
color: #1a2740;
}
/* ---- Status Bar ---- */
QStatusBar {
background-color: #e8ecf2;
border-top: 1px solid #c0cdda;
color: #6a7898;
}
/* ---- Splitter ---- */
QSplitter::handle {
background-color: #c0cdda;
margin: 1px;
}
QSplitter::handle:horizontal {
width: 2px;
}
QSplitter::handle:vertical {
height: 2px;
}
/* ---- Dialog ---- */
QDialog {
background-color: #f0f4f8;
}
/* ---- Date / Time Editor Drop-down ---- */
QDateEdit::drop-down,
QTimeEdit::drop-down {
subcontrol-origin: padding;
subcontrol-position: top right;
width: 24px;
border-left: 1px solid #c0cdda;
}
QCalendarWidget {
background-color: #ffffff;
border-style: solid;
border-color: #c0cdda;
border-width: 1px;
border-radius: 6px;
}
QCalendarWidget QToolButton {
color: #1a2740;
border-radius: 4px;
padding: 4px 8px;
}
QCalendarWidget QToolButton:hover {
background-color: #d5dde8;
}
QCalendarWidget QMenu {
background-color: #ffffff;
}
/* ---- Frame ---- */
QFrame[frameShape="4"], /* HLine */
QFrame[frameShape="5"] /* VLine */ {
background-color: #c0cdda;
}
-12
View File
@@ -281,12 +281,6 @@ font: 700 9pt;</string>
<property name="nativeMenuBar">
<bool>true</bool>
</property>
<widget class="QMenu" name="ToolsMenu">
<property name="title">
<string>工具</string>
</property>
<addaction name="SettingsAction"/>
</widget>
<widget class="QMenu" name="HelpMenu">
<property name="mouseTracking">
<bool>true</bool>
@@ -297,7 +291,6 @@ font: 700 9pt;</string>
<addaction name="ManualAction"/>
<addaction name="AboutAction"/>
</widget>
<addaction name="ToolsMenu"/>
<addaction name="HelpMenu"/>
</widget>
<widget class="QStatusBar" name="StatusBar">
@@ -315,11 +308,6 @@ font: 700 9pt;</string>
<string>关于</string>
</property>
</action>
<action name="SettingsAction">
<property name="text">
<string>全局设置</string>
</property>
</action>
</widget>
<resources/>
<connections/>
-536
View File
@@ -1,536 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>ALSettingsWidget</class>
<widget class="QWidget" name="ALSettingsWidget">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>520</width>
<height>420</height>
</rect>
</property>
<property name="minimumSize">
<size>
<width>480</width>
<height>420</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>580</width>
<height>420</height>
</size>
</property>
<property name="windowTitle">
<string>全局设置 - AutoLibrary</string>
</property>
<layout class="QVBoxLayout" name="ALSettingsWidgetLayout">
<property name="spacing">
<number>0</number>
</property>
<property name="leftMargin">
<number>0</number>
</property>
<property name="topMargin">
<number>0</number>
</property>
<property name="rightMargin">
<number>0</number>
</property>
<property name="bottomMargin">
<number>0</number>
</property>
<item>
<layout class="QHBoxLayout" name="ContentLayout">
<property name="spacing">
<number>5</number>
</property>
<property name="leftMargin">
<number>3</number>
</property>
<property name="topMargin">
<number>3</number>
</property>
<property name="rightMargin">
<number>3</number>
</property>
<property name="bottomMargin">
<number>3</number>
</property>
<item>
<widget class="QListWidget" name="NavigationList">
<property name="minimumSize">
<size>
<width>100</width>
<height>0</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>100</width>
<height>16777215</height>
</size>
</property>
<property name="focusPolicy">
<enum>Qt::FocusPolicy::StrongFocus</enum>
</property>
<property name="frameShape">
<enum>QFrame::Shape::NoFrame</enum>
</property>
<property name="editTriggers">
<set>QAbstractItemView::EditTrigger::NoEditTriggers</set>
</property>
<property name="selectionMode">
<enum>QAbstractItemView::SelectionMode::SingleSelection</enum>
</property>
<property name="iconSize">
<size>
<width>20</width>
<height>20</height>
</size>
</property>
<property name="currentRow">
<number>0</number>
</property>
<item>
<property name="text">
<string>外观</string>
</property>
<property name="icon">
<iconset theme="preferences-desktop-color"/>
</property>
</item>
</widget>
</item>
<item>
<widget class="QScrollArea" name="AppearanceScrollArea">
<property name="frameShape">
<enum>QFrame::Shape::NoFrame</enum>
</property>
<property name="widgetResizable">
<bool>true</bool>
</property>
<widget class="QWidget" name="AppearancePageContent">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>450</width>
<height>380</height>
</rect>
</property>
<layout class="QVBoxLayout" name="AppearancePageLayout">
<property name="spacing">
<number>5</number>
</property>
<property name="leftMargin">
<number>3</number>
</property>
<property name="topMargin">
<number>3</number>
</property>
<property name="rightMargin">
<number>3</number>
</property>
<property name="bottomMargin">
<number>3</number>
</property>
<item>
<widget class="QGroupBox" name="AppearanceGroupBox">
<property name="title">
<string>主题模式</string>
</property>
<layout class="QVBoxLayout" name="AppearanceGroupBoxLayout">
<property name="spacing">
<number>5</number>
</property>
<property name="leftMargin">
<number>3</number>
</property>
<property name="topMargin">
<number>3</number>
</property>
<property name="rightMargin">
<number>3</number>
</property>
<property name="bottomMargin">
<number>3</number>
</property>
<item>
<widget class="QRadioButton" name="LightThemeRadio">
<property name="text">
<string>浅色</string>
</property>
</widget>
</item>
<item>
<widget class="QRadioButton" name="DarkThemeRadio">
<property name="text">
<string>深色</string>
</property>
</widget>
</item>
<item>
<widget class="QRadioButton" name="SystemThemeRadio">
<property name="text">
<string>跟随系统</string>
</property>
<property name="checked">
<bool>true</bool>
</property>
</widget>
</item>
</layout>
</widget>
</item>
<item>
<widget class="QGroupBox" name="InterfaceGroupBox">
<property name="title">
<string>界面风格</string>
</property>
<layout class="QVBoxLayout" name="InterfaceGroupBoxLayout">
<property name="spacing">
<number>5</number>
</property>
<property name="leftMargin">
<number>3</number>
</property>
<property name="topMargin">
<number>3</number>
</property>
<property name="rightMargin">
<number>3</number>
</property>
<property name="bottomMargin">
<number>3</number>
</property>
<item>
<layout class="QHBoxLayout" name="StyleSelectLayout">
<property name="spacing">
<number>5</number>
</property>
<item>
<widget class="QLabel" name="StyleSelectLabel">
<property name="minimumSize">
<size>
<width>100</width>
<height>25</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>100</width>
<height>25</height>
</size>
</property>
<property name="text">
<string>应用程序样式:</string>
</property>
</widget>
</item>
<item>
<widget class="QComboBox" name="StyleComboBox">
<property name="minimumSize">
<size>
<width>160</width>
<height>25</height>
</size>
</property>
</widget>
</item>
</layout>
</item>
<item>
<widget class="QLabel" name="StyleHintLabel">
<property name="text">
<string>更改样式将在下次启动应用程序时生效。</string>
</property>
</widget>
</item>
</layout>
</widget>
</item>
<item>
<widget class="QGroupBox" name="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">
<number>5</number>
</property>
<item>
<widget class="QComboBox" name="ThemeComboBox">
<property name="minimumSize">
<size>
<width>160</width>
<height>25</height>
</size>
</property>
</widget>
</item>
<item>
<widget class="QLineEdit" name="QssPathEdit">
<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="BrowseQssButton">
<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="ThemeInfoLabel">
<property name="minimumSize">
<size>
<width>0</width>
<height>60</height>
</size>
</property>
<property name="text">
<string/>
</property>
<property name="textFormat">
<enum>Qt::TextFormat::RichText</enum>
</property>
<property name="alignment">
<set>Qt::AlignmentFlag::AlignLeading|Qt::AlignmentFlag::AlignTop</set>
</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>
</item>
</layout>
</item>
<item>
<layout class="QHBoxLayout" name="ButtonLayout">
<property name="spacing">
<number>5</number>
</property>
<property name="leftMargin">
<number>3</number>
</property>
<property name="topMargin">
<number>3</number>
</property>
<property name="rightMargin">
<number>3</number>
</property>
<property name="bottomMargin">
<number>3</number>
</property>
<item>
<spacer name="ButtonLeftSpacer">
<property name="orientation">
<enum>Qt::Orientation::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item>
<widget class="QPushButton" name="CancelButton">
<property name="minimumSize">
<size>
<width>80</width>
<height>25</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>80</width>
<height>25</height>
</size>
</property>
<property name="text">
<string>取消</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="ApplyButton">
<property name="minimumSize">
<size>
<width>80</width>
<height>25</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>80</width>
<height>25</height>
</size>
</property>
<property name="text">
<string>应用</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="ConfirmButton">
<property name="minimumSize">
<size>
<width>80</width>
<height>25</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>80</width>
<height>25</height>
</size>
</property>
<property name="text">
<string>确认</string>
</property>
<property name="default">
<bool>true</bool>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</widget>
<resources/>
<connections/>
</ui>
+2 -2
View File
@@ -7,13 +7,13 @@
<x>0</x>
<y>0</y>
<width>350</width>
<height>500</height>
<height>400</height>
</rect>
</property>
<property name="minimumSize">
<size>
<width>350</width>
<height>500</height>
<height>460</height>
</size>
</property>
<property name="maximumSize">
-6
View File
@@ -66,12 +66,6 @@ class CfgKey:
CURRENT = ConfigPath(ConfigType.GLOBAL, "automation.user_path.current")
PATHS = ConfigPath(ConfigType.GLOBAL, "automation.user_path.paths")
class APPEARANCE:
ROOT = ConfigPath(ConfigType.GLOBAL, "appearance")
THEME = ConfigPath(ConfigType.GLOBAL, "appearance.theme")
STYLE = ConfigPath(ConfigType.GLOBAL, "appearance.style")
CUSTOM_THEME = ConfigPath(ConfigType.GLOBAL, "appearance.custom_theme")
class TIMERTASK:
ROOT = ConfigPath(ConfigType.TIMERTASK, "")
TIMER_TASKS = ConfigPath(ConfigType.TIMERTASK, "timer_tasks")
-5
View File
@@ -54,11 +54,6 @@ class ConfigTemplate:
"current": 0,
"paths": []
}
},
"appearance": {
"theme": "system",
"style": "Fusion",
"custom_theme": ""
}
}
case ConfigType.BULLETIN:
@@ -360,6 +360,10 @@ class WebDriverDownloader:
break
if not driver_file:
raise FileNotFoundError(f"未找到 web driver 文件 : {expected_name}")
# Ensure executable permissions on Unix systems (zipfile
# extraction does not preserve the execute bit).
if os.name != 'nt':
os.chmod(driver_file, 0o755)
progress_callback(100, 100, 0.0, "解压完成")
self.download_path.unlink()
self._cleanup(driver_file)
+4
View File
@@ -111,6 +111,10 @@ class WebDriverManager:
for driver_info in self.__driver_infos:
driver_path = self._getDriverPath(driver_info)
if driver_path and driver_path.exists() and driver_path.is_file():
# Repair missing execute permission on Unix
# (zip-extracted drivers from older versions).
if os.name != 'nt' and not os.access(str(driver_path), os.X_OK):
os.chmod(str(driver_path), 0o755)
driver_info.driver_path = driver_path
driver_info.driver_status = WebDriverStatus.INSTALLED
-321
View File
@@ -1,321 +0,0 @@
# -*- coding: utf-8 -*-
"""
Copyright (c) 2026 KenanZhu.
All rights reserved.
This software is provided "as is", without any warranty of any kind.
You may use, modify, and distribute this file under the terms of the MIT License.
See the LICENSE file for details.
"""
import os
import shutil
import tempfile
import threading
import zipfile
from PySide6.QtCore import Qt
from PySide6.QtWidgets import (
QApplication,
QStyleFactory
)
from interfaces.ConfigProvider import CfgKey
from managers.config.ConfigManager import instance as configInstance
from managers.log.LogManager import instance as logInstance
from utils.ThemeUtils import (
packTheme,
readThemeInfo,
unpackTheme,
wrapQssToAtheme
)
_active_style_name = "Fusion"
def setActiveStyle(
style_name: str
):
global _active_style_name
_active_style_name = style_name
def getActiveStyle(
) -> str:
return _active_style_name
class ThemeManager:
"""
Theme manager class.
Manages the themes storage directory, providing import,
list, remove, and apply operations for .altheme theme files.
Args:
themes_dir (str): Path to the themes storage directory.
"""
def __init__(
self,
themes_dir: str
):
self.__themes_dir = os.path.abspath(themes_dir)
self.__lock = threading.Lock()
self.__current_theme_name = ""
os.makedirs(self.__themes_dir, exist_ok=True)
@staticmethod
def _colorSchemeFor(
theme: str
) -> Qt.ColorScheme:
"""
Map a theme identifier to the corresponding Qt color scheme.
"""
if theme == "dark":
return Qt.ColorScheme.Dark
elif theme == "light":
return Qt.ColorScheme.Light
else:
return Qt.ColorScheme.Unknown
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(
self,
source_path: str
) -> str:
"""
Import a theme file into the themes directory.
Supports .altheme (zip archive) and bare .qss files.
Bare .qss files are automatically wrapped into .altheme format.
For .altheme files, validates that theme.qss exists in the archive
and sanitises the theme name to prevent path traversal.
Args:
source_path (str): Path to the .altheme or .qss file.
Returns:
str: The imported theme name.
Raises:
FileNotFoundError: If source_path does not exist.
ValueError: If the file type is unsupported or the .altheme is invalid.
"""
if not os.path.isfile(source_path):
raise FileNotFoundError(source_path)
ext = os.path.splitext(source_path)[1].lower()
with self.__lock:
if ext == ".qss":
name = os.path.splitext(os.path.basename(source_path))[0]
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")
return name
elif ext == ".altheme":
with zipfile.ZipFile(source_path, "r") as zf:
if "theme.qss" not in zf.namelist():
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)
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", "")
for existing in self.listThemes():
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)
return safe_name
else:
raise ValueError(f"不支持的文件类型: {ext}")
def listThemes(
self
) -> list:
"""
List all available themes in the themes directory.
Scans the themes directory for .altheme files and reads
their info.json metadata.
Returns:
list[dict]: A list of theme info dictionaries.
"""
themes = []
seen_keys = set()
if not os.path.isdir(self.__themes_dir):
return themes
for filename in sorted(os.listdir(self.__themes_dir)):
if filename.endswith(".altheme"):
filepath = os.path.join(self.__themes_dir, filename)
try:
info = readThemeInfo(filepath)
with zipfile.ZipFile(filepath, "r") as zf:
if "theme.qss" not in zf.namelist():
raise ValueError("缺少 theme.qss")
name = info.get("name", "")
author = info.get("author", "")
key = (name, author)
if key in seen_keys:
logInstance().getLogger("ThemeManager").warning(
f"主题名称 '{name}' (作者 '{author}') 重复 (文件 '{filename}') 已跳过"
)
continue
seen_keys.add(key)
themes.append(info)
except Exception as e:
logInstance().getLogger("ThemeManager").warning(
f"无法读取主题文件 '{filename}',已跳过: {e}"
)
else:
logInstance().getLogger("ThemeManager").warning(
f"未知文件类型 '{filename}',已跳过"
)
return themes
def removeTheme(
self,
name: str
):
"""
Remove a theme by name.
If the removed theme is currently active, clears the QSS
stylesheet from the application.
Args:
name (str): The theme name to remove.
"""
filepath = os.path.join(self.__themes_dir, name + ".altheme")
with self.__lock:
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 applyTheme(
self,
name: str
):
"""
Apply a theme by name.
Extracts the QSS from the .altheme file, applies it to
QApplication, and sets the Qt color scheme based on
the theme's need_theme metadata.
Args:
name (str): The theme name to apply.
Raises:
FileNotFoundError: If the theme .altheme file does not exist.
"""
filepath = os.path.join(self.__themes_dir, name + ".altheme")
if not os.path.isfile(filepath):
raise FileNotFoundError(filepath)
with self.__lock:
info = readThemeInfo(filepath)
with tempfile.TemporaryDirectory() as tmpdir:
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()
if app:
need_theme = info.get("need_theme", "both")
app.styleHints().setColorScheme(
ThemeManager._colorSchemeFor(need_theme)
)
app.setStyle(QStyleFactory.create(_active_style_name))
self.__current_theme_name = name
def clearTheme(
self,
theme: str
):
"""
Clear the current QSS stylesheet and apply the given color scheme.
Args:
theme (str): The color scheme to apply after clearing
("light", "dark", or "system").
"""
app = QApplication.instance()
if not app:
return
app.setStyleSheet("")
app.styleHints().setColorScheme(
ThemeManager._colorSchemeFor(theme)
)
app.setStyle(QStyleFactory.create(_active_style_name))
# ThemeManager singleton instance.
_theme_manager_instance = None
# Singleton instance lock.
_instance_lock = threading.Lock()
def instance(
themes_dir: str = ""
) -> ThemeManager:
"""
Get the ThemeManager singleton instance.
On first call, initialises the ThemeManager with the themes
directory derived from ConfigManager's config directory.
Args:
themes_dir (str): Optional themes directory path.
Returns:
ThemeManager: The singleton ThemeManager instance.
"""
global _theme_manager_instance
with _instance_lock:
if _theme_manager_instance is None:
if not themes_dir:
cfg = configInstance()
themes_dir = os.path.join(cfg.configDir(), "themes")
_theme_manager_instance = ThemeManager(themes_dir)
return _theme_manager_instance
-9
View File
@@ -1,9 +0,0 @@
# -*- 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.
"""
+15 -13
View File
@@ -10,6 +10,7 @@ See the LICENSE file for details.
import os
import queue
from selenium import webdriver
from selenium.webdriver.remote.webdriver import WebDriver
from selenium.common.exceptions import (
TimeoutException,
WebDriverException,
@@ -37,11 +38,11 @@ class AutoLib(MsgBase):
output_queue: queue.Queue,
run_config: dict,
) -> None:
super().__init__(input_queue, output_queue)
super().__init__(input_queue, output_queue)
self.__run_config: dict = run_config
self.__user_config: dict | None = None
self.__driver = None
self.__driver: WebDriver | None = None
self.__driver_type: str = ""
self.__driver_path: str = ""
self.__login_page: LoginPage = None
@@ -58,7 +59,7 @@ class AutoLib(MsgBase):
else:
if not self.__initDriverUrl():
self.close()
raise Exception("浏览器驱动URL初始化失败 !")
raise Exception("浏览器驱动 URL 初始化失败 !")
self.__initPagesServices()
self.__initPagesFlows()
@@ -67,9 +68,10 @@ class AutoLib(MsgBase):
) -> bool:
self._showTrace("正在初始化浏览器驱动......", no_log=True)
web_driver_config: dict = self.__run_config.get("web_driver", None)
self.__driver_type = web_driver_config.get("driver_type", "none")
match self.__driver_type.lower():
driver_config: dict = self.__run_config.get("web_driver", None)
self.__driver_type = driver_config.get("driver_type", "none")
self.__driver_type = self.__driver_type.lower()
match self.__driver_type:
case "edge":
driver_options = webdriver.EdgeOptions()
case "chrome":
@@ -82,10 +84,10 @@ class AutoLib(MsgBase):
self.TraceLevel.WARNING,
)
return False
if not web_driver_config:
if not driver_config:
self._showTrace("未配置浏览器驱动参数 !", self.TraceLevel.ERROR)
return False
if web_driver_config.get("headless", False):
if driver_config.get("headless", False):
driver_options.add_argument("--headless")
driver_options.add_argument("--disable-gpu")
driver_options.add_argument("--no-sandbox")
@@ -110,11 +112,11 @@ class AutoLib(MsgBase):
"AppleWebKit/537.36 (KHTML, like Gecko) "\
"Chrome/120.0.0.0 "\
"Safari/537.36"
if self.__driver_type.lower() == "edge":
if self.__driver_type == "edge":
user_agent += " Edg/120.0.0.0"
# 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("useAutomationExtension", False)
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}")
# 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:
self._showTrace("未配置浏览器驱动路径 !", self.TraceLevel.WARNING)
return False
try:
self.__driver_path = os.path.abspath(self.__driver_path)
service = None
match self.__driver_type.lower():
match self.__driver_type:
case "edge":
service = EdgeService(executable_path=self.__driver_path)
self.__driver = webdriver.Edge(service=service, options=driver_options)
@@ -161,7 +163,7 @@ class AutoLib(MsgBase):
self._showTrace("未配置图书馆参数 !", self.TraceLevel.ERROR)
return False
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)
try:
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.
See the LICENSE file for details.
"""
from typing import Callable, Optional
import queue
from typing import Callable
from selenium.common.exceptions import (
ElementNotInteractableException,
@@ -19,8 +20,10 @@ from selenium.webdriver.remote.webdriver import WebDriver
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from base.MsgBase import MsgBase
class LoginPage:
class LoginPage(MsgBase):
USERNAME_INPUT = (By.NAME, "username")
PASSWORD_INPUT = (By.NAME, "password")
@@ -36,22 +39,13 @@ class LoginPage:
def __init__(
self,
input_queue: queue.Queue,
output_queue: queue.Queue,
driver: WebDriver,
tracer: Optional[Callable[..., None]] = None,
) -> None:
super().__init__(input_queue, output_queue)
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(
self,
@@ -185,7 +179,7 @@ class LoginPage:
) -> bool:
for attempt in range(max_attempts):
self._trace(
self._showTrace(
f"用户 {username}{attempt + 1} 次尝试登录......",
no_log=True,
)
@@ -196,16 +190,16 @@ class LoginPage:
continue
if not self.fillCaptcha(captcha_text):
continue
self._trace("尝试登录...", no_log=True)
self._showTrace("尝试登录...", no_log=True)
if not self.clickLogin():
continue
if self.waitLoginSuccess():
self._trace(f"用户 {username}{attempt + 1} 次登录成功 !")
self._showTrace(f"用户 {username}{attempt + 1} 次登录成功 !")
return True
else:
self._trace(
self._showTrace(
"登录页面加载失败 ! : "
"用户账号或者密码错误/验证码错误, 具体以页面提示为准",
level=40,
level=self.TraceLevel.ERROR,
)
return False
+18 -2
View File
@@ -35,7 +35,7 @@ class CheckinFlow(MsgBase):
self._driver: WebDriver = driver
self._shell: MainShell = shell
def execute(
def _ensureCheckinButton(
self,
username: str,
) -> bool:
@@ -49,7 +49,13 @@ class CheckinFlow(MsgBase):
self._showTrace(f"签到按钮启用失败 !", self.TraceLevel.ERROR)
return False
self._showTrace("签到按钮已启用")
self._shell.clickCheckinButton()
return True
def _processCheckinDialog(
self,
username: str,
) -> bool:
try:
with CheckinResultDialog(self._driver) as dialog:
result_msg = dialog.getResultMessage()
@@ -87,3 +93,13 @@ class CheckinFlow(MsgBase):
except (TimeoutException, NoSuchElementException, ElementNotInteractableException):
self._showTrace("签到时发生未知错误 !", self.TraceLevel.ERROR)
return False
def execute(
self,
username: str,
) -> bool:
if not self._ensureCheckinButton(username):
return False
self._shell.clickCheckinButton()
return self._processCheckinDialog(username)
+40 -7
View File
@@ -61,19 +61,23 @@ class RenewFlow(MsgBase):
)
return True
def execute(
def _computeRenewTarget(
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)
end_time = record["time"]["end"]
target_renew_mins = timeStrToMins(end_time) + renew_info.get("expect_duration", 2) * 60
if not self._validateRenewTime(end_time, target_renew_mins):
return False
return None
return target_renew_mins
def _ensureExtendButton(
self,
username: str,
) -> bool:
if not self._shell.waitExtendButton():
self._showTrace(f"用户 {username} 续约界面加载失败 !", self.TraceLevel.ERROR)
return False
@@ -83,7 +87,17 @@ class RenewFlow(MsgBase):
f"请连接图书馆网络后重试"
)
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:
with RenewDialog(self._driver) as dialog:
if not dialog.waitUntilReady():
@@ -132,3 +146,22 @@ class RenewFlow(MsgBase):
self._showTrace(f"用户 {username} 续约失败 ! : {e}", self.TraceLevel.ERROR)
self._shell.refresh()
return False
def execute(
self,
username: str,
record: dict,
renew_info: dict,
) -> bool:
max_diff = renew_info.get("max_diff", 30)
prefer_earlier = renew_info.get("prefer_early", True)
target_renew_mins = self._computeRenewTarget(record, renew_info)
if target_renew_mins is None:
return False
if not self._ensureExtendButton(username):
return False
self._shell.clickExtendButton()
return self._processRenewDialog(
username, record, target_renew_mins, max_diff, prefer_earlier,
)
+103 -35
View File
@@ -58,40 +58,105 @@ class ReserveFlow(MsgBase):
self._driver: WebDriver = driver
self._shell: MainShell = shell
def execute(
def _loadReserveView(
self,
) -> ReserveView | None:
try:
return self._shell.gotoReserveView()
except (TimeoutException, ElementNotInteractableException) as e:
self._showTrace(f"加载预约选座页面失败 ! : {e}", self.TraceLevel.ERROR)
return None
def _selectDate(
self,
view: ReserveView,
ctx: ReserveContext,
) -> bool:
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):
self._showTrace(f"选择日期失败 ! : {ctx.date} 不可用", self.TraceLevel.ERROR)
return False
self._showTrace(f"日期 {ctx.date} 选择成功 !")
return True
def _selectPlace(
self,
view: ReserveView,
) -> bool:
if not view.selectPlace("1"):
self._showTrace("选择预约场所失败 ! : 图书馆 不可用", self.TraceLevel.ERROR)
return False
self._showTrace("预约场所 图书馆 选择成功 !")
return True
def _selectFloor(
self,
view: ReserveView,
ctx: ReserveContext,
) -> bool:
if not view.selectFloor(ctx.floor):
display_floor = ReserveView.FLOOR_MAP.get(ctx.floor, ctx.floor)
self._showTrace(f"选择楼层失败 ! : {display_floor} 不可用", self.TraceLevel.ERROR)
return False
self._showTrace(f"楼层 {ReserveView.FLOOR_MAP.get(ctx.floor)} 选择成功 !")
return True
def _selectRoom(
self,
view: ReserveView,
ctx: ReserveContext,
):
seat_map = view.selectRoom(ctx.room)
if seat_map is None:
display_room = ReserveView.ROOM_MAP.get(ctx.room, ctx.room)
self._showTrace(f"选择房间失败 ! : {display_room} 不可用", self.TraceLevel.ERROR)
return False
return None
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)
if seat_status is None:
self._showTrace(
@@ -111,31 +176,34 @@ class ReserveFlow(MsgBase):
try:
view.submitReserve()
submit_reserve = True
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"
" 未找获取到详细信息"
)
reserve_success = True
else:
self._showTrace("预约结果加载失败 !", self.TraceLevel.ERROR)
reserve_success = self._processReserveResult()
except (TimeoutException, ElementNotInteractableException):
self._showTrace("预约提交失败 !", self.TraceLevel.ERROR)
return submit_reserve, reserve_success
def execute(
self,
ctx: ReserveContext,
) -> bool:
# reserve flow pipeline:
# date > place > floor > room > seat (begin/end time) > submit > result
view = self._loadReserveView()
if view is None:
return False
if not self._selectDate(view, ctx):
return False
if not self._selectPlace(view):
return False
if not self._selectFloor(view, ctx):
return False
seat_map = self._selectRoom(view, ctx)
if seat_map is None:
return False
have_hover_on_page = True
submit_reserve, reserve_success = self._selectSeatAndSubmit(
view, seat_map, ctx,
)
if not submit_reserve and have_hover_on_page:
view.refresh()
if reserve_success:
-126
View File
@@ -1,126 +0,0 @@
# -*- coding: utf-8 -*-
"""
Copyright (c) 2026 KenanZhu.
All rights reserved.
This software is provided "as is", without any warranty of any kind.
You may use, modify, and distribute this file under the terms of the MIT License.
See the LICENSE file for details.
"""
import json
import os
import zipfile
def packTheme(
qss_path: str,
info: dict,
output_path: str
):
"""
Pack a .qss file and info dict into a .altheme file.
The .altheme file is a zip archive containing info.json and theme.qss.
Args:
qss_path (str): Path to the .qss stylesheet file.
info (dict): Theme metadata dict with keys name, author, need_theme, brief.
output_path (str): Destination path for the .altheme file.
Raises:
FileNotFoundError: If qss_path does not exist.
"""
if not os.path.isfile(qss_path):
raise FileNotFoundError(qss_path)
with zipfile.ZipFile(output_path, "w", zipfile.ZIP_DEFLATED) as zf:
zf.writestr("info.json", json.dumps(info, ensure_ascii=False, indent=4))
zf.write(qss_path, "theme.qss")
def unpackTheme(
altheme_path: str,
output_dir: str
):
"""
Extract a .altheme file to a directory.
Performs Zip Slip validation before extraction.
Args:
altheme_path (str): Path to the .altheme file.
output_dir (str): Directory to extract contents into.
Raises:
FileNotFoundError: If altheme_path does not exist.
ValueError: If a zip entry contains an unsafe path.
"""
if not os.path.isfile(altheme_path):
raise FileNotFoundError(altheme_path)
os.makedirs(output_dir, exist_ok=True)
with zipfile.ZipFile(altheme_path, "r") as zf:
for name in zf.namelist():
if name.startswith("/") or ".." in name:
raise ValueError(f"不安全的 .altheme 入口: {name}")
zf.extractall(output_dir)
def readThemeInfo(
altheme_path: str
) -> dict:
"""
Read only the info.json metadata from a .altheme file.
Args:
altheme_path (str): Path to the .altheme file.
Returns:
dict: The theme metadata dictionary.
Raises:
FileNotFoundError: If altheme_path does not exist.
ValueError: If the .altheme does not contain info.json.
"""
if not os.path.isfile(altheme_path):
raise FileNotFoundError(altheme_path)
with zipfile.ZipFile(altheme_path, "r") as zf:
if "info.json" not in zf.namelist():
raise ValueError("无效的 .altheme: 缺少 info.json")
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 wrapQssToAtheme(
qss_path: str,
output_path: str,
current_theme: str
):
"""
Wrap a bare .qss file into a .altheme file with auto-generated metadata.
The generated info.json uses the filename as the theme name
and sets default values for author and brief.
Args:
qss_path (str): Path to the bare .qss stylesheet file.
output_path (str): Destination path for the .altheme file.
current_theme (str): The need_theme value to embed in metadata
("light", "dark", or "both").
Raises:
FileNotFoundError: If qss_path does not exist.
"""
filename = os.path.splitext(os.path.basename(qss_path))[0]
info = {
"name": filename,
"author": "未知",
"need_theme": current_theme,
"brief": "没有相关简介"
}
packTheme(qss_path, info, output_path)