蜂鸟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:
ccdojox-crypto
2025-12-18 11:21:52 +08:00
parent f310ca7b97
commit 73a71f198f
202 changed files with 19142 additions and 252 deletions

View File

@@ -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() }
}
}
}

View File

@@ -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)
)
}
}

View File

@@ -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)
}
}
}

View File

@@ -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()
}

View File

@@ -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)))
}
}
}

View File

@@ -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")
)
}
}

View File

@@ -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 {
// 使 topTrailingannotation
return .topTrailing
} else if selectedIndex > middleIndex {
// 使 topLeadingannotation
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()
}
}

View File

@@ -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)"
}
}

View File

@@ -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
}
}

View File

@@ -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)
}
}
}
}

View File

@@ -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))
}
}
}

View File

@@ -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)
}
}

View File

@@ -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")
}
}
}
}

View File

@@ -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."
}
}
}
}

View File

@@ -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
}
}