Files
cmshoppe/app/gui/widgets.py
T

551 lines
14 KiB
Python
Raw Normal View History

2026-07-02 16:47:37 +08:00
"""PySide6 GUI entry point."""
from __future__ import annotations
import os
import sys
import threading
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
try:
from PySide6.QtCore import QAbstractTableModel, QModelIndex, Qt
from PySide6.QtGui import QColor, QIcon, QPainter, QPixmap
from PySide6.QtWidgets import (
QAbstractItemView,
QApplication,
QCheckBox,
QComboBox,
QDialog,
QDialogButtonBox,
QFileDialog,
QFormLayout,
QGridLayout,
QHBoxLayout,
QHeaderView,
QInputDialog,
QLabel,
QLineEdit,
QMainWindow,
QMenu,
QMessageBox,
QPlainTextEdit,
QProgressBar,
QPushButton,
QScrollArea,
QSplitter,
QTableView,
QSpinBox,
QTableWidget,
QTableWidgetItem,
QTabWidget,
QVBoxLayout,
QWidget,
)
QT_IMPORT_ERROR = None
except ModuleNotFoundError as exc:
QApplication = None
QMainWindow = object
QTabWidget = None
QVBoxLayout = None
QWidget = object
QT_IMPORT_ERROR = exc
TAB_TITLES = [
"① 导入采集",
"② AI生成",
2026-07-07 14:58:54 +08:00
"③ 更新蝦皮",
2026-07-02 16:47:37 +08:00
"④ 账号管理",
"⑤ 设置",
]
TAB_STYLE = """
QTabWidget::pane {
border-top: 1px solid #c9d1d9;
}
QTabBar::tab {
min-width: 128px;
min-height: 34px;
padding: 8px 18px;
margin-right: 8px;
border: 1px solid #c9d1d9;
border-bottom-color: #b8c0ca;
background: #f4f6f8;
color: #24292f;
}
QTabBar::tab:selected {
background: #ffffff;
border-color: #687785;
border-bottom-color: #ffffff;
font-weight: 600;
}
QTabBar::tab:hover:!selected {
background: #eaf2ff;
}
"""
COLOR_SUCCESS = "#1a7f37"
COLOR_DANGER = "#cf222e"
COLOR_INFO = "#0969da"
COLOR_PENDING = "#9a6700"
COLOR_MUTED = "#6e7781"
COLOR_WARNING = "#bc4c00"
COLOR_DANGER_BG = "#ffebe9"
2026-07-07 15:42:19 +08:00
STATUS_LEVEL_COLORS = {
"muted": COLOR_MUTED,
"info": COLOR_INFO,
"success": COLOR_SUCCESS,
"warning": COLOR_WARNING,
"danger": COLOR_DANGER,
"pending": COLOR_PENDING,
}
2026-07-07 09:21:19 +08:00
BUTTON_RADIUS_PX = 4
BUTTON_BASE_STYLE = f"""
QPushButton {{
min-height: 24px;
min-width: 72px;
padding: 4px 10px;
border: 1px solid #d0d7de;
border-radius: {BUTTON_RADIUS_PX}px;
background-color: #f6f8fa;
color: #24292f;
}}
QPushButton:hover {{
background-color: #eef4ff;
border-color: #8c959f;
}}
QPushButton:pressed {{
background-color: #d8dee4;
border-color: #6e7781;
}}
QPushButton:focus {{
border-color: #0969da;
}}
QPushButton:disabled {{
background-color: #f6f8fa;
border-color: #d8dee4;
color: #8c959f;
}}
"""
2026-07-02 16:47:37 +08:00
_RawQMessageBox = QMessageBox if QT_IMPORT_ERROR is None else None
class _GuiAttributeProxy:
def __init__(self, name, fallback):
self._name = name
self._fallback = fallback
def _target(self):
package = sys.modules.get("app.gui")
if package is not None:
value = getattr(package, self._name, self._fallback)
if value is not self:
return value
return self._fallback
def __getattr__(self, item):
return getattr(self._target(), item)
def __call__(self, *args, **kwargs):
return self._target()(*args, **kwargs)
def _package_attr(name, fallback):
package = sys.modules.get("app.gui")
if package is not None:
value = getattr(package, name, fallback)
if value is not fallback:
return value
return fallback
def _call_package_attr(name, fallback, *args, **kwargs):
return _package_attr(name, fallback)(*args, **kwargs)
QMessageBox = _GuiAttributeProxy("QMessageBox", _RawQMessageBox)
from .. import accounts, ai, appconfig, chrome, db, diagnostics, editor, excel, image_paths, prompts
from .. import config as account_config
from ..workers import BaseWorker, run_worker as _raw_run_worker
def run_worker(*args, **kwargs):
return _package_attr("run_worker", _raw_run_worker)(*args, **kwargs)
PLAINTEXT_SECRET_TITLE = "本地明文保存提示"
PLAINTEXT_API_KEY_WARNING = (
"API Key 会以本地明文保存到 data/config/ai_models.json,仅供本机调用 AI 使用。"
2026-07-02 16:47:37 +08:00
"该文件已 gitignore,UI 打码显示,日志/导出不记录明文。"
)
PLAINTEXT_PASSWORD_WARNING = (
"密码会以本地明文保存到本地 SQLite,仅供人工参考,不会自动登录/自动填。"
"数据库文件已 gitignore,请勿提交或分享。"
)
def _qcolor(color):
return QColor(color)
def _status_base_color(status):
if status == "running":
return COLOR_INFO
if status == "failed":
return COLOR_DANGER
if status in {"skipped", "cancelled"}:
return COLOR_MUTED
return None
2026-07-07 15:42:19 +08:00
def _status_level_color(level):
return STATUS_LEVEL_COLORS.get(str(level or "muted"), COLOR_MUTED)
def _status_level_for_message(message, default="muted"):
text = str(message or "")
if any(keyword in text for keyword in ("失败", "错误", "异常", "未登录", "不可写", "点数不足", "阻止")):
return "danger"
if any(keyword in text for keyword in ("没有", "请先", "未配置", "不完整", "已取消", "不能", "无法")):
return "warning"
if any(
keyword in text
for keyword in (
"完成",
"成功",
"已保存",
"已回写",
"已新增",
"已删除",
"已软删除",
"已重置",
"已刷新",
"已修改",
"已启动",
"已复用",
"已生成",
"已新建",
"已另存为",
"已重命名",
)
):
return "success"
if any(keyword in text for keyword in ("正在", "开始", "进度", "检测中")):
return "info"
return default
def _emit_status(callback, message, level=None):
if callback is None:
return
actual_level = level or _status_level_for_message(message)
try:
callback(str(message), level=actual_level)
except TypeError:
callback(str(message))
2026-07-02 16:47:37 +08:00
def _danger_metric_text(text, active):
if not active:
return text
return f'<span style="color:{COLOR_DANGER}; font-weight:600;">{text}</span>'
2026-07-07 09:21:19 +08:00
def _outline_button_style(object_name, color, hover_bg="#f6f8fa", pressed_bg="#f6f8fa"):
2026-07-02 16:47:37 +08:00
return (
f"QPushButton#{object_name} {{ "
2026-07-07 09:21:19 +08:00
f"color: {color}; border-color: {color}; font-weight: 600; "
2026-07-02 16:47:37 +08:00
"}"
2026-07-07 09:21:19 +08:00
f"QPushButton#{object_name}:hover {{ "
f"background-color: {hover_bg}; border-color: {color}; "
"}"
f"QPushButton#{object_name}:pressed {{ "
f"background-color: {pressed_bg}; border-color: {color}; "
"}"
f"QPushButton#{object_name}:disabled {{ "
"color: #8c959f; border-color: #d8dee4; "
"}"
)
def _warning_outline_button_style(object_name):
return _outline_button_style(
object_name,
COLOR_WARNING,
hover_bg="#fff8f0",
pressed_bg="#ffedd5",
)
def _danger_outline_button_style(object_name):
return _outline_button_style(
object_name,
COLOR_DANGER,
hover_bg="#fff1f0",
pressed_bg=COLOR_DANGER_BG,
2026-07-02 16:47:37 +08:00
)
def _login_status_display(status):
return f"● {status or '未知'}"
def _login_status_color(status):
status_text = str(status or "")
if status_text == "已登录":
return _qcolor(COLOR_SUCCESS)
if status_text == "检测中":
return _qcolor(COLOR_INFO)
if "未登录" in status_text or "失败" in status_text:
return _qcolor(COLOR_DANGER)
return _qcolor(COLOR_MUTED)
def _warning_dot_icon(size=12):
pixmap = QPixmap(size, size)
pixmap.fill(Qt.transparent)
painter = QPainter(pixmap)
painter.setRenderHint(QPainter.Antialiasing)
painter.setPen(Qt.NoPen)
painter.setBrush(QColor(COLOR_WARNING))
margin = max(1, size // 6)
painter.drawEllipse(margin, margin, size - margin * 2, size - margin * 2)
painter.end()
return QIcon(pixmap)
def _build_empty_state_card(object_name, button_text="前往账号管理"):
card = QWidget()
card.setObjectName(object_name)
card.setStyleSheet(
f"QWidget#{object_name} {{ "
"background: #f6f8fa; border: 1px solid #d0d7de; "
"border-radius: 6px; padding: 10px; "
"}"
)
layout = QHBoxLayout(card)
layout.setContentsMargins(12, 10, 12, 10)
label = QLabel("")
label.setWordWrap(True)
button = QPushButton(button_text)
button.setObjectName(f"{object_name}Button")
button.setVisible(False)
layout.addWidget(label, 1)
layout.addWidget(button)
card.setVisible(False)
return card, label, button
def _set_empty_state(card, label, button, message=None, show_button=False):
active = bool(message)
card.setVisible(active)
label.setText(message or "")
button.setVisible(active and show_button)
BATCH_PROGRESS_FIELDS = [
("total", "总数"),
("imported", "导入"),
("collected", "已采集"),
("generated", "已生成"),
("applied", "已更新"),
("failed", "失败"),
("skipped", "略过"),
]
def _summarize_batch_progress(tasks):
summary = {key: 0 for key, _label in BATCH_PROGRESS_FIELDS}
task_rows = list(tasks or [])
summary["total"] = len(task_rows)
for task in task_rows:
status = getattr(task, "status", None)
stage = getattr(task, "stage", None)
if status == "failed":
summary["failed"] += 1
elif status == "skipped":
summary["skipped"] += 1
elif stage == "applied":
summary["applied"] += 1
elif stage == "generated":
summary["generated"] += 1
elif stage == "collected":
summary["collected"] += 1
else:
summary["imported"] += 1
return summary
def _format_batch_progress(summary):
parts = [f"{label}{summary.get(key, 0)}" for key, label in BATCH_PROGRESS_FIELDS]
return "批次进度:" + " · ".join(parts)
def _build_batch_progress_overview(object_name):
label = QLabel("")
label.setObjectName(object_name)
label.setWordWrap(True)
label.setStyleSheet(
f"QLabel#{object_name} {{ "
"background: #f6f8fa; border: 1px solid #d0d7de; "
"border-radius: 6px; padding: 8px 10px; color: #24292f; "
"}"
)
label.setVisible(False)
return label
def _set_batch_progress_overview(label, tasks):
summary = _summarize_batch_progress(tasks)
active = summary["total"] > 0
label.setVisible(active)
label.setText(_format_batch_progress(summary) if active else "")
def _batch_filter_current_index(combo, batches, selected_batch, had_previous_items):
batch_ids = {batch.id for batch in batches}
if selected_batch in batch_ids:
index = combo.findData(selected_batch)
if index >= 0:
return index
if selected_batch is None and had_previous_items:
index = combo.findData(None)
if index >= 0:
return index
return 1 if batches else 0
2026-07-02 16:47:37 +08:00
def _database_path(db_path=None, config=None) -> str:
return db_path or appconfig.db_path(config)
def _write_reset_run_log(db_path, task, action, message):
run_id = db.create_run_log(
"reset",
total=1,
options={
"action": action,
"task_id": getattr(task, "id", None),
"alias": getattr(task, "alias", None),
"item_id": getattr(task, "item_id", None),
},
path=db_path,
)
db.add_run_log_event(
run_id,
message,
task_id=getattr(task, "id", None),
alias=getattr(task, "alias", None),
item_id=getattr(task, "item_id", None),
path=db_path,
)
db.finish_run_log(
run_id,
status="done",
done=1,
success_count=1,
failed_count=0,
summary_json={
"action": action,
"task_id": getattr(task, "id", None),
"alias": getattr(task, "alias", None),
"item_id": getattr(task, "item_id", None),
},
path=db_path,
)
return run_id
def _safe_create_run_log(run_type, db_path=None, dry_run=False, total=0, options=None):
try:
return db.create_run_log(
run_type,
dry_run=dry_run,
total=total,
options=options or {},
path=db_path,
)
except Exception:
return None
def _safe_finish_run_log(run_id, db_path=None, **fields):
if run_id is None:
return
try:
db.finish_run_log(run_id, path=db_path, **fields)
except Exception:
return
def _safe_add_run_log_event(
run_id,
message,
db_path=None,
task=None,
account=None,
level="info",
task_id=None,
alias=None,
item_id=None,
):
safe_message = diagnostics.redact_log_text(message)
if task is not None:
task_id = getattr(task, "id", task_id)
alias = getattr(task, "alias", alias)
item_id = getattr(task, "item_id", item_id)
if account is not None:
alias = getattr(account, "alias", alias)
if run_id is None:
return safe_message
try:
db.add_run_log_event(
run_id,
safe_message,
task_id=task_id,
alias=alias,
item_id=item_id,
level=level,
path=db_path,
)
except Exception:
pass
return safe_message
def _safe_write_diagnostic_log(
message,
level="INFO",
step=None,
task=None,
account=None,
elapsed_ms=None,
payload=None,
exc=None,
log_dir=None,
):
try:
diagnostics.write_diagnostic_log(
message,
level=level,
step=step,
task_id=getattr(task, "id", None),
alias=getattr(task, "alias", None) or getattr(account, "alias", None),
item_id=getattr(task, "item_id", None),
elapsed_ms=elapsed_ms,
payload=payload,
exc=exc,
log_dir=log_dir,
)
except Exception:
return
def _elapsed_ms(started):
return int((time.monotonic() - started) * 1000)
2026-07-07 09:21:19 +08:00
__all__ = [name for name in globals() if not name.startswith("__")]