From 83fef6dff209cde48c699b07256d5c182328d446 Mon Sep 17 00:00:00 2001 From: 237899745 <237899745@qq.com> Date: Sun, 18 Jan 2026 22:16:36 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=9F=A5=E8=AF=86=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E5=B9=B3=E5=8F=B0=E7=B2=BE=E7=AE=80=E7=89=88=20-=20PyQt6?= =?UTF-8?q?=E6=A1=8C=E9=9D=A2=E5=BA=94=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 主要功能: - 账号管理:添加/编辑/删除账号,测试登录 - 浏览任务:批量浏览应读/选读内容并标记已读 - 截图管理:wkhtmltoimage截图,查看历史 - 金山文档:扫码登录/微信快捷登录,自动上传截图 技术栈: - PyQt6 GUI框架 - Playwright 浏览器自动化 - SQLite 本地数据存储 - wkhtmltoimage 网页截图 Co-Authored-By: Claude Opus 4.5 --- .gitignore | 52 +++ README.md | 123 ++++++ config.py | 228 +++++++++++ core/__init__.py | 13 + core/api_browser.py | 504 ++++++++++++++++++++++++ core/kdocs_uploader.py | 823 ++++++++++++++++++++++++++++++++++++++++ core/screenshot.py | 324 ++++++++++++++++ main.py | 89 +++++ requirements.txt | 19 + screenshot_test.py | 25 ++ ui/__init__.py | 13 + ui/account_widget.py | 416 ++++++++++++++++++++ ui/browse_widget.py | 356 +++++++++++++++++ ui/constants.py | 136 +++++++ ui/kdocs_widget.py | 650 +++++++++++++++++++++++++++++++ ui/log_widget.py | 120 ++++++ ui/main_window.py | 195 ++++++++++ ui/screenshot_widget.py | 387 +++++++++++++++++++ ui/settings_widget.py | 265 +++++++++++++ ui/styles.py | 635 +++++++++++++++++++++++++++++++ utils/__init__.py | 13 + utils/crypto.py | 156 ++++++++ utils/storage.py | 398 +++++++++++++++++++ utils/worker.py | 193 ++++++++++ 24 files changed, 6133 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 config.py create mode 100644 core/__init__.py create mode 100644 core/api_browser.py create mode 100644 core/kdocs_uploader.py create mode 100644 core/screenshot.py create mode 100644 main.py create mode 100644 requirements.txt create mode 100644 screenshot_test.py create mode 100644 ui/__init__.py create mode 100644 ui/account_widget.py create mode 100644 ui/browse_widget.py create mode 100644 ui/constants.py create mode 100644 ui/kdocs_widget.py create mode 100644 ui/log_widget.py create mode 100644 ui/main_window.py create mode 100644 ui/screenshot_widget.py create mode 100644 ui/settings_widget.py create mode 100644 ui/styles.py create mode 100644 utils/__init__.py create mode 100644 utils/crypto.py create mode 100644 utils/storage.py create mode 100644 utils/worker.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8a19e97 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..7ff486e --- /dev/null +++ b/README.md @@ -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 diff --git a/config.py b/config.py new file mode 100644 index 0000000..18d1b2e --- /dev/null +++ b/config.py @@ -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 diff --git a/core/__init__.py b/core/__init__.py new file mode 100644 index 0000000..89045e5 --- /dev/null +++ b/core/__init__.py @@ -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' +] diff --git a/core/api_browser.py b/core/api_browser.py new file mode 100644 index 0000000..dd4feb3 --- /dev/null +++ b/core/api_browser.py @@ -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 diff --git a/core/kdocs_uploader.py b/core/kdocs_uploader.py new file mode 100644 index 0000000..ae6122f --- /dev/null +++ b/core/kdocs_uploader.py @@ -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 diff --git a/core/screenshot.py b/core/screenshot.py new file mode 100644 index 0000000..78bccaf --- /dev/null +++ b/core/screenshot.py @@ -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 diff --git a/main.py b/main.py new file mode 100644 index 0000000..bb039e0 --- /dev/null +++ b/main.py @@ -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() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..1c6f42d --- /dev/null +++ b/requirements.txt @@ -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 diff --git a/screenshot_test.py b/screenshot_test.py new file mode 100644 index 0000000..87017e9 --- /dev/null +++ b/screenshot_test.py @@ -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() diff --git a/ui/__init__.py b/ui/__init__.py new file mode 100644 index 0000000..c4d53ce --- /dev/null +++ b/ui/__init__.py @@ -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', +] diff --git a/ui/account_widget.py b/ui/account_widget.py new file mode 100644 index 0000000..b88835e --- /dev/null +++ b/ui/account_widget.py @@ -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] diff --git a/ui/browse_widget.py b/ui/browse_widget.py new file mode 100644 index 0000000..d2be29e --- /dev/null +++ b/ui/browse_widget.py @@ -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) diff --git a/ui/constants.py b/ui/constants.py new file mode 100644 index 0000000..0dd52dd --- /dev/null +++ b/ui/constants.py @@ -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;" diff --git a/ui/kdocs_widget.py b/ui/kdocs_widget.py new file mode 100644 index 0000000..5075918 --- /dev/null +++ b/ui/kdocs_widget.py @@ -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() + diff --git a/ui/log_widget.py b/ui/log_widget.py new file mode 100644 index 0000000..fed559d --- /dev/null +++ b/ui/log_widget.py @@ -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() diff --git a/ui/main_window.py b/ui/main_window.py new file mode 100644 index 0000000..9737b77 --- /dev/null +++ b/ui/main_window.py @@ -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() diff --git a/ui/screenshot_widget.py b/ui/screenshot_widget.py new file mode 100644 index 0000000..7a7bcf5 --- /dev/null +++ b/ui/screenshot_widget.py @@ -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)]) diff --git a/ui/settings_widget.py b/ui/settings_widget.py new file mode 100644 index 0000000..79fa92a --- /dev/null +++ b/ui/settings_widget.py @@ -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("已恢复默认设置") diff --git a/ui/styles.py b/ui/styles.py new file mode 100644 index 0000000..a9c5b38 --- /dev/null +++ b/ui/styles.py @@ -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)) diff --git a/utils/__init__.py b/utils/__init__.py new file mode 100644 index 0000000..a004346 --- /dev/null +++ b/utils/__init__.py @@ -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' +] diff --git a/utils/crypto.py b/utils/crypto.py new file mode 100644 index 0000000..164ffb8 --- /dev/null +++ b/utils/crypto.py @@ -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) diff --git a/utils/storage.py b/utils/storage.py new file mode 100644 index 0000000..4de2ecd --- /dev/null +++ b/utils/storage.py @@ -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 diff --git a/utils/worker.py b/utils/worker.py new file mode 100644 index 0000000..cb4424e --- /dev/null +++ b/utils/worker.py @@ -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