From 638df6993c1ec8aee0ae8e84510dc7fd30c4f829 Mon Sep 17 00:00:00 2001 From: Rogee Date: Mon, 21 Sep 2026 16:55:07 +0800 Subject: [PATCH] Fix list scrolling and row copy actions --- docs/2026-09-20-18-29-list-scroll-copy.md | 26 ++++ src/accounts_app.py | 154 +++++++++++++++++++++- src/test_onboarding_ui.py | 53 ++++++++ 3 files changed, 227 insertions(+), 6 deletions(-) create mode 100644 docs/2026-09-20-18-29-list-scroll-copy.md diff --git a/docs/2026-09-20-18-29-list-scroll-copy.md b/docs/2026-09-20-18-29-list-scroll-copy.md new file mode 100644 index 0000000..e0a3d80 --- /dev/null +++ b/docs/2026-09-20-18-29-list-scroll-copy.md @@ -0,0 +1,26 @@ +# 列表滚动与右键复制行信息 + +## 调研记录 + +- 目标文件:`src/accounts_app.py`。 +- 作品列表实际使用 `QScrollArea + QGridLayout + WorkCard`,不是可滚动的表格;`works_card_host` 在 `widgetResizable=True` 下被限制为视口高度,虽然内容的 `sizeHint()` 已经超过视口,垂直滚动范围仍为 `0`。 +- 任务、历史、异常列表使用 `QTableWidget`。这些表格依赖 Qt 默认滚动策略,未显式声明滚动条策略,后续样式或布局变化容易造成“内容超出但看不到”的问题。 +- 历史列表已有右键菜单,但仅在任务存在可重放 ID 时显示,且只能重放,不能复制。作品卡片和任务表没有统一的行复制入口。 + +## 实现方案 + +1. 作品卡片渲染完成后重新挂载滚动区域的 host,并按当前视口宽度固定 host 宽度、按网格内容高度设置 host 高度。这样保留三列卡片布局,同时恢复垂直滚动,避免额外的横向滚动条。 +2. 账号列表、作品列表、任务表、历史表显式设置 `ScrollBarAsNeeded`;作品卡片区关闭横向滚动。 +3. 所有任务表和隐藏的作品数据表增加“复制行信息”右键菜单;历史表在原有重放菜单中加入同一操作。 +4. 作品卡片捕获卡片及其子控件的右键事件,显示“复制行信息”。复制内容使用“字段: 值”格式,以 Tab 分隔字段,包含作品 ID、描述、统计、封面地址和时间等信息;表格复制内容包含表头、选择状态和单元格值。 +5. 复制成功后在状态栏提示“已复制行信息”,不改变数据库任务状态,也不触发任何真实业务动作。 + +## 验证 + +- `py_compile src/accounts_app.py src/test_onboarding_ui.py`:通过。 +- `test_lists_scroll_and_copy_rows`:通过,验证作品卡片和任务表均有垂直滚动范围,并验证剪贴板内容。 +- `test_works_preview_saves_selected_mode_and_full_business_data`:通过。 +- `test_history_preview_requires_selection_and_explicit_confirmation`:通过。 +- `git diff --check`:通过。 +- 已补充离线 UI 回归测试:`src/test_onboarding_ui.py::test_lists_scroll_and_copy_rows`。 +- 完整 onboarding 测试在当前 offscreen 环境的既有 profile 测试处超时;使用未修改的 HEAD 文件可复现,未将该环境问题归因于本次改动。 diff --git a/src/accounts_app.py b/src/accounts_app.py index 344cea4..1e2ab3a 100644 --- a/src/accounts_app.py +++ b/src/accounts_app.py @@ -72,6 +72,8 @@ def allowed_image_url(value): class WorkCard(QFrame): + context_requested = Signal(object) + def __init__(self, item, checked=False, parent=None): super().__init__(parent) self.setObjectName("workCard") @@ -150,6 +152,20 @@ class WorkCard(QFrame): self.setStyleSheet( "#workCard { border: 1px solid #d9dde5; border-radius: 6px; }" ) + for child in self.findChildren(QWidget): + child.installEventFilter(self) + + def eventFilter(self, watched, event): + if ( + event.type() == QEvent.Type.MouseButtonPress + and event.button() == Qt.MouseButton.RightButton + ): + self.context_requested.emit(event.globalPosition().toPoint()) + return True + return super().eventFilter(watched, event) + + def contextMenuEvent(self, event): + self.context_requested.emit(event.globalPos()) def toggle_description(self): self._description_expanded = not self._description_expanded @@ -524,6 +540,7 @@ class AccountCardTree(QScrollArea): super().__init__(parent) self.setWidgetResizable(True) self.setFrameShape(QFrame.Shape.NoFrame) + self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) self.host = QWidget() self.layout_box = QVBoxLayout(self.host) @@ -773,6 +790,7 @@ class Window(QMainWindow): history_select.addStretch() main_history_layout.addLayout(history_select) self.history_table = QTableWidget(0, 9) + self._configure_table_scrollbars(self.history_table) self.history_table.setHorizontalHeaderLabels( [ "选择", @@ -841,10 +859,31 @@ class Window(QMainWindow): ) ) self.works_table = QTableWidget(0, 10, main_works_tab) + self.works_table.setHorizontalHeaderLabels( + [ + "选择", + "发布时间", + "作品描述", + "作品ID", + "点赞", + "评论", + "收藏", + "分享", + "封面地址", + "更新时间", + ] + ) + self._enable_table_copy_menu(self.works_table) self._works_table_sync = None self.works_table.hide() self.works_card_area = QScrollArea() self.works_card_area.setWidgetResizable(True) + self.works_card_area.setVerticalScrollBarPolicy( + Qt.ScrollBarPolicy.ScrollBarAsNeeded + ) + self.works_card_area.setHorizontalScrollBarPolicy( + Qt.ScrollBarPolicy.ScrollBarAlwaysOff + ) self.works_card_area.setFrameShape(QFrame.Shape.NoFrame) self.works_card_host = QWidget() self.works_card_grid = QGridLayout(self.works_card_host) @@ -974,8 +1013,21 @@ class Window(QMainWindow): table.horizontalHeader().setSectionResizeMode( 8 if checkable else 7, QHeaderView.ResizeMode.Stretch ) + self._enable_table_copy_menu(table) return table + @staticmethod + def _configure_table_scrollbars(table): + table.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) + table.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) + + def _enable_table_copy_menu(self, table): + self._configure_table_scrollbars(table) + table.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) + table.customContextMenuRequested.connect( + lambda position, table=table: self.table_context_menu(table, position) + ) + def _fill_task_table(self, table, tasks, names, checkable=False): labels = { "pending": "待执行", @@ -1039,6 +1091,21 @@ class Window(QMainWindow): if widget is not None: widget.deleteLater() + def _refresh_works_card_scroll(self): + self.works_card_grid.activate() + viewport = self.works_card_area.viewport() + width = max(1, viewport.width()) + height = max(viewport.height(), self.works_card_grid.sizeHint().height()) + host = self.works_card_area.takeWidget() + if host is None: + host = self.works_card_host + host.setMinimumWidth(width) + host.setMaximumWidth(width) + host.setMinimumHeight(0) + host.setMaximumHeight(16777215) + host.resize(width, height) + self.works_card_area.setWidget(host) + def _toggle_works_selection(self, checked): self.works_table.setDisabled(checked) self.works_card_area.setDisabled(checked) @@ -1487,20 +1554,89 @@ class Window(QMainWindow): self.history_table.setItem(row, column, cell) self.filter_history() + @staticmethod + def _table_row_text(table, row): + values = [] + for column in range(table.columnCount()): + header = table.horizontalHeaderItem(column) + cell = table.item(row, column) + value = cell.text() if cell is not None else "" + if ( + cell is not None + and not value + and cell.flags() & Qt.ItemFlag.ItemIsUserCheckable + ): + value = ( + "已选择" + if cell.checkState() == Qt.CheckState.Checked + else "未选择" + ) + label = header.text() if header and header.text() else f"第{column + 1}列" + values.append(f"{label}: {value}") + return "\t".join(values) + + def _copy_text(self, text): + if not text: + return + QApplication.clipboard().setText(text) + self.statusBar().showMessage("已复制行信息", 3000) + + def copy_table_row(self, table, row): + self._copy_text(self._table_row_text(table, row)) + + def _add_copy_row_action(self, menu, table, row): + action = menu.addAction("复制行信息") + action.triggered.connect( + lambda _checked=False: self.copy_table_row(table, row) + ) + + def table_context_menu(self, table, position): + row = table.rowAt(position.y()) + if row < 0: + return + table.selectRow(row) + menu = QMenu(self) + self._add_copy_row_action(menu, table, row) + menu.exec(table.viewport().mapToGlobal(position)) + + @staticmethod + def _work_row_text(item): + statistics = item.get("statistics") or {} + values = [ + ("发布时间", date_text(item.get("create_time"))), + ("作品描述", item.get("desc") or "未填写作品描述"), + ("作品ID", item.get("aweme_id", "")), + ("点赞", statistics.get("digg_count", 0)), + ("评论", statistics.get("comment_count", 0)), + ("收藏", statistics.get("collect_count", 0)), + ("分享", statistics.get("share_count", 0)), + ("封面地址", item.get("cover") or "无封面地址"), + ("更新时间", date_text(item.get("updated"))), + ] + return "\t".join(f"{label}: {value}" for label, value in values) + + def works_card_context_menu(self, item, global_pos): + menu = QMenu(self) + action = menu.addAction("复制行信息") + action.triggered.connect( + lambda _checked=False: self._copy_text(self._work_row_text(item)) + ) + menu.exec(global_pos) + def history_context_menu(self, position): row = self.history_table.rowAt(position.y()) if row < 0: return item = self.history_table.item(row, 0) nid = item.data(Qt.ItemDataRole.UserRole) if item else None - if not nid: - return self.history_table.selectRow(row) menu = QMenu(self) - action = menu.addAction("清除冷却并重新执行失败任务") - action.triggered.connect( - lambda _checked=False, nid=nid: self.reexecute_history_event(nid) - ) + self._add_copy_row_action(menu, self.history_table, row) + if nid: + action = menu.addAction("清除冷却并重新执行失败任务") + action.triggered.connect( + lambda _checked=False, nid=nid: self.reexecute_history_event(nid) + ) menu.exec(self.history_table.viewport().mapToGlobal(position)) def reexecute_history_event(self, nid): @@ -1586,6 +1722,11 @@ class Window(QMainWindow): cell.setToolTip(str(value)) self.works_table.setItem(row, column, cell) card = WorkCard(item, check.checkState() == Qt.CheckState.Checked) + card.context_requested.connect( + lambda global_pos, item=item: self.works_card_context_menu( + item, global_pos + ) + ) cards.append(card) cards_by_id.setdefault(item.get("aweme_id", ""), []).append(card) self.works_card_grid.addWidget(card, row // 3, row % 3) @@ -1666,6 +1807,7 @@ class Window(QMainWindow): for item in items: load_cover(item) + self._refresh_works_card_scroll() self._toggle_works_selection(self.works_all_mode.isChecked()) def account_menu(self, account_id, global_pos): diff --git a/src/test_onboarding_ui.py b/src/test_onboarding_ui.py index edeba71..c86f85b 100644 --- a/src/test_onboarding_ui.py +++ b/src/test_onboarding_ui.py @@ -309,6 +309,59 @@ class OnboardingUiTest(unittest.TestCase): }, ) + def test_lists_scroll_and_copy_rows(self): + self.window.resize(1260, 860) + self.window.show() + items = [ + { + "aweme_id": str(i), + "desc": f"作品 {i}", + "create_time": 123, + "statistics": {}, + "cover": "", + "updated": 456, + } + for i in range(30) + ] + self.window.works_dialog( + { + "account": "main", + "items": items, + "selected": [], + "mode": "selected", + "refresh_interval": 3600, + } + ) + self.app.processEvents() + self.assertGreater(self.window.works_card_area.verticalScrollBar().maximum(), 0) + + tasks = [ + { + "id": str(i), + "event": "通知", + "source": "大号", + "worker": "小号", + "target": f"用户{i}", + "action": "follow", + "status": "pending", + "result": "", + "created_at": 123, + "started_at": 123, + "finished_at": 123, + } + for i in range(100) + ] + self.window._fill_task_table(self.window.current_task_table, tasks, {}, True) + self.app.processEvents() + self.assertGreater(self.window.current_task_table.verticalScrollBar().maximum(), 0) + + self.window.copy_table_row(self.window.current_task_table, 0) + copied = QApplication.clipboard().text() + self.assertIn("选择: 未选择", copied) + self.assertIn("任务: 0", copied) + self.window._copy_text(self.window._work_row_text(items[0])) + self.assertIn("作品ID: 0", QApplication.clipboard().text()) + def test_anonymous_profile_is_never_bound(self): for user in ({}, {"uid": "0"}, {"uid": True}, {"uid": "not-a-uid"}): with self.assertRaises(RuntimeError):