diff --git a/app/accounts.py b/app/accounts.py new file mode 100644 index 0000000..4ab6d9b --- /dev/null +++ b/app/accounts.py @@ -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 "未登录" diff --git a/app/db.py b/app/db.py index 7e57250..dc45d2e 100644 --- a/app/db.py +++ b/app/db.py @@ -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: diff --git a/app/gui.py b/app/gui.py index fd590eb..baf9258 100644 --- a/app/gui.py +++ b/app/gui.py @@ -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) diff --git a/docs/04-architecture.md b/docs/04-architecture.md index 3b44d84..a384d21 100644 --- a/docs/04-architecture.md +++ b/docs/04-architecture.md @@ -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) diff --git a/docs/06-tasks.md b/docs/06-tasks.md index 64a57a2..f80b4a9 100644 --- a/docs/06-tasks.md +++ b/docs/06-tasks.md @@ -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 · 导入采集(①) diff --git a/docs/api.md b/docs/api.md index a08b768..c82219f 100644 --- a/docs/api.md +++ b/docs/api.md @@ -121,6 +121,37 @@ ensure_user_data_dir(slug, root=None, config=None) -> str `make_slug()` 使用可读 ASCII 前缀 + 8 位 SHA1 后缀,保证稳定且降低别名冲突;非 ASCII 别名使用 `account_`。`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/` 并写 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) diff --git a/docs/current-state.md b/docs/current-state.md index b52b4f8..ab8dcd1 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -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 # 当前入口占位 diff --git a/docs/routes.md b/docs/routes.md index e4f7470..bef00d0 100644 --- a/docs/routes.md +++ b/docs/routes.md @@ -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 都依赖**账号已配置且已登录**(在 ④ 账号管理)。 diff --git a/progress.md b/progress.md index 8895408..4456321 100644 --- a/progress.md +++ b/progress.md @@ -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(可选)。 diff --git a/tests/test_accounts.py b/tests/test_accounts.py new file mode 100644 index 0000000..990b821 --- /dev/null +++ b/tests/test_accounts.py @@ -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() diff --git a/tests/test_gui.py b/tests/test_gui.py index f6ffcb0..445471d 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -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__":