Files
Bitcoin-Monitoring/test1/BTCMenuBarApp.swift
SherlockKudou 370f0731d8 fix: 启用菜单项交互功能并添加价格复制功能
- 修复菜单项在价格加载后仍保持禁用状态的问题
- 添加点击选择币种功能:用户可以直接点击价格菜单项选择该币种
- 添加 Option+点击复制价格功能:按住Option键点击菜单项复制价格到剪贴板
- 添加用户友好的操作提示和通知反馈
- 更新应用权限以支持用户通知功能

解决 PR #3 中的反馈意见:菜单项应在价格加载后变为可交互状态
2025-10-30 04:51:18 +00:00

489 lines
18 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.
//
// BTCMenuBarApp.swift
// test1
//
// Created by Mark on 2025/10/28.
//
import SwiftUI
import AppKit
import Combine
import UserNotifications
// macOS
@MainActor
class BTCMenuBarApp: NSObject, ObservableObject {
private var statusItem: NSStatusItem?
private var popover: NSPopover?
private let appSettings: AppSettings
private let priceManager: PriceManager
private var cancellables = Set<AnyCancellable>()
override init() {
let settings = AppSettings()
self.appSettings = settings
self.priceManager = PriceManager(initialSymbol: settings.selectedSymbol)
super.init()
setupMenuBar()
setupConfigurationObservers()
}
//
private func setupConfigurationObservers() {
//
appSettings.$refreshInterval
.sink { [weak self] newInterval in
self?.priceManager.updateRefreshInterval(newInterval)
}
.store(in: &cancellables)
//
appSettings.$selectedSymbol
.sink { [weak self] newSymbol in
guard let self = self else { return }
self.priceManager.updateSymbol(newSymbol)
self.updateMenuBarTitle(price: self.priceManager.currentPrice)
}
.store(in: &cancellables)
}
//
private func setupMenuBar() {
//
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
guard let statusItem = statusItem else {
print("❌ 无法创建状态栏项目")
return
}
guard let button = statusItem.button else {
print("❌ 无法获取状态栏按钮")
return
}
//
updateMenuBarTitle(price: 0.0)
button.action = #selector(menuBarClicked)
button.target = self
//
priceManager.$currentPrice
.receive(on: DispatchQueue.main)
.sink { [weak self] price in
self?.updateMenuBarTitle(price: price)
}
.store(in: &cancellables)
// UI
priceManager.$selectedSymbol
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
guard let self = self else { return }
self.updateMenuBarTitle(price: self.priceManager.currentPrice)
}
.store(in: &cancellables)
}
//
private func updateMenuBarTitle(price: Double) {
DispatchQueue.main.async {
guard let button = self.statusItem?.button else { return }
let symbol = self.priceManager.selectedSymbol
let symbolImage = self.symbolImage(for: symbol)
symbolImage?.size = NSSize(width: 16, height: 16)
//
button.image = symbolImage
//
if price == 0.0 {
if self.priceManager.isFetching {
button.title = " \(symbol.displayName) 更新中..."
} else if self.priceManager.lastError != nil {
button.title = " \(symbol.displayName) 错误"
} else {
button.title = " \(symbol.displayName) 加载中..."
}
} else {
button.title = " \(symbol.displayName) $\(self.formatPriceWithCommas(price))"
}
}
}
//
private func symbolImage(for symbol: CryptoSymbol) -> NSImage? {
if let image = NSImage(systemSymbolName: symbol.systemImageName, accessibilityDescription: symbol.displayName) {
return image
}
return NSImage(systemSymbolName: "bitcoinsign.circle.fill", accessibilityDescription: "Crypto")
}
//
private func formatPriceWithCommas(_ price: Double) -> String {
let formatter = NumberFormatter()
formatter.numberStyle = .decimal
formatter.minimumFractionDigits = 2
formatter.maximumFractionDigits = 4
formatter.groupingSeparator = ","
formatter.usesGroupingSeparator = true
return formatter.string(from: NSNumber(value: price)) ?? String(format: "%.4f", price)
}
//
@objc private func menuBarClicked() {
guard let button = statusItem?.button else {
print("❌ 无法获取状态栏按钮")
return
}
showMenu(from: button)
}
//
private func showMenu(from view: NSView) {
let menu = NSMenu()
//
//
var symbolMenuItems: [CryptoSymbol: NSMenuItem] = [:]
for symbol in CryptoSymbol.allCases {
let placeholderTitle = "\(symbol.displayName): 加载中..."
let item = NSMenuItem(title: placeholderTitle, action: nil, keyEquivalent: "")
if let icon = symbolImage(for: symbol) {
icon.size = NSSize(width: 16, height: 16)
item.image = icon
}
item.isEnabled = false
menu.addItem(item)
symbolMenuItems[symbol] = item
}
//
Task { @MainActor in
let results = await self.priceManager.fetchAllPrices()
for symbol in CryptoSymbol.allCases {
guard let (priceOpt, errorOpt) = results[symbol], let menuItem = symbolMenuItems[symbol] else { continue }
if let price = priceOpt {
menuItem.title = "\(symbol.displayName): $\(self.formatPriceWithCommas(price))"
menuItem.isEnabled = true //
menuItem.action = #selector(self.copyPriceOrSelectSymbol(_:))
menuItem.target = self
menuItem.representedObject = ["symbol": symbol, "price": price]
} else if let error = errorOpt {
menuItem.title = "\(symbol.displayName): 错误"
menuItem.toolTip = error
menuItem.isEnabled = true // 使
menuItem.action = #selector(self.selectSymbol(_:))
menuItem.target = self
menuItem.representedObject = symbol.rawValue
} else {
menuItem.title = "\(symbol.displayName): 加载中..."
//
}
}
}
// 使
let hintItem = NSMenuItem(title: "💡 点击选择币种Option+点击复制价格", action: nil, keyEquivalent: "")
hintItem.isEnabled = false
menu.addItem(hintItem)
menu.addItem(NSMenuItem.separator())
//
if let errorMessage = priceManager.errorMessage {
let errorItem = NSMenuItem(title: "错误: \(errorMessage)", action: nil, keyEquivalent: "")
if let errorImage = NSImage(systemSymbolName: "exclamationmark.triangle.fill", accessibilityDescription: "错误") {
errorImage.size = NSSize(width: 16, height: 16)
errorItem.image = errorImage
}
errorItem.isEnabled = false
menu.addItem(errorItem)
menu.addItem(NSMenuItem.separator())
}
//
let timeItem = NSMenuItem(title: "上次更新: \(getCurrentTime())", action: nil, keyEquivalent: "")
if let clockImage = NSImage(systemSymbolName: "clock", accessibilityDescription: "时间") {
clockImage.size = NSSize(width: 16, height: 16)
timeItem.image = clockImage
}
timeItem.isEnabled = false
menu.addItem(timeItem)
menu.addItem(NSMenuItem.separator())
//
let symbolSettingsItem = NSMenuItem(title: "币种选择", action: nil, keyEquivalent: "")
if let symbolSettingsImage = NSImage(systemSymbolName: "chart.line.uptrend.xyaxis", accessibilityDescription: "币种选择") {
symbolSettingsImage.size = NSSize(width: 16, height: 16)
symbolSettingsItem.image = symbolSettingsImage
}
let symbolMenu = NSMenu()
let currentSymbol = priceManager.selectedSymbol
for symbol in CryptoSymbol.allCases {
let isCurrent = (symbol == currentSymbol)
let item = NSMenuItem(
title: symbol.menuTitle(isCurrent: isCurrent),
action: #selector(selectSymbol(_:)),
keyEquivalent: ""
)
item.target = self
item.isEnabled = !isCurrent
item.representedObject = symbol.rawValue
if let icon = symbolImage(for: symbol) {
icon.size = NSSize(width: 16, height: 16)
item.image = icon
}
symbolMenu.addItem(item)
}
symbolSettingsItem.submenu = symbolMenu
menu.addItem(symbolSettingsItem)
menu.addItem(NSMenuItem.separator())
//
let refreshTitle = priceManager.isFetching ? "刷新中..." : "刷新价格"
let refreshItem = NSMenuItem(title: refreshTitle, action: #selector(refreshPrice), keyEquivalent: "r")
if let refreshImage = NSImage(systemSymbolName: priceManager.isFetching ? "hourglass" : "arrow.clockwise", accessibilityDescription: "刷新") {
refreshImage.size = NSSize(width: 16, height: 16)
refreshItem.image = refreshImage
}
refreshItem.target = self
refreshItem.isEnabled = !priceManager.isFetching
menu.addItem(refreshItem)
//
let refreshSettingsItem = NSMenuItem(title: "刷新设置", action: nil, keyEquivalent: "")
if let settingsImage = NSImage(systemSymbolName: "timer", accessibilityDescription: "刷新设置") {
settingsImage.size = NSSize(width: 16, height: 16)
refreshSettingsItem.image = settingsImage
}
let refreshSettingsMenu = NSMenu()
let currentInterval = priceManager.getCurrentRefreshInterval()
//
for interval in RefreshInterval.allCases {
let isCurrent = (interval == currentInterval)
let item = NSMenuItem(
title: interval.displayTextWithMark(isCurrent: isCurrent),
action: #selector(selectRefreshInterval(_:)),
keyEquivalent: ""
)
item.target = self
item.representedObject = interval
item.isEnabled = !isCurrent //
refreshSettingsMenu.addItem(item)
}
refreshSettingsItem.submenu = refreshSettingsMenu
menu.addItem(refreshSettingsItem)
menu.addItem(NSMenuItem.separator())
// GitHubGitHub
let checkUpdateItem = NSMenuItem(title: "GitHub", action: #selector(checkForUpdates), keyEquivalent: "")
if let updateImage = NSImage(systemSymbolName: "star.circle", accessibilityDescription: "GitHub") {
updateImage.size = NSSize(width: 16, height: 16)
checkUpdateItem.image = updateImage
}
checkUpdateItem.target = self
menu.addItem(checkUpdateItem)
//
let aboutItem = NSMenuItem(title: "关于", action: #selector(showAbout), keyEquivalent: "")
if let infoImage = NSImage(systemSymbolName: "info.circle", accessibilityDescription: "关于") {
infoImage.size = NSSize(width: 16, height: 16)
aboutItem.image = infoImage
}
aboutItem.target = self
menu.addItem(aboutItem)
// 退退
let quitItem = NSMenuItem(title: "退出", action: #selector(quitApp), keyEquivalent: "q")
if let quitImage = NSImage(systemSymbolName: "power", accessibilityDescription: "退出") {
quitImage.size = NSSize(width: 16, height: 16)
quitItem.image = quitImage
}
quitItem.target = self
menu.addItem(quitItem)
//
guard let statusItem = statusItem,
let button = statusItem.button else {
print("❌ 无法显示菜单 - 状态栏项目不可用")
return
}
statusItem.menu = menu
button.performClick(nil)
statusItem.menu = nil
}
//
private func getCurrentTime() -> String {
let formatter = DateFormatter()
formatter.timeStyle = .medium
return formatter.string(from: Date())
}
//
@objc private func refreshPrice() {
Task {
await priceManager.refreshPrice()
}
}
//
@objc private func selectSymbol(_ sender: NSMenuItem) {
guard let rawValue = sender.representedObject as? String,
let symbol = CryptoSymbol(rawValue: rawValue) else {
return
}
appSettings.saveSelectedSymbol(symbol)
print("✅ 币种已更新为: \(symbol.pairDisplayName)")
}
//
@objc private func copyPriceOrSelectSymbol(_ sender: NSMenuItem) {
guard let data = sender.representedObject as? [String: Any],
let symbol = data["symbol"] as? CryptoSymbol,
let price = data["price"] as? Double else {
return
}
// Option
let currentEvent = NSApp.currentEvent
let isOptionPressed = currentEvent?.modifierFlags.contains(.option) ?? false
if isOptionPressed {
//
let priceString = formatPriceWithCommas(price)
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
pasteboard.setString("$\(priceString)", forType: .string)
print("✅ 已复制 \(symbol.displayName) 价格到剪贴板: $\(priceString)")
//
Task {
await self.showCopyNotification(symbol: symbol, price: priceString)
}
} else {
//
appSettings.saveSelectedSymbol(symbol)
print("✅ 币种已更新为: \(symbol.pairDisplayName)")
}
}
//
@objc private func selectRefreshInterval(_ sender: NSMenuItem) {
guard let interval = sender.representedObject as? RefreshInterval else {
return
}
// UserDefaults
appSettings.saveRefreshInterval(interval)
//
priceManager.updateRefreshInterval(interval)
print("✅ 刷新间隔已更新为: \(interval.displayText)")
}
//
@objc private func showAbout() {
let currentInterval = priceManager.getCurrentRefreshInterval()
//
let version = getAppVersion()
let alert = NSAlert()
alert.messageText = "BTC价格监控器 v\(version)"
alert.informativeText = """
🚀 一款 macOS 原生菜单栏应用,用于实时显示主流币种价格
✨ 功能特性:
• 实时显示主流币种/USDT价格BTC/ETH/BNB/SOL/DOGE
• 可配置刷新间隔(当前:\(currentInterval.displayText)
• 支持手动刷新 (Cmd+R)
• 智能错误重试机制
• 优雅的SF Symbols图标
"""
alert.alertStyle = .informational
alert.addButton(withTitle: "确定")
alert.runModal()
}
// GitHub
@objc private func checkForUpdates() {
let githubURL = "https://github.com/jiayouzl/Bitcoin-Monitoring"
// URL
guard let url = URL(string: githubURL) else {
print("❌ 无效的URL: \(githubURL)")
return
}
// 使URL
NSWorkspace.shared.open(url)
print("✅ 已在浏览器中打开GitHub页面: \(githubURL)")
}
//
/// - Returns: ".."
private func getAppVersion() -> String {
guard let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String else {
return "未知版本"
}
return version
}
//
private func showCopyNotification(symbol: CryptoSymbol, price: String) async {
let center = UNUserNotificationCenter.current()
//
do {
let granted = try await center.requestAuthorization(options: [.alert, .sound])
if !granted {
return //
}
} catch {
print("❌ 通知权限请求失败: \(error)")
return
}
//
let content = UNMutableNotificationContent()
content.title = "价格已复制"
content.body = "\(symbol.displayName): $\(price)"
content.sound = .default
//
let request = UNNotificationRequest(
identifier: "price-copied-\(Date().timeIntervalSince1970)",
content: content,
trigger: nil //
)
//
do {
try await center.add(request)
} catch {
print("❌ 通知发送失败: \(error)")
}
}
// 退
@objc private func quitApp() {
NSApplication.shared.terminate(nil)
}
}