Files
cursornew2026/参考计费/Packages/VibeviewerMenuUI/Sources/VibeviewerMenuUI/Components/ModelsUsageBarChartView.swift
ccdojox-crypto 73a71f198f 蜂鸟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>
2025-12-18 11:21:52 +08:00

333 lines
13 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.

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