refactor(dashboard): R08-T13 — dashboard_tab.py 438 -> 215, tách 3 widget

token_usage_card_widget.py    93   5 thẻ số liệu + thẻ Ngân sách
    usage_chart_widget.py        119   biểu đồ tuần/tháng/năm + đường so sánh
    habits_widget.py             171   thói quen dùng token + nhận xét của AI

Ba widget THẬT, không phải mixin — khác với shell và Co4E, ba mảng này tách
bạch trên màn hình và không đọc state của nhau. Giao tiếp bằng signal:
budget_applied, filter_changed, status_message.

Điểm cần biết: các nút lật khoảng và hai ô chọn thuộc về UsageChartWidget
nhưng được Dashboard nhấc lên hàng điều khiển ở trên. Chúng là control của
biểu đồ, chỉ hiển thị ở chỗ khác.

Giữ 18 cầu tương thích cho tên cũ vì check_dashboard, check_design_parity và
check_controls_alive đọc thẳng self.card_total, self._chart_period_lbl...

756 test xanh. check_dashboard, check_design_parity, check_controls_alive qua.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Nam Pham Dinh Thanh
2026-08-27 22:05:48 +09:00
co-authored by Claude Opus 5
parent f8e22f5f5b
commit 062ea4ba21
4 changed files with 509 additions and 345 deletions
@@ -0,0 +1,93 @@
"""Hàng thẻ số liệu trên đầu Dashboard — R08-T13.
Năm con số (tổng / vào / ra / cache / chi phí) cộng thẻ Ngân sách.
Bố cục không phải sáu ô bằng nhau: chi phí là con số màn hình này sinh ra để
trả lời, nên nó chiếm một thẻ cao gấp đôi bên trái, bốn con số phụ xếp 2×2
bên cạnh. Sáu ô bằng nhau thì không có gì nói cho người dùng biết cái nào
đáng nhìn trước.
"""
from __future__ import annotations
from PySide6.QtCore import Signal
from PySide6.QtWidgets import QGridLayout, QWidget
from ...core import usage_tracker as ut
from ...i18n import tr
from ...ui.icons import icon
from ...ui.widgets import BudgetCard, StatCard
from ...ui.widgets import fmt_tokens as _fmt_tokens
class TokenUsageCardWidget(QWidget):
#: Người dùng bấm Áp dụng trên thẻ Ngân sách. Widget không tự ghi cấu hình
#: — nó chỉ báo; ai sở hữu cấu hình thì người đó ghi.
budget_applied = Signal(float)
def __init__(self, parent=None):
super().__init__(parent)
grid = QGridLayout(self)
grid.setContentsMargins(0, 0, 0, 0)
grid.setSpacing(8)
self.card_total = StatCard()
self.card_in = StatCard()
self.card_out = StatCard()
self.card_cache = StatCard()
self.card_cost = StatCard().as_hero()
# Thẻ chi phí bên trái, chiếm cả hai hàng; bốn con số phụ lấp khối 2×2.
grid.addWidget(self.card_cost, 0, 0, 2, 1)
for i, card in enumerate((self.card_total, self.card_in,
self.card_out, self.card_cache)):
grid.addWidget(card, i // 2, 1 + i % 2)
self.budget_card = BudgetCard()
self.budget_card.apply_btn.setIcon(icon("check"))
self.budget_card.apply_btn.clicked.connect(
lambda: self.budget_applied.emit(self.budget_card.budget_spin.value()))
grid.addWidget(self.budget_card, 0, 3, 2, 1)
# Cột thẻ chi phí và cột Ngân sách rộng hơn bốn ô nhỏ.
for col, stretch in ((0, 3), (1, 2), (2, 2), (3, 3)):
grid.setColumnStretch(col, stretch)
# ---- nạp số liệu -----------------------------------------------------
def set_usage(self, s: dict, costs: dict, pricing: dict) -> None:
est_note = (tr("dashboard.estimated_note", pct=int(s["estimated_share"] * 100))
if s["estimated_share"] > 0 else "")
self.card_total.set(tr("dashboard.card_total"), _fmt_tokens(s["total"]),
tr("dashboard.card_turns", n=s["turns"]))
self.card_in.set(tr("dashboard.card_in"), _fmt_tokens(s["in"]),
ut.format_cost(costs["in"], pricing))
self.card_out.set(tr("dashboard.card_out"), _fmt_tokens(s["out"]),
ut.format_cost(costs["out"], pricing))
self.card_cache.set(tr("dashboard.card_cache"), _fmt_tokens(s["cache"]),
ut.format_cost(costs["cache"], pricing))
self.card_cost.set(tr("dashboard.card_cost"),
ut.format_cost(sum(costs.values()), pricing, digits=2),
est_note)
def set_budget(self, status, pricing: dict, convert) -> None:
"""``status`` là None khi người dùng chưa đặt ngân sách nào."""
if status is None:
self.budget_card.set(tr("usage.budget_title"), "—",
tr("usage.budget_no_budget"))
self.budget_card.budget_spin.setValue(0.0)
return
ccy = pricing.get("currency", "USD")
value = ("%s / %s" % (ut.format_cost(status["remaining_usd"], pricing, digits=2),
ut.format_cost(status["amount_usd"], pricing, digits=2)))
pct = int(round(status["pct_used"] * 100))
sub = (tr("usage.budget_over_warning") if status["over_85"]
else tr("usage.budget_used_pct", pct=pct))
self.budget_card.set(tr("usage.budget_title"), value, sub,
warn=status["over_85"])
# Ô nhập giữ giá trị ngân sách HIỆN TẠI, nhưng không giẫm lên thứ người
# dùng đang gõ dở.
if not self.budget_card.budget_spin.hasFocus():
self.budget_card.budget_spin.setValue(
round(convert(status["amount_usd"], "USD", ccy), 2))