feat: 任务详情字段支持双击复制 (#145)
This commit is contained in:
@@ -7,9 +7,10 @@
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any, Dict, Iterable, List, Mapping
|
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.QtGui import QCloseEvent, QKeySequence
|
||||||
from PyQt5.QtWidgets import (
|
from PyQt5.QtWidgets import (
|
||||||
|
QApplication,
|
||||||
QGridLayout,
|
QGridLayout,
|
||||||
QHBoxLayout,
|
QHBoxLayout,
|
||||||
QShortcut,
|
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)
|
@dataclass(frozen=True)
|
||||||
class ColorPriceView:
|
class ColorPriceView:
|
||||||
"""一个颜色及其采集价格。"""
|
"""一个颜色及其采集价格。"""
|
||||||
@@ -258,7 +316,7 @@ def build_task_detail_view_data(detail: TaskDetail) -> TaskDetailViewData:
|
|||||||
class _ColorCard(CardWidget):
|
class _ColorCard(CardWidget):
|
||||||
"""详情中的只读颜色卡片。"""
|
"""详情中的只读颜色卡片。"""
|
||||||
|
|
||||||
def __init__(self, color: ColorPriceView, parent=None):
|
def __init__(self, color: ColorPriceView, on_copied, parent=None):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.setFocusPolicy(Qt.NoFocus)
|
self.setFocusPolicy(Qt.NoFocus)
|
||||||
self.setCursor(Qt.ArrowCursor)
|
self.setCursor(Qt.ArrowCursor)
|
||||||
@@ -267,8 +325,10 @@ class _ColorCard(CardWidget):
|
|||||||
layout = QVBoxLayout(self)
|
layout = QVBoxLayout(self)
|
||||||
layout.setContentsMargins(14, 10, 14, 10)
|
layout.setContentsMargins(14, 10, 14, 10)
|
||||||
layout.setSpacing(3)
|
layout.setSpacing(3)
|
||||||
name = BodyLabel(color.text, self)
|
name = CopyableBodyLabel(color.text, "颜色", self)
|
||||||
price = StrongBodyLabel(color.price_text, self)
|
price = CopyableStrongBodyLabel(color.price_text, "颜色价格", self)
|
||||||
|
name.copied.connect(on_copied)
|
||||||
|
price.copied.connect(on_copied)
|
||||||
layout.addWidget(name)
|
layout.addWidget(name)
|
||||||
layout.addWidget(price)
|
layout.addWidget(price)
|
||||||
notes = []
|
notes = []
|
||||||
@@ -284,13 +344,15 @@ class _ColorCard(CardWidget):
|
|||||||
class _SizeCard(CardWidget):
|
class _SizeCard(CardWidget):
|
||||||
"""详情中的只读尺码卡片。"""
|
"""详情中的只读尺码卡片。"""
|
||||||
|
|
||||||
def __init__(self, size: str, parent=None):
|
def __init__(self, size: str, on_copied, parent=None):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.setFocusPolicy(Qt.NoFocus)
|
self.setFocusPolicy(Qt.NoFocus)
|
||||||
self.setCursor(Qt.ArrowCursor)
|
self.setCursor(Qt.ArrowCursor)
|
||||||
layout = QHBoxLayout(self)
|
layout = QHBoxLayout(self)
|
||||||
layout.setContentsMargins(14, 8, 14, 8)
|
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}")
|
self.setAccessibleName(f"尺码 {size}")
|
||||||
|
|
||||||
|
|
||||||
@@ -304,6 +366,10 @@ class TaskDetailWindow(QWidget):
|
|||||||
self.data = build_task_detail_view_data(detail)
|
self.data = build_task_detail_view_data(detail)
|
||||||
self.colorItems: List[_ColorCard] = []
|
self.colorItems: List[_ColorCard] = []
|
||||||
self.sizeItems: List[_SizeCard] = []
|
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.setAttribute(Qt.WA_DeleteOnClose)
|
||||||
self.setObjectName("taskDetailWindow")
|
self.setObjectName("taskDetailWindow")
|
||||||
self.setWindowTitle(f"任务详情 · {self.data.task_id}")
|
self.setWindowTitle(f"任务详情 · {self.data.task_id}")
|
||||||
@@ -347,7 +413,11 @@ class TaskDetailWindow(QWidget):
|
|||||||
error_layout = QVBoxLayout(error_card)
|
error_layout = QVBoxLayout(error_card)
|
||||||
error_layout.setContentsMargins(16, 14, 16, 14)
|
error_layout.setContentsMargins(16, 14, 16, 14)
|
||||||
error_layout.addWidget(SubtitleLabel("最近错误", error_card))
|
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.addWidget(error_card)
|
||||||
content_layout.addStretch(1)
|
content_layout.addStretch(1)
|
||||||
scroll.setWidget(content)
|
scroll.setWidget(content)
|
||||||
@@ -355,6 +425,9 @@ class TaskDetailWindow(QWidget):
|
|||||||
|
|
||||||
buttons = QHBoxLayout()
|
buttons = QHBoxLayout()
|
||||||
buttons.setContentsMargins(24, 12, 24, 0)
|
buttons.setContentsMargins(24, 12, 24, 0)
|
||||||
|
self.copyStatusLabel = CaptionLabel("双击字段值可复制", self)
|
||||||
|
self.copyStatusLabel.setAccessibleName("任务详情复制状态")
|
||||||
|
buttons.addWidget(self.copyStatusLabel)
|
||||||
buttons.addStretch(1)
|
buttons.addStretch(1)
|
||||||
close_button = PushButton("关闭", self)
|
close_button = PushButton("关闭", self)
|
||||||
close_button.setAccessibleName("关闭任务详情")
|
close_button.setAccessibleName("关闭任务详情")
|
||||||
@@ -379,7 +452,7 @@ class TaskDetailWindow(QWidget):
|
|||||||
)
|
)
|
||||||
for row, (label, value) in enumerate(rows):
|
for row, (label, value) in enumerate(rows):
|
||||||
name = CaptionLabel(label, card)
|
name = CaptionLabel(label, card)
|
||||||
text = BodyLabel(value, card)
|
text = self._copyable_label(value, label, card)
|
||||||
text.setWordWrap(True)
|
text.setWordWrap(True)
|
||||||
layout.addWidget(name, row, 0, Qt.AlignTop)
|
layout.addWidget(name, row, 0, Qt.AlignTop)
|
||||||
layout.addWidget(text, row, 1)
|
layout.addWidget(text, row, 1)
|
||||||
@@ -405,12 +478,12 @@ class TaskDetailWindow(QWidget):
|
|||||||
flow.setVerticalSpacing(8)
|
flow.setVerticalSpacing(8)
|
||||||
if title == "颜色":
|
if title == "颜色":
|
||||||
for value in values:
|
for value in values:
|
||||||
item = _ColorCard(value, flow_host)
|
item = _ColorCard(value, self._on_field_copied, flow_host)
|
||||||
self.colorItems.append(item)
|
self.colorItems.append(item)
|
||||||
flow.addWidget(item)
|
flow.addWidget(item)
|
||||||
else:
|
else:
|
||||||
for value in values:
|
for value in values:
|
||||||
item = _SizeCard(value, flow_host)
|
item = _SizeCard(value, self._on_field_copied, flow_host)
|
||||||
self.sizeItems.append(item)
|
self.sizeItems.append(item)
|
||||||
flow.addWidget(item)
|
flow.addWidget(item)
|
||||||
layout.addWidget(flow_host)
|
layout.addWidget(flow_host)
|
||||||
@@ -431,10 +504,24 @@ class TaskDetailWindow(QWidget):
|
|||||||
layout.addWidget(SubtitleLabel("采集信息", card), 0, 0, 1, 2)
|
layout.addWidget(SubtitleLabel("采集信息", card), 0, 0, 1, 2)
|
||||||
for row, (label, value) in enumerate(values, start=1):
|
for row, (label, value) in enumerate(values, start=1):
|
||||||
layout.addWidget(CaptionLabel(label, card), row, 0)
|
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)
|
layout.setColumnStretch(1, 1)
|
||||||
return card
|
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:
|
def closeEvent(self, event: QCloseEvent) -> None:
|
||||||
self.closed.emit(self.data.task_id)
|
self.closed.emit(self.data.task_id)
|
||||||
super().closeEvent(event)
|
super().closeEvent(event)
|
||||||
|
|||||||
@@ -6,9 +6,16 @@ from dataclasses import replace
|
|||||||
|
|
||||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
|
||||||
|
from PyQt5.QtCore import Qt
|
||||||
|
from PyQt5.QtTest import QTest
|
||||||
from PyQt5.QtWidgets import QApplication
|
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
|
from src.task_models import TaskDetail, TaskStatus, TaskType
|
||||||
|
|
||||||
|
|
||||||
@@ -148,6 +155,40 @@ class TaskDetailViewTest(unittest.TestCase):
|
|||||||
window.close()
|
window.close()
|
||||||
self.app.processEvents()
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -231,6 +231,10 @@ class TaskTableModel(QAbstractTableModel):
|
|||||||
|
|
||||||
当前 Client 使用可调整大小的非模态详情窗口。用户可以双击任务行,或选中任务后按 Enter 打开。相同任务只保留一个详情窗口;再次打开时切回已有窗口。按 Esc 或“关闭”按钮返回任务列表。
|
当前 Client 使用可调整大小的非模态详情窗口。用户可以双击任务行,或选中任务后按 Enter 打开。相同任务只保留一个详情窗口;再次打开时切回已有窗口。按 Esc 或“关闭”按钮返回任务列表。
|
||||||
|
|
||||||
|
详情中的字段值支持鼠标选择和键盘选择。双击字段值或在字段获得焦点后按 `Ctrl+C`,
|
||||||
|
复制该字段的完整文字,并在窗口底部短暂显示“已复制:字段名”。颜色名称、颜色价格和
|
||||||
|
尺码分别复制;字段名称和章节标题不可复制。商品链接只复制,不自动打开浏览器。
|
||||||
|
|
||||||
详情面向采购人员,任务类型、状态、当前步骤和错误说明优先使用中文。数据库中的 `current_step` 等内部值只用于业务判断和日志,不直接显示;遇到尚未适配的新步骤时显示“未知步骤”。
|
详情面向采购人员,任务类型、状态、当前步骤和错误说明优先使用中文。数据库中的 `current_step` 等内部值只用于业务判断和日志,不直接显示;遇到尚未适配的新步骤时显示“未知步骤”。
|
||||||
采购演练和恢复至少要明确显示“演练已在提交前停止”、“结果待提交”、
|
采购演练和恢复至少要明确显示“演练已在提交前停止”、“结果待提交”、
|
||||||
“只允许核对订单”和“需要人工处理”;不能只用颜色表达安全状态。
|
“只允许核对订单”和“需要人工处理”;不能只用颜色表达安全状态。
|
||||||
|
|||||||
Reference in New Issue
Block a user