From 07c5a85df7da1acc20819cda6655aac6068ae8c0 Mon Sep 17 00:00:00 2001 From: chengma Date: Tue, 11 Aug 2026 11:32:37 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BB=BB=E5=8A=A1=E8=AF=A6=E6=83=85?= =?UTF-8?q?=E5=AD=97=E6=AE=B5=E6=94=AF=E6=8C=81=E5=8F=8C=E5=87=BB=E5=A4=8D?= =?UTF-8?q?=E5=88=B6=20(#145)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/task_detail_view.py | 109 ++++++++++++++++++++++++--- client/test/test_task_detail_view.py | 43 ++++++++++- docs/client/05-ui-specification.md | 4 + 3 files changed, 144 insertions(+), 12 deletions(-) diff --git a/client/src/task_detail_view.py b/client/src/task_detail_view.py index cf89ef9..0e7575e 100644 --- a/client/src/task_detail_view.py +++ b/client/src/task_detail_view.py @@ -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) diff --git a/client/test/test_task_detail_view.py b/client/test/test_task_detail_view.py index c92293c..8f0a776 100644 --- a/client/test/test_task_detail_view.py +++ b/client/test/test_task_detail_view.py @@ -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() diff --git a/docs/client/05-ui-specification.md b/docs/client/05-ui-specification.md index 6669ad8..8273e47 100644 --- a/docs/client/05-ui-specification.md +++ b/docs/client/05-ui-specification.md @@ -231,6 +231,10 @@ class TaskTableModel(QAbstractTableModel): 当前 Client 使用可调整大小的非模态详情窗口。用户可以双击任务行,或选中任务后按 Enter 打开。相同任务只保留一个详情窗口;再次打开时切回已有窗口。按 Esc 或“关闭”按钮返回任务列表。 +详情中的字段值支持鼠标选择和键盘选择。双击字段值或在字段获得焦点后按 `Ctrl+C`, +复制该字段的完整文字,并在窗口底部短暂显示“已复制:字段名”。颜色名称、颜色价格和 +尺码分别复制;字段名称和章节标题不可复制。商品链接只复制,不自动打开浏览器。 + 详情面向采购人员,任务类型、状态、当前步骤和错误说明优先使用中文。数据库中的 `current_step` 等内部值只用于业务判断和日志,不直接显示;遇到尚未适配的新步骤时显示“未知步骤”。 采购演练和恢复至少要明确显示“演练已在提交前停止”、“结果待提交”、 “只允许核对订单”和“需要人工处理”;不能只用颜色表达安全状态。