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

13
ui/__init__.py Normal file
View File

@@ -0,0 +1,13 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""UI模块"""
from .styles import get_stylesheet, LIGHT_THEME, DARK_THEME
from .main_window import MainWindow
from .log_widget import LogWidget
__all__ = [
'get_stylesheet', 'LIGHT_THEME', 'DARK_THEME',
'MainWindow',
'LogWidget',
]

416
ui/account_widget.py Normal file
View File

@@ -0,0 +1,416 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Account management panel - add/edit/delete accounts, test login
"""
from PyQt6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QTableWidget, QTableWidgetItem,
QPushButton, QLineEdit, QLabel, QDialog, QFormLayout, QMessageBox,
QHeaderView, QCheckBox, QScrollArea, QFrame, QSizePolicy, QSpacerItem
)
from PyQt6.QtCore import Qt, pyqtSignal
from config import get_config, save_config, AccountConfig
from utils.crypto import encrypt_password, decrypt_password, is_encrypted
from .constants import (
PAGE_PADDING, SECTION_SPACING, GROUP_PADDING_TOP, GROUP_PADDING_SIDE,
GROUP_PADDING_BOTTOM, GROUP_SPACING, FORM_ROW_SPACING, INPUT_HEIGHT,
BUTTON_HEIGHT, BUTTON_HEIGHT_NORMAL, BUTTON_HEIGHT_SMALL, BUTTON_MIN_WIDTH,
BUTTON_MIN_WIDTH_NORMAL, BUTTON_WIDTH_SMALL, BUTTON_SPACING,
TABLE_ROW_HEIGHT, get_title_style, get_help_text_style
)
class AccountEditDialog(QDialog):
"""Account edit dialog"""
def __init__(self, account: AccountConfig = None, parent=None):
super().__init__(parent)
self.account = account
self.setWindowTitle("编辑账号" if account else "添加账号")
self.setMinimumWidth(480)
self._setup_ui()
self.adjustSize()
def _setup_ui(self):
layout = QVBoxLayout(self)
layout.setContentsMargins(PAGE_PADDING, PAGE_PADDING, PAGE_PADDING, PAGE_PADDING)
layout.setSpacing(SECTION_SPACING)
# Form area
form_layout = QFormLayout()
form_layout.setSpacing(FORM_ROW_SPACING)
form_layout.setLabelAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
form_layout.setFormAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter)
# Username
self.username_edit = QLineEdit()
self.username_edit.setPlaceholderText("请输入用户名")
self.username_edit.setMinimumHeight(INPUT_HEIGHT)
if self.account:
self.username_edit.setText(self.account.username)
form_layout.addRow("用户名:", self.username_edit)
# Password
self.password_edit = QLineEdit()
self.password_edit.setEchoMode(QLineEdit.EchoMode.Password)
self.password_edit.setPlaceholderText("请输入密码")
self.password_edit.setMinimumHeight(INPUT_HEIGHT)
if self.account:
pwd = decrypt_password(self.account.password) if is_encrypted(self.account.password) else self.account.password
self.password_edit.setText(pwd)
form_layout.addRow("密码:", self.password_edit)
# Remark
self.remark_edit = QLineEdit()
self.remark_edit.setPlaceholderText("如:张三(用于截图文件名)")
self.remark_edit.setMinimumHeight(INPUT_HEIGHT)
if self.account:
self.remark_edit.setText(self.account.remark)
form_layout.addRow("备注:", self.remark_edit)
# Enabled checkbox
self.enabled_check = QCheckBox("启用此账号")
self.enabled_check.setChecked(self.account.enabled if self.account else True)
form_layout.addRow("", self.enabled_check)
layout.addLayout(form_layout)
# Spacer
layout.addSpacerItem(QSpacerItem(20, 20, QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Expanding))
# Buttons
btn_layout = QHBoxLayout()
btn_layout.setSpacing(BUTTON_SPACING)
btn_layout.addStretch()
cancel_btn = QPushButton("取消")
cancel_btn.setMinimumWidth(BUTTON_MIN_WIDTH_NORMAL)
cancel_btn.setMinimumHeight(BUTTON_HEIGHT_NORMAL)
cancel_btn.clicked.connect(self.reject)
btn_layout.addWidget(cancel_btn)
save_btn = QPushButton("保存")
save_btn.setObjectName("primary")
save_btn.setMinimumWidth(BUTTON_MIN_WIDTH_NORMAL)
save_btn.setMinimumHeight(BUTTON_HEIGHT_NORMAL)
save_btn.clicked.connect(self._save)
btn_layout.addWidget(save_btn)
layout.addLayout(btn_layout)
def _save(self):
username = self.username_edit.text().strip()
password = self.password_edit.text().strip()
if not username:
QMessageBox.warning(self, "提示", "请输入用户名")
return
if not password:
QMessageBox.warning(self, "提示", "请输入密码")
return
encrypted_pwd = encrypt_password(password)
if self.account:
self.account.username = username
self.account.password = encrypted_pwd
self.account.remark = self.remark_edit.text().strip()
self.account.enabled = self.enabled_check.isChecked()
else:
self.account = AccountConfig(
username=username,
password=encrypted_pwd,
remark=self.remark_edit.text().strip(),
enabled=self.enabled_check.isChecked()
)
self.accept()
def get_account(self) -> AccountConfig:
return self.account
class AccountWidget(QWidget):
"""Account management panel"""
log_signal = pyqtSignal(str)
def __init__(self, parent=None):
super().__init__(parent)
self._worker = None # 保存Worker引用防止被垃圾回收
self._setup_ui()
self._load_accounts()
def _setup_ui(self):
main_layout = QVBoxLayout(self)
main_layout.setContentsMargins(0, 0, 0, 0)
main_layout.setSpacing(0)
# Scroll area
scroll = QScrollArea()
scroll.setWidgetResizable(True)
scroll.setFrameShape(QFrame.Shape.NoFrame)
scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
content = QWidget()
layout = QVBoxLayout(content)
layout.setContentsMargins(PAGE_PADDING, PAGE_PADDING, PAGE_PADDING, PAGE_PADDING)
layout.setSpacing(SECTION_SPACING)
# Title
title = QLabel("📝 账号管理")
title.setStyleSheet(get_title_style())
layout.addWidget(title)
# Toolbar
toolbar = QHBoxLayout()
toolbar.setSpacing(BUTTON_SPACING)
add_btn = QPushButton(" 添加账号")
add_btn.setObjectName("primary")
add_btn.setMinimumHeight(BUTTON_HEIGHT_NORMAL)
add_btn.setMinimumWidth(BUTTON_MIN_WIDTH)
add_btn.clicked.connect(self._add_account)
toolbar.addWidget(add_btn)
toolbar.addStretch()
test_btn = QPushButton("🔑 测试登录")
test_btn.setMinimumHeight(BUTTON_HEIGHT_NORMAL)
test_btn.setMinimumWidth(BUTTON_MIN_WIDTH_NORMAL)
test_btn.clicked.connect(self._test_login)
toolbar.addWidget(test_btn)
layout.addLayout(toolbar)
# Account table
self.table = QTableWidget()
self.table.setColumnCount(5)
self.table.setHorizontalHeaderLabels(["启用", "用户名", "备注", "状态", "操作"])
# 列宽设置:启用-固定,用户名-拉伸,备注-拉伸,状态-固定,操作-固定
self.table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.Fixed)
self.table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch)
self.table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeMode.Stretch)
self.table.horizontalHeader().setSectionResizeMode(3, QHeaderView.ResizeMode.Fixed)
self.table.horizontalHeader().setSectionResizeMode(4, QHeaderView.ResizeMode.Fixed) # 固定宽度不被压缩
self.table.setColumnWidth(0, 60)
self.table.setColumnWidth(3, 80)
self.table.setColumnWidth(4, 220) # 操作列固定220px
self.table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows)
self.table.setSelectionMode(QTableWidget.SelectionMode.SingleSelection)
self.table.verticalHeader().setDefaultSectionSize(TABLE_ROW_HEIGHT)
self.table.verticalHeader().setVisible(False)
self.table.setAlternatingRowColors(True)
self.table.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
self.table.setMinimumHeight(250)
layout.addWidget(self.table, 1)
# Help text
help_text = QLabel("💡 提示: 密码将加密存储。备注用于截图文件命名,建议填写姓名。")
help_text.setStyleSheet(get_help_text_style())
help_text.setWordWrap(True)
layout.addWidget(help_text)
scroll.setWidget(content)
main_layout.addWidget(scroll)
def _load_accounts(self):
"""Load account list"""
config = get_config()
self.table.setRowCount(0)
for i, account in enumerate(config.accounts):
self.table.insertRow(i)
self.table.setRowHeight(i, TABLE_ROW_HEIGHT)
# Enabled checkbox
enabled_widget = QWidget()
enabled_layout = QHBoxLayout(enabled_widget)
enabled_layout.setContentsMargins(0, 0, 0, 0)
enabled_layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
enabled_check = QCheckBox()
enabled_check.setChecked(account.enabled)
enabled_check.stateChanged.connect(lambda state, row=i: self._toggle_enabled(row, state))
enabled_layout.addWidget(enabled_check)
self.table.setCellWidget(i, 0, enabled_widget)
# Username
username_item = QTableWidgetItem(account.username)
username_item.setTextAlignment(Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeft)
username_item.setFlags(username_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
self.table.setItem(i, 1, username_item)
# Remark
remark_item = QTableWidgetItem(account.remark)
remark_item.setTextAlignment(Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeft)
remark_item.setFlags(remark_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
self.table.setItem(i, 2, remark_item)
# Status
status_item = QTableWidgetItem("")
status_item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
status_item.setFlags(status_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
self.table.setItem(i, 3, status_item)
# Action buttons - 用图标按钮彻底解决显示问题
btn_widget = QWidget()
btn_layout = QHBoxLayout(btn_widget)
btn_layout.setContentsMargins(0, 0, 0, 0)
btn_layout.setSpacing(4)
btn_layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
# 编辑按钮
edit_btn = QPushButton("编辑")
edit_btn.setCursor(Qt.CursorShape.PointingHandCursor)
edit_btn.setStyleSheet("""
QPushButton {
min-width: 50px;
padding: 5px 12px;
font-size: 13px;
border: 1px solid #d9d9d9;
border-radius: 4px;
background: #fff;
color: #333;
}
QPushButton:hover {
border-color: #1890ff;
color: #1890ff;
background: #e6f7ff;
}
""")
edit_btn.clicked.connect(lambda _, row=i: self._edit_account(row))
btn_layout.addWidget(edit_btn)
# 删除按钮
del_btn = QPushButton("删除")
del_btn.setCursor(Qt.CursorShape.PointingHandCursor)
del_btn.setStyleSheet("""
QPushButton {
min-width: 50px;
padding: 5px 12px;
font-size: 13px;
border: none;
border-radius: 4px;
background: #ff4d4f;
color: #fff;
}
QPushButton:hover {
background: #ff7875;
}
""")
del_btn.clicked.connect(lambda _, row=i: self._delete_account(row))
btn_layout.addWidget(del_btn)
self.table.setCellWidget(i, 4, btn_widget)
def _add_account(self):
"""Add account"""
dialog = AccountEditDialog(parent=self)
if dialog.exec() == QDialog.DialogCode.Accepted:
account = dialog.get_account()
config = get_config()
config.accounts.append(account)
save_config(config)
self._load_accounts()
self.log_signal.emit(f"账号 {account.username} 添加成功")
def _edit_account(self, row: int):
"""Edit account"""
config = get_config()
if row < len(config.accounts):
account = config.accounts[row]
dialog = AccountEditDialog(account=account, parent=self)
if dialog.exec() == QDialog.DialogCode.Accepted:
save_config(config)
self._load_accounts()
self.log_signal.emit(f"账号 {account.username} 已更新")
def _delete_account(self, row: int):
"""Delete account"""
config = get_config()
if row < len(config.accounts):
account = config.accounts[row]
reply = QMessageBox.question(
self, "确认删除",
f"确定要删除账号 {account.username} 吗?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
)
if reply == QMessageBox.StandardButton.Yes:
config.accounts.pop(row)
save_config(config)
self._load_accounts()
self.log_signal.emit(f"账号 {account.username} 已删除")
def _toggle_enabled(self, row: int, state: int):
"""Toggle account enabled state"""
config = get_config()
if row < len(config.accounts):
config.accounts[row].enabled = state == Qt.CheckState.Checked.value
save_config(config)
def _test_login(self):
"""Test login for selected account"""
selected = self.table.selectedItems()
if not selected:
QMessageBox.information(self, "提示", "请先选择一个账号")
return
row = selected[0].row()
config = get_config()
if row >= len(config.accounts):
return
account = config.accounts[row]
password = decrypt_password(account.password) if is_encrypted(account.password) else account.password
self.log_signal.emit(f"正在测试登录 {account.username}...")
status_item = self.table.item(row, 3)
status_item.setText("登录中...")
from utils.worker import Worker
def test_login(_signals=None, _should_stop=None):
from core.api_browser import APIBrowser
with APIBrowser(log_callback=lambda msg: _signals.log.emit(msg) if _signals else None) as browser:
if browser.login(account.username, password):
real_name = browser.get_real_name()
return {"success": True, "real_name": real_name}
else:
return {"success": False}
def on_result(result):
if result and result.get("success"):
status_item.setText("✅ 成功")
real_name = result.get("real_name", "")
msg = f"账号 {account.username} 登录成功"
if real_name:
msg += f",姓名: {real_name}"
# 如果备注为空,自动填入真实姓名
if not account.remark:
account.remark = real_name
save_config(config)
self._load_accounts() # 刷新表格
msg += "(已自动填入备注)"
self.log_signal.emit(msg)
else:
status_item.setText("❌ 失败")
self.log_signal.emit(f"账号 {account.username} 登录失败")
def on_error(error):
status_item.setText("❌ 错误")
self.log_signal.emit(f"测试登录出错: {error}")
# 保存为实例变量,防止被垃圾回收导致崩溃
self._worker = Worker(test_login)
self._worker.signals.log.connect(self.log_signal.emit)
self._worker.signals.result.connect(on_result)
self._worker.signals.error.connect(on_error)
self._worker.start()
def get_enabled_accounts(self) -> list:
"""Get all enabled accounts"""
config = get_config()
return [a for a in config.accounts if a.enabled]

356
ui/browse_widget.py Normal file
View File

@@ -0,0 +1,356 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Browse task panel - select accounts, browsing type, execute task with progress
"""
from PyQt6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QComboBox,
QPushButton, QProgressBar, QGroupBox, QCheckBox, QListWidget,
QListWidgetItem, QMessageBox, QScrollArea, QFrame, QSizePolicy
)
from PyQt6.QtCore import Qt, pyqtSignal, QSize
from config import get_config, AccountConfig
from utils.crypto import decrypt_password, is_encrypted
from .constants import (
PAGE_PADDING, SECTION_SPACING, GROUP_PADDING_TOP, GROUP_PADDING_SIDE,
GROUP_PADDING_BOTTOM, GROUP_SPACING, FORM_LABEL_WIDTH, FORM_H_SPACING,
INPUT_HEIGHT, INPUT_MIN_WIDTH, BUTTON_HEIGHT, BUTTON_HEIGHT_NORMAL,
BUTTON_MIN_WIDTH, BUTTON_MIN_WIDTH_NORMAL, BUTTON_SPACING,
LIST_MIN_HEIGHT, LIST_ITEM_HEIGHT, PROGRESS_HEIGHT,
get_title_style, get_help_text_style
)
class BrowseWidget(QWidget):
"""Browse task panel"""
log_signal = pyqtSignal(str)
def __init__(self, parent=None):
super().__init__(parent)
self._worker = None
self._is_running = False
self._setup_ui()
self._refresh_accounts()
def _setup_ui(self):
main_layout = QVBoxLayout(self)
main_layout.setContentsMargins(0, 0, 0, 0)
main_layout.setSpacing(0)
# Scroll area
scroll = QScrollArea()
scroll.setWidgetResizable(True)
scroll.setFrameShape(QFrame.Shape.NoFrame)
scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
content = QWidget()
layout = QVBoxLayout(content)
layout.setContentsMargins(16, 12, 16, 12)
layout.setSpacing(10)
# ==================== Title + Control buttons ====================
header_layout = QHBoxLayout()
header_layout.setSpacing(SECTION_SPACING)
title = QLabel("🔄 浏览任务")
title.setStyleSheet(get_title_style())
header_layout.addWidget(title)
header_layout.addStretch()
self.stop_btn = QPushButton("⏹ 停止")
self.stop_btn.setMinimumWidth(BUTTON_MIN_WIDTH_NORMAL)
self.stop_btn.setMinimumHeight(BUTTON_HEIGHT)
self.stop_btn.setEnabled(False)
self.stop_btn.clicked.connect(self._stop_task)
header_layout.addWidget(self.stop_btn)
self.start_btn = QPushButton("▶ 开始任务")
self.start_btn.setObjectName("primary")
self.start_btn.setMinimumWidth(BUTTON_MIN_WIDTH)
self.start_btn.setMinimumHeight(BUTTON_HEIGHT)
self.start_btn.clicked.connect(self._start_task)
header_layout.addWidget(self.start_btn)
layout.addLayout(header_layout)
# ==================== Progress area (compact) ====================
progress_group = QGroupBox("任务进度")
progress_layout = QVBoxLayout(progress_group)
progress_layout.setContentsMargins(12, 8, 12, 8)
progress_layout.setSpacing(6)
# Current task + stats in one row
task_row = QHBoxLayout()
self.current_task_label = QLabel("当前任务: 无")
task_row.addWidget(self.current_task_label)
task_row.addStretch()
self.stats_label = QLabel("已处理: 0 条内容0 个附件")
self.stats_label.setStyleSheet(get_help_text_style())
task_row.addWidget(self.stats_label)
progress_layout.addLayout(task_row)
# Progress bars in one row
prog_row = QHBoxLayout()
prog_row.setSpacing(12)
prog_row.addWidget(QLabel("总体:"))
self.total_progress = QProgressBar()
self.total_progress.setValue(0)
self.total_progress.setFixedHeight(18)
prog_row.addWidget(self.total_progress, 1)
prog_row.addWidget(QLabel("当前:"))
self.account_progress = QProgressBar()
self.account_progress.setValue(0)
self.account_progress.setFixedHeight(18)
prog_row.addWidget(self.account_progress, 1)
progress_layout.addLayout(prog_row)
layout.addWidget(progress_group)
# ==================== Browse options (compact, one row) ====================
options_group = QGroupBox("浏览选项")
options_layout = QHBoxLayout(options_group)
options_layout.setContentsMargins(12, 8, 12, 8)
options_layout.setSpacing(16)
options_layout.addWidget(QLabel("浏览类型:"))
self.browse_type_combo = QComboBox()
self.browse_type_combo.addItems(["应读", "注册前未读"])
self.browse_type_combo.setMinimumWidth(140)
self.browse_type_combo.setFixedHeight(32)
self.browse_type_combo.setStyleSheet("QComboBox { padding-left: 10px; }")
options_layout.addWidget(self.browse_type_combo)
options_layout.addSpacing(20)
self.auto_screenshot_check = QCheckBox("浏览后自动截图")
self.auto_screenshot_check.setChecked(True)
options_layout.addWidget(self.auto_screenshot_check)
self.auto_upload_check = QCheckBox("截图后自动上传")
self.auto_upload_check.setChecked(False)
options_layout.addWidget(self.auto_upload_check)
options_layout.addStretch()
layout.addWidget(options_group)
# ==================== Account selection (compact) ====================
account_group = QGroupBox("选择账号")
account_group.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
account_layout = QVBoxLayout(account_group)
account_layout.setContentsMargins(12, 8, 12, 8)
account_layout.setSpacing(6)
# Toolbar - compact buttons with proper width
toolbar = QHBoxLayout()
toolbar.setSpacing(8)
self.select_all_btn = QPushButton("全选")
self.select_all_btn.setFixedSize(70, 28)
self.select_all_btn.setStyleSheet("QPushButton { padding: 2px 8px; }")
self.select_all_btn.clicked.connect(self._select_all)
toolbar.addWidget(self.select_all_btn)
self.deselect_all_btn = QPushButton("取消全选")
self.deselect_all_btn.setFixedSize(90, 28)
self.deselect_all_btn.setStyleSheet("QPushButton { padding: 2px 8px; }")
self.deselect_all_btn.clicked.connect(self._deselect_all)
toolbar.addWidget(self.deselect_all_btn)
toolbar.addStretch()
self.refresh_btn = QPushButton("刷新")
self.refresh_btn.setFixedSize(70, 28)
self.refresh_btn.setStyleSheet("QPushButton { padding: 2px 8px; }")
self.refresh_btn.clicked.connect(self._refresh_accounts)
toolbar.addWidget(self.refresh_btn)
account_layout.addLayout(toolbar)
# Account list - compact
self.account_list = QListWidget()
self.account_list.setMinimumHeight(120)
self.account_list.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
self.account_list.setSpacing(1)
account_layout.addWidget(self.account_list, 1)
layout.addWidget(account_group, 1)
scroll.setWidget(content)
main_layout.addWidget(scroll)
def _refresh_accounts(self):
"""Refresh account list"""
self.account_list.clear()
config = get_config()
for account in config.accounts:
if account.enabled:
item = QListWidgetItem()
item.setFlags(item.flags() | Qt.ItemFlag.ItemIsUserCheckable)
item.setCheckState(Qt.CheckState.Checked)
display_name = account.remark if account.remark else account.username
item.setText(f"{display_name} ({account.username})")
item.setData(Qt.ItemDataRole.UserRole, account)
item.setSizeHint(item.sizeHint().expandedTo(QSize(0, LIST_ITEM_HEIGHT)))
self.account_list.addItem(item)
def _select_all(self):
"""Select all"""
for i in range(self.account_list.count()):
self.account_list.item(i).setCheckState(Qt.CheckState.Checked)
def _deselect_all(self):
"""Deselect all"""
for i in range(self.account_list.count()):
self.account_list.item(i).setCheckState(Qt.CheckState.Unchecked)
def _get_selected_accounts(self) -> list:
"""Get selected accounts"""
accounts = []
for i in range(self.account_list.count()):
item = self.account_list.item(i)
if item.checkState() == Qt.CheckState.Checked:
account = item.data(Qt.ItemDataRole.UserRole)
accounts.append(account)
return accounts
def _start_task(self):
"""Start task"""
accounts = self._get_selected_accounts()
if not accounts:
QMessageBox.information(self, "提示", "请选择至少一个账号")
return
self._is_running = True
self.start_btn.setEnabled(False)
self.stop_btn.setEnabled(True)
self._stop_requested = False
browse_type = self.browse_type_combo.currentText()
auto_screenshot = self.auto_screenshot_check.isChecked()
auto_upload = self.auto_upload_check.isChecked()
self.total_progress.setMaximum(len(accounts))
self.total_progress.setValue(0)
self.log_signal.emit(f"开始任务: {len(accounts)} 个账号, 类型: {browse_type}")
from utils.worker import Worker
def run_tasks(_signals=None, _should_stop=None):
results = []
for i, account in enumerate(accounts):
if _should_stop and _should_stop():
break
_signals.log.emit(f"[{i+1}/{len(accounts)}] 处理账号: {account.username}")
_signals.progress.emit(i, f"正在处理: {account.username}")
password = decrypt_password(account.password) if is_encrypted(account.password) else account.password
from core.api_browser import APIBrowser
from config import get_config as _get_config
proxy_config = None
cfg = _get_config()
if cfg.proxy.enabled and cfg.proxy.server:
proxy_config = {"server": cfg.proxy.server}
browse_result = None
with APIBrowser(log_callback=lambda msg: _signals.log.emit(msg), proxy_config=proxy_config) as browser:
if browser.login(account.username, password):
browser.save_cookies_for_screenshot(account.username)
result = browser.browse_content(
browse_type,
should_stop_callback=_should_stop,
)
browse_result = {
"success": result.success,
"total_items": result.total_items,
"total_attachments": result.total_attachments,
}
screenshot_path = None
if browse_result and browse_result.get("success") and auto_screenshot:
from core.screenshot import take_screenshot
_signals.log.emit("正在截图...")
ss_result = take_screenshot(
account.username,
password,
browse_type,
remark=account.remark,
log_callback=lambda msg: _signals.log.emit(msg),
proxy_config=proxy_config
)
if ss_result.success:
screenshot_path = ss_result.filepath
if screenshot_path and auto_upload:
from core.kdocs_uploader import get_kdocs_uploader
from config import get_config as _get_config2
cfg2 = _get_config2()
if cfg2.kdocs.enabled:
_signals.log.emit("正在上传到金山文档...")
uploader = get_kdocs_uploader()
uploader._log_callback = lambda msg: _signals.log.emit(msg)
upload_result = uploader.upload_image(
screenshot_path,
cfg2.kdocs.unit,
account.remark or account.username
)
if upload_result.get("success"):
_signals.log.emit("[OK] 上传成功")
else:
_signals.log.emit(f"上传失败: {upload_result.get('error', '未知错误')}")
results.append({
"account": account.username,
"browse": browse_result,
"screenshot": screenshot_path,
})
_signals.progress.emit(i + 1, f"完成: {account.username}")
return results
def on_progress(current, message):
self.total_progress.setValue(current)
self.current_task_label.setText(f"当前任务: {message}")
def on_finished(success, message):
self._is_running = False
self.start_btn.setEnabled(True)
self.stop_btn.setEnabled(False)
self.current_task_label.setText("当前任务: 完成")
self.log_signal.emit(f"任务完成: {message}")
def on_result(results):
if results:
total_items = sum(r.get("browse", {}).get("total_items", 0) for r in results if r.get("browse"))
total_attachments = sum(r.get("browse", {}).get("total_attachments", 0) for r in results if r.get("browse"))
self.stats_label.setText(f"已处理: {total_items} 条内容,{total_attachments} 个附件")
self._worker = Worker(run_tasks)
self._worker.signals.log.connect(self.log_signal.emit)
self._worker.signals.progress.connect(on_progress)
self._worker.signals.finished.connect(on_finished)
self._worker.signals.result.connect(on_result)
self._worker.start()
def _stop_task(self):
"""Stop task"""
if self._worker:
self._worker.stop()
self.log_signal.emit("正在停止任务...")
self.stop_btn.setEnabled(False)

136
ui/constants.py Normal file
View File

@@ -0,0 +1,136 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
UI Design Constants - 统一UI规范
老王说统一规范才能不出SB界面这些数字都是精心调教过的别tm乱改
"""
# ==================== 页面布局 ====================
# 内容区域到边框的距离
PAGE_PADDING = 24
# 主要区块之间的间距
SECTION_SPACING = 24
# ==================== GroupBox 分组框 ====================
# GroupBox 内部边距
# 注意QSS中已经设置了padding-top: 24px给title badge留空间
# 这里只需要设置很小的值或0避免双重间距
GROUP_PADDING_TOP = 8 # QSS padding-top已处理主要间距这里只做微调
GROUP_PADDING_SIDE = 20
GROUP_PADDING_BOTTOM = 20
# GroupBox 内部组件间距
GROUP_SPACING = 16
# ==================== 表单布局 ====================
# 表单行间距
FORM_ROW_SPACING = 16
# 标签最小宽度(右对齐用)
FORM_LABEL_WIDTH = 80
# 水平组件间距(同一行内)
FORM_H_SPACING = 12
# ==================== 输入控件 ====================
# 输入框标准高度
INPUT_HEIGHT = 40
# 小号输入框高度
INPUT_HEIGHT_SMALL = 36
# 输入框最小宽度
INPUT_MIN_WIDTH = 180
# 短输入框宽度如列名A/B/C
INPUT_WIDTH_SHORT = 70
# ==================== 按钮 ====================
# 主要按钮高度
BUTTON_HEIGHT = 40
# 普通按钮高度
BUTTON_HEIGHT_NORMAL = 36
# 小按钮高度
BUTTON_HEIGHT_SMALL = 32
# 主要按钮最小宽度
BUTTON_MIN_WIDTH = 120
# 普通按钮最小宽度
BUTTON_MIN_WIDTH_NORMAL = 100
# 小按钮宽度
BUTTON_WIDTH_SMALL = 65
# 按钮间距
BUTTON_SPACING = 12
# ==================== 列表和表格 ====================
# 表格行高
TABLE_ROW_HEIGHT = 52
# 列表项最小高度
LIST_ITEM_HEIGHT = 44
# 列表最小显示高度
LIST_MIN_HEIGHT = 160
# 图标大小
ICON_SIZE_LARGE = 80
ICON_SIZE_MEDIUM = 48
ICON_SIZE_SMALL = 24
# ==================== 标题 ====================
# 页面标题字号
TITLE_FONT_SIZE = 20
# 副标题字号
SUBTITLE_FONT_SIZE = 16
# 正文字号
TEXT_FONT_SIZE = 13
# 小字字号
TEXT_FONT_SIZE_SMALL = 12
# 标题下方间距
TITLE_MARGIN_BOTTOM = 16
# ==================== 特殊组件 ====================
# 二维码显示区域大小
QR_CODE_SIZE = 200
# 进度条高度
PROGRESS_HEIGHT = 20
# 日志区域最小高度
LOG_MIN_HEIGHT = 120
# ==================== 滚动区域 ====================
# 滚动条宽度
SCROLLBAR_WIDTH = 10
# ==================== 样式片段 ====================
def get_title_style():
"""页面标题样式"""
return f"font-size: {TITLE_FONT_SIZE}px; font-weight: bold; margin-bottom: {TITLE_MARGIN_BOTTOM}px;"
def get_subtitle_style():
"""副标题样式"""
return f"font-size: {SUBTITLE_FONT_SIZE}px; font-weight: 600;"
def get_help_text_style():
"""帮助文本样式"""
return f"color: #666; font-size: {TEXT_FONT_SIZE_SMALL}px; padding: 8px 0;"
def get_status_style(success: bool):
"""状态文本样式"""
color = "#52c41a" if success else "#ff4d4f"
return f"color: {color}; font-size: {TEXT_FONT_SIZE}px;"

650
ui/kdocs_widget.py Normal file
View File

@@ -0,0 +1,650 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
KDocs panel - configure doc URL, columns, QR login, manual upload
"""
import base64
from PyQt6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit,
QPushButton, QGroupBox, QSpinBox, QCheckBox,
QMessageBox, QScrollArea, QFrame
)
from PyQt6.QtCore import Qt, pyqtSignal, QTimer
from PyQt6.QtGui import QPixmap
from config import get_config, save_config
from .constants import (
PAGE_PADDING, SECTION_SPACING, GROUP_PADDING_TOP, GROUP_PADDING_SIDE,
GROUP_PADDING_BOTTOM, GROUP_SPACING, FORM_ROW_SPACING, FORM_LABEL_WIDTH,
FORM_H_SPACING, INPUT_HEIGHT, INPUT_MIN_WIDTH, INPUT_WIDTH_SHORT,
BUTTON_HEIGHT, BUTTON_HEIGHT_NORMAL, BUTTON_MIN_WIDTH, BUTTON_SPACING,
QR_CODE_SIZE, get_title_style, get_status_style
)
class KDocsWidget(QWidget):
"""KDocs panel"""
log_signal = pyqtSignal(str)
def __init__(self, parent=None):
super().__init__(parent)
self._worker = None
self._login_check_timer = None
self._setup_ui()
self._load_config()
self._check_wechat_process() # 检测微信进程
def _setup_ui(self):
main_layout = QVBoxLayout(self)
main_layout.setContentsMargins(16, 16, 16, 16)
main_layout.setSpacing(16)
# ==================== Title + Save button ====================
header_layout = QHBoxLayout()
title = QLabel("📤 金山文档上传")
title.setStyleSheet(get_title_style())
header_layout.addWidget(title)
header_layout.addStretch()
self.enable_check = QCheckBox("启用上传")
self.enable_check.stateChanged.connect(self._on_enable_changed)
header_layout.addWidget(self.enable_check)
self.save_btn = QPushButton("保存配置")
self.save_btn.setObjectName("primary")
self.save_btn.setFixedHeight(36)
self.save_btn.setMinimumWidth(100)
self.save_btn.clicked.connect(self._save_config)
header_layout.addWidget(self.save_btn)
main_layout.addLayout(header_layout)
# ==================== Document config ====================
config_group = QGroupBox("文档配置")
config_layout = QVBoxLayout(config_group)
config_layout.setContentsMargins(16, 24, 16, 16)
config_layout.setSpacing(12)
# Row 1: Doc URL
row1 = QHBoxLayout()
row1.setSpacing(10)
lbl1 = QLabel("文档链接:")
lbl1.setFixedWidth(70)
row1.addWidget(lbl1)
self.doc_url_edit = QLineEdit()
self.doc_url_edit.setPlaceholderText("https://kdocs.cn/l/xxxxxx")
self.doc_url_edit.setMinimumHeight(36)
row1.addWidget(self.doc_url_edit, 1)
config_layout.addLayout(row1)
# Row 2: Sheet name + Unit
row2 = QHBoxLayout()
row2.setSpacing(10)
lbl2 = QLabel("工作表:")
lbl2.setFixedWidth(70)
row2.addWidget(lbl2)
self.sheet_name_edit = QLineEdit()
self.sheet_name_edit.setPlaceholderText("Sheet1")
self.sheet_name_edit.setMinimumHeight(36)
self.sheet_name_edit.setFixedWidth(150)
row2.addWidget(self.sheet_name_edit)
row2.addSpacing(30)
lbl2b = QLabel("所属县区:")
lbl2b.setFixedWidth(70)
row2.addWidget(lbl2b)
self.unit_edit = QLineEdit()
self.unit_edit.setPlaceholderText("XX县")
self.unit_edit.setMinimumHeight(36)
row2.addWidget(self.unit_edit, 1)
config_layout.addLayout(row2)
# Row 3: Columns
row3 = QHBoxLayout()
row3.setSpacing(10)
lbl3 = QLabel("县区列:")
lbl3.setFixedWidth(70)
row3.addWidget(lbl3)
self.unit_col_edit = QLineEdit()
self.unit_col_edit.setFixedWidth(80)
self.unit_col_edit.setMinimumHeight(36)
self.unit_col_edit.setPlaceholderText("A")
row3.addWidget(self.unit_col_edit)
row3.addSpacing(20)
lbl3b = QLabel("姓名列:")
lbl3b.setFixedWidth(60)
row3.addWidget(lbl3b)
self.name_col_edit = QLineEdit()
self.name_col_edit.setFixedWidth(80)
self.name_col_edit.setMinimumHeight(36)
self.name_col_edit.setPlaceholderText("C")
row3.addWidget(self.name_col_edit)
row3.addSpacing(20)
lbl3c = QLabel("图片列:")
lbl3c.setFixedWidth(60)
row3.addWidget(lbl3c)
self.image_col_edit = QLineEdit()
self.image_col_edit.setFixedWidth(80)
self.image_col_edit.setMinimumHeight(36)
self.image_col_edit.setPlaceholderText("D")
row3.addWidget(self.image_col_edit)
row3.addStretch()
config_layout.addLayout(row3)
# Row 4: Row range - SpinBox加宽确保"不限制"显示完整
row4 = QHBoxLayout()
row4.setSpacing(10)
lbl4 = QLabel("起始行:")
lbl4.setFixedWidth(70)
row4.addWidget(lbl4)
self.row_start_spin = QSpinBox()
self.row_start_spin.setRange(0, 10000)
self.row_start_spin.setSpecialValueText("不限制")
self.row_start_spin.setFixedWidth(120)
self.row_start_spin.setMinimumHeight(36)
row4.addWidget(self.row_start_spin)
row4.addSpacing(20)
lbl4b = QLabel("结束行:")
lbl4b.setFixedWidth(60)
row4.addWidget(lbl4b)
self.row_end_spin = QSpinBox()
self.row_end_spin.setRange(0, 10000)
self.row_end_spin.setSpecialValueText("不限制")
self.row_end_spin.setFixedWidth(120)
self.row_end_spin.setMinimumHeight(36)
row4.addWidget(self.row_end_spin)
row4.addStretch()
config_layout.addLayout(row4)
main_layout.addWidget(config_group)
# ==================== Login status ====================
login_group = QGroupBox("登录状态")
login_layout = QHBoxLayout(login_group)
login_layout.setContentsMargins(16, 24, 16, 16)
login_layout.setSpacing(20)
# QR code
self.qr_label = QLabel()
self.qr_label.setFixedSize(150, 150)
self.qr_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.qr_label.setStyleSheet("border: 1px solid #d0d0d0; border-radius: 6px; background: #fafafa;")
self.qr_label.setText("点击获取二维码")
login_layout.addWidget(self.qr_label)
# Status and buttons
status_btn_layout = QVBoxLayout()
status_btn_layout.setSpacing(10)
self.status_label = QLabel("未登录")
self.status_label.setStyleSheet(get_status_style(False))
status_btn_layout.addWidget(self.status_label)
status_btn_layout.addStretch()
self.get_qr_btn = QPushButton("获取二维码")
self.get_qr_btn.setObjectName("primary")
self.get_qr_btn.setFixedHeight(36)
self.get_qr_btn.setMinimumWidth(120)
self.get_qr_btn.clicked.connect(self._get_qr_code)
status_btn_layout.addWidget(self.get_qr_btn)
# 快捷登录按钮(检测到微信进程时显示)
self.quick_login_btn = QPushButton("⚡ 微信快捷登录")
self.quick_login_btn.setFixedHeight(36)
self.quick_login_btn.setMinimumWidth(130)
self.quick_login_btn.setStyleSheet("""
QPushButton {
background-color: #07C160;
color: white;
border: none;
border-radius: 4px;
font-weight: bold;
}
QPushButton:hover {
background-color: #06AD56;
}
QPushButton:disabled {
background-color: #ccc;
}
""")
self.quick_login_btn.clicked.connect(self._quick_login)
self.quick_login_btn.setVisible(False) # 默认隐藏
status_btn_layout.addWidget(self.quick_login_btn)
self.clear_login_btn = QPushButton("清除登录")
self.clear_login_btn.setFixedHeight(36)
self.clear_login_btn.setMinimumWidth(120)
self.clear_login_btn.clicked.connect(self._clear_login)
status_btn_layout.addWidget(self.clear_login_btn)
status_btn_layout.addStretch()
login_layout.addLayout(status_btn_layout)
login_layout.addStretch()
main_layout.addWidget(login_group)
main_layout.addStretch()
def _load_config(self):
"""Load config"""
config = get_config()
kdocs = config.kdocs
self.enable_check.setChecked(kdocs.enabled)
self.doc_url_edit.setText(kdocs.doc_url)
self.sheet_name_edit.setText(kdocs.sheet_name)
self.unit_col_edit.setText(kdocs.unit_column)
self.name_col_edit.setText(kdocs.name_column)
self.image_col_edit.setText(kdocs.image_column)
self.row_start_spin.setValue(kdocs.row_start)
self.row_end_spin.setValue(kdocs.row_end)
self.unit_edit.setText(kdocs.unit)
self._on_enable_changed(Qt.CheckState.Checked.value if kdocs.enabled else Qt.CheckState.Unchecked.value)
def _save_config(self):
"""Save config"""
config = get_config()
config.kdocs.enabled = self.enable_check.isChecked()
config.kdocs.doc_url = self.doc_url_edit.text().strip()
config.kdocs.sheet_name = self.sheet_name_edit.text().strip()
config.kdocs.unit_column = self.unit_col_edit.text().strip().upper() or "A"
config.kdocs.name_column = self.name_col_edit.text().strip().upper() or "C"
config.kdocs.image_column = self.image_col_edit.text().strip().upper() or "D"
config.kdocs.row_start = self.row_start_spin.value()
config.kdocs.row_end = self.row_end_spin.value()
config.kdocs.unit = self.unit_edit.text().strip()
save_config(config)
self.log_signal.emit("金山文档配置已保存")
QMessageBox.information(self, "提示", "配置已保存")
def _on_enable_changed(self, state):
"""Enable state changed"""
pass # Can disable/enable other controls if needed
def _get_qr_code(self):
"""Get login QR code and poll for login status"""
self.get_qr_btn.setEnabled(False)
self.qr_label.setText("获取中...")
self.log_signal.emit("正在获取金山文档登录二维码...")
# 停止之前的检查
if self._login_check_timer:
self._login_check_timer.stop()
self._login_check_timer = None
from utils.worker import Worker
def get_qr_and_wait_login(_signals=None, _should_stop=None):
"""获取二维码并轮询等待登录在同一个线程中完成所有Playwright操作"""
from core.kdocs_uploader import get_kdocs_uploader
import time
uploader = get_kdocs_uploader()
uploader._log_callback = lambda msg: _signals.log.emit(msg) if _signals else None
# 1. 获取二维码
result = uploader.request_qr(force=True)
if not result.get("success"):
return result
if result.get("logged_in"):
return result
# 2. 发送二维码图片(通过信号)
qr_image = result.get("qr_image", "")
if qr_image:
_signals.screenshot_ready.emit(qr_image) # 复用这个信号传递二维码
# 3. 在同一个线程中轮询检查登录状态
max_wait = 120 # 最多等待120秒
check_interval = 3 # 每3秒检查一次
waited = 0
check_count = 0
while waited < max_wait:
if _should_stop and _should_stop():
return {"success": False, "error": "用户取消"}
time.sleep(check_interval)
waited += check_interval
check_count += 1
# 检查登录状态(在同一个线程中,不会有线程问题)
if _signals:
_signals.log.emit(f"[KDocs] 检查登录状态... ({check_count})")
check_result = uploader.check_login_status()
if check_result.get("logged_in"):
return {"success": True, "logged_in": True}
# 每30秒提醒一下用户
if waited % 30 == 0 and _signals:
remaining = max_wait - waited
_signals.log.emit(f"[KDocs] 等待扫码登录...(剩余{remaining}秒)")
return {"success": False, "error": "登录超时120秒请重新获取二维码"}
def on_qr_ready(qr_base64):
"""二维码准备好了,显示出来"""
if qr_base64:
qr_bytes = base64.b64decode(qr_base64)
pixmap = QPixmap()
pixmap.loadFromData(qr_bytes)
scaled = pixmap.scaled(140, 140, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation)
self.qr_label.setPixmap(scaled)
self.log_signal.emit("请使用微信扫描二维码登录120秒内有效")
def on_result(result):
self.get_qr_btn.setEnabled(True)
if result and result.get("success"):
if result.get("logged_in"):
self.status_label.setText("✅ 已登录")
self.status_label.setStyleSheet(get_status_style(True))
self.qr_label.setText("登录成功!")
self.log_signal.emit("金山文档登录成功")
else:
# 不应该走到这里因为上面会返回logged_in=True
pass
else:
error = result.get("error", "未知错误") if result else "未知错误"
self.qr_label.setText(f"失败: {error}")
self.status_label.setText("❌ 未登录")
self.status_label.setStyleSheet(get_status_style(False))
self.log_signal.emit(f"登录失败: {error}")
def on_error(error):
self.get_qr_btn.setEnabled(True)
self.qr_label.setText(f"错误: {error}")
self.log_signal.emit(f"获取二维码出错: {error}")
self._worker = Worker(get_qr_and_wait_login)
self._worker.signals.log.connect(self.log_signal.emit)
self._worker.signals.screenshot_ready.connect(on_qr_ready) # 用于接收二维码
self._worker.signals.result.connect(on_result)
self._worker.signals.error.connect(on_error)
self._worker.start()
def _clear_login(self):
"""Clear login status"""
from core.kdocs_uploader import get_kdocs_uploader
uploader = get_kdocs_uploader()
uploader.clear_login()
self.status_label.setText("❌ 未登录")
self.status_label.setStyleSheet(get_status_style(False))
self.qr_label.setText("点击获取二维码")
self.log_signal.emit("金山文档登录状态已清除")
if self._login_check_timer:
self._login_check_timer.stop()
self._login_check_timer = None
def _check_wechat_process(self) -> bool:
"""检测微信进程是否运行
Returns:
bool: 微信是否在运行
"""
import subprocess
try:
# Windows下检测微信进程可能是Weixin.exe或WeChat.exe
result = subprocess.run(
['tasklist'],
capture_output=True, text=True, timeout=5
)
output_lower = result.stdout.lower()
# 检测多种可能的微信进程名
is_running = 'weixin.exe' in output_lower or 'wechat.exe' in output_lower
if is_running:
self.log_signal.emit("检测到微信进程,可使用快捷登录")
# 始终显示快捷登录按钮,让用户自己决定
self.quick_login_btn.setVisible(True)
return is_running
except Exception:
# 检测失败也显示按钮
self.quick_login_btn.setVisible(True)
return False
def _quick_login(self):
"""微信快捷登录"""
# 先检测微信是否运行
is_wechat_running = self._check_wechat_process()
if not is_wechat_running:
self.log_signal.emit("⚠️ 未检测到微信进程,快捷登录可能失败。建议先打开微信客户端。")
self.quick_login_btn.setEnabled(False)
self.quick_login_btn.setText("登录中...")
self.log_signal.emit("正在执行微信快捷登录...")
from utils.worker import Worker
def do_quick_login(_signals=None, _should_stop=None):
from core.kdocs_uploader import get_kdocs_uploader
import time
uploader = get_kdocs_uploader()
uploader._log_callback = lambda msg: _signals.log.emit(msg) if _signals else None
# 强制重新登录,使用快捷登录
from config import KDOCS_LOGIN_STATE_FILE
try:
if KDOCS_LOGIN_STATE_FILE.exists():
KDOCS_LOGIN_STATE_FILE.unlink()
except Exception:
pass
uploader._cleanup_browser()
# 启动浏览器并执行快捷登录
if not uploader._ensure_playwright(use_storage_state=False):
return {"success": False, "error": uploader._last_error or "浏览器不可用"}
from config import get_config
config = get_config()
doc_url = config.kdocs.doc_url.strip()
if not doc_url:
return {"success": False, "error": "未配置金山文档链接"}
if not uploader._open_document(doc_url):
return {"success": False, "error": uploader._last_error or "打开文档失败"}
# 执行登录流程,优先使用微信快捷登录
agree_names = ["同意", "同意并继续", "我同意", "确定", "确认"]
for loop_idx in range(15):
current_url = uploader._page.url
uploader.log(f"[KDocs] 循环{loop_idx+1}: URL={current_url[:60]}...")
# 【优先】检查是否有"登录并加入编辑"按钮(可能在任何页面弹出)
try:
join_btn = uploader._page.get_by_role("button", name="登录并加入编辑")
btn_count = join_btn.count()
if btn_count > 0:
uploader.log(f"[KDocs] 找到'登录并加入编辑'按钮,直接点击")
join_btn.first.click(timeout=3000)
time.sleep(2)
continue
except Exception as e:
uploader.log(f"[KDocs] 点击'登录并加入编辑'失败: {e}")
# 检查是否已到达文档页面(且没有登录相关元素)
if "kdocs.cn/l/" in current_url and "account.wps.cn" not in current_url:
uploader.log(f"[KDocs] URL是文档页面检查是否真的登录成功...")
time.sleep(1) # 等待页面稳定
# 检查页面上是否还有登录相关元素
has_login_elements = False
# 检查是否有"立即登录"按钮
try:
login_btn = uploader._page.get_by_role("button", name="立即登录")
if login_btn.count() > 0 and login_btn.first.is_visible(timeout=500):
uploader.log("[KDocs] 页面上有'立即登录'按钮,未真正登录")
has_login_elements = True
except Exception:
pass
# 检查是否有二维码(说明还在登录页)
if not has_login_elements:
try:
qr_text = uploader._page.get_by_text("微信扫码登录", exact=False)
if qr_text.count() > 0 and qr_text.first.is_visible(timeout=500):
uploader.log("[KDocs] 页面上有'微信扫码登录',未真正登录")
has_login_elements = True
except Exception:
pass
# 检查是否有"微信快捷登录"按钮
if not has_login_elements:
try:
quick_btn = uploader._page.get_by_text("微信快捷登录", exact=False)
if quick_btn.count() > 0 and quick_btn.first.is_visible(timeout=500):
uploader.log("[KDocs] 页面上有'微信快捷登录'按钮,未真正登录")
has_login_elements = True
except Exception:
pass
if has_login_elements:
uploader.log("[KDocs] 检测到登录元素,继续处理登录流程...")
# 不return继续下面的登录处理
else:
uploader.log("[KDocs] 确认登录成功!")
uploader._logged_in = True
uploader._save_login_state()
return {"success": True, "logged_in": True}
clicked = False
# 点击同意按钮
for name in agree_names:
try:
btn = uploader._page.get_by_role("button", name=name)
if btn.count() > 0 and btn.first.is_visible(timeout=300):
uploader.log(f"[KDocs] 点击同意: {name}")
btn.first.click()
time.sleep(1)
clicked = True
break
except Exception:
pass
if clicked:
continue
# 检查页面上是否有"微信快捷登录"按钮可能URL是kdocs但内容是登录页
has_quick_login_btn = False
try:
quick_btn = uploader._page.get_by_text("微信快捷登录", exact=False)
if quick_btn.count() > 0 and quick_btn.first.is_visible(timeout=300):
has_quick_login_btn = True
except Exception:
pass
# 在登录页面,先勾选协议复选框,再点击微信快捷登录
# 条件URL是account.wps.cn 或者 页面上有微信快捷登录按钮
if "account.wps.cn" in current_url or has_quick_login_btn:
uploader.log("[KDocs] 检测到登录页面,尝试勾选协议...")
# 1. 先勾选"我已阅读并同意"复选框
try:
# 尝试多种方式找到协议复选框
checkbox_selectors = [
"input[type='checkbox']",
"span.checkbox",
"[class*='checkbox']",
"[class*='agree']",
]
checkbox_clicked = False
for selector in checkbox_selectors:
try:
checkboxes = uploader._page.locator(selector)
if checkboxes.count() > 0:
for i in range(min(checkboxes.count(), 3)):
cb = checkboxes.nth(i)
if cb.is_visible(timeout=200):
# 检查是否已勾选
is_checked = cb.is_checked() if hasattr(cb, 'is_checked') else False
if not is_checked:
uploader.log("[KDocs] 勾选协议复选框")
cb.click()
time.sleep(0.5)
checkbox_clicked = True
break
if checkbox_clicked:
break
except Exception:
pass
# 也尝试点击包含"我已阅读"的文本区域
if not checkbox_clicked:
try:
agree_text = uploader._page.get_by_text("我已阅读并同意", exact=False)
if agree_text.count() > 0 and agree_text.first.is_visible(timeout=300):
uploader.log("[KDocs] 点击协议文本区域")
agree_text.first.click()
time.sleep(0.5)
except Exception:
pass
except Exception as e:
uploader.log(f"[KDocs] 勾选协议失败: {e}")
# 2. 点击微信快捷登录按钮
try:
quick = uploader._page.get_by_text("微信快捷登录", exact=False)
if quick.count() > 0 and quick.first.is_visible(timeout=500):
uploader.log("[KDocs] 点击微信快捷登录")
quick.first.click()
time.sleep(4) # 等待快捷登录完成
clicked = True
continue
except Exception:
pass
# 点击立即登录
try:
btn = uploader._page.get_by_role("button", name="立即登录")
if btn.count() > 0 and btn.first.is_visible(timeout=500):
uploader.log("[KDocs] 点击立即登录")
btn.first.click()
time.sleep(2)
clicked = True
continue
except Exception:
pass
if not clicked:
break
# 最终检查
if "kdocs.cn/l/" in uploader._page.url:
uploader._logged_in = True
uploader._save_login_state()
return {"success": True, "logged_in": True}
else:
return {"success": False, "error": "快捷登录失败,请尝试扫码登录"}
def on_result(result):
self.quick_login_btn.setEnabled(True)
self.quick_login_btn.setText("⚡ 微信快捷登录")
if result and result.get("success") and result.get("logged_in"):
self.status_label.setText("✅ 已登录")
self.status_label.setStyleSheet(get_status_style(True))
self.qr_label.setText("快捷登录成功!")
self.log_signal.emit("金山文档快捷登录成功")
else:
error = result.get("error", "未知错误") if result else "未知错误"
self.log_signal.emit(f"快捷登录失败: {error}")
def on_error(error):
self.quick_login_btn.setEnabled(True)
self.quick_login_btn.setText("⚡ 微信快捷登录")
self.log_signal.emit(f"快捷登录出错: {error}")
self._worker = Worker(do_quick_login)
self._worker.signals.log.connect(self.log_signal.emit)
self._worker.signals.result.connect(on_result)
self._worker.signals.error.connect(on_error)
self._worker.start()

120
ui/log_widget.py Normal file
View File

@@ -0,0 +1,120 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Log display panel - collapsible log output area
"""
from datetime import datetime
from PyQt6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QPlainTextEdit,
QPushButton, QFrame, QLabel
)
from PyQt6.QtCore import Qt, pyqtSlot
from PyQt6.QtGui import QTextCursor
from .constants import LOG_MIN_HEIGHT, BUTTON_HEIGHT_SMALL, BUTTON_SPACING
class LogWidget(QWidget):
"""Log display panel"""
def __init__(self, parent=None):
super().__init__(parent)
self.setObjectName("log_widget")
self._collapsed = False
self._max_lines = 500
self._setup_ui()
def _setup_ui(self):
"""Setup UI"""
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
# Header bar
header = QFrame()
header.setFixedHeight(36)
header.setStyleSheet("background-color: rgba(0,0,0,0.03); border-bottom: 1px solid rgba(0,0,0,0.08);")
header_layout = QHBoxLayout(header)
header_layout.setContentsMargins(16, 0, 16, 0)
header_layout.setSpacing(BUTTON_SPACING)
# Title
title = QLabel("📜 日志输出")
title.setStyleSheet("font-weight: bold; font-size: 13px;")
header_layout.addWidget(title)
header_layout.addStretch()
# Clear button
self._clear_btn = QPushButton("清空")
self._clear_btn.setFixedWidth(60)
self._clear_btn.setFixedHeight(BUTTON_HEIGHT_SMALL)
self._clear_btn.clicked.connect(self.clear)
header_layout.addWidget(self._clear_btn)
# Toggle button
self._toggle_btn = QPushButton("")
self._toggle_btn.setFixedWidth(36)
self._toggle_btn.setFixedHeight(BUTTON_HEIGHT_SMALL)
self._toggle_btn.clicked.connect(self._toggle_collapse)
header_layout.addWidget(self._toggle_btn)
layout.addWidget(header)
# Log text area
self._text_edit = QPlainTextEdit()
self._text_edit.setReadOnly(True)
self._text_edit.setMinimumHeight(LOG_MIN_HEIGHT)
self._text_edit.setMaximumHeight(250)
self._text_edit.setStyleSheet("""
QPlainTextEdit {
font-family: "Consolas", "Monaco", "Courier New", monospace;
font-size: 12px;
line-height: 1.5;
padding: 8px 12px;
}
""")
layout.addWidget(self._text_edit)
def _toggle_collapse(self):
"""Toggle collapse state"""
self._collapsed = not self._collapsed
self._text_edit.setVisible(not self._collapsed)
self._toggle_btn.setText("" if self._collapsed else "")
@pyqtSlot(str)
def append_log(self, message: str):
"""Append log message"""
timestamp = datetime.now().strftime("%H:%M:%S")
formatted = f"[{timestamp}] {message}"
self._text_edit.appendPlainText(formatted)
# Limit lines
doc = self._text_edit.document()
if doc.blockCount() > self._max_lines:
cursor = QTextCursor(doc)
cursor.movePosition(QTextCursor.MoveOperation.Start)
cursor.select(QTextCursor.SelectionType.BlockUnderCursor)
cursor.movePosition(
QTextCursor.MoveOperation.Down,
QTextCursor.MoveMode.KeepAnchor,
doc.blockCount() - self._max_lines
)
cursor.removeSelectedText()
# Scroll to bottom
self._text_edit.moveCursor(QTextCursor.MoveOperation.End)
def log(self, message: str):
"""Log alias"""
self.append_log(message)
def clear(self):
"""Clear log"""
self._text_edit.clear()
def get_text(self) -> str:
"""Get all log text"""
return self._text_edit.toPlainText()

195
ui/main_window.py Normal file
View File

@@ -0,0 +1,195 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
主窗口
侧边栏导航 + 主内容区 + 日志输出区
"""
from PyQt6.QtWidgets import (
QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
QPushButton, QStackedWidget, QFrame, QLabel, QSplitter
)
from PyQt6.QtCore import Qt
from .styles import get_stylesheet, LIGHT_THEME, DARK_THEME
from .log_widget import LogWidget
from .account_widget import AccountWidget
from .browse_widget import BrowseWidget
from .screenshot_widget import ScreenshotWidget
from .kdocs_widget import KDocsWidget
from .settings_widget import SettingsWidget
class MainWindow(QMainWindow):
"""主窗口"""
def __init__(self):
super().__init__()
self.setWindowTitle("知识管理平台助手 - 精简版")
self.setMinimumSize(1000, 700)
self.resize(1200, 800)
self._current_theme = "light"
self._setup_ui()
self._apply_theme("light")
def _setup_ui(self):
"""设置UI布局"""
central_widget = QWidget()
self.setCentralWidget(central_widget)
main_layout = QHBoxLayout(central_widget)
main_layout.setContentsMargins(0, 0, 0, 0)
main_layout.setSpacing(0)
# 侧边栏
sidebar = self._create_sidebar()
main_layout.addWidget(sidebar)
# 右侧区域(内容 + 日志)
right_widget = QWidget()
right_layout = QVBoxLayout(right_widget)
right_layout.setContentsMargins(0, 0, 0, 0)
right_layout.setSpacing(0)
# 使用分割器可以调整日志区域大小
splitter = QSplitter(Qt.Orientation.Vertical)
# 主内容区
self.content_stack = QStackedWidget()
self._create_pages()
splitter.addWidget(self.content_stack)
# 日志区域
self.log_widget = LogWidget()
splitter.addWidget(self.log_widget)
# 设置初始比例
splitter.setSizes([600, 150])
right_layout.addWidget(splitter)
main_layout.addWidget(right_widget, 1)
# 连接日志信号
self._connect_log_signals()
def _create_sidebar(self) -> QWidget:
"""创建侧边栏"""
sidebar = QFrame()
sidebar.setObjectName("sidebar")
sidebar.setFixedWidth(180)
layout = QVBoxLayout(sidebar)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
# 标题
title = QLabel("📋 知识管理助手")
title.setObjectName("sidebar_title")
title.setAlignment(Qt.AlignmentFlag.AlignCenter)
layout.addWidget(title)
# 分隔线
line = QFrame()
line.setFrameShape(QFrame.Shape.HLine)
line.setStyleSheet("background-color: rgba(255,255,255,0.1);")
layout.addWidget(line)
# 导航按钮
self._nav_buttons = []
nav_items = [
("📝 账号管理", 0),
("🔄 浏览任务", 1),
("📸 截图管理", 2),
("📤 金山文档", 3),
("⚙️ 系统设置", 4),
]
for text, index in nav_items:
btn = QPushButton(text)
btn.setCheckable(True)
btn.clicked.connect(lambda checked, idx=index: self._switch_page(idx))
layout.addWidget(btn)
self._nav_buttons.append(btn)
layout.addStretch()
# 版本信息
version_label = QLabel("v1.0.0")
version_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
version_label.setStyleSheet("color: rgba(255,255,255,0.5); font-size: 11px; padding: 8px;")
layout.addWidget(version_label)
# 默认选中第一个
self._nav_buttons[0].setChecked(True)
return sidebar
def _create_pages(self):
"""创建各个页面"""
# 账号管理
self.account_widget = AccountWidget()
self.content_stack.addWidget(self.account_widget)
# 浏览任务
self.browse_widget = BrowseWidget()
self.content_stack.addWidget(self.browse_widget)
# 截图管理
self.screenshot_widget = ScreenshotWidget()
self.content_stack.addWidget(self.screenshot_widget)
# 金山文档
self.kdocs_widget = KDocsWidget()
self.content_stack.addWidget(self.kdocs_widget)
# 系统设置
self.settings_widget = SettingsWidget()
self.settings_widget.theme_changed.connect(self._apply_theme)
self.content_stack.addWidget(self.settings_widget)
def _connect_log_signals(self):
"""连接各模块的日志信号到日志面板"""
self.account_widget.log_signal.connect(self.log_widget.append_log)
self.browse_widget.log_signal.connect(self.log_widget.append_log)
self.screenshot_widget.log_signal.connect(self.log_widget.append_log)
self.kdocs_widget.log_signal.connect(self.log_widget.append_log)
self.settings_widget.log_signal.connect(self.log_widget.append_log)
def _switch_page(self, index: int):
"""切换页面"""
# 更新导航按钮状态
for i, btn in enumerate(self._nav_buttons):
btn.setChecked(i == index)
# 切换页面
self.content_stack.setCurrentIndex(index)
def _apply_theme(self, theme_name: str):
"""应用主题"""
self._current_theme = theme_name
theme = LIGHT_THEME if theme_name == "light" else DARK_THEME
self.setStyleSheet(get_stylesheet(theme))
def log(self, message: str):
"""记录日志"""
self.log_widget.append_log(message)
def closeEvent(self, event):
"""窗口关闭事件"""
# 保存配置
from config import get_config, save_config
config = get_config()
config.theme = self._current_theme
save_config(config)
# 清理金山文档上传器
try:
from core.kdocs_uploader import get_kdocs_uploader
uploader = get_kdocs_uploader()
uploader.close()
except Exception:
pass
event.accept()

387
ui/screenshot_widget.py Normal file
View File

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

265
ui/settings_widget.py Normal file
View File

@@ -0,0 +1,265 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Settings panel - wkhtmltoimage path, screenshot quality, theme
"""
import os
from PyQt6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, QPushButton,
QGroupBox, QFormLayout, QSpinBox, QComboBox, QMessageBox,
QFileDialog, QScrollArea, QFrame
)
from PyQt6.QtCore import Qt, pyqtSignal
from config import get_config, save_config
from .constants import (
PAGE_PADDING, SECTION_SPACING, GROUP_PADDING_TOP, GROUP_PADDING_SIDE,
GROUP_PADDING_BOTTOM, FORM_ROW_SPACING, FORM_H_SPACING,
INPUT_HEIGHT, INPUT_MIN_WIDTH, BUTTON_HEIGHT, BUTTON_HEIGHT_NORMAL,
BUTTON_MIN_WIDTH, BUTTON_MIN_WIDTH_NORMAL, BUTTON_SPACING,
get_title_style
)
class SettingsWidget(QWidget):
"""Settings panel"""
log_signal = pyqtSignal(str)
theme_changed = pyqtSignal(str)
def __init__(self, parent=None):
super().__init__(parent)
self._setup_ui()
self._load_settings()
def _setup_ui(self):
main_layout = QVBoxLayout(self)
main_layout.setContentsMargins(0, 0, 0, 0)
main_layout.setSpacing(0)
# Scroll area
scroll = QScrollArea()
scroll.setWidgetResizable(True)
scroll.setFrameShape(QFrame.Shape.NoFrame)
scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
content = QWidget()
layout = QVBoxLayout(content)
layout.setContentsMargins(PAGE_PADDING, PAGE_PADDING, PAGE_PADDING, PAGE_PADDING)
layout.setSpacing(SECTION_SPACING)
# Title
title = QLabel("⚙️ 系统设置")
title.setStyleSheet(get_title_style())
layout.addWidget(title)
# ==================== Screenshot settings ====================
screenshot_group = QGroupBox("截图设置")
screenshot_layout = QFormLayout(screenshot_group)
screenshot_layout.setContentsMargins(GROUP_PADDING_SIDE, GROUP_PADDING_TOP, GROUP_PADDING_SIDE, GROUP_PADDING_BOTTOM)
screenshot_layout.setSpacing(FORM_ROW_SPACING)
screenshot_layout.setLabelAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
# wkhtmltoimage path
wkhtml_layout = QHBoxLayout()
wkhtml_layout.setSpacing(FORM_H_SPACING)
self.wkhtml_edit = QLineEdit()
self.wkhtml_edit.setPlaceholderText("留空则自动检测")
self.wkhtml_edit.setMinimumHeight(INPUT_HEIGHT)
wkhtml_layout.addWidget(self.wkhtml_edit)
browse_btn = QPushButton("浏览...")
browse_btn.setMinimumHeight(BUTTON_HEIGHT_NORMAL)
browse_btn.clicked.connect(self._browse_wkhtml)
wkhtml_layout.addWidget(browse_btn)
detect_btn = QPushButton("检测")
detect_btn.setMinimumHeight(BUTTON_HEIGHT_NORMAL)
detect_btn.clicked.connect(self._detect_wkhtml)
wkhtml_layout.addWidget(detect_btn)
screenshot_layout.addRow("wkhtmltoimage:", wkhtml_layout)
# Screenshot width
self.width_spin = QSpinBox()
self.width_spin.setRange(800, 3840)
self.width_spin.setSingleStep(100)
self.width_spin.setMinimumHeight(INPUT_HEIGHT)
self.width_spin.setMinimumWidth(130)
screenshot_layout.addRow("截图宽度:", self.width_spin)
# Screenshot height
self.height_spin = QSpinBox()
self.height_spin.setRange(600, 2160)
self.height_spin.setSingleStep(100)
self.height_spin.setMinimumHeight(INPUT_HEIGHT)
self.height_spin.setMinimumWidth(130)
screenshot_layout.addRow("截图高度:", self.height_spin)
# Quality
self.quality_spin = QSpinBox()
self.quality_spin.setRange(50, 100)
self.quality_spin.setMinimumHeight(INPUT_HEIGHT)
self.quality_spin.setMinimumWidth(130)
self.quality_spin.setSuffix(" %")
screenshot_layout.addRow("JPEG质量:", self.quality_spin)
# JS delay
self.js_delay_spin = QSpinBox()
self.js_delay_spin.setRange(1000, 10000)
self.js_delay_spin.setSingleStep(500)
self.js_delay_spin.setSuffix(" ms")
self.js_delay_spin.setMinimumHeight(INPUT_HEIGHT)
self.js_delay_spin.setMinimumWidth(130)
screenshot_layout.addRow("JS等待时间:", self.js_delay_spin)
layout.addWidget(screenshot_group)
# ==================== Theme settings ====================
theme_group = QGroupBox("主题设置")
theme_layout = QFormLayout(theme_group)
theme_layout.setContentsMargins(GROUP_PADDING_SIDE, GROUP_PADDING_TOP, GROUP_PADDING_SIDE, GROUP_PADDING_BOTTOM)
theme_layout.setSpacing(FORM_ROW_SPACING)
theme_layout.setLabelAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
self.theme_combo = QComboBox()
self.theme_combo.addItem("浅色主题", "light")
self.theme_combo.addItem("深色主题", "dark")
self.theme_combo.setMinimumHeight(INPUT_HEIGHT)
self.theme_combo.setMinimumWidth(INPUT_MIN_WIDTH)
self.theme_combo.currentIndexChanged.connect(self._on_theme_change)
theme_layout.addRow("界面主题:", self.theme_combo)
layout.addWidget(theme_group)
# ==================== Platform API settings ====================
api_group = QGroupBox("平台设置")
api_layout = QFormLayout(api_group)
api_layout.setContentsMargins(GROUP_PADDING_SIDE, GROUP_PADDING_TOP, GROUP_PADDING_SIDE, GROUP_PADDING_BOTTOM)
api_layout.setSpacing(FORM_ROW_SPACING)
api_layout.setLabelAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
self.base_url_edit = QLineEdit()
self.base_url_edit.setMinimumHeight(INPUT_HEIGHT)
api_layout.addRow("平台地址:", self.base_url_edit)
self.login_url_edit = QLineEdit()
self.login_url_edit.setMinimumHeight(INPUT_HEIGHT)
api_layout.addRow("登录地址:", self.login_url_edit)
layout.addWidget(api_group)
# ==================== Action buttons ====================
btn_layout = QHBoxLayout()
btn_layout.setSpacing(BUTTON_SPACING)
btn_layout.addStretch()
reset_btn = QPushButton("恢复默认")
reset_btn.setMinimumWidth(BUTTON_MIN_WIDTH_NORMAL)
reset_btn.setMinimumHeight(BUTTON_HEIGHT)
reset_btn.clicked.connect(self._reset_defaults)
btn_layout.addWidget(reset_btn)
save_btn = QPushButton("💾 保存设置")
save_btn.setObjectName("primary")
save_btn.setMinimumWidth(BUTTON_MIN_WIDTH)
save_btn.setMinimumHeight(BUTTON_HEIGHT)
save_btn.clicked.connect(self._save_settings)
btn_layout.addWidget(save_btn)
layout.addLayout(btn_layout)
layout.addStretch()
scroll.setWidget(content)
main_layout.addWidget(scroll)
def _load_settings(self):
"""Load settings from config"""
config = get_config()
# Screenshot settings
self.wkhtml_edit.setText(config.screenshot.wkhtmltoimage_path)
self.width_spin.setValue(config.screenshot.width)
self.height_spin.setValue(config.screenshot.height)
self.quality_spin.setValue(config.screenshot.quality)
self.js_delay_spin.setValue(config.screenshot.js_delay_ms)
# Theme
idx = self.theme_combo.findData(config.theme)
if idx >= 0:
self.theme_combo.setCurrentIndex(idx)
# API settings
self.base_url_edit.setText(config.zsgl.base_url)
self.login_url_edit.setText(config.zsgl.login_url)
def _save_settings(self):
"""Save settings to config"""
config = get_config()
# Screenshot settings
config.screenshot.wkhtmltoimage_path = self.wkhtml_edit.text().strip()
config.screenshot.width = self.width_spin.value()
config.screenshot.height = self.height_spin.value()
config.screenshot.quality = self.quality_spin.value()
config.screenshot.js_delay_ms = self.js_delay_spin.value()
# Theme
config.theme = self.theme_combo.currentData()
# API settings
config.zsgl.base_url = self.base_url_edit.text().strip()
config.zsgl.login_url = self.login_url_edit.text().strip()
save_config(config)
self.log_signal.emit("设置已保存")
QMessageBox.information(self, "提示", "设置已保存")
def _browse_wkhtml(self):
"""Browse for wkhtmltoimage executable"""
filepath, _ = QFileDialog.getOpenFileName(
self, "选择 wkhtmltoimage",
"",
"可执行文件 (*.exe);;所有文件 (*)"
)
if filepath:
self.wkhtml_edit.setText(filepath)
def _detect_wkhtml(self):
"""Detect wkhtmltoimage path"""
from core.screenshot import _resolve_wkhtmltoimage_path
path = _resolve_wkhtmltoimage_path()
if path:
self.wkhtml_edit.setText(path)
self.log_signal.emit(f"检测到 wkhtmltoimage: {path}")
QMessageBox.information(self, "检测成功", f"找到 wkhtmltoimage:\n{path}")
else:
self.log_signal.emit("未检测到 wkhtmltoimage")
QMessageBox.warning(self, "检测失败", "未找到 wkhtmltoimage请手动指定路径或安装该工具。")
def _on_theme_change(self, index):
"""Handle theme change"""
theme = self.theme_combo.currentData()
self.theme_changed.emit(theme)
def _reset_defaults(self):
"""Reset to default settings"""
reply = QMessageBox.question(
self, "确认",
"确定要恢复默认设置吗?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
)
if reply == QMessageBox.StandardButton.Yes:
self.wkhtml_edit.clear()
self.width_spin.setValue(1920)
self.height_spin.setValue(1080)
self.quality_spin.setValue(95)
self.js_delay_spin.setValue(3000)
self.theme_combo.setCurrentIndex(0) # 默认浅色主题
self.base_url_edit.setText("https://postoa.aidunsoft.com")
self.login_url_edit.setText("https://postoa.aidunsoft.com/admin/login.aspx")
self.log_signal.emit("已恢复默认设置")

635
ui/styles.py Normal file
View File

@@ -0,0 +1,635 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
QSS Modern Stylesheet - 现代化UI样式
老王说:样式写好了界面才漂亮,这些都是精心调教过的!
"""
# 浅色主题
LIGHT_THEME = {
"bg_primary": "#ffffff",
"bg_secondary": "#fafafa",
"bg_tertiary": "#f0f0f0",
"bg_card": "#ffffff",
"text_primary": "#1a1a1a",
"text_secondary": "#595959",
"text_muted": "#8c8c8c",
"text_placeholder": "#bfbfbf",
"border": "#e8e8e8",
"border_light": "#f0f0f0",
"accent": "#1677ff",
"accent_hover": "#4096ff",
"accent_pressed": "#0958d9",
"accent_bg": "#e6f4ff",
"success": "#52c41a",
"success_bg": "#f6ffed",
"warning": "#faad14",
"warning_bg": "#fffbe6",
"error": "#ff4d4f",
"error_bg": "#fff2f0",
"sidebar_bg": "#001529",
"sidebar_text": "#ffffffd9",
"sidebar_text_muted": "#ffffff73",
"sidebar_hover": "#1677ff",
"sidebar_active_bg": "#1677ff",
"shadow": "rgba(0, 0, 0, 0.08)",
}
# 深色主题
DARK_THEME = {
"bg_primary": "#141414",
"bg_secondary": "#1f1f1f",
"bg_tertiary": "#262626",
"bg_card": "#1f1f1f",
"text_primary": "#ffffffd9",
"text_secondary": "#ffffffa6",
"text_muted": "#ffffff73",
"text_placeholder": "#ffffff40",
"border": "#424242",
"border_light": "#303030",
"accent": "#1668dc",
"accent_hover": "#3c89e8",
"accent_pressed": "#1554ad",
"accent_bg": "#111a2c",
"success": "#49aa19",
"success_bg": "#162312",
"warning": "#d89614",
"warning_bg": "#2b2111",
"error": "#dc4446",
"error_bg": "#2c1618",
"sidebar_bg": "#000000",
"sidebar_text": "#ffffffd9",
"sidebar_text_muted": "#ffffff73",
"sidebar_hover": "#1668dc",
"sidebar_active_bg": "#1668dc",
"shadow": "rgba(0, 0, 0, 0.45)",
}
def get_stylesheet(theme: dict) -> str:
"""Generate QSS stylesheet based on theme"""
return f"""
/* ==================== Global Reset ==================== */
* {{
outline: none;
}}
QWidget {{
font-family: "Microsoft YaHei UI", "Segoe UI", "PingFang SC", sans-serif;
font-size: 14px;
color: {theme["text_primary"]};
background-color: transparent;
}}
QMainWindow {{
background-color: {theme["bg_secondary"]};
}}
/* ==================== Scroll Area ==================== */
QScrollArea {{
background-color: transparent;
border: none;
}}
QScrollArea > QWidget > QWidget {{
background-color: transparent;
}}
/* ==================== Sidebar ==================== */
#sidebar {{
background-color: {theme["sidebar_bg"]};
border: none;
}}
#sidebar QPushButton {{
color: {theme["sidebar_text"]};
background-color: transparent;
border: none;
border-radius: 0;
padding: 14px 20px;
text-align: left;
font-size: 14px;
font-weight: 500;
}}
#sidebar QPushButton:hover {{
background-color: rgba(255, 255, 255, 0.08);
}}
#sidebar QPushButton:checked {{
background-color: {theme["sidebar_active_bg"]};
color: white;
font-weight: 600;
}}
#sidebar_title {{
color: white;
font-size: 16px;
font-weight: bold;
padding: 20px;
background-color: transparent;
}}
/* ==================== Buttons ==================== */
QPushButton {{
background-color: {theme["bg_card"]};
color: {theme["text_primary"]};
border: 1px solid {theme["border"]};
border-radius: 6px;
padding: 8px 16px;
font-weight: 500;
}}
QPushButton:hover {{
border-color: {theme["accent"]};
color: {theme["accent"]};
}}
QPushButton:pressed {{
background-color: {theme["bg_tertiary"]};
}}
QPushButton:disabled {{
background-color: {theme["bg_tertiary"]};
color: {theme["text_muted"]};
border-color: {theme["border"]};
}}
/* Primary Button */
QPushButton#primary {{
background-color: {theme["accent"]};
color: white;
border: none;
font-weight: 600;
}}
QPushButton#primary:hover {{
background-color: {theme["accent_hover"]};
}}
QPushButton#primary:pressed {{
background-color: {theme["accent_pressed"]};
}}
QPushButton#primary:disabled {{
background-color: {theme["accent"]};
opacity: 0.6;
}}
/* Success Button */
QPushButton#success {{
background-color: {theme["success"]};
color: white;
border: none;
}}
QPushButton#success:hover {{
background-color: #73d13d;
}}
/* Danger Button */
QPushButton#danger {{
background-color: {theme["error"]};
color: white;
border: none;
}}
QPushButton#danger:hover {{
background-color: #ff7875;
}}
/* ==================== Input Fields ==================== */
QLineEdit {{
background-color: {theme["bg_card"]};
color: {theme["text_primary"]};
border: 1px solid {theme["border"]};
border-radius: 6px;
padding: 6px 10px;
selection-background-color: {theme["accent"]};
selection-color: white;
}}
QLineEdit:hover {{
border-color: {theme["accent"]};
}}
QLineEdit:focus {{
border-color: {theme["accent"]};
border-width: 2px;
padding: 5px 9px;
}}
QLineEdit:disabled {{
background-color: {theme["bg_tertiary"]};
color: {theme["text_muted"]};
}}
QLineEdit[readOnly="true"] {{
background-color: {theme["bg_secondary"]};
}}
/* ==================== TextEdit ==================== */
QTextEdit, QPlainTextEdit {{
background-color: {theme["bg_card"]};
color: {theme["text_primary"]};
border: 1px solid {theme["border"]};
border-radius: 6px;
padding: 10px 14px;
selection-background-color: {theme["accent"]};
}}
QTextEdit:focus, QPlainTextEdit:focus {{
border-color: {theme["accent"]};
}}
/* ==================== ComboBox ==================== */
QComboBox {{
background-color: {theme["bg_card"]};
color: {theme["text_primary"]};
border: 1px solid {theme["border"]};
border-radius: 6px;
padding: 6px 10px;
padding-right: 30px;
}}
QComboBox:hover {{
border-color: {theme["accent"]};
}}
QComboBox:focus {{
border-color: {theme["accent"]};
}}
QComboBox::drop-down {{
border: none;
width: 30px;
subcontrol-position: right center;
}}
QComboBox::down-arrow {{
image: none;
border-left: 5px solid transparent;
border-right: 5px solid transparent;
border-top: 6px solid {theme["text_secondary"]};
margin-right: 10px;
}}
QComboBox QAbstractItemView {{
background-color: {theme["bg_card"]};
border: 1px solid {theme["border"]};
border-radius: 6px;
padding: 4px;
selection-background-color: {theme["accent_bg"]};
selection-color: {theme["accent"]};
outline: none;
}}
QComboBox QAbstractItemView::item {{
padding: 8px 12px;
border-radius: 4px;
}}
QComboBox QAbstractItemView::item:hover {{
background-color: {theme["bg_tertiary"]};
}}
/* ==================== SpinBox ==================== */
QSpinBox {{
background-color: {theme["bg_card"]};
color: {theme["text_primary"]};
border: 1px solid {theme["border"]};
border-radius: 6px;
padding: 10px 14px;
padding-right: 30px;
}}
QSpinBox:hover {{
border-color: {theme["accent"]};
}}
QSpinBox:focus {{
border-color: {theme["accent"]};
}}
QSpinBox::up-button, QSpinBox::down-button {{
width: 20px;
border: none;
background: transparent;
}}
QSpinBox::up-arrow, QSpinBox::down-arrow {{
width: 0;
height: 0;
border: none;
background: transparent;
}}
/* ==================== CheckBox ==================== */
QCheckBox {{
spacing: 10px;
color: {theme["text_primary"]};
}}
QCheckBox::indicator {{
width: 18px;
height: 18px;
border: 1px solid {theme["border"]};
border-radius: 4px;
background-color: {theme["bg_card"]};
}}
QCheckBox::indicator:hover {{
border-color: {theme["accent"]};
}}
QCheckBox::indicator:checked {{
background-color: {theme["accent"]};
border-color: {theme["accent"]};
}}
/* ==================== GroupBox - Card Style ==================== */
QGroupBox {{
background-color: {theme["bg_card"]};
border: 1px solid {theme["border"]};
border-radius: 10px;
margin-top: 14px;
padding: 24px 0 0 0;
}}
QGroupBox::title {{
subcontrol-origin: margin;
subcontrol-position: top left;
left: 16px;
top: 0px;
padding: 4px 12px;
background-color: {theme["accent"]};
color: white;
border-radius: 4px;
font-weight: 600;
font-size: 13px;
}}
/* ==================== Table ==================== */
QTableWidget {{
background-color: {theme["bg_card"]};
border: 1px solid {theme["border"]};
border-radius: 8px;
gridline-color: {theme["border_light"]};
selection-background-color: {theme["accent_bg"]};
selection-color: {theme["text_primary"]};
}}
QTableWidget::item {{
padding: 8px 4px;
border: none;
}}
QTableWidget::item:selected {{
background-color: {theme["accent_bg"]};
color: {theme["accent"]};
}}
QTableWidget::item:hover {{
background-color: {theme["bg_secondary"]};
}}
QHeaderView::section {{
background-color: {theme["bg_secondary"]};
color: {theme["text_secondary"]};
padding: 12px 8px;
border: none;
border-bottom: 1px solid {theme["border"]};
font-weight: 600;
font-size: 13px;
}}
QHeaderView::section:first {{
border-top-left-radius: 8px;
}}
QHeaderView::section:last {{
border-top-right-radius: 8px;
}}
QTableCornerButton::section {{
background-color: {theme["bg_secondary"]};
border: none;
}}
/* ==================== List ==================== */
QListWidget {{
background-color: {theme["bg_card"]};
border: 1px solid {theme["border"]};
border-radius: 8px;
padding: 4px;
outline: none;
}}
QListWidget::item {{
padding: 12px 14px;
border-radius: 6px;
margin: 2px 0;
}}
QListWidget::item:selected {{
background-color: {theme["accent_bg"]};
color: {theme["accent"]};
}}
QListWidget::item:hover:!selected {{
background-color: {theme["bg_secondary"]};
}}
/* ==================== Progress Bar ==================== */
QProgressBar {{
background-color: {theme["bg_tertiary"]};
border: none;
border-radius: 4px;
height: 8px;
text-align: center;
}}
QProgressBar::chunk {{
background-color: {theme["accent"]};
border-radius: 4px;
}}
/* ==================== ScrollBar ==================== */
QScrollBar:vertical {{
background-color: transparent;
width: 8px;
margin: 4px 2px;
}}
QScrollBar::handle:vertical {{
background-color: {theme["border"]};
border-radius: 4px;
min-height: 40px;
}}
QScrollBar::handle:vertical:hover {{
background-color: {theme["text_muted"]};
}}
QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {{
height: 0;
background: none;
}}
QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical {{
background: none;
}}
QScrollBar:horizontal {{
background-color: transparent;
height: 8px;
margin: 2px 4px;
}}
QScrollBar::handle:horizontal {{
background-color: {theme["border"]};
border-radius: 4px;
min-width: 40px;
}}
QScrollBar::handle:horizontal:hover {{
background-color: {theme["text_muted"]};
}}
QScrollBar::add-line:horizontal, QScrollBar::sub-line:horizontal {{
width: 0;
background: none;
}}
/* ==================== Tab Widget ==================== */
QTabWidget::pane {{
border: 1px solid {theme["border"]};
border-radius: 8px;
background-color: {theme["bg_card"]};
padding: 8px;
}}
QTabBar::tab {{
background-color: transparent;
color: {theme["text_secondary"]};
padding: 10px 20px;
margin-right: 4px;
border-bottom: 2px solid transparent;
}}
QTabBar::tab:selected {{
color: {theme["accent"]};
border-bottom: 2px solid {theme["accent"]};
}}
QTabBar::tab:hover:!selected {{
color: {theme["text_primary"]};
}}
/* ==================== ToolTip ==================== */
QToolTip {{
background-color: {theme["bg_tertiary"]};
color: {theme["text_primary"]};
border: 1px solid {theme["border"]};
border-radius: 6px;
padding: 8px 12px;
}}
/* ==================== Message Box ==================== */
QMessageBox {{
background-color: {theme["bg_card"]};
}}
QMessageBox QLabel {{
color: {theme["text_primary"]};
font-size: 14px;
}}
QMessageBox QPushButton {{
min-width: 80px;
min-height: 32px;
}}
/* ==================== Dialog ==================== */
QDialog {{
background-color: {theme["bg_card"]};
}}
/* ==================== Label ==================== */
QLabel {{
color: {theme["text_primary"]};
background-color: transparent;
}}
QLabel#title {{
font-size: 20px;
font-weight: 600;
color: {theme["text_primary"]};
}}
QLabel#subtitle {{
font-size: 14px;
color: {theme["text_secondary"]};
}}
QLabel#help {{
font-size: 13px;
color: {theme["text_muted"]};
}}
/* ==================== Frame ==================== */
QFrame {{
background-color: transparent;
}}
QFrame#card {{
background-color: {theme["bg_card"]};
border: 1px solid {theme["border"]};
border-radius: 10px;
}}
QFrame#separator {{
background-color: {theme["border"]};
max-height: 1px;
}}
/* ==================== Log Widget ==================== */
#log_widget {{
background-color: {theme["bg_secondary"]};
border-top: 1px solid {theme["border"]};
}}
#log_widget QPlainTextEdit {{
background-color: {theme["bg_secondary"]};
border: none;
color: {theme["text_secondary"]};
font-family: "Cascadia Code", "Consolas", "Monaco", monospace;
font-size: 12px;
padding: 8px;
}}
/* ==================== Status Colors ==================== */
.success {{
color: {theme["success"]};
}}
.warning {{
color: {theme["warning"]};
}}
.error {{
color: {theme["error"]};
}}
/* ==================== Form Label ==================== */
QLabel#formLabel {{
color: {theme["text_secondary"]};
font-weight: 500;
}}
"""
def apply_theme(app, theme_name: str = "light"):
"""Apply theme to application"""
theme = LIGHT_THEME if theme_name == "light" else DARK_THEME
app.setStyleSheet(get_stylesheet(theme))