feat: 完成Tab④账号管理

- 新增 accounts 服务层,封装账号 CRUD、目录创建、端口分配与登录检测

- 接入 PySide6 账号管理 Tab:表格、账号弹窗、启动登录、后台检测登录

- 密码仅本地保存并在 UI 打码,表格不展示明文;不自动登录或填密码

- 增加顶部 Tab 栏防误点样式,扩大点击区域并高亮当前 Tab

- 补充账号服务和 GUI 单元测试,并同步架构、API、路由、任务和进度文档
This commit is contained in:
chengma
2026-06-27 10:30:45 +08:00
parent 479ab5c41d
commit 598df5f20b
11 changed files with 970 additions and 40 deletions
+237
View File
@@ -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 "未登录"
+13 -7
View File
@@ -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
View File
@@ -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)
+4 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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)
+9 -8
View File
@@ -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
# 当前入口占位
+9
View File
@@ -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 都依赖**账号已配置且已登录**(在 ④ 账号管理)。
+9
View File
@@ -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(可选)。
+142
View File
@@ -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
View File
@@ -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__":