Fix list scrolling and row copy actions

This commit is contained in:
2026-09-21 16:55:07 +08:00
parent eca6365ebc
commit 638df6993c
3 changed files with 227 additions and 6 deletions
+26
View File
@@ -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 文件可复现,未将该环境问题归因于本次改动。
+148 -6
View File
@@ -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):
+53
View File
@@ -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):