蜂鸟Pro v2.0.1 - 基础框架版本 (待完善)
## 当前状态 - 插件界面已完成重命名 (cursorpro → hummingbird) - 双账号池 UI 已实现 (Auto/Pro 卡片) - 后端已切换到 MySQL 数据库 - 添加了 Cursor 官方用量 API 文档 ## 已知问题 (待修复) 1. 激活时检查账号导致无账号时激活失败 2. 未启用无感换号时不应获取账号 3. 账号用量模块不显示 (seamless 未启用时应隐藏) 4. 积分显示为 0 (后端未正确返回) 5. Auto/Pro 双密钥逻辑混乱,状态不同步 6. 账号添加后无自动分析功能 ## 下一版本计划 - 重构数据模型,优化账号状态管理 - 实现 Cursor API 自动分析账号 - 修复激活流程,不依赖账号 - 启用无感时才分配账号 - 完善账号用量实时显示 ## 文件说明 - docs/系统设计文档.md - 完整架构设计 - cursor 官方用量接口.md - Cursor API 文档 - 参考计费/ - Vibeviewer 开源项目参考 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
import SwiftUI
|
||||
import VibeviewerLoginUI
|
||||
|
||||
@MainActor
|
||||
struct ActionButtonsView: View {
|
||||
let isLoading: Bool
|
||||
let isLoggedIn: Bool
|
||||
let onRefresh: () -> Void
|
||||
let onLogin: () -> Void
|
||||
let onLogout: () -> Void
|
||||
let onSettings: () -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 10) {
|
||||
if self.isLoading {
|
||||
ProgressView()
|
||||
} else {
|
||||
Button("刷新") { self.onRefresh() }
|
||||
}
|
||||
|
||||
if !self.isLoggedIn {
|
||||
Button("登录") { self.onLogin() }
|
||||
} else {
|
||||
Button("退出登录") { self.onLogout() }
|
||||
}
|
||||
Button("设置") { self.onSettings() }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import SwiftUI
|
||||
import VibeviewerShareUI
|
||||
|
||||
@MainActor
|
||||
struct DashboardErrorView: View {
|
||||
let message: String
|
||||
let onRetry: (() -> Void)?
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.foregroundStyle(Color.red.opacity(0.9))
|
||||
Text("Failed to Refresh Data")
|
||||
.font(.app(.satoshiBold, size: 12))
|
||||
}
|
||||
|
||||
Text(message)
|
||||
.font(.app(.satoshiMedium, size: 11))
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
if let onRetry {
|
||||
Button {
|
||||
onRetry()
|
||||
} label: {
|
||||
Text("Retry")
|
||||
}
|
||||
.buttonStyle(.vibe(.primary))
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
.padding(10)
|
||||
.maxFrame(true, false, alignment: .leading)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.fill(Color.red.opacity(0.08))
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.stroke(Color.red.opacity(0.25), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
struct ErrorBannerView: View {
|
||||
let message: String?
|
||||
|
||||
var body: some View {
|
||||
if let msg = message, !msg.isEmpty {
|
||||
Text(msg)
|
||||
.foregroundStyle(.red)
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import SwiftUI
|
||||
import VibeviewerModel
|
||||
import VibeviewerShareUI
|
||||
|
||||
/// 会员类型徽章组件
|
||||
struct MembershipBadge: View {
|
||||
let membershipType: MembershipType
|
||||
let isEnterpriseUser: Bool
|
||||
|
||||
var body: some View {
|
||||
Text(membershipType.displayName(isEnterprise: isEnterpriseUser))
|
||||
.font(.app(.satoshiMedium, size: 12))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
VStack(spacing: 12) {
|
||||
MembershipBadge(membershipType: .free, isEnterpriseUser: false)
|
||||
MembershipBadge(membershipType: .freeTrial, isEnterpriseUser: false)
|
||||
MembershipBadge(membershipType: .pro, isEnterpriseUser: false)
|
||||
MembershipBadge(membershipType: .proPlus, isEnterpriseUser: false)
|
||||
MembershipBadge(membershipType: .ultra, isEnterpriseUser: false)
|
||||
MembershipBadge(membershipType: .enterprise, isEnterpriseUser: false)
|
||||
MembershipBadge(membershipType: .enterprise, isEnterpriseUser: true)
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import SwiftUI
|
||||
import VibeviewerShareUI
|
||||
import VibeviewerAppEnvironment
|
||||
import VibeviewerModel
|
||||
import VibeviewerSettingsUI
|
||||
|
||||
struct MenuFooterView: View {
|
||||
@Environment(\.dashboardRefreshService) private var refresher
|
||||
@Environment(\.settingsWindowManager) private var settingsWindow
|
||||
@Environment(AppSession.self) private var session
|
||||
|
||||
let onRefresh: () -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .center, spacing: 12) {
|
||||
Button {
|
||||
settingsWindow.show()
|
||||
} label: {
|
||||
Image(systemName: "gear")
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
// 显示会员类型徽章
|
||||
if let membershipType = session.snapshot?.usageSummary?.membershipType {
|
||||
MembershipBadge(
|
||||
membershipType: membershipType,
|
||||
isEnterpriseUser: session.credentials?.isEnterpriseUser ?? false
|
||||
)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Button {
|
||||
onRefresh()
|
||||
} label: {
|
||||
HStack(spacing: 4) {
|
||||
if refresher.isRefreshing {
|
||||
ProgressView()
|
||||
.controlSize(.mini)
|
||||
.progressViewStyle(.circular)
|
||||
.tint(.white)
|
||||
.frame(width: 16, height: 16)
|
||||
}
|
||||
Text("Refresh")
|
||||
.font(.app(.satoshiMedium, size: 12))
|
||||
}
|
||||
}
|
||||
.buttonStyle(.vibe(Color(hex: "5B67E2").opacity(0.8)))
|
||||
.animation(.easeInOut(duration: 0.2), value: refresher.isRefreshing)
|
||||
|
||||
Button {
|
||||
NSApplication.shared.terminate(nil)
|
||||
} label: {
|
||||
Text("Quit")
|
||||
.font(.app(.satoshiMedium, size: 12))
|
||||
}
|
||||
.buttonStyle(.vibe(Color(hex: "F58283").opacity(0.8)))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
import SwiftUI
|
||||
import VibeviewerModel
|
||||
import VibeviewerCore
|
||||
import VibeviewerShareUI
|
||||
import Foundation
|
||||
|
||||
struct MetricsViewDataSource: Equatable {
|
||||
var icon: String
|
||||
var title: String
|
||||
var description: String?
|
||||
var currentValue: String
|
||||
var targetValue: String?
|
||||
var progress: Double
|
||||
var tint: Color
|
||||
}
|
||||
|
||||
struct MetricsView: View {
|
||||
enum MetricType {
|
||||
case billing(MetricsViewDataSource)
|
||||
case onDemand(MetricsViewDataSource)
|
||||
case free(MetricsViewDataSource)
|
||||
}
|
||||
|
||||
var metric: MetricType
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
switch metric {
|
||||
case .billing(let dataSource):
|
||||
MetricContentView(dataSource: dataSource)
|
||||
case .onDemand(let dataSource):
|
||||
MetricContentView(dataSource: dataSource)
|
||||
case .free(let dataSource):
|
||||
MetricContentView(dataSource: dataSource)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct MetricContentView: View {
|
||||
let dataSource: MetricsViewDataSource
|
||||
|
||||
@State var isHovering: Bool = false
|
||||
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
var tintColor: Color {
|
||||
if isHovering {
|
||||
return dataSource.tint
|
||||
} else {
|
||||
return dataSource.tint.opacity(colorScheme == .dark ? 0.5 : 0.8)
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(alignment: .center, spacing: 12) {
|
||||
HStack(alignment: .center, spacing: 4) {
|
||||
Image(systemName: dataSource.icon)
|
||||
.font(.system(size: 16))
|
||||
.foregroundStyle(tintColor)
|
||||
Text(dataSource.title)
|
||||
.font(.app(.satoshiBold, size: 12))
|
||||
.foregroundStyle(tintColor)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
HStack(alignment: .lastTextBaseline, spacing: 0) {
|
||||
if let target = dataSource.targetValue, !target.isEmpty {
|
||||
Text(target)
|
||||
.font(.app(.satoshiRegular, size: 12))
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Text(" / ")
|
||||
.font(.app(.satoshiRegular, size: 12))
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Text(dataSource.currentValue)
|
||||
.font(.app(.satoshiBold, size: 16))
|
||||
.foregroundStyle(.primary)
|
||||
.contentTransition(.numericText())
|
||||
} else {
|
||||
Text(dataSource.currentValue)
|
||||
.font(.app(.satoshiBold, size: 16))
|
||||
.foregroundStyle(.primary)
|
||||
.contentTransition(.numericText())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
progressBar(color: tintColor)
|
||||
|
||||
if let description = dataSource.description {
|
||||
Text(description)
|
||||
.font(.app(.satoshiRegular, size: 10))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.animation(.easeInOut(duration: 0.2), value: isHovering)
|
||||
.onHover { isHovering = $0 }
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func progressBar(color: Color) -> some View {
|
||||
ZStack(alignment: .leading) {
|
||||
RoundedRectangle(cornerRadius: 100)
|
||||
.fill(Color(hex: "686868").opacity(0.5))
|
||||
.frame(height: 4)
|
||||
|
||||
GeometryReader { proxy in
|
||||
RoundedRectangle(cornerRadius: 100)
|
||||
.fill(color)
|
||||
.frame(width: proxy.size.width * dataSource.progress, height: 4)
|
||||
}
|
||||
.frame(height: 4)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension DashboardSnapshot {
|
||||
// MARK: - Subscription Expiry Configuration
|
||||
|
||||
/// Configuration for subscription expiry date calculation
|
||||
/// Modify this enum to change expiry date behavior with minimal code changes
|
||||
private enum SubscriptionExpiryRule {
|
||||
case endOfCurrentMonth
|
||||
case specificDaysFromNow(Int)
|
||||
case endOfNextMonth
|
||||
// Add more cases as needed
|
||||
}
|
||||
|
||||
/// Current expiry rule - change this to modify expiry date calculation
|
||||
private var currentExpiryRule: SubscriptionExpiryRule {
|
||||
.endOfCurrentMonth // Can be easily changed to any other rule
|
||||
}
|
||||
|
||||
// MARK: - Helper Properties for Expiry Date Calculation
|
||||
|
||||
/// Current subscription expiry date based on configured rule
|
||||
private var subscriptionExpiryDate: Date {
|
||||
let calendar = Calendar.current
|
||||
let now = Date()
|
||||
|
||||
switch currentExpiryRule {
|
||||
case .endOfCurrentMonth:
|
||||
let endOfMonth = calendar.dateInterval(of: .month, for: now)?.end ?? now
|
||||
return calendar.date(byAdding: .day, value: -1, to: endOfMonth) ?? now
|
||||
|
||||
case .specificDaysFromNow(let days):
|
||||
return calendar.date(byAdding: .day, value: days, to: now) ?? now
|
||||
|
||||
case .endOfNextMonth:
|
||||
let nextMonth = calendar.date(byAdding: .month, value: 1, to: now) ?? now
|
||||
let endOfNextMonth = calendar.dateInterval(of: .month, for: nextMonth)?.end ?? now
|
||||
return calendar.date(byAdding: .day, value: -1, to: endOfNextMonth) ?? now
|
||||
}
|
||||
}
|
||||
|
||||
/// Formatted expiry date string in yy:mm:dd format
|
||||
private var expiryDateString: String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "yy:MM:dd"
|
||||
return formatter.string(from: subscriptionExpiryDate)
|
||||
}
|
||||
|
||||
/// Remaining days until subscription expiry
|
||||
private var remainingDays: Int {
|
||||
let calendar = Calendar.current
|
||||
let days = calendar.dateComponents([.day], from: Date(), to: subscriptionExpiryDate).day ?? 0
|
||||
return max(days, 1) // At least 1 day to avoid division by zero
|
||||
}
|
||||
|
||||
/// Remaining balance in cents
|
||||
private var remainingBalanceCents: Int {
|
||||
return max((hardLimitDollars * 100) - spendingCents, 0)
|
||||
}
|
||||
|
||||
/// Average daily spending allowance from remaining balance
|
||||
private var averageDailyAllowance: String {
|
||||
let dailyAllowanceCents = remainingBalanceCents / remainingDays
|
||||
return dailyAllowanceCents.dollarStringFromCents
|
||||
}
|
||||
|
||||
var billingMetrics: MetricsViewDataSource {
|
||||
// 如果有新的usageSummary数据,优先使用
|
||||
if let usageSummary = usageSummary {
|
||||
let description = "Expires \(expiryDateString)"
|
||||
|
||||
// UsageSummary 的 used/limit 已经是美分,直接转换为美元显示
|
||||
return MetricsViewDataSource(
|
||||
icon: "dollarsign.circle.fill",
|
||||
title: "Plan Usage",
|
||||
description: description,
|
||||
currentValue: usageSummary.individualUsage.plan.used.dollarStringFromCents,
|
||||
targetValue: usageSummary.individualUsage.plan.limit.dollarStringFromCents,
|
||||
progress: min(Double(usageSummary.individualUsage.plan.used) / Double(usageSummary.individualUsage.plan.limit), 1),
|
||||
tint: Color(hex: "55E07A")
|
||||
)
|
||||
} else {
|
||||
// 回退到旧的数据源
|
||||
let description = "Expires \(expiryDateString), \(averageDailyAllowance)/day remaining"
|
||||
|
||||
return MetricsViewDataSource(
|
||||
icon: "dollarsign.circle.fill",
|
||||
title: "Usage Spending",
|
||||
description: description,
|
||||
currentValue: spendingCents.dollarStringFromCents,
|
||||
targetValue: (hardLimitDollars * 100).dollarStringFromCents,
|
||||
progress: min(Double(spendingCents) / Double(hardLimitDollars * 100), 1),
|
||||
tint: Color(hex: "55E07A")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
var onDemandMetrics: MetricsViewDataSource? {
|
||||
guard let usageSummary = usageSummary,
|
||||
let onDemand = usageSummary.individualUsage.onDemand,
|
||||
let limit = onDemand.limit else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let description = "Expires \(expiryDateString)"
|
||||
|
||||
// UsageSummary 的 used/limit 已经是美分,直接转换为美元显示
|
||||
return MetricsViewDataSource(
|
||||
icon: "bolt.circle.fill",
|
||||
title: "On-Demand Usage",
|
||||
description: description,
|
||||
currentValue: onDemand.used.dollarStringFromCents,
|
||||
targetValue: limit.dollarStringFromCents,
|
||||
progress: min(Double(onDemand.used) / Double(limit), 1),
|
||||
tint: Color(hex: "FF6B6B")
|
||||
)
|
||||
}
|
||||
|
||||
var freeUsageMetrics: MetricsViewDataSource? {
|
||||
guard freeUsageCents > 0 else { return nil }
|
||||
let description = "Free credits (team plan)"
|
||||
return MetricsViewDataSource(
|
||||
icon: "gift.circle.fill",
|
||||
title: "Free Usage",
|
||||
description: description,
|
||||
currentValue: freeUsageCents.dollarStringFromCents,
|
||||
targetValue: nil,
|
||||
progress: 1.0,
|
||||
tint: Color(hex: "4DA3FF")
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
import SwiftUI
|
||||
import VibeviewerModel
|
||||
import VibeviewerCore
|
||||
import Charts
|
||||
|
||||
struct ModelsUsageBarChartView: View {
|
||||
let data: ModelsUsageChartData
|
||||
|
||||
@State private var selectedDate: String?
|
||||
|
||||
// 基于“模型前缀 → 基础色”的分组映射,整体采用墨绿色系的相近色
|
||||
// 这里的颜色是几种不同明度/偏色的墨绿色,方便同一前缀下做细微区分
|
||||
private let mossGreenPalette: [Color] = [
|
||||
Color(red: 0/255, green: 92/255, blue: 66/255), // 深墨绿
|
||||
Color(red: 24/255, green: 120/255, blue: 88/255), // 偏亮墨绿
|
||||
Color(red: 16/255, green: 104/255, blue: 80/255), // 略偏蓝的墨绿
|
||||
Color(red: 40/255, green: 132/255, blue: 96/255), // 柔和一点的墨绿
|
||||
Color(red: 6/255, green: 76/255, blue: 60/255) // 更深一点的墨绿
|
||||
]
|
||||
|
||||
/// 不同模型前缀对应的基础 palette 偏移量(同一前缀颜色更接近)
|
||||
private let modelPrefixOffsets: [String: Int] = [
|
||||
"gpt-": 0,
|
||||
"claude-": 1,
|
||||
"composer-": 2,
|
||||
"grok-": 3,
|
||||
"Other": 4
|
||||
]
|
||||
|
||||
/// 实际用于展示的数据点(最多 7 天,优先展示最近的数据)
|
||||
private var displayedDataPoints: [ModelsUsageChartData.DataPoint] {
|
||||
guard data.dataPoints.count > 7 else {
|
||||
return data.dataPoints
|
||||
}
|
||||
return Array(data.dataPoints.suffix(7))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
if displayedDataPoints.isEmpty {
|
||||
emptyView
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
chartView
|
||||
legendView
|
||||
summaryView
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var emptyView: some View {
|
||||
Text("暂无数据")
|
||||
.font(.app(.satoshiRegular, size: 12))
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
.padding(.vertical, 40)
|
||||
}
|
||||
|
||||
private var chartView: some View {
|
||||
Chart {
|
||||
ForEach(displayedDataPoints, id: \.date) { item in
|
||||
let stackedData = calculateStackedData(for: item)
|
||||
|
||||
ForEach(Array(stackedData.enumerated()), id: \.offset) { index, stackedItem in
|
||||
BarMark(
|
||||
x: .value("Date", item.dateLabel),
|
||||
yStart: .value("Start", stackedItem.start),
|
||||
yEnd: .value("End", stackedItem.end)
|
||||
)
|
||||
.foregroundStyle(barColor(for: stackedItem.modelName, dateLabel: item.dateLabel))
|
||||
.cornerRadius(4)
|
||||
.opacity(shouldDimBar(for: item.dateLabel) ? 0.4 : 1.0)
|
||||
}
|
||||
}
|
||||
|
||||
if let selectedDate = selectedDate,
|
||||
let selectedItem = displayedDataPoints.first(where: { $0.dateLabel == selectedDate }) {
|
||||
RuleMark(x: .value("Selected", selectedDate))
|
||||
.lineStyle(StrokeStyle(lineWidth: 2, dash: [4]))
|
||||
.foregroundStyle(Color.gray.opacity(0.3))
|
||||
.annotation(
|
||||
position: annotationPosition(for: selectedDate),
|
||||
alignment: .center,
|
||||
spacing: 8,
|
||||
overflowResolution: AnnotationOverflowResolution(x: .disabled, y: .disabled)
|
||||
) {
|
||||
annotationView(for: selectedItem)
|
||||
}
|
||||
}
|
||||
}
|
||||
// 确保 X 轴始终展示所有日期标签(即使某些日期没有数据)
|
||||
.chartXScale(domain: displayedDataPoints.map { $0.dateLabel })
|
||||
.chartXSelection(value: $selectedDate)
|
||||
.chartYAxis {
|
||||
AxisMarks(position: .leading) { value in
|
||||
AxisValueLabel {
|
||||
if let intValue = value.as(Int.self) {
|
||||
Text("\(intValue)")
|
||||
.font(.app(.satoshiRegular, size: 10))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
AxisGridLine(stroke: StrokeStyle(lineWidth: 0.5))
|
||||
.foregroundStyle(.secondary.opacity(0.2))
|
||||
}
|
||||
}
|
||||
.chartXAxis {
|
||||
AxisMarks { value in
|
||||
AxisValueLabel {
|
||||
if let stringValue = value.as(String.self) {
|
||||
Text(stringValue)
|
||||
.font(.app(.satoshiRegular, size: 9))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(height: 180)
|
||||
.animation(.easeInOut(duration: 0.2), value: selectedDate)
|
||||
}
|
||||
|
||||
private func barColor(for modelName: String, dateLabel: String) -> AnyShapeStyle {
|
||||
let color = colorForModel(modelName)
|
||||
if selectedDate == dateLabel {
|
||||
return AnyShapeStyle(color.opacity(0.9))
|
||||
} else {
|
||||
return AnyShapeStyle(color.gradient)
|
||||
}
|
||||
}
|
||||
|
||||
private func colorForModel(_ modelName: String) -> Color {
|
||||
// 1. 根据模型名前缀找到对应的基础偏移量
|
||||
let prefixOffset: Int = {
|
||||
for (prefix, offset) in modelPrefixOffsets {
|
||||
if modelName.hasPrefix(prefix) {
|
||||
return offset
|
||||
}
|
||||
}
|
||||
// 没有匹配到已知前缀时,统一归为 "Other" 分组
|
||||
return modelPrefixOffsets["Other"] ?? 0
|
||||
}()
|
||||
|
||||
// 2. 使用模型名的哈希生成一个稳定的索引,叠加前缀偏移,让同一前缀的颜色彼此相近
|
||||
let hash = abs(modelName.hashValue)
|
||||
let index = (prefixOffset + hash) % mossGreenPalette.count
|
||||
|
||||
return mossGreenPalette[index]
|
||||
}
|
||||
|
||||
private func shouldDimBar(for dateLabel: String) -> Bool {
|
||||
guard selectedDate != nil else { return false }
|
||||
return selectedDate != dateLabel
|
||||
}
|
||||
|
||||
/// 根据选中项的位置动态计算 annotation 位置
|
||||
/// 左侧使用 topTrailing,右侧使用 topLeading,中间使用 top
|
||||
private func annotationPosition(for dateLabel: String) -> AnnotationPosition {
|
||||
guard let selectedIndex = displayedDataPoints.firstIndex(where: { $0.dateLabel == dateLabel }) else {
|
||||
return .top
|
||||
}
|
||||
|
||||
let totalCount = displayedDataPoints.count
|
||||
let middleIndex = totalCount / 2
|
||||
|
||||
if selectedIndex < middleIndex {
|
||||
// 左侧:使用 topTrailing,annotation 显示在右侧
|
||||
return .topTrailing
|
||||
} else if selectedIndex > middleIndex {
|
||||
// 右侧:使用 topLeading,annotation 显示在左侧
|
||||
return .topLeading
|
||||
} else {
|
||||
// 中间:使用 top
|
||||
return .top
|
||||
}
|
||||
}
|
||||
|
||||
/// 计算堆叠数据:为每个模型计算起始和结束位置
|
||||
private func calculateStackedData(for item: ModelsUsageChartData.DataPoint) -> [(modelName: String, start: Int, end: Int)] {
|
||||
var cumulativeY: Int = 0
|
||||
var result: [(modelName: String, start: Int, end: Int)] = []
|
||||
|
||||
for modelUsage in item.modelUsages {
|
||||
if modelUsage.requests > 0 {
|
||||
result.append((
|
||||
modelName: modelUsage.modelName,
|
||||
start: cumulativeY,
|
||||
end: cumulativeY + modelUsage.requests
|
||||
))
|
||||
cumulativeY += modelUsage.requests
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
private var legendView: some View {
|
||||
// 获取所有唯一的模型名称
|
||||
let uniqueModels = Set(displayedDataPoints.flatMap { $0.modelUsages.map { $0.modelName } })
|
||||
.sorted()
|
||||
|
||||
// 限制显示的模型数量(最多显示前8个)
|
||||
let displayedModels = Array(uniqueModels.prefix(8))
|
||||
|
||||
return ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 16) {
|
||||
ForEach(displayedModels, id: \.self) { modelName in
|
||||
HStack(spacing: 6) {
|
||||
RoundedRectangle(cornerRadius: 2)
|
||||
.fill(colorForModel(modelName).gradient)
|
||||
.frame(width: 12, height: 12)
|
||||
Text(modelName)
|
||||
.font(.app(.satoshiRegular, size: 10))
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
|
||||
if uniqueModels.count > 8 {
|
||||
Text("+\(uniqueModels.count - 8) more")
|
||||
.font(.app(.satoshiRegular, size: 10))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func annotationView(for item: ModelsUsageChartData.DataPoint) -> some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(item.dateLabel)
|
||||
.font(.app(.satoshiMedium, size: 11))
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
ForEach(item.modelUsages.prefix(5), id: \.modelName) { modelUsage in
|
||||
if modelUsage.requests > 0 {
|
||||
HStack(spacing: 6) {
|
||||
Circle()
|
||||
.fill(colorForModel(modelUsage.modelName))
|
||||
.frame(width: 6, height: 6)
|
||||
Text("\(modelUsage.modelName): \(modelUsage.requests)")
|
||||
.font(.app(.satoshiRegular, size: 11))
|
||||
.foregroundStyle(.primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if item.modelUsages.count > 5 {
|
||||
Text("... and \(item.modelUsages.count - 5) more")
|
||||
.font(.app(.satoshiRegular, size: 10))
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.leading, 12)
|
||||
}
|
||||
|
||||
if item.modelUsages.count > 1 {
|
||||
Divider()
|
||||
.padding(.vertical, 2)
|
||||
|
||||
Text("Total: \(item.totalValue)")
|
||||
.font(.app(.satoshiBold, size: 13))
|
||||
.foregroundStyle(.primary)
|
||||
} else if let firstModel = item.modelUsages.first {
|
||||
Text("\(firstModel.requests) requests")
|
||||
.font(.app(.satoshiBold, size: 13))
|
||||
.foregroundStyle(.primary)
|
||||
.padding(.top, 2)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
.fixedSize(horizontal: true, vertical: false)
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.fill(.background)
|
||||
.shadow(color: .black.opacity(0.1), radius: 4, x: 0, y: 2)
|
||||
}
|
||||
}
|
||||
|
||||
private var summaryView: some View {
|
||||
HStack(spacing: 16) {
|
||||
if let total = totalValue {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Total")
|
||||
.font(.app(.satoshiRegular, size: 10))
|
||||
.foregroundStyle(.secondary)
|
||||
Text("\(total)")
|
||||
.font(.app(.satoshiBold, size: 14))
|
||||
.foregroundStyle(.primary)
|
||||
}
|
||||
}
|
||||
|
||||
if let avg = averageValue {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Average")
|
||||
.font(.app(.satoshiRegular, size: 10))
|
||||
.foregroundStyle(.secondary)
|
||||
Text(String(format: "%.1f", avg))
|
||||
.font(.app(.satoshiBold, size: 14))
|
||||
.foregroundStyle(.primary)
|
||||
}
|
||||
}
|
||||
|
||||
if let max = maxValue {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Peak")
|
||||
.font(.app(.satoshiRegular, size: 10))
|
||||
.foregroundStyle(.secondary)
|
||||
Text("\(max)")
|
||||
.font(.app(.satoshiBold, size: 14))
|
||||
.foregroundStyle(.primary)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding(.top, 8)
|
||||
}
|
||||
|
||||
private var totalValue: Int? {
|
||||
guard !displayedDataPoints.isEmpty else { return nil }
|
||||
return displayedDataPoints.reduce(0) { $0 + $1.totalValue }
|
||||
}
|
||||
|
||||
private var averageValue: Double? {
|
||||
guard let total = totalValue, !displayedDataPoints.isEmpty else { return nil }
|
||||
return Double(total) / Double(displayedDataPoints.count)
|
||||
}
|
||||
|
||||
private var maxValue: Int? {
|
||||
displayedDataPoints.map { $0.totalValue }.max()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import SwiftUI
|
||||
import VibeviewerModel
|
||||
import VibeviewerCore
|
||||
import VibeviewerShareUI
|
||||
|
||||
struct TotalCreditsUsageView: View {
|
||||
let snapshot: DashboardSnapshot?
|
||||
|
||||
@State private var isModelsUsageExpanded: Bool = false
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .trailing, spacing: 6) {
|
||||
if let billingCycleText {
|
||||
Text(billingCycleText)
|
||||
.font(.app(.satoshiRegular, size: 10))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
headerView
|
||||
|
||||
if isModelsUsageExpanded, let modelsUsageSummary = snapshot?.modelsUsageSummary {
|
||||
modelsUsageDetailView(modelsUsageSummary)
|
||||
}
|
||||
|
||||
Text(snapshot?.displayTotalUsageCents.dollarStringFromCents ?? "0")
|
||||
.font(.app(.satoshiBold, size: 16))
|
||||
.foregroundStyle(.primary)
|
||||
.contentTransition(.numericText())
|
||||
}
|
||||
.maxFrame(true, false, alignment: .trailing)
|
||||
}
|
||||
|
||||
private var headerView: some View {
|
||||
HStack(alignment: .center, spacing: 4) {
|
||||
Text("Total Credits Usage")
|
||||
.font(.app(.satoshiRegular, size: 12))
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
// 如果有模型用量数据,显示展开/折叠箭头
|
||||
if snapshot?.modelsUsageSummary != nil {
|
||||
Image(systemName: "chevron.down")
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
.rotationEffect(.degrees(isModelsUsageExpanded ? 180 : 0))
|
||||
}
|
||||
}
|
||||
.onTapGesture {
|
||||
if snapshot?.modelsUsageSummary != nil {
|
||||
isModelsUsageExpanded.toggle()
|
||||
}
|
||||
}
|
||||
.maxFrame(true, false, alignment: .trailing)
|
||||
}
|
||||
|
||||
private func modelsUsageDetailView(_ summary: ModelsUsageSummary) -> some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
ForEach(summary.modelsSortedByCost.prefix(5), id: \.modelName) { model in
|
||||
UsageEventView.EventItemView(event: makeAggregateEvent(from: model))
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// 将模型聚合数据映射为一个“虚构”的 UsageEvent,供 UsageEventView.EventItemView 复用 UI
|
||||
private func makeAggregateEvent(from model: ModelUsageInfo) -> UsageEvent {
|
||||
let tokenUsage = TokenUsage(
|
||||
outputTokens: model.outputTokens,
|
||||
inputTokens: model.inputTokens,
|
||||
totalCents: model.costCents,
|
||||
cacheWriteTokens: model.cacheWriteTokens,
|
||||
cacheReadTokens: model.cacheReadTokens
|
||||
)
|
||||
|
||||
// occurredAtMs 使用 "0" 即可,这里不会参与分组和排序,仅用于展示
|
||||
return UsageEvent(
|
||||
occurredAtMs: "0",
|
||||
modelName: model.modelName,
|
||||
kind: "aggregate",
|
||||
requestCostCount: 0,
|
||||
usageCostDisplay: model.formattedCost,
|
||||
usageCostCents: Int(model.costCents.rounded()),
|
||||
isTokenBased: true,
|
||||
userDisplayName: "",
|
||||
cursorTokenFee: 0,
|
||||
tokenUsage: tokenUsage
|
||||
)
|
||||
}
|
||||
|
||||
/// 当前计费周期展示文案(如 "Billing cycle: Oct 1 – Oct 31")
|
||||
private var billingCycleText: String? {
|
||||
guard
|
||||
let startMs = snapshot?.billingCycleStartMs,
|
||||
let endMs = snapshot?.billingCycleEndMs,
|
||||
let startDate = Date.fromMillisecondsString(startMs),
|
||||
let endDate = Date.fromMillisecondsString(endMs)
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "MMM d"
|
||||
let start = formatter.string(from: startDate)
|
||||
let end = formatter.string(from: endDate)
|
||||
|
||||
return "\(start) – \(end)"
|
||||
}
|
||||
|
||||
private func formatNumber(_ number: Int) -> String {
|
||||
let formatter = NumberFormatter()
|
||||
formatter.numberStyle = .decimal
|
||||
formatter.groupingSeparator = ","
|
||||
return formatter.string(from: NSNumber(value: number)) ?? "\(number)"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import SwiftUI
|
||||
import VibeviewerShareUI
|
||||
|
||||
@MainActor
|
||||
struct UnloginView: View {
|
||||
enum LoginMethod: String, CaseIterable, Identifiable {
|
||||
case web
|
||||
case cookie
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .web:
|
||||
return "Web Login"
|
||||
case .cookie:
|
||||
return "Cookie Login"
|
||||
}
|
||||
}
|
||||
|
||||
var description: String {
|
||||
switch self {
|
||||
case .web:
|
||||
return "Open Cursor login page and automatically capture your cookies after login."
|
||||
case .cookie:
|
||||
return "Paste your Cursor cookie header (from browser Developer Tools) to log in directly."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let onWebLogin: () -> Void
|
||||
let onCookieLogin: (String) -> Void
|
||||
|
||||
@State private var selectedLoginMethod: LoginMethod = .web
|
||||
@State private var manualCookie: String = ""
|
||||
@State private var manualCookieError: String?
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Login to Cursor")
|
||||
.font(.app(.satoshiBold, size: 16))
|
||||
|
||||
Text("Choose a login method that works best for you.")
|
||||
.font(.app(.satoshiMedium, size: 11))
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Picker("Login Method", selection: $selectedLoginMethod) {
|
||||
ForEach(LoginMethod.allCases) { method in
|
||||
Text(method.title).tag(method)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.labelsHidden()
|
||||
|
||||
Text(selectedLoginMethod.description)
|
||||
.font(.app(.satoshiMedium, size: 11))
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Group {
|
||||
switch selectedLoginMethod {
|
||||
case .web:
|
||||
Button {
|
||||
onWebLogin()
|
||||
} label: {
|
||||
Text("Login via Web")
|
||||
}
|
||||
.buttonStyle(.vibe(.primary))
|
||||
|
||||
case .cookie:
|
||||
manualCookieLoginView
|
||||
}
|
||||
}
|
||||
}
|
||||
.maxFrame(true, false, alignment: .leading)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var manualCookieLoginView: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Cursor Cookie Header")
|
||||
.font(.app(.satoshiMedium, size: 12))
|
||||
|
||||
TextEditor(text: $manualCookie)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
.frame(minHeight: 80, maxHeight: 120)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 6)
|
||||
.stroke(Color.secondary.opacity(0.3), lineWidth: 1)
|
||||
)
|
||||
.overlay {
|
||||
if manualCookie.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
Text("Example:\nCookie: cursor_session=...; other_key=...")
|
||||
.foregroundStyle(Color.secondary.opacity(0.7))
|
||||
.font(.app(.satoshiMedium, size: 10))
|
||||
.padding(6)
|
||||
.frame(
|
||||
maxWidth: .infinity,
|
||||
maxHeight: .infinity,
|
||||
alignment: .topLeading
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if let error = manualCookieError {
|
||||
Text(error)
|
||||
.font(.app(.satoshiMedium, size: 10))
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
|
||||
HStack {
|
||||
Spacer()
|
||||
Button("Login with Cookie") {
|
||||
submitManualCookie()
|
||||
}
|
||||
.buttonStyle(.vibe(.primary))
|
||||
.disabled(normalizedCookieHeader(from: manualCookie).isEmpty)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func submitManualCookie() {
|
||||
let normalized = normalizedCookieHeader(from: manualCookie)
|
||||
guard !normalized.isEmpty else {
|
||||
manualCookieError = "Cookie header cannot be empty."
|
||||
return
|
||||
}
|
||||
manualCookieError = nil
|
||||
onCookieLogin(normalized)
|
||||
}
|
||||
|
||||
/// 归一化用户输入的 Cookie 字符串:
|
||||
/// - 去除首尾空白
|
||||
/// - 支持用户直接粘贴包含 `Cookie:` 或 `cookie:` 前缀的完整请求头
|
||||
private func normalizedCookieHeader(from input: String) -> String {
|
||||
var value = input.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !value.isEmpty else { return "" }
|
||||
|
||||
let lowercased = value.lowercased()
|
||||
if lowercased.hasPrefix("cookie:") {
|
||||
if let range = value.range(of: ":", options: .caseInsensitive) {
|
||||
let afterColon = value[range.upperBound...]
|
||||
value = String(afterColon).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
}
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
import SwiftUI
|
||||
import VibeviewerModel
|
||||
import VibeviewerShareUI
|
||||
import VibeviewerCore
|
||||
|
||||
struct UsageEventView: View {
|
||||
var events: [UsageEvent]
|
||||
@Environment(AppSettings.self) private var appSettings
|
||||
|
||||
var body: some View {
|
||||
UsageEventViewBody(events: events, limit: appSettings.usageHistory.limit)
|
||||
}
|
||||
|
||||
struct EventItemView: View {
|
||||
let event: UsageEvent
|
||||
@State private var isExpanded = false
|
||||
|
||||
// MARK: - Body
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
mainRowView
|
||||
|
||||
if isExpanded {
|
||||
expandedDetailsView
|
||||
}
|
||||
}
|
||||
.animation(.easeInOut(duration: 0.2), value: isExpanded)
|
||||
}
|
||||
// MARK: - Computed Properties
|
||||
|
||||
private var totalTokensDisplay: String {
|
||||
let totalTokens = event.tokenUsage?.totalTokens ?? 0
|
||||
let value = Double(totalTokens)
|
||||
|
||||
switch totalTokens {
|
||||
case 0..<1_000:
|
||||
return "\(totalTokens)"
|
||||
case 1_000..<1_000_000:
|
||||
return String(format: "%.1fK", value / 1_000.0)
|
||||
case 1_000_000..<1_000_000_000:
|
||||
return String(format: "%.2fM", value / 1_000_000.0)
|
||||
default:
|
||||
return String(format: "%.2fB", value / 1_000_000_000.0)
|
||||
}
|
||||
}
|
||||
|
||||
private var costDisplay: String {
|
||||
let totalCents = (event.tokenUsage?.totalCents ?? 0.0) + event.cursorTokenFee
|
||||
let dollars = totalCents / 100.0
|
||||
return String(format: "$%.2f", dollars)
|
||||
}
|
||||
|
||||
private var tokenDetails: [(label: String, value: Int)] {
|
||||
let rawDetails: [(String, Int)] = [
|
||||
("Input", event.tokenUsage?.inputTokens ?? 0),
|
||||
("Output", event.tokenUsage?.outputTokens ?? 0),
|
||||
("Cache Write", event.tokenUsage?.cacheWriteTokens ?? 0),
|
||||
("Cache Read", event.tokenUsage?.cacheReadTokens ?? 0),
|
||||
("Total Tokens", event.tokenUsage?.totalTokens ?? 0),
|
||||
]
|
||||
return rawDetails
|
||||
}
|
||||
|
||||
// MARK: - Subviews
|
||||
|
||||
private var brandLogoView: some View {
|
||||
event.brand.logo
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: 18, height: 18)
|
||||
.padding(6)
|
||||
.background(.thinMaterial, in: .circle)
|
||||
}
|
||||
|
||||
private var modelNameView: some View {
|
||||
Text(event.modelName)
|
||||
.font(.app(.satoshiBold, size: 14))
|
||||
.lineLimit(1)
|
||||
// .foregroundStyle(event.kind.isError ? AnyShapeStyle(Color.red.secondary) : AnyShapeStyle(.primary))
|
||||
}
|
||||
|
||||
private var tokenCostView: some View {
|
||||
HStack(spacing: 12) {
|
||||
Text(totalTokensDisplay)
|
||||
.font(.app(.satoshiMedium, size: 12))
|
||||
.foregroundStyle(.secondary)
|
||||
.monospacedDigit()
|
||||
|
||||
Text(costDisplay)
|
||||
.font(.app(.satoshiMedium, size: 12))
|
||||
.foregroundStyle(.secondary)
|
||||
.monospacedDigit()
|
||||
}
|
||||
.layoutPriority(1)
|
||||
}
|
||||
|
||||
private var mainRowView: some View {
|
||||
HStack(spacing: 12) {
|
||||
brandLogoView
|
||||
modelNameView
|
||||
Spacer()
|
||||
tokenCostView
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
isExpanded.toggle()
|
||||
}
|
||||
}
|
||||
|
||||
private func tokenDetailRowView(for detail: (String, Int)) -> some View {
|
||||
HStack {
|
||||
Text(detail.0)
|
||||
.font(.app(.satoshiRegular, size: 12))
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 70, alignment: .leading)
|
||||
|
||||
Spacer()
|
||||
|
||||
Text("\(detail.1)")
|
||||
.font(.app(.satoshiMedium, size: 12))
|
||||
.foregroundStyle(.primary)
|
||||
.monospacedDigit()
|
||||
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
}
|
||||
|
||||
private var expandedDetailsView: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
ForEach(tokenDetails, id: \.0) { detail in
|
||||
tokenDetailRowView(for: detail)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
.transition(.opacity)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
struct UsageEventViewBody: View {
|
||||
let events: [UsageEvent]
|
||||
let limit: Int
|
||||
|
||||
private var groups: [UsageEventHourGroup] {
|
||||
Array(events.prefix(limit)).groupedByHour()
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
UsageEventGroupsView(groups: groups)
|
||||
}
|
||||
}
|
||||
|
||||
struct UsageEventGroupsView: View {
|
||||
let groups: [UsageEventHourGroup]
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
ForEach(groups) { group in
|
||||
HourGroupSectionView(group: group)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct HourGroupSectionView: View {
|
||||
let group: UsageEventHourGroup
|
||||
|
||||
var body: some View {
|
||||
let totalRequestsText: String = String(group.totalRequests)
|
||||
let totalCostText: String = {
|
||||
let totalCents = group.events.reduce(0.0) { sum, event in
|
||||
sum + (event.tokenUsage?.totalCents ?? 0.0) + event.cursorTokenFee
|
||||
}
|
||||
let dollars = totalCents / 100.0
|
||||
return String(format: "$%.2f", dollars)
|
||||
}()
|
||||
return VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(spacing: 8) {
|
||||
Text(group.title)
|
||||
.font(.app(.satoshiBold, size: 12))
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
HStack(spacing: 6) {
|
||||
HStack(alignment: .center, spacing: 2) {
|
||||
Image(systemName: "arrow.triangle.2.circlepath")
|
||||
.font(.app(.satoshiMedium, size: 10))
|
||||
.foregroundStyle(.primary)
|
||||
Text(totalRequestsText)
|
||||
.font(.app(.satoshiMedium, size: 12))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
HStack(alignment: .center, spacing: 2) {
|
||||
Image(systemName: "dollarsign.circle")
|
||||
.font(.app(.satoshiMedium, size: 10))
|
||||
.foregroundStyle(.primary)
|
||||
Text(totalCostText)
|
||||
.font(.app(.satoshiMedium, size: 12))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ForEach(group.events, id: \.occurredAtMs) { event in
|
||||
UsageEventView.EventItemView(event: event)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import SwiftUI
|
||||
import Observation
|
||||
import VibeviewerModel
|
||||
import VibeviewerShareUI
|
||||
|
||||
struct UsageHeaderView: View {
|
||||
enum Action {
|
||||
case dashboard
|
||||
}
|
||||
|
||||
var action: (Action) -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Text("VibeViewer")
|
||||
.font(.app(.satoshiMedium, size: 16))
|
||||
.foregroundStyle(.primary)
|
||||
Spacer()
|
||||
|
||||
Button("Dashboard") {
|
||||
action(.dashboard)
|
||||
}
|
||||
.buttonStyle(.vibe(.secondary))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import SwiftUI
|
||||
import VibeviewerModel
|
||||
|
||||
@MainActor
|
||||
struct UsageHistorySection: View {
|
||||
let isLoading: Bool
|
||||
@Bindable var settings: AppSettings
|
||||
let events: [UsageEvent]
|
||||
let onReload: () -> Void
|
||||
let onToday: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Divider()
|
||||
HStack {
|
||||
Spacer()
|
||||
Stepper("条数: \(self.settings.usageHistory.limit)", value: self.$settings.usageHistory.limit, in: 1 ... 100)
|
||||
.frame(minWidth: 120)
|
||||
}
|
||||
.font(.callout)
|
||||
|
||||
HStack(spacing: 10) {
|
||||
if self.isLoading {
|
||||
ProgressView()
|
||||
} else {
|
||||
Button("加载用量历史") { self.onReload() }
|
||||
}
|
||||
Button("今天") { self.onToday() }
|
||||
}
|
||||
|
||||
if !self.events.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
ForEach(Array(self.events.prefix(self.settings.usageHistory.limit).enumerated()), id: \.offset) { _, e in
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
Text(self.formatTimestamp(e.occurredAtMs))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 120, alignment: .leading)
|
||||
Text(e.modelName)
|
||||
.font(.callout)
|
||||
.frame(minWidth: 90, alignment: .leading)
|
||||
Spacer(minLength: 6)
|
||||
Text("req: \(e.requestCostCount)")
|
||||
.font(.caption)
|
||||
Text(e.usageCostDisplay)
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.top, 4)
|
||||
} else {
|
||||
Text("暂无用量历史").font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func formatTimestamp(_ msString: String) -> String {
|
||||
guard let ms = Double(msString) else { return msString }
|
||||
let date = Date(timeIntervalSince1970: ms / 1000.0)
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "HH:mm:ss"
|
||||
return formatter.string(from: date)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import SwiftUI
|
||||
import VibeviewerCore
|
||||
import VibeviewerModel
|
||||
|
||||
@MainActor
|
||||
struct DashboardSummaryView: View {
|
||||
let snapshot: DashboardSnapshot?
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if let snapshot {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("邮箱: \(snapshot.email)")
|
||||
Text("所有模型总请求: \(snapshot.totalRequestsAllModels)")
|
||||
Text("Usage Spending ($): \(snapshot.spendingCents.dollarStringFromCents)")
|
||||
Text("预算上限 ($): \(snapshot.hardLimitDollars)")
|
||||
|
||||
if let usageSummary = snapshot.usageSummary {
|
||||
Text("Plan Usage: \(usageSummary.individualUsage.plan.used)/\(usageSummary.individualUsage.plan.limit)")
|
||||
if let onDemand = usageSummary.individualUsage.onDemand,
|
||||
let limit = onDemand.limit {
|
||||
Text("On-Demand Usage: \(onDemand.used)/\(limit)")
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Text("未登录,请先登录 Cursor")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
import Observation
|
||||
import SwiftUI
|
||||
import VibeviewerAPI
|
||||
import VibeviewerAppEnvironment
|
||||
import VibeviewerLoginUI
|
||||
import VibeviewerModel
|
||||
import VibeviewerSettingsUI
|
||||
import VibeviewerCore
|
||||
import VibeviewerShareUI
|
||||
|
||||
@MainActor
|
||||
public struct MenuPopoverView: View {
|
||||
@Environment(\.loginService) private var loginService
|
||||
@Environment(\.cursorStorage) private var storage
|
||||
@Environment(\.loginWindowManager) private var loginWindow
|
||||
@Environment(\.settingsWindowManager) private var settingsWindow
|
||||
@Environment(\.dashboardRefreshService) private var refresher
|
||||
@Environment(AppSettings.self) private var appSettings
|
||||
@Environment(AppSession.self) private var session
|
||||
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
enum ViewState: Equatable {
|
||||
case loading
|
||||
case loaded
|
||||
case error(String)
|
||||
}
|
||||
|
||||
public init() {}
|
||||
|
||||
@State private var state: ViewState = .loading
|
||||
@State private var isLoggingIn: Bool = false
|
||||
@State private var loginError: String?
|
||||
|
||||
public var body: some View {
|
||||
@Bindable var appSettings = appSettings
|
||||
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
UsageHeaderView { action in
|
||||
switch action {
|
||||
case .dashboard:
|
||||
self.openDashboard()
|
||||
}
|
||||
}
|
||||
|
||||
if isLoggingIn {
|
||||
loginLoadingView
|
||||
} else if let snapshot = self.session.snapshot {
|
||||
if let loginError {
|
||||
// 出错时只展示错误视图,不展示旧的 snapshot 内容
|
||||
DashboardErrorView(
|
||||
message: loginError,
|
||||
onRetry: { manualRefresh() }
|
||||
)
|
||||
} else {
|
||||
let isProSeriesUser = snapshot.usageSummary?.membershipType.isProSeries == true
|
||||
|
||||
if !isProSeriesUser {
|
||||
MetricsView(metric: .billing(snapshot.billingMetrics))
|
||||
|
||||
if let free = snapshot.freeUsageMetrics {
|
||||
MetricsView(metric: .free(free))
|
||||
}
|
||||
|
||||
if let onDemandMetrics = snapshot.onDemandMetrics {
|
||||
MetricsView(metric: .onDemand(onDemandMetrics))
|
||||
}
|
||||
|
||||
Divider().opacity(0.5)
|
||||
}
|
||||
|
||||
UsageEventView(events: self.session.snapshot?.usageEvents ?? [])
|
||||
|
||||
if let modelsUsageChart = self.session.snapshot?.modelsUsageChart {
|
||||
Divider().opacity(0.5)
|
||||
|
||||
ModelsUsageBarChartView(data: modelsUsageChart)
|
||||
}
|
||||
|
||||
Divider().opacity(0.5)
|
||||
|
||||
TotalCreditsUsageView(snapshot: snapshot)
|
||||
|
||||
Divider().opacity(0.5)
|
||||
|
||||
MenuFooterView(onRefresh: {
|
||||
manualRefresh()
|
||||
})
|
||||
}
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
loginButtonView
|
||||
|
||||
if let loginError {
|
||||
DashboardErrorView(
|
||||
message: loginError,
|
||||
onRetry: nil
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 24)
|
||||
.background {
|
||||
ZStack {
|
||||
Color(hex: colorScheme == .dark ? "1F1E1E" : "F9F9F9")
|
||||
Circle()
|
||||
.fill(Color(hex: colorScheme == .dark ? "354E48" : "F2A48B"))
|
||||
.padding(80)
|
||||
.blur(radius: 100)
|
||||
}
|
||||
.cornerRadiusWithCorners(32 - 4)
|
||||
}
|
||||
.padding(session.credentials != nil ? 4 : 0)
|
||||
}
|
||||
|
||||
private var loginButtonView: some View {
|
||||
UnloginView(
|
||||
onWebLogin: {
|
||||
loginWindow.show(onCookieCaptured: { cookie in
|
||||
self.performLogin(with: cookie)
|
||||
})
|
||||
},
|
||||
onCookieLogin: { cookie in
|
||||
self.performLogin(with: cookie)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private func openDashboard() {
|
||||
NSWorkspace.shared.open(URL(string: "https://cursor.com/dashboard?tab=usage")!)
|
||||
}
|
||||
|
||||
private var loginLoadingView: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Logging in…")
|
||||
.font(.app(.satoshiBold, size: 16))
|
||||
|
||||
HStack(spacing: 8) {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
Text("Fetching your latest usage data, this may take a few seconds.")
|
||||
.font(.app(.satoshiMedium, size: 11))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.maxFrame(true, false, alignment: .leading)
|
||||
}
|
||||
|
||||
private func performLogin(with cookieHeader: String) {
|
||||
Task { @MainActor in
|
||||
self.loginError = nil
|
||||
self.isLoggingIn = true
|
||||
defer { self.isLoggingIn = false }
|
||||
|
||||
do {
|
||||
try await self.loginService.login(with: cookieHeader)
|
||||
} catch LoginServiceError.fetchAccountFailed {
|
||||
self.loginError = "Failed to fetch account info. Please check your cookie and try again."
|
||||
} catch LoginServiceError.saveCredentialsFailed {
|
||||
self.loginError = "Failed to save credentials locally. Please try again."
|
||||
} catch LoginServiceError.initialRefreshFailed {
|
||||
self.loginError = "Failed to load dashboard data. Please try again later."
|
||||
} catch {
|
||||
self.loginError = "Login failed. Please try again."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func manualRefresh() {
|
||||
Task { @MainActor in
|
||||
guard self.session.credentials != nil else {
|
||||
self.loginError = "You need to login before refreshing dashboard data."
|
||||
return
|
||||
}
|
||||
|
||||
self.loginError = nil
|
||||
|
||||
// 使用后台刷新服务的公共方法进行刷新
|
||||
await self.refresher.refreshNow()
|
||||
|
||||
// 如果刷新后完全没有 snapshot,则认为刷新失败并展示错误
|
||||
if self.session.snapshot == nil {
|
||||
self.loginError = "Failed to refresh dashboard data. Please try again later."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import SwiftUI
|
||||
import VibeviewerShareUI
|
||||
import VibeviewerAppEnvironment
|
||||
import VibeviewerModel
|
||||
|
||||
struct SettingsView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(AppSettings.self) private var appSettings
|
||||
|
||||
@State private var refreshFrequency: String = ""
|
||||
@State private var usageHistoryLimit: String = ""
|
||||
@State private var pauseOnScreenSleep: Bool = false
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
HStack {
|
||||
Text("Settings")
|
||||
.font(.app(.satoshiBold, size: 18))
|
||||
|
||||
Spacer()
|
||||
|
||||
Button {
|
||||
dismiss()
|
||||
} label: {
|
||||
Image(systemName: "xmark")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Refresh Frequency (minutes)")
|
||||
.font(.app(.satoshiMedium, size: 12))
|
||||
|
||||
TextField("5", text: $refreshFrequency)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: 80)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Usage History Limit")
|
||||
.font(.app(.satoshiMedium, size: 12))
|
||||
|
||||
TextField("5", text: $usageHistoryLimit)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: 80)
|
||||
}
|
||||
|
||||
Toggle("Pause refresh when screen sleeps", isOn: $pauseOnScreenSleep)
|
||||
.font(.app(.satoshiMedium, size: 12))
|
||||
}
|
||||
|
||||
HStack {
|
||||
Spacer()
|
||||
|
||||
Button("Cancel") {
|
||||
dismiss()
|
||||
}
|
||||
.buttonStyle(.vibe(Color(hex: "F58283").opacity(0.8)))
|
||||
|
||||
Button("Save") {
|
||||
saveSettings()
|
||||
dismiss()
|
||||
}
|
||||
.buttonStyle(.vibe(Color(hex: "5B67E2").opacity(0.8)))
|
||||
}
|
||||
}
|
||||
.padding(20)
|
||||
.frame(width: 320, height: 240)
|
||||
.onAppear {
|
||||
loadSettings()
|
||||
}
|
||||
}
|
||||
|
||||
private func loadSettings() {
|
||||
refreshFrequency = String(appSettings.overview.refreshInterval)
|
||||
usageHistoryLimit = String(appSettings.usageHistory.limit)
|
||||
pauseOnScreenSleep = appSettings.pauseOnScreenSleep
|
||||
}
|
||||
|
||||
private func saveSettings() {
|
||||
if let refreshValue = Int(refreshFrequency) {
|
||||
appSettings.overview.refreshInterval = refreshValue
|
||||
}
|
||||
|
||||
if let limitValue = Int(usageHistoryLimit) {
|
||||
appSettings.usageHistory.limit = limitValue
|
||||
}
|
||||
|
||||
appSettings.pauseOnScreenSleep = pauseOnScreenSleep
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user