feat: 知识管理平台精简版 - PyQt6桌面应用

主要功能:
- 账号管理:添加/编辑/删除账号,测试登录
- 浏览任务:批量浏览应读/选读内容并标记已读
- 截图管理:wkhtmltoimage截图,查看历史
- 金山文档:扫码登录/微信快捷登录,自动上传截图

技术栈:
- PyQt6 GUI框架
- Playwright 浏览器自动化
- SQLite 本地数据存储
- wkhtmltoimage 网页截图

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-18 22:16:36 +08:00
commit 83fef6dff2
24 changed files with 6133 additions and 0 deletions

52
.gitignore vendored Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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