feat: 知识管理平台精简版 - PyQt6桌面应用
主要功能: - 账号管理:添加/编辑/删除账号,测试登录 - 浏览任务:批量浏览应读/选读内容并标记已读 - 截图管理:wkhtmltoimage截图,查看历史 - 金山文档:扫码登录/微信快捷登录,自动上传截图 技术栈: - PyQt6 GUI框架 - Playwright 浏览器自动化 - SQLite 本地数据存储 - wkhtmltoimage 网页截图 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
13
ui/__init__.py
Normal file
13
ui/__init__.py
Normal 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
416
ui/account_widget.py
Normal 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
356
ui/browse_widget.py
Normal 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
136
ui/constants.py
Normal 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
650
ui/kdocs_widget.py
Normal 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
120
ui/log_widget.py
Normal 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
195
ui/main_window.py
Normal 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
387
ui/screenshot_widget.py
Normal 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
265
ui/settings_widget.py
Normal 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
635
ui/styles.py
Normal 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))
|
||||
Reference in New Issue
Block a user