尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

SwiftUI开发macOS菜单栏应用:实时监控AI编程工具API用量

SwiftUI开发macOS菜单栏应用:实时监控AI编程工具API用量 最近在深度使用 Claude Code 和 Codex 进行开发时一个高频痛点反复出现每次想快速查看剩余的 API 调用额度或使用情况都得先放下手头的代码切换到浏览器或特定应用界面去查询流程被打断效率严重打折。对于依赖这些 AI 编码工具提升生产力的开发者来说这种“开小差”式的查询体验实在不够优雅。有没有一种方式能让这些关键信息像系统状态一样常驻在视线边缘随时可查又毫不打扰答案是肯定的而且它就藏在 macOS 最经典的设计元素之一——菜单栏Menu Bar里。本文将为你完整呈现如何打造一个原生、轻量、信息即时的 macOS 菜单栏应用专门用于监控 Claude Code 和 Codex 的用量与限制。从 SwiftUI 基础搭建、菜单栏交互设计到与 AI 服务 API 的实际对接、数据解析与展示再到应用打包与发布我们将一步步拆解。无论你是 Swift/macOS 开发新手还是想为常用工具打造效率利器的进阶开发者都能从本文获得可直接复用的代码与清晰的项目思路。1. 核心概念为何需要菜单栏应用在深入代码之前我们有必要厘清几个核心概念理解为什么菜单栏应用是解决此类需求的绝佳方案。1.1 菜单栏应用的本质macOS 的菜单栏位于屏幕顶部是一个系统级的全局区域。菜单栏应用Menu Bar App或称 Status Bar App将其主要界面精简为一个菜单栏图标或称状态项点击后以下拉菜单Menu或弹出面板Popover的形式与用户交互。这类应用的核心特点是常驻与轻量图标始终可见但应用本身可以非常轻量仅在需要时激活界面占用资源极少。即时访问用户无需切换应用或窗口一键即可获取信息或执行操作实现了真正的“零上下文切换”。无干扰当不需要时它只是一个安静的图标不会侵占宝贵的屏幕空间Dock 或桌面。1.2 Claude Code 与 Codex 的用量监控需求Claude Code 和 Codex 作为 AI 编程助手通常通过 API 提供服务并伴有使用限制例如速率限制Rate Limits每分钟/每小时/每天的最大请求次数。配额Quotas基于令牌Token的用量如每月免费额度或套餐包含的令牌数。用量统计Usage当前周期内已使用的令牌数、请求次数等。开发者需要频繁查看这些信息以确保成本控制避免意外超出免费额度或产生计划外费用。流程规划在额度将尽时调整使用策略或切换模型。故障排查当 AI 助手无响应时快速判断是否是达到了速率限制。1.3 传统查询方式的弊端通常查询这些信息需要通过登录相应的开发者门户网站。在复杂的仪表盘中寻找用量页面。或者通过命令行调用 API如curl。 这些方式都要求中断当前的编码工作流破坏了心流状态。一个菜单栏应用能将“查询”这个动作的成本降至一次点击意义重大。2. 环境准备与项目创建我们将使用 Apple 官方的 SwiftUI 框架来构建这个应用因为它声明式的语法和与 macOS 系统的深度集成能让我们高效地创建原生体验。2.1 开发环境要求操作系统macOS 12 (Monterey) 或更高版本。建议使用最新稳定版以获得最佳 SwiftUI 支持。开发工具Xcode 14 或更高版本。本文示例基于 Xcode 15。编程语言Swift 5.9。目标框架AppKit (用于菜单栏集成) 与 SwiftUI (用于界面)。2.2 创建新的 macOS 项目打开 Xcode选择 “Create New Project…”。在模板选择器中选择 “macOS” - “App”然后点击 “Next”。输入你的产品名称例如AICodeUsageMonitor。Interface选择 “SwiftUI”Life Cycle选择 “SwiftUI App”。语言选择 “Swift”。选择一个位置存放项目取消勾选 “Create Git repository on my Mac”可根据需要选择点击 “Create”。2.3 项目初始结构创建完成后你会看到以下主要文件AICodeUsageMonitorApp.swift: 应用的入口和主结构。ContentView.swift: 初始的视图文件对于菜单栏应用我们可能不会直接使用它。Assets.xcassets: 资源文件用于存放应用图标和菜单栏图标。3. 构建菜单栏应用的核心骨架一个标准的菜单栏应用其生命周期和界面管理与普通窗口应用不同。我们需要创建一个NSApplicationDelegate或利用main和App协议来管理状态。3.1 创建菜单栏状态项Status Item我们将创建一个新的 Swift 文件来管理菜单栏逻辑。在项目中新建一个 Swift 文件命名为MenuBarController.swift。// 文件路径AICodeUsageMonitor/MenuBarController.swift import AppKit import SwiftUI class MenuBarController: NSObject { private var statusItem: NSStatusItem! private var popover: NSPopover! // 单例模式便于全局访问 static let shared MenuBarController() override init() { super.init() setupStatusItem() } private func setupStatusItem() { // 创建状态项系统会自动将其添加到菜单栏 statusItem NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) // 配置状态项图标和点击行为 if let button statusItem.button { // 这里先使用一个系统图标后续可以替换为自定义图标 button.image NSImage(systemSymbolName: brain.head.profile, accessibilityDescription: AI Usage) button.action #selector(togglePopover(_:)) button.target self } // 初始化 Popover popover NSPopover() popover.contentSize NSSize(width: 300, height: 400) // 初始大小 popover.behavior .transient // 点击外部区域自动关闭 popover.contentViewController NSHostingController(rootView: ContentView()) // 使用 SwiftUI 视图 } objc private func togglePopover(_ sender: AnyObject?) { guard let button statusItem.button else { return } if popover.isShown { popover.performClose(sender) } else { // 显示 Popover锚定在状态项按钮上 popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY) // 激活应用但保持窗口层级非激活状态也能显示 NSApplication.shared.activate(ignoringOtherApps: true) } } }3.2 修改应用入口以启动菜单栏我们需要修改AICodeUsageMonitorApp.swift在应用启动时初始化我们的菜单栏控制器并隐藏 Dock 图标和菜单栏因为这是一个“仅菜单栏”的应用。// 文件路径AICodeUsageMonitor/AICodeUsageMonitorApp.swift import SwiftUI main struct AICodeUsageMonitorApp: App { // 使用 NSApplicationDelegateAdaptor 来接入 AppKit 的生命周期管理 NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate var body: some Scene { // 一个空的 Settings 场景防止没有 Scene 时报错。 // 对于纯菜单栏应用我们通常不需要任何 WindowGroup。 Settings { EmptyView() } } } class AppDelegate: NSObject, NSApplicationDelegate { func applicationDidFinishLaunching(_ notification: Notification) { // 1. 初始化菜单栏控制器这会自动添加图标到菜单栏 _ MenuBarController.shared // 2. 隐藏 Dock 图标 NSApp.setActivationPolicy(.accessory) // 3. 可选隐藏主窗口如果存在 if let window NSApplication.shared.windows.first { window.close() } } // 处理点击 Dock 图标如果用户后来想显示窗口 func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) - Bool { // 如果用户点击了 Dock 图标我们可以选择显示一个设置窗口或不做任何事。 // 这里我们简单地不处理保持应用在后台。 return false } }3.3 设计 SwiftUI 内容视图现在我们来设计点击菜单栏图标后弹出的内容视图。更新ContentView.swift。// 文件路径AICodeUsageMonitor/ContentView.swift import SwiftUI struct ContentView: View { // 这里使用模拟数据后续会替换为真实 API 数据 State private var claudeUsage: AIUsageData .mockClaude State private var codexUsage: AIUsageData .mockCodex State private var isLoading false State private var lastUpdated: Date .now var body: some View { VStack(alignment: .leading, spacing: 16) { // 标题和刷新按钮 HStack { Text(AI 用量监控) .font(.headline) Spacer() Button(action: refreshData) { Image(systemName: arrow.clockwise) } .buttonStyle(.borderless) .disabled(isLoading) } Divider() // Claude Code 用量面板 UsagePanel( serviceName: Claude Code, iconName: c.circle.fill, iconColor: .orange, usageData: $claudeUsage ) Divider() // Codex 用量面板 UsagePanel( serviceName: Codex, iconName: chevron.left.forwardslash.chevron.right, iconColor: .blue, usageData: $codexUsage ) Divider() // 底部信息 VStack(alignment: .leading, spacing: 4) { Text(最后更新: \(lastUpdated.formatted(date: .omitted, time: .shortened))) .font(.caption) .foregroundColor(.secondary) if isLoading { HStack { ProgressView() .controlSize(.small) Text(更新中...) .font(.caption) .foregroundColor(.secondary) } } } } .padding() .frame(width: 300, height: 400) // 与 Popover 大小匹配 .onAppear { // 视图出现时加载数据 refreshData() } } private func refreshData() { isLoading true // 模拟网络请求延迟 DispatchQueue.main.asyncAfter(deadline: .now() 1.0) { // 这里后续会替换为真实的 API 调用 claudeUsage .mockClaude codexUsage .mockCodex lastUpdated .now isLoading false } } } // 用量数据模型 struct AIUsageData { let used: Int let limit: Int let resetTime: Date? let rateLimitRemaining: Int? var usagePercentage: Double { guard limit 0 else { return 0 } return Double(used) / Double(limit) } var formattedUsage: String { return \(used) / \(limit) } // 模拟数据 static let mockClaude AIUsageData( used: 12500, limit: 100000, resetTime: Calendar.current.date(byAdding: .day, value: 1, to: .now), rateLimitRemaining: 45 ) static let mockCodex AIUsageData( used: 89000, limit: 120000, resetTime: Calendar.current.date(byAdding: .hour, value: 6, to: .now), rateLimitRemaining: 120 ) } // 用量面板子视图 struct UsagePanel: View { let serviceName: String let iconName: String let iconColor: Color Binding var usageData: AIUsageData var body: some View { VStack(alignment: .leading, spacing: 8) { HStack { Image(systemName: iconName) .foregroundColor(iconColor) Text(serviceName) .font(.subheadline) .bold() Spacer() Text(usageData.formattedUsage) .font(.caption.monospacedDigit()) .foregroundColor(.secondary) } // 进度条 ProgressView(value: usageData.usagePercentage) .progressViewStyle(.linear) .tint(usageColor) // 详细信息 VStack(alignment: .leading, spacing: 4) { Text(已用 \(Int(usageData.usagePercentage * 100))%) .font(.caption) if let resetTime usageData.resetTime { Text(重置: \(resetTime, style: .relative)) .font(.caption2) .foregroundColor(.secondary) } if let remaining usageData.rateLimitRemaining { Text(剩余请求: \(remaining)) .font(.caption2) .foregroundColor(.secondary) } } } } private var usageColor: Color { let percentage usageData.usagePercentage switch percentage { case 0..0.7: return .green case 0.7..0.9: return .yellow default: return .red } } }现在运行项目 (Cmd R)。你会看到菜单栏上出现了一个大脑图标。点击它一个包含模拟用量数据的弹出面板就会出现。我们已经完成了菜单栏应用的核心 UI 骨架。4. 对接真实 API获取 Claude Code 与 Codex 用量数据模拟数据只是开始核心功能是获取真实数据。Claude Code 和 Codex 通常通过其官方 API 提供用量查询接口。请注意以下 API 端点、参数和响应格式为示例你需要根据 Anthropic 和 OpenAI 官方文档进行调整。4.1 网络请求与模型层首先创建一个文件来处理网络请求和数据模型。// 文件路径AICodeUsageMonitor/Network/APIManager.swift import Foundation class APIManager { static let shared APIManager() private let session: URLSession private init() { let configuration URLSessionConfiguration.default configuration.timeoutIntervalForRequest 10 session URLSession(configuration: configuration) } // 通用的 GET 请求方法 private func performRequestT: Decodable(url: URL, apiKey: String, completion: escaping (ResultT, Error) - Void) { var request URLRequest(url: url) request.httpMethod GET request.setValue(Bearer \(apiKey), forHTTPHeaderField: Authorization) request.setValue(application/json, forHTTPHeaderField: Content-Type) let task session.dataTask(with: request) { data, response, error in if let error error { DispatchQueue.main.async { completion(.failure(error)) } return } guard let httpResponse response as? HTTPURLResponse, (200...299).contains(httpResponse.statusCode) else { let statusCode (response as? HTTPURLResponse)?.statusCode ?? -1 DispatchQueue.main.async { completion(.failure(NetworkError.httpError(statusCode: statusCode))) } return } guard let data data else { DispatchQueue.main.async { completion(.failure(NetworkError.noData)) } return } do { let decodedData try JSONDecoder().decode(T.self, from: data) DispatchQueue.main.async { completion(.success(decodedData)) } } catch { DispatchQueue.main.async { completion(.failure(error)) } } } task.resume() } // 示例获取 Claude Code 用量 (假设的 API) func fetchClaudeUsage(apiKey: String, completion: escaping (ResultClaudeUsageResponse, Error) - Void) { // !!! 重要此 URL 和响应结构为示例请替换为真实的 Claude API 端点 !!! guard let url URL(string: https://api.anthropic.com/v1/usage) else { completion(.failure(NetworkError.invalidURL)) return } performRequest(url: url, apiKey: apiKey, completion: completion) } // 示例获取 Codex 用量 (使用 OpenAI 格式示例) func fetchCodexUsage(apiKey: String, completion: escaping (ResultOpenAIUsageResponse, Error) - Void) { // !!! 重要此 URL 和响应结构为示例请替换为真实的 OpenAI API 端点 !!! guard let url URL(string: https://api.openai.com/v1/usage) else { completion(.failure(NetworkError.invalidURL)) return } performRequest(url: url, apiKey: apiKey, completion: completion) } } enum NetworkError: LocalizedError { case invalidURL case httpError(statusCode: Int) case noData case decodingError var errorDescription: String? { switch self { case .invalidURL: return 无效的 API 地址。 case .httpError(let statusCode): return 网络请求失败状态码: \(statusCode)。 case .noData: return 服务器未返回数据。 case .decodingError: return 解析响应数据失败。 } } } // 假设的 Claude API 响应模型 struct ClaudeUsageResponse: Codable { let totalTokensUsed: Int let tokenLimit: Int let resetTimestamp: TimeInterval? // 可能为 Unix 时间戳 // 可以添加计算属性来转换为我们的 AIUsageData func toAIUsageData() - AIUsageData { let resetDate resetTimestamp.map { Date(timeIntervalSince1970: $0) } return AIUsageData( used: totalTokensUsed, limit: tokenLimit, resetTime: resetDate, rateLimitRemaining: nil // Claude API 可能不直接提供这个 ) } } // 假设的 OpenAI API 响应模型 (参考 Billing API) struct OpenAIUsageResponse: Codable { let totalUsage: Int // 单位可能是美分或令牌数需根据实际 API 调整 let hardLimit: Int let grants: [Grant]? struct Grant: Codable { let expiresAt: TimeInterval? } func toAIUsageData() - AIUsageData { let resetTime grants?.first?.expiresAt.map { Date(timeIntervalSince1970: $0) } // 注意这里需要根据实际 API 文档确认 totalUsage 和 hardLimit 的单位和含义 return AIUsageData( used: totalUsage, limit: hardLimit, resetTime: resetTime, rateLimitRemaining: nil // 可能需要从其他端点获取 ) } }4.2 创建数据存储与配置管理器我们需要安全地存储 API 密钥并管理应用配置。我们将使用UserDefaults进行简单存储并考虑使用 Keychain 增强安全性。// 文件路径AICodeUsageMonitor/Managers/ConfigManager.swift import Foundation import Security // 用于 Keychain 操作进阶 class ConfigManager { static let shared ConfigManager() private let defaults UserDefaults.standard private enum Keys { static let claudeAPIKey claude_api_key static let openaiAPIKey openai_api_key static let refreshInterval refresh_interval_minutes } // 使用 UserDefaults 存储简单但不安全 var claudeAPIKey: String { get { defaults.string(forKey: Keys.claudeAPIKey) ?? } set { defaults.set(newValue, forKey: Keys.claudeAPIKey) } } var openaiAPIKey: String { get { defaults.string(forKey: Keys.openaiAPIKey) ?? } set { defaults.set(newValue, forKey: Keys.openaiAPIKey) } } var refreshIntervalMinutes: Int { get { defaults.integer(forKey: Keys.refreshInterval) } set { defaults.set(newValue, forKey: Keys.refreshInterval) } } private init() { // 设置默认刷新间隔为 5 分钟 if defaults.object(forKey: Keys.refreshInterval) nil { defaults.set(5, forKey: Keys.refreshInterval) } } // 进阶使用 Keychain 安全存储 API 密钥 (示例函数) func saveKeyToKeychain(service: String, account: String, key: String) - Bool { guard let data key.data(using: .utf8) else { return false } let query: [String: Any] [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, kSecAttrAccount as String: account, kSecValueData as String: data ] SecItemDelete(query as CFDictionary) // 先删除旧的 let status SecItemAdd(query as CFDictionary, nil) return status errSecSuccess } func loadKeyFromKeychain(service: String, account: String) - String? { let query: [String: Any] [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, kSecAttrAccount as String: account, kSecReturnData as String: true, kSecMatchLimit as String: kSecMatchLimitOne ] var dataTypeRef: AnyObject? let status SecItemCopyMatching(query as CFDictionary, dataTypeRef) if status errSecSuccess, let data dataTypeRef as? Data { return String(data: data, encoding: .utf8) } return nil } }4.3 集成 API 调用到视图现在更新ContentView.swift中的refreshData函数调用真实的 API。// 在 ContentView 结构体内更新 refreshData 函数 private func refreshData() { isLoading true let config ConfigManager.shared let apiManager APIManager.shared let group DispatchGroup() var claudeResult: ResultClaudeUsageResponse, Error? var codexResult: ResultOpenAIUsageResponse, Error? // 获取 Claude 数据 if !config.claudeAPIKey.isEmpty { group.enter() apiManager.fetchClaudeUsage(apiKey: config.claudeAPIKey) { result in claudeResult result group.leave() } } // 获取 Codex 数据 if !config.openaiAPIKey.isEmpty { group.enter() apiManager.fetchCodexUsage(apiKey: config.openaiAPIKey) { result in codexResult result group.leave() } } group.notify(queue: .main) { // 处理 Claude 结果 if let claudeResult claudeResult { switch claudeResult { case .success(let response): self.claudeUsage response.toAIUsageData() case .failure(let error): // 处理错误例如更新 UI 显示错误信息 print(Claude API 错误: \(error.localizedDescription)) // 可以设置一个错误状态的数据 self.claudeUsage AIUsageData(used: 0, limit: 0, resetTime: nil, rateLimitRemaining: nil) } } // 处理 Codex 结果 if let codexResult codexResult { switch codexResult { case .success(let response): self.codexUsage response.toAIUsageData() case .failure(let error): print(Codex API 错误: \(error.localizedDescription)) self.codexUsage AIUsageData(used: 0, limit: 0, resetTime: nil, rateLimitRemaining: nil) } } self.lastUpdated .now self.isLoading false } }5. 完善功能设置界面与自动刷新一个完整的应用还需要配置界面和后台自动刷新的能力。5.1 创建设置视图新建一个 SwiftUI 视图文件SettingsView.swift用于输入 API 密钥和配置刷新间隔。// 文件路径AICodeUsageMonitor/SettingsView.swift import SwiftUI struct SettingsView: View { State private var claudeAPIKey: String ConfigManager.shared.claudeAPIKey State private var openaiAPIKey: String ConfigManager.shared.openaiAPIKey State private var refreshInterval: Int ConfigManager.shared.refreshIntervalMinutes Environment(\.dismiss) private var dismiss var body: some View { Form { Section(API 配置) { SecureField(Claude API Key, text: $claudeAPIKey) .textFieldStyle(RoundedBorderTextFieldStyle()) SecureField(OpenAI API Key, text: $openaiAPIKey) .textFieldStyle(RoundedBorderTextFieldStyle()) Text(密钥仅存储在本地用于查询用量。) .font(.caption) .foregroundColor(.secondary) } Section(刷新设置) { Picker(自动刷新间隔, selection: $refreshInterval) { Text(1 分钟).tag(1) Text(5 分钟).tag(5) Text(15 分钟).tag(15) Text(30 分钟).tag(30) Text(手动).tag(0) } .pickerStyle(.menu) if refreshInterval 0 { Text(每隔 \(refreshInterval) 分钟自动从菜单栏获取最新用量。) .font(.caption) .foregroundColor(.secondary) } else { Text(仅在点击菜单栏图标或手动刷新时更新。) .font(.caption) .foregroundColor(.secondary) } } Section { HStack { Spacer() Button(保存并关闭) { saveSettings() dismiss() } .keyboardShortcut(.defaultAction) Button(取消) { dismiss() } .keyboardShortcut(.cancelAction) Spacer() } } } .padding() .frame(width: 400, height: 300) } private func saveSettings() { let config ConfigManager.shared config.claudeAPIKey claudeAPIKey config.openaiAPIKey openaiAPIKey config.refreshIntervalMinutes refreshInterval // 保存后可以通知主视图或刷新数据 NotificationCenter.default.post(name: .settingsUpdated, object: nil) } } // 定义通知名称 extension Notification.Name { static let settingsUpdated Notification.Name(settingsUpdated) }5.2 在菜单栏添加设置入口修改MenuBarController的setupStatusItem方法为其添加一个包含“设置”和“退出”选项的上下文菜单。// 在 MenuBarController.swift 的 setupStatusItem 方法中初始化 popover 后添加 private func setupStatusItem() { // ... 之前的代码创建 statusItem 和 button... // 初始化 Popover ... // popover NSPopover() ... // 创建上下文菜单 let menu NSMenu() let settingsItem NSMenuItem(title: 设置..., action: #selector(openSettings(_:)), keyEquivalent: ,) settingsItem.target self menu.addItem(settingsItem) menu.addItem(NSMenuItem.separator()) let quitItem NSMenuItem(title: 退出, action: #selector(quitApp(_:)), keyEquivalent: q) quitItem.target self menu.addItem(quitItem) statusItem.menu menu // 赋予状态项一个菜单 } objc private func openSettings(_ sender: AnyObject?) { // 关闭 Popover如果开着 if popover.isShown { popover.performClose(sender) } // 创建并显示设置窗口 let settingsView SettingsView() let hostingController NSHostingController(rootView: settingsView) let window NSWindow(contentViewController: hostingController) window.title AI 用量监控 - 设置 window.setContentSize(NSSize(width: 420, height: 350)) window.styleMask [.titled, .closable] window.center() window.makeKeyAndOrderFront(nil) // 将窗口关联到当前应用防止被垃圾回收 NSApp.activate(ignoringOtherApps: true) } objc private func quitApp(_ sender: AnyObject?) { NSApplication.shared.terminate(nil) }5.3 实现后台自动刷新我们需要一个定时器根据设置的间隔自动刷新数据。在MenuBarController或一个单独的RefreshService中实现。// 文件路径AICodeUsageMonitor/Services/RefreshService.swift import Foundation import Combine class RefreshService: ObservableObject { static let shared RefreshService() private var timer: Timer? private var cancellables SetAnyCancellable() private init() { setupObserver() } private func setupObserver() { // 监听设置更新的通知 NotificationCenter.default.publisher(for: .settingsUpdated) .sink { [weak self] _ in self?.restartTimer() } .store(in: cancellables) } func startTimer() { stopTimer() // 先停止现有的定时器 let interval ConfigManager.shared.refreshIntervalMinutes guard interval 0 else { print(自动刷新已禁用) return } print(启动自动刷新定时器间隔 \(interval) 分钟) timer Timer.scheduledTimer(withTimeInterval: TimeInterval(interval * 60), repeats: true) { [weak self] _ in self?.triggerRefresh() } // 立即触发一次刷新 triggerRefresh() } func stopTimer() { timer?.invalidate() timer nil } private func triggerRefresh() { print(定时刷新触发于 \(Date())) // 发送一个全局通知让 ContentView 刷新数据 NotificationCenter.default.post(name: .refreshData, object: nil) } private func restartTimer() { stopTimer() startTimer() } } extension Notification.Name { static let refreshData Notification.Name(refreshData) }然后在AppDelegate的applicationDidFinishLaunching中启动服务并在ContentView中监听刷新通知。// 在 AppDelegate.swift 的 applicationDidFinishLaunching 末尾添加 RefreshService.shared.startTimer()// 在 ContentView.swift 的 body 内添加 .onReceive 修饰器 var body: some View { VStack(alignment: .leading, spacing: 16) { // ... 原有视图代码 ... } .padding() .frame(width: 300, height: 400) .onAppear { refreshData() } .onReceive(NotificationCenter.default.publisher(for: .refreshData)) { _ in refreshData() } .onReceive(NotificationCenter.default.publisher(for: .settingsUpdated)) { _ in // 设置更新后也刷新一次数据 refreshData() } }6. 常见问题与排查思路在开发和使用此类菜单栏应用时你可能会遇到一些典型问题。问题现象可能原因解决思路菜单栏图标不显示1. 应用没有NSStatusItem。2. 图标资源缺失或命名错误。3. 应用沙盒权限问题。1. 检查MenuBarController的setupStatusItem是否被调用。2. 确认button.image设置的NSImage有效。可以使用系统符号NSImage(systemSymbolName:)测试。3. 对于沙盒应用需要在Signing Capabilities中添加App Sandbox并启用User Selected File等权限如果涉及文件访问。点击图标无反应1. 按钮的action和target未正确设置。2.popover或menu的配置有冲突。1. 确保button.action和button.target已正确关联到MenuBarController实例的方法。2. 如果同时设置了statusItem.menu和button.action点击行为可能被菜单覆盖。确保逻辑清晰通常二选一。Popover 显示位置异常popover.show(relativeTo:of:preferredEdge:)方法的参数不正确。确保relativeTo:传入的是状态项按钮的boundsof:传入的是按钮本身。preferredEdge:通常用.minY上方或.maxY下方。API 请求失败1. 网络连接问题。2. API 密钥无效或过期。3. API 端点 URL 或响应格式变化。4. 未处理 SSL 证书或网络权限。1. 检查网络。2. 在设置中重新输入并保存有效的 API 密钥。3.最重要对照 Claude/OpenAI 最新官方文档核对APIManager中的 URL 和响应模型 (ClaudeUsageResponse,OpenAIUsageResponse)。这是最可能出错的地方。4. 对于 macOS 应用如果访问https接口通常没问题。如果自签名证书需额外处理。自动刷新不工作1.RefreshService的定时器未启动或被释放。2. 刷新间隔设置为 0手动。3.ContentView未监听refreshData通知。1. 在AppDelegate中确认RefreshService.shared.startTimer()被调用。2. 检查设置中的刷新间隔。3. 确保ContentView添加了.onReceive(NotificationCenter.default.publisher(for: .refreshData))修饰器。应用无法退出NSApplication.shared.terminate(_:)未被正确调用或存在未释放的资源/窗口。确保退出菜单项正确连接到quitApp方法。对于有窗口的应用关闭所有窗口可能有助于退出。纯菜单栏应用使用NSApp.setActivationPolicy(.accessory)后点击 Dock 菜单的“退出”或我们的菜单项应能正常退出。7. 最佳实践与进阶优化完成基础功能后我们可以从工程化角度考虑如何让应用更健壮、更安全、体验更好。7.1 安全存储 API 密钥如前所述使用UserDefaults存储明文 API 密钥不安全。强烈建议使用 macOS 的 Keychain Services。上面的ConfigManager已经提供了示例方法。你应该修改ConfigManager使其优先从 Keychain 读取密钥UserDefaults仅作为备份或标志位。在SettingsView中从 Keychain 加载初始值保存时写入 Keychain。7.2 错误处理与用户反馈目前的错误处理只是打印到控制台。应该向用户提供友好提示。在 UI 中显示错误在ContentView的AIUsageData模型中增加一个errorMessage字段当 API 请求失败时在用量面板上显示错误图标和简短提示如“获取失败”。使用 Alert对于严重的配置错误如未设置 API 密钥可以在应用启动或尝试刷新时弹出Alert提示用户去设置。7.3 数据持久化与离线查看每次打开 Popover 都重新请求 API 可能造成不必要的延迟和流量消耗。缓存机制将最后一次成功获取的AIUsageData连同时间戳一起保存到UserDefaults或本地文件。当 Popover 打开时先显示缓存的数据然后立即在后台发起新的请求获取成功后更新 UI。这能提供瞬时的用户体验。在APIManager的fetch方法成功回调中将数据归档存储。7.4 应用图标与打包自定义菜单栏图标替换NSImage(systemSymbolName: ...)为你设计的自定义图标。将图标文件建议使用 PDF 矢量格式或多种尺寸的 PNG放入Assets.xcassets然后通过NSImage(named: “YourIconName”)引用。配置应用信息在 Xcode 项目的Info.plist或 Target 的Signing Capabilities中设置合适的Bundle Identifier、Version和Build。发布准备如果打算分发需要考虑代码签名、公证Notarization和沙盒配置以符合 macOS 应用商店或直接分发的安全要求。7.5 扩展功能思路多账户支持允许添加多组 Claude/OpenAI API 密钥并在菜单栏中切换查看。用量预测与告警根据历史使用速率预测配额耗尽时间并在用量达到阈值如 80%、90%时通过本地通知UserNotifications提醒用户。更丰富的菜单栏显示不点击时图标本身可以变化如颜色、填充度来直观反映总体用量状态如绿色代表充足红色代表即将用完。导出用量报告将历史用量数据导出为 CSV 或 JSON 文件。通过以上步骤你已经拥有了一个功能完整、可扩展的 macOS 菜单栏应用它能够优雅且高效地解决 Claude Code 和 Codex 用量监控的痛点。这个项目不仅是一个实用工具也是一个学习 SwiftUI、AppKit 以及 macOS 应用开发生态的绝佳范例。你可以根据实际 AI 服务的 API 文档调整网络请求部分并在此基础上添加更多个性化功能打造属于你自己的终极开发效率看板。
返回列表