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>
220 lines
10 KiB
Python
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()
|