Fix list scrolling and row copy actions
This commit is contained in:
@@ -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
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user