feat: 任务详情字段支持双击复制 (#145)

This commit is contained in:
chengma
2026-08-11 11:32:37 +08:00
parent eb5494cdf1
commit 07c5a85df7
3 changed files with 144 additions and 12 deletions
+98 -11
View File
@@ -7,9 +7,10 @@
from dataclasses import dataclass
from typing import Any, Dict, Iterable, List, Mapping
from PyQt5.QtCore import Qt, pyqtSignal
from PyQt5.QtCore import Qt, QTimer, pyqtSignal
from PyQt5.QtGui import QCloseEvent, QKeySequence
from PyQt5.QtWidgets import (
QApplication,
QGridLayout,
QHBoxLayout,
QShortcut,
@@ -80,6 +81,63 @@ CURRENT_STEP_TEXT = {
}
class _CopyableLabelMixin:
"""让只读 Label 支持双击或 Ctrl+C 复制完整字段值。"""
copied = pyqtSignal(str, str)
def _init_copyable(self, field_name: str) -> None:
self._copy_field_name = field_name
self.setTextInteractionFlags(
Qt.TextSelectableByMouse | Qt.TextSelectableByKeyboard
)
self.setFocusPolicy(Qt.StrongFocus)
self.setCursor(Qt.IBeamCursor)
self.setToolTip(f"双击或按 Ctrl+C 复制{field_name}")
self.setAccessibleDescription(
f"可选择文本;双击或按 Ctrl+C 复制完整{field_name}"
)
def copy_full_text(self) -> None:
"""复制完整可见内容,并通知详情窗口显示反馈。"""
value = self.text()
QApplication.clipboard().setText(value)
self.copied.emit(self._copy_field_name, value)
def mouseDoubleClickEvent(self, event) -> None:
if event.button() == Qt.LeftButton:
self.copy_full_text()
event.accept()
return
super().mouseDoubleClickEvent(event)
def keyPressEvent(self, event) -> None:
if event.matches(QKeySequence.Copy):
self.copy_full_text()
event.accept()
return
super().keyPressEvent(event)
class CopyableBodyLabel(_CopyableLabelMixin, BodyLabel):
"""保持 BodyLabel 外观的可复制字段值。"""
def __init__(self, text: str, field_name: str, parent=None):
super().__init__(parent)
self.setText(text)
self._init_copyable(field_name)
class CopyableStrongBodyLabel(_CopyableLabelMixin, StrongBodyLabel):
"""保持强调文字外观的可复制字段值。"""
def __init__(self, text: str, field_name: str, parent=None):
super().__init__(parent)
self.setText(text)
self._init_copyable(field_name)
@dataclass(frozen=True)
class ColorPriceView:
"""一个颜色及其采集价格。"""
@@ -258,7 +316,7 @@ def build_task_detail_view_data(detail: TaskDetail) -> TaskDetailViewData:
class _ColorCard(CardWidget):
"""详情中的只读颜色卡片。"""
def __init__(self, color: ColorPriceView, parent=None):
def __init__(self, color: ColorPriceView, on_copied, parent=None):
super().__init__(parent)
self.setFocusPolicy(Qt.NoFocus)
self.setCursor(Qt.ArrowCursor)
@@ -267,8 +325,10 @@ class _ColorCard(CardWidget):
layout = QVBoxLayout(self)
layout.setContentsMargins(14, 10, 14, 10)
layout.setSpacing(3)
name = BodyLabel(color.text, self)
price = StrongBodyLabel(color.price_text, self)
name = CopyableBodyLabel(color.text, "颜色", self)
price = CopyableStrongBodyLabel(color.price_text, "颜色价格", self)
name.copied.connect(on_copied)
price.copied.connect(on_copied)
layout.addWidget(name)
layout.addWidget(price)
notes = []
@@ -284,13 +344,15 @@ class _ColorCard(CardWidget):
class _SizeCard(CardWidget):
"""详情中的只读尺码卡片。"""
def __init__(self, size: str, parent=None):
def __init__(self, size: str, on_copied, parent=None):
super().__init__(parent)
self.setFocusPolicy(Qt.NoFocus)
self.setCursor(Qt.ArrowCursor)
layout = QHBoxLayout(self)
layout.setContentsMargins(14, 8, 14, 8)
layout.addWidget(BodyLabel(size, self))
value = CopyableBodyLabel(size, "尺码", self)
value.copied.connect(on_copied)
layout.addWidget(value)
self.setAccessibleName(f"尺码 {size}")
@@ -304,6 +366,10 @@ class TaskDetailWindow(QWidget):
self.data = build_task_detail_view_data(detail)
self.colorItems: List[_ColorCard] = []
self.sizeItems: List[_SizeCard] = []
self._copy_feedback_timer = QTimer(self)
self._copy_feedback_timer.setSingleShot(True)
self._copy_feedback_timer.setInterval(2_000)
self._copy_feedback_timer.timeout.connect(self._reset_copy_feedback)
self.setAttribute(Qt.WA_DeleteOnClose)
self.setObjectName("taskDetailWindow")
self.setWindowTitle(f"任务详情 · {self.data.task_id}")
@@ -347,7 +413,11 @@ class TaskDetailWindow(QWidget):
error_layout = QVBoxLayout(error_card)
error_layout.setContentsMargins(16, 14, 16, 14)
error_layout.addWidget(SubtitleLabel("最近错误", error_card))
error_layout.addWidget(BodyLabel(self.data.last_error, error_card))
error = self._copyable_label(
self.data.last_error, "最近错误", error_card
)
error.setWordWrap(True)
error_layout.addWidget(error)
content_layout.addWidget(error_card)
content_layout.addStretch(1)
scroll.setWidget(content)
@@ -355,6 +425,9 @@ class TaskDetailWindow(QWidget):
buttons = QHBoxLayout()
buttons.setContentsMargins(24, 12, 24, 0)
self.copyStatusLabel = CaptionLabel("双击字段值可复制", self)
self.copyStatusLabel.setAccessibleName("任务详情复制状态")
buttons.addWidget(self.copyStatusLabel)
buttons.addStretch(1)
close_button = PushButton("关闭", self)
close_button.setAccessibleName("关闭任务详情")
@@ -379,7 +452,7 @@ class TaskDetailWindow(QWidget):
)
for row, (label, value) in enumerate(rows):
name = CaptionLabel(label, card)
text = BodyLabel(value, card)
text = self._copyable_label(value, label, card)
text.setWordWrap(True)
layout.addWidget(name, row, 0, Qt.AlignTop)
layout.addWidget(text, row, 1)
@@ -405,12 +478,12 @@ class TaskDetailWindow(QWidget):
flow.setVerticalSpacing(8)
if title == "颜色":
for value in values:
item = _ColorCard(value, flow_host)
item = _ColorCard(value, self._on_field_copied, flow_host)
self.colorItems.append(item)
flow.addWidget(item)
else:
for value in values:
item = _SizeCard(value, flow_host)
item = _SizeCard(value, self._on_field_copied, flow_host)
self.sizeItems.append(item)
flow.addWidget(item)
layout.addWidget(flow_host)
@@ -431,10 +504,24 @@ class TaskDetailWindow(QWidget):
layout.addWidget(SubtitleLabel("采集信息", card), 0, 0, 1, 2)
for row, (label, value) in enumerate(values, start=1):
layout.addWidget(CaptionLabel(label, card), row, 0)
layout.addWidget(BodyLabel(value, card), row, 1)
layout.addWidget(self._copyable_label(value, label, card), row, 1)
layout.setColumnStretch(1, 1)
return card
def _copyable_label(
self, value: str, field_name: str, parent: QWidget
) -> CopyableBodyLabel:
label = CopyableBodyLabel(value, field_name, parent)
label.copied.connect(self._on_field_copied)
return label
def _on_field_copied(self, field_name: str, _value: str) -> None:
self.copyStatusLabel.setText(f"已复制:{field_name}")
self._copy_feedback_timer.start()
def _reset_copy_feedback(self) -> None:
self.copyStatusLabel.setText("双击字段值可复制")
def closeEvent(self, event: QCloseEvent) -> None:
self.closed.emit(self.data.task_id)
super().closeEvent(event)
+42 -1
View File
@@ -6,9 +6,16 @@ from dataclasses import replace
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PyQt5.QtCore import Qt
from PyQt5.QtTest import QTest
from PyQt5.QtWidgets import QApplication
from src.task_detail_view import TaskDetailWindow, build_task_detail_view_data
from src.task_detail_view import (
CopyableBodyLabel,
CopyableStrongBodyLabel,
TaskDetailWindow,
build_task_detail_view_data,
)
from src.task_models import TaskDetail, TaskStatus, TaskType
@@ -148,6 +155,40 @@ class TaskDetailViewTest(unittest.TestCase):
window.close()
self.app.processEvents()
def test_double_click_copies_full_field_and_updates_feedback(self):
window = TaskDetailWindow(make_detail(collected_data()))
title = next(
label
for label in window.findChildren(CopyableBodyLabel)
if label.text() == "采集标题"
)
QTest.mouseDClick(title, Qt.LeftButton)
self.assertEqual(QApplication.clipboard().text(), "采集标题")
self.assertEqual(window.copyStatusLabel.text(), "已复制:商品标题")
self.assertTrue(
title.textInteractionFlags() & Qt.TextSelectableByKeyboard
)
window.close()
self.app.processEvents()
def test_ctrl_c_copies_price_without_changing_link_behavior(self):
window = TaskDetailWindow(make_detail(collected_data()))
price = next(
label
for label in window.findChildren(CopyableStrongBodyLabel)
if label.text() == "¥10.00~¥12.00"
)
price.setFocus()
QTest.keyClick(price, Qt.Key_C, Qt.ControlModifier)
self.assertEqual(QApplication.clipboard().text(), "¥10.00~¥12.00")
self.assertEqual(window.copyStatusLabel.text(), "已复制:颜色价格")
window.close()
self.app.processEvents()
if __name__ == "__main__":
unittest.main()