Files
cmbuyer/client/src/cmbuyer_client/ui/records.py
T

96 lines
3.9 KiB
Python
Raw Normal View History

2026-08-05 01:43:21 +08:00
"""采购记录的只读 Qt Model/View 数据源。"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Protocol
from PySide6.QtCore import QAbstractTableModel, QModelIndex, Qt
from cmbuyer_client.logging_policy import redact_text
from cmbuyer_client.core.errors import ValidationError
from cmbuyer_client.core.validation import rfc3339_z_nanoseconds
@dataclass(frozen=True)
class PurchaseRecord:
record_id: str
title: str
status: str
created_at: str
original_text: str = ""
result_text: str = ""
image_description: str = ""
created_at_nanoseconds: int = field(init=False, repr=False)
def __post_init__(self) -> None:
# 记录 provider 只能注入可显示摘要;最终 UI 边界仍统一脱敏,避免
# consumer bug 把 Bearer/裸 token 放入 model、详情或可见日志。
for field in ("title", "status", "original_text", "result_text", "image_description"):
object.__setattr__(self, field, redact_text(getattr(self, field)))
if not isinstance(self.created_at, str):
raise ValueError("noncanonical_record_timestamp")
fraction = self.created_at[20:-1] if len(self.created_at) > 20 and self.created_at.endswith("Z") else ""
if fraction and fraction.endswith("0"):
raise ValueError("noncanonical_record_timestamp")
try:
timestamp = rfc3339_z_nanoseconds(self.created_at)
except ValidationError:
raise ValueError("noncanonical_record_timestamp") from None
object.__setattr__(self, "created_at_nanoseconds", timestamp)
class PurchaseRecordProvider(Protocol):
"""只返回已准备好的无秘密 View DTO;不得在 GUI 线程查询 SQLite/HTTP。"""
def snapshot(self) -> list[PurchaseRecord]: ...
class PurchaseRecordModel(QAbstractTableModel):
RECORD_ID_ROLE = int(Qt.ItemDataRole.UserRole) + 1
def __init__(self, records: list[PurchaseRecord] | None = None, parent=None) -> None:
super().__init__(parent)
self._records: list[PurchaseRecord] = []
self.set_records(records or [])
def rowCount(self, parent: QModelIndex = QModelIndex()) -> int:
return 0 if parent.isValid() else len(self._records)
def columnCount(self, parent: QModelIndex = QModelIndex()) -> int:
return 0 if parent.isValid() else 2
def data(self, index: QModelIndex, role: int = int(Qt.ItemDataRole.DisplayRole)):
if not index.isValid() or not 0 <= index.row() < len(self._records):
return None
record = self._records[index.row()]
if role == int(Qt.ItemDataRole.DisplayRole):
return record.title if index.column() == 0 else record.status
if role == self.RECORD_ID_ROLE:
return record.record_id
if role == int(Qt.ItemDataRole.ToolTipRole):
return f"{record.title}\n{record.created_at}"
if role == int(Qt.ItemDataRole.TextAlignmentRole) and index.column() == 1:
return int(Qt.AlignmentFlag.AlignCenter)
return None
def headerData(self, section: int, orientation: Qt.Orientation, role: int = int(Qt.ItemDataRole.DisplayRole)):
if role != int(Qt.ItemDataRole.DisplayRole) or orientation != Qt.Orientation.Horizontal:
return None
return ("标题", "状态")[section] if 0 <= section < 2 else None
def set_records(self, records: list[PurchaseRecord]) -> None:
self.beginResetModel()
self._records = sorted(
records,
key=lambda item: (item.created_at_nanoseconds, item.record_id),
reverse=True,
)
self.endResetModel()
def record_at(self, row: int) -> PurchaseRecord | None:
return self._records[row] if 0 <= row < len(self._records) else None
def row_for_id(self, record_id: str) -> int:
return next((row for row, item in enumerate(self._records) if item.record_id == record_id), -1)