Files
cowork-local/ui/dashboard_tab.py
T
Nam Pham Dinh ThanhandClaude Opus 5 062ea4ba21 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>
2026-08-27 22:05:48 +09:00

220 lines
10 KiB
Python

"""Màn Dashboard — khung lắp ráp (R08-T13).
Ba mảng đã bóc sang ``presentation/dashboard/``:
token_usage_card_widget.py 5 thẻ số liệu + thẻ Ngân sách
usage_chart_widget.py biểu đồ theo tuần / tháng / năm
habits_widget.py thói quen dùng token + nhận xét của AI
File này còn ba việc: dựng hàng điều khiển (bộ lọc khoảng áp cho CẢ màn — thẻ,
biểu đồ và thói quen đều đi theo nó), nạp dữ liệu một lần rồi chia cho ba
widget, và tự làm mới mỗi 30 giây.
"""
from __future__ import annotations
from datetime import timedelta
from typing import Dict
from PySide6.QtCore import Qt, QTimer, Signal
from PySide6.QtWidgets import (
QComboBox, QHBoxLayout, QLabel, QPushButton, QScrollArea, QVBoxLayout,
QWidget,
)
from ..core import usage_tracker as ut
from ..i18n import on_language_changed, tr
from ..presentation.dashboard.habits_widget import HabitsWidget
from ..presentation.dashboard.token_usage_card_widget import TokenUsageCardWidget
from ..presentation.dashboard.usage_chart_widget import UsageChartWidget
from ..state import AppContext
from .icons import icon
class DashboardTab(QWidget):
status_message = Signal(str)
_PERIODS = ("today", "week", "month", "all")
def __init__(self, ctx: AppContext):
super().__init__()
self.ctx = ctx
outer = QVBoxLayout(self)
scroll = QScrollArea()
scroll.setWidgetResizable(True)
scroll.setFrameShape(QScrollArea.NoFrame)
content = QWidget()
scroll.setWidget(content)
outer.addWidget(scroll)
root = QVBoxLayout(content)
self.cards = TokenUsageCardWidget()
self.chart_panel = UsageChartWidget()
self.habits_panel = HabitsWidget(ctx)
# ---- hàng tiêu đề ------------------------------------------------
head = QHBoxLayout()
self._title = QLabel()
self._title.setStyleSheet("font-weight:700; font-size:15px;")
self.refresh_btn = QPushButton("")
self.refresh_btn.setIcon(icon("refresh"))
self.refresh_btn.setFixedWidth(34)
self.refresh_btn.clicked.connect(self.refresh)
head.addWidget(self._title, 1)
head.addWidget(self.refresh_btn)
root.addLayout(head)
# ---- hàng điều khiển ---------------------------------------------
# Hai hàng, gom theo việc control làm gì, thay vì chín widget xâu trên
# một dòng nơi tiêu đề, bộ lật ngày, hai ô chọn biểu đồ, ô chọn tiền tệ
# và nút Làm mới đọc thành một dải không phân biệt được.
# Hàng 1 là "tôi đang ở đâu"; hàng 2 là "tôi đang xem cái gì".
for g in ("week", "month", "year"):
self.chart_panel.gran_combo.addItem(tr("dashboard.gran_%s" % g), g)
self.chart_panel.gran_combo.currentIndexChanged.connect(self._on_gran_changed)
for m in ("cost", "tokens"):
self.chart_panel.metric_combo.addItem(tr("dashboard.metric_%s" % m), m)
self.chart_panel.filter_changed.connect(self.refresh)
# Ô chọn tiền hiển thị — dời từ thẻ Token Usage của Monitoring sang đây,
# ngay cạnh Làm mới; hai màn vẫn dùng chung khoá usage.currency nên đổi
# ở đây là đổi cả hai nơi.
self.currency_lbl = QLabel()
self.currency_lbl.setObjectName("hint")
self.currency_combo = QComboBox()
for cur in ut.SUPPORTED_CURRENCIES:
self.currency_combo.addItem(cur, cur)
idx = self.currency_combo.findData(
(self.ctx.config.data.get("usage") or {}).get("currency", "USD"))
self.currency_combo.setCurrentIndex(max(0, idx))
self.currency_combo.currentIndexChanged.connect(self._on_currency_changed)
controls = QHBoxLayout()
controls.setSpacing(6)
controls.addWidget(self.chart_panel.prev_btn) # lật khoảng
controls.addWidget(self.chart_panel.period_lbl)
controls.addWidget(self.chart_panel.next_btn)
controls.addSpacing(12)
controls.addWidget(self.chart_panel.gran_combo) # biểu đồ vẽ gì
controls.addWidget(self.chart_panel.metric_combo)
controls.addStretch(1)
controls.addWidget(self.currency_lbl) # tiền hiện thế nào
controls.addWidget(self.currency_combo)
root.addLayout(controls)
# ---- ba mảng nội dung --------------------------------------------
root.addWidget(self.cards)
root.addWidget(self.chart_panel)
self.habits_panel.habits.setMinimumHeight(160)
self.habits_panel.ai_advice.setMinimumHeight(140)
self.habits_panel.ai_advice.setOpenExternalLinks(False)
self.habits_panel._khoang = self._period_range
root.addWidget(self.habits_panel, 1)
self.cards.budget_applied.connect(self._apply_budget)
self.habits_panel.status_message.connect(self.status_message)
# Tự làm mới mỗi 30 giây để con số đi theo việc đang chạy.
self._timer = QTimer(self)
self._timer.setInterval(30_000)
self._timer.timeout.connect(self.refresh)
self._timer.start()
on_language_changed(self._retranslate)
self.refresh()
# ---- cầu tương thích -------------------------------------------------
# Ba checker trong tools/ đọc thẳng tên cũ. Giữ nguyên đường vào; nơi ở
# thật của chúng nay là ba widget con.
card_total = property(lambda self: self.cards.card_total)
card_in = property(lambda self: self.cards.card_in)
card_out = property(lambda self: self.cards.card_out)
card_cache = property(lambda self: self.cards.card_cache)
card_cost = property(lambda self: self.cards.card_cost)
budget_card = property(lambda self: self.cards.budget_card)
chart = property(lambda self: self.chart_panel.chart)
gran_combo = property(lambda self: self.chart_panel.gran_combo)
metric_combo = property(lambda self: self.chart_panel.metric_combo)
chart_prev_btn = property(lambda self: self.chart_panel.prev_btn)
chart_next_btn = property(lambda self: self.chart_panel.next_btn)
_chart_period_lbl = property(lambda self: self.chart_panel.period_lbl)
_chart_title = property(lambda self: self.chart_panel.title)
_habits_title = property(lambda self: self.habits_panel.title)
_ai_title = property(lambda self: self.habits_panel.ai_title)
habits = property(lambda self: self.habits_panel.habits)
ai_advice = property(lambda self: self.habits_panel.ai_advice)
ai_analyze_btn = property(lambda self: self.habits_panel.ai_analyze_btn)
apply_strategy_btn = property(lambda self: self.habits_panel.apply_strategy_btn)
# ---- helpers ---------------------------------------------------------
def _pricing(self) -> Dict:
from ..core import model_pricing as mp
mp.sync_to_usage(self.ctx.config) # tổng/chi phí lấy thẳng từ bảng giá
return {**ut.DEFAULT_PRICING, **(self.ctx.config.data.get("usage") or {})}
def _period_range(self):
"""Khoảng đang chọn dạng (đầu, cuối) bao gồm cả hai đầu — nó điều khiển
cả màn: thẻ số liệu, biểu đồ và thói quen."""
gran = self.chart_panel.gran_combo.currentData() or "week"
start, end = ut.period_bounds(gran, self.chart_panel.offset)
return start, end - timedelta(days=1) # load_events tính cả ngày cuối
def _on_gran_changed(self, *_a) -> None:
self.chart_panel.offset = 0 # đổi độ mịn → quay về khoảng hiện tại
self.refresh() # bộ lọc điều khiển CẢ màn
def _on_currency_changed(self, _idx: int) -> None:
cur = self.currency_combo.currentData()
if not cur:
return
self.ctx.config.data.setdefault("usage", {})["currency"] = cur
self.ctx.save()
self.refresh()
def _apply_budget(self, amount: float) -> None:
"""Ghi giá trị ở ô nhập thành ngân sách mới — mở một chu kỳ số dư mới,
phần đã tiêu trước thời điểm này không còn được tính."""
ccy = (self.ctx.config.data.get("usage") or {}).get("currency", "USD")
ut.set_budget(self.ctx.config, amount, ccy)
self.ctx.save()
self._refresh_budget()
def _refresh_budget(self) -> None:
from ..core import model_pricing as mp
pricing = self._pricing()
self.cards.set_budget(
ut.budget_status(self.ctx.config), pricing,
lambda v, a, b: mp.convert(v, a, b, self.ctx.config))
def _retranslate(self) -> None:
self._title.setText(tr("dashboard.title"))
self.refresh_btn.setToolTip(tr("dashboard.refresh_tooltip"))
self.currency_lbl.setText(tr("monitoring.overview_currency"))
self.currency_combo.setToolTip(tr("dashboard.currency_tooltip"))
self.habits_panel.ai_analyze_btn.setText(tr("dashboard.ai_analyze_btn"))
self.habits_panel.ai_analyze_btn.setToolTip(tr("dashboard.ai_analyze_tooltip"))
self.habits_panel.apply_strategy_btn.setText(tr("dashboard.strategy_btn"))
self.habits_panel.apply_strategy_btn.setToolTip(tr("dashboard.strategy_tooltip"))
self.habits_panel.title.setText(tr("dashboard.habits_title"))
self.chart_panel.title.setText(tr("dashboard.chart_title"))
self.chart_panel.prev_btn.setToolTip(tr("dashboard.chart_prev"))
self.chart_panel.next_btn.setToolTip(tr("dashboard.chart_next"))
self.budget_card.apply_btn.setToolTip(tr("usage.budget_apply_tooltip"))
self.budget_card.budget_spin.setToolTip(tr("usage.budget_spin_tooltip"))
self.refresh()
# ---- nạp dữ liệu -----------------------------------------------------
def refresh(self) -> None:
start, end = self._period_range()
events = ut.load_events(start, end)
pricing = self._pricing()
s = ut.summarize(events)
self.cards.set_usage(s, ut.cost_usd_events(events, pricing), pricing)
self.habits_panel.set_summary(s, bool(events))
# Biểu đồ đọc TOÀN BỘ sự kiện rồi tự cắt theo khoảng — nó cần cả khoảng
# liền trước để vẽ đường so sánh, thứ không nằm trong (start, end).
self.chart_panel.refresh(ut.load_events(), pricing)
self._refresh_budget()