feat(ui): add configurable refresh interval and enhanced menu features

Major feature enhancement for the BTC price monitoring application:

### New Features
- **Configurable Refresh Intervals**: Users can now select from 5s, 10s, 30s, 60s options
- **Persistent Settings**: User preferences saved via UserDefaults
- **GitHub Integration**: Direct link to project repository via menu
- **Version Display**: Shows current app version in about dialog
- **Debug Logging**: Comprehensive debug output for development (Debug builds only)

### Implementation Details
- **AppSettings.swift**: New configuration management class
- **RefreshInterval.swift**: Enum defining refresh options with display text
- **Enhanced PriceManager**: Dynamic timer management with configurable intervals
- **Updated BTCMenuBarApp**: Added refresh settings submenu, GitHub link, and version info
- **Debug Infrastructure**: Conditional compilation logging throughout price update flow

### UI/UX Improvements
- **Refresh Settings Submenu**: Visual indicators (✓) for current selection
- **Enhanced About Dialog**: Shows current refresh interval and app version
- **Clean Menu Structure**: Organized with proper separators and SF Symbols
- **GitHub Button**: Quick access to project repository

### Code Quality
- **Removed Redundancy**: Deleted unused ContentView.swift template file
- **Comprehensive Comments**: Added detailed Chinese comments throughout
- **Error Handling**: Robust error handling with user-friendly messages
- **Architecture Clean**: Follows MVVM pattern with clear separation of concerns

### Files Changed
- Modified: BTCMenuBarApp.swift, PriceManager.swift, project.pbxproj
- Added: AppSettings.swift, RefreshInterval.swift
- Deleted: ContentView.swift (unused template)
- Updated: Entitlements, app entry point, and service files

BREAKING CHANGE: None - all changes are additive and backward compatible
This commit is contained in:
ZhangLei
2025-10-29 14:21:07 +08:00
parent 168ef505b9
commit a526deb79e
10 changed files with 298 additions and 52 deletions

View File

@@ -78,7 +78,7 @@
attributes = { attributes = {
BuildIndependentTargetsInParallel = 1; BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 1620; LastSwiftUpdateCheck = 1620;
LastUpgradeCheck = 1620; LastUpgradeCheck = 2600;
TargetAttributes = { TargetAttributes = {
4E94106F2EB09F90003658CB = { 4E94106F2EB09F90003658CB = {
CreatedOnToolsVersion = 16.2; CreatedOnToolsVersion = 16.2;
@@ -159,6 +159,7 @@
CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO; COPY_PHASE_STRIP = NO;
DEAD_CODE_STRIPPING = YES;
DEBUG_INFORMATION_FORMAT = dwarf; DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES; ENABLE_TESTABILITY = YES;
@@ -183,6 +184,7 @@
MTL_FAST_MATH = YES; MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES; ONLY_ACTIVE_ARCH = YES;
SDKROOT = macosx; SDKROOT = macosx;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_OPTIMIZATION_LEVEL = "-Onone";
}; };
@@ -222,6 +224,7 @@
CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO; COPY_PHASE_STRIP = NO;
DEAD_CODE_STRIPPING = YES;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO; ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_STRICT_OBJC_MSGSEND = YES;
@@ -239,6 +242,7 @@
MTL_ENABLE_DEBUG_INFO = NO; MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES; MTL_FAST_MATH = YES;
SDKROOT = macosx; SDKROOT = macosx;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_COMPILATION_MODE = wholemodule; SWIFT_COMPILATION_MODE = wholemodule;
}; };
name = Release; name = Release;
@@ -253,8 +257,12 @@
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES; COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_ASSET_PATHS = "\"test1/Preview Content\""; DEVELOPMENT_ASSET_PATHS = "\"test1/Preview Content\"";
ENABLE_APP_SANDBOX = YES;
ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
ENABLE_USER_SELECTED_FILES = readonly;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.finance"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.finance";
INFOPLIST_KEY_NSHumanReadableCopyright = ""; INFOPLIST_KEY_NSHumanReadableCopyright = "";
@@ -263,7 +271,7 @@
"@executable_path/../Frameworks", "@executable_path/../Frameworks",
); );
MACOSX_DEPLOYMENT_TARGET = 12.4; MACOSX_DEPLOYMENT_TARGET = 12.4;
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0.1;
PRODUCT_BUNDLE_IDENTIFIER = com.mark.test1; PRODUCT_BUNDLE_IDENTIFIER = com.mark.test1;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_EMIT_LOC_STRINGS = YES;
@@ -281,8 +289,12 @@
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES; COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_ASSET_PATHS = "\"test1/Preview Content\""; DEVELOPMENT_ASSET_PATHS = "\"test1/Preview Content\"";
ENABLE_APP_SANDBOX = YES;
ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
ENABLE_USER_SELECTED_FILES = readonly;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.finance"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.finance";
INFOPLIST_KEY_NSHumanReadableCopyright = ""; INFOPLIST_KEY_NSHumanReadableCopyright = "";
@@ -291,7 +303,7 @@
"@executable_path/../Frameworks", "@executable_path/../Frameworks",
); );
MACOSX_DEPLOYMENT_TARGET = 12.4; MACOSX_DEPLOYMENT_TARGET = 12.4;
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0.1;
PRODUCT_BUNDLE_IDENTIFIER = com.mark.test1; PRODUCT_BUNDLE_IDENTIFIER = com.mark.test1;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_EMIT_LOC_STRINGS = YES;

