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

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

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

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

387
ui/screenshot_widget.py Normal file
View File

@@ -0,0 +1,387 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Screenshot management panel - manual screenshot, view history, open directory
"""
import os
import subprocess
import sys
from datetime import datetime
from PyQt6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton,
QListWidget, QListWidgetItem, QComboBox, QGroupBox, QMessageBox,
QFileDialog, QScrollArea, QFrame, QSizePolicy
)
from PyQt6.QtCore import Qt, pyqtSignal, QSize
from PyQt6.QtGui import QPixmap, QIcon
from config import get_config, SCREENSHOTS_DIR
from utils.crypto import decrypt_password, is_encrypted
from .constants import (
PAGE_PADDING, SECTION_SPACING, GROUP_PADDING_TOP, GROUP_PADDING_SIDE,
GROUP_PADDING_BOTTOM, GROUP_SPACING, FORM_LABEL_WIDTH, FORM_H_SPACING,
INPUT_HEIGHT, INPUT_MIN_WIDTH, BUTTON_HEIGHT, BUTTON_HEIGHT_NORMAL,
BUTTON_MIN_WIDTH, BUTTON_SPACING, LIST_MIN_HEIGHT, ICON_SIZE_LARGE,
get_title_style
)
class ScreenshotWidget(QWidget):
"""Screenshot management panel"""
log_signal = pyqtSignal(str)
def __init__(self, parent=None):
super().__init__(parent)
self._worker = None
self._setup_ui()
self._refresh_screenshots()
def _setup_ui(self):
main_layout = QVBoxLayout(self)
main_layout.setContentsMargins(0, 0, 0, 0)
main_layout.setSpacing(0)
# Scroll area
scroll = QScrollArea()
scroll.setWidgetResizable(True)
scroll.setFrameShape(QFrame.Shape.NoFrame)
scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
content = QWidget()
layout = QVBoxLayout(content)
layout.setContentsMargins(PAGE_PADDING, PAGE_PADDING, PAGE_PADDING, PAGE_PADDING)
layout.setSpacing(SECTION_SPACING)
# Title
title = QLabel("📸 截图管理")
title.setStyleSheet(get_title_style())
layout.addWidget(title)
# ==================== Manual screenshot ====================
manual_group = QGroupBox("手动截图")
manual_layout = QVBoxLayout(manual_group)
manual_layout.setContentsMargins(GROUP_PADDING_SIDE, GROUP_PADDING_TOP, GROUP_PADDING_SIDE, GROUP_PADDING_BOTTOM)
manual_layout.setSpacing(GROUP_SPACING)
# Account row
account_layout = QHBoxLayout()
account_layout.setSpacing(FORM_H_SPACING)
account_label = QLabel("选择账号:")
account_label.setFixedWidth(FORM_LABEL_WIDTH)
account_layout.addWidget(account_label)
self.account_combo = QComboBox()
self.account_combo.setMinimumWidth(INPUT_MIN_WIDTH + 100)
self.account_combo.setMinimumHeight(INPUT_HEIGHT)
account_layout.addWidget(self.account_combo)
refresh_account_btn = QPushButton("🔄 刷新")
refresh_account_btn.setMinimumHeight(BUTTON_HEIGHT_NORMAL)
refresh_account_btn.clicked.connect(self._refresh_accounts)
account_layout.addWidget(refresh_account_btn)
account_layout.addStretch()
manual_layout.addLayout(account_layout)
# Browse type row
type_layout = QHBoxLayout()
type_layout.setSpacing(FORM_H_SPACING)
type_label = QLabel("浏览类型:")
type_label.setFixedWidth(FORM_LABEL_WIDTH)
type_layout.addWidget(type_label)
self.browse_type_combo = QComboBox()
self.browse_type_combo.addItems(["应读", "注册前未读"])
self.browse_type_combo.setMinimumWidth(INPUT_MIN_WIDTH)
self.browse_type_combo.setMinimumHeight(INPUT_HEIGHT)
type_layout.addWidget(self.browse_type_combo)
type_layout.addStretch()
manual_layout.addLayout(type_layout)
# Screenshot button
btn_layout = QHBoxLayout()
btn_layout.setSpacing(BUTTON_SPACING)
self.screenshot_btn = QPushButton("📷 执行截图")
self.screenshot_btn.setObjectName("primary")
self.screenshot_btn.setMinimumWidth(BUTTON_MIN_WIDTH)
self.screenshot_btn.setMinimumHeight(BUTTON_HEIGHT)
self.screenshot_btn.clicked.connect(self._take_screenshot)
btn_layout.addWidget(self.screenshot_btn)
btn_layout.addStretch()
manual_layout.addLayout(btn_layout)
layout.addWidget(manual_group)
# ==================== Screenshot history ====================
history_group = QGroupBox("截图历史")
history_group.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
history_layout = QVBoxLayout(history_group)
history_layout.setContentsMargins(GROUP_PADDING_SIDE, GROUP_PADDING_TOP, GROUP_PADDING_SIDE, GROUP_PADDING_BOTTOM)
history_layout.setSpacing(GROUP_SPACING)
# Toolbar
toolbar = QHBoxLayout()
toolbar.setSpacing(BUTTON_SPACING)
open_dir_btn = QPushButton("📂 打开目录")
open_dir_btn.setMinimumHeight(BUTTON_HEIGHT_NORMAL)
open_dir_btn.clicked.connect(self._open_screenshot_dir)
toolbar.addWidget(open_dir_btn)
toolbar.addStretch()
refresh_btn = QPushButton("🔄 刷新列表")
refresh_btn.setMinimumHeight(BUTTON_HEIGHT_NORMAL)
refresh_btn.clicked.connect(self._refresh_screenshots)
toolbar.addWidget(refresh_btn)
history_layout.addLayout(toolbar)
# Screenshot list - 图标模式,一行显示多个
self.screenshot_list = QListWidget()
self.screenshot_list.setViewMode(QListWidget.ViewMode.IconMode) # 图标模式
self.screenshot_list.setIconSize(QSize(150, 100)) # 缩略图大小
self.screenshot_list.setGridSize(QSize(170, 130)) # 网格大小(含文字)
self.screenshot_list.setResizeMode(QListWidget.ResizeMode.Adjust) # 自动调整
self.screenshot_list.setWrapping(True) # 自动换行
self.screenshot_list.setSpacing(8)
self.screenshot_list.setMinimumHeight(200)
self.screenshot_list.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
self.screenshot_list.itemDoubleClicked.connect(self._preview_screenshot)
history_layout.addWidget(self.screenshot_list, 1)
# Action buttons
action_layout = QHBoxLayout()
action_layout.setSpacing(BUTTON_SPACING)
self.delete_all_btn = QPushButton("🗑 删除全部")
self.delete_all_btn.setObjectName("danger")
self.delete_all_btn.setMinimumHeight(BUTTON_HEIGHT_NORMAL)
self.delete_all_btn.clicked.connect(self._delete_all)
action_layout.addWidget(self.delete_all_btn)
action_layout.addStretch()
self.preview_btn = QPushButton("👁 预览")
self.preview_btn.setMinimumHeight(BUTTON_HEIGHT_NORMAL)
self.preview_btn.clicked.connect(self._preview_selected)
action_layout.addWidget(self.preview_btn)
self.delete_btn = QPushButton("🗑 删除选中")
self.delete_btn.setMinimumHeight(BUTTON_HEIGHT_NORMAL)
self.delete_btn.clicked.connect(self._delete_selected)
action_layout.addWidget(self.delete_btn)
history_layout.addLayout(action_layout)
layout.addWidget(history_group, 1)
scroll.setWidget(content)
main_layout.addWidget(scroll)
# Load accounts
self._refresh_accounts()
def _refresh_accounts(self):
"""Refresh account list"""
self.account_combo.clear()
config = get_config()
for account in config.accounts:
if account.enabled:
display_name = account.remark if account.remark else account.username
self.account_combo.addItem(f"{display_name} ({account.username})", account)
def _refresh_screenshots(self):
"""Refresh screenshot list"""
self.screenshot_list.clear()
if not SCREENSHOTS_DIR.exists():
return
files = []
for f in SCREENSHOTS_DIR.iterdir():
if f.suffix.lower() in (".jpg", ".jpeg", ".png"):
files.append(f)
# Sort by modification time, newest first
files.sort(key=lambda x: x.stat().st_mtime, reverse=True)
for f in files[:50]: # Only show last 50
item = QListWidgetItem()
# 文件名太长时截断显示
name = f.stem # 不含扩展名
if len(name) > 15:
name = name[:12] + "..."
item.setText(name)
item.setData(Qt.ItemDataRole.UserRole, str(f))
item.setToolTip(f.name) # 完整文件名显示在提示中
# Try to load thumbnail
try:
pixmap = QPixmap(str(f))
if not pixmap.isNull():
# 缩放到合适大小显示缩略图
scaled = pixmap.scaled(
150, 100,
Qt.AspectRatioMode.KeepAspectRatio,
Qt.TransformationMode.SmoothTransformation
)
item.setIcon(QIcon(scaled))
except Exception as e:
print(f"加载缩略图失败: {f.name}, {e}")
self.screenshot_list.addItem(item)
def _take_screenshot(self):
"""Execute screenshot"""
if self.account_combo.currentIndex() < 0:
QMessageBox.information(self, "提示", "请先选择一个账号")
return
account = self.account_combo.currentData()
if not account:
return
browse_type = self.browse_type_combo.currentText()
password = decrypt_password(account.password) if is_encrypted(account.password) else account.password
self.screenshot_btn.setEnabled(False)
self.screenshot_btn.setText("截图中...")
self.log_signal.emit(f"开始截图: {account.username}")
from utils.worker import Worker
def do_screenshot(_signals=None, _should_stop=None):
from core.screenshot import take_screenshot
from config import get_config as _get_config
cfg = _get_config()
proxy_config = None
if cfg.proxy.enabled and cfg.proxy.server:
proxy_config = {"server": cfg.proxy.server}
result = take_screenshot(
account.username,
password,
browse_type,
remark=account.remark,
log_callback=lambda msg: _signals.log.emit(msg),
proxy_config=proxy_config
)
return result
def on_result(result):
self.screenshot_btn.setEnabled(True)
self.screenshot_btn.setText("📷 执行截图")
if result and result.success:
self.log_signal.emit(f"[OK] 截图成功: {result.filename}")
self._refresh_screenshots()
else:
error = result.error_message if result else "未知错误"
self.log_signal.emit(f"[FAIL] 截图失败: {error}")
def on_error(error):
self.screenshot_btn.setEnabled(True)
self.screenshot_btn.setText("📷 执行截图")
self.log_signal.emit(f"截图出错: {error}")
self._worker = Worker(do_screenshot)
self._worker.signals.log.connect(self.log_signal.emit)
self._worker.signals.result.connect(on_result)
self._worker.signals.error.connect(on_error)
self._worker.start()
def _preview_screenshot(self, item):
"""Preview screenshot"""
filepath = item.data(Qt.ItemDataRole.UserRole)
if filepath and os.path.exists(filepath):
if sys.platform == "win32":
os.startfile(filepath)
elif sys.platform == "darwin":
subprocess.run(["open", filepath])
else:
subprocess.run(["xdg-open", filepath])
def _preview_selected(self):
"""Preview selected screenshot"""
current = self.screenshot_list.currentItem()
if current:
self._preview_screenshot(current)
else:
QMessageBox.information(self, "提示", "请先选择一个截图")
def _delete_selected(self):
"""Delete selected screenshot"""
current = self.screenshot_list.currentItem()
if not current:
QMessageBox.information(self, "提示", "请先选择一个截图")
return
filepath = current.data(Qt.ItemDataRole.UserRole)
filename = current.text()
reply = QMessageBox.question(
self, "确认删除",
f"确定要删除截图 {filename} 吗?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
)
if reply == QMessageBox.StandardButton.Yes:
try:
if os.path.exists(filepath):
os.remove(filepath)
self._refresh_screenshots()
self.log_signal.emit(f"已删除截图: {filename}")
except Exception as e:
QMessageBox.warning(self, "错误", f"删除失败: {e}")
def _delete_all(self):
"""Delete all screenshots"""
count = self.screenshot_list.count()
if count == 0:
QMessageBox.information(self, "提示", "没有截图可删除")
return
reply = QMessageBox.warning(
self, "⚠️ 确认删除全部",
f"确定要删除全部 {count} 张截图吗?\n\n此操作不可恢复!",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No
)
if reply == QMessageBox.StandardButton.Yes:
deleted = 0
failed = 0
for i in range(count):
item = self.screenshot_list.item(i)
filepath = item.data(Qt.ItemDataRole.UserRole)
try:
if os.path.exists(filepath):
os.remove(filepath)
deleted += 1
except Exception:
failed += 1
self._refresh_screenshots()
msg = f"已删除 {deleted} 张截图"
if failed > 0:
msg += f"{failed} 张删除失败"
self.log_signal.emit(msg)
def _open_screenshot_dir(self):
"""Open screenshot directory"""
SCREENSHOTS_DIR.mkdir(exist_ok=True)
if sys.platform == "win32":
os.startfile(str(SCREENSHOTS_DIR))
elif sys.platform == "darwin":
subprocess.run(["open", str(SCREENSHOTS_DIR)])
else:
subprocess.run(["xdg-open", str(SCREENSHOTS_DIR)])