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()
+4
View File
@@ -231,6 +231,10 @@ class TaskTableModel(QAbstractTableModel):
当前 Client 使用可调整大小的非模态详情窗口。用户可以双击任务行,或选中任务后按 Enter 打开。相同任务只保留一个详情窗口;再次打开时切回已有窗口。按 Esc 或“关闭”按钮返回任务列表。
详情中的字段值支持鼠标选择和键盘选择。双击字段值或在字段获得焦点后按 `Ctrl+C`,
复制该字段的完整文字,并在窗口底部短暂显示“已复制:字段名”。颜色名称、颜色价格和
尺码分别复制;字段名称和章节标题不可复制。商品链接只复制,不自动打开浏览器。
详情面向采购人员,任务类型、状态、当前步骤和错误说明优先使用中文。数据库中的 `current_step` 等内部值只用于业务判断和日志,不直接显示;遇到尚未适配的新步骤时显示“未知步骤”。
采购演练和恢复至少要明确显示“演练已在提交前停止”、“结果待提交”、
“只允许核对订单”和“需要人工处理”;不能只用颜色表达安全状态。