Files
zsglpt-pc/ui/kdocs_widget.py
237899745 83fef6dff2 feat: 知识管理平台精简版 - PyQt6桌面应用
主要功能:
- 账号管理:添加/编辑/删除账号,测试登录
- 浏览任务:批量浏览应读/选读内容并标记已读
- 截图管理:wkhtmltoimage截图,查看历史
- 金山文档:扫码登录/微信快捷登录,自动上传截图

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-18 22:16:36 +08:00

651 lines
27 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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()