Files
XStats/Packages/XStatsKit/Sources/XStatsUI/Panel/AIUsagePage.swift
T

866 lines
44 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Copyright (C) 2026 ysicing
// SPDX-License-Identifier: AGPL-3.0-or-later
import AIUsage
import Localization
import SwiftUI
extension EnvironmentValues {
@Entry var aiUsageWesternUnits = false
}
private struct CostRefreshKey: Hashable {
let attempt: Date?
let currency: AIUsageCurrency
let enabled: Bool
let localUsage: Bool
let visible: Bool
}
struct AIUsagePage: View {
var body: some View { PageScroll { LocalUsageContent(compact: false) } }
}
struct AIUsagePopover: View {
var body: some View { LocalUsageContent(compact: true) }
}
private struct LocalUsageContent: View {
@Environment(\.isSnapshot) private var isSnapshot
let compact: Bool
@Environment(AppModel.self) private var model
@State private var selectedModel = ""
@State private var source = "all"
@State private var activityMode = UsageActivityMode.daily
private var enabledProviders: [AIProviderID] {
AIProviderID.allCases.filter { model.settings.aiUsageSources.contains($0) }
}
private var provider: AIProviderID? {
if enabledProviders.count == 1 { return enabledProviders.first }
return AIProviderID(rawValue: source).flatMap { model.settings.aiUsageSources.contains($0) ? $0 : nil }
}
private var sourceOptions: [(String, String)] {
[("all", tr("全部"))] + enabledProviders
.map { ($0.rawValue, $0 == .codex ? "Codex" : "Claude") }
}
private var sourceTitle: String {
if let provider { return provider == .codex ? "Codex" : "Claude Code" }
return enabledProviders
.map { $0 == .codex ? "Codex" : "Claude Code" }.joined(separator: " + ")
}
private var isVisible: Bool {
compact ? model.openPopover == .aiUsage
: model.isMainWindowVisible && model.settings.panelTab == .aiUsage
}
var body: some View {
let now = Date()
VStack(alignment: .leading, spacing: compact ? DS.Space.s3 : DS.Space.s4) {
toolbar
if model.settings.aiUsageEnabled && !enabledProviders.isEmpty {
Text(sourceTitle)
.dsFont(.sm, weight: .semibold)
.foregroundStyle(DS.Palette.textPrimary)
}
if !model.aiUsage.visibleQuotaProviders(for: provider).isEmpty {
quotaSection
}
if !model.settings.aiUsageEnabled {
Card {
Label(tr("AI 用量"), systemImage: "chart.bar").dsFont(.base, weight: .semibold)
Text(tr("查看 Codex / Claude 的 Token 用量与订阅额度。"))
.dsFont(.xs).foregroundStyle(DS.Palette.textSecondary)
.fixedSize(horizontal: false, vertical: true)
Button(tr("启用 AI 用量")) { model.settings.aiUsageEnabled = true }
.buttonStyle(DSButtonStyle(kind: .primary))
}
} else if model.settings.aiUsageSources.isEmpty {
Card {
Text(tr("数据来源") + " · " + tr("尚未启用")).dsFont(.sm).foregroundStyle(DS.Palette.textSecondary)
Button(tr("设置")) { model.openAIUsageSettings() }
.buttonStyle(DSButtonStyle(kind: .secondary))
}
} else if model.settings.aiUsageShowsLocalUsage {
if let report = model.aiUsage.localReport(for: provider) {
let rows = report.summary(mode: activityMode, model: selectedModel.isEmpty ? nil : selectedModel, now: now)
let activityRows = report.selected(days: 365, model: selectedModel.isEmpty ? nil : selectedModel, now: now)
let total = LocalUsageReport.total(rows)
UsageSummary(total: total, compact: compact,
title: selectedModel.isEmpty ? tr("本地用量") : selectedModel,
costRows: rows)
UsageActivityPanel(rows: activityRows, compact: compact, now: now, mode: $activityMode)
modelRanking(report: report, rows: rows, total: total)
status(report)
} else {
VStack(spacing: DS.Space.s3) {
if model.aiUsage.isRefreshing { ProgressView().controlSize(.small) }
Image(systemName: "chart.bar.xaxis").font(.system(size: DS.TextSize.xl.rawValue))
.foregroundStyle(DS.Palette.textTertiary)
Text(tr(model.aiUsage.isRefreshing ? "正在读取会话日志…" : "暂无本机用量数据"))
.dsFont(.xs).foregroundStyle(DS.Palette.textSecondary)
}
.frame(maxWidth: .infinity).padding(.vertical, DS.Space.s12)
}
} else if model.aiUsage.visibleQuotaProviders(for: provider).isEmpty {
Card { Text(tr("暂无额度数据")).dsFont(.xs).foregroundStyle(DS.Palette.textSecondary) }
}
}
.environment(\.aiUsageWesternUnits, model.settings.aiUsageWesternUnits)
.task(id: CostRefreshKey(attempt: model.aiUsage.lastAttemptAt, currency: model.settings.aiUsageCurrency,
enabled: model.settings.aiUsageEnabled, localUsage: model.settings.aiUsageShowsLocalUsage,
visible: isVisible)) {
guard !isSnapshot, isVisible else { return }
await model.aiUsage.refreshCostReferences()
}
.onChange(of: model.settings.aiUsageSources) { _, sources in
if let id = AIProviderID(rawValue: source), !sources.contains(id) { source = "all" }
selectedModel = ""
}
}
@ViewBuilder private var toolbar: some View {
if !compact || (model.settings.aiUsageEnabled && enabledProviders.count > 1) {
HStack(spacing: DS.Space.s2) {
if model.settings.aiUsageEnabled && enabledProviders.count > 1 {
UsageChoices(selection: $source, options: sourceOptions,
label: tr("数据来源"))
.frame(maxWidth: compact ? .infinity : 260)
.onChange(of: source) { _, _ in selectedModel = "" }
}
if !compact {
Spacer(minLength: DS.Space.s4)
AIUsageRefreshButton()
AIUsageSettingsButton()
}
}
}
}
private var quotaSection: some View {
Card(spacing: DS.Space.s3) {
let visibleProviders = model.aiUsage.visibleQuotaProviders(for: provider)
HStack(spacing: DS.Space.s1) {
Text(tr("订阅额度")).dsFont(.sm, weight: .semibold)
Image(systemName: "info.circle")
.font(.system(size: DS.TextSize.xs.rawValue))
.foregroundStyle(DS.Palette.textTertiary)
.help(tr("服务端用量 · 与本机 Token 统计不同"))
.accessibilityLabel(tr("服务端用量 · 与本机 Token 统计不同"))
Spacer()
if let provider, model.aiUsage.visibleQuotaState(for: provider).snapshot?.source == .sub2api {
Text("Sub2API").dsFont(.xs, weight: .medium)
.foregroundStyle(DS.Palette.textTertiary)
}
}
ForEach(visibleProviders) { id in
let state = model.aiUsage.visibleQuotaState(for: id)
VStack(alignment: .leading, spacing: DS.Space.s3) {
if provider == nil {
HStack {
Text(id == .codex ? "Codex" : "Claude Code").dsFont(.xs, weight: .semibold)
Spacer()
if state.snapshot?.source == .sub2api {
Text("Sub2API").dsFont(.xs, weight: .medium)
.foregroundStyle(DS.Palette.textTertiary)
}
}
}
if let snapshot = state.snapshot {
ForEach(snapshot.windows) { window in
VStack(alignment: .leading, spacing: DS.Space.s1) {
HStack(alignment: .firstTextBaseline) {
Text(quotaTitle(window.kind))
.foregroundStyle(DS.Palette.textSecondary)
Spacer()
Text(tr(model.settings.aiQuotaShowsRemaining ? "剩余" : "已用"))
.foregroundStyle(DS.Palette.textSecondary)
Text(AIUsageFormat.quotaPercent(remainingPercent: window.remainingPercent,
showsRemaining: model.settings.aiQuotaShowsRemaining,
locale: L10n.locale))
.monospacedDigit()
.foregroundStyle(DS.Palette.textPrimary)
}
.dsFont(.xs)
ProgressTrack(fraction: (AIUsageFormat.quotaValue(remainingPercent: window.remainingPercent,
showsRemaining: model.settings.aiQuotaShowsRemaining) ?? 0) / 100,
color: progressTint(for: window.remainingPercent))
.accessibilityLabel(quotaTitle(window.kind) + " " + tr(model.settings.aiQuotaShowsRemaining ? "剩余" : "已用")
+ " " + AIUsageFormat.quotaPercent(remainingPercent: window.remainingPercent,
showsRemaining: model.settings.aiQuotaShowsRemaining,
locale: L10n.locale))
if let reset = window.resetsAt {
let resetText = tr("重置:") + " " + reset.formatted(Date.FormatStyle(date: .abbreviated, time: .shortened, locale: L10n.locale))
if state.isStale {
Text(resetText)
.dsFont(.xs).foregroundStyle(DS.Palette.textTertiary)
} else {
TimelineView(.periodic(from: .now, by: 60)) { context in
let fraction = window.remainingTimeFraction(at: context.date)
HStack(alignment: .firstTextBaseline, spacing: DS.Space.s1) {
Text(resetText)
.foregroundStyle(DS.Palette.textTertiary)
.lineLimit(1)
.minimumScaleFactor(0.8)
Spacer(minLength: DS.Space.s1)
if let fraction {
Text("\(Int((fraction * 100).rounded()))%")
.monospacedDigit()
.foregroundStyle(DS.Palette.textSecondary)
}
}
.dsFont(.xs)
if let fraction {
// 条长表示距离重置的剩余时间,与右侧百分比一致。
ProgressTrack(fraction: fraction, color: DS.Palette.primary)
.accessibilityLabel(tr("距重置"))
.accessibilityValue("\(Int((fraction * 100).rounded()))%")
}
}
}
}
}
}
// Sub2API 的 Fable 窗口可能为 null;显示缺失状态,不推断为 0% 已用。
if id == .claude, snapshot.source == .sub2api,
snapshot.window(.fableWeekly) == nil {
HStack {
Text(quotaTitle(.fableWeekly))
Spacer()
Text(tr("暂无额度数据"))
}
.dsFont(.xs)
.foregroundStyle(DS.Palette.textTertiary)
}
if state.isStale {
VStack(alignment: .leading, spacing: DS.Space.s1) {
Label(tr("上次额度"), systemImage: "clock")
.foregroundStyle(DS.Palette.warning)
Text(tr("上次成功:\(snapshot.fetchedAt.formatted(Date.FormatStyle(date: .abbreviated, time: .shortened, locale: L10n.locale)))"))
.foregroundStyle(DS.Palette.textTertiary)
if let lastAttemptAt = state.lastAttemptAt {
Text(tr("最近刷新:\(lastAttemptAt.formatted(Date.FormatStyle(date: .abbreviated, time: .shortened, locale: L10n.locale)))"))
.foregroundStyle(DS.Palette.textTertiary)
}
if state.failure != nil {
Text(quotaFailureText(state.failure))
.foregroundStyle(DS.Palette.warning)
}
}
.dsFont(.xs)
} else if let lastAttemptAt = state.lastAttemptAt {
Text(tr("最近刷新:\(lastAttemptAt.formatted(Date.FormatStyle(date: .omitted, time: .shortened, locale: L10n.locale)))"))
.dsFont(.xs)
.foregroundStyle(DS.Palette.textTertiary)
}
} else if state.isRefreshing {
ProgressView().controlSize(.small)
} else {
Text(quotaFailureText(state.failure))
.dsFont(.xs).foregroundStyle(DS.Palette.textSecondary)
}
}
if id != visibleProviders.last {
HairlineDivider()
}
}
}
}
private func quotaTitle(_ kind: AIQuotaKind) -> String {
switch kind {
case .session: tr("5 小时")
case .weekly: tr("7 天")
case .fableWeekly: "Fable · " + tr("7 天")
case .opusWeekly: "Opus · " + tr("7 天")
case .sonnetWeekly: "Sonnet · " + tr("7 天")
}
}
private func progressTint(for percent: Double) -> Color {
if percent <= 10 { return DS.Palette.critical }
if percent <= 20 { return DS.Palette.error }
if percent < 40 { return DS.Palette.warning }
if percent < 80 { return DS.Palette.primary }
return DS.Palette.success
}
private func quotaFailureText(_ failure: AIQuotaFailure?) -> String {
switch failure {
case nil: tr("暂无额度数据")
case .notConfigured: tr("未检测到登录凭据,请先登录对应 CLI")
case .unauthorized: tr("登录已失效,请在对应 CLI 重新登录")
case .rateLimited: tr("服务暂时限流,稍后会自动重试")
case .network: tr("暂时无法连接额度服务")
case .invalidResponse: tr("额度接口返回了无法识别的数据")
case .sub2apiConfiguration: tr("Sub2API 配置无法读取,请检查设置")
case .sub2apiUnauthorized: tr("Sub2API 登录失败,请检查管理员邮箱和密码")
case .sub2apiNetwork: tr("暂时无法连接 Sub2API")
case .sub2apiInvalidResponse: tr("Sub2API 返回了无法识别的额度数据")
case .sub2apiAccountMismatch: tr("Sub2API 账号平台与所选来源不匹配")
}
}
private func modelRanking(report: LocalUsageReport, rows: [ModelTokenUsage], total: ModelTokenUsage) -> some View {
let models = LocalUsageReport.models(rows)
return Card(spacing: DS.Space.s4) {
HStack {
Text(tr("模型排行")).dsFont(.sm, weight: .semibold)
Spacer()
Picker(tr("模型"), selection: $selectedModel) {
Text(tr("全部模型")).tag("")
ForEach(Array(Set(report.rows.map(\.model))).sorted(), id: \.self) { name in
Text(verbatim: name).tag(name)
}
}
.labelsHidden().controlSize(.small)
.frame(maxWidth: compact ? 150 : 220, alignment: .trailing)
.accessibilityLabel(tr("模型"))
}
if models.isEmpty {
Text(tr("所选时间范围没有用量记录")).dsFont(.xs)
.foregroundStyle(DS.Palette.textSecondary).padding(.vertical, DS.Space.s4)
}
ForEach(models) { item in
UsageModelRow(item: item, total: total.total, compact: compact)
if item.id != models.last?.id { HairlineDivider() }
}
}
}
private func status(_ report: LocalUsageReport) -> some View {
let state = model.aiUsage.localState(for: provider)
return VStack(alignment: .leading, spacing: DS.Space.s1) {
if report.unreadableFiles > 0 {
Label(tr("部分会话日志无法读取,统计可能不完整"), systemImage: "exclamationmark.triangle")
.foregroundStyle(DS.Palette.warning)
}
if state.stale {
Label(tr("读取失败,显示上次统计结果"), systemImage: "exclamationmark.triangle")
.foregroundStyle(DS.Palette.warning)
}
HStack {
Spacer()
if let updated = state.updated {
Text(tr("上次检查:\(updated.formatted(Date.FormatStyle(date: .omitted, time: .shortened, locale: L10n.locale)))"))
}
}.foregroundStyle(DS.Palette.textTertiary)
}.dsFont(.xs)
}
}
/// 鼠标选择只对选中底色作短暂淡入;键盘选择即时响应,不动画数据、图表或布局。
private struct UsageChoices<Value: Hashable>: View {
@Binding var selection: Value
let options: [(Value, String)]
let label: String
@State private var pointerSelection = false
@Environment(\.accessibilityReduceMotion) private var reduceMotion
var body: some View {
HStack(spacing: DS.Space.s1 / 2) {
ForEach(options, id: \.0) { value, title in
Button {
pointerSelection = true
selection = value
} label: {
Text(title).dsFont(.xs, weight: selection == value ? .semibold : .medium)
.lineLimit(1)
.minimumScaleFactor(0.8)
.foregroundStyle(selection == value ? DS.Palette.textPrimary : DS.Palette.textSecondary)
.frame(maxWidth: .infinity)
.padding(.vertical, DS.Space.s2)
.background {
RoundedRectangle(cornerRadius: DS.Radius.md)
.fill(DS.Palette.elevated)
.opacity(selection == value ? 1 : 0)
.animation(pointerSelection ? (reduceMotion ? DS.Motion.quick : DS.Motion.usageSelection) : nil,
value: selection)
}
}
.buttonStyle(UsagePressStyle())
.accessibilityLabel(title)
.accessibilityAddTraits(selection == value ? .isSelected : [])
.onKeyPress(.space) { selectWithKeyboard(value); return .handled }
.onKeyPress(.return) { selectWithKeyboard(value); return .handled }
}
}
.padding(DS.Space.s1 / 2)
.background(DS.Palette.track, in: RoundedRectangle(cornerRadius: DS.Radius.md))
.accessibilityElement(children: .contain)
.accessibilityLabel(label)
}
private func selectWithKeyboard(_ value: Value) {
pointerSelection = false
var transaction = Transaction()
transaction.disablesAnimations = true
withTransaction(transaction) { selection = value }
}
}
private struct UsagePressStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label.opacity(configuration.isPressed ? 0.65 : 1)
}
}
/// 所有数字和日期都跟随应用语言(`L10n.locale`),不跟随系统语言:
/// 否则中文系统 + 英文界面下,坐标轴、悬停提示和时间会夹在英文文案里显示中文。
enum UsageNumber {
static func exact(_ value: Int, locale: Locale = L10n.locale) -> String {
value.formatted(.number.locale(locale))
}
static func day(_ date: Date, locale: Locale = L10n.locale) -> String {
date.formatted(.dateTime.year().month().day().locale(locale))
}
static func short(_ value: Int, locale: Locale = L10n.locale, westernUnits: Bool = false) -> String {
AIUsageFormat.tokens(value, westernUnits: westernUnits, locale: locale)
}
}
private struct UsageSummary: View {
@Environment(\.aiUsageWesternUnits) private var westernUnits
let total: ModelTokenUsage
let compact: Bool
let title: String
let costRows: [ModelTokenUsage]
@State private var expanded = false
var body: some View {
Card(padding: DS.Space.s6, spacing: compact ? DS.Space.s3 : DS.Space.s6) {
HStack {
Text(title).dsFont(.sm, weight: .medium).foregroundStyle(DS.Palette.textSecondary).lineLimit(1)
Spacer()
}
HStack(alignment: .firstTextBaseline, spacing: DS.Space.s2) {
Text(UsageNumber.short(total.total, westernUnits: westernUnits))
.dsFont(.xxl, weight: .semibold)
.tracking(-0.6).monospacedDigit()
.help(UsageNumber.exact(total.total))
.accessibilityLabel(UsageNumber.exact(total.total) + " Tokens")
Text("Tokens").dsFont(.sm).foregroundStyle(DS.Palette.textTertiary)
Spacer()
}
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), alignment: .leading), count: compact ? 2 : 4),
alignment: .leading, spacing: DS.Space.s3) {
UsageMetric(title: tr("输入 Token"), value: UsageNumber.short(total.input, westernUnits: westernUnits), exact: UsageNumber.exact(total.input))
UsageMetric(title: tr("输出 Token"), value: UsageNumber.short(total.output, westernUnits: westernUnits), exact: UsageNumber.exact(total.output))
UsageMetric(title: tr("缓存命中率"), value: total.input > 0
? (Double(total.cached) / Double(total.input)).formatted(.percent.precision(.fractionLength(1)).locale(L10n.locale)) : "—")
UsageMetric(title: tr("用量记录"), value: UsageNumber.exact(total.records))
}
UsageCostSummary(rows: costRows)
DisclosureGroup(isExpanded: $expanded) {
VStack(spacing: DS.Space.s2) {
InfoRow(label: tr("缓存 Token"), text: UsageNumber.exact(total.cached))
InfoRow(label: tr("缓存创建 Token"), text: UsageNumber.exact(total.cacheCreated))
Text(tr("缓存 Token 已包含在输入中,推理 Token 已包含在输出中。"))
.dsFont(.xs).foregroundStyle(DS.Palette.textSecondary)
.fixedSize(horizontal: false, vertical: true)
}.padding(.top, DS.Space.s2)
} label: {
Text(tr("缓存 Token")).dsFont(.xs).foregroundStyle(DS.Palette.textSecondary)
}
}
}
}
private struct UsageCostSummary: View {
let rows: [ModelTokenUsage]
@Environment(AppModel.self) private var model
var body: some View {
let estimate = model.aiUsage.estimatedCost(rows)
let references = model.aiUsage.costReferences
let currency = model.settings.aiUsageCurrency
VStack(alignment: .leading, spacing: DS.Space.s1) {
HStack {
Text(tr("估算费用")).foregroundStyle(DS.Palette.textSecondary)
Spacer()
if let usd = estimate.usd {
if let amount = AIUsageFormat.money(usd: usd, currency: currency,
usdToCNY: references.exchangeRate?.cnyPerUSD, locale: L10n.locale) {
Text(amount + (estimate.unpricedModels.isEmpty ? "" : "+"))
.monospacedDigit().foregroundStyle(DS.Palette.textPrimary)
} else {
Text(tr("暂无汇率")).foregroundStyle(DS.Palette.textTertiary)
}
} else if model.aiUsage.isRefreshingCostReferences {
ProgressView().controlSize(.mini)
} else {
Text(tr("暂无价格")).foregroundStyle(DS.Palette.textTertiary)
}
}.dsFont(.sm, weight: .medium)
Text(tr("按模型基础 API 单价估算,不含阶梯加价,非订阅账单。"))
.dsFont(.xs).foregroundStyle(DS.Palette.textTertiary)
.fixedSize(horizontal: false, vertical: true)
if !estimate.unpricedModels.isEmpty {
Text(tr("部分模型暂无价格"))
.dsFont(.xs).foregroundStyle(DS.Palette.warning)
.help(estimate.unpricedModels.joined(separator: "\n"))
}
if let catalog = references.catalog {
Text(tr("价格更新:\(catalog.fetchedAt.formatted(Date.FormatStyle(date: .abbreviated, time: .shortened, locale: L10n.locale)))"))
.dsFont(.xs).foregroundStyle(DS.Palette.textTertiary)
}
if currency == .cny, let rate = references.exchangeRate {
let value = rate.cnyPerUSD.formatted(.number.precision(.fractionLength(0...4)).locale(L10n.locale))
Text(tr("参考汇率:1 USD ≈ \(value) CNY"))
.dsFont(.xs).foregroundStyle(DS.Palette.textTertiary)
.help(tr("汇率更新:\(rate.fetchedAt.formatted(Date.FormatStyle(date: .abbreviated, time: .shortened, locale: L10n.locale)))"))
}
}
}
}
private struct UsageMetric: View {
let title: String
let value: String
var exact: String = ""
var body: some View {
VStack(alignment: .leading, spacing: DS.Space.s1) {
Text(title).dsFont(.xs).foregroundStyle(DS.Palette.textSecondary)
Text(value).dsFont(.base, weight: .medium).monospacedDigit().lineLimit(1).minimumScaleFactor(0.8)
.help(exact.isEmpty ? value : exact)
.accessibilityLabel(exact.isEmpty ? value : exact)
}.frame(maxWidth: .infinity, alignment: .leading)
}
}
private struct UsageModelRow: View {
@Environment(\.aiUsageWesternUnits) private var westernUnits
let item: ModelTokenUsage
let total: Int
let compact: Bool
var body: some View {
VStack(alignment: .leading, spacing: DS.Space.s2) {
HStack(alignment: .firstTextBaseline) {
HStack(spacing: DS.Space.s2) {
Circle().fill(DS.Palette.primary).frame(width: DS.Space.s1, height: DS.Space.s1)
Text(verbatim: item.model).dsFont(.xs, weight: .medium).lineLimit(1).help(item.model)
}
Spacer(minLength: DS.Space.s2)
Text(UsageNumber.short(item.total, westernUnits: westernUnits)).dsFont(.sm, weight: .semibold)
.monospacedDigit().fixedSize().help(UsageNumber.exact(item.total))
.accessibilityLabel(UsageNumber.exact(item.total) + " Tokens")
Text((total > 0 ? Double(item.total) / Double(total) : 0).formatted(.percent.precision(.fractionLength(0)).locale(L10n.locale)))
.dsFont(.xs).foregroundStyle(DS.Palette.textTertiary)
.monospacedDigit().frame(width: DS.Space.s12, alignment: .trailing)
}
ProgressTrack(fraction: total > 0 ? Double(item.total) / Double(total) : 0,
color: DS.Palette.primary, height: DS.Space.s1 / 2, showsTrack: false)
.accessibilityHidden(true)
if !compact {
HStack(spacing: DS.Space.s6) {
Text(tr("输入 Token") + " " + UsageNumber.short(item.input, westernUnits: westernUnits))
Text(tr("输出 Token") + " " + UsageNumber.short(item.output, westernUnits: westernUnits))
Spacer()
Text(tr("用量记录") + " " + UsageNumber.exact(item.records))
}.dsFont(.xs).foregroundStyle(DS.Palette.textSecondary).monospacedDigit()
}
}.padding(.vertical, DS.Space.s1)
}
}
/// 摘要和图表共用筛选范围;短周期看柱状分布,累计看年度每日热力图。
private struct UsageActivityPanel: View {
@Environment(\.aiUsageWesternUnits) private var westernUnits
let rows: [ModelTokenUsage]
let compact: Bool
let now: Date
@Binding var mode: UsageActivityMode
@State private var hovered: String?
var body: some View {
Card(spacing: DS.Space.s2) {
HStack(spacing: DS.Space.s2) {
Text(tr("Token 活动")).dsFont(.sm, weight: .semibold)
Spacer(minLength: DS.Space.s2)
if !compact { choices.frame(width: 280) }
}
if compact { choices }
Text(hovered ?? tr(mode == .daily ? "按小时统计" : "按天统计"))
.dsFont(.xs).foregroundStyle(DS.Palette.textSecondary)
.lineLimit(1).minimumScaleFactor(0.75)
.frame(maxWidth: .infinity, alignment: .trailing)
.frame(height: DS.Space.s6)
if mode == .cumulative {
let activity = UsageActivity(rows: rows, now: now)
if compact {
ScrollView(.horizontal) {
ActivityGrid(activity: activity, hovered: $hovered, side: DS.Space.s3)
.frame(height: gridHeight)
}.defaultScrollAnchor(.trailing)
} else {
ActivityGrid(activity: activity, hovered: $hovered).frame(height: gridHeight)
}
Text(UsageNumber.day(activity.start) + " – " + UsageNumber.day(activity.end))
.dsFont(.xs).foregroundStyle(DS.Palette.textSecondary)
HStack {
Text(tr("单日最高:\(UsageNumber.short(activity.peak, westernUnits: westernUnits)) Tokens"))
.help(UsageNumber.exact(activity.peak) + " Tokens")
Spacer(minLength: DS.Space.s1)
ForEach(0..<5) { level in
RoundedRectangle(cornerRadius: DS.Space.s1 / 2)
.fill(level == 0 ? DS.Palette.track : DS.Palette.primary.opacity(Double(level) / 4))
.frame(width: DS.Space.s3, height: DS.Space.s3)
}
}.dsFont(.xs).foregroundStyle(DS.Palette.textSecondary)
} else {
let series = UsageTimeSeries(rows: rows, mode: mode, now: now)
if series.hasMissingHourlyData {
Text(tr("暂无小时明细,请刷新用量。"))
.dsFont(.xs).foregroundStyle(DS.Palette.textSecondary)
.frame(maxWidth: .infinity, minHeight: 140)
} else {
UsageBarChart(buckets: series.buckets, hourly: mode == .daily, hovered: $hovered)
}
}
}
.onChange(of: mode) { _, _ in hovered = nil }
.onChange(of: rows) { _, _ in hovered = nil }
}
private var choices: some View {
UsageChoices(selection: $mode, options: [(.daily, tr("今天")), (.weekly, tr("7 天")),
(.monthly, tr("30 天")), (.cumulative, tr("累计"))],
label: tr("Token 活动"))
}
private var gridHeight: CGFloat { 7 * DS.Space.s3 + 6 * (DS.Space.s1 / 2) + DS.Space.s6 }
}
private struct UsageBarChart: View {
@Environment(\.aiUsageWesternUnits) private var westernUnits
let buckets: [UsageTimeSeries.Bucket]
let hourly: Bool
@Binding var hovered: String?
var body: some View {
let peak = max(1, buckets.map(\.total).max() ?? 0)
VStack(spacing: DS.Space.s1) {
HStack(spacing: DS.Space.s2) {
VStack(alignment: .trailing) {
Text(UsageNumber.short(peak, westernUnits: westernUnits))
Spacer()
Text(UsageNumber.short(peak / 2, westernUnits: westernUnits))
Spacer()
Text("0")
}.dsFont(.xs).foregroundStyle(DS.Palette.textTertiary)
.frame(width: 48, alignment: .trailing).accessibilityHidden(true)
GeometryReader { geometry in
ZStack {
VStack { HairlineDivider(); Spacer(); HairlineDivider(); Spacer(); HairlineDivider() }
.accessibilityHidden(true)
HStack(alignment: .bottom, spacing: DS.Space.s1 / 2) {
ForEach(buckets) { bucket in
let label = bucket.start.formatted(hourly
? .dateTime.month().day().hour().minute().timeZone().locale(L10n.locale)
: .dateTime.month().day().locale(L10n.locale))
+ " · " + UsageNumber.exact(bucket.total) + " Tokens"
RoundedRectangle(cornerRadius: DS.Space.s1 / 2)
.fill(DS.Palette.primary.opacity(0.8))
.frame(maxWidth: 24)
.frame(height: bucket.total == 0 ? 0 : max(2, geometry.size.height * Double(bucket.total) / Double(peak)))
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom)
.contentShape(Rectangle())
.onHover { active in hovered = active ? label : nil }
.accessibilityElement(children: .ignore).accessibilityLabel(label)
}
}
}
}
}.frame(height: 140)
GeometryReader { geometry in
let indices = buckets.count == 7 ? [0, 2, 4, 6]
: Array(Set([0, buckets.count / 4, buckets.count / 2, buckets.count * 3 / 4, max(0, buckets.count - 1)])).sorted()
ForEach(indices, id: \.self) { index in
if buckets.indices.contains(index) {
Text(buckets[index].start.formatted(hourly
? .dateTime.hour(.twoDigits(amPM: .omitted)).minute(.twoDigits).locale(L10n.locale)
: .dateTime.month(.twoDigits).day(.twoDigits).locale(L10n.locale)))
.position(x: min(geometry.size.width - 20, max(20, geometry.size.width * (Double(index) + 0.5) / Double(buckets.count))), y: 9)
}
}
}.frame(height: 20).padding(.leading, 48 + DS.Space.s2)
.dsFont(.xs).foregroundStyle(DS.Palette.textTertiary).accessibilityHidden(true)
}
// 时间轴固定从左到右,阿拉伯语标签仍按其文字方向排版。
.environment(\.layoutDirection, .leftToRight)
}
}
private struct ActivityGrid: View {
let activity: UsageActivity
@Binding var hovered: String?
var side: CGFloat?
private var gap: CGFloat { DS.Space.s1 / 2 }
var body: some View {
if let side {
grid(side: side)
} else {
GeometryReader { geometry in
grid(side: min(DS.Space.s3,
max(1, (geometry.size.width - CGFloat(activity.weeks.count - 1) * gap)
/ CGFloat(max(1, activity.weeks.count)))))
}
}
}
private func grid(side: CGFloat) -> some View {
let peak = max(1, activity.peak)
return HStack(alignment: .top, spacing: gap) {
ForEach(activity.weeks) { week in
VStack(spacing: gap) {
ForEach(0..<7, id: \.self) { row in
if let date = week.days[row] {
let count = activity.daily[date] ?? 0
let level = min(4, max(1, Int(ceil(Double(count) / Double(peak) * 4))))
ActivityCell(side: side,
color: count == 0 ? DS.Palette.track : DS.Palette.primary.opacity(Double(level) / 4),
label: UsageNumber.day(date) + " · " + UsageNumber.exact(count) + " Tokens",
hovered: $hovered)
} else {
Color.clear.frame(width: side, height: side).accessibilityHidden(true)
}
}
Color.clear.frame(width: side, height: DS.Space.s4)
.overlay(alignment: .leading) {
if let month = week.days.compactMap({ $0 }).first(where: { Calendar.current.component(.day, from: $0) == 1 }) {
Text(month.formatted(.dateTime.month(.abbreviated).locale(L10n.locale)))
.dsFont(.xs).foregroundStyle(DS.Palette.textSecondary).fixedSize()
}
}
}
}
}
}
}
private struct ActivityCell: View {
let side: CGFloat
let color: Color
let label: String
@Binding var hovered: String?
var body: some View {
RoundedRectangle(cornerRadius: DS.Space.s1 / 2)
.fill(color).frame(width: side, height: side)
.contentShape(Rectangle())
.onHover { inside in
if inside { hovered = label }
else if hovered == label { hovered = nil }
}
.accessibilityLabel(label)
}
}
struct AIUsageRefreshButton: View {
@Environment(AppModel.self) private var model
var body: some View {
ZStack {
MiniIconButton(systemName: "arrow.clockwise", help: tr("立即刷新")) {
Task { await model.aiUsage.refresh() }
}
.opacity(model.aiUsage.isRefreshing ? 0 : 1)
.disabled(!model.settings.aiUsageEnabled || model.settings.aiUsageSources.isEmpty || model.aiUsage.isRefreshing)
if model.aiUsage.isRefreshing { ProgressView().controlSize(.small).accessibilityLabel(tr("正在刷新")) }
}
.frame(width: DS.Size.controlHeight, height: DS.Size.controlHeight)
}
}
struct AIUsageSettingsButton: View {
@Environment(AppModel.self) private var model
var body: some View {
MiniIconButton(systemName: "slider.horizontal.3", help: tr("AI 用量") + " · " + tr("设置")) {
model.openAIUsageSettings()
}
}
}
struct AIUsageSettingsPanel: View {
var body: some View {
ScrollView { UsageSettings().padding(DS.Space.s4) }
.frame(minWidth: 390, minHeight: 480)
.background(DS.Palette.background)
.appLanguageEnvironment()
}
}
private struct UsageSettings: View {
@Environment(AppModel.self) private var model
var body: some View {
@Bindable var settings = model.settings
VStack(alignment: .leading, spacing: DS.Space.s4) {
Text(tr("设置")).dsFont(.base, weight: .semibold)
SettingRow(title: tr("AI 用量"), subtitle: nil) {
DSToggle(isOn: $settings.aiUsageEnabled, label: tr("AI 用量"))
}
SettingRow(title: tr("显示本地用量"),
subtitle: tr("读取本机会话日志;关闭后停止扫描并隐藏 Token 统计。")) {
DSToggle(isOn: $settings.aiUsageShowsLocalUsage, label: tr("显示本地用量"))
}
HairlineDivider()
Text(tr("数据来源")).dsFont(.xs).foregroundStyle(DS.Palette.textSecondary)
ForEach(AIProviderID.allCases) { provider in
let name = provider == .codex ? "Codex" : "Claude Code"
SettingRow(title: name, subtitle: nil) {
DSToggle(isOn: Binding(get: { settings.aiUsageSources.contains(provider) }, set: { enabled in
if enabled { settings.aiUsageSources.insert(provider) }
else { settings.aiUsageSources.remove(provider) }
}), label: name)
}
if settings.aiUsageEnabled && settings.aiUsageSources.contains(provider) {
let quota = model.aiUsage.visibleQuotaState(for: provider)
Sub2APISettingsView(provider: provider,
directAvailable: quota.snapshot?.source == .direct,
needsFallback: quota.snapshot?.source != .direct
&& (quota.failure != nil || quota.snapshot?.source == .sub2api))
}
}
HairlineDivider()
SettingRow(title: tr("刷新间隔"), subtitle: tr("AI 用量与额度共用此间隔;新数据到达后更新菜单栏。")) {
Picker(tr("刷新间隔"), selection: $settings.aiUsageRefreshMinutes) {
ForEach(AppSettings.aiUsageRefreshOptions, id: \.self) { value in
Text(tr("\(value) 分钟")).tag(value)
}
}.labelsHidden()
}
SettingRow(title: tr("额度显示"), subtitle: nil) {
Picker(tr("额度显示"), selection: $settings.aiQuotaShowsRemaining) {
Text(tr("已用")).tag(false)
Text(tr("剩余")).tag(true)
}.labelsHidden()
}
if settings.language.resolved.isChinese {
SettingRow(title: tr("数字单位"), subtitle: nil) {
Picker(tr("数字单位"), selection: $settings.aiUsageWesternUnits) {
Text(tr("万 / 亿")).tag(false)
Text("K / M / B").tag(true)
}.labelsHidden()
}
}
SettingRow(title: tr("货币"), subtitle: nil) {
Picker(tr("货币"), selection: $settings.aiUsageCurrency) {
Text(tr("美元")).tag(AIUsageCurrency.usd)
Text(tr("人民币")).tag(AIUsageCurrency.cny)
}.labelsHidden()
}
Text(tr("价格来源:models.dev;人民币按缓存汇率换算。"))
.dsFont(.xs).foregroundStyle(DS.Palette.textSecondary)
.fixedSize(horizontal: false, vertical: true)
Text(tr("本机 Token 统计不代表订阅账单或其他设备的用量"))
.dsFont(.xs).foregroundStyle(DS.Palette.textSecondary)
.fixedSize(horizontal: false, vertical: true)
}
}
}