feat: 完成Tab④账号管理
- 新增 accounts 服务层,封装账号 CRUD、目录创建、端口分配与登录检测 - 接入 PySide6 账号管理 Tab:表格、账号弹窗、启动登录、后台检测登录 - 密码仅本地保存并在 UI 打码,表格不展示明文;不自动登录或填密码 - 增加顶部 Tab 栏防误点样式,扩大点击区域并高亮当前 Tab - 补充账号服务和 GUI 单元测试,并同步架构、API、路由、任务和进度文档
This commit is contained in:
+237
@@ -0,0 +1,237 @@
|
||||
"""Account management services for the PySide6 accounts tab."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from . import appconfig, chrome, db, editor
|
||||
from . import config as account_config
|
||||
|
||||
|
||||
DEFAULT_REGION_HOST = editor.DEFAULT_REGION_HOST
|
||||
|
||||
|
||||
class AccountError(RuntimeError):
|
||||
"""Raised when account management cannot complete an operation."""
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
return datetime.now().isoformat(timespec="seconds")
|
||||
|
||||
|
||||
def _db_path(path=None, config=None) -> str:
|
||||
return path or appconfig.db_path(config)
|
||||
|
||||
|
||||
def _trim(value) -> str:
|
||||
return str(value or "").strip()
|
||||
|
||||
|
||||
def _optional_text(value) -> Optional[str]:
|
||||
text = _trim(value)
|
||||
return text or None
|
||||
|
||||
|
||||
def _required_text(value, field_name: str) -> str:
|
||||
text = _trim(value)
|
||||
if not text:
|
||||
raise AccountError(f"{field_name}不能为空")
|
||||
return text
|
||||
|
||||
|
||||
def normalize_region_host(value=None) -> str:
|
||||
text = _trim(value) or DEFAULT_REGION_HOST
|
||||
if "://" in text:
|
||||
text = urlparse(text).netloc
|
||||
text = text.strip("/")
|
||||
if "/" in text:
|
||||
text = text.split("/", 1)[0]
|
||||
return text or DEFAULT_REGION_HOST
|
||||
|
||||
|
||||
def normalize_debug_port(value) -> int:
|
||||
try:
|
||||
port = int(value)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise AccountError("调试端口必须是数字") from exc
|
||||
if port <= 0 or port > 65535:
|
||||
raise AccountError("调试端口必须在 1-65535 范围内")
|
||||
return port
|
||||
|
||||
|
||||
def preview_user_data_dir(alias, config=None) -> str:
|
||||
slug = account_config.make_slug(alias)
|
||||
root = appconfig.user_data_root(config)
|
||||
return os.path.abspath(os.path.join(str(root), slug))
|
||||
|
||||
|
||||
def mask_password(password) -> str:
|
||||
return "******" if password else ""
|
||||
|
||||
|
||||
def next_debug_port(config=None, path=None) -> int:
|
||||
cfg = appconfig.load_config() if config is None else config
|
||||
start, end = appconfig.debug_port_range(cfg)
|
||||
database_path = _db_path(path, cfg)
|
||||
db.init_db(database_path)
|
||||
used = {int(account.debug_port) for account in db.list_accounts(path=database_path)}
|
||||
for port in range(start, end + 1):
|
||||
if port not in used:
|
||||
return port
|
||||
return end + 1
|
||||
|
||||
|
||||
def _assert_debug_port_available(port, database_path, ignore_alias=None) -> None:
|
||||
used_by = [
|
||||
account.alias
|
||||
for account in db.list_accounts(path=database_path)
|
||||
if int(account.debug_port) == int(port) and account.alias != ignore_alias
|
||||
]
|
||||
if used_by:
|
||||
raise AccountError(f"调试端口 {port} 已被账号 {used_by[0]} 使用")
|
||||
|
||||
|
||||
def list_accounts(path=None, config=None):
|
||||
database_path = _db_path(path, config)
|
||||
db.init_db(database_path)
|
||||
return db.list_accounts(path=database_path)
|
||||
|
||||
|
||||
def get_account(alias, path=None, config=None):
|
||||
database_path = _db_path(path, config)
|
||||
db.init_db(database_path)
|
||||
account = db.get_account_by_alias(alias, path=database_path)
|
||||
if account is None:
|
||||
raise AccountError(f"账号不存在: {alias}")
|
||||
return account
|
||||
|
||||
|
||||
def create_account(
|
||||
account_name,
|
||||
alias,
|
||||
region_host=None,
|
||||
debug_port=None,
|
||||
password=None,
|
||||
note=None,
|
||||
path=None,
|
||||
config=None,
|
||||
):
|
||||
cfg = appconfig.load_config() if config is None else config
|
||||
database_path = _db_path(path, cfg)
|
||||
db.init_db(database_path)
|
||||
|
||||
account_name = _required_text(account_name, "账号名")
|
||||
alias = _required_text(alias, "别名")
|
||||
region_host = normalize_region_host(region_host)
|
||||
port = normalize_debug_port(
|
||||
next_debug_port(config=cfg, path=database_path) if debug_port is None else debug_port
|
||||
)
|
||||
_assert_debug_port_available(port, database_path)
|
||||
slug = account_config.make_slug(alias)
|
||||
user_data_dir = account_config.ensure_user_data_dir(slug, config=cfg)
|
||||
|
||||
try:
|
||||
return db.add_account(
|
||||
account_name,
|
||||
alias,
|
||||
region_host,
|
||||
port,
|
||||
password=_optional_text(password),
|
||||
note=_optional_text(note),
|
||||
slug=slug,
|
||||
user_data_dir=user_data_dir,
|
||||
path=database_path,
|
||||
)
|
||||
except db.DbError as exc:
|
||||
raise AccountError(str(exc)) from exc
|
||||
|
||||
|
||||
def update_account(
|
||||
original_alias,
|
||||
account_name,
|
||||
alias,
|
||||
region_host=None,
|
||||
debug_port=None,
|
||||
password=None,
|
||||
note=None,
|
||||
path=None,
|
||||
config=None,
|
||||
):
|
||||
cfg = appconfig.load_config() if config is None else config
|
||||
database_path = _db_path(path, cfg)
|
||||
existing = get_account(original_alias, path=database_path, config=cfg)
|
||||
|
||||
account_name = _required_text(account_name, "账号名")
|
||||
alias = _required_text(alias, "别名")
|
||||
region_host = normalize_region_host(region_host)
|
||||
port = normalize_debug_port(debug_port)
|
||||
_assert_debug_port_available(port, database_path, ignore_alias=existing.alias)
|
||||
|
||||
if alias != existing.alias:
|
||||
slug = account_config.make_slug(alias)
|
||||
user_data_dir = account_config.ensure_user_data_dir(slug, config=cfg)
|
||||
else:
|
||||
slug = existing.slug
|
||||
user_data_dir = os.path.abspath(existing.user_data_dir)
|
||||
os.makedirs(user_data_dir, exist_ok=True)
|
||||
|
||||
try:
|
||||
db.update_account(
|
||||
original_alias,
|
||||
path=database_path,
|
||||
account_name=account_name,
|
||||
alias=alias,
|
||||
region_host=region_host,
|
||||
slug=slug,
|
||||
user_data_dir=user_data_dir,
|
||||
debug_port=port,
|
||||
password=_optional_text(password),
|
||||
note=_optional_text(note),
|
||||
)
|
||||
return get_account(alias, path=database_path, config=cfg)
|
||||
except db.DbError as exc:
|
||||
raise AccountError(str(exc)) from exc
|
||||
|
||||
|
||||
def delete_account(alias, path=None, config=None) -> None:
|
||||
database_path = _db_path(path, config)
|
||||
db.init_db(database_path)
|
||||
db.delete_account(alias, path=database_path)
|
||||
|
||||
|
||||
def resolve_account(account_or_alias, path=None, config=None):
|
||||
if isinstance(account_or_alias, str):
|
||||
return get_account(account_or_alias, path=path, config=config)
|
||||
return account_or_alias
|
||||
|
||||
|
||||
def launch_for_login(account_or_alias, path=None, config=None):
|
||||
account = resolve_account(account_or_alias, path=path, config=config)
|
||||
return chrome.launch_chrome(account, config=config)
|
||||
|
||||
|
||||
def detect_login(account_or_alias, timeout=8, path=None, config=None) -> dict:
|
||||
database_path = _db_path(path, config)
|
||||
account = resolve_account(account_or_alias, path=database_path, config=config)
|
||||
status = editor.login_status(account, timeout=timeout)
|
||||
if status.get("logged_in"):
|
||||
db.update_account(account.alias, path=database_path, last_login_at=_now())
|
||||
return status
|
||||
|
||||
|
||||
def login_status_text(status) -> str:
|
||||
if not status:
|
||||
return "未知"
|
||||
if status.get("logged_in"):
|
||||
return "已登录"
|
||||
reason = status.get("reason")
|
||||
if reason == "LOGIN_PAGE":
|
||||
return "未登录"
|
||||
if reason == "NO_SESSION_COOKIE":
|
||||
return "未登录"
|
||||
if reason:
|
||||
return f"未登录({reason})"
|
||||
return "未登录"
|
||||
@@ -19,6 +19,7 @@ DEFAULT_BUSY_TIMEOUT_MS = 5000
|
||||
VALID_BATCH_FIELDS = {"source_files_json", "status", "note"}
|
||||
VALID_ACCOUNT_FIELDS = {
|
||||
"account_name",
|
||||
"alias",
|
||||
"region_host",
|
||||
"slug",
|
||||
"user_data_dir",
|
||||
@@ -354,19 +355,24 @@ def add_account(
|
||||
return get_account_by_alias(alias, conn=database)
|
||||
|
||||
|
||||
def update_account(alias, path=None, conn=None, **fields) -> None:
|
||||
def update_account(account_alias, path=None, conn=None, **fields) -> None:
|
||||
_validate_fields(fields, VALID_ACCOUNT_FIELDS)
|
||||
if not fields:
|
||||
return
|
||||
fields["updated_at"] = _now()
|
||||
assignments = ", ".join(f"{field} = ?" for field in fields)
|
||||
params = list(fields.values()) + [alias]
|
||||
params = list(fields.values()) + [account_alias]
|
||||
with _connection(conn, path) as database:
|
||||
with database:
|
||||
database.execute(
|
||||
f"UPDATE accounts SET {assignments} WHERE alias = ?",
|
||||
params,
|
||||
)
|
||||
try:
|
||||
with database:
|
||||
database.execute(
|
||||
f"UPDATE accounts SET {assignments} WHERE alias = ?",
|
||||
params,
|
||||
)
|
||||
except sqlite3.IntegrityError as exc:
|
||||
raise DbError(
|
||||
f"账号别名或 slug 已存在: {fields.get('alias', account_alias)}"
|
||||
) from exc
|
||||
|
||||
|
||||
def delete_account(alias, path=None, conn=None) -> None:
|
||||
|
||||
+399
-3
@@ -7,8 +7,22 @@ import sys
|
||||
|
||||
try:
|
||||
from PySide6.QtWidgets import (
|
||||
QAbstractItemView,
|
||||
QApplication,
|
||||
QDialog,
|
||||
QDialogButtonBox,
|
||||
QFormLayout,
|
||||
QHBoxLayout,
|
||||
QHeaderView,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QMainWindow,
|
||||
QMessageBox,
|
||||
QPlainTextEdit,
|
||||
QPushButton,
|
||||
QSpinBox,
|
||||
QTableWidget,
|
||||
QTableWidgetItem,
|
||||
QTabWidget,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
@@ -31,24 +45,406 @@ TAB_TITLES = [
|
||||
"⑤ 设置",
|
||||
]
|
||||
|
||||
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;
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
if QT_IMPORT_ERROR is None:
|
||||
from . import accounts, appconfig
|
||||
from . import config as account_config
|
||||
|
||||
|
||||
class AccountDialog(QDialog):
|
||||
"""Dialog for adding or editing one account."""
|
||||
|
||||
def __init__(self, parent=None, account=None, default_port=9222, config=None):
|
||||
super().__init__(parent)
|
||||
self._account = account
|
||||
self._config = config
|
||||
self.setWindowTitle("编辑账号" if account else "新增账号")
|
||||
|
||||
self.account_name_edit = QLineEdit()
|
||||
self.alias_edit = QLineEdit()
|
||||
self.region_host_edit = QLineEdit(accounts.DEFAULT_REGION_HOST)
|
||||
self.debug_port_spin = QSpinBox()
|
||||
self.debug_port_spin.setRange(1, 65535)
|
||||
self.debug_port_spin.setValue(int(default_port))
|
||||
self.password_edit = QLineEdit()
|
||||
self.password_edit.setEchoMode(QLineEdit.Password)
|
||||
self.note_edit = QPlainTextEdit()
|
||||
self.note_edit.setMaximumHeight(76)
|
||||
self.slug_edit = QLineEdit()
|
||||
self.slug_edit.setReadOnly(True)
|
||||
self.user_data_dir_edit = QLineEdit()
|
||||
self.user_data_dir_edit.setReadOnly(True)
|
||||
|
||||
if account is not None:
|
||||
self.account_name_edit.setText(account.account_name)
|
||||
self.alias_edit.setText(account.alias)
|
||||
self.region_host_edit.setText(account.region_host)
|
||||
self.debug_port_spin.setValue(int(account.debug_port))
|
||||
self.password_edit.setText(account.password or "")
|
||||
self.note_edit.setPlainText(account.note or "")
|
||||
self.slug_edit.setText(account.slug)
|
||||
self.user_data_dir_edit.setText(account.user_data_dir)
|
||||
|
||||
form = QFormLayout()
|
||||
form.addRow("账号名", self.account_name_edit)
|
||||
form.addRow("别名", self.alias_edit)
|
||||
form.addRow("地区", self.region_host_edit)
|
||||
form.addRow("调试端口", self.debug_port_spin)
|
||||
form.addRow("密码", self.password_edit)
|
||||
form.addRow("备注", self.note_edit)
|
||||
form.addRow("slug", self.slug_edit)
|
||||
form.addRow("数据目录", self.user_data_dir_edit)
|
||||
|
||||
buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
|
||||
buttons.accepted.connect(self.accept)
|
||||
buttons.rejected.connect(self.reject)
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.addLayout(form)
|
||||
layout.addWidget(buttons)
|
||||
|
||||
self.alias_edit.textChanged.connect(self._update_path_preview)
|
||||
self._update_path_preview()
|
||||
|
||||
def _update_path_preview(self):
|
||||
alias = self.alias_edit.text().strip()
|
||||
if not alias:
|
||||
self.slug_edit.clear()
|
||||
self.user_data_dir_edit.clear()
|
||||
return
|
||||
try:
|
||||
slug = account_config.make_slug(alias)
|
||||
self.slug_edit.setText(slug)
|
||||
if self._account is not None and alias == self._account.alias:
|
||||
self.user_data_dir_edit.setText(self._account.user_data_dir)
|
||||
else:
|
||||
self.user_data_dir_edit.setText(
|
||||
accounts.preview_user_data_dir(alias, config=self._config)
|
||||
)
|
||||
except Exception:
|
||||
self.slug_edit.clear()
|
||||
self.user_data_dir_edit.clear()
|
||||
|
||||
def values(self):
|
||||
return {
|
||||
"account_name": self.account_name_edit.text().strip(),
|
||||
"alias": self.alias_edit.text().strip(),
|
||||
"region_host": self.region_host_edit.text().strip(),
|
||||
"debug_port": self.debug_port_spin.value(),
|
||||
"password": self.password_edit.text(),
|
||||
"note": self.note_edit.toPlainText().strip(),
|
||||
}
|
||||
|
||||
|
||||
from .workers import BaseWorker, run_worker
|
||||
|
||||
|
||||
class AccountLoginCheckWorker(BaseWorker):
|
||||
def __init__(self, account, db_path=None, config=None, timeout=8):
|
||||
super().__init__()
|
||||
self.account = account
|
||||
self.db_path = db_path
|
||||
self.config = config
|
||||
self.timeout = timeout
|
||||
|
||||
def execute(self):
|
||||
status = accounts.detect_login(
|
||||
self.account,
|
||||
timeout=self.timeout,
|
||||
path=self.db_path,
|
||||
config=self.config,
|
||||
)
|
||||
self.row_updated.emit(self.account.id, status)
|
||||
return {"alias": self.account.alias, "status": status}
|
||||
|
||||
|
||||
class AccountsTab(QWidget):
|
||||
COLUMNS = ["账号名", "别名", "地区", "端口", "登录状态", "备注"]
|
||||
|
||||
def __init__(self, parent=None, db_path=None, config=None, status_callback=None):
|
||||
super().__init__(parent)
|
||||
self.db_path = db_path
|
||||
self.config = appconfig.load_config() if config is None else config
|
||||
self.status_callback = status_callback
|
||||
self.account_rows = []
|
||||
self.login_statuses = {}
|
||||
self.threads = []
|
||||
|
||||
self.table = QTableWidget(0, len(self.COLUMNS))
|
||||
self.table.setHorizontalHeaderLabels(self.COLUMNS)
|
||||
self.table.setSelectionBehavior(QAbstractItemView.SelectRows)
|
||||
self.table.setSelectionMode(QAbstractItemView.SingleSelection)
|
||||
self.table.setEditTriggers(QAbstractItemView.NoEditTriggers)
|
||||
self.table.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch)
|
||||
self.table.verticalHeader().setVisible(False)
|
||||
|
||||
self.add_button = QPushButton("新增")
|
||||
self.edit_button = QPushButton("编辑")
|
||||
self.delete_button = QPushButton("删除")
|
||||
self.launch_button = QPushButton("启动登录")
|
||||
self.check_button = QPushButton("检测登录")
|
||||
|
||||
toolbar = QHBoxLayout()
|
||||
for button in (
|
||||
self.add_button,
|
||||
self.edit_button,
|
||||
self.delete_button,
|
||||
self.launch_button,
|
||||
self.check_button,
|
||||
):
|
||||
toolbar.addWidget(button)
|
||||
toolbar.addStretch(1)
|
||||
|
||||
self.empty_label = QLabel("")
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(18, 18, 18, 18)
|
||||
layout.addLayout(toolbar)
|
||||
layout.addWidget(self.table, 1)
|
||||
layout.addWidget(self.empty_label)
|
||||
|
||||
self.add_button.clicked.connect(self.add_account)
|
||||
self.edit_button.clicked.connect(self.edit_account)
|
||||
self.delete_button.clicked.connect(self.delete_account)
|
||||
self.launch_button.clicked.connect(self.launch_login)
|
||||
self.check_button.clicked.connect(self.check_login)
|
||||
self.table.itemSelectionChanged.connect(self._update_button_state)
|
||||
self.table.doubleClicked.connect(self.edit_account)
|
||||
|
||||
self.refresh_accounts()
|
||||
|
||||
def _set_status(self, message):
|
||||
if self.status_callback is not None:
|
||||
self.status_callback(message)
|
||||
|
||||
def _selected_account(self):
|
||||
selected = self.table.selectionModel().selectedRows()
|
||||
if not selected:
|
||||
return None
|
||||
row = selected[0].row()
|
||||
if row < 0 or row >= len(self.account_rows):
|
||||
return None
|
||||
return self.account_rows[row]
|
||||
|
||||
def _update_button_state(self):
|
||||
has_selection = self._selected_account() is not None
|
||||
for button in (
|
||||
self.edit_button,
|
||||
self.delete_button,
|
||||
self.launch_button,
|
||||
self.check_button,
|
||||
):
|
||||
button.setEnabled(has_selection)
|
||||
|
||||
def refresh_accounts(self):
|
||||
try:
|
||||
self.account_rows = accounts.list_accounts(
|
||||
path=self.db_path,
|
||||
config=self.config,
|
||||
)
|
||||
except Exception as exc:
|
||||
self.account_rows = []
|
||||
self._set_status(f"账号读取失败:{exc}")
|
||||
|
||||
self.table.setRowCount(len(self.account_rows))
|
||||
for row, account in enumerate(self.account_rows):
|
||||
status = self.login_statuses.get(account.alias, "未知")
|
||||
values = [
|
||||
account.account_name,
|
||||
account.alias,
|
||||
account.region_host,
|
||||
str(account.debug_port),
|
||||
status,
|
||||
account.note or "",
|
||||
]
|
||||
for column, value in enumerate(values):
|
||||
item = QTableWidgetItem(value)
|
||||
self.table.setItem(row, column, item)
|
||||
self.empty_label.setText("" if self.account_rows else "暂无账号")
|
||||
self._update_button_state()
|
||||
|
||||
def _show_error(self, message):
|
||||
QMessageBox.warning(self, "账号管理", str(message))
|
||||
self._set_status(str(message))
|
||||
|
||||
def add_account(self, checked=False):
|
||||
try:
|
||||
default_port = accounts.next_debug_port(
|
||||
path=self.db_path,
|
||||
config=self.config,
|
||||
)
|
||||
except Exception:
|
||||
default_port = appconfig.default_debug_port(self.config)
|
||||
dialog = AccountDialog(self, default_port=default_port, config=self.config)
|
||||
if dialog.exec() != QDialog.Accepted:
|
||||
return
|
||||
try:
|
||||
accounts.create_account(
|
||||
path=self.db_path,
|
||||
config=self.config,
|
||||
**dialog.values(),
|
||||
)
|
||||
except Exception as exc:
|
||||
self._show_error(exc)
|
||||
return
|
||||
self.refresh_accounts()
|
||||
self._set_status("账号已新增")
|
||||
|
||||
def edit_account(self, checked=False):
|
||||
account = self._selected_account()
|
||||
if account is None:
|
||||
return
|
||||
dialog = AccountDialog(
|
||||
self,
|
||||
account=account,
|
||||
default_port=account.debug_port,
|
||||
config=self.config,
|
||||
)
|
||||
if dialog.exec() != QDialog.Accepted:
|
||||
return
|
||||
try:
|
||||
updated = accounts.update_account(
|
||||
account.alias,
|
||||
path=self.db_path,
|
||||
config=self.config,
|
||||
**dialog.values(),
|
||||
)
|
||||
except Exception as exc:
|
||||
self._show_error(exc)
|
||||
return
|
||||
if updated.alias != account.alias:
|
||||
self.login_statuses.pop(account.alias, None)
|
||||
self.refresh_accounts()
|
||||
self._set_status("账号已保存")
|
||||
|
||||
def delete_account(self, checked=False):
|
||||
account = self._selected_account()
|
||||
if account is None:
|
||||
return
|
||||
answer = QMessageBox.question(
|
||||
self,
|
||||
"删除账号",
|
||||
f"确认删除账号「{account.alias}」?",
|
||||
QMessageBox.Yes | QMessageBox.No,
|
||||
QMessageBox.No,
|
||||
)
|
||||
if answer != QMessageBox.Yes:
|
||||
return
|
||||
try:
|
||||
accounts.delete_account(account.alias, path=self.db_path, config=self.config)
|
||||
except Exception as exc:
|
||||
self._show_error(exc)
|
||||
return
|
||||
self.login_statuses.pop(account.alias, None)
|
||||
self.refresh_accounts()
|
||||
self._set_status("账号已删除")
|
||||
|
||||
def launch_login(self, checked=False):
|
||||
account = self._selected_account()
|
||||
if account is None:
|
||||
return
|
||||
try:
|
||||
accounts.launch_for_login(account, config=self.config)
|
||||
except Exception as exc:
|
||||
self._show_error(exc)
|
||||
return
|
||||
self.login_statuses[account.alias] = "已启动"
|
||||
self.refresh_accounts()
|
||||
self._set_status("Chrome 已启动,请人工登录")
|
||||
|
||||
def check_login(self, checked=False):
|
||||
account = self._selected_account()
|
||||
if account is None:
|
||||
return
|
||||
self.login_statuses[account.alias] = "检测中"
|
||||
self.refresh_accounts()
|
||||
worker = AccountLoginCheckWorker(
|
||||
account,
|
||||
db_path=self.db_path,
|
||||
config=self.config,
|
||||
)
|
||||
worker.finished.connect(self._on_login_check_finished)
|
||||
worker.failed.connect(
|
||||
lambda _task_id, error, alias=account.alias: self._on_login_check_failed(
|
||||
alias,
|
||||
error,
|
||||
)
|
||||
)
|
||||
thread = run_worker(worker, start=False)
|
||||
thread.finished.connect(lambda: self._forget_thread(thread))
|
||||
self.threads.append(thread)
|
||||
thread.start()
|
||||
|
||||
def _forget_thread(self, thread):
|
||||
if thread in self.threads:
|
||||
self.threads.remove(thread)
|
||||
|
||||
def _on_login_check_finished(self, payload):
|
||||
if payload.get("ok") is False and not payload.get("alias"):
|
||||
return
|
||||
alias = payload.get("alias")
|
||||
status = payload.get("status") or {}
|
||||
if alias:
|
||||
self.login_statuses[alias] = accounts.login_status_text(status)
|
||||
self.refresh_accounts()
|
||||
self._set_status("登录状态已刷新")
|
||||
|
||||
def _on_login_check_failed(self, alias, error):
|
||||
self.login_statuses[alias] = "检测失败"
|
||||
self.refresh_accounts()
|
||||
self._set_status(f"登录检测失败:{error}")
|
||||
|
||||
|
||||
class MainWindow(QMainWindow):
|
||||
"""Main application window with the fixed five-tab workflow."""
|
||||
|
||||
def __init__(self):
|
||||
def __init__(self, db_path=None, config=None):
|
||||
super().__init__()
|
||||
self.db_path = db_path
|
||||
self.config = appconfig.load_config() if config is None else config
|
||||
self.setWindowTitle("cmshopee")
|
||||
self.resize(1180, 760)
|
||||
self.tabs = QTabWidget()
|
||||
self.tabs.setObjectName("mainTabs")
|
||||
self.tabs.setStyleSheet(TAB_STYLE)
|
||||
self.tabs.currentChanged.connect(self._on_tab_changed)
|
||||
for title in TAB_TITLES:
|
||||
self.tabs.addTab(self._build_placeholder_tab(title), title)
|
||||
self.tabs.addTab(self._build_tab(title), title)
|
||||
self.setCentralWidget(self.tabs)
|
||||
self.statusBar().showMessage("就绪")
|
||||
|
||||
def _build_placeholder_tab(self, title):
|
||||
def _build_tab(self, title):
|
||||
if title == "④ 账号管理":
|
||||
return AccountsTab(
|
||||
db_path=self.db_path,
|
||||
config=self.config,
|
||||
status_callback=self.statusBar().showMessage,
|
||||
)
|
||||
widget = QWidget()
|
||||
widget.setObjectName(title)
|
||||
layout = QVBoxLayout(widget)
|
||||
|
||||
@@ -20,6 +20,7 @@ GUI(PySide6 QTabWidget,5 Tab)
|
||||
├── db SQLite 读写:账号、任务、各阶段结果(cmshopee.db)
|
||||
├── excel openpyxl 导入输入列 / 回写输出列到原 Excel
|
||||
├── config 账号 ↔ user-data-dir 绑定、slug、目录创建
|
||||
├── accounts 账号 CRUD 服务:生成 slug/目录、启动登录、检测登录
|
||||
├── chrome 按账号拼启动参数、启动/探测 Chrome、生成快捷方式
|
||||
├── cdp CDP 客户端(连接、找/开 tab、执行 JS、拖拽)
|
||||
├── editor 登录检测 / 采集旧标题旧封面 / 改标题 / 换封面 / 点更新
|
||||
@@ -35,7 +36,7 @@ Shopee 卖家中心页面 / 本地图片目录
|
||||
真实组件:
|
||||
|
||||
- GUI 入口:根目录 `main.py` 调用 `app/gui.py`(待建,PySide6 + `QMainWindow` + `QTabWidget`,5 Tab);也支持 `python -m app`。
|
||||
- 核心模块统一放在正式代码包 `app/`:`appconfig.py`、`db.py`、`excel.py`、`config.py`、`chrome.py`、`editor.py`、`workers.py`、`ai.py`、`prompts.py`;CDP 底座迁入 `app/cdp.py`(当前根目录 `cdp.py` 为已验证来源)。
|
||||
- 核心模块统一放在正式代码包 `app/`:`appconfig.py`、`db.py`、`excel.py`、`config.py`、`accounts.py`、`chrome.py`、`editor.py`、`workers.py`、`ai.py`、`prompts.py`;CDP 底座迁入 `app/cdp.py`(当前根目录 `cdp.py` 为已验证来源)。
|
||||
- 已验证脚本(重构进模块):`prototypes/demo.py`、`prototypes/set_title.py`、`prototypes/set_cover.py`、`prototypes/get_title.py`、`prototypes/cookies.py`、`prototypes/inspect_images.py`、`prototypes/grab.py`。
|
||||
- 外部依赖:本机 Google Chrome;Shopee;AI 服务(文本+图像,服务商待定);`openpyxl`。
|
||||
|
||||
@@ -66,6 +67,7 @@ imported → collected → generated → applied
|
||||
- `db`:SQLite 读写账号、任务、各阶段结果;建表/迁移。
|
||||
- `excel`:openpyxl 读输入列、把输出列回写原 Excel(处理文件锁)。
|
||||
- `config`:账号 ↔ user-data-dir 绑定;slug;目录创建。
|
||||
- `accounts`:账号 CRUD 服务;生成目录、启动登录、检测登录;不自动登录/填密码。
|
||||
- `chrome`:拼接启动命令、启动、探测端口、(可选)生成快捷方式。
|
||||
- `cdp`:连接调试端口、找/开 tab、执行 JS、拖拽、注入文件。
|
||||
- `editor`:登录检测、**采集**(读旧标题、下载旧封面)、改标题、换封面、点更新。
|
||||
@@ -354,7 +356,7 @@ cmshopee/
|
||||
│ ├── __init__.py
|
||||
│ ├── __main__.py # 支持 python -m app
|
||||
│ ├── cdp.py # CDP 底座(由根目录 cdp.py 迁入)
|
||||
│ ├── appconfig.py / db.py / excel.py / config.py / chrome.py
|
||||
│ ├── appconfig.py / db.py / excel.py / config.py / accounts.py / chrome.py
|
||||
│ ├── editor.py / ai.py / prompts.py / gui.py / workers.py
|
||||
├── main.py # GUI 启动入口:from app.gui import main
|
||||
├── config.json # 应用配置(模型选择/生成参数/路径,gitignore)
|
||||
|
||||
+1
-1
@@ -40,7 +40,7 @@
|
||||
| T-103 | 首次登录保活 + 登录检测 `is_logged_in` | T-102, T-001 | 关闭再启动免重登;登录/未登录判断准确 | DONE |
|
||||
| T-104 | PySide6 五 Tab 主窗口骨架(`QMainWindow` + `QTabWidget`,5 Tab 空壳) | T-002 | 五个 Tab 按顺序可切换;启动不阻塞;基础状态栏可用 | DONE |
|
||||
| T-104b | PySide6 worker 基类与线程启动工具(`BaseWorker` + `QThread` 包装) | T-104 | signals: progress/log/row_updated/failed/finished/cancelled;取消标记可用;worker 不直接操作 QWidget | DONE |
|
||||
| T-105 | Tab④ 账号增删改(账号名/别名/端口/密码本地明文仅参考)+ 启动登录 + 检测登录 | T-104, T-101, T-103 | 增删改入库、建目录;密码字段 UI 打码;状态列刷新;不自动登录/填密码 | TODO |
|
||||
| T-105 | Tab④ 账号增删改(账号名/别名/端口/密码本地明文仅参考)+ 启动登录 + 检测登录 + 顶部 Tab 栏防误点样式 | T-104, T-101, T-103 | 增删改入库、建目录;密码字段 UI 打码;状态列刷新;不自动登录/填密码;5 个 Tab 有足够宽度/间距与当前态高亮,不易误点 | DONE |
|
||||
| T-106 | 可选:为账号生成桌面快捷方式 | T-102 | `.lnk` 目标含该账号参数;双击进对应账号 | TODO |
|
||||
|
||||
## Phase 2 · 导入采集(①)
|
||||
|
||||
+44
-2
@@ -121,6 +121,37 @@ ensure_user_data_dir(slug, root=None, config=None) -> str
|
||||
|
||||
`make_slug()` 使用可读 ASCII 前缀 + 8 位 SHA1 后缀,保证稳定且降低别名冲突;非 ASCII 别名使用 `account_<hash>`。`ensure_user_data_dir()` 拒绝非 `[a-z0-9_]` slug,防止路径穿越。
|
||||
|
||||
## accounts 模块(`app/accounts.py`,已建)
|
||||
|
||||
账号管理服务层,供 ④ Tab 调用;不操作 QWidget,不自动登录/填密码。
|
||||
|
||||
```python
|
||||
class AccountError(RuntimeError): ...
|
||||
normalize_region_host(value=None) -> str
|
||||
normalize_debug_port(value) -> int
|
||||
preview_user_data_dir(alias, config=None) -> str
|
||||
mask_password(password) -> str # 展示用,永不返回明文
|
||||
next_debug_port(config=None, path=None) -> int
|
||||
list_accounts(path=None, config=None) -> list[db.Account]
|
||||
get_account(alias, path=None, config=None) -> db.Account
|
||||
create_account(account_name, alias, region_host=None, debug_port=None,
|
||||
password=None, note=None, path=None, config=None) -> db.Account
|
||||
update_account(original_alias, account_name, alias, region_host=None, debug_port=None,
|
||||
password=None, note=None, path=None, config=None) -> db.Account
|
||||
delete_account(alias, path=None, config=None) -> None
|
||||
launch_for_login(account_or_alias, path=None, config=None) -> subprocess.Popen
|
||||
detect_login(account_or_alias, timeout=8, path=None, config=None) -> dict
|
||||
login_status_text(status) -> str
|
||||
```
|
||||
|
||||
规则:
|
||||
|
||||
- `create_account()` / `update_account()` 负责生成 slug、创建 `chrome_user_data_dir/<slug>` 并写 DB;编辑别名会生成新 slug/目录,但不删除旧 user-data-dir。
|
||||
- `debug_port` 在账号服务层按账号唯一校验;默认端口取 `debug_port_range` 中第一个未占用端口。
|
||||
- `delete_account()` 只删除 DB 账号记录,不删除本地 user-data-dir,避免误删登录态。
|
||||
- `launch_for_login()` 只启动带该账号 user-data-dir 和 CDP 端口的 Chrome;不会读取、填写或提交密码。
|
||||
- `detect_login()` 复用 `editor.login_status()`;检测为已登录时更新 `last_login_at`。
|
||||
|
||||
## chrome 模块(`app/chrome.py`,已建)
|
||||
|
||||
```python
|
||||
@@ -219,11 +250,22 @@ render_prompt(template_text, task) -> str
|
||||
```python
|
||||
# GUI 入口
|
||||
main() -> int # 创建 QApplication + MainWindow
|
||||
class MainWindow(QMainWindow) # QTabWidget: ①②③④⑤
|
||||
class MainWindow(QMainWindow) # QTabWidget: ①②③④⑤;支持注入 db_path/config 便于测试
|
||||
class AccountsTab(QWidget) # ④ 账号管理:表格 + 增删改 + 启动登录 + 检测登录
|
||||
class AccountDialog(QDialog) # 账号编辑弹窗;密码 QLineEdit.Password
|
||||
TAB_TITLES: list[str] # 固定 Tab 顺序
|
||||
TAB_STYLE: str # 顶层 Tab 栏防误点样式:最小宽度/padding/间距/当前态
|
||||
```
|
||||
|
||||
`MainWindow` 目前只实现五 Tab 空壳与基础状态栏;业务控件按后续 Tab 任务逐步填充。缺 PySide6 时 `main()` 返回 1 并输出明确提示。
|
||||
`MainWindow` 已实现五 Tab 与 ④ 账号管理;①②③⑤ 仍为空壳,业务控件按后续 Tab 任务逐步填充。缺 PySide6 时 `main()` 返回 1 并输出明确提示。
|
||||
|
||||
主 Tab 栏必须在 `MainWindow` 初始化时应用 `TAB_STYLE`:5 个 Tab 不使用 Qt 默认紧凑宽度,需保证点击区域稳定、间距清晰、当前 Tab 高亮明显。该样式属于全局导航基础,不归后续业务 Tab 任务重复实现。
|
||||
|
||||
④ 账号管理要点:
|
||||
|
||||
- 表格列:账号名、别名、地区、端口、登录状态、备注;不展示密码。
|
||||
- 弹窗字段:账号名、别名、地区、调试端口、密码、备注、slug、数据目录;密码框使用打码显示。
|
||||
- 「启动登录」只启动 Chrome,人工登录;「检测登录」用 worker 跑 `accounts.detect_login()` 并刷新状态列。
|
||||
|
||||
## workers 模块(`app/workers.py`,已建,PySide6)
|
||||
|
||||
|
||||
@@ -6,10 +6,10 @@
|
||||
## 当前快照
|
||||
|
||||
- 日期:2026-06-27
|
||||
- 阶段:V0 单账号 CDP 流程已验证;V1 已完成 T-000 正式代码包结构、T-001 `app/editor.py` 模块化、T-002 `app/appconfig.py` 应用配置、T-003 SQLite 持久化地基、T-004 本地数据忽略规则、T-005 AI 模型清单后端、T-006 单元测试基座、T-101 账号 user-data-dir 工具、T-102 Chrome 启动器、T-103 登录保活与检测、T-104 PySide6 主窗口骨架、T-104b PySide6 worker 基座。
|
||||
- 阶段:V0 单账号 CDP 流程已验证;V1 已完成 T-000 正式代码包结构、T-001 `app/editor.py` 模块化、T-002 `app/appconfig.py` 应用配置、T-003 SQLite 持久化地基、T-004 本地数据忽略规则、T-005 AI 模型清单后端、T-006 单元测试基座、T-101 账号 user-data-dir 工具、T-102 Chrome 启动器、T-103 登录保活与检测、T-104 PySide6 主窗口骨架、T-104b PySide6 worker 基座、T-105 Tab④ 账号管理。
|
||||
- 技术栈:Python 3.10+,自研 CDP(websocket-client + requests),SQLite(sqlite3)+ `config.json` + openpyxl + AI(服务商待定),GUI PySide6 5 Tab(已定)。
|
||||
- 生产代码:已建立 `app/` 包 + 根入口 `main.py`;`app/cdp.py` 为已验证 CDP 底座;`app/editor.py` 已封装登录状态检测、标题/封面/采集/更新按钮能力;`app/appconfig.py` 已实现 `config.json` 默认值/读写/更新、AI 参数与端口读取,以及 `config/ai_models.json` 模型清单 CRUD/过滤/打码/测试连接;`app/db.py` 已实现 SQLite schema、连接 PRAGMA、批次/账号/任务与阶段写库函数;`app/config.py` 已实现账号 slug 与 user-data-dir 创建;`app/chrome.py` 已实现 Chrome 参数拼装、启动与 CDP 端口探测;`app/gui.py` 已实现 PySide6 `MainWindow` + 五 Tab 空壳 + 状态栏;`app/workers.py` 已实现 `BaseWorker`、通用 signals、取消标记和 `QThread` 启动包装。
|
||||
- 测试:`tests/` 已建立;T-006 后纯逻辑改动必须运行 `python -m unittest discover -s tests`,当前覆盖 appconfig/db/config/chrome/editor 登录检测/gui 骨架/worker signal 与线程包装,并对尚未实现的 app.excel/app.prompts 做契约占位 skip;CDP/Shopee 改动仍需测试商品手动验证。
|
||||
- 生产代码:已建立 `app/` 包 + 根入口 `main.py`;`app/cdp.py` 为已验证 CDP 底座;`app/editor.py` 已封装登录状态检测、标题/封面/采集/更新按钮能力;`app/appconfig.py` 已实现 `config.json` 默认值/读写/更新、AI 参数与端口读取,以及 `config/ai_models.json` 模型清单 CRUD/过滤/打码/测试连接;`app/db.py` 已实现 SQLite schema、连接 PRAGMA、批次/账号/任务与阶段写库函数;`app/config.py` 已实现账号 slug 与 user-data-dir 创建;`app/accounts.py` 已实现账号 CRUD 服务、端口默认分配、启动登录、检测登录;`app/chrome.py` 已实现 Chrome 参数拼装、启动与 CDP 端口探测;`app/gui.py` 已实现 PySide6 `MainWindow`、五 Tab、顶部 Tab 栏防误点样式、④ 账号管理表格/弹窗/按钮与状态栏;`app/workers.py` 已实现 `BaseWorker`、通用 signals、取消标记和 `QThread` 启动包装。
|
||||
- 测试:`tests/` 已建立;T-006 后纯逻辑改动必须运行 `python -m unittest discover -s tests`,当前覆盖 appconfig/db/config/accounts/chrome/editor 登录检测/gui 账号管理/worker signal 与线程包装,并对尚未实现的 app.excel/app.prompts 做契约占位 skip;CDP/Shopee 改动仍需测试商品手动验证。
|
||||
- 数据:`config.json`、`config/ai_models.json`、`cmshopee.db`、`chrome_user_data_dir/`、`images/` 已由 `.gitignore` 排除;`app/appconfig.py` 首次读取缺失的 `config.json` 时会在本地写默认配置,`app/db.py` 调用 `init_db()` 时会在本地创建 SQLite DB。
|
||||
|
||||
## 既定设计要点(文档已定)
|
||||
@@ -32,14 +32,15 @@
|
||||
| `prototypes/` | 已有 | 已验证原型/探查脚本(demo/set_title/set_cover/get_title/cookies/inspect_images/grab/1.py),保留作人工回归与探查参考;见 `prototypes/README.md` |
|
||||
| `chrome-remote-debug-lan.md` | 已有 | WSL→Windows CDP 转发排查记录 |
|
||||
| `app/__init__.py` / `app/__main__.py` / `main.py` | 已有 | 正式包与启动入口;`python main.py` / `python -m app` 可运行占位入口 |
|
||||
| `app/gui.py` | 已有 | T-104 产出:PySide6 `QMainWindow` + `QTabWidget` 五 Tab 空壳 + 状态栏 |
|
||||
| `app/gui.py` | 已有 | T-104/T-105 产出:PySide6 `QMainWindow` + 五 Tab;顶部 Tab 栏防误点样式;④ 账号管理表格、账号弹窗、启动登录、检测登录 |
|
||||
| `app/workers.py` | 已有 | T-104b 产出:`BaseWorker` + 通用 signals + 取消标记 + `run_worker()` QThread 包装 |
|
||||
| `app/accounts.py` | 已有 | T-105 产出:账号 CRUD 服务、目录创建、端口分配、启动登录、检测登录 |
|
||||
| `app/editor.py` | 已有 | T-001/T-103 产出:登录状态检测、打开商品页、读/写标题、读/下载封面、上传拖封面、更新按钮、apply_task |
|
||||
| `app/appconfig.py` | 已有 | T-002 产出:`config.json` 默认值、读写、更新、路径/端口/AI 参数读取;拒绝敏感字段写入 |
|
||||
| `app/db.py` | 已有 | T-003 产出:batches/accounts/tasks schema;WAL/busy_timeout/foreign_keys;账号/批次/任务与 set_* 阶段写库 |
|
||||
| `app/config.py` | 已有 | T-101 产出:别名→稳定 slug;创建并返回绝对 user-data-dir |
|
||||
| `app/chrome.py` | 已有 | T-102 产出:Chrome 启动参数、`subprocess.Popen` 启动、`/json/version` 端口探测;快捷方式待 T-106 |
|
||||
| `tests/` | 已有 | T-006 产出:stdlib unittest 基座;覆盖 appconfig/db/config/chrome/editor/gui/workers;excel/prompts 模块契约占位测试 |
|
||||
| `tests/` | 已有 | T-006 产出:stdlib unittest 基座;覆盖 appconfig/db/config/accounts/chrome/editor/gui/workers;excel/prompts 模块契约占位测试 |
|
||||
| `app/excel.py` | 待建 | Phase 2 产出 |
|
||||
| `config.json` / `config/ai_models.json` / `cmshopee.db` / `chrome_user_data_dir/` / `images/` | 本地待建,已忽略 | 含配置、密钥、业务、登录态、图片,不提交版本库 |
|
||||
|
||||
@@ -55,9 +56,9 @@
|
||||
|
||||
任务状态以 [`06-tasks.md`](06-tasks.md) 为准,历史记录见 [`../progress.md`](../progress.md)。
|
||||
|
||||
- 已完成:T-000(正式代码包结构)、T-001(`app/editor.py` 模块化)、T-002(`app/appconfig.py` + `config.json`)、T-003(`app/db.py` + SQLite 建表)、T-004(本地数据 gitignore)、T-005(AI 模型清单后端)、T-006(单元测试基座)、T-101(账号 slug/user-data-dir)、T-102(Chrome 启动器)、T-103(登录保活与检测)、T-104(PySide6 五 Tab 主窗口骨架)、T-104b(PySide6 worker 基座)。
|
||||
- 已完成:T-000(正式代码包结构)、T-001(`app/editor.py` 模块化)、T-002(`app/appconfig.py` + `config.json`)、T-003(`app/db.py` + SQLite 建表)、T-004(本地数据 gitignore)、T-005(AI 模型清单后端)、T-006(单元测试基座)、T-101(账号 slug/user-data-dir)、T-102(Chrome 启动器)、T-103(登录保活与检测)、T-104(PySide6 五 Tab 主窗口骨架)、T-104b(PySide6 worker 基座)、T-105(Tab④ 账号管理)。
|
||||
- 正在进行:无。
|
||||
- 下一个可领取任务:**T-105(Tab④ 账号增删改 + 启动登录 + 检测登录)**。
|
||||
- 下一个可领取任务:**T-106(可选:为账号生成桌面快捷方式)**。
|
||||
|
||||
## 当前可运行内容
|
||||
|
||||
@@ -83,7 +84,7 @@ py -3 -c "import tempfile; from app import config; d=tempfile.TemporaryDirectory
|
||||
# chrome 参数拼装 / 端口探测由 tests/test_chrome.py 覆盖
|
||||
python -m unittest discover -s tests
|
||||
|
||||
# GUI 骨架由 tests/test_gui.py 覆盖;worker 基座由 tests/test_workers.py 覆盖
|
||||
# GUI 骨架与 ④ 账号管理由 tests/test_gui.py 覆盖;worker 基座由 tests/test_workers.py 覆盖
|
||||
# 默认 python 当前 PySide6=6.5.3,py -3 环境缺 PySide6 时相关测试会 skip
|
||||
|
||||
# 当前入口占位
|
||||
|
||||
@@ -18,6 +18,15 @@
|
||||
|
||||
任务的**阶段状态**贯穿各 Tab:`imported → collected → generated → applied`(或 `failed/skipped`)。② 不设逐条人工确认阶段;③ 无常驻提交开关,但点击「开始更新」后必须弹窗确认当前筛选范围和任务数量。各 Tab 聚焦各自阶段的列与按钮,但操作同一批任务(同一 batch)。
|
||||
|
||||
## 全局 Tab 栏可用性
|
||||
|
||||
5 个主 Tab 是高频导航入口,不能使用 Qt 默认的紧凑宽度。`MainWindow` 必须为 `QTabWidget/QTabBar` 设置基础样式:
|
||||
|
||||
- 每个 Tab 设置稳定最小宽度和足够左右 padding,避免文字贴边或窄到误点。
|
||||
- Tab 之间保留明显间距,当前 Tab 有清晰背景/边框高亮。
|
||||
- 样式只影响顶层主 Tab,不改变各业务表格、弹窗和后续 Tab 内部布局。
|
||||
- 新增业务 Tab 内容时不得缩小主 Tab 栏点击区域。
|
||||
|
||||
## 首次使用引导保护
|
||||
|
||||
- ① 导入采集 与 ③ 更新shopee 都依赖**账号已配置且已登录**(在 ④ 账号管理)。
|
||||
|
||||
@@ -343,3 +343,12 @@
|
||||
- 细节:`workers.py` 不导入 `QtWidgets`,业务 worker 子类只能通过 signal 回传 UI 所需数据;`run_worker()` 默认启动线程,也支持 `start=False` 便于测试或调用方先连接额外信号;缺 PySide6 时抛明确 `WorkerError`,测试自动 skip。
|
||||
- 验证:`python -m compileall app main.py tests` 通过;`python -m unittest discover -s tests` 通过(28 tests,skipped=2);`py -3 -m compileall app main.py tests` 通过;`py -3 -m unittest discover -s tests` 通过(24 tests,skipped=4,其中 GUI/worker 因 py -3 缺 PySide6 跳过)。
|
||||
- 下一步:按任务看板领取 T-105。
|
||||
|
||||
## 【2026-06-27】T-105 Tab④ 账号管理
|
||||
|
||||
- 状态:DONE
|
||||
- 变更:新增 `app/accounts.py` 账号管理服务层,封装账号新增/编辑/删除、slug 与 user-data-dir 创建、端口默认分配、启动登录、登录检测与 `last_login_at` 更新;`app/db.py` 支持账号别名更新并把唯一约束错误转为 `DbError`;`app/gui.py` 将 ④ Tab 替换为账号表格、账号编辑弹窗、启动登录与检测登录按钮,检测登录通过 `BaseWorker` 后台执行;新增/更新 `tests/test_accounts.py`、`tests/test_gui.py`;同步 `docs/06-tasks.md`、`docs/current-state.md`、`docs/api.md`、`docs/04-architecture.md`。
|
||||
- 细节:密码只在 DB 本地明文保存与弹窗密码框中打码显示,账号表格不展示密码;账号服务层校验调试端口不重复;「启动登录」只启动对应 user-data-dir 的 Chrome,人工登录,不读取/填写/提交密码;删除账号只删 DB 记录,不删除本地 user-data-dir。
|
||||
- 追加收尾:运行最新 GUI 后发现顶部 5 个 Tab 间距偏紧,容易误点;已在 `MainWindow` 应用全局 `TAB_STYLE`,包含 Tab 最小宽度、padding、间距、hover 与当前态高亮,并补测试防止后续误删。
|
||||
- 验证:`python -m compileall app main.py tests` 通过;`python -m unittest discover -s tests` 通过;`py -3 -m compileall app main.py tests` 通过;`py -3 -m unittest discover -s tests` 通过;本轮未连接真实 Shopee/CDP 做登录检测实跑,登录检测路径由 mock 覆盖。
|
||||
- 下一步:按任务看板领取 T-106(可选)。
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import os
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
from _helpers import TempDirMixin
|
||||
|
||||
from app import accounts
|
||||
from app import config as account_config
|
||||
|
||||
|
||||
class AccountsTests(TempDirMixin, unittest.TestCase):
|
||||
def make_config(self, temp_dir):
|
||||
return {
|
||||
"chrome_path": "chrome.exe",
|
||||
"user_data_root": os.path.join(temp_dir, "chrome_user_data_dir"),
|
||||
"db_path": os.path.join(temp_dir, "cmshopee.db"),
|
||||
"debug_port_range": [9222, 9224],
|
||||
}
|
||||
|
||||
def test_create_account_stores_record_and_creates_user_data_dir(self):
|
||||
with self.make_temp_dir() as temp_dir:
|
||||
cfg = self.make_config(temp_dir)
|
||||
account = accounts.create_account(
|
||||
"主店",
|
||||
"alias",
|
||||
"https://seller.shopee.tw/portal/",
|
||||
9222,
|
||||
password="secret",
|
||||
note="备注",
|
||||
config=cfg,
|
||||
)
|
||||
|
||||
self.assertEqual("主店", account.account_name)
|
||||
self.assertEqual("alias", account.alias)
|
||||
self.assertEqual("seller.shopee.tw", account.region_host)
|
||||
self.assertEqual("alias_cdb6fdbe", account.slug)
|
||||
self.assertTrue(os.path.isdir(account.user_data_dir))
|
||||
self.assertEqual("******", accounts.mask_password(account.password))
|
||||
self.assertEqual([account], accounts.list_accounts(config=cfg))
|
||||
|
||||
self.assert_removed(temp_dir)
|
||||
|
||||
def test_update_account_can_change_alias_and_rebuild_directory(self):
|
||||
with self.make_temp_dir() as temp_dir:
|
||||
cfg = self.make_config(temp_dir)
|
||||
accounts.create_account("主店", "alias", debug_port=9222, config=cfg)
|
||||
|
||||
updated = accounts.update_account(
|
||||
"alias",
|
||||
"新主店",
|
||||
"new alias",
|
||||
"seller.shopee.tw",
|
||||
9333,
|
||||
password="new-secret",
|
||||
note="新备注",
|
||||
config=cfg,
|
||||
)
|
||||
|
||||
self.assertEqual("new alias", updated.alias)
|
||||
self.assertEqual(account_config.make_slug("new alias"), updated.slug)
|
||||
self.assertEqual(9333, updated.debug_port)
|
||||
self.assertTrue(os.path.isdir(updated.user_data_dir))
|
||||
with self.assertRaises(accounts.AccountError):
|
||||
accounts.get_account("alias", config=cfg)
|
||||
self.assertEqual("new-secret", accounts.get_account("new alias", config=cfg).password)
|
||||
|
||||
self.assert_removed(temp_dir)
|
||||
|
||||
def test_next_debug_port_skips_existing_accounts(self):
|
||||
with self.make_temp_dir() as temp_dir:
|
||||
cfg = self.make_config(temp_dir)
|
||||
accounts.create_account("店1", "a1", debug_port=9222, config=cfg)
|
||||
accounts.create_account("店2", "a2", debug_port=9223, config=cfg)
|
||||
|
||||
self.assertEqual(9224, accounts.next_debug_port(config=cfg))
|
||||
|
||||
self.assert_removed(temp_dir)
|
||||
|
||||
def test_launch_for_login_only_launches_chrome(self):
|
||||
with self.make_temp_dir() as temp_dir:
|
||||
cfg = self.make_config(temp_dir)
|
||||
account = accounts.create_account("主店", "alias", debug_port=9222, config=cfg)
|
||||
process = object()
|
||||
|
||||
with mock.patch("app.accounts.chrome.launch_chrome", return_value=process) as launch:
|
||||
result = accounts.launch_for_login("alias", config=cfg)
|
||||
|
||||
self.assertIs(process, result)
|
||||
launch.assert_called_once_with(account, config=cfg)
|
||||
|
||||
self.assert_removed(temp_dir)
|
||||
|
||||
def test_detect_login_updates_last_login_at_when_logged_in(self):
|
||||
with self.make_temp_dir() as temp_dir:
|
||||
cfg = self.make_config(temp_dir)
|
||||
accounts.create_account("主店", "alias", debug_port=9222, config=cfg)
|
||||
|
||||
with mock.patch(
|
||||
"app.accounts.editor.login_status",
|
||||
return_value={"logged_in": True, "reason": None},
|
||||
) as login_status:
|
||||
status = accounts.detect_login("alias", timeout=1, config=cfg)
|
||||
|
||||
self.assertTrue(status["logged_in"])
|
||||
login_status.assert_called_once()
|
||||
self.assertIsNotNone(accounts.get_account("alias", config=cfg).last_login_at)
|
||||
|
||||
self.assert_removed(temp_dir)
|
||||
|
||||
def test_duplicate_alias_raises_clear_error(self):
|
||||
with self.make_temp_dir() as temp_dir:
|
||||
cfg = self.make_config(temp_dir)
|
||||
accounts.create_account("店1", "alias", debug_port=9222, config=cfg)
|
||||
|
||||
with self.assertRaises(accounts.AccountError):
|
||||
accounts.create_account("店2", "alias", debug_port=9223, config=cfg)
|
||||
|
||||
self.assert_removed(temp_dir)
|
||||
|
||||
def test_duplicate_debug_port_raises_clear_error(self):
|
||||
with self.make_temp_dir() as temp_dir:
|
||||
cfg = self.make_config(temp_dir)
|
||||
accounts.create_account("店1", "a1", debug_port=9222, config=cfg)
|
||||
accounts.create_account("店2", "a2", debug_port=9223, config=cfg)
|
||||
|
||||
with self.assertRaises(accounts.AccountError):
|
||||
accounts.create_account("店3", "a3", debug_port=9222, config=cfg)
|
||||
with self.assertRaises(accounts.AccountError):
|
||||
accounts.update_account(
|
||||
"a2",
|
||||
"店2",
|
||||
"a2",
|
||||
"seller.shopee.tw",
|
||||
9222,
|
||||
config=cfg,
|
||||
)
|
||||
|
||||
self.assert_removed(temp_dir)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+103
-17
@@ -5,40 +5,126 @@ import sys
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
|
||||
from _helpers import TempDirMixin
|
||||
|
||||
from app import gui
|
||||
from app import accounts
|
||||
|
||||
if gui.QT_IMPORT_ERROR is not None:
|
||||
raise unittest.SkipTest("PySide6 未安装")
|
||||
|
||||
from PySide6.QtWidgets import QApplication
|
||||
from PySide6.QtWidgets import QApplication, QLineEdit
|
||||
|
||||
from app.gui import MainWindow, TAB_TITLES
|
||||
from app.gui import AccountDialog, AccountsTab, MainWindow, TAB_STYLE, TAB_TITLES
|
||||
|
||||
|
||||
class GuiTests(unittest.TestCase):
|
||||
class GuiTests(TempDirMixin, unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.app = QApplication.instance() or QApplication([])
|
||||
|
||||
def test_main_window_has_five_tabs_in_workflow_order(self):
|
||||
window = MainWindow()
|
||||
self.addCleanup(window.close)
|
||||
def make_config(self, temp_dir):
|
||||
return {
|
||||
"chrome_path": "chrome.exe",
|
||||
"user_data_root": os.path.join(temp_dir, "chrome_user_data_dir"),
|
||||
"db_path": os.path.join(temp_dir, "cmshopee.db"),
|
||||
"debug_port_range": [9222, 9260],
|
||||
}
|
||||
|
||||
self.assertEqual(5, window.tabs.count())
|
||||
self.assertEqual(
|
||||
TAB_TITLES,
|
||||
[window.tabs.tabText(index) for index in range(window.tabs.count())],
|
||||
)
|
||||
self.assertEqual("就绪", window.statusBar().currentMessage())
|
||||
def test_main_window_has_five_tabs_in_workflow_order(self):
|
||||
with self.make_temp_dir() as temp_dir:
|
||||
window = MainWindow(config=self.make_config(temp_dir))
|
||||
self.addCleanup(window.close)
|
||||
|
||||
self.assertEqual(5, window.tabs.count())
|
||||
self.assertEqual(
|
||||
TAB_TITLES,
|
||||
[window.tabs.tabText(index) for index in range(window.tabs.count())],
|
||||
)
|
||||
self.assertEqual("就绪", window.statusBar().currentMessage())
|
||||
self.assertEqual(TAB_STYLE, window.tabs.styleSheet())
|
||||
self.assertIn("min-width: 128px", window.tabs.styleSheet())
|
||||
self.assertIn("padding: 8px 18px", window.tabs.styleSheet())
|
||||
self.assertIn("margin-right: 8px", window.tabs.styleSheet())
|
||||
|
||||
self.assert_removed(temp_dir)
|
||||
|
||||
def test_tab_switch_updates_status_bar(self):
|
||||
window = MainWindow()
|
||||
self.addCleanup(window.close)
|
||||
with self.make_temp_dir() as temp_dir:
|
||||
window = MainWindow(config=self.make_config(temp_dir))
|
||||
self.addCleanup(window.close)
|
||||
|
||||
window.tabs.setCurrentIndex(2)
|
||||
window.tabs.setCurrentIndex(2)
|
||||
|
||||
self.assertEqual("③ 更新shopee", window.tabs.tabText(window.tabs.currentIndex()))
|
||||
self.assertEqual("当前:③ 更新shopee", window.statusBar().currentMessage())
|
||||
self.assertEqual("③ 更新shopee", window.tabs.tabText(window.tabs.currentIndex()))
|
||||
self.assertEqual("当前:③ 更新shopee", window.statusBar().currentMessage())
|
||||
|
||||
self.assert_removed(temp_dir)
|
||||
|
||||
def test_accounts_tab_lists_accounts_without_showing_password(self):
|
||||
with self.make_temp_dir() as temp_dir:
|
||||
cfg = self.make_config(temp_dir)
|
||||
accounts.create_account(
|
||||
"主店",
|
||||
"alias",
|
||||
"seller.shopee.tw",
|
||||
9222,
|
||||
password="secret",
|
||||
config=cfg,
|
||||
)
|
||||
|
||||
window = MainWindow(config=cfg)
|
||||
self.addCleanup(window.close)
|
||||
tab = window.tabs.widget(TAB_TITLES.index("④ 账号管理"))
|
||||
|
||||
self.assertIsInstance(tab, AccountsTab)
|
||||
self.assertEqual(1, tab.table.rowCount())
|
||||
self.assertEqual("主店", tab.table.item(0, 0).text())
|
||||
self.assertEqual("alias", tab.table.item(0, 1).text())
|
||||
self.assertEqual("未知", tab.table.item(0, 4).text())
|
||||
visible_values = [
|
||||
tab.table.item(0, column).text()
|
||||
for column in range(tab.table.columnCount())
|
||||
if tab.table.item(0, column) is not None
|
||||
]
|
||||
self.assertNotIn("secret", visible_values)
|
||||
|
||||
self.assert_removed(temp_dir)
|
||||
|
||||
def test_account_dialog_masks_password_field(self):
|
||||
with self.make_temp_dir() as temp_dir:
|
||||
cfg = self.make_config(temp_dir)
|
||||
account = accounts.create_account(
|
||||
"主店",
|
||||
"alias",
|
||||
debug_port=9222,
|
||||
password="secret",
|
||||
config=cfg,
|
||||
)
|
||||
|
||||
dialog = AccountDialog(account=account, config=cfg)
|
||||
self.addCleanup(dialog.close)
|
||||
|
||||
self.assertEqual(QLineEdit.Password, dialog.password_edit.echoMode())
|
||||
self.assertEqual("secret", dialog.password_edit.text())
|
||||
self.assertIn(account.slug, dialog.user_data_dir_edit.text())
|
||||
|
||||
self.assert_removed(temp_dir)
|
||||
|
||||
def test_accounts_tab_updates_login_status_cell(self):
|
||||
with self.make_temp_dir() as temp_dir:
|
||||
cfg = self.make_config(temp_dir)
|
||||
accounts.create_account("主店", "alias", debug_port=9222, config=cfg)
|
||||
tab = AccountsTab(config=cfg)
|
||||
self.addCleanup(tab.close)
|
||||
|
||||
tab._on_login_check_finished(
|
||||
{"alias": "alias", "status": {"logged_in": True, "reason": None}}
|
||||
)
|
||||
|
||||
self.assertEqual("已登录", tab.table.item(0, 4).text())
|
||||
|
||||
self.assert_removed(temp_dir)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
Reference in New Issue
Block a user