feat: 知识管理平台精简版 - PyQt6桌面应用
主要功能: - 账号管理:添加/编辑/删除账号,测试登录 - 浏览任务:批量浏览应读/选读内容并标记已读 - 截图管理:wkhtmltoimage截图,查看历史 - 金山文档:扫码登录/微信快捷登录,自动上传截图 技术栈: - PyQt6 GUI框架 - Playwright 浏览器自动化 - SQLite 本地数据存储 - wkhtmltoimage 网页截图 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
52
.gitignore
vendored
Normal file
52
.gitignore
vendored
Normal file
@@ -0,0 +1,52 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
|
||||
# Virtual environments
|
||||
venv/
|
||||
ENV/
|
||||
env/
|
||||
.venv/
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# Data files (user specific)
|
||||
data/
|
||||
*.db
|
||||
*.db.bak
|
||||
*.cookies.txt
|
||||
*_state.json
|
||||
encryption_key.bin
|
||||
|
||||
# Screenshots
|
||||
screenshots/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
123
README.md
Normal file
123
README.md
Normal file
@@ -0,0 +1,123 @@
|
||||
# 知识管理平台助手 - 精简版
|
||||
|
||||
一个基于 PyQt6 的本地桌面应用,用于自动化处理知识管理平台的浏览、截图和上传任务。
|
||||
|
||||
## 功能特性
|
||||
|
||||
- ✅ **账号管理**:添加/编辑/删除账号,支持密码加密存储
|
||||
- ✅ **自动浏览**:使用纯HTTP请求快速浏览内容并标记已读
|
||||
- ✅ **网页截图**:使用wkhtmltoimage截取浏览结果
|
||||
- ✅ **金山文档**:自动上传截图到金山文档表格
|
||||
- ✅ **现代UI**:支持深色/浅色主题切换
|
||||
|
||||
## 安装
|
||||
|
||||
### 1. 安装 Python 依赖
|
||||
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
### 2. 安装 Playwright 浏览器
|
||||
|
||||
```bash
|
||||
playwright install chromium
|
||||
```
|
||||
|
||||
### 3. 安装 wkhtmltoimage
|
||||
|
||||
从 https://wkhtmltopdf.org/downloads.html 下载安装。
|
||||
|
||||
安装后确保 `wkhtmltoimage` 在系统 PATH 中,或在设置中手动指定路径。
|
||||
|
||||
## 使用方法
|
||||
|
||||
### 启动应用
|
||||
|
||||
```bash
|
||||
python main.py
|
||||
```
|
||||
|
||||
### 基本流程
|
||||
|
||||
1. **添加账号**:在"账号管理"页面添加知识管理平台账号
|
||||
2. **开始浏览**:在"浏览任务"页面选择账号和浏览类型,点击开始
|
||||
3. **查看截图**:浏览完成后自动截图,可在"截图管理"页面查看
|
||||
4. **上传金山文档**(可选):
|
||||
- 在"金山文档"页面配置表格链接和列设置
|
||||
- 扫码登录金山文档
|
||||
- 启用自动上传或手动上传
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
zsglpt-lite/
|
||||
├── main.py # 应用入口
|
||||
├── requirements.txt # 依赖清单
|
||||
├── config.py # 配置管理
|
||||
│
|
||||
├── core/ # 核心业务逻辑
|
||||
│ ├── api_browser.py # API浏览器(HTTP请求实现)
|
||||
│ ├── screenshot.py # wkhtmltoimage截图
|
||||
│ └── kdocs_uploader.py # 金山文档上传
|
||||
│
|
||||
├── ui/ # PyQt界面
|
||||
│ ├── main_window.py # 主窗口
|
||||
│ ├── account_widget.py # 账号管理面板
|
||||
│ ├── browse_widget.py # 浏览任务面板
|
||||
│ ├── screenshot_widget.py # 截图管理面板
|
||||
│ ├── kdocs_widget.py # 金山文档面板
|
||||
│ ├── settings_widget.py # 设置面板
|
||||
│ ├── log_widget.py # 日志显示面板
|
||||
│ └── styles.py # QSS样式
|
||||
│
|
||||
├── utils/ # 工具类
|
||||
│ ├── storage.py # JSON配置存储
|
||||
│ ├── crypto.py # 密码加密
|
||||
│ └── worker.py # 后台线程
|
||||
│
|
||||
└── data/ # 运行数据
|
||||
├── config.json # 用户配置
|
||||
├── cookies/ # Cookie缓存
|
||||
└── screenshots/ # 截图保存
|
||||
```
|
||||
|
||||
## 配置说明
|
||||
|
||||
配置文件位于 `data/config.json`,包含:
|
||||
|
||||
- `accounts`:账号列表(密码加密存储)
|
||||
- `kdocs`:金山文档配置
|
||||
- `screenshot`:截图参数配置
|
||||
- `proxy`:代理设置
|
||||
- `zsgl`:知识管理平台URL配置
|
||||
- `theme`:界面主题
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **首次使用**需要安装 wkhtmltoimage
|
||||
2. **金山文档功能**需要安装 Playwright 和 Chromium
|
||||
3. **密码**使用 Fernet 对称加密存储,密钥保存在 `data/encryption_key.bin`
|
||||
4. **代理**支持 HTTP 代理,在设置中配置
|
||||
|
||||
## 依赖说明
|
||||
|
||||
| 依赖 | 版本 | 说明 |
|
||||
|------|------|------|
|
||||
| PyQt6 | >=6.5.0 | UI框架 |
|
||||
| requests | >=2.31.0 | HTTP请求 |
|
||||
| beautifulsoup4 | >=4.12.0 | HTML解析 |
|
||||
| playwright | >=1.42.0 | 金山文档自动化 |
|
||||
| cryptography | >=41.0.0 | 密码加密 |
|
||||
| Pillow | >=10.0.0 | 图像处理 |
|
||||
|
||||
## 开发信息
|
||||
|
||||
- 从原项目 `zsglpt` 精简而来
|
||||
- 移除了 Flask Web服务、SocketIO、邮件通知、定时任务等
|
||||
- 代码量约 2000+ 行(原项目 8000+ 行)
|
||||
- 精简比例约 70%
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
228
config.py
Normal file
228
config.py
Normal file
@@ -0,0 +1,228 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
配置管理模块 - 精简版
|
||||
使用JSON本地存储,不搞数据库那些花里胡哨的东西
|
||||
"""
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
from typing import List, Optional
|
||||
from pathlib import Path
|
||||
|
||||
# 项目根目录
|
||||
BASE_DIR = Path(__file__).parent.absolute()
|
||||
DATA_DIR = BASE_DIR / "data"
|
||||
COOKIES_DIR = DATA_DIR / "cookies"
|
||||
SCREENSHOTS_DIR = DATA_DIR / "screenshots"
|
||||
|
||||
# 确保目录存在
|
||||
DATA_DIR.mkdir(exist_ok=True)
|
||||
COOKIES_DIR.mkdir(exist_ok=True)
|
||||
SCREENSHOTS_DIR.mkdir(exist_ok=True)
|
||||
|
||||
# 配置文件路径
|
||||
CONFIG_FILE = DATA_DIR / "config.json"
|
||||
ENCRYPTION_KEY_FILE = DATA_DIR / "encryption_key.bin"
|
||||
ENCRYPTION_SALT_FILE = DATA_DIR / "encryption_salt.bin"
|
||||
KDOCS_LOGIN_STATE_FILE = DATA_DIR / "kdocs_login_state.json"
|
||||
|
||||
|
||||
@dataclass
|
||||
class AccountConfig:
|
||||
"""账号配置"""
|
||||
username: str
|
||||
password: str # 加密存储
|
||||
remark: str = ""
|
||||
enabled: bool = True
|
||||
|
||||
|
||||
@dataclass
|
||||
class KDocsConfig:
|
||||
"""金山文档配置"""
|
||||
enabled: bool = False
|
||||
doc_url: str = "https://kdocs.cn/l/cpwEOo5ynKX4"
|
||||
sheet_name: str = "Sheet1"
|
||||
sheet_index: int = 0
|
||||
unit_column: str = "A"
|
||||
image_column: str = "D"
|
||||
unit: str = "" # 县区名
|
||||
name_column: str = "C" # 姓名列
|
||||
row_start: int = 0 # 有效行起始(0表示不限制)
|
||||
row_end: int = 0 # 有效行结束(0表示不限制)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ScreenshotConfig:
|
||||
"""截图配置"""
|
||||
dir: str = str(SCREENSHOTS_DIR)
|
||||
quality: int = 95
|
||||
width: int = 1920
|
||||
height: int = 1080
|
||||
js_delay_ms: int = 3000
|
||||
timeout_seconds: int = 60
|
||||
wkhtmltoimage_path: str = "" # 自定义路径,空则自动查找
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProxyConfig:
|
||||
"""代理配置"""
|
||||
enabled: bool = False
|
||||
server: str = "" # http://host:port
|
||||
|
||||
|
||||
@dataclass
|
||||
class ZSGLConfig:
|
||||
"""知识管理平台配置"""
|
||||
base_url: str = "https://postoa.aidunsoft.com"
|
||||
login_url: str = "https://postoa.aidunsoft.com/admin/login.aspx"
|
||||
index_url_pattern: str = "index.aspx"
|
||||
|
||||
|
||||
@dataclass
|
||||
class AppConfig:
|
||||
"""应用总配置"""
|
||||
accounts: List[AccountConfig] = field(default_factory=list)
|
||||
kdocs: KDocsConfig = field(default_factory=KDocsConfig)
|
||||
screenshot: ScreenshotConfig = field(default_factory=ScreenshotConfig)
|
||||
proxy: ProxyConfig = field(default_factory=ProxyConfig)
|
||||
zsgl: ZSGLConfig = field(default_factory=ZSGLConfig)
|
||||
theme: str = "light" # light/dark
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""转换为字典"""
|
||||
return {
|
||||
"accounts": [
|
||||
{
|
||||
"username": a.username,
|
||||
"password": a.password,
|
||||
"remark": a.remark,
|
||||
"enabled": a.enabled
|
||||
} for a in self.accounts
|
||||
],
|
||||
"kdocs": {
|
||||
"enabled": self.kdocs.enabled,
|
||||
"doc_url": self.kdocs.doc_url,
|
||||
"sheet_name": self.kdocs.sheet_name,
|
||||
"sheet_index": self.kdocs.sheet_index,
|
||||
"unit_column": self.kdocs.unit_column,
|
||||
"image_column": self.kdocs.image_column,
|
||||
"unit": self.kdocs.unit,
|
||||
"name_column": self.kdocs.name_column,
|
||||
"row_start": self.kdocs.row_start,
|
||||
"row_end": self.kdocs.row_end,
|
||||
},
|
||||
"screenshot": {
|
||||
"dir": self.screenshot.dir,
|
||||
"quality": self.screenshot.quality,
|
||||
"width": self.screenshot.width,
|
||||
"height": self.screenshot.height,
|
||||
"js_delay_ms": self.screenshot.js_delay_ms,
|
||||
"timeout_seconds": self.screenshot.timeout_seconds,
|
||||
"wkhtmltoimage_path": self.screenshot.wkhtmltoimage_path,
|
||||
},
|
||||
"proxy": {
|
||||
"enabled": self.proxy.enabled,
|
||||
"server": self.proxy.server,
|
||||
},
|
||||
"zsgl": {
|
||||
"base_url": self.zsgl.base_url,
|
||||
"login_url": self.zsgl.login_url,
|
||||
"index_url_pattern": self.zsgl.index_url_pattern,
|
||||
},
|
||||
"theme": self.theme,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> "AppConfig":
|
||||
"""从字典创建配置"""
|
||||
config = cls()
|
||||
|
||||
# 加载账号
|
||||
accounts_data = data.get("accounts", [])
|
||||
config.accounts = [
|
||||
AccountConfig(
|
||||
username=a.get("username", ""),
|
||||
password=a.get("password", ""),
|
||||
remark=a.get("remark", ""),
|
||||
enabled=a.get("enabled", True)
|
||||
) for a in accounts_data
|
||||
]
|
||||
|
||||
# 加载金山文档配置
|
||||
kdocs_data = data.get("kdocs", {})
|
||||
config.kdocs = KDocsConfig(
|
||||
enabled=kdocs_data.get("enabled", False),
|
||||
doc_url=kdocs_data.get("doc_url", "https://kdocs.cn/l/cpwEOo5ynKX4"),
|
||||
sheet_name=kdocs_data.get("sheet_name", "Sheet1"),
|
||||
sheet_index=kdocs_data.get("sheet_index", 0),
|
||||
unit_column=kdocs_data.get("unit_column", "A"),
|
||||
image_column=kdocs_data.get("image_column", "D"),
|
||||
unit=kdocs_data.get("unit", ""),
|
||||
name_column=kdocs_data.get("name_column", "C"),
|
||||
row_start=kdocs_data.get("row_start", 0),
|
||||
row_end=kdocs_data.get("row_end", 0),
|
||||
)
|
||||
|
||||
# 加载截图配置
|
||||
screenshot_data = data.get("screenshot", {})
|
||||
config.screenshot = ScreenshotConfig(
|
||||
dir=screenshot_data.get("dir", str(SCREENSHOTS_DIR)),
|
||||
quality=screenshot_data.get("quality", 95),
|
||||
width=screenshot_data.get("width", 1920),
|
||||
height=screenshot_data.get("height", 1080),
|
||||
js_delay_ms=screenshot_data.get("js_delay_ms", 3000),
|
||||
timeout_seconds=screenshot_data.get("timeout_seconds", 60),
|
||||
wkhtmltoimage_path=screenshot_data.get("wkhtmltoimage_path", ""),
|
||||
)
|
||||
|
||||
# 加载代理配置
|
||||
proxy_data = data.get("proxy", {})
|
||||
config.proxy = ProxyConfig(
|
||||
enabled=proxy_data.get("enabled", False),
|
||||
server=proxy_data.get("server", ""),
|
||||
)
|
||||
|
||||
# 加载知识管理平台配置
|
||||
zsgl_data = data.get("zsgl", {})
|
||||
config.zsgl = ZSGLConfig(
|
||||
base_url=zsgl_data.get("base_url", "https://postoa.aidunsoft.com"),
|
||||
login_url=zsgl_data.get("login_url", "https://postoa.aidunsoft.com/admin/login.aspx"),
|
||||
index_url_pattern=zsgl_data.get("index_url_pattern", "index.aspx"),
|
||||
)
|
||||
|
||||
# 主题
|
||||
config.theme = data.get("theme", "light")
|
||||
|
||||
return config
|
||||
|
||||
|
||||
# 全局配置实例
|
||||
_config: Optional[AppConfig] = None
|
||||
|
||||
|
||||
def get_config() -> AppConfig:
|
||||
"""获取配置实例(懒加载)"""
|
||||
global _config
|
||||
if _config is None:
|
||||
from utils.storage import load_config
|
||||
_config = load_config()
|
||||
return _config
|
||||
|
||||
|
||||
def save_config(config: AppConfig = None):
|
||||
"""保存配置"""
|
||||
global _config
|
||||
if config:
|
||||
_config = config
|
||||
if _config:
|
||||
from utils.storage import save_config as _save
|
||||
_save(_config)
|
||||
|
||||
|
||||
def reload_config():
|
||||
"""重新加载配置"""
|
||||
global _config
|
||||
from utils.storage import load_config
|
||||
_config = load_config()
|
||||
return _config
|
||||
13
core/__init__.py
Normal file
13
core/__init__.py
Normal file
@@ -0,0 +1,13 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""核心业务逻辑模块"""
|
||||
|
||||
from .api_browser import APIBrowser, APIBrowseResult, get_cookie_jar_path
|
||||
from .screenshot import take_screenshot, ScreenshotResult
|
||||
from .kdocs_uploader import KDocsUploader
|
||||
|
||||
__all__ = [
|
||||
'APIBrowser', 'APIBrowseResult', 'get_cookie_jar_path',
|
||||
'take_screenshot', 'ScreenshotResult',
|
||||
'KDocsUploader'
|
||||
]
|
||||
504
core/api_browser.py
Normal file
504
core/api_browser.py
Normal file
@@ -0,0 +1,504 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
API浏览器 - 精简版
|
||||
用纯HTTP请求实现浏览功能,比浏览器自动化快30-60倍
|
||||
从原项目精简提取,移除了缓存、诊断日志等复杂功能
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
import hashlib
|
||||
from typing import Optional, Callable, List, Dict, Any
|
||||
from dataclasses import dataclass
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
import requests
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
|
||||
@dataclass
|
||||
class APIBrowseResult:
|
||||
"""API浏览结果"""
|
||||
success: bool
|
||||
total_items: int = 0
|
||||
total_attachments: int = 0
|
||||
error_message: str = ""
|
||||
|
||||
|
||||
def get_cookie_jar_path(username: str) -> str:
|
||||
"""获取截图用的cookies文件路径(Netscape Cookie格式)"""
|
||||
from config import COOKIES_DIR
|
||||
|
||||
COOKIES_DIR.mkdir(exist_ok=True)
|
||||
filename = hashlib.sha256(username.encode()).hexdigest()[:32] + ".cookies.txt"
|
||||
return str(COOKIES_DIR / filename)
|
||||
|
||||
|
||||
def is_cookie_jar_fresh(cookie_path: str, max_age_seconds: int = 86400) -> bool:
|
||||
"""判断cookies文件是否存在且未过期(默认24小时)"""
|
||||
if not cookie_path or not os.path.exists(cookie_path):
|
||||
return False
|
||||
try:
|
||||
file_age = time.time() - os.path.getmtime(cookie_path)
|
||||
return file_age <= max(0, int(max_age_seconds or 0))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
class APIBrowser:
|
||||
"""
|
||||
API浏览器 - 使用纯HTTP请求实现浏览
|
||||
|
||||
用法:
|
||||
with APIBrowser(log_callback=print) as browser:
|
||||
if browser.login(username, password):
|
||||
result = browser.browse_content("应读")
|
||||
"""
|
||||
|
||||
def __init__(self, log_callback: Optional[Callable] = None, proxy_config: Optional[dict] = None):
|
||||
self.session = requests.Session()
|
||||
self.session.headers.update({
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
||||
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8",
|
||||
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
|
||||
})
|
||||
self.logged_in = False
|
||||
self.log_callback = log_callback
|
||||
self.stop_flag = False
|
||||
self._closed = False
|
||||
self.last_total_records = 0
|
||||
self._username = ""
|
||||
|
||||
# 获取配置
|
||||
from config import get_config
|
||||
config = get_config()
|
||||
self.base_url = config.zsgl.base_url
|
||||
self.login_url = config.zsgl.login_url
|
||||
self.index_url_pattern = config.zsgl.index_url_pattern
|
||||
|
||||
# 设置代理
|
||||
if proxy_config and proxy_config.get("server"):
|
||||
proxy_server = proxy_config["server"]
|
||||
self.session.proxies = {"http": proxy_server, "https": proxy_server}
|
||||
self.proxy_server = proxy_server
|
||||
else:
|
||||
self.proxy_server = None
|
||||
|
||||
def log(self, message: str):
|
||||
"""记录日志"""
|
||||
if self.log_callback:
|
||||
self.log_callback(message)
|
||||
|
||||
def _request_with_retry(self, method: str, url: str, max_retries: int = 3,
|
||||
retry_delay: float = 1, **kwargs) -> requests.Response:
|
||||
"""带重试机制的请求方法"""
|
||||
kwargs.setdefault("timeout", 10.0)
|
||||
last_error = None
|
||||
|
||||
for attempt in range(1, max_retries + 1):
|
||||
try:
|
||||
if method.lower() == "get":
|
||||
resp = self.session.get(url, **kwargs)
|
||||
else:
|
||||
resp = self.session.post(url, **kwargs)
|
||||
return resp
|
||||
except Exception as e:
|
||||
last_error = e
|
||||
if attempt < max_retries:
|
||||
self.log(f"[API] 请求超时,{retry_delay}秒后重试 ({attempt}/{max_retries})...")
|
||||
time.sleep(retry_delay)
|
||||
else:
|
||||
self.log(f"[API] 请求失败,已重试{max_retries}次: {str(e)}")
|
||||
|
||||
raise last_error
|
||||
|
||||
def _get_aspnet_fields(self, soup: BeautifulSoup) -> Dict[str, str]:
|
||||
"""获取ASP.NET隐藏字段"""
|
||||
fields = {}
|
||||
for name in ["__VIEWSTATE", "__VIEWSTATEGENERATOR", "__EVENTVALIDATION"]:
|
||||
field = soup.find("input", {"name": name})
|
||||
if field:
|
||||
fields[name] = field.get("value", "")
|
||||
return fields
|
||||
|
||||
def login(self, username: str, password: str) -> bool:
|
||||
"""登录"""
|
||||
self.log(f"[API] 登录: {username}")
|
||||
self._username = username
|
||||
|
||||
try:
|
||||
resp = self._request_with_retry("get", self.login_url)
|
||||
soup = BeautifulSoup(resp.text, "html.parser")
|
||||
fields = self._get_aspnet_fields(soup)
|
||||
|
||||
data = fields.copy()
|
||||
data["txtUserName"] = username
|
||||
data["txtPassword"] = password
|
||||
data["btnSubmit"] = "登 录"
|
||||
|
||||
resp = self._request_with_retry(
|
||||
"post",
|
||||
self.login_url,
|
||||
data=data,
|
||||
headers={
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"Origin": self.base_url,
|
||||
"Referer": self.login_url,
|
||||
},
|
||||
allow_redirects=True,
|
||||
)
|
||||
|
||||
if self.index_url_pattern in resp.url:
|
||||
self.logged_in = True
|
||||
self.log(f"[API] 登录成功")
|
||||
return True
|
||||
else:
|
||||
soup = BeautifulSoup(resp.text, "html.parser")
|
||||
error = soup.find(id="lblMsg")
|
||||
error_msg = error.get_text().strip() if error else "未知错误"
|
||||
self.log(f"[API] 登录失败: {error_msg}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
self.log(f"[API] 登录异常: {str(e)}")
|
||||
return False
|
||||
|
||||
def get_real_name(self) -> Optional[str]:
|
||||
"""获取用户真实姓名"""
|
||||
if not self.logged_in:
|
||||
return None
|
||||
|
||||
try:
|
||||
url = f"{self.base_url}/admin/center.aspx"
|
||||
resp = self._request_with_retry("get", url)
|
||||
soup = BeautifulSoup(resp.text, "html.parser")
|
||||
|
||||
nlist = soup.find("div", {"class": "nlist-5"})
|
||||
if nlist:
|
||||
first_li = nlist.find("li")
|
||||
if first_li:
|
||||
text = first_li.get_text()
|
||||
match = re.search(r"姓名[::]\s*([^\((]+)", text)
|
||||
if match:
|
||||
return match.group(1).strip()
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def save_cookies_for_screenshot(self, username: str) -> bool:
|
||||
"""保存cookies供wkhtmltoimage使用(Netscape Cookie格式)"""
|
||||
cookies_path = get_cookie_jar_path(username)
|
||||
try:
|
||||
parsed = urlsplit(self.base_url)
|
||||
cookie_domain = parsed.hostname or "postoa.aidunsoft.com"
|
||||
|
||||
lines = [
|
||||
"# Netscape HTTP Cookie File",
|
||||
"# Generated by zsglpt-lite",
|
||||
]
|
||||
for cookie in self.session.cookies:
|
||||
domain = cookie.domain or cookie_domain
|
||||
include_subdomains = "TRUE" if domain.startswith(".") else "FALSE"
|
||||
path = cookie.path or "/"
|
||||
secure = "TRUE" if getattr(cookie, "secure", False) else "FALSE"
|
||||
expires = int(getattr(cookie, "expires", 0) or 0)
|
||||
lines.append("\t".join([
|
||||
domain,
|
||||
include_subdomains,
|
||||
path,
|
||||
secure,
|
||||
str(expires),
|
||||
cookie.name,
|
||||
cookie.value,
|
||||
]))
|
||||
|
||||
with open(cookies_path, "w", encoding="utf-8") as f:
|
||||
f.write("\n".join(lines) + "\n")
|
||||
|
||||
self.log(f"[API] Cookies已保存供截图使用")
|
||||
return True
|
||||
except Exception as e:
|
||||
self.log(f"[API] 保存cookies失败: {e}")
|
||||
return False
|
||||
|
||||
def get_article_list_page(self, bz: int = 0, page: int = 1) -> tuple:
|
||||
"""获取单页文章列表"""
|
||||
if not self.logged_in:
|
||||
return [], 0, None
|
||||
|
||||
if page > 1:
|
||||
url = f"{self.base_url}/admin/center.aspx?bz={bz}&page={page}"
|
||||
else:
|
||||
url = f"{self.base_url}/admin/center.aspx?bz={bz}"
|
||||
|
||||
resp = self._request_with_retry("get", url)
|
||||
soup = BeautifulSoup(resp.text, "html.parser")
|
||||
articles = []
|
||||
|
||||
ltable = soup.find("table", {"class": "ltable"})
|
||||
if ltable:
|
||||
rows = ltable.find_all("tr")[1:]
|
||||
for row in rows:
|
||||
if "暂无记录" in row.get_text():
|
||||
continue
|
||||
|
||||
link = row.find("a", href=True)
|
||||
if link:
|
||||
href = link.get("href", "")
|
||||
title = link.get_text().strip()
|
||||
match = re.search(r"id=(\d+)", href)
|
||||
article_id = match.group(1) if match else None
|
||||
articles.append({
|
||||
"title": title,
|
||||
"href": href,
|
||||
"article_id": article_id,
|
||||
})
|
||||
|
||||
# 获取总页数
|
||||
total_pages = 1
|
||||
total_records = 0
|
||||
|
||||
page_content = soup.find(id="PageContent")
|
||||
if page_content:
|
||||
text = page_content.get_text()
|
||||
total_match = re.search(r"共(\d+)记录", text)
|
||||
if total_match:
|
||||
total_records = int(total_match.group(1))
|
||||
total_pages = (total_records + 9) // 10
|
||||
|
||||
self.last_total_records = total_records
|
||||
return articles, total_pages, None
|
||||
|
||||
def get_article_attachments(self, article_href: str) -> tuple:
|
||||
"""获取文章的附件列表和文章信息"""
|
||||
if not article_href.startswith("http"):
|
||||
url = f"{self.base_url}/admin/{article_href}"
|
||||
else:
|
||||
url = article_href
|
||||
|
||||
resp = self._request_with_retry("get", url)
|
||||
soup = BeautifulSoup(resp.text, "html.parser")
|
||||
|
||||
attachments = []
|
||||
article_info = {"channel_id": None, "article_id": None}
|
||||
|
||||
# 从saveread按钮获取channel_id和article_id
|
||||
for elem in soup.find_all(["button", "input"]):
|
||||
onclick = elem.get("onclick", "")
|
||||
match = re.search(r"saveread\((\d+),(\d+)\)", onclick)
|
||||
if match:
|
||||
article_info["channel_id"] = match.group(1)
|
||||
article_info["article_id"] = match.group(2)
|
||||
break
|
||||
|
||||
attach_list = soup.find("div", {"class": "attach-list2"})
|
||||
if attach_list:
|
||||
items = attach_list.find_all("li")
|
||||
for item in items:
|
||||
download_links = item.find_all("a", onclick=re.compile(r"download2?\.ashx"))
|
||||
for link in download_links:
|
||||
onclick = link.get("onclick", "")
|
||||
id_match = re.search(r"id=(\d+)", onclick)
|
||||
channel_match = re.search(r"channel_id=(\d+)", onclick)
|
||||
if id_match:
|
||||
attach_id = id_match.group(1)
|
||||
channel_id = channel_match.group(1) if channel_match else "1"
|
||||
h3 = item.find("h3")
|
||||
filename = h3.get_text().strip() if h3 else f"附件{attach_id}"
|
||||
attachments.append({
|
||||
"id": attach_id,
|
||||
"channel_id": channel_id,
|
||||
"filename": filename
|
||||
})
|
||||
break
|
||||
|
||||
return attachments, article_info
|
||||
|
||||
def mark_article_read(self, channel_id: str, article_id: str) -> bool:
|
||||
"""通过saveread API标记文章已读"""
|
||||
if not channel_id or not article_id:
|
||||
return False
|
||||
|
||||
import random
|
||||
saveread_url = (
|
||||
f"{self.base_url}/tools/submit_ajax.ashx?action=saveread"
|
||||
f"&time={random.random()}&fl={channel_id}&id={article_id}"
|
||||
)
|
||||
|
||||
try:
|
||||
resp = self._request_with_retry("post", saveread_url)
|
||||
if resp.status_code == 200:
|
||||
try:
|
||||
data = resp.json()
|
||||
return data.get("status") == 1
|
||||
except:
|
||||
return True
|
||||
return False
|
||||
except:
|
||||
return False
|
||||
|
||||
def mark_attachment_read(self, attach_id: str, channel_id: str = "1") -> bool:
|
||||
"""通过访问预览通道标记附件已读"""
|
||||
download_url = f"{self.base_url}/tools/download2.ashx?site=main&id={attach_id}&channel_id={channel_id}"
|
||||
|
||||
try:
|
||||
resp = self._request_with_retry("get", download_url, stream=True)
|
||||
resp.close()
|
||||
return resp.status_code == 200
|
||||
except:
|
||||
return False
|
||||
|
||||
def browse_content(
|
||||
self,
|
||||
browse_type: str,
|
||||
should_stop_callback: Optional[Callable] = None,
|
||||
progress_callback: Optional[Callable] = None,
|
||||
) -> APIBrowseResult:
|
||||
"""
|
||||
浏览内容并标记已读
|
||||
|
||||
Args:
|
||||
browse_type: 浏览类型 (应读/注册前未读)
|
||||
should_stop_callback: 检查是否应该停止的回调函数
|
||||
progress_callback: 进度回调,用于实时上报已浏览内容数量
|
||||
回调参数: {"total_items": int, "browsed_items": int}
|
||||
|
||||
Returns:
|
||||
浏览结果
|
||||
"""
|
||||
result = APIBrowseResult(success=False)
|
||||
|
||||
if not self.logged_in:
|
||||
result.error_message = "未登录"
|
||||
return result
|
||||
|
||||
# 根据浏览类型确定bz参数(网站更新后 bz=0 为应读)
|
||||
bz = 0
|
||||
|
||||
self.log(f"[API] 开始浏览 '{browse_type}' (bz={bz})...")
|
||||
|
||||
try:
|
||||
total_items = 0
|
||||
total_attachments = 0
|
||||
|
||||
# 获取第一页
|
||||
articles, total_pages, _ = self.get_article_list_page(bz, 1)
|
||||
|
||||
if not articles:
|
||||
self.log(f"[API] '{browse_type}' 没有待处理内容")
|
||||
result.success = True
|
||||
return result
|
||||
|
||||
total_records = self.last_total_records
|
||||
self.log(f"[API] 共 {total_records} 条记录,开始处理...")
|
||||
|
||||
# 上报初始进度
|
||||
if progress_callback:
|
||||
progress_callback({"total_items": total_records, "browsed_items": 0})
|
||||
|
||||
processed_hrefs = set()
|
||||
current_page = 1
|
||||
max_iterations = total_records + 20
|
||||
|
||||
for iteration in range(max_iterations):
|
||||
if should_stop_callback and should_stop_callback():
|
||||
self.log("[API] 收到停止信号")
|
||||
break
|
||||
|
||||
if not articles:
|
||||
break
|
||||
|
||||
new_articles_in_page = 0
|
||||
|
||||
for article in articles:
|
||||
if should_stop_callback and should_stop_callback():
|
||||
break
|
||||
|
||||
article_href = article["href"]
|
||||
if article_href in processed_hrefs:
|
||||
continue
|
||||
|
||||
processed_hrefs.add(article_href)
|
||||
new_articles_in_page += 1
|
||||
title = article["title"][:30]
|
||||
|
||||
# 获取附件和文章信息
|
||||
try:
|
||||
attachments, article_info = self.get_article_attachments(article_href)
|
||||
except Exception as e:
|
||||
self.log(f"[API] 获取文章失败: {title} | {str(e)}")
|
||||
continue
|
||||
|
||||
total_items += 1
|
||||
|
||||
# 标记文章已读
|
||||
article_marked = False
|
||||
if article_info.get("channel_id") and article_info.get("article_id"):
|
||||
article_marked = self.mark_article_read(
|
||||
article_info["channel_id"],
|
||||
article_info["article_id"]
|
||||
)
|
||||
|
||||
# 处理附件
|
||||
if attachments:
|
||||
for attach in attachments:
|
||||
if self.mark_attachment_read(attach["id"], attach["channel_id"]):
|
||||
total_attachments += 1
|
||||
self.log(f"[API] [{total_items}] {title} - {len(attachments)}个附件")
|
||||
else:
|
||||
status = "已标记" if article_marked else "标记失败"
|
||||
self.log(f"[API] [{total_items}] {title} - 无附件({status})")
|
||||
|
||||
# 上报进度
|
||||
if progress_callback:
|
||||
progress_callback({"total_items": total_records, "browsed_items": total_items})
|
||||
|
||||
# 简单延迟,避免请求太快
|
||||
time.sleep(0.05)
|
||||
|
||||
# 决定下一步
|
||||
if new_articles_in_page > 0:
|
||||
current_page = 1
|
||||
else:
|
||||
current_page += 1
|
||||
if current_page > total_pages:
|
||||
break
|
||||
|
||||
# 获取下一页
|
||||
try:
|
||||
articles, new_total_pages, _ = self.get_article_list_page(bz, current_page)
|
||||
if new_total_pages > 0:
|
||||
total_pages = new_total_pages
|
||||
except Exception as e:
|
||||
self.log(f"[API] 获取第{current_page}页列表失败: {str(e)}")
|
||||
break
|
||||
|
||||
self.log(f"[API] 浏览完成: {total_items} 条内容,{total_attachments} 个附件")
|
||||
result.success = True
|
||||
result.total_items = total_items
|
||||
result.total_attachments = total_attachments
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
result.error_message = str(e)
|
||||
self.log(f"[API] 浏览出错: {str(e)}")
|
||||
return result
|
||||
|
||||
def close(self):
|
||||
"""关闭会话"""
|
||||
if self._closed:
|
||||
return
|
||||
self._closed = True
|
||||
try:
|
||||
self.session.close()
|
||||
except:
|
||||
pass
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
self.close()
|
||||
return False
|
||||
823
core/kdocs_uploader.py
Normal file
823
core/kdocs_uploader.py
Normal file
@@ -0,0 +1,823 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
金山文档上传模块 - 精简版
|
||||
使用Playwright自动化上传截图到金山文档表格
|
||||
移除了队列、并发控制,改为单任务顺序执行
|
||||
"""
|
||||
|
||||
import base64
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from io import BytesIO
|
||||
from typing import Any, Dict, Optional, Callable
|
||||
from urllib.parse import urlparse
|
||||
|
||||
try:
|
||||
from playwright.sync_api import sync_playwright, TimeoutError as PlaywrightTimeoutError
|
||||
except ImportError:
|
||||
sync_playwright = None
|
||||
|
||||
class PlaywrightTimeoutError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class KDocsUploader:
|
||||
"""金山文档上传器"""
|
||||
|
||||
def __init__(self, log_callback: Optional[Callable] = None):
|
||||
self._playwright = None
|
||||
self._browser = None
|
||||
self._context = None
|
||||
self._page = None
|
||||
self._doc_url: Optional[str] = None
|
||||
self._last_error: Optional[str] = None
|
||||
self._logged_in = False
|
||||
self._log_callback = log_callback
|
||||
|
||||
def log(self, msg: str):
|
||||
"""记录日志"""
|
||||
if self._log_callback:
|
||||
self._log_callback(msg)
|
||||
|
||||
def _ensure_playwright(self, use_storage_state: bool = True) -> bool:
|
||||
"""确保Playwright已启动"""
|
||||
if sync_playwright is None:
|
||||
self._last_error = "playwright 未安装"
|
||||
return False
|
||||
|
||||
try:
|
||||
from config import KDOCS_LOGIN_STATE_FILE
|
||||
|
||||
if self._playwright is None:
|
||||
self._playwright = sync_playwright().start()
|
||||
if self._browser is None:
|
||||
# 调试模式:有头模式,方便查看浏览器行为
|
||||
# 生产环境改回 "true"
|
||||
headless = os.environ.get("KDOCS_HEADLESS", "false").lower() != "false"
|
||||
# 使用系统安装的Chrome浏览器(支持微信快捷登录)
|
||||
# channel='chrome' 会使用系统Chrome,而不是Playwright自带的Chromium
|
||||
chrome_args = [
|
||||
"--disable-blink-features=AutomationControlled", # 隐藏自动化特征
|
||||
"--disable-features=DialMediaRouteProvider", # 禁用本地网络发现提示
|
||||
"--allow-running-insecure-content",
|
||||
]
|
||||
try:
|
||||
self._browser = self._playwright.chromium.launch(
|
||||
headless=headless,
|
||||
channel='chrome', # 使用系统Chrome
|
||||
args=chrome_args
|
||||
)
|
||||
self.log("[KDocs] 使用系统Chrome浏览器")
|
||||
except Exception as e:
|
||||
# 如果系统没有Chrome,回退到Chromium
|
||||
self.log(f"[KDocs] 系统Chrome不可用({e}),使用Chromium")
|
||||
self._browser = self._playwright.chromium.launch(headless=headless, args=chrome_args)
|
||||
if self._context is None:
|
||||
storage_state = str(KDOCS_LOGIN_STATE_FILE)
|
||||
# 创建context时的通用配置
|
||||
context_options = {
|
||||
"permissions": ["clipboard-read", "clipboard-write"], # 剪贴板权限
|
||||
"ignore_https_errors": True,
|
||||
}
|
||||
if use_storage_state and os.path.exists(storage_state):
|
||||
context_options["storage_state"] = storage_state
|
||||
self._context = self._browser.new_context(**context_options)
|
||||
|
||||
# 授予本地网络访问权限(用于微信快捷登录检测)
|
||||
try:
|
||||
self._context.grant_permissions(
|
||||
["clipboard-read", "clipboard-write"],
|
||||
origin="https://account.wps.cn"
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
if self._page is None or self._page.is_closed():
|
||||
self._page = self._context.new_page()
|
||||
self._page.set_default_timeout(60000)
|
||||
return True
|
||||
except Exception as e:
|
||||
self._last_error = f"浏览器启动失败: {e}"
|
||||
self._cleanup_browser()
|
||||
return False
|
||||
|
||||
def _cleanup_browser(self):
|
||||
"""清理浏览器资源"""
|
||||
for attr in ['_page', '_context', '_browser', '_playwright']:
|
||||
obj = getattr(self, attr, None)
|
||||
if obj:
|
||||
try:
|
||||
if hasattr(obj, 'close'):
|
||||
obj.close()
|
||||
elif hasattr(obj, 'stop'):
|
||||
obj.stop()
|
||||
except Exception:
|
||||
pass
|
||||
setattr(self, attr, None)
|
||||
|
||||
def _open_document(self, doc_url: str) -> bool:
|
||||
"""打开金山文档"""
|
||||
try:
|
||||
self._doc_url = doc_url
|
||||
self._ensure_clipboard_permissions(doc_url)
|
||||
self._page.goto(doc_url, wait_until="domcontentloaded", timeout=30000)
|
||||
time.sleep(3) # 等待页面完全加载,包括登录按钮
|
||||
return True
|
||||
except Exception as e:
|
||||
self._last_error = f"打开文档失败: {e}"
|
||||
return False
|
||||
|
||||
def _ensure_clipboard_permissions(self, doc_url: str):
|
||||
"""授予剪贴板权限"""
|
||||
if not self._context or not doc_url:
|
||||
return
|
||||
try:
|
||||
parsed = urlparse(doc_url)
|
||||
if not parsed.scheme or not parsed.netloc:
|
||||
return
|
||||
origin = f"{parsed.scheme}://{parsed.netloc}"
|
||||
self._context.grant_permissions(["clipboard-read", "clipboard-write"], origin=origin)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _is_login_url(self, url: str) -> bool:
|
||||
"""检查是否是登录页面"""
|
||||
if not url:
|
||||
return False
|
||||
lower = url.lower()
|
||||
if "account.wps.cn" in lower or "passport" in lower:
|
||||
return True
|
||||
if "login" in lower and "kdocs.cn" not in lower:
|
||||
return True
|
||||
return False
|
||||
|
||||
def _page_has_login_gate(self, page) -> bool:
|
||||
"""检查页面是否需要登录"""
|
||||
url = getattr(page, "url", "") or ""
|
||||
|
||||
# 如果URL已经是文档页面,说明已登录成功
|
||||
if "kdocs.cn/l/" in url or "www.kdocs.cn/l/" in url:
|
||||
# 但可能有邀请对话框,先尝试点击关闭
|
||||
try:
|
||||
join_btn = page.get_by_role("button", name="登录并加入编辑")
|
||||
if join_btn.count() > 0 and join_btn.first.is_visible(timeout=500):
|
||||
self.log("[KDocs] 点击加入编辑按钮")
|
||||
join_btn.first.click()
|
||||
time.sleep(1)
|
||||
except Exception:
|
||||
pass
|
||||
# 已经在文档页面,算作已登录
|
||||
return False
|
||||
|
||||
# 检查是否在登录页面
|
||||
if self._is_login_url(url):
|
||||
self.log(f"[KDocs] 检测到登录页面URL: {url}")
|
||||
return True
|
||||
|
||||
# 只检查登录页面上的登录按钮(排除文档页面的邀请对话框)
|
||||
login_buttons = ["立即登录", "去登录"]
|
||||
for text in login_buttons:
|
||||
try:
|
||||
btn = page.get_by_role("button", name=text)
|
||||
if btn.count() > 0 and btn.first.is_visible(timeout=500):
|
||||
self.log(f"[KDocs] 检测到登录按钮: {text}")
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 检查是否有二维码元素可见(说明还在等待扫码)
|
||||
try:
|
||||
qr_selectors = ["canvas", "img[class*='qr']", "div[class*='qrcode']"]
|
||||
for selector in qr_selectors:
|
||||
qr = page.locator(selector)
|
||||
if qr.count() > 0:
|
||||
for i in range(min(qr.count(), 3)):
|
||||
el = qr.nth(i)
|
||||
try:
|
||||
if el.is_visible(timeout=200):
|
||||
box = el.bounding_box()
|
||||
if box and 80 <= box.get("width", 0) <= 400:
|
||||
self.log(f"[KDocs] 检测到二维码元素: {selector}")
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return False
|
||||
|
||||
def _is_logged_in(self) -> bool:
|
||||
"""检查是否已登录"""
|
||||
if not self._page or self._page.is_closed():
|
||||
return False
|
||||
return not self._page_has_login_gate(self._page)
|
||||
|
||||
def _save_login_state(self):
|
||||
"""保存登录状态"""
|
||||
try:
|
||||
from config import KDOCS_LOGIN_STATE_FILE
|
||||
storage_state = str(KDOCS_LOGIN_STATE_FILE)
|
||||
KDOCS_LOGIN_STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._context.storage_state(path=storage_state)
|
||||
self.log("[KDocs] 登录状态已保存")
|
||||
except Exception as e:
|
||||
self.log(f"[KDocs] 保存登录状态失败: {e}")
|
||||
|
||||
def _ensure_login_dialog(self, use_quick_login: bool = False):
|
||||
"""确保打开登录对话框
|
||||
|
||||
Args:
|
||||
use_quick_login: 是否尝试使用微信快捷登录
|
||||
"""
|
||||
agree_names = ["同意", "同意并继续", "我同意", "确定", "确认"]
|
||||
|
||||
# 循环处理登录流程
|
||||
max_clicks = 8
|
||||
for round_num in range(max_clicks):
|
||||
clicked = False
|
||||
current_url = self._page.url
|
||||
|
||||
# 检查是否已经到达文档页面(登录成功)
|
||||
# 需要确保不是临时跳转,等待页面稳定
|
||||
if "kdocs.cn/l/" in current_url or "www.kdocs.cn/l/" in current_url:
|
||||
time.sleep(1) # 等待页面稳定
|
||||
stable_url = self._page.url
|
||||
if "kdocs.cn/l/" in stable_url and "account.wps.cn" not in stable_url:
|
||||
self.log("[KDocs] 已到达文档页面,登录成功")
|
||||
return
|
||||
|
||||
# 1. 先检查是否有隐私协议同意按钮
|
||||
for name in agree_names:
|
||||
try:
|
||||
btn = self._page.get_by_role("button", name=name)
|
||||
if btn.count() > 0 and btn.first.is_visible(timeout=300):
|
||||
self.log(f"[KDocs] 点击同意按钮: {name}")
|
||||
btn.first.click()
|
||||
time.sleep(1)
|
||||
clicked = True
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
if clicked:
|
||||
continue
|
||||
|
||||
# 2. 如果启用快捷登录且在登录页面(account.wps.cn),尝试点击"微信快捷登录"
|
||||
if use_quick_login and "account.wps.cn" in current_url:
|
||||
try:
|
||||
quick_login = self._page.get_by_text("微信快捷登录", exact=False)
|
||||
if quick_login.count() > 0 and quick_login.first.is_visible(timeout=500):
|
||||
self.log("[KDocs] 点击微信快捷登录")
|
||||
quick_login.first.click()
|
||||
time.sleep(3) # 等待快捷登录处理
|
||||
# 检查是否登录成功
|
||||
if "kdocs.cn/l/" in self._page.url:
|
||||
self.log("[KDocs] 微信快捷登录成功")
|
||||
return
|
||||
clicked = True
|
||||
continue
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 3. 点击"立即登录"进入登录页面
|
||||
try:
|
||||
btn = self._page.get_by_role("button", name="立即登录")
|
||||
if btn.count() > 0 and btn.first.is_visible(timeout=500):
|
||||
self.log("[KDocs] 点击立即登录")
|
||||
btn.first.click()
|
||||
time.sleep(2)
|
||||
clicked = True
|
||||
continue
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 4. 点击"登录并加入编辑"(文档页面的邀请对话框)
|
||||
try:
|
||||
btn = self._page.get_by_role("button", name="登录并加入编辑")
|
||||
if btn.count() > 0 and btn.first.is_visible(timeout=500):
|
||||
self.log("[KDocs] 点击登录并加入编辑")
|
||||
btn.first.click()
|
||||
time.sleep(1.5)
|
||||
clicked = True
|
||||
continue
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 如果没有点击到任何按钮,退出循环
|
||||
if not clicked:
|
||||
self.log("[KDocs] 未找到更多可点击的按钮")
|
||||
break
|
||||
|
||||
# 最后确保点击微信扫码登录(切换到扫码模式)
|
||||
wechat_names = ["微信登录", "微信扫码登录", "扫码登录", "微信扫码"]
|
||||
for name in wechat_names:
|
||||
try:
|
||||
btn = self._page.get_by_role("button", name=name)
|
||||
if btn.is_visible(timeout=1000):
|
||||
self.log(f"[KDocs] 点击微信登录: {name}")
|
||||
btn.click()
|
||||
time.sleep(1)
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 尝试用文本查找微信登录
|
||||
for name in wechat_names:
|
||||
try:
|
||||
el = self._page.get_by_text(name, exact=False).first
|
||||
if el.is_visible(timeout=500):
|
||||
self.log(f"[KDocs] 点击微信登录文本: {name}")
|
||||
el.click()
|
||||
time.sleep(1)
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
self.log("[KDocs] 未找到登录按钮,可能页面已在登录状态或需要手动操作")
|
||||
|
||||
def _capture_qr_image(self) -> Optional[bytes]:
|
||||
"""捕获登录二维码图片"""
|
||||
# 查找二维码元素的选择器
|
||||
selectors = [
|
||||
"canvas",
|
||||
"img[src*='qr']",
|
||||
"img[class*='qr']",
|
||||
"img[class*='code']",
|
||||
"div[class*='qr'] img",
|
||||
"div[class*='qrcode'] img",
|
||||
"div[class*='scan'] img",
|
||||
".qrcode img",
|
||||
".qr-code img",
|
||||
"img", # 最后尝试所有图片
|
||||
]
|
||||
|
||||
# 先在主页面查找
|
||||
for selector in selectors:
|
||||
result = self._try_capture_qr_with_selector(self._page, selector)
|
||||
if result:
|
||||
return result
|
||||
|
||||
# 尝试在iframe中查找
|
||||
try:
|
||||
frames = self._page.frames
|
||||
for frame in frames:
|
||||
if frame == self._page.main_frame:
|
||||
continue
|
||||
for selector in selectors[:5]: # 只用前几个选择器
|
||||
result = self._try_capture_qr_with_selector(frame, selector)
|
||||
if result:
|
||||
return result
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
def _try_capture_qr_with_selector(self, page_or_frame, selector: str) -> Optional[bytes]:
|
||||
"""尝试用指定选择器捕获二维码"""
|
||||
try:
|
||||
locator = page_or_frame.locator(selector)
|
||||
count = locator.count()
|
||||
for i in range(min(count, 10)):
|
||||
el = locator.nth(i)
|
||||
try:
|
||||
if not el.is_visible(timeout=300):
|
||||
continue
|
||||
box = el.bounding_box()
|
||||
if not box:
|
||||
continue
|
||||
w, h = box.get("width", 0), box.get("height", 0)
|
||||
# 二维码通常是正方形,大小在100-400之间
|
||||
if 80 <= w <= 400 and 80 <= h <= 400 and abs(w - h) < 60:
|
||||
screenshot = el.screenshot()
|
||||
if screenshot and len(screenshot) > 500:
|
||||
return screenshot
|
||||
except Exception:
|
||||
continue
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
def request_qr(self, force: bool = False) -> Dict[str, Any]:
|
||||
"""
|
||||
请求登录二维码
|
||||
|
||||
Args:
|
||||
force: 是否强制重新登录
|
||||
|
||||
Returns:
|
||||
{
|
||||
"success": bool,
|
||||
"logged_in": bool, # 是否已登录
|
||||
"qr_image": str, # base64编码的二维码图片
|
||||
"error": str # 错误信息
|
||||
}
|
||||
"""
|
||||
from config import get_config, KDOCS_LOGIN_STATE_FILE
|
||||
|
||||
config = get_config()
|
||||
doc_url = config.kdocs.doc_url.strip()
|
||||
|
||||
if not doc_url:
|
||||
return {"success": False, "error": "未配置金山文档链接"}
|
||||
|
||||
if force:
|
||||
# 清除登录状态
|
||||
try:
|
||||
if KDOCS_LOGIN_STATE_FILE.exists():
|
||||
KDOCS_LOGIN_STATE_FILE.unlink()
|
||||
except Exception:
|
||||
pass
|
||||
self._cleanup_browser()
|
||||
|
||||
if not self._ensure_playwright(use_storage_state=not force):
|
||||
return {"success": False, "error": self._last_error or "浏览器不可用"}
|
||||
|
||||
if not self._open_document(doc_url):
|
||||
return {"success": False, "error": self._last_error or "打开文档失败"}
|
||||
|
||||
# 检查是否已登录
|
||||
self.log(f"[KDocs] 当前页面URL: {self._page.url}")
|
||||
if not force and self._is_logged_in():
|
||||
self._logged_in = True
|
||||
self._save_login_state()
|
||||
return {"success": True, "logged_in": True, "qr_image": ""}
|
||||
|
||||
# 需要登录,获取二维码
|
||||
self.log("[KDocs] 需要登录,尝试打开登录对话框...")
|
||||
self._ensure_login_dialog()
|
||||
time.sleep(2) # 等待登录对话框加载
|
||||
|
||||
self.log("[KDocs] 尝试捕获二维码...")
|
||||
qr_image = None
|
||||
for i in range(15): # 增加尝试次数
|
||||
qr_image = self._capture_qr_image()
|
||||
if qr_image and len(qr_image) > 1024:
|
||||
self.log(f"[KDocs] 二维码捕获成功,大小: {len(qr_image)} bytes")
|
||||
break
|
||||
self.log(f"[KDocs] 第{i+1}次尝试捕获二维码...")
|
||||
time.sleep(1)
|
||||
|
||||
if not qr_image:
|
||||
# 尝试截取整个页面帮助调试
|
||||
self.log("[KDocs] 二维码捕获失败,当前页面可能没有显示二维码")
|
||||
return {"success": False, "error": "二维码获取失败,请检查网络或手动打开金山文档链接确认"}
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"logged_in": False,
|
||||
"qr_image": base64.b64encode(qr_image).decode("ascii"),
|
||||
}
|
||||
|
||||
def check_login_status(self) -> Dict[str, Any]:
|
||||
"""检查登录状态(不重新打开页面,只检查当前状态)"""
|
||||
# 如果页面不存在或已关闭,说明还没开始登录流程
|
||||
if not self._page or self._page.is_closed():
|
||||
return {"success": False, "logged_in": False, "error": "页面未打开"}
|
||||
|
||||
try:
|
||||
clicked_confirm = False
|
||||
|
||||
# 在主页面和所有iframe中查找确认按钮
|
||||
frames_to_check = [self._page] + list(self._page.frames)
|
||||
|
||||
for frame in frames_to_check:
|
||||
if clicked_confirm:
|
||||
break
|
||||
|
||||
# 尝试点击确认登录按钮(微信扫码后PC端需要再点一下确认)
|
||||
confirm_names = ["确认登录", "确定登录", "登录", "确定", "确认", "同意并登录"]
|
||||
for name in confirm_names:
|
||||
try:
|
||||
confirm_btn = frame.get_by_role("button", name=name)
|
||||
if confirm_btn.count() > 0 and confirm_btn.first.is_visible(timeout=200):
|
||||
self.log(f"[KDocs] 找到确认按钮: {name}")
|
||||
confirm_btn.first.click()
|
||||
clicked_confirm = True
|
||||
time.sleep(3)
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 如果按钮角色没找到,尝试用文本查找
|
||||
if not clicked_confirm:
|
||||
for name in confirm_names:
|
||||
try:
|
||||
el = frame.get_by_text(name, exact=True)
|
||||
if el.count() > 0 and el.first.is_visible(timeout=200):
|
||||
self.log(f"[KDocs] 找到确认文本: {name}")
|
||||
el.first.click()
|
||||
clicked_confirm = True
|
||||
time.sleep(3)
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 尝试用CSS选择器查找
|
||||
if not clicked_confirm:
|
||||
try:
|
||||
# WPS登录页面的确认按钮可能的选择器
|
||||
selectors = [
|
||||
"button.ant-btn-primary",
|
||||
"button[type='primary']",
|
||||
".confirm-btn",
|
||||
".login-confirm",
|
||||
".btn-primary",
|
||||
".wps-btn-primary",
|
||||
"a.confirm",
|
||||
"div.confirm",
|
||||
"[class*='confirm']",
|
||||
"[class*='login-btn']"
|
||||
]
|
||||
for selector in selectors:
|
||||
btns = frame.locator(selector)
|
||||
if btns.count() > 0:
|
||||
for i in range(min(btns.count(), 3)):
|
||||
btn = btns.nth(i)
|
||||
try:
|
||||
if btn.is_visible(timeout=100):
|
||||
btn_text = btn.inner_text() or ""
|
||||
if any(kw in btn_text for kw in ["确认", "登录", "确定"]):
|
||||
self.log(f"[KDocs] 找到按钮(CSS): {btn_text}")
|
||||
btn.click()
|
||||
clicked_confirm = True
|
||||
time.sleep(3)
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
if clicked_confirm:
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 如果点击了确认按钮,等待页面自动跳转(不要reload!)
|
||||
if clicked_confirm:
|
||||
self.log("[KDocs] 已点击确认,等待页面跳转...")
|
||||
time.sleep(3) # 等待页面自动跳转
|
||||
|
||||
# 检查当前URL是否已经到达文档页面
|
||||
current_url = self._page.url
|
||||
self.log(f"[KDocs] 当前URL: {current_url}")
|
||||
|
||||
# 直接检查URL判断是否已登录
|
||||
if "kdocs.cn/l/" in current_url and "account.wps.cn" not in current_url:
|
||||
# 已到达文档页面,登录成功
|
||||
logged_in = True
|
||||
# 尝试点击可能存在的"加入编辑"按钮
|
||||
try:
|
||||
join_btn = self._page.get_by_role("button", name="登录并加入编辑")
|
||||
if join_btn.count() > 0 and join_btn.first.is_visible(timeout=500):
|
||||
self.log("[KDocs] 点击加入编辑")
|
||||
join_btn.first.click()
|
||||
time.sleep(1)
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
# 还在登录页面或其他页面
|
||||
logged_in = self._is_logged_in()
|
||||
|
||||
self._logged_in = logged_in
|
||||
|
||||
if logged_in:
|
||||
self._save_login_state()
|
||||
self.log("[KDocs] 登录状态检测:已登录")
|
||||
|
||||
return {"success": True, "logged_in": logged_in}
|
||||
|
||||
except Exception as e:
|
||||
return {"success": False, "logged_in": False, "error": str(e)}
|
||||
|
||||
def _navigate_to_cell(self, cell_address: str):
|
||||
"""导航到指定单元格"""
|
||||
try:
|
||||
name_box = self._page.locator("input.edit-box").first
|
||||
name_box.click()
|
||||
name_box.fill(cell_address)
|
||||
name_box.press("Enter")
|
||||
except Exception:
|
||||
name_box = self._page.locator('#root input[type="text"]').first
|
||||
name_box.click()
|
||||
name_box.fill(cell_address)
|
||||
name_box.press("Enter")
|
||||
time.sleep(0.3)
|
||||
|
||||
def _get_current_cell_address(self) -> str:
|
||||
"""获取当前单元格地址"""
|
||||
try:
|
||||
name_box = self._page.locator("input.edit-box").first
|
||||
value = name_box.input_value()
|
||||
if value and re.match(r"^[A-Z]+\d+$", value.upper()):
|
||||
return value.upper()
|
||||
except Exception:
|
||||
pass
|
||||
return ""
|
||||
|
||||
def _search_and_get_row(self, search_text: str, expected_col: str = None,
|
||||
row_start: int = 0, row_end: int = 0) -> int:
|
||||
"""搜索并获取行号"""
|
||||
# 打开搜索
|
||||
self._page.keyboard.press("Control+f")
|
||||
time.sleep(0.3)
|
||||
|
||||
# 输入搜索内容
|
||||
try:
|
||||
search_input = self._page.get_by_role("textbox").nth(3)
|
||||
if search_input.is_visible(timeout=500):
|
||||
search_input.fill(search_text)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
time.sleep(0.2)
|
||||
|
||||
# 点击查找
|
||||
try:
|
||||
find_btn = self._page.get_by_role("button", name="查找").first
|
||||
find_btn.click()
|
||||
except Exception:
|
||||
self._page.keyboard.press("Enter")
|
||||
|
||||
time.sleep(0.3)
|
||||
|
||||
# 获取当前位置
|
||||
self._page.keyboard.press("Escape")
|
||||
time.sleep(0.3)
|
||||
|
||||
address = self._get_current_cell_address()
|
||||
if not address:
|
||||
return -1
|
||||
|
||||
# 提取行号
|
||||
match = re.search(r"(\d+)$", address)
|
||||
if not match:
|
||||
return -1
|
||||
|
||||
row_num = int(match.group(1))
|
||||
col_letter = "".join(c for c in address if c.isalpha()).upper()
|
||||
|
||||
# 检查列
|
||||
if expected_col and col_letter != expected_col.upper():
|
||||
return -1
|
||||
|
||||
# 检查行范围
|
||||
if row_start > 0 and row_num < row_start:
|
||||
return -1
|
||||
if row_end > 0 and row_num > row_end:
|
||||
return -1
|
||||
|
||||
return row_num
|
||||
|
||||
def _upload_image_to_cell(self, row_num: int, image_path: str, image_col: str) -> bool:
|
||||
"""上传图片到单元格"""
|
||||
cell_address = f"{image_col}{row_num}"
|
||||
self._navigate_to_cell(cell_address)
|
||||
time.sleep(0.3)
|
||||
|
||||
# 清除单元格内容
|
||||
try:
|
||||
self._page.keyboard.press("Escape")
|
||||
time.sleep(0.2)
|
||||
self._page.keyboard.press("Delete")
|
||||
time.sleep(0.3)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 插入 -> 图片 -> 单元格图片
|
||||
try:
|
||||
insert_btn = self._page.get_by_role("button", name="插入")
|
||||
insert_btn.click()
|
||||
time.sleep(0.3)
|
||||
|
||||
image_btn = self._page.get_by_role("button", name="图片")
|
||||
image_btn.click()
|
||||
time.sleep(0.3)
|
||||
|
||||
cell_image_option = self._page.get_by_role("option", name="单元格图片")
|
||||
cell_image_option.click()
|
||||
time.sleep(0.2)
|
||||
|
||||
local_option = self._page.get_by_role("option", name="本地")
|
||||
with self._page.expect_file_chooser() as fc_info:
|
||||
local_option.click()
|
||||
file_chooser = fc_info.value
|
||||
file_chooser.set_files(image_path)
|
||||
|
||||
time.sleep(2)
|
||||
self.log(f"[KDocs] 图片已上传到 {cell_address}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self._last_error = f"上传图片失败: {e}"
|
||||
return False
|
||||
|
||||
def upload_image(
|
||||
self,
|
||||
image_path: str,
|
||||
unit: str,
|
||||
name: str,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
上传截图到金山文档
|
||||
|
||||
Args:
|
||||
image_path: 图片路径
|
||||
unit: 县区名(用于定位行)
|
||||
name: 姓名(用于定位行)
|
||||
|
||||
Returns:
|
||||
{"success": bool, "error": str}
|
||||
"""
|
||||
from config import get_config
|
||||
|
||||
config = get_config()
|
||||
kdocs_config = config.kdocs
|
||||
|
||||
if not kdocs_config.enabled:
|
||||
return {"success": False, "error": "金山文档上传未启用"}
|
||||
|
||||
doc_url = kdocs_config.doc_url.strip()
|
||||
if not doc_url:
|
||||
return {"success": False, "error": "未配置金山文档链接"}
|
||||
|
||||
if not unit or not name:
|
||||
return {"success": False, "error": "缺少县区或姓名"}
|
||||
|
||||
if not image_path or not os.path.exists(image_path):
|
||||
return {"success": False, "error": "图片文件不存在"}
|
||||
|
||||
if not self._ensure_playwright():
|
||||
return {"success": False, "error": self._last_error or "浏览器不可用"}
|
||||
|
||||
if not self._open_document(doc_url):
|
||||
return {"success": False, "error": self._last_error or "打开文档失败"}
|
||||
|
||||
if not self._is_logged_in():
|
||||
return {"success": False, "error": "未登录,请先扫码登录"}
|
||||
|
||||
try:
|
||||
# 选择工作表
|
||||
if kdocs_config.sheet_name:
|
||||
try:
|
||||
tab = self._page.locator("[role='tab']").filter(has_text=kdocs_config.sheet_name)
|
||||
if tab.count() > 0:
|
||||
tab.first.click()
|
||||
time.sleep(0.5)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 搜索姓名找到行
|
||||
self.log(f"[KDocs] 搜索人员: {name}")
|
||||
row_num = self._search_and_get_row(
|
||||
name,
|
||||
expected_col=kdocs_config.name_column,
|
||||
row_start=kdocs_config.row_start,
|
||||
row_end=kdocs_config.row_end,
|
||||
)
|
||||
|
||||
if row_num < 0:
|
||||
return {"success": False, "error": f"未找到人员: {name}"}
|
||||
|
||||
self.log(f"[KDocs] 找到人员在第 {row_num} 行")
|
||||
|
||||
# 上传图片
|
||||
if self._upload_image_to_cell(row_num, image_path, kdocs_config.image_column):
|
||||
return {"success": True}
|
||||
else:
|
||||
return {"success": False, "error": self._last_error or "上传失败"}
|
||||
|
||||
except Exception as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
def clear_login(self):
|
||||
"""清除登录状态"""
|
||||
from config import KDOCS_LOGIN_STATE_FILE
|
||||
|
||||
try:
|
||||
if KDOCS_LOGIN_STATE_FILE.exists():
|
||||
KDOCS_LOGIN_STATE_FILE.unlink()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
self._logged_in = False
|
||||
self._cleanup_browser()
|
||||
|
||||
def close(self):
|
||||
"""关闭上传器"""
|
||||
self._cleanup_browser()
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
self.close()
|
||||
return False
|
||||
|
||||
|
||||
# 全局实例
|
||||
_uploader: Optional[KDocsUploader] = None
|
||||
|
||||
|
||||
def get_kdocs_uploader() -> KDocsUploader:
|
||||
"""获取金山文档上传器实例"""
|
||||
global _uploader
|
||||
if _uploader is None:
|
||||
_uploader = KDocsUploader()
|
||||
return _uploader
|
||||
324
core/screenshot.py
Normal file
324
core/screenshot.py
Normal file
@@ -0,0 +1,324 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
截图模块 - 精简版
|
||||
使用wkhtmltoimage进行网页截图
|
||||
移除了线程池、复杂重试逻辑,保持简单
|
||||
"""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
from datetime import datetime
|
||||
from typing import Optional, Callable, List, Tuple
|
||||
from dataclasses import dataclass
|
||||
|
||||
from .api_browser import APIBrowser, get_cookie_jar_path, is_cookie_jar_fresh
|
||||
|
||||
|
||||
@dataclass
|
||||
class ScreenshotResult:
|
||||
"""截图结果"""
|
||||
success: bool
|
||||
filename: str = ""
|
||||
filepath: str = ""
|
||||
error_message: str = ""
|
||||
|
||||
|
||||
def _resolve_wkhtmltoimage_path() -> Optional[str]:
|
||||
"""查找wkhtmltoimage路径"""
|
||||
from config import get_config
|
||||
config = get_config()
|
||||
|
||||
# 优先使用配置的路径
|
||||
custom_path = config.screenshot.wkhtmltoimage_path
|
||||
if custom_path and os.path.exists(custom_path):
|
||||
return custom_path
|
||||
|
||||
# 先尝试PATH
|
||||
found = shutil.which("wkhtmltoimage")
|
||||
if found:
|
||||
return found
|
||||
|
||||
# Windows默认安装路径
|
||||
win_paths = [
|
||||
r"C:\Program Files\wkhtmltopdf\bin\wkhtmltoimage.exe",
|
||||
r"C:\Program Files (x86)\wkhtmltopdf\bin\wkhtmltoimage.exe",
|
||||
os.path.expandvars(r"%ProgramFiles%\wkhtmltopdf\bin\wkhtmltoimage.exe"),
|
||||
os.path.expandvars(r"%ProgramFiles(x86)%\wkhtmltopdf\bin\wkhtmltoimage.exe"),
|
||||
]
|
||||
for p in win_paths:
|
||||
if os.path.exists(p):
|
||||
return p
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _read_cookie_pairs(cookies_path: str) -> List[Tuple[str, str]]:
|
||||
"""读取cookie文件"""
|
||||
if not cookies_path or not os.path.exists(cookies_path):
|
||||
return []
|
||||
|
||||
pairs = []
|
||||
try:
|
||||
with open(cookies_path, "r", encoding="utf-8", errors="ignore") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
parts = line.split("\t")
|
||||
if len(parts) < 7:
|
||||
continue
|
||||
name = parts[5].strip()
|
||||
value = parts[6].strip()
|
||||
if name:
|
||||
pairs.append((name, value))
|
||||
except Exception:
|
||||
return []
|
||||
return pairs
|
||||
|
||||
|
||||
def _select_cookie_pairs(pairs: List[Tuple[str, str]]) -> List[Tuple[str, str]]:
|
||||
"""选择关键cookie"""
|
||||
preferred_names = {"ASP.NET_SessionId", ".ASPXAUTH"}
|
||||
preferred = [(name, value) for name, value in pairs if name in preferred_names and value]
|
||||
if preferred:
|
||||
return preferred
|
||||
return [(name, value) for name, value in pairs if name and value and name.isascii() and value.isascii()]
|
||||
|
||||
|
||||
def take_screenshot_wkhtmltoimage(
|
||||
url: str,
|
||||
output_path: str,
|
||||
cookies_path: Optional[str] = None,
|
||||
proxy_server: Optional[str] = None,
|
||||
run_script: Optional[str] = None,
|
||||
window_status: Optional[str] = None,
|
||||
log_callback: Optional[Callable] = None,
|
||||
) -> bool:
|
||||
"""
|
||||
使用wkhtmltoimage截图
|
||||
|
||||
Args:
|
||||
url: 要截图的URL
|
||||
output_path: 输出文件路径
|
||||
cookies_path: cookie文件路径
|
||||
proxy_server: 代理服务器
|
||||
run_script: 运行的JavaScript脚本
|
||||
window_status: 等待的window.status值
|
||||
log_callback: 日志回调
|
||||
|
||||
Returns:
|
||||
是否成功
|
||||
"""
|
||||
from config import get_config
|
||||
config = get_config()
|
||||
screenshot_config = config.screenshot
|
||||
|
||||
wkhtmltoimage_path = _resolve_wkhtmltoimage_path()
|
||||
if not wkhtmltoimage_path:
|
||||
if log_callback:
|
||||
log_callback("wkhtmltoimage 未安装或不在 PATH 中")
|
||||
return False
|
||||
|
||||
ext = os.path.splitext(output_path)[1].lower()
|
||||
image_format = "jpg" if ext in (".jpg", ".jpeg") else "png"
|
||||
|
||||
cmd = [
|
||||
wkhtmltoimage_path,
|
||||
"--format", image_format,
|
||||
"--width", str(screenshot_config.width),
|
||||
"--disable-smart-width",
|
||||
"--javascript-delay", str(screenshot_config.js_delay_ms),
|
||||
"--load-error-handling", "ignore",
|
||||
"--enable-local-file-access",
|
||||
"--encoding", "utf-8",
|
||||
]
|
||||
|
||||
# User-Agent
|
||||
ua = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
||||
cmd.extend(["--custom-header", "User-Agent", ua, "--custom-header-propagation"])
|
||||
|
||||
# 图片质量
|
||||
if image_format in ("jpg", "jpeg"):
|
||||
cmd.extend(["--quality", str(screenshot_config.quality)])
|
||||
|
||||
# 高度
|
||||
if screenshot_config.height > 0:
|
||||
cmd.extend(["--height", str(screenshot_config.height)])
|
||||
|
||||
# 自定义脚本
|
||||
if run_script:
|
||||
cmd.extend(["--run-script", run_script])
|
||||
if window_status:
|
||||
cmd.extend(["--window-status", window_status])
|
||||
|
||||
# Cookies
|
||||
if cookies_path:
|
||||
cookie_pairs = _select_cookie_pairs(_read_cookie_pairs(cookies_path))
|
||||
if cookie_pairs:
|
||||
for name, value in cookie_pairs:
|
||||
cmd.extend(["--cookie", name, value])
|
||||
else:
|
||||
cmd.extend(["--cookie-jar", cookies_path])
|
||||
|
||||
# 代理
|
||||
if proxy_server:
|
||||
cmd.extend(["--proxy", proxy_server])
|
||||
|
||||
cmd.extend([url, output_path])
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=screenshot_config.timeout_seconds
|
||||
)
|
||||
if result.returncode != 0:
|
||||
if log_callback:
|
||||
err_msg = (result.stderr or result.stdout or "").strip()
|
||||
log_callback(f"wkhtmltoimage 截图失败: {err_msg[:200]}")
|
||||
return False
|
||||
return True
|
||||
except subprocess.TimeoutExpired:
|
||||
if log_callback:
|
||||
log_callback("wkhtmltoimage 截图超时")
|
||||
return False
|
||||
except Exception as e:
|
||||
if log_callback:
|
||||
log_callback(f"wkhtmltoimage 截图异常: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def take_screenshot(
|
||||
username: str,
|
||||
password: str,
|
||||
browse_type: str = "应读",
|
||||
remark: str = "",
|
||||
log_callback: Optional[Callable] = None,
|
||||
proxy_config: Optional[dict] = None,
|
||||
) -> ScreenshotResult:
|
||||
"""
|
||||
为账号执行完整的截图流程
|
||||
|
||||
Args:
|
||||
username: 用户名
|
||||
password: 密码
|
||||
browse_type: 浏览类型
|
||||
remark: 账号备注(用于文件名)
|
||||
log_callback: 日志回调
|
||||
proxy_config: 代理配置
|
||||
|
||||
Returns:
|
||||
截图结果
|
||||
"""
|
||||
from config import get_config, SCREENSHOTS_DIR
|
||||
config = get_config()
|
||||
|
||||
result = ScreenshotResult(success=False)
|
||||
|
||||
def log(msg: str):
|
||||
if log_callback:
|
||||
log_callback(msg)
|
||||
|
||||
# 确保截图目录存在
|
||||
SCREENSHOTS_DIR.mkdir(exist_ok=True)
|
||||
|
||||
# 获取或刷新cookies
|
||||
cookie_path = get_cookie_jar_path(username)
|
||||
proxy_server = proxy_config.get("server") if proxy_config else None
|
||||
|
||||
if not is_cookie_jar_fresh(cookie_path):
|
||||
log("正在登录获取Cookie...")
|
||||
with APIBrowser(log_callback=log, proxy_config=proxy_config) as browser:
|
||||
if not browser.login(username, password):
|
||||
result.error_message = "登录失败"
|
||||
return result
|
||||
if not browser.save_cookies_for_screenshot(username):
|
||||
result.error_message = "保存Cookie失败"
|
||||
return result
|
||||
|
||||
log(f"导航到 '{browse_type}' 页面...")
|
||||
|
||||
# 构建截图URL
|
||||
from urllib.parse import urlsplit
|
||||
parsed = urlsplit(config.zsgl.login_url)
|
||||
base = f"{parsed.scheme}://{parsed.netloc}"
|
||||
|
||||
bz = 0 # 应读
|
||||
target_url = f"{base}/admin/center.aspx?bz={bz}"
|
||||
index_url = f"{base}/admin/index.aspx"
|
||||
|
||||
# 生成文件名
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
account_name = remark if remark else username
|
||||
screenshot_filename = f"{account_name}_{browse_type}_{timestamp}.jpg"
|
||||
screenshot_path = str(SCREENSHOTS_DIR / screenshot_filename)
|
||||
|
||||
# 构建JavaScript注入脚本(用于正确显示页面)
|
||||
run_script = (
|
||||
"(function(){"
|
||||
"function done(){window.status='ready';}"
|
||||
"function ensureNav(){try{if(typeof loadMenuTree==='function'){loadMenuTree(true);}}catch(e){}}"
|
||||
"function expandMenu(){"
|
||||
"try{var body=document.body;if(body&&body.classList.contains('lay-mini')){body.classList.remove('lay-mini');}}catch(e){}"
|
||||
"try{if(typeof mainPageResize==='function'){mainPageResize();}}catch(e){}"
|
||||
"}"
|
||||
"function navReady(){"
|
||||
"try{var nav=document.getElementById('sidebar-nav');return nav && nav.querySelectorAll('a').length>0;}catch(e){return false;}"
|
||||
"}"
|
||||
"function frameReady(){"
|
||||
"try{var f=document.getElementById('mainframe');return f && f.contentDocument && f.contentDocument.readyState==='complete';}catch(e){return false;}"
|
||||
"}"
|
||||
"function check(){"
|
||||
"if(navReady() && frameReady()){done();return;}"
|
||||
"setTimeout(check,300);"
|
||||
"}"
|
||||
"var f=document.getElementById('mainframe');"
|
||||
"ensureNav();"
|
||||
"expandMenu();"
|
||||
"if(!f){done();return;}"
|
||||
f"f.src='{target_url}';"
|
||||
"f.onload=function(){ensureNav();expandMenu();setTimeout(check,300);};"
|
||||
"setTimeout(check,5000);"
|
||||
"})();"
|
||||
)
|
||||
|
||||
# 尝试截图(先尝试完整页面,失败则直接截目标页)
|
||||
log("正在截图...")
|
||||
|
||||
cookies_for_shot = cookie_path if is_cookie_jar_fresh(cookie_path) else None
|
||||
|
||||
success = take_screenshot_wkhtmltoimage(
|
||||
index_url,
|
||||
screenshot_path,
|
||||
cookies_path=cookies_for_shot,
|
||||
proxy_server=proxy_server,
|
||||
run_script=run_script,
|
||||
window_status="ready",
|
||||
log_callback=log,
|
||||
)
|
||||
|
||||
if not success:
|
||||
# 备选:直接截目标页
|
||||
log("尝试直接截图目标页...")
|
||||
success = take_screenshot_wkhtmltoimage(
|
||||
target_url,
|
||||
screenshot_path,
|
||||
cookies_path=cookies_for_shot,
|
||||
proxy_server=proxy_server,
|
||||
log_callback=log,
|
||||
)
|
||||
|
||||
if success and os.path.exists(screenshot_path) and os.path.getsize(screenshot_path) > 1000:
|
||||
log(f"[OK] 截图成功: {screenshot_filename}")
|
||||
result.success = True
|
||||
result.filename = screenshot_filename
|
||||
result.filepath = screenshot_path
|
||||
else:
|
||||
result.error_message = "截图失败"
|
||||
if os.path.exists(screenshot_path):
|
||||
os.remove(screenshot_path)
|
||||
|
||||
return result
|
||||
89
main.py
Normal file
89
main.py
Normal file
@@ -0,0 +1,89 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
知识管理平台助手 - 精简版
|
||||
PyQt6桌面应用入口
|
||||
|
||||
功能:
|
||||
- 知识管理平台浏览(登录、标记已读)
|
||||
- wkhtmltoimage截图
|
||||
- 金山文档表格上传
|
||||
|
||||
作者: 老王
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
|
||||
# 确保在正确的目录
|
||||
os.chdir(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from PyQt6.QtWidgets import QApplication
|
||||
from PyQt6.QtCore import Qt
|
||||
from PyQt6.QtGui import QFont
|
||||
|
||||
from config import get_config
|
||||
from ui.main_window import MainWindow
|
||||
from ui.styles import get_stylesheet, LIGHT_THEME, DARK_THEME
|
||||
|
||||
|
||||
def main():
|
||||
"""应用入口"""
|
||||
# 高DPI支持
|
||||
if hasattr(Qt, 'AA_EnableHighDpiScaling'):
|
||||
QApplication.setAttribute(Qt.ApplicationAttribute.AA_EnableHighDpiScaling, True)
|
||||
if hasattr(Qt, 'AA_UseHighDpiPixmaps'):
|
||||
QApplication.setAttribute(Qt.ApplicationAttribute.AA_UseHighDpiPixmaps, True)
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
|
||||
# 设置应用信息
|
||||
app.setApplicationName("知识管理平台助手")
|
||||
app.setApplicationVersion("1.0.0")
|
||||
app.setOrganizationName("zsglpt-lite")
|
||||
|
||||
# 设置默认字体
|
||||
font = QFont("Microsoft YaHei", 10)
|
||||
app.setFont(font)
|
||||
|
||||
# 加载配置并应用主题
|
||||
config = get_config()
|
||||
theme = LIGHT_THEME if config.theme == "light" else DARK_THEME
|
||||
app.setStyleSheet(get_stylesheet(theme))
|
||||
|
||||
# 创建主窗口
|
||||
window = MainWindow()
|
||||
window.show()
|
||||
|
||||
# Check for JSON to SQLite migration
|
||||
from config import CONFIG_FILE
|
||||
if CONFIG_FILE.exists():
|
||||
from utils.storage import migrate_from_json
|
||||
window.log("检测到旧JSON配置,正在迁移到SQLite...")
|
||||
if migrate_from_json():
|
||||
window.log("✅ 配置迁移成功")
|
||||
else:
|
||||
window.log("⚠️ 配置迁移失败,将使用默认配置")
|
||||
|
||||
# 启动日志
|
||||
window.log("应用启动成功")
|
||||
window.log(f"数据目录: {os.path.abspath('data')}")
|
||||
|
||||
# Show database info
|
||||
from utils.storage import _get_db_path
|
||||
db_path = _get_db_path()
|
||||
window.log(f"数据库: {db_path}")
|
||||
|
||||
# 检查wkhtmltoimage
|
||||
from core.screenshot import _resolve_wkhtmltoimage_path
|
||||
wkhtml = _resolve_wkhtmltoimage_path()
|
||||
if wkhtml:
|
||||
window.log(f"wkhtmltoimage: {wkhtml}")
|
||||
else:
|
||||
window.log("⚠️ 警告: 未找到 wkhtmltoimage,截图功能可能不可用")
|
||||
|
||||
sys.exit(app.exec())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
19
requirements.txt
Normal file
19
requirements.txt
Normal file
@@ -0,0 +1,19 @@
|
||||
# zsglpt-lite dependencies
|
||||
|
||||
# UI Framework
|
||||
PyQt6>=6.5.0
|
||||
|
||||
# HTTP requests
|
||||
requests>=2.31.0
|
||||
|
||||
# HTML parsing
|
||||
beautifulsoup4>=4.12.0
|
||||
|
||||
# KDocs automation (Playwright)
|
||||
playwright>=1.42.0
|
||||
|
||||
# Password encryption
|
||||
cryptography>=41.0.0
|
||||
|
||||
# Image processing
|
||||
Pillow>=10.0.0
|
||||
25
screenshot_test.py
Normal file
25
screenshot_test.py
Normal file
@@ -0,0 +1,25 @@
|
||||
#!/usr/bin/env python3
|
||||
import sys
|
||||
from PyQt6.QtWidgets import QApplication
|
||||
from PyQt6.QtCore import QTimer
|
||||
from ui.main_window import MainWindow
|
||||
from ui.styles import apply_theme
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
apply_theme(app, 'light')
|
||||
|
||||
window = MainWindow()
|
||||
window.resize(1000, 700)
|
||||
window.show()
|
||||
|
||||
def do_work():
|
||||
window.stack.setCurrentIndex(3)
|
||||
QTimer.singleShot(500, take_shot)
|
||||
|
||||
def take_shot():
|
||||
window.grab().save('C:/Users/Administrator/Desktop/kdocs_screenshot.png')
|
||||
print('Screenshot saved!')
|
||||
app.quit()
|
||||
|
||||
QTimer.singleShot(500, do_work)
|
||||
app.exec()
|
||||
13
ui/__init__.py
Normal file
13
ui/__init__.py
Normal file
@@ -0,0 +1,13 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""UI模块"""
|
||||
|
||||
from .styles import get_stylesheet, LIGHT_THEME, DARK_THEME
|
||||
from .main_window import MainWindow
|
||||
from .log_widget import LogWidget
|
||||
|
||||
__all__ = [
|
||||
'get_stylesheet', 'LIGHT_THEME', 'DARK_THEME',
|
||||
'MainWindow',
|
||||
'LogWidget',
|
||||
]
|
||||
416
ui/account_widget.py
Normal file
416
ui/account_widget.py
Normal file
@@ -0,0 +1,416 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Account management panel - add/edit/delete accounts, test login
|
||||
"""
|
||||
|
||||
from PyQt6.QtWidgets import (
|
||||
QWidget, QVBoxLayout, QHBoxLayout, QTableWidget, QTableWidgetItem,
|
||||
QPushButton, QLineEdit, QLabel, QDialog, QFormLayout, QMessageBox,
|
||||
QHeaderView, QCheckBox, QScrollArea, QFrame, QSizePolicy, QSpacerItem
|
||||
)
|
||||
from PyQt6.QtCore import Qt, pyqtSignal
|
||||
|
||||
from config import get_config, save_config, AccountConfig
|
||||
from utils.crypto import encrypt_password, decrypt_password, is_encrypted
|
||||
from .constants import (
|
||||
PAGE_PADDING, SECTION_SPACING, GROUP_PADDING_TOP, GROUP_PADDING_SIDE,
|
||||
GROUP_PADDING_BOTTOM, GROUP_SPACING, FORM_ROW_SPACING, INPUT_HEIGHT,
|
||||
BUTTON_HEIGHT, BUTTON_HEIGHT_NORMAL, BUTTON_HEIGHT_SMALL, BUTTON_MIN_WIDTH,
|
||||
BUTTON_MIN_WIDTH_NORMAL, BUTTON_WIDTH_SMALL, BUTTON_SPACING,
|
||||
TABLE_ROW_HEIGHT, get_title_style, get_help_text_style
|
||||
)
|
||||
|
||||
|
||||
class AccountEditDialog(QDialog):
|
||||
"""Account edit dialog"""
|
||||
|
||||
def __init__(self, account: AccountConfig = None, parent=None):
|
||||
super().__init__(parent)
|
||||
self.account = account
|
||||
self.setWindowTitle("编辑账号" if account else "添加账号")
|
||||
self.setMinimumWidth(480)
|
||||
self._setup_ui()
|
||||
self.adjustSize()
|
||||
|
||||
def _setup_ui(self):
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(PAGE_PADDING, PAGE_PADDING, PAGE_PADDING, PAGE_PADDING)
|
||||
layout.setSpacing(SECTION_SPACING)
|
||||
|
||||
# Form area
|
||||
form_layout = QFormLayout()
|
||||
form_layout.setSpacing(FORM_ROW_SPACING)
|
||||
form_layout.setLabelAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
|
||||
form_layout.setFormAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter)
|
||||
|
||||
# Username
|
||||
self.username_edit = QLineEdit()
|
||||
self.username_edit.setPlaceholderText("请输入用户名")
|
||||
self.username_edit.setMinimumHeight(INPUT_HEIGHT)
|
||||
if self.account:
|
||||
self.username_edit.setText(self.account.username)
|
||||
form_layout.addRow("用户名:", self.username_edit)
|
||||
|
||||
# Password
|
||||
self.password_edit = QLineEdit()
|
||||
self.password_edit.setEchoMode(QLineEdit.EchoMode.Password)
|
||||
self.password_edit.setPlaceholderText("请输入密码")
|
||||
self.password_edit.setMinimumHeight(INPUT_HEIGHT)
|
||||
if self.account:
|
||||
pwd = decrypt_password(self.account.password) if is_encrypted(self.account.password) else self.account.password
|
||||
self.password_edit.setText(pwd)
|
||||
form_layout.addRow("密码:", self.password_edit)
|
||||
|
||||
# Remark
|
||||
self.remark_edit = QLineEdit()
|
||||
self.remark_edit.setPlaceholderText("如:张三(用于截图文件名)")
|
||||
self.remark_edit.setMinimumHeight(INPUT_HEIGHT)
|
||||
if self.account:
|
||||
self.remark_edit.setText(self.account.remark)
|
||||
form_layout.addRow("备注:", self.remark_edit)
|
||||
|
||||
# Enabled checkbox
|
||||
self.enabled_check = QCheckBox("启用此账号")
|
||||
self.enabled_check.setChecked(self.account.enabled if self.account else True)
|
||||
form_layout.addRow("", self.enabled_check)
|
||||
|
||||
layout.addLayout(form_layout)
|
||||
|
||||
# Spacer
|
||||
layout.addSpacerItem(QSpacerItem(20, 20, QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Expanding))
|
||||
|
||||
# Buttons
|
||||
btn_layout = QHBoxLayout()
|
||||
btn_layout.setSpacing(BUTTON_SPACING)
|
||||
btn_layout.addStretch()
|
||||
|
||||
cancel_btn = QPushButton("取消")
|
||||
cancel_btn.setMinimumWidth(BUTTON_MIN_WIDTH_NORMAL)
|
||||
cancel_btn.setMinimumHeight(BUTTON_HEIGHT_NORMAL)
|
||||
cancel_btn.clicked.connect(self.reject)
|
||||
btn_layout.addWidget(cancel_btn)
|
||||
|
||||
save_btn = QPushButton("保存")
|
||||
save_btn.setObjectName("primary")
|
||||
save_btn.setMinimumWidth(BUTTON_MIN_WIDTH_NORMAL)
|
||||
save_btn.setMinimumHeight(BUTTON_HEIGHT_NORMAL)
|
||||
save_btn.clicked.connect(self._save)
|
||||
btn_layout.addWidget(save_btn)
|
||||
|
||||
layout.addLayout(btn_layout)
|
||||
|
||||
def _save(self):
|
||||
username = self.username_edit.text().strip()
|
||||
password = self.password_edit.text().strip()
|
||||
|
||||
if not username:
|
||||
QMessageBox.warning(self, "提示", "请输入用户名")
|
||||
return
|
||||
if not password:
|
||||
QMessageBox.warning(self, "提示", "请输入密码")
|
||||
return
|
||||
|
||||
encrypted_pwd = encrypt_password(password)
|
||||
|
||||
if self.account:
|
||||
self.account.username = username
|
||||
self.account.password = encrypted_pwd
|
||||
self.account.remark = self.remark_edit.text().strip()
|
||||
self.account.enabled = self.enabled_check.isChecked()
|
||||
else:
|
||||
self.account = AccountConfig(
|
||||
username=username,
|
||||
password=encrypted_pwd,
|
||||
remark=self.remark_edit.text().strip(),
|
||||
enabled=self.enabled_check.isChecked()
|
||||
)
|
||||
|
||||
self.accept()
|
||||
|
||||
def get_account(self) -> AccountConfig:
|
||||
return self.account
|
||||
|
||||
|
||||
class AccountWidget(QWidget):
|
||||
"""Account management panel"""
|
||||
|
||||
log_signal = pyqtSignal(str)
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self._worker = None # 保存Worker引用防止被垃圾回收
|
||||
self._setup_ui()
|
||||
self._load_accounts()
|
||||
|
||||
def _setup_ui(self):
|
||||
main_layout = QVBoxLayout(self)
|
||||
main_layout.setContentsMargins(0, 0, 0, 0)
|
||||
main_layout.setSpacing(0)
|
||||
|
||||
# Scroll area
|
||||
scroll = QScrollArea()
|
||||
scroll.setWidgetResizable(True)
|
||||
scroll.setFrameShape(QFrame.Shape.NoFrame)
|
||||
scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||||
|
||||
content = QWidget()
|
||||
layout = QVBoxLayout(content)
|
||||
layout.setContentsMargins(PAGE_PADDING, PAGE_PADDING, PAGE_PADDING, PAGE_PADDING)
|
||||
layout.setSpacing(SECTION_SPACING)
|
||||
|
||||
# Title
|
||||
title = QLabel("📝 账号管理")
|
||||
title.setStyleSheet(get_title_style())
|
||||
layout.addWidget(title)
|
||||
|
||||
# Toolbar
|
||||
toolbar = QHBoxLayout()
|
||||
toolbar.setSpacing(BUTTON_SPACING)
|
||||
|
||||
add_btn = QPushButton("➕ 添加账号")
|
||||
add_btn.setObjectName("primary")
|
||||
add_btn.setMinimumHeight(BUTTON_HEIGHT_NORMAL)
|
||||
add_btn.setMinimumWidth(BUTTON_MIN_WIDTH)
|
||||
add_btn.clicked.connect(self._add_account)
|
||||
toolbar.addWidget(add_btn)
|
||||
|
||||
toolbar.addStretch()
|
||||
|
||||
test_btn = QPushButton("🔑 测试登录")
|
||||
test_btn.setMinimumHeight(BUTTON_HEIGHT_NORMAL)
|
||||
test_btn.setMinimumWidth(BUTTON_MIN_WIDTH_NORMAL)
|
||||
test_btn.clicked.connect(self._test_login)
|
||||
toolbar.addWidget(test_btn)
|
||||
|
||||
layout.addLayout(toolbar)
|
||||
|
||||
# Account table
|
||||
self.table = QTableWidget()
|
||||
self.table.setColumnCount(5)
|
||||
self.table.setHorizontalHeaderLabels(["启用", "用户名", "备注", "状态", "操作"])
|
||||
# 列宽设置:启用-固定,用户名-拉伸,备注-拉伸,状态-固定,操作-固定
|
||||
self.table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.Fixed)
|
||||
self.table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch)
|
||||
self.table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeMode.Stretch)
|
||||
self.table.horizontalHeader().setSectionResizeMode(3, QHeaderView.ResizeMode.Fixed)
|
||||
self.table.horizontalHeader().setSectionResizeMode(4, QHeaderView.ResizeMode.Fixed) # 固定宽度不被压缩
|
||||
self.table.setColumnWidth(0, 60)
|
||||
self.table.setColumnWidth(3, 80)
|
||||
self.table.setColumnWidth(4, 220) # 操作列固定220px
|
||||
self.table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows)
|
||||
self.table.setSelectionMode(QTableWidget.SelectionMode.SingleSelection)
|
||||
self.table.verticalHeader().setDefaultSectionSize(TABLE_ROW_HEIGHT)
|
||||
self.table.verticalHeader().setVisible(False)
|
||||
self.table.setAlternatingRowColors(True)
|
||||
self.table.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
|
||||
self.table.setMinimumHeight(250)
|
||||
layout.addWidget(self.table, 1)
|
||||
|
||||
# Help text
|
||||
help_text = QLabel("💡 提示: 密码将加密存储。备注用于截图文件命名,建议填写姓名。")
|
||||
help_text.setStyleSheet(get_help_text_style())
|
||||
help_text.setWordWrap(True)
|
||||
layout.addWidget(help_text)
|
||||
|
||||
scroll.setWidget(content)
|
||||
main_layout.addWidget(scroll)
|
||||
|
||||
def _load_accounts(self):
|
||||
"""Load account list"""
|
||||
config = get_config()
|
||||
self.table.setRowCount(0)
|
||||
|
||||
for i, account in enumerate(config.accounts):
|
||||
self.table.insertRow(i)
|
||||
self.table.setRowHeight(i, TABLE_ROW_HEIGHT)
|
||||
|
||||
# Enabled checkbox
|
||||
enabled_widget = QWidget()
|
||||
enabled_layout = QHBoxLayout(enabled_widget)
|
||||
enabled_layout.setContentsMargins(0, 0, 0, 0)
|
||||
enabled_layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
enabled_check = QCheckBox()
|
||||
enabled_check.setChecked(account.enabled)
|
||||
enabled_check.stateChanged.connect(lambda state, row=i: self._toggle_enabled(row, state))
|
||||
enabled_layout.addWidget(enabled_check)
|
||||
self.table.setCellWidget(i, 0, enabled_widget)
|
||||
|
||||
# Username
|
||||
username_item = QTableWidgetItem(account.username)
|
||||
username_item.setTextAlignment(Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeft)
|
||||
username_item.setFlags(username_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
|
||||
self.table.setItem(i, 1, username_item)
|
||||
|
||||
# Remark
|
||||
remark_item = QTableWidgetItem(account.remark)
|
||||
remark_item.setTextAlignment(Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeft)
|
||||
remark_item.setFlags(remark_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
|
||||
self.table.setItem(i, 2, remark_item)
|
||||
|
||||
# Status
|
||||
status_item = QTableWidgetItem("—")
|
||||
status_item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
status_item.setFlags(status_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
|
||||
self.table.setItem(i, 3, status_item)
|
||||
|
||||
# Action buttons - 用图标按钮彻底解决显示问题
|
||||
btn_widget = QWidget()
|
||||
btn_layout = QHBoxLayout(btn_widget)
|
||||
btn_layout.setContentsMargins(0, 0, 0, 0)
|
||||
btn_layout.setSpacing(4)
|
||||
btn_layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
|
||||
# 编辑按钮
|
||||
edit_btn = QPushButton("编辑")
|
||||
edit_btn.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
edit_btn.setStyleSheet("""
|
||||
QPushButton {
|
||||
min-width: 50px;
|
||||
padding: 5px 12px;
|
||||
font-size: 13px;
|
||||
border: 1px solid #d9d9d9;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
color: #333;
|
||||
}
|
||||
QPushButton:hover {
|
||||
border-color: #1890ff;
|
||||
color: #1890ff;
|
||||
background: #e6f7ff;
|
||||
}
|
||||
""")
|
||||
edit_btn.clicked.connect(lambda _, row=i: self._edit_account(row))
|
||||
btn_layout.addWidget(edit_btn)
|
||||
|
||||
# 删除按钮
|
||||
del_btn = QPushButton("删除")
|
||||
del_btn.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
del_btn.setStyleSheet("""
|
||||
QPushButton {
|
||||
min-width: 50px;
|
||||
padding: 5px 12px;
|
||||
font-size: 13px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: #ff4d4f;
|
||||
color: #fff;
|
||||
}
|
||||
QPushButton:hover {
|
||||
background: #ff7875;
|
||||
}
|
||||
""")
|
||||
del_btn.clicked.connect(lambda _, row=i: self._delete_account(row))
|
||||
btn_layout.addWidget(del_btn)
|
||||
|
||||
self.table.setCellWidget(i, 4, btn_widget)
|
||||
|
||||
def _add_account(self):
|
||||
"""Add account"""
|
||||
dialog = AccountEditDialog(parent=self)
|
||||
if dialog.exec() == QDialog.DialogCode.Accepted:
|
||||
account = dialog.get_account()
|
||||
config = get_config()
|
||||
config.accounts.append(account)
|
||||
save_config(config)
|
||||
self._load_accounts()
|
||||
self.log_signal.emit(f"账号 {account.username} 添加成功")
|
||||
|
||||
def _edit_account(self, row: int):
|
||||
"""Edit account"""
|
||||
config = get_config()
|
||||
if row < len(config.accounts):
|
||||
account = config.accounts[row]
|
||||
dialog = AccountEditDialog(account=account, parent=self)
|
||||
if dialog.exec() == QDialog.DialogCode.Accepted:
|
||||
save_config(config)
|
||||
self._load_accounts()
|
||||
self.log_signal.emit(f"账号 {account.username} 已更新")
|
||||
|
||||
def _delete_account(self, row: int):
|
||||
"""Delete account"""
|
||||
config = get_config()
|
||||
if row < len(config.accounts):
|
||||
account = config.accounts[row]
|
||||
reply = QMessageBox.question(
|
||||
self, "确认删除",
|
||||
f"确定要删除账号 {account.username} 吗?",
|
||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
|
||||
)
|
||||
if reply == QMessageBox.StandardButton.Yes:
|
||||
config.accounts.pop(row)
|
||||
save_config(config)
|
||||
self._load_accounts()
|
||||
self.log_signal.emit(f"账号 {account.username} 已删除")
|
||||
|
||||
def _toggle_enabled(self, row: int, state: int):
|
||||
"""Toggle account enabled state"""
|
||||
config = get_config()
|
||||
if row < len(config.accounts):
|
||||
config.accounts[row].enabled = state == Qt.CheckState.Checked.value
|
||||
save_config(config)
|
||||
|
||||
def _test_login(self):
|
||||
"""Test login for selected account"""
|
||||
selected = self.table.selectedItems()
|
||||
if not selected:
|
||||
QMessageBox.information(self, "提示", "请先选择一个账号")
|
||||
return
|
||||
|
||||
row = selected[0].row()
|
||||
config = get_config()
|
||||
if row >= len(config.accounts):
|
||||
return
|
||||
|
||||
account = config.accounts[row]
|
||||
password = decrypt_password(account.password) if is_encrypted(account.password) else account.password
|
||||
|
||||
self.log_signal.emit(f"正在测试登录 {account.username}...")
|
||||
|
||||
status_item = self.table.item(row, 3)
|
||||
status_item.setText("登录中...")
|
||||
|
||||
from utils.worker import Worker
|
||||
|
||||
def test_login(_signals=None, _should_stop=None):
|
||||
from core.api_browser import APIBrowser
|
||||
with APIBrowser(log_callback=lambda msg: _signals.log.emit(msg) if _signals else None) as browser:
|
||||
if browser.login(account.username, password):
|
||||
real_name = browser.get_real_name()
|
||||
return {"success": True, "real_name": real_name}
|
||||
else:
|
||||
return {"success": False}
|
||||
|
||||
def on_result(result):
|
||||
if result and result.get("success"):
|
||||
status_item.setText("✅ 成功")
|
||||
real_name = result.get("real_name", "")
|
||||
msg = f"账号 {account.username} 登录成功"
|
||||
if real_name:
|
||||
msg += f",姓名: {real_name}"
|
||||
# 如果备注为空,自动填入真实姓名
|
||||
if not account.remark:
|
||||
account.remark = real_name
|
||||
save_config(config)
|
||||
self._load_accounts() # 刷新表格
|
||||
msg += "(已自动填入备注)"
|
||||
self.log_signal.emit(msg)
|
||||
else:
|
||||
status_item.setText("❌ 失败")
|
||||
self.log_signal.emit(f"账号 {account.username} 登录失败")
|
||||
|
||||
def on_error(error):
|
||||
status_item.setText("❌ 错误")
|
||||
self.log_signal.emit(f"测试登录出错: {error}")
|
||||
|
||||
# 保存为实例变量,防止被垃圾回收导致崩溃
|
||||
self._worker = Worker(test_login)
|
||||
self._worker.signals.log.connect(self.log_signal.emit)
|
||||
self._worker.signals.result.connect(on_result)
|
||||
self._worker.signals.error.connect(on_error)
|
||||
self._worker.start()
|
||||
|
||||
def get_enabled_accounts(self) -> list:
|
||||
"""Get all enabled accounts"""
|
||||
config = get_config()
|
||||
return [a for a in config.accounts if a.enabled]
|
||||
356
ui/browse_widget.py
Normal file
356
ui/browse_widget.py
Normal file
@@ -0,0 +1,356 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Browse task panel - select accounts, browsing type, execute task with progress
|
||||
"""
|
||||
|
||||
from PyQt6.QtWidgets import (
|
||||
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QComboBox,
|
||||
QPushButton, QProgressBar, QGroupBox, QCheckBox, QListWidget,
|
||||
QListWidgetItem, QMessageBox, QScrollArea, QFrame, QSizePolicy
|
||||
)
|
||||
from PyQt6.QtCore import Qt, pyqtSignal, QSize
|
||||
|
||||
from config import get_config, AccountConfig
|
||||
from utils.crypto import decrypt_password, is_encrypted
|
||||
from .constants import (
|
||||
PAGE_PADDING, SECTION_SPACING, GROUP_PADDING_TOP, GROUP_PADDING_SIDE,
|
||||
GROUP_PADDING_BOTTOM, GROUP_SPACING, FORM_LABEL_WIDTH, FORM_H_SPACING,
|
||||
INPUT_HEIGHT, INPUT_MIN_WIDTH, BUTTON_HEIGHT, BUTTON_HEIGHT_NORMAL,
|
||||
BUTTON_MIN_WIDTH, BUTTON_MIN_WIDTH_NORMAL, BUTTON_SPACING,
|
||||
LIST_MIN_HEIGHT, LIST_ITEM_HEIGHT, PROGRESS_HEIGHT,
|
||||
get_title_style, get_help_text_style
|
||||
)
|
||||
|
||||
|
||||
class BrowseWidget(QWidget):
|
||||
"""Browse task panel"""
|
||||
|
||||
log_signal = pyqtSignal(str)
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self._worker = None
|
||||
self._is_running = False
|
||||
self._setup_ui()
|
||||
self._refresh_accounts()
|
||||
|
||||
def _setup_ui(self):
|
||||
main_layout = QVBoxLayout(self)
|
||||
main_layout.setContentsMargins(0, 0, 0, 0)
|
||||
main_layout.setSpacing(0)
|
||||
|
||||
# Scroll area
|
||||
scroll = QScrollArea()
|
||||
scroll.setWidgetResizable(True)
|
||||
scroll.setFrameShape(QFrame.Shape.NoFrame)
|
||||
scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||||
|
||||
content = QWidget()
|
||||
layout = QVBoxLayout(content)
|
||||
layout.setContentsMargins(16, 12, 16, 12)
|
||||
layout.setSpacing(10)
|
||||
|
||||
# ==================== Title + Control buttons ====================
|
||||
header_layout = QHBoxLayout()
|
||||
header_layout.setSpacing(SECTION_SPACING)
|
||||
|
||||
title = QLabel("🔄 浏览任务")
|
||||
title.setStyleSheet(get_title_style())
|
||||
header_layout.addWidget(title)
|
||||
|
||||
header_layout.addStretch()
|
||||
|
||||
self.stop_btn = QPushButton("⏹ 停止")
|
||||
self.stop_btn.setMinimumWidth(BUTTON_MIN_WIDTH_NORMAL)
|
||||
self.stop_btn.setMinimumHeight(BUTTON_HEIGHT)
|
||||
self.stop_btn.setEnabled(False)
|
||||
self.stop_btn.clicked.connect(self._stop_task)
|
||||
header_layout.addWidget(self.stop_btn)
|
||||
|
||||
self.start_btn = QPushButton("▶ 开始任务")
|
||||
self.start_btn.setObjectName("primary")
|
||||
self.start_btn.setMinimumWidth(BUTTON_MIN_WIDTH)
|
||||
self.start_btn.setMinimumHeight(BUTTON_HEIGHT)
|
||||
self.start_btn.clicked.connect(self._start_task)
|
||||
header_layout.addWidget(self.start_btn)
|
||||
|
||||
layout.addLayout(header_layout)
|
||||
|
||||
# ==================== Progress area (compact) ====================
|
||||
progress_group = QGroupBox("任务进度")
|
||||
progress_layout = QVBoxLayout(progress_group)
|
||||
progress_layout.setContentsMargins(12, 8, 12, 8)
|
||||
progress_layout.setSpacing(6)
|
||||
|
||||
# Current task + stats in one row
|
||||
task_row = QHBoxLayout()
|
||||
self.current_task_label = QLabel("当前任务: 无")
|
||||
task_row.addWidget(self.current_task_label)
|
||||
task_row.addStretch()
|
||||
self.stats_label = QLabel("已处理: 0 条内容,0 个附件")
|
||||
self.stats_label.setStyleSheet(get_help_text_style())
|
||||
task_row.addWidget(self.stats_label)
|
||||
progress_layout.addLayout(task_row)
|
||||
|
||||
# Progress bars in one row
|
||||
prog_row = QHBoxLayout()
|
||||
prog_row.setSpacing(12)
|
||||
|
||||
prog_row.addWidget(QLabel("总体:"))
|
||||
self.total_progress = QProgressBar()
|
||||
self.total_progress.setValue(0)
|
||||
self.total_progress.setFixedHeight(18)
|
||||
prog_row.addWidget(self.total_progress, 1)
|
||||
|
||||
prog_row.addWidget(QLabel("当前:"))
|
||||
self.account_progress = QProgressBar()
|
||||
self.account_progress.setValue(0)
|
||||
self.account_progress.setFixedHeight(18)
|
||||
prog_row.addWidget(self.account_progress, 1)
|
||||
|
||||
progress_layout.addLayout(prog_row)
|
||||
|
||||
layout.addWidget(progress_group)
|
||||
|
||||
# ==================== Browse options (compact, one row) ====================
|
||||
options_group = QGroupBox("浏览选项")
|
||||
options_layout = QHBoxLayout(options_group)
|
||||
options_layout.setContentsMargins(12, 8, 12, 8)
|
||||
options_layout.setSpacing(16)
|
||||
|
||||
options_layout.addWidget(QLabel("浏览类型:"))
|
||||
self.browse_type_combo = QComboBox()
|
||||
self.browse_type_combo.addItems(["应读", "注册前未读"])
|
||||
self.browse_type_combo.setMinimumWidth(140)
|
||||
self.browse_type_combo.setFixedHeight(32)
|
||||
self.browse_type_combo.setStyleSheet("QComboBox { padding-left: 10px; }")
|
||||
options_layout.addWidget(self.browse_type_combo)
|
||||
|
||||
options_layout.addSpacing(20)
|
||||
|
||||
self.auto_screenshot_check = QCheckBox("浏览后自动截图")
|
||||
self.auto_screenshot_check.setChecked(True)
|
||||
options_layout.addWidget(self.auto_screenshot_check)
|
||||
|
||||
self.auto_upload_check = QCheckBox("截图后自动上传")
|
||||
self.auto_upload_check.setChecked(False)
|
||||
options_layout.addWidget(self.auto_upload_check)
|
||||
|
||||
options_layout.addStretch()
|
||||
|
||||
layout.addWidget(options_group)
|
||||
|
||||
# ==================== Account selection (compact) ====================
|
||||
account_group = QGroupBox("选择账号")
|
||||
account_group.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
|
||||
account_layout = QVBoxLayout(account_group)
|
||||
account_layout.setContentsMargins(12, 8, 12, 8)
|
||||
account_layout.setSpacing(6)
|
||||
|
||||
# Toolbar - compact buttons with proper width
|
||||
toolbar = QHBoxLayout()
|
||||
toolbar.setSpacing(8)
|
||||
|
||||
self.select_all_btn = QPushButton("全选")
|
||||
self.select_all_btn.setFixedSize(70, 28)
|
||||
self.select_all_btn.setStyleSheet("QPushButton { padding: 2px 8px; }")
|
||||
self.select_all_btn.clicked.connect(self._select_all)
|
||||
toolbar.addWidget(self.select_all_btn)
|
||||
|
||||
self.deselect_all_btn = QPushButton("取消全选")
|
||||
self.deselect_all_btn.setFixedSize(90, 28)
|
||||
self.deselect_all_btn.setStyleSheet("QPushButton { padding: 2px 8px; }")
|
||||
self.deselect_all_btn.clicked.connect(self._deselect_all)
|
||||
toolbar.addWidget(self.deselect_all_btn)
|
||||
|
||||
toolbar.addStretch()
|
||||
|
||||
self.refresh_btn = QPushButton("刷新")
|
||||
self.refresh_btn.setFixedSize(70, 28)
|
||||
self.refresh_btn.setStyleSheet("QPushButton { padding: 2px 8px; }")
|
||||
self.refresh_btn.clicked.connect(self._refresh_accounts)
|
||||
toolbar.addWidget(self.refresh_btn)
|
||||
|
||||
account_layout.addLayout(toolbar)
|
||||
|
||||
# Account list - compact
|
||||
self.account_list = QListWidget()
|
||||
self.account_list.setMinimumHeight(120)
|
||||
self.account_list.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
|
||||
self.account_list.setSpacing(1)
|
||||
account_layout.addWidget(self.account_list, 1)
|
||||
|
||||
layout.addWidget(account_group, 1)
|
||||
|
||||
scroll.setWidget(content)
|
||||
main_layout.addWidget(scroll)
|
||||
|
||||
def _refresh_accounts(self):
|
||||
"""Refresh account list"""
|
||||
self.account_list.clear()
|
||||
config = get_config()
|
||||
|
||||
for account in config.accounts:
|
||||
if account.enabled:
|
||||
item = QListWidgetItem()
|
||||
item.setFlags(item.flags() | Qt.ItemFlag.ItemIsUserCheckable)
|
||||
item.setCheckState(Qt.CheckState.Checked)
|
||||
display_name = account.remark if account.remark else account.username
|
||||
item.setText(f"{display_name} ({account.username})")
|
||||
item.setData(Qt.ItemDataRole.UserRole, account)
|
||||
item.setSizeHint(item.sizeHint().expandedTo(QSize(0, LIST_ITEM_HEIGHT)))
|
||||
self.account_list.addItem(item)
|
||||
|
||||
def _select_all(self):
|
||||
"""Select all"""
|
||||
for i in range(self.account_list.count()):
|
||||
self.account_list.item(i).setCheckState(Qt.CheckState.Checked)
|
||||
|
||||
def _deselect_all(self):
|
||||
"""Deselect all"""
|
||||
for i in range(self.account_list.count()):
|
||||
self.account_list.item(i).setCheckState(Qt.CheckState.Unchecked)
|
||||
|
||||
def _get_selected_accounts(self) -> list:
|
||||
"""Get selected accounts"""
|
||||
accounts = []
|
||||
for i in range(self.account_list.count()):
|
||||
item = self.account_list.item(i)
|
||||
if item.checkState() == Qt.CheckState.Checked:
|
||||
account = item.data(Qt.ItemDataRole.UserRole)
|
||||
accounts.append(account)
|
||||
return accounts
|
||||
|
||||
def _start_task(self):
|
||||
"""Start task"""
|
||||
accounts = self._get_selected_accounts()
|
||||
if not accounts:
|
||||
QMessageBox.information(self, "提示", "请选择至少一个账号")
|
||||
return
|
||||
|
||||
self._is_running = True
|
||||
self.start_btn.setEnabled(False)
|
||||
self.stop_btn.setEnabled(True)
|
||||
self._stop_requested = False
|
||||
|
||||
browse_type = self.browse_type_combo.currentText()
|
||||
auto_screenshot = self.auto_screenshot_check.isChecked()
|
||||
auto_upload = self.auto_upload_check.isChecked()
|
||||
|
||||
self.total_progress.setMaximum(len(accounts))
|
||||
self.total_progress.setValue(0)
|
||||
|
||||
self.log_signal.emit(f"开始任务: {len(accounts)} 个账号, 类型: {browse_type}")
|
||||
|
||||
from utils.worker import Worker
|
||||
|
||||
def run_tasks(_signals=None, _should_stop=None):
|
||||
results = []
|
||||
for i, account in enumerate(accounts):
|
||||
if _should_stop and _should_stop():
|
||||
break
|
||||
|
||||
_signals.log.emit(f"[{i+1}/{len(accounts)}] 处理账号: {account.username}")
|
||||
_signals.progress.emit(i, f"正在处理: {account.username}")
|
||||
|
||||
password = decrypt_password(account.password) if is_encrypted(account.password) else account.password
|
||||
|
||||
from core.api_browser import APIBrowser
|
||||
from config import get_config as _get_config
|
||||
|
||||
proxy_config = None
|
||||
cfg = _get_config()
|
||||
if cfg.proxy.enabled and cfg.proxy.server:
|
||||
proxy_config = {"server": cfg.proxy.server}
|
||||
|
||||
browse_result = None
|
||||
with APIBrowser(log_callback=lambda msg: _signals.log.emit(msg), proxy_config=proxy_config) as browser:
|
||||
if browser.login(account.username, password):
|
||||
browser.save_cookies_for_screenshot(account.username)
|
||||
|
||||
result = browser.browse_content(
|
||||
browse_type,
|
||||
should_stop_callback=_should_stop,
|
||||
)
|
||||
browse_result = {
|
||||
"success": result.success,
|
||||
"total_items": result.total_items,
|
||||
"total_attachments": result.total_attachments,
|
||||
}
|
||||
|
||||
screenshot_path = None
|
||||
if browse_result and browse_result.get("success") and auto_screenshot:
|
||||
from core.screenshot import take_screenshot
|
||||
|
||||
_signals.log.emit("正在截图...")
|
||||
ss_result = take_screenshot(
|
||||
account.username,
|
||||
password,
|
||||
browse_type,
|
||||
remark=account.remark,
|
||||
log_callback=lambda msg: _signals.log.emit(msg),
|
||||
proxy_config=proxy_config
|
||||
)
|
||||
if ss_result.success:
|
||||
screenshot_path = ss_result.filepath
|
||||
|
||||
if screenshot_path and auto_upload:
|
||||
from core.kdocs_uploader import get_kdocs_uploader
|
||||
from config import get_config as _get_config2
|
||||
|
||||
cfg2 = _get_config2()
|
||||
if cfg2.kdocs.enabled:
|
||||
_signals.log.emit("正在上传到金山文档...")
|
||||
uploader = get_kdocs_uploader()
|
||||
uploader._log_callback = lambda msg: _signals.log.emit(msg)
|
||||
upload_result = uploader.upload_image(
|
||||
screenshot_path,
|
||||
cfg2.kdocs.unit,
|
||||
account.remark or account.username
|
||||
)
|
||||
if upload_result.get("success"):
|
||||
_signals.log.emit("[OK] 上传成功")
|
||||
else:
|
||||
_signals.log.emit(f"上传失败: {upload_result.get('error', '未知错误')}")
|
||||
|
||||
results.append({
|
||||
"account": account.username,
|
||||
"browse": browse_result,
|
||||
"screenshot": screenshot_path,
|
||||
})
|
||||
|
||||
_signals.progress.emit(i + 1, f"完成: {account.username}")
|
||||
|
||||
return results
|
||||
|
||||
def on_progress(current, message):
|
||||
self.total_progress.setValue(current)
|
||||
self.current_task_label.setText(f"当前任务: {message}")
|
||||
|
||||
def on_finished(success, message):
|
||||
self._is_running = False
|
||||
self.start_btn.setEnabled(True)
|
||||
self.stop_btn.setEnabled(False)
|
||||
self.current_task_label.setText("当前任务: 完成")
|
||||
self.log_signal.emit(f"任务完成: {message}")
|
||||
|
||||
def on_result(results):
|
||||
if results:
|
||||
total_items = sum(r.get("browse", {}).get("total_items", 0) for r in results if r.get("browse"))
|
||||
total_attachments = sum(r.get("browse", {}).get("total_attachments", 0) for r in results if r.get("browse"))
|
||||
self.stats_label.setText(f"已处理: {total_items} 条内容,{total_attachments} 个附件")
|
||||
|
||||
self._worker = Worker(run_tasks)
|
||||
self._worker.signals.log.connect(self.log_signal.emit)
|
||||
self._worker.signals.progress.connect(on_progress)
|
||||
self._worker.signals.finished.connect(on_finished)
|
||||
self._worker.signals.result.connect(on_result)
|
||||
self._worker.start()
|
||||
|
||||
def _stop_task(self):
|
||||
"""Stop task"""
|
||||
if self._worker:
|
||||
self._worker.stop()
|
||||
self.log_signal.emit("正在停止任务...")
|
||||
self.stop_btn.setEnabled(False)
|
||||
136
ui/constants.py
Normal file
136
ui/constants.py
Normal file
@@ -0,0 +1,136 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
UI Design Constants - 统一UI规范
|
||||
老王说:统一规范才能不出SB界面,这些数字都是精心调教过的,别tm乱改!
|
||||
"""
|
||||
|
||||
# ==================== 页面布局 ====================
|
||||
# 内容区域到边框的距离
|
||||
PAGE_PADDING = 24
|
||||
|
||||
# 主要区块之间的间距
|
||||
SECTION_SPACING = 24
|
||||
|
||||
# ==================== GroupBox 分组框 ====================
|
||||
# GroupBox 内部边距
|
||||
# 注意:QSS中已经设置了padding-top: 24px给title badge留空间
|
||||
# 这里只需要设置很小的值或0,避免双重间距
|
||||
GROUP_PADDING_TOP = 8 # QSS padding-top已处理主要间距,这里只做微调
|
||||
GROUP_PADDING_SIDE = 20
|
||||
GROUP_PADDING_BOTTOM = 20
|
||||
|
||||
# GroupBox 内部组件间距
|
||||
GROUP_SPACING = 16
|
||||
|
||||
# ==================== 表单布局 ====================
|
||||
# 表单行间距
|
||||
FORM_ROW_SPACING = 16
|
||||
|
||||
# 标签最小宽度(右对齐用)
|
||||
FORM_LABEL_WIDTH = 80
|
||||
|
||||
# 水平组件间距(同一行内)
|
||||
FORM_H_SPACING = 12
|
||||
|
||||
# ==================== 输入控件 ====================
|
||||
# 输入框标准高度
|
||||
INPUT_HEIGHT = 40
|
||||
|
||||
# 小号输入框高度
|
||||
INPUT_HEIGHT_SMALL = 36
|
||||
|
||||
# 输入框最小宽度
|
||||
INPUT_MIN_WIDTH = 180
|
||||
|
||||
# 短输入框宽度(如列名A/B/C)
|
||||
INPUT_WIDTH_SHORT = 70
|
||||
|
||||
# ==================== 按钮 ====================
|
||||
# 主要按钮高度
|
||||
BUTTON_HEIGHT = 40
|
||||
|
||||
# 普通按钮高度
|
||||
BUTTON_HEIGHT_NORMAL = 36
|
||||
|
||||
# 小按钮高度
|
||||
BUTTON_HEIGHT_SMALL = 32
|
||||
|
||||
# 主要按钮最小宽度
|
||||
BUTTON_MIN_WIDTH = 120
|
||||
|
||||
# 普通按钮最小宽度
|
||||
BUTTON_MIN_WIDTH_NORMAL = 100
|
||||
|
||||
# 小按钮宽度
|
||||
BUTTON_WIDTH_SMALL = 65
|
||||
|
||||
# 按钮间距
|
||||
BUTTON_SPACING = 12
|
||||
|
||||
# ==================== 列表和表格 ====================
|
||||
# 表格行高
|
||||
TABLE_ROW_HEIGHT = 52
|
||||
|
||||
# 列表项最小高度
|
||||
LIST_ITEM_HEIGHT = 44
|
||||
|
||||
# 列表最小显示高度
|
||||
LIST_MIN_HEIGHT = 160
|
||||
|
||||
# 图标大小
|
||||
ICON_SIZE_LARGE = 80
|
||||
ICON_SIZE_MEDIUM = 48
|
||||
ICON_SIZE_SMALL = 24
|
||||
|
||||
# ==================== 标题 ====================
|
||||
# 页面标题字号
|
||||
TITLE_FONT_SIZE = 20
|
||||
|
||||
# 副标题字号
|
||||
SUBTITLE_FONT_SIZE = 16
|
||||
|
||||
# 正文字号
|
||||
TEXT_FONT_SIZE = 13
|
||||
|
||||
# 小字字号
|
||||
TEXT_FONT_SIZE_SMALL = 12
|
||||
|
||||
# 标题下方间距
|
||||
TITLE_MARGIN_BOTTOM = 16
|
||||
|
||||
# ==================== 特殊组件 ====================
|
||||
# 二维码显示区域大小
|
||||
QR_CODE_SIZE = 200
|
||||
|
||||
# 进度条高度
|
||||
PROGRESS_HEIGHT = 20
|
||||
|
||||
# 日志区域最小高度
|
||||
LOG_MIN_HEIGHT = 120
|
||||
|
||||
# ==================== 滚动区域 ====================
|
||||
# 滚动条宽度
|
||||
SCROLLBAR_WIDTH = 10
|
||||
|
||||
|
||||
# ==================== 样式片段 ====================
|
||||
def get_title_style():
|
||||
"""页面标题样式"""
|
||||
return f"font-size: {TITLE_FONT_SIZE}px; font-weight: bold; margin-bottom: {TITLE_MARGIN_BOTTOM}px;"
|
||||
|
||||
|
||||
def get_subtitle_style():
|
||||
"""副标题样式"""
|
||||
return f"font-size: {SUBTITLE_FONT_SIZE}px; font-weight: 600;"
|
||||
|
||||
|
||||
def get_help_text_style():
|
||||
"""帮助文本样式"""
|
||||
return f"color: #666; font-size: {TEXT_FONT_SIZE_SMALL}px; padding: 8px 0;"
|
||||
|
||||
|
||||
def get_status_style(success: bool):
|
||||
"""状态文本样式"""
|
||||
color = "#52c41a" if success else "#ff4d4f"
|
||||
return f"color: {color}; font-size: {TEXT_FONT_SIZE}px;"
|
||||
650
ui/kdocs_widget.py
Normal file
650
ui/kdocs_widget.py
Normal file
@@ -0,0 +1,650 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
KDocs panel - configure doc URL, columns, QR login, manual upload
|
||||
"""
|
||||
|
||||
import base64
|
||||
from PyQt6.QtWidgets import (
|
||||
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit,
|
||||
QPushButton, QGroupBox, QSpinBox, QCheckBox,
|
||||
QMessageBox, QScrollArea, QFrame
|
||||
)
|
||||
from PyQt6.QtCore import Qt, pyqtSignal, QTimer
|
||||
from PyQt6.QtGui import QPixmap
|
||||
|
||||
from config import get_config, save_config
|
||||
from .constants import (
|
||||
PAGE_PADDING, SECTION_SPACING, GROUP_PADDING_TOP, GROUP_PADDING_SIDE,
|
||||
GROUP_PADDING_BOTTOM, GROUP_SPACING, FORM_ROW_SPACING, FORM_LABEL_WIDTH,
|
||||
FORM_H_SPACING, INPUT_HEIGHT, INPUT_MIN_WIDTH, INPUT_WIDTH_SHORT,
|
||||
BUTTON_HEIGHT, BUTTON_HEIGHT_NORMAL, BUTTON_MIN_WIDTH, BUTTON_SPACING,
|
||||
QR_CODE_SIZE, get_title_style, get_status_style
|
||||
)
|
||||
|
||||
|
||||
class KDocsWidget(QWidget):
|
||||
"""KDocs panel"""
|
||||
|
||||
log_signal = pyqtSignal(str)
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self._worker = None
|
||||
self._login_check_timer = None
|
||||
self._setup_ui()
|
||||
self._load_config()
|
||||
self._check_wechat_process() # 检测微信进程
|
||||
|
||||
def _setup_ui(self):
|
||||
main_layout = QVBoxLayout(self)
|
||||
main_layout.setContentsMargins(16, 16, 16, 16)
|
||||
main_layout.setSpacing(16)
|
||||
|
||||
# ==================== Title + Save button ====================
|
||||
header_layout = QHBoxLayout()
|
||||
title = QLabel("📤 金山文档上传")
|
||||
title.setStyleSheet(get_title_style())
|
||||
header_layout.addWidget(title)
|
||||
|
||||
header_layout.addStretch()
|
||||
|
||||
self.enable_check = QCheckBox("启用上传")
|
||||
self.enable_check.stateChanged.connect(self._on_enable_changed)
|
||||
header_layout.addWidget(self.enable_check)
|
||||
|
||||
self.save_btn = QPushButton("保存配置")
|
||||
self.save_btn.setObjectName("primary")
|
||||
self.save_btn.setFixedHeight(36)
|
||||
self.save_btn.setMinimumWidth(100)
|
||||
self.save_btn.clicked.connect(self._save_config)
|
||||
header_layout.addWidget(self.save_btn)
|
||||
|
||||
main_layout.addLayout(header_layout)
|
||||
|
||||
# ==================== Document config ====================
|
||||
config_group = QGroupBox("文档配置")
|
||||
config_layout = QVBoxLayout(config_group)
|
||||
config_layout.setContentsMargins(16, 24, 16, 16)
|
||||
config_layout.setSpacing(12)
|
||||
|
||||
# Row 1: Doc URL
|
||||
row1 = QHBoxLayout()
|
||||
row1.setSpacing(10)
|
||||
lbl1 = QLabel("文档链接:")
|
||||
lbl1.setFixedWidth(70)
|
||||
row1.addWidget(lbl1)
|
||||
self.doc_url_edit = QLineEdit()
|
||||
self.doc_url_edit.setPlaceholderText("https://kdocs.cn/l/xxxxxx")
|
||||
self.doc_url_edit.setMinimumHeight(36)
|
||||
row1.addWidget(self.doc_url_edit, 1)
|
||||
config_layout.addLayout(row1)
|
||||
|
||||
# Row 2: Sheet name + Unit
|
||||
row2 = QHBoxLayout()
|
||||
row2.setSpacing(10)
|
||||
lbl2 = QLabel("工作表:")
|
||||
lbl2.setFixedWidth(70)
|
||||
row2.addWidget(lbl2)
|
||||
self.sheet_name_edit = QLineEdit()
|
||||
self.sheet_name_edit.setPlaceholderText("Sheet1")
|
||||
self.sheet_name_edit.setMinimumHeight(36)
|
||||
self.sheet_name_edit.setFixedWidth(150)
|
||||
row2.addWidget(self.sheet_name_edit)
|
||||
row2.addSpacing(30)
|
||||
lbl2b = QLabel("所属县区:")
|
||||
lbl2b.setFixedWidth(70)
|
||||
row2.addWidget(lbl2b)
|
||||
self.unit_edit = QLineEdit()
|
||||
self.unit_edit.setPlaceholderText("如:XX县")
|
||||
self.unit_edit.setMinimumHeight(36)
|
||||
row2.addWidget(self.unit_edit, 1)
|
||||
config_layout.addLayout(row2)
|
||||
|
||||
# Row 3: Columns
|
||||
row3 = QHBoxLayout()
|
||||
row3.setSpacing(10)
|
||||
lbl3 = QLabel("县区列:")
|
||||
lbl3.setFixedWidth(70)
|
||||
row3.addWidget(lbl3)
|
||||
self.unit_col_edit = QLineEdit()
|
||||
self.unit_col_edit.setFixedWidth(80)
|
||||
self.unit_col_edit.setMinimumHeight(36)
|
||||
self.unit_col_edit.setPlaceholderText("A")
|
||||
row3.addWidget(self.unit_col_edit)
|
||||
row3.addSpacing(20)
|
||||
lbl3b = QLabel("姓名列:")
|
||||
lbl3b.setFixedWidth(60)
|
||||
row3.addWidget(lbl3b)
|
||||
self.name_col_edit = QLineEdit()
|
||||
self.name_col_edit.setFixedWidth(80)
|
||||
self.name_col_edit.setMinimumHeight(36)
|
||||
self.name_col_edit.setPlaceholderText("C")
|
||||
row3.addWidget(self.name_col_edit)
|
||||
row3.addSpacing(20)
|
||||
lbl3c = QLabel("图片列:")
|
||||
lbl3c.setFixedWidth(60)
|
||||
row3.addWidget(lbl3c)
|
||||
self.image_col_edit = QLineEdit()
|
||||
self.image_col_edit.setFixedWidth(80)
|
||||
self.image_col_edit.setMinimumHeight(36)
|
||||
self.image_col_edit.setPlaceholderText("D")
|
||||
row3.addWidget(self.image_col_edit)
|
||||
row3.addStretch()
|
||||
config_layout.addLayout(row3)
|
||||
|
||||
# Row 4: Row range - SpinBox加宽确保"不限制"显示完整
|
||||
row4 = QHBoxLayout()
|
||||
row4.setSpacing(10)
|
||||
lbl4 = QLabel("起始行:")
|
||||
lbl4.setFixedWidth(70)
|
||||
row4.addWidget(lbl4)
|
||||
self.row_start_spin = QSpinBox()
|
||||
self.row_start_spin.setRange(0, 10000)
|
||||
self.row_start_spin.setSpecialValueText("不限制")
|
||||
self.row_start_spin.setFixedWidth(120)
|
||||
self.row_start_spin.setMinimumHeight(36)
|
||||
row4.addWidget(self.row_start_spin)
|
||||
row4.addSpacing(20)
|
||||
lbl4b = QLabel("结束行:")
|
||||
lbl4b.setFixedWidth(60)
|
||||
row4.addWidget(lbl4b)
|
||||
self.row_end_spin = QSpinBox()
|
||||
self.row_end_spin.setRange(0, 10000)
|
||||
self.row_end_spin.setSpecialValueText("不限制")
|
||||
self.row_end_spin.setFixedWidth(120)
|
||||
self.row_end_spin.setMinimumHeight(36)
|
||||
row4.addWidget(self.row_end_spin)
|
||||
row4.addStretch()
|
||||
config_layout.addLayout(row4)
|
||||
|
||||
main_layout.addWidget(config_group)
|
||||
|
||||
# ==================== Login status ====================
|
||||
login_group = QGroupBox("登录状态")
|
||||
login_layout = QHBoxLayout(login_group)
|
||||
login_layout.setContentsMargins(16, 24, 16, 16)
|
||||
login_layout.setSpacing(20)
|
||||
|
||||
# QR code
|
||||
self.qr_label = QLabel()
|
||||
self.qr_label.setFixedSize(150, 150)
|
||||
self.qr_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self.qr_label.setStyleSheet("border: 1px solid #d0d0d0; border-radius: 6px; background: #fafafa;")
|
||||
self.qr_label.setText("点击获取二维码")
|
||||
login_layout.addWidget(self.qr_label)
|
||||
|
||||
# Status and buttons
|
||||
status_btn_layout = QVBoxLayout()
|
||||
status_btn_layout.setSpacing(10)
|
||||
|
||||
self.status_label = QLabel("未登录")
|
||||
self.status_label.setStyleSheet(get_status_style(False))
|
||||
status_btn_layout.addWidget(self.status_label)
|
||||
|
||||
status_btn_layout.addStretch()
|
||||
|
||||
self.get_qr_btn = QPushButton("获取二维码")
|
||||
self.get_qr_btn.setObjectName("primary")
|
||||
self.get_qr_btn.setFixedHeight(36)
|
||||
self.get_qr_btn.setMinimumWidth(120)
|
||||
self.get_qr_btn.clicked.connect(self._get_qr_code)
|
||||
status_btn_layout.addWidget(self.get_qr_btn)
|
||||
|
||||
# 快捷登录按钮(检测到微信进程时显示)
|
||||
self.quick_login_btn = QPushButton("⚡ 微信快捷登录")
|
||||
self.quick_login_btn.setFixedHeight(36)
|
||||
self.quick_login_btn.setMinimumWidth(130)
|
||||
self.quick_login_btn.setStyleSheet("""
|
||||
QPushButton {
|
||||
background-color: #07C160;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-weight: bold;
|
||||
}
|
||||
QPushButton:hover {
|
||||
background-color: #06AD56;
|
||||
}
|
||||
QPushButton:disabled {
|
||||
background-color: #ccc;
|
||||
}
|
||||
""")
|
||||
self.quick_login_btn.clicked.connect(self._quick_login)
|
||||
self.quick_login_btn.setVisible(False) # 默认隐藏
|
||||
status_btn_layout.addWidget(self.quick_login_btn)
|
||||
|
||||
self.clear_login_btn = QPushButton("清除登录")
|
||||
self.clear_login_btn.setFixedHeight(36)
|
||||
self.clear_login_btn.setMinimumWidth(120)
|
||||
self.clear_login_btn.clicked.connect(self._clear_login)
|
||||
status_btn_layout.addWidget(self.clear_login_btn)
|
||||
|
||||
status_btn_layout.addStretch()
|
||||
|
||||
login_layout.addLayout(status_btn_layout)
|
||||
login_layout.addStretch()
|
||||
|
||||
main_layout.addWidget(login_group)
|
||||
main_layout.addStretch()
|
||||
|
||||
def _load_config(self):
|
||||
"""Load config"""
|
||||
config = get_config()
|
||||
kdocs = config.kdocs
|
||||
|
||||
self.enable_check.setChecked(kdocs.enabled)
|
||||
self.doc_url_edit.setText(kdocs.doc_url)
|
||||
self.sheet_name_edit.setText(kdocs.sheet_name)
|
||||
self.unit_col_edit.setText(kdocs.unit_column)
|
||||
self.name_col_edit.setText(kdocs.name_column)
|
||||
self.image_col_edit.setText(kdocs.image_column)
|
||||
self.row_start_spin.setValue(kdocs.row_start)
|
||||
self.row_end_spin.setValue(kdocs.row_end)
|
||||
self.unit_edit.setText(kdocs.unit)
|
||||
|
||||
self._on_enable_changed(Qt.CheckState.Checked.value if kdocs.enabled else Qt.CheckState.Unchecked.value)
|
||||
|
||||
def _save_config(self):
|
||||
"""Save config"""
|
||||
config = get_config()
|
||||
|
||||
config.kdocs.enabled = self.enable_check.isChecked()
|
||||
config.kdocs.doc_url = self.doc_url_edit.text().strip()
|
||||
config.kdocs.sheet_name = self.sheet_name_edit.text().strip()
|
||||
config.kdocs.unit_column = self.unit_col_edit.text().strip().upper() or "A"
|
||||
config.kdocs.name_column = self.name_col_edit.text().strip().upper() or "C"
|
||||
config.kdocs.image_column = self.image_col_edit.text().strip().upper() or "D"
|
||||
config.kdocs.row_start = self.row_start_spin.value()
|
||||
config.kdocs.row_end = self.row_end_spin.value()
|
||||
config.kdocs.unit = self.unit_edit.text().strip()
|
||||
|
||||
save_config(config)
|
||||
self.log_signal.emit("金山文档配置已保存")
|
||||
QMessageBox.information(self, "提示", "配置已保存")
|
||||
|
||||
def _on_enable_changed(self, state):
|
||||
"""Enable state changed"""
|
||||
pass # Can disable/enable other controls if needed
|
||||
|
||||
def _get_qr_code(self):
|
||||
"""Get login QR code and poll for login status"""
|
||||
self.get_qr_btn.setEnabled(False)
|
||||
self.qr_label.setText("获取中...")
|
||||
self.log_signal.emit("正在获取金山文档登录二维码...")
|
||||
|
||||
# 停止之前的检查
|
||||
if self._login_check_timer:
|
||||
self._login_check_timer.stop()
|
||||
self._login_check_timer = None
|
||||
|
||||
from utils.worker import Worker
|
||||
|
||||
def get_qr_and_wait_login(_signals=None, _should_stop=None):
|
||||
"""获取二维码并轮询等待登录(在同一个线程中完成所有Playwright操作)"""
|
||||
from core.kdocs_uploader import get_kdocs_uploader
|
||||
import time
|
||||
|
||||
uploader = get_kdocs_uploader()
|
||||
uploader._log_callback = lambda msg: _signals.log.emit(msg) if _signals else None
|
||||
|
||||
# 1. 获取二维码
|
||||
result = uploader.request_qr(force=True)
|
||||
if not result.get("success"):
|
||||
return result
|
||||
|
||||
if result.get("logged_in"):
|
||||
return result
|
||||
|
||||
# 2. 发送二维码图片(通过信号)
|
||||
qr_image = result.get("qr_image", "")
|
||||
if qr_image:
|
||||
_signals.screenshot_ready.emit(qr_image) # 复用这个信号传递二维码
|
||||
|
||||
# 3. 在同一个线程中轮询检查登录状态
|
||||
max_wait = 120 # 最多等待120秒
|
||||
check_interval = 3 # 每3秒检查一次
|
||||
waited = 0
|
||||
check_count = 0
|
||||
|
||||
while waited < max_wait:
|
||||
if _should_stop and _should_stop():
|
||||
return {"success": False, "error": "用户取消"}
|
||||
|
||||
time.sleep(check_interval)
|
||||
waited += check_interval
|
||||
check_count += 1
|
||||
|
||||
# 检查登录状态(在同一个线程中,不会有线程问题)
|
||||
if _signals:
|
||||
_signals.log.emit(f"[KDocs] 检查登录状态... ({check_count})")
|
||||
check_result = uploader.check_login_status()
|
||||
if check_result.get("logged_in"):
|
||||
return {"success": True, "logged_in": True}
|
||||
|
||||
# 每30秒提醒一下用户
|
||||
if waited % 30 == 0 and _signals:
|
||||
remaining = max_wait - waited
|
||||
_signals.log.emit(f"[KDocs] 等待扫码登录...(剩余{remaining}秒)")
|
||||
|
||||
return {"success": False, "error": "登录超时(120秒),请重新获取二维码"}
|
||||
|
||||
def on_qr_ready(qr_base64):
|
||||
"""二维码准备好了,显示出来"""
|
||||
if qr_base64:
|
||||
qr_bytes = base64.b64decode(qr_base64)
|
||||
pixmap = QPixmap()
|
||||
pixmap.loadFromData(qr_bytes)
|
||||
scaled = pixmap.scaled(140, 140, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation)
|
||||
self.qr_label.setPixmap(scaled)
|
||||
self.log_signal.emit("请使用微信扫描二维码登录(120秒内有效)")
|
||||
|
||||
def on_result(result):
|
||||
self.get_qr_btn.setEnabled(True)
|
||||
if result and result.get("success"):
|
||||
if result.get("logged_in"):
|
||||
self.status_label.setText("✅ 已登录")
|
||||
self.status_label.setStyleSheet(get_status_style(True))
|
||||
self.qr_label.setText("登录成功!")
|
||||
self.log_signal.emit("金山文档登录成功")
|
||||
else:
|
||||
# 不应该走到这里,因为上面会返回logged_in=True
|
||||
pass
|
||||
else:
|
||||
error = result.get("error", "未知错误") if result else "未知错误"
|
||||
self.qr_label.setText(f"失败: {error}")
|
||||
self.status_label.setText("❌ 未登录")
|
||||
self.status_label.setStyleSheet(get_status_style(False))
|
||||
self.log_signal.emit(f"登录失败: {error}")
|
||||
|
||||
def on_error(error):
|
||||
self.get_qr_btn.setEnabled(True)
|
||||
self.qr_label.setText(f"错误: {error}")
|
||||
self.log_signal.emit(f"获取二维码出错: {error}")
|
||||
|
||||
self._worker = Worker(get_qr_and_wait_login)
|
||||
self._worker.signals.log.connect(self.log_signal.emit)
|
||||
self._worker.signals.screenshot_ready.connect(on_qr_ready) # 用于接收二维码
|
||||
self._worker.signals.result.connect(on_result)
|
||||
self._worker.signals.error.connect(on_error)
|
||||
self._worker.start()
|
||||
|
||||
def _clear_login(self):
|
||||
"""Clear login status"""
|
||||
from core.kdocs_uploader import get_kdocs_uploader
|
||||
uploader = get_kdocs_uploader()
|
||||
uploader.clear_login()
|
||||
|
||||
self.status_label.setText("❌ 未登录")
|
||||
self.status_label.setStyleSheet(get_status_style(False))
|
||||
self.qr_label.setText("点击获取二维码")
|
||||
self.log_signal.emit("金山文档登录状态已清除")
|
||||
|
||||
if self._login_check_timer:
|
||||
self._login_check_timer.stop()
|
||||
self._login_check_timer = None
|
||||
|
||||
def _check_wechat_process(self) -> bool:
|
||||
"""检测微信进程是否运行
|
||||
|
||||
Returns:
|
||||
bool: 微信是否在运行
|
||||
"""
|
||||
import subprocess
|
||||
try:
|
||||
# Windows下检测微信进程(可能是Weixin.exe或WeChat.exe)
|
||||
result = subprocess.run(
|
||||
['tasklist'],
|
||||
capture_output=True, text=True, timeout=5
|
||||
)
|
||||
output_lower = result.stdout.lower()
|
||||
# 检测多种可能的微信进程名
|
||||
is_running = 'weixin.exe' in output_lower or 'wechat.exe' in output_lower
|
||||
if is_running:
|
||||
self.log_signal.emit("检测到微信进程,可使用快捷登录")
|
||||
# 始终显示快捷登录按钮,让用户自己决定
|
||||
self.quick_login_btn.setVisible(True)
|
||||
return is_running
|
||||
except Exception:
|
||||
# 检测失败也显示按钮
|
||||
self.quick_login_btn.setVisible(True)
|
||||
return False
|
||||
|
||||
def _quick_login(self):
|
||||
"""微信快捷登录"""
|
||||
# 先检测微信是否运行
|
||||
is_wechat_running = self._check_wechat_process()
|
||||
if not is_wechat_running:
|
||||
self.log_signal.emit("⚠️ 未检测到微信进程,快捷登录可能失败。建议先打开微信客户端。")
|
||||
|
||||
self.quick_login_btn.setEnabled(False)
|
||||
self.quick_login_btn.setText("登录中...")
|
||||
self.log_signal.emit("正在执行微信快捷登录...")
|
||||
|
||||
from utils.worker import Worker
|
||||
|
||||
def do_quick_login(_signals=None, _should_stop=None):
|
||||
from core.kdocs_uploader import get_kdocs_uploader
|
||||
import time
|
||||
|
||||
uploader = get_kdocs_uploader()
|
||||
uploader._log_callback = lambda msg: _signals.log.emit(msg) if _signals else None
|
||||
|
||||
# 强制重新登录,使用快捷登录
|
||||
from config import KDOCS_LOGIN_STATE_FILE
|
||||
try:
|
||||
if KDOCS_LOGIN_STATE_FILE.exists():
|
||||
KDOCS_LOGIN_STATE_FILE.unlink()
|
||||
except Exception:
|
||||
pass
|
||||
uploader._cleanup_browser()
|
||||
|
||||
# 启动浏览器并执行快捷登录
|
||||
if not uploader._ensure_playwright(use_storage_state=False):
|
||||
return {"success": False, "error": uploader._last_error or "浏览器不可用"}
|
||||
|
||||
from config import get_config
|
||||
config = get_config()
|
||||
doc_url = config.kdocs.doc_url.strip()
|
||||
if not doc_url:
|
||||
return {"success": False, "error": "未配置金山文档链接"}
|
||||
|
||||
if not uploader._open_document(doc_url):
|
||||
return {"success": False, "error": uploader._last_error or "打开文档失败"}
|
||||
|
||||
# 执行登录流程,优先使用微信快捷登录
|
||||
agree_names = ["同意", "同意并继续", "我同意", "确定", "确认"]
|
||||
|
||||
for loop_idx in range(15):
|
||||
current_url = uploader._page.url
|
||||
uploader.log(f"[KDocs] 循环{loop_idx+1}: URL={current_url[:60]}...")
|
||||
|
||||
# 【优先】检查是否有"登录并加入编辑"按钮(可能在任何页面弹出)
|
||||
try:
|
||||
join_btn = uploader._page.get_by_role("button", name="登录并加入编辑")
|
||||
btn_count = join_btn.count()
|
||||
if btn_count > 0:
|
||||
uploader.log(f"[KDocs] 找到'登录并加入编辑'按钮,直接点击")
|
||||
join_btn.first.click(timeout=3000)
|
||||
time.sleep(2)
|
||||
continue
|
||||
except Exception as e:
|
||||
uploader.log(f"[KDocs] 点击'登录并加入编辑'失败: {e}")
|
||||
|
||||
# 检查是否已到达文档页面(且没有登录相关元素)
|
||||
if "kdocs.cn/l/" in current_url and "account.wps.cn" not in current_url:
|
||||
uploader.log(f"[KDocs] URL是文档页面,检查是否真的登录成功...")
|
||||
time.sleep(1) # 等待页面稳定
|
||||
|
||||
# 检查页面上是否还有登录相关元素
|
||||
has_login_elements = False
|
||||
|
||||
# 检查是否有"立即登录"按钮
|
||||
try:
|
||||
login_btn = uploader._page.get_by_role("button", name="立即登录")
|
||||
if login_btn.count() > 0 and login_btn.first.is_visible(timeout=500):
|
||||
uploader.log("[KDocs] 页面上有'立即登录'按钮,未真正登录")
|
||||
has_login_elements = True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 检查是否有二维码(说明还在登录页)
|
||||
if not has_login_elements:
|
||||
try:
|
||||
qr_text = uploader._page.get_by_text("微信扫码登录", exact=False)
|
||||
if qr_text.count() > 0 and qr_text.first.is_visible(timeout=500):
|
||||
uploader.log("[KDocs] 页面上有'微信扫码登录',未真正登录")
|
||||
has_login_elements = True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 检查是否有"微信快捷登录"按钮
|
||||
if not has_login_elements:
|
||||
try:
|
||||
quick_btn = uploader._page.get_by_text("微信快捷登录", exact=False)
|
||||
if quick_btn.count() > 0 and quick_btn.first.is_visible(timeout=500):
|
||||
uploader.log("[KDocs] 页面上有'微信快捷登录'按钮,未真正登录")
|
||||
has_login_elements = True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if has_login_elements:
|
||||
uploader.log("[KDocs] 检测到登录元素,继续处理登录流程...")
|
||||
# 不return,继续下面的登录处理
|
||||
else:
|
||||
uploader.log("[KDocs] 确认登录成功!")
|
||||
uploader._logged_in = True
|
||||
uploader._save_login_state()
|
||||
return {"success": True, "logged_in": True}
|
||||
|
||||
clicked = False
|
||||
|
||||
# 点击同意按钮
|
||||
for name in agree_names:
|
||||
try:
|
||||
btn = uploader._page.get_by_role("button", name=name)
|
||||
if btn.count() > 0 and btn.first.is_visible(timeout=300):
|
||||
uploader.log(f"[KDocs] 点击同意: {name}")
|
||||
btn.first.click()
|
||||
time.sleep(1)
|
||||
clicked = True
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
if clicked:
|
||||
continue
|
||||
|
||||
# 检查页面上是否有"微信快捷登录"按钮(可能URL是kdocs但内容是登录页)
|
||||
has_quick_login_btn = False
|
||||
try:
|
||||
quick_btn = uploader._page.get_by_text("微信快捷登录", exact=False)
|
||||
if quick_btn.count() > 0 and quick_btn.first.is_visible(timeout=300):
|
||||
has_quick_login_btn = True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 在登录页面,先勾选协议复选框,再点击微信快捷登录
|
||||
# 条件:URL是account.wps.cn 或者 页面上有微信快捷登录按钮
|
||||
if "account.wps.cn" in current_url or has_quick_login_btn:
|
||||
uploader.log("[KDocs] 检测到登录页面,尝试勾选协议...")
|
||||
# 1. 先勾选"我已阅读并同意"复选框
|
||||
try:
|
||||
# 尝试多种方式找到协议复选框
|
||||
checkbox_selectors = [
|
||||
"input[type='checkbox']",
|
||||
"span.checkbox",
|
||||
"[class*='checkbox']",
|
||||
"[class*='agree']",
|
||||
]
|
||||
checkbox_clicked = False
|
||||
for selector in checkbox_selectors:
|
||||
try:
|
||||
checkboxes = uploader._page.locator(selector)
|
||||
if checkboxes.count() > 0:
|
||||
for i in range(min(checkboxes.count(), 3)):
|
||||
cb = checkboxes.nth(i)
|
||||
if cb.is_visible(timeout=200):
|
||||
# 检查是否已勾选
|
||||
is_checked = cb.is_checked() if hasattr(cb, 'is_checked') else False
|
||||
if not is_checked:
|
||||
uploader.log("[KDocs] 勾选协议复选框")
|
||||
cb.click()
|
||||
time.sleep(0.5)
|
||||
checkbox_clicked = True
|
||||
break
|
||||
if checkbox_clicked:
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 也尝试点击包含"我已阅读"的文本区域
|
||||
if not checkbox_clicked:
|
||||
try:
|
||||
agree_text = uploader._page.get_by_text("我已阅读并同意", exact=False)
|
||||
if agree_text.count() > 0 and agree_text.first.is_visible(timeout=300):
|
||||
uploader.log("[KDocs] 点击协议文本区域")
|
||||
agree_text.first.click()
|
||||
time.sleep(0.5)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
uploader.log(f"[KDocs] 勾选协议失败: {e}")
|
||||
|
||||
# 2. 点击微信快捷登录按钮
|
||||
try:
|
||||
quick = uploader._page.get_by_text("微信快捷登录", exact=False)
|
||||
if quick.count() > 0 and quick.first.is_visible(timeout=500):
|
||||
uploader.log("[KDocs] 点击微信快捷登录")
|
||||
quick.first.click()
|
||||
time.sleep(4) # 等待快捷登录完成
|
||||
clicked = True
|
||||
continue
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 点击立即登录
|
||||
try:
|
||||
btn = uploader._page.get_by_role("button", name="立即登录")
|
||||
if btn.count() > 0 and btn.first.is_visible(timeout=500):
|
||||
uploader.log("[KDocs] 点击立即登录")
|
||||
btn.first.click()
|
||||
time.sleep(2)
|
||||
clicked = True
|
||||
continue
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not clicked:
|
||||
break
|
||||
|
||||
# 最终检查
|
||||
if "kdocs.cn/l/" in uploader._page.url:
|
||||
uploader._logged_in = True
|
||||
uploader._save_login_state()
|
||||
return {"success": True, "logged_in": True}
|
||||
else:
|
||||
return {"success": False, "error": "快捷登录失败,请尝试扫码登录"}
|
||||
|
||||
def on_result(result):
|
||||
self.quick_login_btn.setEnabled(True)
|
||||
self.quick_login_btn.setText("⚡ 微信快捷登录")
|
||||
if result and result.get("success") and result.get("logged_in"):
|
||||
self.status_label.setText("✅ 已登录")
|
||||
self.status_label.setStyleSheet(get_status_style(True))
|
||||
self.qr_label.setText("快捷登录成功!")
|
||||
self.log_signal.emit("金山文档快捷登录成功")
|
||||
else:
|
||||
error = result.get("error", "未知错误") if result else "未知错误"
|
||||
self.log_signal.emit(f"快捷登录失败: {error}")
|
||||
|
||||
def on_error(error):
|
||||
self.quick_login_btn.setEnabled(True)
|
||||
self.quick_login_btn.setText("⚡ 微信快捷登录")
|
||||
self.log_signal.emit(f"快捷登录出错: {error}")
|
||||
|
||||
self._worker = Worker(do_quick_login)
|
||||
self._worker.signals.log.connect(self.log_signal.emit)
|
||||
self._worker.signals.result.connect(on_result)
|
||||
self._worker.signals.error.connect(on_error)
|
||||
self._worker.start()
|
||||
|
||||
120
ui/log_widget.py
Normal file
120
ui/log_widget.py
Normal file
@@ -0,0 +1,120 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Log display panel - collapsible log output area
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from PyQt6.QtWidgets import (
|
||||
QWidget, QVBoxLayout, QHBoxLayout, QPlainTextEdit,
|
||||
QPushButton, QFrame, QLabel
|
||||
)
|
||||
from PyQt6.QtCore import Qt, pyqtSlot
|
||||
from PyQt6.QtGui import QTextCursor
|
||||
|
||||
from .constants import LOG_MIN_HEIGHT, BUTTON_HEIGHT_SMALL, BUTTON_SPACING
|
||||
|
||||
|
||||
class LogWidget(QWidget):
|
||||
"""Log display panel"""
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setObjectName("log_widget")
|
||||
self._collapsed = False
|
||||
self._max_lines = 500
|
||||
self._setup_ui()
|
||||
|
||||
def _setup_ui(self):
|
||||
"""Setup UI"""
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(0)
|
||||
|
||||
# Header bar
|
||||
header = QFrame()
|
||||
header.setFixedHeight(36)
|
||||
header.setStyleSheet("background-color: rgba(0,0,0,0.03); border-bottom: 1px solid rgba(0,0,0,0.08);")
|
||||
header_layout = QHBoxLayout(header)
|
||||
header_layout.setContentsMargins(16, 0, 16, 0)
|
||||
header_layout.setSpacing(BUTTON_SPACING)
|
||||
|
||||
# Title
|
||||
title = QLabel("📜 日志输出")
|
||||
title.setStyleSheet("font-weight: bold; font-size: 13px;")
|
||||
header_layout.addWidget(title)
|
||||
|
||||
header_layout.addStretch()
|
||||
|
||||
# Clear button
|
||||
self._clear_btn = QPushButton("清空")
|
||||
self._clear_btn.setFixedWidth(60)
|
||||
self._clear_btn.setFixedHeight(BUTTON_HEIGHT_SMALL)
|
||||
self._clear_btn.clicked.connect(self.clear)
|
||||
header_layout.addWidget(self._clear_btn)
|
||||
|
||||
# Toggle button
|
||||
self._toggle_btn = QPushButton("▼")
|
||||
self._toggle_btn.setFixedWidth(36)
|
||||
self._toggle_btn.setFixedHeight(BUTTON_HEIGHT_SMALL)
|
||||
self._toggle_btn.clicked.connect(self._toggle_collapse)
|
||||
header_layout.addWidget(self._toggle_btn)
|
||||
|
||||
layout.addWidget(header)
|
||||
|
||||
# Log text area
|
||||
self._text_edit = QPlainTextEdit()
|
||||
self._text_edit.setReadOnly(True)
|
||||
self._text_edit.setMinimumHeight(LOG_MIN_HEIGHT)
|
||||
self._text_edit.setMaximumHeight(250)
|
||||
self._text_edit.setStyleSheet("""
|
||||
QPlainTextEdit {
|
||||
font-family: "Consolas", "Monaco", "Courier New", monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
""")
|
||||
layout.addWidget(self._text_edit)
|
||||
|
||||
def _toggle_collapse(self):
|
||||
"""Toggle collapse state"""
|
||||
self._collapsed = not self._collapsed
|
||||
self._text_edit.setVisible(not self._collapsed)
|
||||
self._toggle_btn.setText("▲" if self._collapsed else "▼")
|
||||
|
||||
@pyqtSlot(str)
|
||||
def append_log(self, message: str):
|
||||
"""Append log message"""
|
||||
timestamp = datetime.now().strftime("%H:%M:%S")
|
||||
formatted = f"[{timestamp}] {message}"
|
||||
|
||||
self._text_edit.appendPlainText(formatted)
|
||||
|
||||
# Limit lines
|
||||
doc = self._text_edit.document()
|
||||
if doc.blockCount() > self._max_lines:
|
||||
cursor = QTextCursor(doc)
|
||||
cursor.movePosition(QTextCursor.MoveOperation.Start)
|
||||
cursor.select(QTextCursor.SelectionType.BlockUnderCursor)
|
||||
cursor.movePosition(
|
||||
QTextCursor.MoveOperation.Down,
|
||||
QTextCursor.MoveMode.KeepAnchor,
|
||||
doc.blockCount() - self._max_lines
|
||||
)
|
||||
cursor.removeSelectedText()
|
||||
|
||||
# Scroll to bottom
|
||||
self._text_edit.moveCursor(QTextCursor.MoveOperation.End)
|
||||
|
||||
def log(self, message: str):
|
||||
"""Log alias"""
|
||||
self.append_log(message)
|
||||
|
||||
def clear(self):
|
||||
"""Clear log"""
|
||||
self._text_edit.clear()
|
||||
|
||||
def get_text(self) -> str:
|
||||
"""Get all log text"""
|
||||
return self._text_edit.toPlainText()
|
||||
195
ui/main_window.py
Normal file
195
ui/main_window.py
Normal file
@@ -0,0 +1,195 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
主窗口
|
||||
侧边栏导航 + 主内容区 + 日志输出区
|
||||
"""
|
||||
|
||||
from PyQt6.QtWidgets import (
|
||||
QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
|
||||
QPushButton, QStackedWidget, QFrame, QLabel, QSplitter
|
||||
)
|
||||
from PyQt6.QtCore import Qt
|
||||
|
||||
from .styles import get_stylesheet, LIGHT_THEME, DARK_THEME
|
||||
from .log_widget import LogWidget
|
||||
from .account_widget import AccountWidget
|
||||
from .browse_widget import BrowseWidget
|
||||
from .screenshot_widget import ScreenshotWidget
|
||||
from .kdocs_widget import KDocsWidget
|
||||
from .settings_widget import SettingsWidget
|
||||
|
||||
|
||||
class MainWindow(QMainWindow):
|
||||
"""主窗口"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.setWindowTitle("知识管理平台助手 - 精简版")
|
||||
self.setMinimumSize(1000, 700)
|
||||
self.resize(1200, 800)
|
||||
|
||||
self._current_theme = "light"
|
||||
self._setup_ui()
|
||||
self._apply_theme("light")
|
||||
|
||||
def _setup_ui(self):
|
||||
"""设置UI布局"""
|
||||
central_widget = QWidget()
|
||||
self.setCentralWidget(central_widget)
|
||||
|
||||
main_layout = QHBoxLayout(central_widget)
|
||||
main_layout.setContentsMargins(0, 0, 0, 0)
|
||||
main_layout.setSpacing(0)
|
||||
|
||||
# 侧边栏
|
||||
sidebar = self._create_sidebar()
|
||||
main_layout.addWidget(sidebar)
|
||||
|
||||
# 右侧区域(内容 + 日志)
|
||||
right_widget = QWidget()
|
||||
right_layout = QVBoxLayout(right_widget)
|
||||
right_layout.setContentsMargins(0, 0, 0, 0)
|
||||
right_layout.setSpacing(0)
|
||||
|
||||
# 使用分割器可以调整日志区域大小
|
||||
splitter = QSplitter(Qt.Orientation.Vertical)
|
||||
|
||||
# 主内容区
|
||||
self.content_stack = QStackedWidget()
|
||||
self._create_pages()
|
||||
splitter.addWidget(self.content_stack)
|
||||
|
||||
# 日志区域
|
||||
self.log_widget = LogWidget()
|
||||
splitter.addWidget(self.log_widget)
|
||||
|
||||
# 设置初始比例
|
||||
splitter.setSizes([600, 150])
|
||||
|
||||
right_layout.addWidget(splitter)
|
||||
main_layout.addWidget(right_widget, 1)
|
||||
|
||||
# 连接日志信号
|
||||
self._connect_log_signals()
|
||||
|
||||
def _create_sidebar(self) -> QWidget:
|
||||
"""创建侧边栏"""
|
||||
sidebar = QFrame()
|
||||
sidebar.setObjectName("sidebar")
|
||||
sidebar.setFixedWidth(180)
|
||||
|
||||
layout = QVBoxLayout(sidebar)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(0)
|
||||
|
||||
# 标题
|
||||
title = QLabel("📋 知识管理助手")
|
||||
title.setObjectName("sidebar_title")
|
||||
title.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
layout.addWidget(title)
|
||||
|
||||
# 分隔线
|
||||
line = QFrame()
|
||||
line.setFrameShape(QFrame.Shape.HLine)
|
||||
line.setStyleSheet("background-color: rgba(255,255,255,0.1);")
|
||||
layout.addWidget(line)
|
||||
|
||||
# 导航按钮
|
||||
self._nav_buttons = []
|
||||
|
||||
nav_items = [
|
||||
("📝 账号管理", 0),
|
||||
("🔄 浏览任务", 1),
|
||||
("📸 截图管理", 2),
|
||||
("📤 金山文档", 3),
|
||||
("⚙️ 系统设置", 4),
|
||||
]
|
||||
|
||||
for text, index in nav_items:
|
||||
btn = QPushButton(text)
|
||||
btn.setCheckable(True)
|
||||
btn.clicked.connect(lambda checked, idx=index: self._switch_page(idx))
|
||||
layout.addWidget(btn)
|
||||
self._nav_buttons.append(btn)
|
||||
|
||||
layout.addStretch()
|
||||
|
||||
# 版本信息
|
||||
version_label = QLabel("v1.0.0")
|
||||
version_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
version_label.setStyleSheet("color: rgba(255,255,255,0.5); font-size: 11px; padding: 8px;")
|
||||
layout.addWidget(version_label)
|
||||
|
||||
# 默认选中第一个
|
||||
self._nav_buttons[0].setChecked(True)
|
||||
|
||||
return sidebar
|
||||
|
||||
def _create_pages(self):
|
||||
"""创建各个页面"""
|
||||
# 账号管理
|
||||
self.account_widget = AccountWidget()
|
||||
self.content_stack.addWidget(self.account_widget)
|
||||
|
||||
# 浏览任务
|
||||
self.browse_widget = BrowseWidget()
|
||||
self.content_stack.addWidget(self.browse_widget)
|
||||
|
||||
# 截图管理
|
||||
self.screenshot_widget = ScreenshotWidget()
|
||||
self.content_stack.addWidget(self.screenshot_widget)
|
||||
|
||||
# 金山文档
|
||||
self.kdocs_widget = KDocsWidget()
|
||||
self.content_stack.addWidget(self.kdocs_widget)
|
||||
|
||||
# 系统设置
|
||||
self.settings_widget = SettingsWidget()
|
||||
self.settings_widget.theme_changed.connect(self._apply_theme)
|
||||
self.content_stack.addWidget(self.settings_widget)
|
||||
|
||||
def _connect_log_signals(self):
|
||||
"""连接各模块的日志信号到日志面板"""
|
||||
self.account_widget.log_signal.connect(self.log_widget.append_log)
|
||||
self.browse_widget.log_signal.connect(self.log_widget.append_log)
|
||||
self.screenshot_widget.log_signal.connect(self.log_widget.append_log)
|
||||
self.kdocs_widget.log_signal.connect(self.log_widget.append_log)
|
||||
self.settings_widget.log_signal.connect(self.log_widget.append_log)
|
||||
|
||||
def _switch_page(self, index: int):
|
||||
"""切换页面"""
|
||||
# 更新导航按钮状态
|
||||
for i, btn in enumerate(self._nav_buttons):
|
||||
btn.setChecked(i == index)
|
||||
|
||||
# 切换页面
|
||||
self.content_stack.setCurrentIndex(index)
|
||||
|
||||
def _apply_theme(self, theme_name: str):
|
||||
"""应用主题"""
|
||||
self._current_theme = theme_name
|
||||
theme = LIGHT_THEME if theme_name == "light" else DARK_THEME
|
||||
self.setStyleSheet(get_stylesheet(theme))
|
||||
|
||||
def log(self, message: str):
|
||||
"""记录日志"""
|
||||
self.log_widget.append_log(message)
|
||||
|
||||
def closeEvent(self, event):
|
||||
"""窗口关闭事件"""
|
||||
# 保存配置
|
||||
from config import get_config, save_config
|
||||
config = get_config()
|
||||
config.theme = self._current_theme
|
||||
save_config(config)
|
||||
|
||||
# 清理金山文档上传器
|
||||
try:
|
||||
from core.kdocs_uploader import get_kdocs_uploader
|
||||
uploader = get_kdocs_uploader()
|
||||
uploader.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
event.accept()
|
||||
387
ui/screenshot_widget.py
Normal file
387
ui/screenshot_widget.py
Normal file
@@ -0,0 +1,387 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Screenshot management panel - manual screenshot, view history, open directory
|
||||
"""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from PyQt6.QtWidgets import (
|
||||
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton,
|
||||
QListWidget, QListWidgetItem, QComboBox, QGroupBox, QMessageBox,
|
||||
QFileDialog, QScrollArea, QFrame, QSizePolicy
|
||||
)
|
||||
from PyQt6.QtCore import Qt, pyqtSignal, QSize
|
||||
from PyQt6.QtGui import QPixmap, QIcon
|
||||
|
||||
from config import get_config, SCREENSHOTS_DIR
|
||||
from utils.crypto import decrypt_password, is_encrypted
|
||||
from .constants import (
|
||||
PAGE_PADDING, SECTION_SPACING, GROUP_PADDING_TOP, GROUP_PADDING_SIDE,
|
||||
GROUP_PADDING_BOTTOM, GROUP_SPACING, FORM_LABEL_WIDTH, FORM_H_SPACING,
|
||||
INPUT_HEIGHT, INPUT_MIN_WIDTH, BUTTON_HEIGHT, BUTTON_HEIGHT_NORMAL,
|
||||
BUTTON_MIN_WIDTH, BUTTON_SPACING, LIST_MIN_HEIGHT, ICON_SIZE_LARGE,
|
||||
get_title_style
|
||||
)
|
||||
|
||||
|
||||
class ScreenshotWidget(QWidget):
|
||||
"""Screenshot management panel"""
|
||||
|
||||
log_signal = pyqtSignal(str)
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self._worker = None
|
||||
self._setup_ui()
|
||||
self._refresh_screenshots()
|
||||
|
||||
def _setup_ui(self):
|
||||
main_layout = QVBoxLayout(self)
|
||||
main_layout.setContentsMargins(0, 0, 0, 0)
|
||||
main_layout.setSpacing(0)
|
||||
|
||||
# Scroll area
|
||||
scroll = QScrollArea()
|
||||
scroll.setWidgetResizable(True)
|
||||
scroll.setFrameShape(QFrame.Shape.NoFrame)
|
||||
scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||||
|
||||
content = QWidget()
|
||||
layout = QVBoxLayout(content)
|
||||
layout.setContentsMargins(PAGE_PADDING, PAGE_PADDING, PAGE_PADDING, PAGE_PADDING)
|
||||
layout.setSpacing(SECTION_SPACING)
|
||||
|
||||
# Title
|
||||
title = QLabel("📸 截图管理")
|
||||
title.setStyleSheet(get_title_style())
|
||||
layout.addWidget(title)
|
||||
|
||||
# ==================== Manual screenshot ====================
|
||||
manual_group = QGroupBox("手动截图")
|
||||
manual_layout = QVBoxLayout(manual_group)
|
||||
manual_layout.setContentsMargins(GROUP_PADDING_SIDE, GROUP_PADDING_TOP, GROUP_PADDING_SIDE, GROUP_PADDING_BOTTOM)
|
||||
manual_layout.setSpacing(GROUP_SPACING)
|
||||
|
||||
# Account row
|
||||
account_layout = QHBoxLayout()
|
||||
account_layout.setSpacing(FORM_H_SPACING)
|
||||
|
||||
account_label = QLabel("选择账号:")
|
||||
account_label.setFixedWidth(FORM_LABEL_WIDTH)
|
||||
account_layout.addWidget(account_label)
|
||||
|
||||
self.account_combo = QComboBox()
|
||||
self.account_combo.setMinimumWidth(INPUT_MIN_WIDTH + 100)
|
||||
self.account_combo.setMinimumHeight(INPUT_HEIGHT)
|
||||
account_layout.addWidget(self.account_combo)
|
||||
|
||||
refresh_account_btn = QPushButton("🔄 刷新")
|
||||
refresh_account_btn.setMinimumHeight(BUTTON_HEIGHT_NORMAL)
|
||||
refresh_account_btn.clicked.connect(self._refresh_accounts)
|
||||
account_layout.addWidget(refresh_account_btn)
|
||||
|
||||
account_layout.addStretch()
|
||||
manual_layout.addLayout(account_layout)
|
||||
|
||||
# Browse type row
|
||||
type_layout = QHBoxLayout()
|
||||
type_layout.setSpacing(FORM_H_SPACING)
|
||||
|
||||
type_label = QLabel("浏览类型:")
|
||||
type_label.setFixedWidth(FORM_LABEL_WIDTH)
|
||||
type_layout.addWidget(type_label)
|
||||
|
||||
self.browse_type_combo = QComboBox()
|
||||
self.browse_type_combo.addItems(["应读", "注册前未读"])
|
||||
self.browse_type_combo.setMinimumWidth(INPUT_MIN_WIDTH)
|
||||
self.browse_type_combo.setMinimumHeight(INPUT_HEIGHT)
|
||||
type_layout.addWidget(self.browse_type_combo)
|
||||
|
||||
type_layout.addStretch()
|
||||
manual_layout.addLayout(type_layout)
|
||||
|
||||
# Screenshot button
|
||||
btn_layout = QHBoxLayout()
|
||||
btn_layout.setSpacing(BUTTON_SPACING)
|
||||
|
||||
self.screenshot_btn = QPushButton("📷 执行截图")
|
||||
self.screenshot_btn.setObjectName("primary")
|
||||
self.screenshot_btn.setMinimumWidth(BUTTON_MIN_WIDTH)
|
||||
self.screenshot_btn.setMinimumHeight(BUTTON_HEIGHT)
|
||||
self.screenshot_btn.clicked.connect(self._take_screenshot)
|
||||
btn_layout.addWidget(self.screenshot_btn)
|
||||
|
||||
btn_layout.addStretch()
|
||||
manual_layout.addLayout(btn_layout)
|
||||
|
||||
layout.addWidget(manual_group)
|
||||
|
||||
# ==================== Screenshot history ====================
|
||||
history_group = QGroupBox("截图历史")
|
||||
history_group.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
|
||||
history_layout = QVBoxLayout(history_group)
|
||||
history_layout.setContentsMargins(GROUP_PADDING_SIDE, GROUP_PADDING_TOP, GROUP_PADDING_SIDE, GROUP_PADDING_BOTTOM)
|
||||
history_layout.setSpacing(GROUP_SPACING)
|
||||
|
||||
# Toolbar
|
||||
toolbar = QHBoxLayout()
|
||||
toolbar.setSpacing(BUTTON_SPACING)
|
||||
|
||||
open_dir_btn = QPushButton("📂 打开目录")
|
||||
open_dir_btn.setMinimumHeight(BUTTON_HEIGHT_NORMAL)
|
||||
open_dir_btn.clicked.connect(self._open_screenshot_dir)
|
||||
toolbar.addWidget(open_dir_btn)
|
||||
|
||||
toolbar.addStretch()
|
||||
|
||||
refresh_btn = QPushButton("🔄 刷新列表")
|
||||
refresh_btn.setMinimumHeight(BUTTON_HEIGHT_NORMAL)
|
||||
refresh_btn.clicked.connect(self._refresh_screenshots)
|
||||
toolbar.addWidget(refresh_btn)
|
||||
|
||||
history_layout.addLayout(toolbar)
|
||||
|
||||
# Screenshot list - 图标模式,一行显示多个
|
||||
self.screenshot_list = QListWidget()
|
||||
self.screenshot_list.setViewMode(QListWidget.ViewMode.IconMode) # 图标模式
|
||||
self.screenshot_list.setIconSize(QSize(150, 100)) # 缩略图大小
|
||||
self.screenshot_list.setGridSize(QSize(170, 130)) # 网格大小(含文字)
|
||||
self.screenshot_list.setResizeMode(QListWidget.ResizeMode.Adjust) # 自动调整
|
||||
self.screenshot_list.setWrapping(True) # 自动换行
|
||||
self.screenshot_list.setSpacing(8)
|
||||
self.screenshot_list.setMinimumHeight(200)
|
||||
self.screenshot_list.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
|
||||
self.screenshot_list.itemDoubleClicked.connect(self._preview_screenshot)
|
||||
history_layout.addWidget(self.screenshot_list, 1)
|
||||
|
||||
# Action buttons
|
||||
action_layout = QHBoxLayout()
|
||||
action_layout.setSpacing(BUTTON_SPACING)
|
||||
|
||||
self.delete_all_btn = QPushButton("🗑 删除全部")
|
||||
self.delete_all_btn.setObjectName("danger")
|
||||
self.delete_all_btn.setMinimumHeight(BUTTON_HEIGHT_NORMAL)
|
||||
self.delete_all_btn.clicked.connect(self._delete_all)
|
||||
action_layout.addWidget(self.delete_all_btn)
|
||||
|
||||
action_layout.addStretch()
|
||||
|
||||
self.preview_btn = QPushButton("👁 预览")
|
||||
self.preview_btn.setMinimumHeight(BUTTON_HEIGHT_NORMAL)
|
||||
self.preview_btn.clicked.connect(self._preview_selected)
|
||||
action_layout.addWidget(self.preview_btn)
|
||||
|
||||
self.delete_btn = QPushButton("🗑 删除选中")
|
||||
self.delete_btn.setMinimumHeight(BUTTON_HEIGHT_NORMAL)
|
||||
self.delete_btn.clicked.connect(self._delete_selected)
|
||||
action_layout.addWidget(self.delete_btn)
|
||||
|
||||
history_layout.addLayout(action_layout)
|
||||
|
||||
layout.addWidget(history_group, 1)
|
||||
|
||||
scroll.setWidget(content)
|
||||
main_layout.addWidget(scroll)
|
||||
|
||||
# Load accounts
|
||||
self._refresh_accounts()
|
||||
|
||||
def _refresh_accounts(self):
|
||||
"""Refresh account list"""
|
||||
self.account_combo.clear()
|
||||
config = get_config()
|
||||
|
||||
for account in config.accounts:
|
||||
if account.enabled:
|
||||
display_name = account.remark if account.remark else account.username
|
||||
self.account_combo.addItem(f"{display_name} ({account.username})", account)
|
||||
|
||||
def _refresh_screenshots(self):
|
||||
"""Refresh screenshot list"""
|
||||
self.screenshot_list.clear()
|
||||
|
||||
if not SCREENSHOTS_DIR.exists():
|
||||
return
|
||||
|
||||
files = []
|
||||
for f in SCREENSHOTS_DIR.iterdir():
|
||||
if f.suffix.lower() in (".jpg", ".jpeg", ".png"):
|
||||
files.append(f)
|
||||
|
||||
# Sort by modification time, newest first
|
||||
files.sort(key=lambda x: x.stat().st_mtime, reverse=True)
|
||||
|
||||
for f in files[:50]: # Only show last 50
|
||||
item = QListWidgetItem()
|
||||
# 文件名太长时截断显示
|
||||
name = f.stem # 不含扩展名
|
||||
if len(name) > 15:
|
||||
name = name[:12] + "..."
|
||||
item.setText(name)
|
||||
item.setData(Qt.ItemDataRole.UserRole, str(f))
|
||||
item.setToolTip(f.name) # 完整文件名显示在提示中
|
||||
|
||||
# Try to load thumbnail
|
||||
try:
|
||||
pixmap = QPixmap(str(f))
|
||||
if not pixmap.isNull():
|
||||
# 缩放到合适大小显示缩略图
|
||||
scaled = pixmap.scaled(
|
||||
150, 100,
|
||||
Qt.AspectRatioMode.KeepAspectRatio,
|
||||
Qt.TransformationMode.SmoothTransformation
|
||||
)
|
||||
item.setIcon(QIcon(scaled))
|
||||
except Exception as e:
|
||||
print(f"加载缩略图失败: {f.name}, {e}")
|
||||
|
||||
self.screenshot_list.addItem(item)
|
||||
|
||||
def _take_screenshot(self):
|
||||
"""Execute screenshot"""
|
||||
if self.account_combo.currentIndex() < 0:
|
||||
QMessageBox.information(self, "提示", "请先选择一个账号")
|
||||
return
|
||||
|
||||
account = self.account_combo.currentData()
|
||||
if not account:
|
||||
return
|
||||
|
||||
browse_type = self.browse_type_combo.currentText()
|
||||
password = decrypt_password(account.password) if is_encrypted(account.password) else account.password
|
||||
|
||||
self.screenshot_btn.setEnabled(False)
|
||||
self.screenshot_btn.setText("截图中...")
|
||||
self.log_signal.emit(f"开始截图: {account.username}")
|
||||
|
||||
from utils.worker import Worker
|
||||
|
||||
def do_screenshot(_signals=None, _should_stop=None):
|
||||
from core.screenshot import take_screenshot
|
||||
from config import get_config as _get_config
|
||||
|
||||
cfg = _get_config()
|
||||
proxy_config = None
|
||||
if cfg.proxy.enabled and cfg.proxy.server:
|
||||
proxy_config = {"server": cfg.proxy.server}
|
||||
|
||||
result = take_screenshot(
|
||||
account.username,
|
||||
password,
|
||||
browse_type,
|
||||
remark=account.remark,
|
||||
log_callback=lambda msg: _signals.log.emit(msg),
|
||||
proxy_config=proxy_config
|
||||
)
|
||||
return result
|
||||
|
||||
def on_result(result):
|
||||
self.screenshot_btn.setEnabled(True)
|
||||
self.screenshot_btn.setText("📷 执行截图")
|
||||
if result and result.success:
|
||||
self.log_signal.emit(f"[OK] 截图成功: {result.filename}")
|
||||
self._refresh_screenshots()
|
||||
else:
|
||||
error = result.error_message if result else "未知错误"
|
||||
self.log_signal.emit(f"[FAIL] 截图失败: {error}")
|
||||
|
||||
def on_error(error):
|
||||
self.screenshot_btn.setEnabled(True)
|
||||
self.screenshot_btn.setText("📷 执行截图")
|
||||
self.log_signal.emit(f"截图出错: {error}")
|
||||
|
||||
self._worker = Worker(do_screenshot)
|
||||
self._worker.signals.log.connect(self.log_signal.emit)
|
||||
self._worker.signals.result.connect(on_result)
|
||||
self._worker.signals.error.connect(on_error)
|
||||
self._worker.start()
|
||||
|
||||
def _preview_screenshot(self, item):
|
||||
"""Preview screenshot"""
|
||||
filepath = item.data(Qt.ItemDataRole.UserRole)
|
||||
if filepath and os.path.exists(filepath):
|
||||
if sys.platform == "win32":
|
||||
os.startfile(filepath)
|
||||
elif sys.platform == "darwin":
|
||||
subprocess.run(["open", filepath])
|
||||
else:
|
||||
subprocess.run(["xdg-open", filepath])
|
||||
|
||||
def _preview_selected(self):
|
||||
"""Preview selected screenshot"""
|
||||
current = self.screenshot_list.currentItem()
|
||||
if current:
|
||||
self._preview_screenshot(current)
|
||||
else:
|
||||
QMessageBox.information(self, "提示", "请先选择一个截图")
|
||||
|
||||
def _delete_selected(self):
|
||||
"""Delete selected screenshot"""
|
||||
current = self.screenshot_list.currentItem()
|
||||
if not current:
|
||||
QMessageBox.information(self, "提示", "请先选择一个截图")
|
||||
return
|
||||
|
||||
filepath = current.data(Qt.ItemDataRole.UserRole)
|
||||
filename = current.text()
|
||||
|
||||
reply = QMessageBox.question(
|
||||
self, "确认删除",
|
||||
f"确定要删除截图 {filename} 吗?",
|
||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
|
||||
)
|
||||
|
||||
if reply == QMessageBox.StandardButton.Yes:
|
||||
try:
|
||||
if os.path.exists(filepath):
|
||||
os.remove(filepath)
|
||||
self._refresh_screenshots()
|
||||
self.log_signal.emit(f"已删除截图: {filename}")
|
||||
except Exception as e:
|
||||
QMessageBox.warning(self, "错误", f"删除失败: {e}")
|
||||
|
||||
def _delete_all(self):
|
||||
"""Delete all screenshots"""
|
||||
count = self.screenshot_list.count()
|
||||
if count == 0:
|
||||
QMessageBox.information(self, "提示", "没有截图可删除")
|
||||
return
|
||||
|
||||
reply = QMessageBox.warning(
|
||||
self, "⚠️ 确认删除全部",
|
||||
f"确定要删除全部 {count} 张截图吗?\n\n此操作不可恢复!",
|
||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||||
QMessageBox.StandardButton.No
|
||||
)
|
||||
|
||||
if reply == QMessageBox.StandardButton.Yes:
|
||||
deleted = 0
|
||||
failed = 0
|
||||
for i in range(count):
|
||||
item = self.screenshot_list.item(i)
|
||||
filepath = item.data(Qt.ItemDataRole.UserRole)
|
||||
try:
|
||||
if os.path.exists(filepath):
|
||||
os.remove(filepath)
|
||||
deleted += 1
|
||||
except Exception:
|
||||
failed += 1
|
||||
|
||||
self._refresh_screenshots()
|
||||
msg = f"已删除 {deleted} 张截图"
|
||||
if failed > 0:
|
||||
msg += f",{failed} 张删除失败"
|
||||
self.log_signal.emit(msg)
|
||||
|
||||
def _open_screenshot_dir(self):
|
||||
"""Open screenshot directory"""
|
||||
SCREENSHOTS_DIR.mkdir(exist_ok=True)
|
||||
if sys.platform == "win32":
|
||||
os.startfile(str(SCREENSHOTS_DIR))
|
||||
elif sys.platform == "darwin":
|
||||
subprocess.run(["open", str(SCREENSHOTS_DIR)])
|
||||
else:
|
||||
subprocess.run(["xdg-open", str(SCREENSHOTS_DIR)])
|
||||
265
ui/settings_widget.py
Normal file
265
ui/settings_widget.py
Normal file
@@ -0,0 +1,265 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Settings panel - wkhtmltoimage path, screenshot quality, theme
|
||||
"""
|
||||
|
||||
import os
|
||||
from PyQt6.QtWidgets import (
|
||||
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, QPushButton,
|
||||
QGroupBox, QFormLayout, QSpinBox, QComboBox, QMessageBox,
|
||||
QFileDialog, QScrollArea, QFrame
|
||||
)
|
||||
from PyQt6.QtCore import Qt, pyqtSignal
|
||||
|
||||
from config import get_config, save_config
|
||||
from .constants import (
|
||||
PAGE_PADDING, SECTION_SPACING, GROUP_PADDING_TOP, GROUP_PADDING_SIDE,
|
||||
GROUP_PADDING_BOTTOM, FORM_ROW_SPACING, FORM_H_SPACING,
|
||||
INPUT_HEIGHT, INPUT_MIN_WIDTH, BUTTON_HEIGHT, BUTTON_HEIGHT_NORMAL,
|
||||
BUTTON_MIN_WIDTH, BUTTON_MIN_WIDTH_NORMAL, BUTTON_SPACING,
|
||||
get_title_style
|
||||
)
|
||||
|
||||
|
||||
class SettingsWidget(QWidget):
|
||||
"""Settings panel"""
|
||||
|
||||
log_signal = pyqtSignal(str)
|
||||
theme_changed = pyqtSignal(str)
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self._setup_ui()
|
||||
self._load_settings()
|
||||
|
||||
def _setup_ui(self):
|
||||
main_layout = QVBoxLayout(self)
|
||||
main_layout.setContentsMargins(0, 0, 0, 0)
|
||||
main_layout.setSpacing(0)
|
||||
|
||||
# Scroll area
|
||||
scroll = QScrollArea()
|
||||
scroll.setWidgetResizable(True)
|
||||
scroll.setFrameShape(QFrame.Shape.NoFrame)
|
||||
scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||||
|
||||
content = QWidget()
|
||||
layout = QVBoxLayout(content)
|
||||
layout.setContentsMargins(PAGE_PADDING, PAGE_PADDING, PAGE_PADDING, PAGE_PADDING)
|
||||
layout.setSpacing(SECTION_SPACING)
|
||||
|
||||
# Title
|
||||
title = QLabel("⚙️ 系统设置")
|
||||
title.setStyleSheet(get_title_style())
|
||||
layout.addWidget(title)
|
||||
|
||||
# ==================== Screenshot settings ====================
|
||||
screenshot_group = QGroupBox("截图设置")
|
||||
screenshot_layout = QFormLayout(screenshot_group)
|
||||
screenshot_layout.setContentsMargins(GROUP_PADDING_SIDE, GROUP_PADDING_TOP, GROUP_PADDING_SIDE, GROUP_PADDING_BOTTOM)
|
||||
screenshot_layout.setSpacing(FORM_ROW_SPACING)
|
||||
screenshot_layout.setLabelAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
|
||||
|
||||
# wkhtmltoimage path
|
||||
wkhtml_layout = QHBoxLayout()
|
||||
wkhtml_layout.setSpacing(FORM_H_SPACING)
|
||||
|
||||
self.wkhtml_edit = QLineEdit()
|
||||
self.wkhtml_edit.setPlaceholderText("留空则自动检测")
|
||||
self.wkhtml_edit.setMinimumHeight(INPUT_HEIGHT)
|
||||
wkhtml_layout.addWidget(self.wkhtml_edit)
|
||||
|
||||
browse_btn = QPushButton("浏览...")
|
||||
browse_btn.setMinimumHeight(BUTTON_HEIGHT_NORMAL)
|
||||
browse_btn.clicked.connect(self._browse_wkhtml)
|
||||
wkhtml_layout.addWidget(browse_btn)
|
||||
|
||||
detect_btn = QPushButton("检测")
|
||||
detect_btn.setMinimumHeight(BUTTON_HEIGHT_NORMAL)
|
||||
detect_btn.clicked.connect(self._detect_wkhtml)
|
||||
wkhtml_layout.addWidget(detect_btn)
|
||||
|
||||
screenshot_layout.addRow("wkhtmltoimage:", wkhtml_layout)
|
||||
|
||||
# Screenshot width
|
||||
self.width_spin = QSpinBox()
|
||||
self.width_spin.setRange(800, 3840)
|
||||
self.width_spin.setSingleStep(100)
|
||||
self.width_spin.setMinimumHeight(INPUT_HEIGHT)
|
||||
self.width_spin.setMinimumWidth(130)
|
||||
screenshot_layout.addRow("截图宽度:", self.width_spin)
|
||||
|
||||
# Screenshot height
|
||||
self.height_spin = QSpinBox()
|
||||
self.height_spin.setRange(600, 2160)
|
||||
self.height_spin.setSingleStep(100)
|
||||
self.height_spin.setMinimumHeight(INPUT_HEIGHT)
|
||||
self.height_spin.setMinimumWidth(130)
|
||||
screenshot_layout.addRow("截图高度:", self.height_spin)
|
||||
|
||||
# Quality
|
||||
self.quality_spin = QSpinBox()
|
||||
self.quality_spin.setRange(50, 100)
|
||||
self.quality_spin.setMinimumHeight(INPUT_HEIGHT)
|
||||
self.quality_spin.setMinimumWidth(130)
|
||||
self.quality_spin.setSuffix(" %")
|
||||
screenshot_layout.addRow("JPEG质量:", self.quality_spin)
|
||||
|
||||
# JS delay
|
||||
self.js_delay_spin = QSpinBox()
|
||||
self.js_delay_spin.setRange(1000, 10000)
|
||||
self.js_delay_spin.setSingleStep(500)
|
||||
self.js_delay_spin.setSuffix(" ms")
|
||||
self.js_delay_spin.setMinimumHeight(INPUT_HEIGHT)
|
||||
self.js_delay_spin.setMinimumWidth(130)
|
||||
screenshot_layout.addRow("JS等待时间:", self.js_delay_spin)
|
||||
|
||||
layout.addWidget(screenshot_group)
|
||||
|
||||
# ==================== Theme settings ====================
|
||||
theme_group = QGroupBox("主题设置")
|
||||
theme_layout = QFormLayout(theme_group)
|
||||
theme_layout.setContentsMargins(GROUP_PADDING_SIDE, GROUP_PADDING_TOP, GROUP_PADDING_SIDE, GROUP_PADDING_BOTTOM)
|
||||
theme_layout.setSpacing(FORM_ROW_SPACING)
|
||||
theme_layout.setLabelAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
|
||||
|
||||
self.theme_combo = QComboBox()
|
||||
self.theme_combo.addItem("浅色主题", "light")
|
||||
self.theme_combo.addItem("深色主题", "dark")
|
||||
self.theme_combo.setMinimumHeight(INPUT_HEIGHT)
|
||||
self.theme_combo.setMinimumWidth(INPUT_MIN_WIDTH)
|
||||
self.theme_combo.currentIndexChanged.connect(self._on_theme_change)
|
||||
theme_layout.addRow("界面主题:", self.theme_combo)
|
||||
|
||||
layout.addWidget(theme_group)
|
||||
|
||||
# ==================== Platform API settings ====================
|
||||
api_group = QGroupBox("平台设置")
|
||||
api_layout = QFormLayout(api_group)
|
||||
api_layout.setContentsMargins(GROUP_PADDING_SIDE, GROUP_PADDING_TOP, GROUP_PADDING_SIDE, GROUP_PADDING_BOTTOM)
|
||||
api_layout.setSpacing(FORM_ROW_SPACING)
|
||||
api_layout.setLabelAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
|
||||
|
||||
self.base_url_edit = QLineEdit()
|
||||
self.base_url_edit.setMinimumHeight(INPUT_HEIGHT)
|
||||
api_layout.addRow("平台地址:", self.base_url_edit)
|
||||
|
||||
self.login_url_edit = QLineEdit()
|
||||
self.login_url_edit.setMinimumHeight(INPUT_HEIGHT)
|
||||
api_layout.addRow("登录地址:", self.login_url_edit)
|
||||
|
||||
layout.addWidget(api_group)
|
||||
|
||||
# ==================== Action buttons ====================
|
||||
btn_layout = QHBoxLayout()
|
||||
btn_layout.setSpacing(BUTTON_SPACING)
|
||||
btn_layout.addStretch()
|
||||
|
||||
reset_btn = QPushButton("恢复默认")
|
||||
reset_btn.setMinimumWidth(BUTTON_MIN_WIDTH_NORMAL)
|
||||
reset_btn.setMinimumHeight(BUTTON_HEIGHT)
|
||||
reset_btn.clicked.connect(self._reset_defaults)
|
||||
btn_layout.addWidget(reset_btn)
|
||||
|
||||
save_btn = QPushButton("💾 保存设置")
|
||||
save_btn.setObjectName("primary")
|
||||
save_btn.setMinimumWidth(BUTTON_MIN_WIDTH)
|
||||
save_btn.setMinimumHeight(BUTTON_HEIGHT)
|
||||
save_btn.clicked.connect(self._save_settings)
|
||||
btn_layout.addWidget(save_btn)
|
||||
|
||||
layout.addLayout(btn_layout)
|
||||
layout.addStretch()
|
||||
|
||||
scroll.setWidget(content)
|
||||
main_layout.addWidget(scroll)
|
||||
|
||||
def _load_settings(self):
|
||||
"""Load settings from config"""
|
||||
config = get_config()
|
||||
|
||||
# Screenshot settings
|
||||
self.wkhtml_edit.setText(config.screenshot.wkhtmltoimage_path)
|
||||
self.width_spin.setValue(config.screenshot.width)
|
||||
self.height_spin.setValue(config.screenshot.height)
|
||||
self.quality_spin.setValue(config.screenshot.quality)
|
||||
self.js_delay_spin.setValue(config.screenshot.js_delay_ms)
|
||||
|
||||
# Theme
|
||||
idx = self.theme_combo.findData(config.theme)
|
||||
if idx >= 0:
|
||||
self.theme_combo.setCurrentIndex(idx)
|
||||
|
||||
# API settings
|
||||
self.base_url_edit.setText(config.zsgl.base_url)
|
||||
self.login_url_edit.setText(config.zsgl.login_url)
|
||||
|
||||
def _save_settings(self):
|
||||
"""Save settings to config"""
|
||||
config = get_config()
|
||||
|
||||
# Screenshot settings
|
||||
config.screenshot.wkhtmltoimage_path = self.wkhtml_edit.text().strip()
|
||||
config.screenshot.width = self.width_spin.value()
|
||||
config.screenshot.height = self.height_spin.value()
|
||||
config.screenshot.quality = self.quality_spin.value()
|
||||
config.screenshot.js_delay_ms = self.js_delay_spin.value()
|
||||
|
||||
# Theme
|
||||
config.theme = self.theme_combo.currentData()
|
||||
|
||||
# API settings
|
||||
config.zsgl.base_url = self.base_url_edit.text().strip()
|
||||
config.zsgl.login_url = self.login_url_edit.text().strip()
|
||||
|
||||
save_config(config)
|
||||
self.log_signal.emit("设置已保存")
|
||||
QMessageBox.information(self, "提示", "设置已保存")
|
||||
|
||||
def _browse_wkhtml(self):
|
||||
"""Browse for wkhtmltoimage executable"""
|
||||
filepath, _ = QFileDialog.getOpenFileName(
|
||||
self, "选择 wkhtmltoimage",
|
||||
"",
|
||||
"可执行文件 (*.exe);;所有文件 (*)"
|
||||
)
|
||||
if filepath:
|
||||
self.wkhtml_edit.setText(filepath)
|
||||
|
||||
def _detect_wkhtml(self):
|
||||
"""Detect wkhtmltoimage path"""
|
||||
from core.screenshot import _resolve_wkhtmltoimage_path
|
||||
|
||||
path = _resolve_wkhtmltoimage_path()
|
||||
if path:
|
||||
self.wkhtml_edit.setText(path)
|
||||
self.log_signal.emit(f"检测到 wkhtmltoimage: {path}")
|
||||
QMessageBox.information(self, "检测成功", f"找到 wkhtmltoimage:\n{path}")
|
||||
else:
|
||||
self.log_signal.emit("未检测到 wkhtmltoimage")
|
||||
QMessageBox.warning(self, "检测失败", "未找到 wkhtmltoimage,请手动指定路径或安装该工具。")
|
||||
|
||||
def _on_theme_change(self, index):
|
||||
"""Handle theme change"""
|
||||
theme = self.theme_combo.currentData()
|
||||
self.theme_changed.emit(theme)
|
||||
|
||||
def _reset_defaults(self):
|
||||
"""Reset to default settings"""
|
||||
reply = QMessageBox.question(
|
||||
self, "确认",
|
||||
"确定要恢复默认设置吗?",
|
||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
|
||||
)
|
||||
|
||||
if reply == QMessageBox.StandardButton.Yes:
|
||||
self.wkhtml_edit.clear()
|
||||
self.width_spin.setValue(1920)
|
||||
self.height_spin.setValue(1080)
|
||||
self.quality_spin.setValue(95)
|
||||
self.js_delay_spin.setValue(3000)
|
||||
self.theme_combo.setCurrentIndex(0) # 默认浅色主题
|
||||
self.base_url_edit.setText("https://postoa.aidunsoft.com")
|
||||
self.login_url_edit.setText("https://postoa.aidunsoft.com/admin/login.aspx")
|
||||
self.log_signal.emit("已恢复默认设置")
|
||||
635
ui/styles.py
Normal file
635
ui/styles.py
Normal file
@@ -0,0 +1,635 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
QSS Modern Stylesheet - 现代化UI样式
|
||||
老王说:样式写好了界面才漂亮,这些都是精心调教过的!
|
||||
"""
|
||||
|
||||
# 浅色主题
|
||||
LIGHT_THEME = {
|
||||
"bg_primary": "#ffffff",
|
||||
"bg_secondary": "#fafafa",
|
||||
"bg_tertiary": "#f0f0f0",
|
||||
"bg_card": "#ffffff",
|
||||
"text_primary": "#1a1a1a",
|
||||
"text_secondary": "#595959",
|
||||
"text_muted": "#8c8c8c",
|
||||
"text_placeholder": "#bfbfbf",
|
||||
"border": "#e8e8e8",
|
||||
"border_light": "#f0f0f0",
|
||||
"accent": "#1677ff",
|
||||
"accent_hover": "#4096ff",
|
||||
"accent_pressed": "#0958d9",
|
||||
"accent_bg": "#e6f4ff",
|
||||
"success": "#52c41a",
|
||||
"success_bg": "#f6ffed",
|
||||
"warning": "#faad14",
|
||||
"warning_bg": "#fffbe6",
|
||||
"error": "#ff4d4f",
|
||||
"error_bg": "#fff2f0",
|
||||
"sidebar_bg": "#001529",
|
||||
"sidebar_text": "#ffffffd9",
|
||||
"sidebar_text_muted": "#ffffff73",
|
||||
"sidebar_hover": "#1677ff",
|
||||
"sidebar_active_bg": "#1677ff",
|
||||
"shadow": "rgba(0, 0, 0, 0.08)",
|
||||
}
|
||||
|
||||
# 深色主题
|
||||
DARK_THEME = {
|
||||
"bg_primary": "#141414",
|
||||
"bg_secondary": "#1f1f1f",
|
||||
"bg_tertiary": "#262626",
|
||||
"bg_card": "#1f1f1f",
|
||||
"text_primary": "#ffffffd9",
|
||||
"text_secondary": "#ffffffa6",
|
||||
"text_muted": "#ffffff73",
|
||||
"text_placeholder": "#ffffff40",
|
||||
"border": "#424242",
|
||||
"border_light": "#303030",
|
||||
"accent": "#1668dc",
|
||||
"accent_hover": "#3c89e8",
|
||||
"accent_pressed": "#1554ad",
|
||||
"accent_bg": "#111a2c",
|
||||
"success": "#49aa19",
|
||||
"success_bg": "#162312",
|
||||
"warning": "#d89614",
|
||||
"warning_bg": "#2b2111",
|
||||
"error": "#dc4446",
|
||||
"error_bg": "#2c1618",
|
||||
"sidebar_bg": "#000000",
|
||||
"sidebar_text": "#ffffffd9",
|
||||
"sidebar_text_muted": "#ffffff73",
|
||||
"sidebar_hover": "#1668dc",
|
||||
"sidebar_active_bg": "#1668dc",
|
||||
"shadow": "rgba(0, 0, 0, 0.45)",
|
||||
}
|
||||
|
||||
|
||||
def get_stylesheet(theme: dict) -> str:
|
||||
"""Generate QSS stylesheet based on theme"""
|
||||
return f"""
|
||||
/* ==================== Global Reset ==================== */
|
||||
* {{
|
||||
outline: none;
|
||||
}}
|
||||
|
||||
QWidget {{
|
||||
font-family: "Microsoft YaHei UI", "Segoe UI", "PingFang SC", sans-serif;
|
||||
font-size: 14px;
|
||||
color: {theme["text_primary"]};
|
||||
background-color: transparent;
|
||||
}}
|
||||
|
||||
QMainWindow {{
|
||||
background-color: {theme["bg_secondary"]};
|
||||
}}
|
||||
|
||||
/* ==================== Scroll Area ==================== */
|
||||
QScrollArea {{
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
}}
|
||||
|
||||
QScrollArea > QWidget > QWidget {{
|
||||
background-color: transparent;
|
||||
}}
|
||||
|
||||
/* ==================== Sidebar ==================== */
|
||||
#sidebar {{
|
||||
background-color: {theme["sidebar_bg"]};
|
||||
border: none;
|
||||
}}
|
||||
|
||||
#sidebar QPushButton {{
|
||||
color: {theme["sidebar_text"]};
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
padding: 14px 20px;
|
||||
text-align: left;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}}
|
||||
|
||||
#sidebar QPushButton:hover {{
|
||||
background-color: rgba(255, 255, 255, 0.08);
|
||||
}}
|
||||
|
||||
#sidebar QPushButton:checked {{
|
||||
background-color: {theme["sidebar_active_bg"]};
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
}}
|
||||
|
||||
#sidebar_title {{
|
||||
color: white;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
padding: 20px;
|
||||
background-color: transparent;
|
||||
}}
|
||||
|
||||
/* ==================== Buttons ==================== */
|
||||
QPushButton {{
|
||||
background-color: {theme["bg_card"]};
|
||||
color: {theme["text_primary"]};
|
||||
border: 1px solid {theme["border"]};
|
||||
border-radius: 6px;
|
||||
padding: 8px 16px;
|
||||
font-weight: 500;
|
||||
}}
|
||||
|
||||
QPushButton:hover {{
|
||||
border-color: {theme["accent"]};
|
||||
color: {theme["accent"]};
|
||||
}}
|
||||
|
||||
QPushButton:pressed {{
|
||||
background-color: {theme["bg_tertiary"]};
|
||||
}}
|
||||
|
||||
QPushButton:disabled {{
|
||||
background-color: {theme["bg_tertiary"]};
|
||||
color: {theme["text_muted"]};
|
||||
border-color: {theme["border"]};
|
||||
}}
|
||||
|
||||
/* Primary Button */
|
||||
QPushButton#primary {{
|
||||
background-color: {theme["accent"]};
|
||||
color: white;
|
||||
border: none;
|
||||
font-weight: 600;
|
||||
}}
|
||||
|
||||
QPushButton#primary:hover {{
|
||||
background-color: {theme["accent_hover"]};
|
||||
}}
|
||||
|
||||
QPushButton#primary:pressed {{
|
||||
background-color: {theme["accent_pressed"]};
|
||||
}}
|
||||
|
||||
QPushButton#primary:disabled {{
|
||||
background-color: {theme["accent"]};
|
||||
opacity: 0.6;
|
||||
}}
|
||||
|
||||
/* Success Button */
|
||||
QPushButton#success {{
|
||||
background-color: {theme["success"]};
|
||||
color: white;
|
||||
border: none;
|
||||
}}
|
||||
|
||||
QPushButton#success:hover {{
|
||||
background-color: #73d13d;
|
||||
}}
|
||||
|
||||
/* Danger Button */
|
||||
QPushButton#danger {{
|
||||
background-color: {theme["error"]};
|
||||
color: white;
|
||||
border: none;
|
||||
}}
|
||||
|
||||
QPushButton#danger:hover {{
|
||||
background-color: #ff7875;
|
||||
}}
|
||||
|
||||
/* ==================== Input Fields ==================== */
|
||||
QLineEdit {{
|
||||
background-color: {theme["bg_card"]};
|
||||
color: {theme["text_primary"]};
|
||||
border: 1px solid {theme["border"]};
|
||||
border-radius: 6px;
|
||||
padding: 6px 10px;
|
||||
selection-background-color: {theme["accent"]};
|
||||
selection-color: white;
|
||||
}}
|
||||
|
||||
QLineEdit:hover {{
|
||||
border-color: {theme["accent"]};
|
||||
}}
|
||||
|
||||
QLineEdit:focus {{
|
||||
border-color: {theme["accent"]};
|
||||
border-width: 2px;
|
||||
padding: 5px 9px;
|
||||
}}
|
||||
|
||||
QLineEdit:disabled {{
|
||||
background-color: {theme["bg_tertiary"]};
|
||||
color: {theme["text_muted"]};
|
||||
}}
|
||||
|
||||
QLineEdit[readOnly="true"] {{
|
||||
background-color: {theme["bg_secondary"]};
|
||||
}}
|
||||
|
||||
/* ==================== TextEdit ==================== */
|
||||
QTextEdit, QPlainTextEdit {{
|
||||
background-color: {theme["bg_card"]};
|
||||
color: {theme["text_primary"]};
|
||||
border: 1px solid {theme["border"]};
|
||||
border-radius: 6px;
|
||||
padding: 10px 14px;
|
||||
selection-background-color: {theme["accent"]};
|
||||
}}
|
||||
|
||||
QTextEdit:focus, QPlainTextEdit:focus {{
|
||||
border-color: {theme["accent"]};
|
||||
}}
|
||||
|
||||
/* ==================== ComboBox ==================== */
|
||||
QComboBox {{
|
||||
background-color: {theme["bg_card"]};
|
||||
color: {theme["text_primary"]};
|
||||
border: 1px solid {theme["border"]};
|
||||
border-radius: 6px;
|
||||
padding: 6px 10px;
|
||||
padding-right: 30px;
|
||||
}}
|
||||
|
||||
QComboBox:hover {{
|
||||
border-color: {theme["accent"]};
|
||||
}}
|
||||
|
||||
QComboBox:focus {{
|
||||
border-color: {theme["accent"]};
|
||||
}}
|
||||
|
||||
QComboBox::drop-down {{
|
||||
border: none;
|
||||
width: 30px;
|
||||
subcontrol-position: right center;
|
||||
}}
|
||||
|
||||
QComboBox::down-arrow {{
|
||||
image: none;
|
||||
border-left: 5px solid transparent;
|
||||
border-right: 5px solid transparent;
|
||||
border-top: 6px solid {theme["text_secondary"]};
|
||||
margin-right: 10px;
|
||||
}}
|
||||
|
||||
QComboBox QAbstractItemView {{
|
||||
background-color: {theme["bg_card"]};
|
||||
border: 1px solid {theme["border"]};
|
||||
border-radius: 6px;
|
||||
padding: 4px;
|
||||
selection-background-color: {theme["accent_bg"]};
|
||||
selection-color: {theme["accent"]};
|
||||
outline: none;
|
||||
}}
|
||||
|
||||
QComboBox QAbstractItemView::item {{
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
}}
|
||||
|
||||
QComboBox QAbstractItemView::item:hover {{
|
||||
background-color: {theme["bg_tertiary"]};
|
||||
}}
|
||||
|
||||
/* ==================== SpinBox ==================== */
|
||||
QSpinBox {{
|
||||
background-color: {theme["bg_card"]};
|
||||
color: {theme["text_primary"]};
|
||||
border: 1px solid {theme["border"]};
|
||||
border-radius: 6px;
|
||||
padding: 10px 14px;
|
||||
padding-right: 30px;
|
||||
}}
|
||||
|
||||
QSpinBox:hover {{
|
||||
border-color: {theme["accent"]};
|
||||
}}
|
||||
|
||||
QSpinBox:focus {{
|
||||
border-color: {theme["accent"]};
|
||||
}}
|
||||
|
||||
QSpinBox::up-button, QSpinBox::down-button {{
|
||||
width: 20px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
}}
|
||||
|
||||
QSpinBox::up-arrow, QSpinBox::down-arrow {{
|
||||
width: 0;
|
||||
height: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
}}
|
||||
|
||||
/* ==================== CheckBox ==================== */
|
||||
QCheckBox {{
|
||||
spacing: 10px;
|
||||
color: {theme["text_primary"]};
|
||||
}}
|
||||
|
||||
QCheckBox::indicator {{
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border: 1px solid {theme["border"]};
|
||||
border-radius: 4px;
|
||||
background-color: {theme["bg_card"]};
|
||||
}}
|
||||
|
||||
QCheckBox::indicator:hover {{
|
||||
border-color: {theme["accent"]};
|
||||
}}
|
||||
|
||||
QCheckBox::indicator:checked {{
|
||||
background-color: {theme["accent"]};
|
||||
border-color: {theme["accent"]};
|
||||
}}
|
||||
|
||||
/* ==================== GroupBox - Card Style ==================== */
|
||||
QGroupBox {{
|
||||
background-color: {theme["bg_card"]};
|
||||
border: 1px solid {theme["border"]};
|
||||
border-radius: 10px;
|
||||
margin-top: 14px;
|
||||
padding: 24px 0 0 0;
|
||||
}}
|
||||
|
||||
QGroupBox::title {{
|
||||
subcontrol-origin: margin;
|
||||
subcontrol-position: top left;
|
||||
left: 16px;
|
||||
top: 0px;
|
||||
padding: 4px 12px;
|
||||
background-color: {theme["accent"]};
|
||||
color: white;
|
||||
border-radius: 4px;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
}}
|
||||
|
||||
/* ==================== Table ==================== */
|
||||
QTableWidget {{
|
||||
background-color: {theme["bg_card"]};
|
||||
border: 1px solid {theme["border"]};
|
||||
border-radius: 8px;
|
||||
gridline-color: {theme["border_light"]};
|
||||
selection-background-color: {theme["accent_bg"]};
|
||||
selection-color: {theme["text_primary"]};
|
||||
}}
|
||||
|
||||
QTableWidget::item {{
|
||||
padding: 8px 4px;
|
||||
border: none;
|
||||
}}
|
||||
|
||||
QTableWidget::item:selected {{
|
||||
background-color: {theme["accent_bg"]};
|
||||
color: {theme["accent"]};
|
||||
}}
|
||||
|
||||
QTableWidget::item:hover {{
|
||||
background-color: {theme["bg_secondary"]};
|
||||
}}
|
||||
|
||||
QHeaderView::section {{
|
||||
background-color: {theme["bg_secondary"]};
|
||||
color: {theme["text_secondary"]};
|
||||
padding: 12px 8px;
|
||||
border: none;
|
||||
border-bottom: 1px solid {theme["border"]};
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
}}
|
||||
|
||||
QHeaderView::section:first {{
|
||||
border-top-left-radius: 8px;
|
||||
}}
|
||||
|
||||
QHeaderView::section:last {{
|
||||
border-top-right-radius: 8px;
|
||||
}}
|
||||
|
||||
QTableCornerButton::section {{
|
||||
background-color: {theme["bg_secondary"]};
|
||||
border: none;
|
||||
}}
|
||||
|
||||
/* ==================== List ==================== */
|
||||
QListWidget {{
|
||||
background-color: {theme["bg_card"]};
|
||||
border: 1px solid {theme["border"]};
|
||||
border-radius: 8px;
|
||||
padding: 4px;
|
||||
outline: none;
|
||||
}}
|
||||
|
||||
QListWidget::item {{
|
||||
padding: 12px 14px;
|
||||
border-radius: 6px;
|
||||
margin: 2px 0;
|
||||
}}
|
||||
|
||||
QListWidget::item:selected {{
|
||||
background-color: {theme["accent_bg"]};
|
||||
color: {theme["accent"]};
|
||||
}}
|
||||
|
||||
QListWidget::item:hover:!selected {{
|
||||
background-color: {theme["bg_secondary"]};
|
||||
}}
|
||||
|
||||
/* ==================== Progress Bar ==================== */
|
||||
QProgressBar {{
|
||||
background-color: {theme["bg_tertiary"]};
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
height: 8px;
|
||||
text-align: center;
|
||||
}}
|
||||
|
||||
QProgressBar::chunk {{
|
||||
background-color: {theme["accent"]};
|
||||
border-radius: 4px;
|
||||
}}
|
||||
|
||||
/* ==================== ScrollBar ==================== */
|
||||
QScrollBar:vertical {{
|
||||
background-color: transparent;
|
||||
width: 8px;
|
||||
margin: 4px 2px;
|
||||
}}
|
||||
|
||||
QScrollBar::handle:vertical {{
|
||||
background-color: {theme["border"]};
|
||||
border-radius: 4px;
|
||||
min-height: 40px;
|
||||
}}
|
||||
|
||||
QScrollBar::handle:vertical:hover {{
|
||||
background-color: {theme["text_muted"]};
|
||||
}}
|
||||
|
||||
QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {{
|
||||
height: 0;
|
||||
background: none;
|
||||
}}
|
||||
|
||||
QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical {{
|
||||
background: none;
|
||||
}}
|
||||
|
||||
QScrollBar:horizontal {{
|
||||
background-color: transparent;
|
||||
height: 8px;
|
||||
margin: 2px 4px;
|
||||
}}
|
||||
|
||||
QScrollBar::handle:horizontal {{
|
||||
background-color: {theme["border"]};
|
||||
border-radius: 4px;
|
||||
min-width: 40px;
|
||||
}}
|
||||
|
||||
QScrollBar::handle:horizontal:hover {{
|
||||
background-color: {theme["text_muted"]};
|
||||
}}
|
||||
|
||||
QScrollBar::add-line:horizontal, QScrollBar::sub-line:horizontal {{
|
||||
width: 0;
|
||||
background: none;
|
||||
}}
|
||||
|
||||
/* ==================== Tab Widget ==================== */
|
||||
QTabWidget::pane {{
|
||||
border: 1px solid {theme["border"]};
|
||||
border-radius: 8px;
|
||||
background-color: {theme["bg_card"]};
|
||||
padding: 8px;
|
||||
}}
|
||||
|
||||
QTabBar::tab {{
|
||||
background-color: transparent;
|
||||
color: {theme["text_secondary"]};
|
||||
padding: 10px 20px;
|
||||
margin-right: 4px;
|
||||
border-bottom: 2px solid transparent;
|
||||
}}
|
||||
|
||||
QTabBar::tab:selected {{
|
||||
color: {theme["accent"]};
|
||||
border-bottom: 2px solid {theme["accent"]};
|
||||
}}
|
||||
|
||||
QTabBar::tab:hover:!selected {{
|
||||
color: {theme["text_primary"]};
|
||||
}}
|
||||
|
||||
/* ==================== ToolTip ==================== */
|
||||
QToolTip {{
|
||||
background-color: {theme["bg_tertiary"]};
|
||||
color: {theme["text_primary"]};
|
||||
border: 1px solid {theme["border"]};
|
||||
border-radius: 6px;
|
||||
padding: 8px 12px;
|
||||
}}
|
||||
|
||||
/* ==================== Message Box ==================== */
|
||||
QMessageBox {{
|
||||
background-color: {theme["bg_card"]};
|
||||
}}
|
||||
|
||||
QMessageBox QLabel {{
|
||||
color: {theme["text_primary"]};
|
||||
font-size: 14px;
|
||||
}}
|
||||
|
||||
QMessageBox QPushButton {{
|
||||
min-width: 80px;
|
||||
min-height: 32px;
|
||||
}}
|
||||
|
||||
/* ==================== Dialog ==================== */
|
||||
QDialog {{
|
||||
background-color: {theme["bg_card"]};
|
||||
}}
|
||||
|
||||
/* ==================== Label ==================== */
|
||||
QLabel {{
|
||||
color: {theme["text_primary"]};
|
||||
background-color: transparent;
|
||||
}}
|
||||
|
||||
QLabel#title {{
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: {theme["text_primary"]};
|
||||
}}
|
||||
|
||||
QLabel#subtitle {{
|
||||
font-size: 14px;
|
||||
color: {theme["text_secondary"]};
|
||||
}}
|
||||
|
||||
QLabel#help {{
|
||||
font-size: 13px;
|
||||
color: {theme["text_muted"]};
|
||||
}}
|
||||
|
||||
/* ==================== Frame ==================== */
|
||||
QFrame {{
|
||||
background-color: transparent;
|
||||
}}
|
||||
|
||||
QFrame#card {{
|
||||
background-color: {theme["bg_card"]};
|
||||
border: 1px solid {theme["border"]};
|
||||
border-radius: 10px;
|
||||
}}
|
||||
|
||||
QFrame#separator {{
|
||||
background-color: {theme["border"]};
|
||||
max-height: 1px;
|
||||
}}
|
||||
|
||||
/* ==================== Log Widget ==================== */
|
||||
#log_widget {{
|
||||
background-color: {theme["bg_secondary"]};
|
||||
border-top: 1px solid {theme["border"]};
|
||||
}}
|
||||
|
||||
#log_widget QPlainTextEdit {{
|
||||
background-color: {theme["bg_secondary"]};
|
||||
border: none;
|
||||
color: {theme["text_secondary"]};
|
||||
font-family: "Cascadia Code", "Consolas", "Monaco", monospace;
|
||||
font-size: 12px;
|
||||
padding: 8px;
|
||||
}}
|
||||
|
||||
/* ==================== Status Colors ==================== */
|
||||
.success {{
|
||||
color: {theme["success"]};
|
||||
}}
|
||||
|
||||
.warning {{
|
||||
color: {theme["warning"]};
|
||||
}}
|
||||
|
||||
.error {{
|
||||
color: {theme["error"]};
|
||||
}}
|
||||
|
||||
/* ==================== Form Label ==================== */
|
||||
QLabel#formLabel {{
|
||||
color: {theme["text_secondary"]};
|
||||
font-weight: 500;
|
||||
}}
|
||||
"""
|
||||
|
||||
|
||||
def apply_theme(app, theme_name: str = "light"):
|
||||
"""Apply theme to application"""
|
||||
theme = LIGHT_THEME if theme_name == "light" else DARK_THEME
|
||||
app.setStyleSheet(get_stylesheet(theme))
|
||||
13
utils/__init__.py
Normal file
13
utils/__init__.py
Normal file
@@ -0,0 +1,13 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""工具模块"""
|
||||
|
||||
from .storage import load_config, save_config
|
||||
from .crypto import encrypt_password, decrypt_password, is_encrypted
|
||||
from .worker import Worker, WorkerSignals
|
||||
|
||||
__all__ = [
|
||||
'load_config', 'save_config',
|
||||
'encrypt_password', 'decrypt_password', 'is_encrypted',
|
||||
'Worker', 'WorkerSignals'
|
||||
]
|
||||
156
utils/crypto.py
Normal file
156
utils/crypto.py
Normal file
@@ -0,0 +1,156 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
加密工具模块 - 精简版
|
||||
用于加密存储敏感信息(如第三方账号密码)
|
||||
使用Fernet对称加密
|
||||
"""
|
||||
|
||||
import os
|
||||
import base64
|
||||
from pathlib import Path
|
||||
from cryptography.fernet import Fernet
|
||||
from cryptography.hazmat.primitives import hashes
|
||||
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
|
||||
|
||||
|
||||
def _get_key_paths():
|
||||
"""获取密钥文件路径"""
|
||||
from config import ENCRYPTION_KEY_FILE, ENCRYPTION_SALT_FILE
|
||||
return ENCRYPTION_KEY_FILE, ENCRYPTION_SALT_FILE
|
||||
|
||||
|
||||
def _get_or_create_salt() -> bytes:
|
||||
"""获取或创建盐值"""
|
||||
_, salt_path = _get_key_paths()
|
||||
|
||||
if salt_path.exists():
|
||||
with open(salt_path, 'rb') as f:
|
||||
return f.read()
|
||||
|
||||
# 生成新的盐值
|
||||
salt = os.urandom(16)
|
||||
salt_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(salt_path, 'wb') as f:
|
||||
f.write(salt)
|
||||
return salt
|
||||
|
||||
|
||||
def _derive_key(password: bytes, salt: bytes) -> bytes:
|
||||
"""从密码派生加密密钥"""
|
||||
kdf = PBKDF2HMAC(
|
||||
algorithm=hashes.SHA256(),
|
||||
length=32,
|
||||
salt=salt,
|
||||
iterations=480000, # OWASP推荐的迭代次数
|
||||
)
|
||||
return base64.urlsafe_b64encode(kdf.derive(password))
|
||||
|
||||
|
||||
def get_encryption_key() -> bytes:
|
||||
"""获取加密密钥(优先环境变量,否则从文件读取或生成)"""
|
||||
key_path, _ = _get_key_paths()
|
||||
|
||||
# 优先从环境变量读取
|
||||
env_key = os.environ.get('ENCRYPTION_KEY')
|
||||
if env_key:
|
||||
salt = _get_or_create_salt()
|
||||
return _derive_key(env_key.encode(), salt)
|
||||
|
||||
# 从文件读取
|
||||
if key_path.exists():
|
||||
with open(key_path, 'rb') as f:
|
||||
return f.read()
|
||||
|
||||
# 生成新的密钥
|
||||
key = Fernet.generate_key()
|
||||
key_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(key_path, 'wb') as f:
|
||||
f.write(key)
|
||||
print(f"[OK] 已生成新的加密密钥")
|
||||
return key
|
||||
|
||||
|
||||
# 全局Fernet实例
|
||||
_fernet = None
|
||||
|
||||
|
||||
def _get_fernet() -> Fernet:
|
||||
"""获取Fernet加密器(懒加载)"""
|
||||
global _fernet
|
||||
if _fernet is None:
|
||||
key = get_encryption_key()
|
||||
_fernet = Fernet(key)
|
||||
return _fernet
|
||||
|
||||
|
||||
def encrypt_password(plain_password: str) -> str:
|
||||
"""
|
||||
加密密码
|
||||
|
||||
Args:
|
||||
plain_password: 明文密码
|
||||
|
||||
Returns:
|
||||
str: 加密后的密码(base64编码)
|
||||
"""
|
||||
if not plain_password:
|
||||
return ''
|
||||
|
||||
fernet = _get_fernet()
|
||||
encrypted = fernet.encrypt(plain_password.encode('utf-8'))
|
||||
return encrypted.decode('utf-8')
|
||||
|
||||
|
||||
def decrypt_password(encrypted_password: str) -> str:
|
||||
"""
|
||||
解密密码
|
||||
|
||||
Args:
|
||||
encrypted_password: 加密的密码
|
||||
|
||||
Returns:
|
||||
str: 明文密码
|
||||
"""
|
||||
if not encrypted_password:
|
||||
return ''
|
||||
|
||||
try:
|
||||
fernet = _get_fernet()
|
||||
decrypted = fernet.decrypt(encrypted_password.encode('utf-8'))
|
||||
return decrypted.decode('utf-8')
|
||||
except Exception as e:
|
||||
# 解密失败,可能是旧的明文密码
|
||||
print(f"[Warning] 密码解密失败,可能是未加密的旧数据: {e}")
|
||||
return encrypted_password
|
||||
|
||||
|
||||
def is_encrypted(password: str) -> bool:
|
||||
"""
|
||||
检查密码是否已加密
|
||||
Fernet加密的数据以'gAAAAA'开头
|
||||
|
||||
Args:
|
||||
password: 要检查的密码
|
||||
|
||||
Returns:
|
||||
bool: 是否已加密
|
||||
"""
|
||||
if not password:
|
||||
return False
|
||||
return password.startswith('gAAAAA')
|
||||
|
||||
|
||||
def migrate_password(password: str) -> str:
|
||||
"""
|
||||
迁移密码:如果是明文则加密,如果已加密则保持不变
|
||||
|
||||
Args:
|
||||
password: 密码(可能是明文或已加密)
|
||||
|
||||
Returns:
|
||||
str: 加密后的密码
|
||||
"""
|
||||
if is_encrypted(password):
|
||||
return password
|
||||
return encrypt_password(password)
|
||||
398
utils/storage.py
Normal file
398
utils/storage.py
Normal file
@@ -0,0 +1,398 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
SQLite storage module - local database for config and accounts
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, List, Optional
|
||||
from contextlib import contextmanager
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from config import AppConfig, AccountConfig
|
||||
|
||||
|
||||
def _get_db_path() -> Path:
|
||||
"""Get database file path"""
|
||||
from config import DATA_DIR
|
||||
return DATA_DIR / "zsglpt.db"
|
||||
|
||||
|
||||
@contextmanager
|
||||
def get_connection():
|
||||
"""Get database connection with context manager"""
|
||||
db_path = _get_db_path()
|
||||
db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
conn.row_factory = sqlite3.Row
|
||||
try:
|
||||
yield conn
|
||||
conn.commit()
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def init_database():
|
||||
"""Initialize database tables"""
|
||||
with get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Accounts table
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS accounts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
password TEXT NOT NULL,
|
||||
remark TEXT DEFAULT '',
|
||||
enabled INTEGER DEFAULT 1,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
''')
|
||||
|
||||
# Settings table (key-value store)
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
''')
|
||||
|
||||
# Create index
|
||||
cursor.execute('''
|
||||
CREATE INDEX IF NOT EXISTS idx_accounts_username ON accounts(username)
|
||||
''')
|
||||
|
||||
|
||||
def _ensure_db():
|
||||
"""Ensure database is initialized"""
|
||||
db_path = _get_db_path()
|
||||
if not db_path.exists():
|
||||
init_database()
|
||||
|
||||
|
||||
# ==================== Account Operations ====================
|
||||
|
||||
def get_all_accounts() -> List[dict]:
|
||||
"""Get all accounts from database"""
|
||||
_ensure_db()
|
||||
with get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('SELECT * FROM accounts ORDER BY id')
|
||||
rows = cursor.fetchall()
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
|
||||
def get_account_by_username(username: str) -> Optional[dict]:
|
||||
"""Get account by username"""
|
||||
_ensure_db()
|
||||
with get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('SELECT * FROM accounts WHERE username = ?', (username,))
|
||||
row = cursor.fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
def add_account(username: str, password: str, remark: str = '', enabled: bool = True) -> int:
|
||||
"""Add new account, returns account id"""
|
||||
_ensure_db()
|
||||
with get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('''
|
||||
INSERT INTO accounts (username, password, remark, enabled)
|
||||
VALUES (?, ?, ?, ?)
|
||||
''', (username, password, remark, 1 if enabled else 0))
|
||||
return cursor.lastrowid
|
||||
|
||||
|
||||
def update_account(account_id: int, username: str, password: str, remark: str, enabled: bool):
|
||||
"""Update existing account"""
|
||||
_ensure_db()
|
||||
with get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('''
|
||||
UPDATE accounts
|
||||
SET username = ?, password = ?, remark = ?, enabled = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
''', (username, password, remark, 1 if enabled else 0, account_id))
|
||||
|
||||
|
||||
def delete_account(account_id: int):
|
||||
"""Delete account by id"""
|
||||
_ensure_db()
|
||||
with get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('DELETE FROM accounts WHERE id = ?', (account_id,))
|
||||
|
||||
|
||||
def delete_account_by_username(username: str):
|
||||
"""Delete account by username"""
|
||||
_ensure_db()
|
||||
with get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('DELETE FROM accounts WHERE username = ?', (username,))
|
||||
|
||||
|
||||
# ==================== Settings Operations ====================
|
||||
|
||||
def get_setting(key: str, default: str = '') -> str:
|
||||
"""Get setting value by key"""
|
||||
_ensure_db()
|
||||
with get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('SELECT value FROM settings WHERE key = ?', (key,))
|
||||
row = cursor.fetchone()
|
||||
return row['value'] if row else default
|
||||
|
||||
|
||||
def set_setting(key: str, value: str):
|
||||
"""Set setting value"""
|
||||
_ensure_db()
|
||||
with get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('''
|
||||
INSERT OR REPLACE INTO settings (key, value, updated_at)
|
||||
VALUES (?, ?, CURRENT_TIMESTAMP)
|
||||
''', (key, value))
|
||||
|
||||
|
||||
def get_all_settings() -> dict:
|
||||
"""Get all settings as dictionary"""
|
||||
_ensure_db()
|
||||
with get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('SELECT key, value FROM settings')
|
||||
rows = cursor.fetchall()
|
||||
return {row['key']: row['value'] for row in rows}
|
||||
|
||||
|
||||
# ==================== Config Bridge (compatibility with existing code) ====================
|
||||
|
||||
def load_config() -> "AppConfig":
|
||||
"""
|
||||
Load config from SQLite database
|
||||
Returns AppConfig object for compatibility
|
||||
"""
|
||||
from config import AppConfig, AccountConfig, KDocsConfig, ScreenshotConfig, ProxyConfig, ZSGLConfig, SCREENSHOTS_DIR
|
||||
|
||||
_ensure_db()
|
||||
|
||||
config = AppConfig()
|
||||
|
||||
# Load accounts
|
||||
accounts = get_all_accounts()
|
||||
config.accounts = [
|
||||
AccountConfig(
|
||||
username=a['username'],
|
||||
password=a['password'],
|
||||
remark=a['remark'] or '',
|
||||
enabled=bool(a['enabled'])
|
||||
) for a in accounts
|
||||
]
|
||||
|
||||
# Load settings
|
||||
settings = get_all_settings()
|
||||
|
||||
# KDocs config - 默认文档链接
|
||||
DEFAULT_KDOCS_URL = 'https://kdocs.cn/l/cpwEOo5ynKX4'
|
||||
config.kdocs = KDocsConfig(
|
||||
enabled=settings.get('kdocs_enabled', 'false').lower() == 'true',
|
||||
doc_url=settings.get('kdocs_doc_url', '') or DEFAULT_KDOCS_URL, # 空字符串也用默认值
|
||||
sheet_name=settings.get('kdocs_sheet_name', 'Sheet1'),
|
||||
sheet_index=int(settings.get('kdocs_sheet_index', '0')),
|
||||
unit_column=settings.get('kdocs_unit_column', 'A'),
|
||||
image_column=settings.get('kdocs_image_column', 'D'),
|
||||
unit=settings.get('kdocs_unit', ''),
|
||||
name_column=settings.get('kdocs_name_column', 'C'),
|
||||
row_start=int(settings.get('kdocs_row_start', '0')),
|
||||
row_end=int(settings.get('kdocs_row_end', '0')),
|
||||
)
|
||||
|
||||
# Screenshot config
|
||||
config.screenshot = ScreenshotConfig(
|
||||
dir=settings.get('screenshot_dir', str(SCREENSHOTS_DIR)),
|
||||
quality=int(settings.get('screenshot_quality', '95')),
|
||||
width=int(settings.get('screenshot_width', '1920')),
|
||||
height=int(settings.get('screenshot_height', '1080')),
|
||||
js_delay_ms=int(settings.get('screenshot_js_delay_ms', '3000')),
|
||||
timeout_seconds=int(settings.get('screenshot_timeout_seconds', '60')),
|
||||
wkhtmltoimage_path=settings.get('screenshot_wkhtmltoimage_path', ''),
|
||||
)
|
||||
|
||||
# Proxy config
|
||||
config.proxy = ProxyConfig(
|
||||
enabled=settings.get('proxy_enabled', 'false').lower() == 'true',
|
||||
server=settings.get('proxy_server', ''),
|
||||
)
|
||||
|
||||
# ZSGL config
|
||||
config.zsgl = ZSGLConfig(
|
||||
base_url=settings.get('zsgl_base_url', 'https://postoa.aidunsoft.com'),
|
||||
login_url=settings.get('zsgl_login_url', 'https://postoa.aidunsoft.com/admin/login.aspx'),
|
||||
index_url_pattern=settings.get('zsgl_index_url_pattern', 'index.aspx'),
|
||||
)
|
||||
|
||||
# Theme
|
||||
config.theme = settings.get('theme', 'light')
|
||||
|
||||
return config
|
||||
|
||||
|
||||
def save_config(config: "AppConfig") -> bool:
|
||||
"""
|
||||
Save config to SQLite database
|
||||
"""
|
||||
_ensure_db()
|
||||
|
||||
try:
|
||||
with get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Save accounts - first get existing accounts
|
||||
existing_usernames = set()
|
||||
cursor.execute('SELECT username FROM accounts')
|
||||
for row in cursor.fetchall():
|
||||
existing_usernames.add(row['username'])
|
||||
|
||||
# Update or insert accounts
|
||||
config_usernames = set()
|
||||
for account in config.accounts:
|
||||
config_usernames.add(account.username)
|
||||
|
||||
if account.username in existing_usernames:
|
||||
# Update existing
|
||||
cursor.execute('''
|
||||
UPDATE accounts
|
||||
SET password = ?, remark = ?, enabled = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE username = ?
|
||||
''', (account.password, account.remark, 1 if account.enabled else 0, account.username))
|
||||
else:
|
||||
# Insert new
|
||||
cursor.execute('''
|
||||
INSERT INTO accounts (username, password, remark, enabled)
|
||||
VALUES (?, ?, ?, ?)
|
||||
''', (account.username, account.password, account.remark, 1 if account.enabled else 0))
|
||||
|
||||
# Delete removed accounts
|
||||
removed = existing_usernames - config_usernames
|
||||
for username in removed:
|
||||
cursor.execute('DELETE FROM accounts WHERE username = ?', (username,))
|
||||
|
||||
# Save settings
|
||||
settings_to_save = {
|
||||
# KDocs
|
||||
'kdocs_enabled': str(config.kdocs.enabled).lower(),
|
||||
'kdocs_doc_url': config.kdocs.doc_url,
|
||||
'kdocs_sheet_name': config.kdocs.sheet_name,
|
||||
'kdocs_sheet_index': str(config.kdocs.sheet_index),
|
||||
'kdocs_unit_column': config.kdocs.unit_column,
|
||||
'kdocs_image_column': config.kdocs.image_column,
|
||||
'kdocs_unit': config.kdocs.unit,
|
||||
'kdocs_name_column': config.kdocs.name_column,
|
||||
'kdocs_row_start': str(config.kdocs.row_start),
|
||||
'kdocs_row_end': str(config.kdocs.row_end),
|
||||
|
||||
# Screenshot
|
||||
'screenshot_dir': config.screenshot.dir,
|
||||
'screenshot_quality': str(config.screenshot.quality),
|
||||
'screenshot_width': str(config.screenshot.width),
|
||||
'screenshot_height': str(config.screenshot.height),
|
||||
'screenshot_js_delay_ms': str(config.screenshot.js_delay_ms),
|
||||
'screenshot_timeout_seconds': str(config.screenshot.timeout_seconds),
|
||||
'screenshot_wkhtmltoimage_path': config.screenshot.wkhtmltoimage_path,
|
||||
|
||||
# Proxy
|
||||
'proxy_enabled': str(config.proxy.enabled).lower(),
|
||||
'proxy_server': config.proxy.server,
|
||||
|
||||
# ZSGL
|
||||
'zsgl_base_url': config.zsgl.base_url,
|
||||
'zsgl_login_url': config.zsgl.login_url,
|
||||
'zsgl_index_url_pattern': config.zsgl.index_url_pattern,
|
||||
|
||||
# Theme
|
||||
'theme': config.theme,
|
||||
}
|
||||
|
||||
for key, value in settings_to_save.items():
|
||||
cursor.execute('''
|
||||
INSERT OR REPLACE INTO settings (key, value, updated_at)
|
||||
VALUES (?, ?, CURRENT_TIMESTAMP)
|
||||
''', (key, value))
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"[Error] Save config failed: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def backup_config() -> bool:
|
||||
"""Backup database file"""
|
||||
db_path = _get_db_path()
|
||||
|
||||
if not db_path.exists():
|
||||
return False
|
||||
|
||||
backup_path = db_path.with_suffix('.db.bak')
|
||||
|
||||
try:
|
||||
import shutil
|
||||
shutil.copy2(db_path, backup_path)
|
||||
return True
|
||||
except IOError as e:
|
||||
print(f"[Error] Backup failed: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def restore_config() -> bool:
|
||||
"""Restore database from backup"""
|
||||
db_path = _get_db_path()
|
||||
backup_path = db_path.with_suffix('.db.bak')
|
||||
|
||||
if not backup_path.exists():
|
||||
return False
|
||||
|
||||
try:
|
||||
import shutil
|
||||
shutil.copy2(backup_path, db_path)
|
||||
return True
|
||||
except IOError as e:
|
||||
print(f"[Error] Restore failed: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def migrate_from_json():
|
||||
"""Migrate data from old JSON config to SQLite"""
|
||||
from config import CONFIG_FILE
|
||||
|
||||
if not CONFIG_FILE.exists():
|
||||
return False
|
||||
|
||||
try:
|
||||
with open(CONFIG_FILE, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
|
||||
# Load using old format
|
||||
from config import AppConfig
|
||||
old_config = AppConfig.from_dict(data)
|
||||
|
||||
# Save to SQLite
|
||||
save_config(old_config)
|
||||
|
||||
# Rename old file
|
||||
backup = CONFIG_FILE.with_suffix('.json.migrated')
|
||||
CONFIG_FILE.rename(backup)
|
||||
|
||||
print(f"[Info] Migrated from JSON to SQLite, old file renamed to {backup}")
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"[Error] Migration failed: {e}")
|
||||
return False
|
||||
193
utils/worker.py
Normal file
193
utils/worker.py
Normal file
@@ -0,0 +1,193 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
后台线程管理模块
|
||||
使用QThread实现非阻塞的后台任务
|
||||
"""
|
||||
|
||||
from typing import Callable, Any, Optional
|
||||
from PyQt6.QtCore import QObject, QThread, pyqtSignal
|
||||
|
||||
|
||||
class WorkerSignals(QObject):
|
||||
"""工作线程信号类"""
|
||||
# 进度信号:(百分比, 消息)
|
||||
progress = pyqtSignal(int, str)
|
||||
# 日志信号:日志消息
|
||||
log = pyqtSignal(str)
|
||||
# 完成信号:(成功/失败, 结果/错误消息)
|
||||
finished = pyqtSignal(bool, str)
|
||||
# 截图完成信号:截图文件路径
|
||||
screenshot_ready = pyqtSignal(str)
|
||||
# 通用结果信号:任意结果对象
|
||||
result = pyqtSignal(object)
|
||||
# 错误信号
|
||||
error = pyqtSignal(str)
|
||||
|
||||
|
||||
class Worker(QThread):
|
||||
"""
|
||||
通用后台工作线程
|
||||
|
||||
用法:
|
||||
worker = Worker(some_function, arg1, arg2, kwarg1=value1)
|
||||
worker.signals.finished.connect(on_finished)
|
||||
worker.signals.progress.connect(on_progress)
|
||||
worker.start()
|
||||
"""
|
||||
|
||||
def __init__(self, fn: Callable, *args, **kwargs):
|
||||
super().__init__()
|
||||
self.fn = fn
|
||||
self.args = args
|
||||
self.kwargs = kwargs
|
||||
self.signals = WorkerSignals()
|
||||
self._is_stopped = False
|
||||
|
||||
def run(self):
|
||||
"""执行后台任务"""
|
||||
try:
|
||||
# 把信号传递给任务函数,方便回调
|
||||
self.kwargs['_signals'] = self.signals
|
||||
self.kwargs['_should_stop'] = self.should_stop
|
||||
|
||||
result = self.fn(*self.args, **self.kwargs)
|
||||
|
||||
if not self._is_stopped:
|
||||
self.signals.result.emit(result)
|
||||
self.signals.finished.emit(True, "完成")
|
||||
except Exception as e:
|
||||
if not self._is_stopped:
|
||||
error_msg = str(e)
|
||||
self.signals.error.emit(error_msg)
|
||||
self.signals.finished.emit(False, error_msg)
|
||||
|
||||
def stop(self):
|
||||
"""停止线程"""
|
||||
self._is_stopped = True
|
||||
|
||||
def should_stop(self) -> bool:
|
||||
"""检查是否应该停止"""
|
||||
return self._is_stopped
|
||||
|
||||
|
||||
class TaskRunner:
|
||||
"""
|
||||
任务运行器
|
||||
管理多个后台任务,确保不会同时运行太多任务
|
||||
"""
|
||||
|
||||
def __init__(self, max_workers: int = 1):
|
||||
self.max_workers = max_workers
|
||||
self._workers: list[Worker] = []
|
||||
self._queue: list[tuple] = [] # (fn, args, kwargs, callbacks)
|
||||
|
||||
def submit(self, fn: Callable, *args,
|
||||
on_progress: Optional[Callable] = None,
|
||||
on_log: Optional[Callable] = None,
|
||||
on_finished: Optional[Callable] = None,
|
||||
on_result: Optional[Callable] = None,
|
||||
on_error: Optional[Callable] = None,
|
||||
**kwargs) -> Optional[Worker]:
|
||||
"""
|
||||
提交任务
|
||||
|
||||
Args:
|
||||
fn: 要执行的函数
|
||||
*args: 位置参数
|
||||
on_progress: 进度回调 (percent, message)
|
||||
on_log: 日志回调 (message)
|
||||
on_finished: 完成回调 (success, message)
|
||||
on_result: 结果回调 (result)
|
||||
on_error: 错误回调 (error_message)
|
||||
**kwargs: 关键字参数
|
||||
|
||||
Returns:
|
||||
Worker对象,如果队列满了返回None
|
||||
"""
|
||||
callbacks = {
|
||||
'on_progress': on_progress,
|
||||
'on_log': on_log,
|
||||
'on_finished': on_finished,
|
||||
'on_result': on_result,
|
||||
'on_error': on_error,
|
||||
}
|
||||
|
||||
# 清理已完成的worker
|
||||
self._workers = [w for w in self._workers if w.isRunning()]
|
||||
|
||||
if len(self._workers) >= self.max_workers:
|
||||
# 加入队列等待
|
||||
self._queue.append((fn, args, kwargs, callbacks))
|
||||
return None
|
||||
|
||||
return self._start_worker(fn, args, kwargs, callbacks)
|
||||
|
||||
def _start_worker(self, fn: Callable, args: tuple, kwargs: dict,
|
||||
callbacks: dict) -> Worker:
|
||||
"""启动一个worker"""
|
||||
worker = Worker(fn, *args, **kwargs)
|
||||
|
||||
# 连接信号
|
||||
if callbacks.get('on_progress'):
|
||||
worker.signals.progress.connect(callbacks['on_progress'])
|
||||
if callbacks.get('on_log'):
|
||||
worker.signals.log.connect(callbacks['on_log'])
|
||||
if callbacks.get('on_result'):
|
||||
worker.signals.result.connect(callbacks['on_result'])
|
||||
if callbacks.get('on_error'):
|
||||
worker.signals.error.connect(callbacks['on_error'])
|
||||
|
||||
# 完成时的处理
|
||||
def on_worker_finished(success, message):
|
||||
if callbacks.get('on_finished'):
|
||||
callbacks['on_finished'](success, message)
|
||||
# 处理队列中的下一个任务
|
||||
self._process_queue()
|
||||
|
||||
worker.signals.finished.connect(on_worker_finished)
|
||||
|
||||
self._workers.append(worker)
|
||||
worker.start()
|
||||
return worker
|
||||
|
||||
def _process_queue(self):
|
||||
"""处理队列中的任务"""
|
||||
# 清理已完成的worker
|
||||
self._workers = [w for w in self._workers if w.isRunning()]
|
||||
|
||||
if self._queue and len(self._workers) < self.max_workers:
|
||||
fn, args, kwargs, callbacks = self._queue.pop(0)
|
||||
self._start_worker(fn, args, kwargs, callbacks)
|
||||
|
||||
def stop_all(self):
|
||||
"""停止所有任务"""
|
||||
for worker in self._workers:
|
||||
worker.stop()
|
||||
self._queue.clear()
|
||||
|
||||
def is_running(self) -> bool:
|
||||
"""检查是否有任务在运行"""
|
||||
return any(w.isRunning() for w in self._workers)
|
||||
|
||||
@property
|
||||
def running_count(self) -> int:
|
||||
"""运行中的任务数"""
|
||||
return sum(1 for w in self._workers if w.isRunning())
|
||||
|
||||
@property
|
||||
def queue_size(self) -> int:
|
||||
"""队列中等待的任务数"""
|
||||
return len(self._queue)
|
||||
|
||||
|
||||
# 全局任务运行器
|
||||
_task_runner: Optional[TaskRunner] = None
|
||||
|
||||
|
||||
def get_task_runner() -> TaskRunner:
|
||||
"""获取全局任务运行器"""
|
||||
global _task_runner
|
||||
if _task_runner is None:
|
||||
_task_runner = TaskRunner(max_workers=1)
|
||||
return _task_runner
|
||||
Reference in New Issue
Block a user