feat(client): add safe polling session UI
This commit is contained in:
@@ -5,8 +5,9 @@ from __future__ import annotations
|
||||
import sys
|
||||
from collections.abc import Sequence
|
||||
|
||||
from .core.errors import StateError
|
||||
from .logging_policy import configure_application_logger
|
||||
from .runtime import RuntimePaths
|
||||
from .runtime import LocalStateRuntime, RuntimePaths
|
||||
|
||||
|
||||
def select_application_argv(argv: Sequence[str] | None) -> list[str]:
|
||||
@@ -30,8 +31,7 @@ def main(argv: Sequence[str] | None = None) -> int:
|
||||
return 1
|
||||
|
||||
try:
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtWidgets import QApplication, QLabel, QMainWindow
|
||||
from PySide6.QtWidgets import QApplication
|
||||
except ImportError:
|
||||
logger.error("缺少 PySide6,无法启动桌面界面。")
|
||||
print("无法启动采购工具:缺少 PySide6。请先安装 requirements.txt 中的依赖。", file=sys.stderr)
|
||||
@@ -39,19 +39,47 @@ def main(argv: Sequence[str] | None = None) -> int:
|
||||
|
||||
application = QApplication.instance() or QApplication(select_application_argv(argv))
|
||||
application.setApplicationName("采购工具")
|
||||
runtime: LocalStateRuntime | None = None
|
||||
coordinator = None
|
||||
try:
|
||||
runtime = LocalStateRuntime.open(paths)
|
||||
try:
|
||||
summary = runtime.store.load_profile_summary("default")
|
||||
except StateError as error:
|
||||
if error.reason != "profile_not_found":
|
||||
raise
|
||||
summary = None
|
||||
|
||||
window = QMainWindow()
|
||||
window.setWindowTitle("采购工具")
|
||||
window.setAccessibleName("采购工具")
|
||||
window.setMinimumSize(420, 240)
|
||||
window.resize(560, 320)
|
||||
from .polling.coordinator import PollingCoordinator
|
||||
from .ui.main_window import PurchaseToolWindow
|
||||
|
||||
message = QLabel("应用骨架已初始化。\n采购执行功能尚未启用。")
|
||||
message.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
message.setWordWrap(True)
|
||||
message.setAccessibleName("当前状态")
|
||||
window.setCentralWidget(message)
|
||||
|
||||
logger.info("应用已启动;采购执行功能尚未启用。")
|
||||
window.show()
|
||||
return application.exec()
|
||||
settings = None if summary is None else summary.settings
|
||||
has_token = False if summary is None else summary.has_stored_device_token
|
||||
coordinator = PollingCoordinator(
|
||||
profile_id="default",
|
||||
store=runtime.store,
|
||||
gateway_factory=None,
|
||||
consumer=None,
|
||||
profile_settings=settings,
|
||||
poll_interval_seconds=15 if settings is None else settings.poll_interval_seconds,
|
||||
failure_threshold=3 if settings is None else settings.failure_threshold,
|
||||
)
|
||||
window = PurchaseToolWindow(
|
||||
store=runtime.store,
|
||||
coordinator=coordinator,
|
||||
profile_settings=settings,
|
||||
has_stored_device_token=has_token,
|
||||
)
|
||||
logger.info("应用已启动;单趟执行能力尚未接入,真实领取保持禁用。")
|
||||
window.show()
|
||||
return application.exec()
|
||||
except (OSError, RuntimeError, StateError):
|
||||
logger.error("无法打开采购工具本地安全状态。")
|
||||
print("无法启动采购工具:本地安全状态不可用。", file=sys.stderr)
|
||||
return 3
|
||||
finally:
|
||||
worker_stopped = True
|
||||
if coordinator is not None:
|
||||
worker_stopped = coordinator.shutdown()
|
||||
if runtime is not None and worker_stopped:
|
||||
runtime.close()
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
"""采购工具轮询会话协调器。"""
|
||||
|
||||
from .coordinator import ClaimedTaskView, PollingCoordinator, PollingState, RecoveryStatus, StartReadiness
|
||||
|
||||
__all__ = ["ClaimedTaskView", "PollingCoordinator", "PollingState", "RecoveryStatus", "StartReadiness"]
|
||||
@@ -0,0 +1,586 @@
|
||||
"""在 Qt 事件循环中协调可恢复的领取会话。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import Protocol
|
||||
|
||||
from PySide6.QtCore import QObject, QThread, QTimer, Signal, Slot
|
||||
|
||||
from cmbuyer_client.core.errors import (
|
||||
AmbiguousRemoteError,
|
||||
ClientError,
|
||||
CredentialRemoteError,
|
||||
ManualRemoteError,
|
||||
ProtocolRemoteError,
|
||||
StateError,
|
||||
)
|
||||
from cmbuyer_client.core.models import ClaimedTask
|
||||
from cmbuyer_client.localstate.models import PollingSession, ProfileSettings, RecoverySnapshot
|
||||
from cmbuyer_client.logging_policy import redact_text
|
||||
|
||||
|
||||
SAFE_AMBIGUOUS_REASONS = frozenset(
|
||||
("http_result_unknown", "server_result_unknown", "truncated_response")
|
||||
)
|
||||
|
||||
|
||||
class PollingState(str, Enum):
|
||||
STOPPED = "STOPPED"
|
||||
STARTING = "STARTING"
|
||||
BLOCKED = "BLOCKED"
|
||||
RECOVERING = "RECOVERING"
|
||||
WAITING = "WAITING"
|
||||
CLAIMING = "CLAIMING"
|
||||
ACTIVE = "ACTIVE"
|
||||
RECOVERY_REQUIRED = "RECOVERY_REQUIRED"
|
||||
|
||||
|
||||
class PollingStore(Protocol):
|
||||
def recovery_snapshot(self, profile_id: str) -> RecoverySnapshot: ...
|
||||
|
||||
def start_or_resume_polling(self, profile_id: str) -> PollingSession: ...
|
||||
|
||||
def request_stop(self, profile_id: str) -> PollingSession: ...
|
||||
|
||||
|
||||
class ClaimGateway(Protocol):
|
||||
def claim_next(self, profile_id: str) -> ClaimedTask | None: ...
|
||||
|
||||
|
||||
class ExecutionConsumer(Protocol):
|
||||
def accept_claim(self, claimed: ClaimedTask) -> None: ...
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class StartReadiness:
|
||||
"""由后续已取证执行能力注入;T-304 自己不探测网络或设备。"""
|
||||
|
||||
ready: bool
|
||||
reason: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ClaimedTaskView:
|
||||
"""允许发往 UI 的最小投影,刻意不包含 authorization/claim token。"""
|
||||
|
||||
task_id: str
|
||||
title: str
|
||||
status: str = "已领取"
|
||||
|
||||
@classmethod
|
||||
def from_claim(cls, claimed: ClaimedTask) -> "ClaimedTaskView":
|
||||
return cls(task_id=claimed.task.id, title=redact_text(claimed.task.title))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RecoveryStatus:
|
||||
"""可进入 UI 的恢复摘要;不携带 task snapshot、claim token 或密文。"""
|
||||
|
||||
has_open_session: bool
|
||||
session_accept_new: bool
|
||||
has_pending_claim: bool
|
||||
has_active_claim: bool
|
||||
has_pending_active_work: bool
|
||||
|
||||
@classmethod
|
||||
def from_snapshot(cls, snapshot: RecoverySnapshot) -> "RecoveryStatus":
|
||||
return cls(
|
||||
has_open_session=snapshot.session is not None,
|
||||
session_accept_new=bool(snapshot.session and snapshot.session.accept_new),
|
||||
has_pending_claim=snapshot.pending_claim is not None,
|
||||
has_active_claim=snapshot.active_claim is not None,
|
||||
has_pending_active_work=bool(snapshot.pending_renew or snapshot.pending_evidence),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _BootstrapResult:
|
||||
recovery: RecoveryStatus
|
||||
session: PollingSession | None
|
||||
normalized_stop: PollingSession | None = None
|
||||
|
||||
|
||||
class _PollingWorker(QObject):
|
||||
bootstrap_finished = Signal(object)
|
||||
claim_finished = Signal(object)
|
||||
stop_finished = Signal(object)
|
||||
failed = Signal(str, object)
|
||||
|
||||
def __init__(self, store: PollingStore) -> None:
|
||||
super().__init__()
|
||||
self._store = store
|
||||
self._gateway: ClaimGateway | None = None
|
||||
|
||||
@Slot(object)
|
||||
def configure_gateway(self, gateway: object) -> None:
|
||||
if not hasattr(gateway, "claim_next"):
|
||||
self.failed.emit("configure", RuntimeError("invalid_claim_gateway"))
|
||||
return
|
||||
self._gateway = gateway # type: ignore[assignment]
|
||||
|
||||
@Slot(str)
|
||||
def bootstrap(self, profile_id: str) -> None:
|
||||
try:
|
||||
snapshot = self._store.recovery_snapshot(profile_id)
|
||||
recovery = RecoveryStatus.from_snapshot(snapshot)
|
||||
if snapshot.session is not None and snapshot.session.accept_new:
|
||||
stopped = self._store.request_stop(profile_id)
|
||||
self.bootstrap_finished.emit(_BootstrapResult(recovery, None, stopped))
|
||||
return
|
||||
if snapshot.active_claim is not None:
|
||||
self.bootstrap_finished.emit(_BootstrapResult(recovery, None))
|
||||
return
|
||||
session = self._store.start_or_resume_polling(profile_id)
|
||||
self.bootstrap_finished.emit(_BootstrapResult(recovery, session))
|
||||
except Exception as error:
|
||||
self.failed.emit("bootstrap", error)
|
||||
|
||||
@Slot(str)
|
||||
def inspect_restart(self, profile_id: str) -> None:
|
||||
try:
|
||||
snapshot = self._store.recovery_snapshot(profile_id)
|
||||
recovery = RecoveryStatus.from_snapshot(snapshot)
|
||||
stopped = None
|
||||
if snapshot.session is not None and snapshot.session.accept_new:
|
||||
stopped = self._store.request_stop(profile_id)
|
||||
self.bootstrap_finished.emit(_BootstrapResult(recovery, None, stopped))
|
||||
except Exception as error:
|
||||
self.failed.emit("inspect", error)
|
||||
|
||||
@Slot(str)
|
||||
def claim(self, profile_id: str) -> None:
|
||||
try:
|
||||
# DurableClientGateway 在返回前已经提交 EMPTY 或 active claim;UI 不能
|
||||
# 以 generation 过期为由丢弃这个业务结果。
|
||||
if self._gateway is None:
|
||||
raise RuntimeError("claim_gateway_not_configured")
|
||||
self.claim_finished.emit(self._gateway.claim_next(profile_id))
|
||||
except Exception as error:
|
||||
self.failed.emit("claim", error)
|
||||
|
||||
@Slot(str)
|
||||
def stop(self, profile_id: str) -> None:
|
||||
try:
|
||||
self.stop_finished.emit(self._store.request_stop(profile_id))
|
||||
except Exception as error:
|
||||
self.failed.emit("stop", error)
|
||||
|
||||
|
||||
class PollingCoordinator(QObject):
|
||||
"""把计时、阻塞 I/O 和可见状态收敛到一个会话边界。"""
|
||||
|
||||
state_changed = Signal(object, str, int)
|
||||
claim_visible = Signal(object)
|
||||
recovery_status_changed = Signal(object)
|
||||
configuration_freeze_changed = Signal(bool)
|
||||
settled = Signal()
|
||||
_configure_gateway_requested = Signal(object)
|
||||
_bootstrap_requested = Signal(str)
|
||||
_inspect_requested = Signal(str)
|
||||
_claim_requested = Signal(str)
|
||||
_stop_requested_signal = Signal(str)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
profile_id: str,
|
||||
store: PollingStore | None,
|
||||
gateway_factory: Callable[[ProfileSettings], ClaimGateway] | None,
|
||||
consumer: ExecutionConsumer | None,
|
||||
profile_settings: ProfileSettings | None = None,
|
||||
readiness: StartReadiness | None = None,
|
||||
poll_interval_seconds: int = 15,
|
||||
failure_threshold: int = 3,
|
||||
timer_interval_ms: int | None = None,
|
||||
parent: QObject | None = None,
|
||||
) -> None:
|
||||
super().__init__(parent)
|
||||
if not 5 <= poll_interval_seconds <= 300:
|
||||
raise ValueError("invalid_poll_interval")
|
||||
if not 1 <= failure_threshold <= 10:
|
||||
raise ValueError("invalid_failure_threshold")
|
||||
self.profile_id = profile_id
|
||||
self._store = store
|
||||
self._gateway_factory = gateway_factory
|
||||
self._gateway: ClaimGateway | None = None
|
||||
self._consumer = consumer
|
||||
self._profile_settings = profile_settings
|
||||
self._frozen_profile: ProfileSettings | None = None
|
||||
self._readiness = readiness
|
||||
self.recovery_status: RecoveryStatus | None = None
|
||||
self._timer_interval_override = timer_interval_ms
|
||||
self._interval_ms = timer_interval_ms or (
|
||||
profile_settings.poll_interval_seconds * 1000 if profile_settings is not None else poll_interval_seconds * 1000
|
||||
)
|
||||
if self._interval_ms <= 0:
|
||||
raise ValueError("invalid_timer_interval")
|
||||
self._failure_threshold = (
|
||||
profile_settings.failure_threshold if profile_settings is not None else failure_threshold
|
||||
)
|
||||
self._consecutive_failures = 0
|
||||
self._operation: str | None = None
|
||||
self._stop_requested = False
|
||||
self._epoch = 0
|
||||
self._scheduled_epoch: int | None = None
|
||||
self._post_stop_state = PollingState.STOPPED
|
||||
self._post_stop_reason = "轮询已停止。"
|
||||
self._thread: QThread | None = None
|
||||
self._worker: _PollingWorker | None = None
|
||||
|
||||
self._timer = QTimer(self)
|
||||
self._timer.setSingleShot(True)
|
||||
self._timer.timeout.connect(self._on_timer_timeout)
|
||||
|
||||
if consumer is None:
|
||||
self.state = PollingState.BLOCKED
|
||||
self.reason = "单趟执行能力尚未接入,不能领取真实任务。"
|
||||
elif store is None or gateway_factory is None:
|
||||
self.state = PollingState.BLOCKED
|
||||
self.reason = "轮询依赖未完整注入,不能领取真实任务。"
|
||||
elif profile_settings is None:
|
||||
self.state = PollingState.BLOCKED
|
||||
self.reason = "尚未保存完整配置,不能开始轮询。"
|
||||
elif readiness is None or not readiness.ready:
|
||||
self.state = PollingState.BLOCKED
|
||||
self.reason = "执行就绪条件未满足,不能开始轮询。" if readiness is None else readiness.reason
|
||||
else:
|
||||
self.state = PollingState.STOPPED
|
||||
self.reason = "轮询已停止。"
|
||||
|
||||
if store is not None:
|
||||
self._thread = QThread(self)
|
||||
self._worker = _PollingWorker(store)
|
||||
self._worker.moveToThread(self._thread)
|
||||
self._configure_gateway_requested.connect(self._worker.configure_gateway)
|
||||
self._inspect_requested.connect(self._worker.inspect_restart)
|
||||
self._bootstrap_requested.connect(self._worker.bootstrap)
|
||||
self._claim_requested.connect(self._worker.claim)
|
||||
self._stop_requested_signal.connect(self._worker.stop)
|
||||
self._worker.bootstrap_finished.connect(self._on_bootstrap_finished)
|
||||
self._worker.claim_finished.connect(self._on_claim_finished)
|
||||
self._worker.stop_finished.connect(self._on_stop_finished)
|
||||
self._worker.failed.connect(self._on_worker_failed)
|
||||
self._thread.start()
|
||||
self._operation = "inspect"
|
||||
self._set_state(PollingState.STARTING, "正在读取重启恢复状态并关闭遗留自动领取许可…")
|
||||
self._inspect_requested.emit(self.profile_id)
|
||||
|
||||
@property
|
||||
def can_start(self) -> bool:
|
||||
return (
|
||||
self._consumer is not None
|
||||
and self._store is not None
|
||||
and self._gateway_factory is not None
|
||||
and self._profile_settings is not None
|
||||
and self._readiness is not None
|
||||
and self._readiness.ready
|
||||
and self.state == PollingState.STOPPED
|
||||
and self._operation is None
|
||||
)
|
||||
|
||||
@property
|
||||
def consecutive_failures(self) -> int:
|
||||
return self._consecutive_failures
|
||||
|
||||
@property
|
||||
def operation_in_flight(self) -> bool:
|
||||
return self._operation is not None
|
||||
|
||||
def update_profile_settings(self, settings: ProfileSettings) -> None:
|
||||
if self._operation is None and self.state in (PollingState.STOPPED, PollingState.BLOCKED):
|
||||
self._profile_settings = settings
|
||||
self._refresh_idle_gate()
|
||||
|
||||
def update_readiness(self, readiness: StartReadiness) -> None:
|
||||
self._readiness = readiness
|
||||
self._refresh_idle_gate()
|
||||
|
||||
def _refresh_idle_gate(self) -> None:
|
||||
if self._operation is not None or self.state == PollingState.RECOVERY_REQUIRED:
|
||||
return
|
||||
if self._consumer is None:
|
||||
self._set_state(PollingState.BLOCKED, "单趟执行能力尚未接入,不能领取真实任务。")
|
||||
elif self._gateway_factory is None or self._store is None:
|
||||
self._set_state(PollingState.BLOCKED, "轮询依赖未完整注入,不能领取真实任务。")
|
||||
elif self._profile_settings is None:
|
||||
self._set_state(PollingState.BLOCKED, "尚未保存完整配置,不能开始轮询。")
|
||||
elif self._readiness is None or not self._readiness.ready:
|
||||
reason = "执行就绪条件未满足,不能开始轮询。" if self._readiness is None else self._readiness.reason
|
||||
self._set_state(PollingState.BLOCKED, reason)
|
||||
else:
|
||||
self._set_state(PollingState.STOPPED, "轮询已停止。")
|
||||
|
||||
def start(self) -> None:
|
||||
# 这道门禁必须早于任何 store/gateway 调用;独立应用没有 consumer,
|
||||
# 即使调用方绕过禁用按钮直接调用本方法也保持零 HTTP。
|
||||
if self._consumer is None:
|
||||
self._set_state(PollingState.BLOCKED, "单趟执行能力尚未接入,不能领取真实任务。")
|
||||
return
|
||||
if self._store is None or self._gateway_factory is None or self._worker is None:
|
||||
self._set_state(PollingState.BLOCKED, "轮询依赖未完整注入,不能领取真实任务。")
|
||||
return
|
||||
if self._profile_settings is None:
|
||||
self._set_state(PollingState.BLOCKED, "尚未保存完整配置,不能开始轮询。")
|
||||
return
|
||||
if self._readiness is None or not self._readiness.ready:
|
||||
reason = "执行就绪条件未满足,不能开始轮询。" if self._readiness is None else self._readiness.reason
|
||||
self._set_state(PollingState.BLOCKED, reason)
|
||||
return
|
||||
if not self.can_start:
|
||||
return
|
||||
frozen = self._profile_settings
|
||||
try:
|
||||
gateway = self._gateway_factory(frozen)
|
||||
except Exception:
|
||||
self._set_state(PollingState.BLOCKED, "领取网关无法按本次冻结配置建立,不能开始轮询。")
|
||||
return
|
||||
if gateway is None or not hasattr(gateway, "claim_next"):
|
||||
self._set_state(PollingState.BLOCKED, "领取网关未完整注入,不能开始轮询。")
|
||||
return
|
||||
self._frozen_profile = frozen
|
||||
self._interval_ms = self._timer_interval_override or frozen.poll_interval_seconds * 1000
|
||||
self._failure_threshold = frozen.failure_threshold
|
||||
self._gateway = gateway
|
||||
self._configure_gateway_requested.emit(gateway)
|
||||
self._epoch += 1
|
||||
self._scheduled_epoch = None
|
||||
self._timer.stop()
|
||||
self._stop_requested = False
|
||||
self._post_stop_state = PollingState.STOPPED
|
||||
self._consecutive_failures = 0
|
||||
self._operation = "bootstrap"
|
||||
self._set_state(PollingState.STARTING, "正在读取本地恢复状态并建立轮询会话…")
|
||||
self._bootstrap_requested.emit(self.profile_id)
|
||||
|
||||
def stop(self) -> None:
|
||||
if self.state == PollingState.STOPPED and self._operation is None:
|
||||
return
|
||||
if self._store is None or self._worker is None:
|
||||
return
|
||||
# epoch/latch 双保险:stopEvent 先让 timer 队列中已经排队的 timeout
|
||||
# 失效,再处理持久 stop;之后只有显式 Start 才会获得新 epoch。
|
||||
self._epoch += 1
|
||||
self._scheduled_epoch = None
|
||||
self._stop_requested = True
|
||||
self._timer.stop()
|
||||
if self._operation in ("inspect", "bootstrap", "claim", "stop"):
|
||||
if self._operation == "claim":
|
||||
self._set_state(PollingState.CLAIMING, "正在等待本次有界领取返回;不会取消或重发请求。")
|
||||
return
|
||||
self._request_stop(PollingState.STOPPED, "轮询已停止;只阻止下一次领取。")
|
||||
|
||||
def shutdown(self, wait_ms: int = 5000) -> bool:
|
||||
"""只结束空闲 worker;飞行中 I/O 必须由事件循环等待 settled。"""
|
||||
|
||||
self._timer.stop()
|
||||
if self._operation is not None:
|
||||
return False
|
||||
if self._thread is not None and self._thread.isRunning():
|
||||
self._thread.quit()
|
||||
return self._thread.wait(wait_ms)
|
||||
return True
|
||||
|
||||
@Slot(object)
|
||||
def _on_bootstrap_finished(self, raw: object) -> None:
|
||||
completed_operation = self._operation
|
||||
self._operation = None
|
||||
result = raw
|
||||
if not isinstance(result, _BootstrapResult):
|
||||
self._block("本地恢复结果无效,轮询已阻止。")
|
||||
return
|
||||
self.recovery_status = result.recovery
|
||||
self.recovery_status_changed.emit(result.recovery)
|
||||
self.configuration_freeze_changed.emit(
|
||||
result.recovery.has_pending_claim or result.recovery.has_active_claim
|
||||
)
|
||||
if completed_operation == "inspect":
|
||||
if result.recovery.has_active_claim:
|
||||
self._set_state(PollingState.RECOVERY_REQUIRED, "遗留会话已停止;必须先安全恢复当前任务。")
|
||||
elif result.recovery.has_pending_claim:
|
||||
self._set_state(PollingState.STOPPED, "遗留领取请求已停止;显式开始后只使用原幂等键恢复。")
|
||||
else:
|
||||
self._refresh_idle_gate()
|
||||
self.settled.emit()
|
||||
return
|
||||
if result.normalized_stop is not None:
|
||||
if result.recovery.has_active_claim:
|
||||
self._set_state(PollingState.RECOVERY_REQUIRED, "遗留会话已停止;必须先安全恢复当前任务。")
|
||||
else:
|
||||
self._set_state(PollingState.STOPPED, "遗留会话已停止;请再次显式开始轮询。")
|
||||
self.settled.emit()
|
||||
return
|
||||
if result.recovery.has_active_claim:
|
||||
self._set_state(
|
||||
PollingState.RECOVERY_REQUIRED,
|
||||
"检测到未关闭的采购任务,必须先完成安全恢复,不能领取新任务。",
|
||||
)
|
||||
self.settled.emit()
|
||||
return
|
||||
if self._stop_requested:
|
||||
self._request_stop(PollingState.STOPPED, "轮询已停止;未发起领取请求。")
|
||||
return
|
||||
if result.recovery.has_pending_claim:
|
||||
self._set_state(PollingState.RECOVERING, "正在使用原幂等键恢复结果不明的领取请求…")
|
||||
else:
|
||||
self._set_state(PollingState.WAITING, "轮询会话已启动,正在等待领取。")
|
||||
self._schedule_claim(0)
|
||||
|
||||
def _schedule_claim(self, delay_ms: int) -> None:
|
||||
self._scheduled_epoch = self._epoch
|
||||
self._timer.start(delay_ms)
|
||||
|
||||
@Slot()
|
||||
def _on_timer_timeout(self) -> None:
|
||||
scheduled_epoch = self._scheduled_epoch
|
||||
self._scheduled_epoch = None
|
||||
if scheduled_epoch != self._epoch:
|
||||
return
|
||||
self._begin_claim(scheduled_epoch)
|
||||
|
||||
def _begin_claim(self, dispatch_epoch: int) -> None:
|
||||
if dispatch_epoch != self._epoch:
|
||||
return
|
||||
if self._operation is not None or self._stop_requested:
|
||||
return
|
||||
if self.state not in (PollingState.WAITING, PollingState.RECOVERING):
|
||||
return
|
||||
self._operation = "claim"
|
||||
self.configuration_freeze_changed.emit(True)
|
||||
self._set_state(PollingState.CLAIMING, "正在领取已授权任务…")
|
||||
self._claim_requested.emit(self.profile_id)
|
||||
|
||||
@Slot(object)
|
||||
def _on_claim_finished(self, claimed: object) -> None:
|
||||
self._operation = None
|
||||
self._consecutive_failures = 0
|
||||
if claimed is not None and not isinstance(claimed, ClaimedTask):
|
||||
self._request_stop(PollingState.BLOCKED, "领取结果类型无效,轮询已阻止。")
|
||||
return
|
||||
if isinstance(claimed, ClaimedTask):
|
||||
self.claim_visible.emit(ClaimedTaskView.from_claim(claimed))
|
||||
if self._stop_requested:
|
||||
self._request_stop(
|
||||
PollingState.RECOVERY_REQUIRED,
|
||||
"停止期间领取已落库;必须先安全恢复该任务,不能领取下一条。",
|
||||
)
|
||||
return
|
||||
try:
|
||||
consumer = self._consumer
|
||||
if consumer is None:
|
||||
raise RuntimeError("execution_consumer_missing")
|
||||
consumer.accept_claim(claimed)
|
||||
except Exception:
|
||||
self._request_stop(
|
||||
PollingState.RECOVERY_REQUIRED,
|
||||
"执行 consumer 未接收已落库任务;必须安全恢复,不能重新领取。",
|
||||
)
|
||||
return
|
||||
self._set_state(PollingState.ACTIVE, "任务已安全领取并交给单趟执行能力。")
|
||||
return
|
||||
self.configuration_freeze_changed.emit(False)
|
||||
if self._stop_requested:
|
||||
self._request_stop(PollingState.STOPPED, "轮询已停止;本次没有可领取任务。")
|
||||
return
|
||||
self._set_state(PollingState.WAITING, "暂无已授权任务,等待下一次轮询。")
|
||||
self._schedule_claim(self._interval_ms)
|
||||
|
||||
def _request_stop(self, target: PollingState, reason: str) -> None:
|
||||
if self._operation == "stop":
|
||||
return
|
||||
self._timer.stop()
|
||||
self._post_stop_state = target
|
||||
self._post_stop_reason = reason
|
||||
self._operation = "stop"
|
||||
self._stop_requested_signal.emit(self.profile_id)
|
||||
|
||||
@Slot(object)
|
||||
def _on_stop_finished(self, session: object) -> None:
|
||||
self._operation = None
|
||||
if not isinstance(session, PollingSession) or session.accept_new:
|
||||
self._block("停止状态未能持久化,轮询已阻止。")
|
||||
return
|
||||
self._set_state(self._post_stop_state, self._post_stop_reason)
|
||||
self.settled.emit()
|
||||
|
||||
@Slot(str, object)
|
||||
def _on_worker_failed(self, operation: str, error: object) -> None:
|
||||
self._operation = None
|
||||
if operation == "inspect":
|
||||
if isinstance(error, StateError) and error.reason == "profile_not_found":
|
||||
self.recovery_status = RecoveryStatus(False, False, False, False, False)
|
||||
self.recovery_status_changed.emit(self.recovery_status)
|
||||
self._refresh_idle_gate()
|
||||
else:
|
||||
self._block("本地恢复状态无法安全读取;已停止且不能领取任务。")
|
||||
self.settled.emit()
|
||||
return
|
||||
if (
|
||||
operation == "claim"
|
||||
and isinstance(error, AmbiguousRemoteError)
|
||||
and error.reason in SAFE_AMBIGUOUS_REASONS
|
||||
):
|
||||
self._consecutive_failures += 1
|
||||
if self._stop_requested:
|
||||
self._request_stop(PollingState.STOPPED, "轮询已停止;结果不明的原领取请求已保留。")
|
||||
elif self._consecutive_failures >= self._failure_threshold:
|
||||
self._request_stop(
|
||||
PollingState.BLOCKED,
|
||||
"连续领取失败达到阈值;原幂等请求已保留,需排查后重新开始。",
|
||||
)
|
||||
else:
|
||||
self._set_state(
|
||||
PollingState.RECOVERING,
|
||||
"领取结果不明;等待使用相同幂等键恢复,不会创建新请求。",
|
||||
)
|
||||
self._schedule_claim(self._interval_ms)
|
||||
return
|
||||
|
||||
if operation == "claim" and isinstance(error, AmbiguousRemoteError):
|
||||
self.configuration_freeze_changed.emit(True)
|
||||
self._request_stop(
|
||||
PollingState.BLOCKED,
|
||||
"领取响应无法证明可安全定时恢复;原槽已保留,需显式开始后同键恢复。",
|
||||
)
|
||||
return
|
||||
|
||||
if operation == "stop":
|
||||
self._block("停止状态无法安全落库,轮询已阻止;未清除任何恢复事实。")
|
||||
return
|
||||
|
||||
if isinstance(error, CredentialRemoteError):
|
||||
reason = "设备凭据无效或已撤销;修复凭据后再手工开始。"
|
||||
elif isinstance(error, ProtocolRemoteError):
|
||||
reason = "服务响应与固定协议不兼容;已停止普通重试。"
|
||||
elif isinstance(error, ManualRemoteError):
|
||||
reason = "服务端要求人工处理;已停止普通重试。"
|
||||
elif isinstance(error, ClientError):
|
||||
reason = "本地安全状态无法推进;已停止普通重试。"
|
||||
else:
|
||||
reason = "轮询发生未分类错误;已失败闭合。"
|
||||
|
||||
if operation == "claim":
|
||||
# DurableClientGateway 已把协议错误和 409 人工冲突标成 terminal,
|
||||
# 二者没有 pending/active;凭据或本地错误则可能保留 pending,继续冻结。
|
||||
self.configuration_freeze_changed.emit(
|
||||
not isinstance(error, (ProtocolRemoteError, ManualRemoteError))
|
||||
)
|
||||
|
||||
if operation in ("bootstrap", "configure"):
|
||||
self._block(reason)
|
||||
else:
|
||||
self._request_stop(PollingState.BLOCKED, reason)
|
||||
|
||||
def _block(self, reason: str) -> None:
|
||||
self._timer.stop()
|
||||
self._scheduled_epoch = None
|
||||
self._set_state(PollingState.BLOCKED, reason)
|
||||
if self._operation is None:
|
||||
self.settled.emit()
|
||||
|
||||
def _set_state(self, state: PollingState, reason: str) -> None:
|
||||
self.state = state
|
||||
self.reason = reason
|
||||
self.state_changed.emit(state, reason, self._consecutive_failures)
|
||||
@@ -0,0 +1,5 @@
|
||||
"""采购工具原生 Qt Widgets 界面。"""
|
||||
|
||||
from .main_window import PurchaseToolWindow
|
||||
|
||||
__all__ = ["PurchaseToolWindow"]
|
||||
@@ -0,0 +1,419 @@
|
||||
"""采购执行 Tab:状态、当前任务、滚动日志和历史记录主从视图。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtCore import QModelIndex, QSize, Qt, Signal, Slot
|
||||
from PySide6.QtGui import QAction, QKeySequence, QShortcut
|
||||
from PySide6.QtWidgets import (
|
||||
QAbstractItemView,
|
||||
QFrame,
|
||||
QGroupBox,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QMenu,
|
||||
QPlainTextEdit,
|
||||
QPushButton,
|
||||
QSizePolicy,
|
||||
QSplitter,
|
||||
QStackedWidget,
|
||||
QTableView,
|
||||
QToolButton,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from cmbuyer_client.polling.coordinator import ClaimedTaskView, PollingCoordinator, PollingState
|
||||
from cmbuyer_client.logging_policy import redact_text
|
||||
|
||||
from .records import PurchaseRecord, PurchaseRecordModel, PurchaseRecordProvider
|
||||
|
||||
|
||||
class _LogView(QPlainTextEdit):
|
||||
def __init__(self, parent: QWidget | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self.setObjectName("rollingLog")
|
||||
self.setReadOnly(True)
|
||||
self.setPlaceholderText("轮询启动后将在这里显示脱敏日志。")
|
||||
self.setAccessibleName("滚动日志")
|
||||
|
||||
def append_event(self, text: str) -> None:
|
||||
bar = self.verticalScrollBar()
|
||||
follow = bar.value() >= bar.maximum() - 2
|
||||
self.appendPlainText(redact_text(text))
|
||||
if follow:
|
||||
bar.setValue(bar.maximum())
|
||||
|
||||
|
||||
class _RecordTableView(QTableView):
|
||||
"""把双击与 Enter 收敛为唯一 activation 信号,避免平台重复发命令。"""
|
||||
|
||||
record_activated = Signal(object)
|
||||
|
||||
def mouseDoubleClickEvent(self, event) -> None:
|
||||
index = self.indexAt(event.position().toPoint())
|
||||
if index.isValid():
|
||||
self.setCurrentIndex(index.siblingAtColumn(0))
|
||||
self.record_activated.emit(index)
|
||||
event.accept()
|
||||
return
|
||||
super().mouseDoubleClickEvent(event)
|
||||
|
||||
def keyPressEvent(self, event) -> None:
|
||||
if event.key() in (Qt.Key.Key_Return, Qt.Key.Key_Enter) and self.currentIndex().isValid():
|
||||
self.record_activated.emit(self.currentIndex())
|
||||
event.accept()
|
||||
return
|
||||
super().keyPressEvent(event)
|
||||
|
||||
|
||||
class ExecutionPage(QWidget):
|
||||
LIVE_PAGE = 0
|
||||
DETAIL_PAGE = 1
|
||||
COMPACT_DETAIL_WIDTH = 760
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: PollingCoordinator,
|
||||
record_provider: PurchaseRecordProvider | None = None,
|
||||
parent: QWidget | None = None,
|
||||
) -> None:
|
||||
super().__init__(parent)
|
||||
self.setObjectName("executionPage")
|
||||
self._coordinator = coordinator
|
||||
self._selected_record_id: str | None = None
|
||||
self._saved_scroll = 0
|
||||
|
||||
outer = QVBoxLayout(self)
|
||||
outer.setContentsMargins(12, 12, 12, 12)
|
||||
|
||||
status_row = QHBoxLayout()
|
||||
self.service_status = self._status("采购服务", "待首次真实领取验证")
|
||||
self.device_status = self._status("ADB", "待后续执行能力验证")
|
||||
self.app_status = self._status("拼多多版本", "待后续执行能力验证")
|
||||
self.session_status = self._status("会话", "已停止")
|
||||
for widget in (self.service_status, self.device_status, self.app_status, self.session_status):
|
||||
status_row.addWidget(widget)
|
||||
status_row.addStretch(1)
|
||||
|
||||
self.poll_action = QAction("开始轮询", self)
|
||||
self.poll_action.setObjectName("pollAction")
|
||||
self.poll_action.triggered.connect(self._toggle_polling)
|
||||
self.addAction(self.poll_action)
|
||||
self.poll_button = QPushButton()
|
||||
self.poll_button.setObjectName("pollButton")
|
||||
self.poll_button.clicked.connect(self.poll_action.trigger)
|
||||
status_row.addWidget(self.poll_button)
|
||||
outer.addLayout(status_row)
|
||||
|
||||
self.banner = QLabel()
|
||||
self.banner.setObjectName("sessionBanner")
|
||||
self.banner.setWordWrap(True)
|
||||
self.banner.setAccessibleName("轮询会话状态")
|
||||
self.banner.setFrameShape(QFrame.Shape.StyledPanel)
|
||||
outer.addWidget(self.banner)
|
||||
|
||||
self.body_splitter = QSplitter(Qt.Orientation.Horizontal)
|
||||
self.body_splitter.setObjectName("executionSplitter")
|
||||
self.body_splitter.setChildrenCollapsible(False)
|
||||
self.left_stack = QStackedWidget()
|
||||
self.left_stack.setObjectName("leftWorkspace")
|
||||
self.left_stack.addWidget(self._build_live_page())
|
||||
self.left_stack.addWidget(self._build_detail_page())
|
||||
self.body_splitter.addWidget(self.left_stack)
|
||||
self.body_splitter.addWidget(self._build_records_page())
|
||||
self.body_splitter.setStretchFactor(0, 2)
|
||||
self.body_splitter.setStretchFactor(1, 1)
|
||||
self.body_splitter.setSizes([760, 380])
|
||||
outer.addWidget(self.body_splitter, 1)
|
||||
|
||||
self.view_record_action = QAction("查看所选记录", self)
|
||||
self.view_record_action.setObjectName("viewSelectedRecord")
|
||||
self.view_record_action.setEnabled(False)
|
||||
self.view_record_action.triggered.connect(self.open_selected_record)
|
||||
self.addAction(self.view_record_action)
|
||||
self.view_record_button.setDefaultAction(self.view_record_action)
|
||||
|
||||
self.return_action = QAction("返回当前任务", self)
|
||||
self.return_action.setObjectName("returnToCurrentTask")
|
||||
self.return_action.setEnabled(False)
|
||||
self.return_action.triggered.connect(self.return_to_live)
|
||||
self.addAction(self.return_action)
|
||||
self.return_button.setDefaultAction(self.return_action)
|
||||
self.escape_shortcut = QShortcut(QKeySequence(Qt.Key.Key_Escape), self)
|
||||
self.escape_shortcut.setContext(Qt.ShortcutContext.WidgetWithChildrenShortcut)
|
||||
self.escape_shortcut.activated.connect(self._escape)
|
||||
|
||||
self.record_view.clicked.connect(self._on_record_selected)
|
||||
self.record_view.record_activated.connect(self._open_index)
|
||||
self.record_view.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
|
||||
self.record_view.customContextMenuRequested.connect(self._show_record_menu)
|
||||
self.record_view.selectionModel().currentChanged.connect(self._on_current_changed)
|
||||
coordinator.state_changed.connect(self._on_polling_state)
|
||||
coordinator.claim_visible.connect(self._show_claimed_task)
|
||||
self._on_polling_state(coordinator.state, coordinator.reason, coordinator.consecutive_failures)
|
||||
if record_provider is not None:
|
||||
# T-304 没有公共历史仓库;只接受调用方准备好的 View DTO 快照,
|
||||
# 独立应用不注入 provider,模型保持真实空态。
|
||||
self.set_records(record_provider.snapshot())
|
||||
|
||||
@staticmethod
|
||||
def _status(name: str, value: str) -> QLabel:
|
||||
label = QLabel(f"{name}\n{value}")
|
||||
label.setFrameShape(QFrame.Shape.StyledPanel)
|
||||
label.setMinimumWidth(118)
|
||||
label.setAccessibleName(name)
|
||||
return label
|
||||
|
||||
def _build_live_page(self) -> QWidget:
|
||||
page = QWidget()
|
||||
layout = QVBoxLayout(page)
|
||||
task_group = QGroupBox("当前任务")
|
||||
task_layout = QHBoxLayout(task_group)
|
||||
self.current_task_text = QLabel("当前没有任务。\n启动后只领取已授权任务。")
|
||||
self.current_task_text.setObjectName("currentTaskText")
|
||||
self.current_task_text.setWordWrap(True)
|
||||
self.current_task_text.setAlignment(Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignLeft)
|
||||
self.current_task_text.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
|
||||
self.current_image = QLabel("暂无可信商品图片")
|
||||
self.current_image.setObjectName("currentTaskImage")
|
||||
self.current_image.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self.current_image.setFrameShape(QFrame.Shape.StyledPanel)
|
||||
self.current_image.setMinimumSize(QSize(180, 120))
|
||||
self.current_image.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
|
||||
task_layout.addWidget(self.current_task_text, 2)
|
||||
task_layout.addWidget(self.current_image, 1)
|
||||
layout.addWidget(task_group, 1)
|
||||
|
||||
log_group = QGroupBox("滚动日志")
|
||||
log_layout = QVBoxLayout(log_group)
|
||||
self.log_view = _LogView()
|
||||
log_layout.addWidget(self.log_view)
|
||||
layout.addWidget(log_group, 2)
|
||||
return page
|
||||
|
||||
def _build_detail_page(self) -> QWidget:
|
||||
page = QWidget()
|
||||
page.setObjectName("recordDetailPage")
|
||||
layout = QVBoxLayout(page)
|
||||
header = QHBoxLayout()
|
||||
self.detail_title = QLabel("采购记录详情")
|
||||
self.detail_title.setObjectName("recordDetailTitle")
|
||||
self.detail_title.setStyleSheet("font-size: 18px; font-weight: 600;")
|
||||
header.addWidget(self.detail_title)
|
||||
header.addStretch(1)
|
||||
self.return_button = QToolButton()
|
||||
self.return_button.setObjectName("returnCurrentTaskButton")
|
||||
header.addWidget(self.return_button)
|
||||
layout.addLayout(header)
|
||||
|
||||
top = QSplitter(Qt.Orientation.Horizontal)
|
||||
self.detail_original = QPlainTextEdit()
|
||||
self.detail_original.setObjectName("recordOriginalText")
|
||||
self.detail_original.setReadOnly(True)
|
||||
self.detail_original.setPlaceholderText("没有可显示的原始文字。")
|
||||
self.detail_image = QLabel("没有可显示的可信图片")
|
||||
self.detail_image.setObjectName("recordImage")
|
||||
self.detail_image.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self.detail_image.setFrameShape(QFrame.Shape.StyledPanel)
|
||||
top.addWidget(self.detail_original)
|
||||
top.addWidget(self.detail_image)
|
||||
top.setStretchFactor(0, 2)
|
||||
top.setStretchFactor(1, 1)
|
||||
layout.addWidget(top, 2)
|
||||
|
||||
result_group = QGroupBox("采购结果")
|
||||
result_layout = QVBoxLayout(result_group)
|
||||
self.detail_result = QPlainTextEdit()
|
||||
self.detail_result.setObjectName("recordResult")
|
||||
self.detail_result.setReadOnly(True)
|
||||
self.detail_result.setPlaceholderText("暂无采购结果。")
|
||||
result_layout.addWidget(self.detail_result)
|
||||
layout.addWidget(result_group, 1)
|
||||
return page
|
||||
|
||||
def _build_records_page(self) -> QWidget:
|
||||
page = QGroupBox("采购记录")
|
||||
page.setObjectName("recordsPanel")
|
||||
self.records_panel = page
|
||||
layout = QVBoxLayout(page)
|
||||
header = QHBoxLayout()
|
||||
self.records_summary = QLabel("暂无记录")
|
||||
header.addWidget(self.records_summary)
|
||||
header.addStretch(1)
|
||||
self.view_record_button = QToolButton()
|
||||
self.view_record_button.setObjectName("viewSelectedRecordButton")
|
||||
header.addWidget(self.view_record_button)
|
||||
layout.addLayout(header)
|
||||
self.record_model = PurchaseRecordModel(parent=self)
|
||||
self.record_view = _RecordTableView()
|
||||
self.record_view.setObjectName("purchaseRecordTable")
|
||||
self.record_view.setModel(self.record_model)
|
||||
self.record_view.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
|
||||
self.record_view.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
|
||||
self.record_view.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
|
||||
self.record_view.setAlternatingRowColors(True)
|
||||
self.record_view.setSortingEnabled(False)
|
||||
self.record_view.horizontalHeader().setStretchLastSection(False)
|
||||
self.record_view.horizontalHeader().setSectionResizeMode(0, self.record_view.horizontalHeader().ResizeMode.Stretch)
|
||||
self.record_view.horizontalHeader().setSectionResizeMode(1, self.record_view.horizontalHeader().ResizeMode.ResizeToContents)
|
||||
self.record_view.verticalHeader().setVisible(False)
|
||||
layout.addWidget(self.record_view)
|
||||
return page
|
||||
|
||||
def set_records(self, records: list[PurchaseRecord]) -> None:
|
||||
selected_id = self._selected_record_id
|
||||
self.record_model.set_records(records)
|
||||
self.records_summary.setText(f"共 {len(records)} 条" if records else "暂无记录")
|
||||
if selected_id is not None:
|
||||
row = self.record_model.row_for_id(selected_id)
|
||||
if row >= 0:
|
||||
self.record_view.setCurrentIndex(self.record_model.index(row, 0))
|
||||
if self.left_stack.currentIndex() == self.DETAIL_PAGE:
|
||||
self._render_record(self.record_model.record_at(row))
|
||||
return
|
||||
self._selected_record_id = None
|
||||
self.view_record_action.setEnabled(False)
|
||||
if self.left_stack.currentIndex() == self.DETAIL_PAGE:
|
||||
self.return_to_live()
|
||||
|
||||
@Slot(object, str, int)
|
||||
def _on_polling_state(self, state: object, reason: str, failures: int) -> None:
|
||||
polling_state = state if isinstance(state, PollingState) else PollingState.BLOCKED
|
||||
self.session_status.setText(f"会话\n{self._state_text(polling_state)}")
|
||||
suffix = f"(连续失败 {failures} 次)" if failures else ""
|
||||
self.banner.setText(reason + suffix)
|
||||
running = polling_state in (
|
||||
PollingState.STARTING,
|
||||
PollingState.RECOVERING,
|
||||
PollingState.WAITING,
|
||||
PollingState.CLAIMING,
|
||||
PollingState.ACTIVE,
|
||||
)
|
||||
self.poll_action.setText("停止轮询" if running else "开始轮询")
|
||||
self.poll_action.setEnabled(running or self._coordinator.can_start)
|
||||
self.poll_button.setText(self.poll_action.text())
|
||||
self.poll_button.setEnabled(self.poll_action.isEnabled())
|
||||
self.poll_button.setToolTip("" if self.poll_action.isEnabled() else reason)
|
||||
|
||||
@staticmethod
|
||||
def _state_text(state: PollingState) -> str:
|
||||
return {
|
||||
PollingState.STOPPED: "已停止",
|
||||
PollingState.STARTING: "启动中",
|
||||
PollingState.BLOCKED: "已阻止",
|
||||
PollingState.RECOVERING: "安全恢复",
|
||||
PollingState.WAITING: "等待领取",
|
||||
PollingState.CLAIMING: "正在领取",
|
||||
PollingState.ACTIVE: "任务执行中",
|
||||
PollingState.RECOVERY_REQUIRED: "待安全恢复",
|
||||
}[state]
|
||||
|
||||
@Slot()
|
||||
def _toggle_polling(self) -> None:
|
||||
if self._coordinator.state in (
|
||||
PollingState.STARTING,
|
||||
PollingState.RECOVERING,
|
||||
PollingState.WAITING,
|
||||
PollingState.CLAIMING,
|
||||
PollingState.ACTIVE,
|
||||
):
|
||||
self._coordinator.stop()
|
||||
else:
|
||||
self._coordinator.start()
|
||||
|
||||
@Slot(QModelIndex)
|
||||
def _on_record_selected(self, index: QModelIndex) -> None:
|
||||
record = self.record_model.record_at(index.row())
|
||||
if record is None:
|
||||
return
|
||||
self._selected_record_id = record.record_id
|
||||
self.view_record_action.setEnabled(True)
|
||||
if self.left_stack.currentIndex() == self.DETAIL_PAGE:
|
||||
self._saved_scroll = self.record_view.verticalScrollBar().value()
|
||||
self._render_record(record)
|
||||
|
||||
@Slot(object)
|
||||
def _show_claimed_task(self, raw: object) -> None:
|
||||
if not isinstance(raw, ClaimedTaskView):
|
||||
return
|
||||
self.current_task_text.setText(
|
||||
f"标题:{raw.title}\n任务 ID:{raw.task_id}\n状态:{raw.status}"
|
||||
)
|
||||
self.log_view.append_event(f"已安全领取任务 {raw.task_id};等待单趟执行能力处理。")
|
||||
|
||||
@Slot(QModelIndex, QModelIndex)
|
||||
def _on_current_changed(self, current: QModelIndex, previous: QModelIndex) -> None:
|
||||
del previous
|
||||
if current.isValid():
|
||||
self._on_record_selected(current)
|
||||
|
||||
@Slot(QModelIndex)
|
||||
def _open_index(self, index: QModelIndex) -> None:
|
||||
if index.isValid():
|
||||
self.record_view.setCurrentIndex(index.siblingAtColumn(0))
|
||||
self._on_record_selected(index)
|
||||
self.view_record_action.trigger()
|
||||
|
||||
@Slot()
|
||||
def open_selected_record(self) -> None:
|
||||
if self._selected_record_id is None:
|
||||
return
|
||||
row = self.record_model.row_for_id(self._selected_record_id)
|
||||
record = self.record_model.record_at(row)
|
||||
if record is None:
|
||||
return
|
||||
self._saved_scroll = self.record_view.verticalScrollBar().value()
|
||||
self._render_record(record)
|
||||
self.left_stack.setCurrentIndex(self.DETAIL_PAGE)
|
||||
self.return_action.setEnabled(True)
|
||||
self._apply_compact_detail()
|
||||
self.return_button.setFocus()
|
||||
|
||||
def _render_record(self, record: PurchaseRecord | None) -> None:
|
||||
if record is None:
|
||||
self.detail_title.setText("记录不存在")
|
||||
self.detail_original.clear()
|
||||
self.detail_result.clear()
|
||||
self.detail_image.setText("没有可显示的可信图片")
|
||||
return
|
||||
self.detail_title.setText(record.title)
|
||||
self.detail_original.setPlainText(record.original_text)
|
||||
self.detail_result.setPlainText(record.result_text)
|
||||
self.detail_image.setText(record.image_description or "没有可显示的可信图片")
|
||||
|
||||
@Slot()
|
||||
def return_to_live(self) -> None:
|
||||
if self.left_stack.currentIndex() != self.DETAIL_PAGE:
|
||||
return
|
||||
self.left_stack.setCurrentIndex(self.LIVE_PAGE)
|
||||
self.return_action.setEnabled(False)
|
||||
self.records_panel.setVisible(True)
|
||||
row = self.record_model.row_for_id(self._selected_record_id or "")
|
||||
if row >= 0:
|
||||
self.record_view.setCurrentIndex(self.record_model.index(row, 0))
|
||||
self.record_view.verticalScrollBar().setValue(self._saved_scroll)
|
||||
self.record_view.setFocus()
|
||||
|
||||
@Slot()
|
||||
def _escape(self) -> None:
|
||||
# Qt popup/menu 优先消费 Esc;只有详情态的页面级 shortcut 会执行返回。
|
||||
if self.left_stack.currentIndex() == self.DETAIL_PAGE:
|
||||
self.return_action.trigger()
|
||||
|
||||
@Slot(object)
|
||||
def _show_record_menu(self, point: object) -> None:
|
||||
index = self.record_view.indexAt(point)
|
||||
if index.isValid():
|
||||
self.record_view.setCurrentIndex(index.siblingAtColumn(0))
|
||||
self._on_record_selected(index)
|
||||
menu = QMenu(self.record_view)
|
||||
menu.addAction(self.view_record_action)
|
||||
menu.exec(self.record_view.viewport().mapToGlobal(point))
|
||||
|
||||
def resizeEvent(self, event) -> None:
|
||||
super().resizeEvent(event)
|
||||
self._apply_compact_detail()
|
||||
|
||||
def _apply_compact_detail(self) -> None:
|
||||
compact_detail = self.width() < self.COMPACT_DETAIL_WIDTH and self.left_stack.currentIndex() == self.DETAIL_PAGE
|
||||
self.records_panel.setVisible(not compact_detail)
|
||||
@@ -0,0 +1,95 @@
|
||||
"""采购工具固定双 Tab 原生窗口。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtCore import QTimer, Qt, Slot
|
||||
from PySide6.QtGui import QCloseEvent
|
||||
from PySide6.QtWidgets import QMainWindow, QTabWidget
|
||||
|
||||
from cmbuyer_client.localstate.models import ProfileSettings
|
||||
from cmbuyer_client.polling.coordinator import PollingCoordinator, PollingState, RecoveryStatus
|
||||
|
||||
from .execution import ExecutionPage
|
||||
from .records import PurchaseRecordProvider
|
||||
from .settings import ProfileStore, SettingsPage
|
||||
|
||||
|
||||
class PurchaseToolWindow(QMainWindow):
|
||||
EXECUTION_PAGE_ID = "purchase-execution"
|
||||
SETTINGS_PAGE_ID = "settings"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
store: ProfileStore,
|
||||
coordinator: PollingCoordinator,
|
||||
profile_settings: ProfileSettings | None,
|
||||
has_stored_device_token: bool,
|
||||
record_provider: PurchaseRecordProvider | None = None,
|
||||
parent=None,
|
||||
) -> None:
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("采购工具")
|
||||
self.setAccessibleName("采购工具")
|
||||
self.setMinimumSize(720, 520)
|
||||
self.resize(1180, 760)
|
||||
self._coordinator = coordinator
|
||||
self._close_pending = False
|
||||
|
||||
recovery = coordinator.recovery_status
|
||||
frozen = recovery is None or recovery.has_pending_claim or recovery.has_active_claim
|
||||
self.tabs = QTabWidget()
|
||||
self.tabs.setObjectName("mainTabs")
|
||||
self.tabs.setTabsClosable(False)
|
||||
self.tabs.setMovable(False)
|
||||
self.execution_page = ExecutionPage(coordinator, record_provider)
|
||||
self.execution_page.setProperty("pageId", self.EXECUTION_PAGE_ID)
|
||||
self.settings_page = SettingsPage(
|
||||
store,
|
||||
settings=profile_settings,
|
||||
has_stored_device_token=has_stored_device_token,
|
||||
identity_frozen=frozen,
|
||||
)
|
||||
self.settings_page.setProperty("pageId", self.SETTINGS_PAGE_ID)
|
||||
self.tabs.addTab(self.execution_page, "采购执行")
|
||||
self.tabs.addTab(self.settings_page, "配置")
|
||||
self.tabs.setCurrentWidget(self.execution_page)
|
||||
self.setCentralWidget(self.tabs)
|
||||
|
||||
self.settings_page.settings_saved.connect(
|
||||
lambda settings, has_token: self._coordinator.update_profile_settings(settings)
|
||||
)
|
||||
coordinator.recovery_status_changed.connect(self._on_recovery_status)
|
||||
coordinator.configuration_freeze_changed.connect(self.settings_page.set_identity_frozen)
|
||||
coordinator.settled.connect(self._finish_pending_close)
|
||||
|
||||
@Slot(object)
|
||||
def _on_recovery_status(self, raw: object) -> None:
|
||||
if not isinstance(raw, RecoveryStatus):
|
||||
self.settings_page.set_identity_frozen(True)
|
||||
return
|
||||
self.settings_page.set_identity_frozen(raw.has_pending_claim or raw.has_active_claim)
|
||||
|
||||
def closeEvent(self, event: QCloseEvent) -> None:
|
||||
running = self._coordinator.state in (
|
||||
PollingState.STARTING,
|
||||
PollingState.RECOVERING,
|
||||
PollingState.WAITING,
|
||||
PollingState.CLAIMING,
|
||||
PollingState.ACTIVE,
|
||||
) or self._coordinator.operation_in_flight
|
||||
if running:
|
||||
# 不 terminate 飞行中的 QThread。先提升 stop latch,等待 HTTP 自身
|
||||
# 超时和 DurableClientGateway 落库,再由 settled 重试关闭。
|
||||
self._close_pending = True
|
||||
self._coordinator.stop()
|
||||
event.ignore()
|
||||
return
|
||||
event.accept()
|
||||
|
||||
@Slot()
|
||||
def _finish_pending_close(self) -> None:
|
||||
if not self._close_pending:
|
||||
return
|
||||
self._close_pending = False
|
||||
QTimer.singleShot(0, self.close)
|
||||
@@ -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)
|
||||
@@ -0,0 +1,247 @@
|
||||
"""只做本地校验和显式保存的配置页。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Protocol
|
||||
|
||||
from PySide6.QtCore import QRegularExpression, Qt, Signal, Slot
|
||||
from PySide6.QtGui import QAction, QKeySequence, QRegularExpressionValidator
|
||||
from PySide6.QtWidgets import (
|
||||
QComboBox,
|
||||
QFormLayout,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QPushButton,
|
||||
QScrollArea,
|
||||
QSpinBox,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from cmbuyer_client.core.models import SecretToken
|
||||
from cmbuyer_client.core.errors import ValidationError
|
||||
from cmbuyer_client.core.validation import require_uuid4
|
||||
from cmbuyer_client.localstate.models import LOOPBACK_SERVICE_URL, ProfileSettings
|
||||
|
||||
|
||||
class ProfileStore(Protocol):
|
||||
def save_profile(self, settings: ProfileSettings, token: SecretToken | None) -> None: ...
|
||||
|
||||
|
||||
class SettingsPage(QScrollArea):
|
||||
settings_saved = Signal(object, bool)
|
||||
VALIDATION_HINT = "服务身份将在首次真实领取时验证;设备与 App 状态由后续已取证执行能力验证。"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
store: ProfileStore,
|
||||
*,
|
||||
profile_id: str = "default",
|
||||
settings: ProfileSettings | None = None,
|
||||
has_stored_device_token: bool = False,
|
||||
identity_frozen: bool = False,
|
||||
parent: QWidget | None = None,
|
||||
) -> None:
|
||||
super().__init__(parent)
|
||||
self.setObjectName("settingsPage")
|
||||
self.setWidgetResizable(True)
|
||||
self._store = store
|
||||
self._profile_id = profile_id
|
||||
self._has_stored_device_token = has_stored_device_token
|
||||
self._loaded_settings = settings
|
||||
self._identity_frozen = identity_frozen
|
||||
|
||||
content = QWidget()
|
||||
outer = QVBoxLayout(content)
|
||||
title = QLabel("配置")
|
||||
title.setObjectName("settingsTitle")
|
||||
title.setStyleSheet("font-size: 20px; font-weight: 600;")
|
||||
outer.addWidget(title)
|
||||
|
||||
form = QFormLayout()
|
||||
form.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.ExpandingFieldsGrow)
|
||||
form.setLabelAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
|
||||
outer.addLayout(form)
|
||||
|
||||
self.service_url = QLineEdit(LOOPBACK_SERVICE_URL)
|
||||
self.service_url.setReadOnly(True)
|
||||
self.service_url.setObjectName("serviceUrl")
|
||||
form.addRow("采购服务 URL", self.service_url)
|
||||
|
||||
self.device_id = QLineEdit()
|
||||
self.device_id.setObjectName("deviceId")
|
||||
self.device_id.setPlaceholderText("小写 UUIDv4")
|
||||
form.addRow("设备 UUID", self.device_id)
|
||||
|
||||
self.device_token = QLineEdit()
|
||||
self.device_token.setObjectName("deviceToken")
|
||||
self.device_token.setEchoMode(QLineEdit.EchoMode.Password)
|
||||
self.device_token.setMaxLength(64)
|
||||
self.device_token.setPlaceholderText("首次必填;已有凭据时留空表示保留")
|
||||
self.device_token.setValidator(QRegularExpressionValidator(QRegularExpression("[0-9a-f]{0,64}"), self))
|
||||
form.addRow("设备 token", self.device_token)
|
||||
|
||||
self.token_status = QLabel()
|
||||
self.token_status.setObjectName("tokenStatus")
|
||||
form.addRow("凭据状态", self.token_status)
|
||||
|
||||
self.adb_path = QLineEdit()
|
||||
self.adb_path.setObjectName("adbPath")
|
||||
form.addRow("ADB 路径", self.adb_path)
|
||||
|
||||
self.adb_serial = QLineEdit()
|
||||
self.adb_serial.setObjectName("adbSerial")
|
||||
form.addRow("设备 serial", self.adb_serial)
|
||||
|
||||
self.transport = QComboBox()
|
||||
self.transport.setObjectName("transport")
|
||||
self.transport.addItem("USB", "usb")
|
||||
self.transport.addItem("WiFi", "wifi")
|
||||
form.addRow("连接方式", self.transport)
|
||||
|
||||
self.poll_interval = self._spin(5, 300, 15, "pollInterval", " 秒")
|
||||
form.addRow("轮询间隔", self.poll_interval)
|
||||
self.failure_threshold = self._spin(1, 10, 3, "failureThreshold", " 次")
|
||||
form.addRow("连续失败停止阈值", self.failure_threshold)
|
||||
self.http_timeout = self._spin(1, 120, 10, "httpTimeout", " 秒")
|
||||
form.addRow("HTTP 超时", self.http_timeout)
|
||||
self.step_timeout = self._spin(5, 300, 45, "stepTimeout", " 秒")
|
||||
form.addRow("真机步骤超时", self.step_timeout)
|
||||
|
||||
self.validation_hint = QLabel(self.VALIDATION_HINT)
|
||||
self.validation_hint.setObjectName("deferredValidationHint")
|
||||
self.validation_hint.setWordWrap(True)
|
||||
outer.addWidget(self.validation_hint)
|
||||
|
||||
self.feedback = QLabel()
|
||||
self.feedback.setObjectName("settingsFeedback")
|
||||
self.feedback.setWordWrap(True)
|
||||
self.feedback.setAccessibleName("配置保存状态")
|
||||
outer.addWidget(self.feedback)
|
||||
|
||||
self.save_action = QAction("保存配置", self)
|
||||
self.save_action.setShortcut(QKeySequence.StandardKey.Save)
|
||||
self.save_action.triggered.connect(self.save)
|
||||
self.addAction(self.save_action)
|
||||
self.save_button = QPushButton("保存配置")
|
||||
self.save_button.setObjectName("saveSettings")
|
||||
self.save_button.clicked.connect(self.save_action.trigger)
|
||||
outer.addWidget(self.save_button, 0, Qt.AlignmentFlag.AlignRight)
|
||||
outer.addStretch(1)
|
||||
self.setWidget(content)
|
||||
|
||||
if settings is not None:
|
||||
self._load(settings)
|
||||
self._update_token_status()
|
||||
self.set_identity_frozen(identity_frozen)
|
||||
|
||||
@staticmethod
|
||||
def _spin(minimum: int, maximum: int, value: int, name: str, suffix: str) -> QSpinBox:
|
||||
field = QSpinBox()
|
||||
field.setObjectName(name)
|
||||
field.setRange(minimum, maximum)
|
||||
field.setValue(value)
|
||||
field.setSuffix(suffix)
|
||||
return field
|
||||
|
||||
def _load(self, settings: ProfileSettings) -> None:
|
||||
self.device_id.setText(settings.device_id)
|
||||
self.adb_path.setText(settings.adb_path)
|
||||
self.adb_serial.setText(settings.adb_serial)
|
||||
self.transport.setCurrentIndex(max(0, self.transport.findData(settings.transport)))
|
||||
self.poll_interval.setValue(settings.poll_interval_seconds)
|
||||
self.failure_threshold.setValue(settings.failure_threshold)
|
||||
self.http_timeout.setValue(settings.http_timeout_seconds)
|
||||
self.step_timeout.setValue(settings.step_timeout_seconds)
|
||||
|
||||
def set_identity_frozen(self, frozen: bool) -> None:
|
||||
self._identity_frozen = frozen
|
||||
for field in (
|
||||
self.device_id,
|
||||
self.adb_path,
|
||||
self.adb_serial,
|
||||
self.transport,
|
||||
self.poll_interval,
|
||||
self.failure_threshold,
|
||||
self.http_timeout,
|
||||
self.step_timeout,
|
||||
):
|
||||
field.setEnabled(not frozen)
|
||||
if frozen:
|
||||
self.feedback.setText("存在待恢复或执行中的领取;服务与设备身份参数已冻结。")
|
||||
elif self.feedback.text().startswith("存在待恢复或执行中的领取"):
|
||||
self.feedback.clear()
|
||||
|
||||
@Slot()
|
||||
def save(self) -> None:
|
||||
self.feedback.clear()
|
||||
token_text = self.device_token.text().strip()
|
||||
device_id = self.device_id.text().strip()
|
||||
try:
|
||||
require_uuid4(device_id, "invalid_device_id")
|
||||
except (TypeError, ValueError, ValidationError):
|
||||
self._validation_error(self.device_id, "设备 UUID 必须是小写 UUIDv4。")
|
||||
return
|
||||
if not self._has_stored_device_token and not token_text:
|
||||
self._validation_error(self.device_token, "首次保存必须填写设备 token。")
|
||||
return
|
||||
if token_text and (len(token_text) != 64 or any(character not in "0123456789abcdef" for character in token_text)):
|
||||
self._validation_error(self.device_token, "设备 token 必须为 64 位小写十六进制。")
|
||||
return
|
||||
try:
|
||||
if self._identity_frozen:
|
||||
if self._loaded_settings is None:
|
||||
self._validation_error(self.device_id, "冻结配置缺少原始设置,不能保存。")
|
||||
return
|
||||
settings = self._loaded_settings
|
||||
else:
|
||||
adb_text = self.adb_path.text().strip()
|
||||
if not adb_text:
|
||||
self._validation_error(self.adb_path, "请填写 ADB 路径。")
|
||||
return
|
||||
adb_file = Path(adb_text).expanduser()
|
||||
if not adb_file.is_file():
|
||||
self._validation_error(self.adb_path, "ADB 路径必须指向本机已存在的文件。")
|
||||
return
|
||||
adb_file = adb_file.resolve(strict=True)
|
||||
serial = self.adb_serial.text().strip()
|
||||
if not serial:
|
||||
self._validation_error(self.adb_serial, "请填写设备 serial。")
|
||||
return
|
||||
settings = ProfileSettings(
|
||||
profile_id=self._profile_id,
|
||||
service_url=LOOPBACK_SERVICE_URL,
|
||||
device_id=device_id,
|
||||
adb_path=str(adb_file),
|
||||
adb_serial=serial,
|
||||
transport=str(self.transport.currentData()),
|
||||
poll_interval_seconds=self.poll_interval.value(),
|
||||
failure_threshold=self.failure_threshold.value(),
|
||||
http_timeout_seconds=self.http_timeout.value(),
|
||||
step_timeout_seconds=self.step_timeout.value(),
|
||||
)
|
||||
token = SecretToken(token_text) if token_text else None
|
||||
except (TypeError, ValueError):
|
||||
self._validation_error(self.device_id, "配置格式不正确,请检查设备 UUID 与各项参数。")
|
||||
return
|
||||
try:
|
||||
# None 明确表示保留 T-303 中已有的 DPAPI 密文,绝不是清除凭据。
|
||||
self._store.save_profile(settings, token)
|
||||
except Exception:
|
||||
self.feedback.setText("配置保存失败,本地安全存储未更新。")
|
||||
(self.device_token if token_text else self.device_id).setFocus()
|
||||
return
|
||||
self._has_stored_device_token = True
|
||||
self._loaded_settings = settings
|
||||
self.device_token.clear()
|
||||
self._update_token_status()
|
||||
self.feedback.setText("配置已保存。本地校验不代表服务、设备或 App 已就绪。")
|
||||
self.settings_saved.emit(settings, True)
|
||||
|
||||
def _validation_error(self, field: QWidget, message: str) -> None:
|
||||
self.feedback.setText(message)
|
||||
field.setFocus()
|
||||
|
||||
def _update_token_status(self) -> None:
|
||||
self.token_status.setText("已保存" if self._has_stored_device_token else "未保存")
|
||||
Reference in New Issue
Block a user