55
test1/AppSettings.swift Normal file
View File

@@ -0,0 +1,55 @@
//
// AppSettings.swift
// Bitcoin Monitoring
//
// Created by Mark on 2025/10/29.
//
import Foundation
import Combine
///
///
@MainActor
class AppSettings: ObservableObject {
// MARK: - Published Properties
///
@Published var refreshInterval: RefreshInterval = .thirtySeconds
// MARK: - Private Properties
private let defaults = UserDefaults.standard
private let refreshIntervalKey = "BTCRefreshInterval"
// MARK: - Initialization
init() {
loadSettings()
}
// MARK: - Configuration Methods
/// UserDefaults
/// 使30
func loadSettings() {
let savedValue = defaults.double(forKey: refreshIntervalKey)
// 使
if let savedInterval = RefreshInterval.allCases.first(where: { $0.rawValue == savedValue }) {
refreshInterval = savedInterval
} else {
refreshInterval = .thirtySeconds
//
saveRefreshInterval(.thirtySeconds)
}
}
///
/// - Parameter interval:
func saveRefreshInterval(_ interval: RefreshInterval) {
refreshInterval = interval
defaults.set(interval.rawValue, forKey: refreshIntervalKey)
}
}

View File

@@ -2,7 +2,7 @@
// BTCMenuBarApp.swift // BTCMenuBarApp.swift
// test1 // test1
// //
// Created by zl_vm on 2025/10/28. // Created by Mark on 2025/10/28.
// //
import SwiftUI import SwiftUI
@@ -15,11 +15,23 @@ class BTCMenuBarApp: NSObject, ObservableObject {
private var statusItem: NSStatusItem? private var statusItem: NSStatusItem?
private var popover: NSPopover? private var popover: NSPopover?
private var priceManager = PriceManager() private var priceManager = PriceManager()
private var appSettings = AppSettings()
private var cancellables = Set<AnyCancellable>() private var cancellables = Set<AnyCancellable>()
override init() { override init() {
super.init() super.init()
setupMenuBar() setupMenuBar()
setupConfigurationObservers()
}
//
private func setupConfigurationObservers() {
//
appSettings.$refreshInterval
.sink { [weak self] newInterval in
self?.priceManager.updateRefreshInterval(newInterval)
}
.store(in: &cancellables)
} }
// //
@@ -134,8 +146,45 @@ class BTCMenuBarApp: NSObject, ObservableObject {
refreshItem.isEnabled = !priceManager.isFetching refreshItem.isEnabled = !priceManager.isFetching
menu.addItem(refreshItem) 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()) 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: "") let aboutItem = NSMenuItem(title: "关于", action: #selector(showAbout), keyEquivalent: "")
if let infoImage = NSImage(systemSymbolName: "info.circle", accessibilityDescription: "关于") { if let infoImage = NSImage(systemSymbolName: "info.circle", accessibilityDescription: "关于") {
@@ -180,31 +229,70 @@ class BTCMenuBarApp: NSObject, ObservableObject {
} }
} }
//
@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() { @objc private func showAbout() {
let currentInterval = priceManager.getCurrentRefreshInterval()
//
let version = getAppVersion()
let alert = NSAlert() let alert = NSAlert()
alert.messageText = "BTC价格监控器" alert.messageText = "BTC价格监控器 v\(version)"
alert.informativeText = """ alert.informativeText = """
🚀 一个专业的macOS菜单栏应用用于实时显示BTC价格 🚀 一macOS 原生菜单栏应用用于实时显示BTC价格
✨ 功能特性: ✨ 功能特性:
• 实时显示BTC/USDT价格 • 实时显示BTC/USDT价格
每30秒自动刷新 可配置刷新间隔(当前:\(currentInterval.displayText)
• 支持手动刷新 (Cmd+R) • 支持手动刷新 (Cmd+R)
• 智能错误重试机制 • 智能错误重试机制
• 优雅的SF Symbols图标 • 优雅的SF Symbols图标
📊 技术信息:
数据来源币安官方API
作者:张雷
版本1.0.0
架构SwiftUI + AppKit
""" """
alert.alertStyle = .informational alert.alertStyle = .informational
alert.addButton(withTitle: "确定") alert.addButton(withTitle: "确定")
alert.runModal() 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
}
// 退 // 退
@objc private func quitApp() { @objc private func quitApp() {
NSApplication.shared.terminate(nil) NSApplication.shared.terminate(nil)

View File

@@ -2,7 +2,7 @@
// BTCPriceResponse.swift // BTCPriceResponse.swift
// test1 // test1
// //
// Created by zl_vm on 2025/10/28. // Created by Mark on 2025/10/28.
// //
import Foundation import Foundation
@@ -11,4 +11,4 @@ import Foundation
struct BTCPriceResponse: Codable { struct BTCPriceResponse: Codable {
let symbol: String let symbol: String
let price: String let price: String
} }

View File

@@ -1,24 +0,0 @@
//
// ContentView.swift
// test1
//
// Created by zl_vm on 2025/10/28.
//
import SwiftUI
struct ContentView: View {
var body: some View {
VStack {
Image(systemName: "globe")
.imageScale(.large)
.foregroundStyle(.tint)
Text("Hello, world!")
}
.padding()
}
}
#Preview {
ContentView()
}

View File

@@ -2,7 +2,7 @@
// PriceManager.swift // PriceManager.swift
// test1 // test1
// //
// Created by zl_vm on 2025/10/28. // Created by Mark on 2025/10/28.
// //
import Foundation import Foundation
@@ -17,7 +17,7 @@ class PriceManager: ObservableObject {
private let priceService = PriceService() private let priceService = PriceService()
private var timer: Timer? private var timer: Timer?
private let refreshInterval: TimeInterval = 30.0 // 30 private var currentRefreshInterval: TimeInterval = 30.0 //
init() { init() {
startPriceUpdates() startPriceUpdates()
@@ -31,28 +31,48 @@ class PriceManager: ObservableObject {
// //
func startPriceUpdates() { func startPriceUpdates() {
#if DEBUG
print("⏰ [BTC Price Manager] 启动定时器,刷新间隔: \(Int(currentRefreshInterval))")
#endif
// //
Task { Task {
await fetchPrice() await fetchPrice()
} }
// 使weak self // 使weak self
timer = Timer.scheduledTimer(withTimeInterval: refreshInterval, repeats: true) { [weak self] _ in timer = Timer.scheduledTimer(withTimeInterval: currentRefreshInterval, repeats: true) { [weak self] _ in
Task { @MainActor in Task { @MainActor in
await self?.fetchPrice() await self?.fetchPrice()
} }
} }
#if DEBUG
print("✅ [BTC Price Manager] 定时器启动成功")
#endif
} }
// //
@MainActor @MainActor
func stopPriceUpdates() { func stopPriceUpdates() {
#if DEBUG
print("⏹️ [BTC Price Manager] 停止定时器")
#endif
timer?.invalidate() timer?.invalidate()
timer = nil timer = nil
#if DEBUG
print("✅ [BTC Price Manager] 定时器已停止")
#endif
} }
// //
func refreshPrice() async { func refreshPrice() async {
#if DEBUG
print("🔄 [BTC Price Manager] 用户手动刷新价格")
#endif
await fetchPrice() await fetchPrice()
} }
@@ -61,15 +81,35 @@ class PriceManager: ObservableObject {
isFetching = true isFetching = true
lastError = nil lastError = nil
#if DEBUG
print("🔄 [BTC Price Manager] 开始获取价格...")
#endif
// 3 // 3
let maxRetries = 3 let maxRetries = 3
for attempt in 1...maxRetries { for attempt in 1...maxRetries {
#if DEBUG
print("📡 [BTC Price Manager] 尝试获取价格 (第\(attempt)次)")
#endif
do { do {
let price = try await priceService.fetchBTCPrice() let price = try await priceService.fetchBTCPrice()
currentPrice = price currentPrice = price
#if DEBUG
let formatter = DateFormatter()
formatter.timeStyle = .medium
let currentTime = formatter.string(from: Date())
print("✅ [BTC Price Manager] 价格更新成功: $\(String(format: "%.2f", price)) | 时间: \(currentTime)")
#endif
break // 退 break // 退
} catch let error as PriceError { } catch let error as PriceError {
#if DEBUG
print("❌ [BTC Price Manager] 价格获取失败 (第\(attempt)次): \(error.localizedDescription)")
#endif
if attempt == maxRetries { if attempt == maxRetries {
lastError = error lastError = error
} else { } else {
@@ -77,6 +117,10 @@ class PriceManager: ObservableObject {
try? await Task.sleep(nanoseconds: UInt64(attempt * 1_000_000_000)) // try? await Task.sleep(nanoseconds: UInt64(attempt * 1_000_000_000)) //
} }
} catch { } catch {
#if DEBUG
print("❌ [BTC Price Manager] 网络错误 (第\(attempt)次): \(error.localizedDescription)")
#endif
if attempt == maxRetries { if attempt == maxRetries {
lastError = .networkError(error) lastError = .networkError(error)
} else { } else {
@@ -86,6 +130,14 @@ class PriceManager: ObservableObject {
} }
isFetching = false isFetching = false
#if DEBUG
if let error = lastError {
print("⚠️ [BTC Price Manager] 价格获取流程结束,最终失败: \(error.localizedDescription)")
} else {
print("✅ [BTC Price Manager] 价格获取流程结束,成功")
}
#endif
} }
// //
@@ -109,4 +161,34 @@ class PriceManager: ObservableObject {
var errorMessage: String? { var errorMessage: String? {
return lastError?.localizedDescription return lastError?.localizedDescription
} }
}
// MARK: - Refresh Interval Configuration
///
/// - Parameter interval:
func updateRefreshInterval(_ interval: RefreshInterval) {
let oldInterval = RefreshInterval.allCases.first { $0.rawValue == currentRefreshInterval }?.displayText ?? "未知"
#if DEBUG
print("⏱️ [BTC Price Manager] 刷新间隔变更: \(oldInterval)\(interval.displayText)")
#endif
currentRefreshInterval = interval.rawValue
//
if timer != nil {
#if DEBUG
print("🔄 [BTC Price Manager] 重启定时器以应用新的刷新间隔")
#endif
stopPriceUpdates()
startPriceUpdates()
}
}
///
/// - Returns: RefreshInterval
func getCurrentRefreshInterval() -> RefreshInterval {
return RefreshInterval.allCases.first { $0.rawValue == currentRefreshInterval } ?? .thirtySeconds
}
}

View File

@@ -2,7 +2,7 @@
// PriceService.swift // PriceService.swift
// test1 // test1
// //
// Created by zl_vm on 2025/10/28. // Created by Mark on 2025/10/28.
// //
import Foundation import Foundation
@@ -66,4 +66,4 @@ enum PriceError: Error, LocalizedError {
return "网络错误:\(error.localizedDescription)" return "网络错误:\(error.localizedDescription)"
} }
} }
} }

View File

@@ -0,0 +1,39 @@
//
// RefreshInterval.swift
// Bitcoin Monitoring
//
// Created by Mark on 2025/10/29.
//
import Foundation
///
///
enum RefreshInterval: Double, CaseIterable, Codable {
case fiveSeconds = 5
case tenSeconds = 10
case thirtySeconds = 30
case sixtySeconds = 60
///
/// - Returns:
var displayText: String {
switch self {
case .fiveSeconds:
return "5秒"
case .tenSeconds:
return "10秒"
case .thirtySeconds:
return "30秒"
case .sixtySeconds:
return "60秒"
}
}
///
/// - Parameter isCurrent:
/// - Returns:
func displayTextWithMark(isCurrent: Bool) -> String {
return isCurrent ? "\(displayText)" : " \(displayText)"
}
}

View File

@@ -2,12 +2,6 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.files.user-selected.read-only</key>
<true/>
<key>com.apple.security.network.client</key>
<true/>
<key>com.apple.security.network.server</key> <key>com.apple.security.network.server</key>
<false/> <false/>
</dict> </dict>

View File

@@ -2,7 +2,7 @@
// test1App.swift // test1App.swift
// test1 // test1
// //
// Created by zl_vm on 2025/10/28. // Created by Mark on 2025/10/28.
// //
import SwiftUI import SwiftUI