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