feat(client): add safe polling session UI
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
"""采购记录的只读 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)
|
||||
Reference in New Issue
Block a user