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)