feat: 添加Option+点击功能设置,支持复制价格和打开币安交易页面

This commit is contained in:
ZhangLei
2025-11-11 16:47:44 +08:00
parent eb27b3be0c
commit de96284ac2
7 changed files with 294 additions and 94 deletions

View File

@@ -78,7 +78,7 @@
attributes = {
BuildIndependentTargetsInParallel = YES;
LastSwiftUpdateCheck = 1620;
LastUpgradeCheck = 2600;
LastUpgradeCheck = 2610;
TargetAttributes = {
4E94106F2EB09F90003658CB = {
CreatedOnToolsVersion = 16.2;
@@ -131,6 +131,7 @@
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
@@ -196,6 +197,7 @@
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
@@ -273,7 +275,7 @@
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 13.5;
MARKETING_VERSION = 1.2.0;
MARKETING_VERSION = 1.2.1;
PRODUCT_BUNDLE_IDENTIFIER = "com.mark.bitcoin-monitoring";
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES;
@@ -306,7 +308,7 @@
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 13.5;
MARKETING_VERSION = 1.2.0;
MARKETING_VERSION = 1.2.1;
PRODUCT_BUNDLE_IDENTIFIER = "com.mark.bitcoin-monitoring";
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES;

View File

@@ -450,91 +450,130 @@ class MenuBarManager: NSObject, ObservableObject {
}
// Option
// Option+
@objc private func selectOrCopySymbol(_ sender: NSMenuItem) {
guard let data = sender.representedObject as? [String: Any] else {
print("❌ 无法获取菜单项数据")
return
}
// Option
// Option
let currentEvent = NSApp.currentEvent
let isOptionPressed = currentEvent?.modifierFlags.contains(.option) ?? false
let isCustom = data["isCustom"] as? Bool ?? false
if isOptionPressed {
//
let price = data["price"] as? Double ?? 0.0
let displayName: String
//
let displayName: String
let symbolForURL: String // URL
if isCustom {
guard let customSymbol = data["customSymbol"] as? CustomCryptoSymbol else {
print("❌ 无法获取自定义币种数据")
return
}
displayName = customSymbol.displayName
} else {
guard let symbol = data["symbol"] as? CryptoSymbol else {
print("❌ 无法获取默认币种数据")
return
}
displayName = symbol.displayName
if isCustom {
guard let customSymbol = data["customSymbol"] as? CustomCryptoSymbol else {
print("❌ 无法获取自定义币种数据")
return
}
displayName = customSymbol.displayName
symbolForURL = customSymbol.symbol // BTC, ETH
} else {
guard let symbol = data["symbol"] as? CryptoSymbol else {
print("❌ 无法获取默认币种数据")
return
}
displayName = symbol.displayName
symbolForURL = symbol.displayName // 使displayNameBTC, ETH
}
//
if price == 0.0 {
Task { @MainActor in
print("🔄 价格未加载,正在获取 \(displayName) 价格...")
var newPrice: Double?
if isOptionPressed {
// Option+
let optionAction = appSettings.optionClickAction
if isCustom, let customSymbol = data["customSymbol"] as? CustomCryptoSymbol {
newPrice = await self.priceManager.fetchCustomSymbolPrice(forApiSymbol: customSymbol.apiSymbol)
} else if let symbol = data["symbol"] as? CryptoSymbol {
newPrice = await self.priceManager.fetchSinglePrice(for: symbol)
}
switch optionAction {
case .copyPrice:
//
copyPriceToClipboard(symbol: displayName, data: data, isCustom: isCustom)
if let priceToCopy = newPrice {
let priceString = self.formatPriceWithCommas(priceToCopy)
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
pasteboard.setString("$\(priceString)", forType: .string)
print("✅ 已复制 \(displayName) 价格到剪贴板: $\(priceString)")
} else {
print("❌ 无法获取 \(displayName) 价格")
}
case .openSpotTrading:
//
let spotSuccess = BinanceURLGenerator.openSpotTradingPage(for: symbolForURL)
if spotSuccess {
print("✅ 已打开 \(displayName) 币安现货交易页面")
} else {
print("❌ 打开 \(displayName) 币安现货交易页面失败")
}
} else {
let priceString = formatPriceWithCommas(price)
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
pasteboard.setString("$\(priceString)", forType: .string)
print("✅ 已复制 \(displayName) 价格到剪贴板: $\(priceString)")
case .openFuturesTrading:
//
let futuresSuccess = BinanceURLGenerator.openFuturesTradingPage(for: symbolForURL)
if futuresSuccess {
print("✅ 已打开 \(displayName) 币安合约交易页面")
} else {
print("❌ 打开 \(displayName) 币安合约交易页面失败")
}
}
} else {
//
if isCustom, let customSymbol = data["customSymbol"] as? CustomCryptoSymbol {
// -
if let index = appSettings.customCryptoSymbols.firstIndex(of: customSymbol) {
appSettings.selectCustomCryptoSymbol(at: index)
print("✅ 已切换到自定义币种: \(customSymbol.displayName)")
//
selectSymbol(data: data, isCustom: isCustom, displayName: displayName)
}
}
//
private func copyPriceToClipboard(symbol: String, data: [String: Any], isCustom: Bool) {
let price = data["price"] as? Double ?? 0.0
//
if price == 0.0 {
Task { @MainActor in
print("🔄 价格未加载,正在获取 \(symbol) 价格...")
var newPrice: Double?
if isCustom, let customSymbol = data["customSymbol"] as? CustomCryptoSymbol {
newPrice = await self.priceManager.fetchCustomSymbolPrice(forApiSymbol: customSymbol.apiSymbol)
} else if let symbol = data["symbol"] as? CryptoSymbol {
newPrice = await self.priceManager.fetchSinglePrice(for: symbol)
}
// UI
self.priceManager.updateCryptoSymbolSettings()
// 使0.0
self.updateMenuBarTitle(price: 0.0)
} else if let symbol = data["symbol"] as? CryptoSymbol {
//
appSettings.saveSelectedSymbol(symbol)
print("✅ 已切换到默认币种: \(symbol.displayName)")
if let priceToCopy = newPrice {
let priceString = self.formatPriceWithCommas(priceToCopy)
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
pasteboard.setString("$\(priceString)", forType: .string)
// UI
self.priceManager.updateCryptoSymbolSettings()
// 使0.0
self.updateMenuBarTitle(price: 0.0)
print("✅ 已复制 \(symbol) 价格到剪贴板: $\(priceString)")
} else {
print("❌ 无法获取 \(symbol) 价格")
}
}
} else {
let priceString = formatPriceWithCommas(price)
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
pasteboard.setString("$\(priceString)", forType: .string)
print("✅ 已复制 \(symbol) 价格到剪贴板: $\(priceString)")
}
}
//
private func selectSymbol(data: [String: Any], isCustom: Bool, displayName: String) {
if isCustom, let customSymbol = data["customSymbol"] as? CustomCryptoSymbol {
// -
if let index = appSettings.customCryptoSymbols.firstIndex(of: customSymbol) {
appSettings.selectCustomCryptoSymbol(at: index)
print("✅ 已切换到自定义币种: \(displayName)")
}
// UI
self.priceManager.updateCryptoSymbolSettings()
// 使0.0
self.updateMenuBarTitle(price: 0.0)
} else if let symbol = data["symbol"] as? CryptoSymbol {
//
appSettings.saveSelectedSymbol(symbol)
print("✅ 已切换到默认币种: \(displayName)")
// UI
self.priceManager.updateCryptoSymbolSettings()
// 使0.0
self.updateMenuBarTitle(price: 0.0)
}
}

View File

@@ -9,6 +9,26 @@ import Foundation
import Combine
import ServiceManagement
/// Option+
/// Option
enum OptionClickAction: String, CaseIterable, Codable {
case copyPrice = "copyPrice"
case openSpotTrading = "openSpotTrading"
case openFuturesTrading = "openFuturesTrading"
///
var displayName: String {
switch self {
case .copyPrice:
return "复制价格"
case .openSpotTrading:
return "Binance现货交易"
case .openFuturesTrading:
return "Binance合约交易"
}
}
}
///
///
@MainActor
@@ -45,6 +65,11 @@ class AppSettings: ObservableObject {
///
@Published var proxyPassword: String = ""
// MARK: - Option+
/// Option+
@Published var optionClickAction: OptionClickAction = .copyPrice
// MARK: - Private Properties
private let defaults = UserDefaults.standard
@@ -66,6 +91,10 @@ class AppSettings: ObservableObject {
private let proxyUsernameKey = "ProxyUsername"
private let proxyPasswordKey = "ProxyPassword"
// MARK: - Option+
private let optionClickActionKey = "OptionClickAction"
// MARK: - Initialization
init() {
@@ -178,6 +207,20 @@ class AppSettings: ObservableObject {
proxyUsername = defaults.string(forKey: proxyUsernameKey) ?? ""
proxyPassword = defaults.string(forKey: proxyPasswordKey) ?? ""
// Option+
if let optionClickActionRaw = defaults.string(forKey: optionClickActionKey),
let savedAction = OptionClickAction(rawValue: optionClickActionRaw) {
optionClickAction = savedAction
#if DEBUG
print("🔧 [AppSettings] ✅ 已加载Option+点击功能: \(savedAction.displayName)")
#endif
} else {
optionClickAction = .copyPrice
#if DEBUG
print("🔧 [AppSettings] ❌ 未找到有效Option+点击功能配置,使用默认值: \(optionClickAction.displayName)")
#endif
}
//
checkAndSyncLaunchAtLoginStatus()
@@ -185,7 +228,7 @@ class AppSettings: ObservableObject {
let proxyInfo = proxyEnabled ? "\(proxyHost):\(proxyPort)" : "未启用"
let authInfo = proxyEnabled && !proxyUsername.isEmpty ? " (认证: \(proxyUsername))" : ""
let customInfo = useCustomSymbol && !customCryptoSymbols.isEmpty ? " (自定义: \(customCryptoSymbols.count)个)" : ""
print("🔧 [AppSettings] 配置加载完成 - 刷新间隔: \(refreshInterval.displayText), 币种: \(getCurrentActiveDisplayName())\(customInfo), 开机自启动: \(launchAtLogin), 代理: \(proxyInfo)\(authInfo)")
print("🔧 [AppSettings] 配置加载完成 - 刷新间隔: \(refreshInterval.displayText), 币种: \(getCurrentActiveDisplayName())\(customInfo), 开机自启动: \(launchAtLogin), 代理: \(proxyInfo)\(authInfo), Option+点击: \(optionClickAction.displayName)")
#endif
}
@@ -223,8 +266,12 @@ class AppSettings: ObservableObject {
defaults.set("", forKey: proxyUsernameKey)
defaults.set("", forKey: proxyPasswordKey)
// Option+
optionClickAction = .copyPrice
defaults.set(optionClickAction.rawValue, forKey: optionClickActionKey)
#if DEBUG
print("🔧 [AppSettings] 重置完成 - 刷新间隔: \(refreshInterval.displayText), 币种: \(selectedSymbol.displayName), 自定义币种: 已清除, 代理: 已重置")
print("🔧 [AppSettings] 重置完成 - 刷新间隔: \(refreshInterval.displayText), 币种: \(selectedSymbol.displayName), 自定义币种: 已清除, 代理: 已重置, Option+点击: \(optionClickAction.displayName)")
#endif
//
@@ -344,6 +391,19 @@ class AppSettings: ObservableObject {
return false
}
// MARK: - Option+
/// Option+
/// - Parameter action:
func saveOptionClickAction(_ action: OptionClickAction) {
optionClickAction = action
defaults.set(action.rawValue, forKey: optionClickActionKey)
#if DEBUG
print("🔧 [AppSettings] 保存Option+点击功能设置: \(action.displayName)")
#endif
}
// MARK: -
///

View File

@@ -0,0 +1,72 @@
//
// BinanceURLGenerator.swift
// Bitcoin Monitoring
//
// Created by Mark on 2025/11/11.
//
import Foundation
import AppKit
/**
* URL
* URL
*/
struct BinanceURLGenerator {
/// URL
/// - Parameter symbol: BTCETH
/// - Returns: URL
static func generateSpotTradingURL(for symbol: String) -> String {
return "https://www.binance.com/zh-CN/trade/\(symbol)_USDT?type=spot"
}
/// URL
/// - Parameter symbol: BTCETH
/// - Returns: URL
static func generateFuturesTradingURL(for symbol: String) -> String {
return "https://www.binance.com/zh-CN/futures/\(symbol)USDT"
}
///
/// - Parameter symbol:
/// - Returns:
static func openSpotTradingPage(for symbol: String) -> Bool {
let url = generateSpotTradingURL(for: symbol)
return openURL(url)
}
///
/// - Parameter symbol:
/// - Returns:
static func openFuturesTradingPage(for symbol: String) -> Bool {
let url = generateFuturesTradingURL(for: symbol)
return openURL(url)
}
/// 使URL
/// - Parameter urlString: URL
/// - Returns:
private static func openURL(_ urlString: String) -> Bool {
guard let url = URL(string: urlString) else {
#if DEBUG
print("❌ [BinanceURLGenerator] 无效的URL: \(urlString)")
#endif
return false
}
// 使NSWorkspaceURLmacOS
if #available(macOS 10.15, *) {
NSWorkspace.shared.open(url)
} else {
// macOS使NSWorkspaceAPI
let workspace = NSWorkspace.shared
workspace.open(url)
}
#if DEBUG
print("✅ [BinanceURLGenerator] 已打开URL: \(urlString)")
#endif
return true
}
}

View File

@@ -147,6 +147,10 @@ struct AboutWindowView: View {
FeatureRow(icon: "exclamationmark.triangle.fill", title: "智能重试机制", description: "网络错误自动恢复")
FeatureRow(icon: "network", title: "支持代理服务器", description: "适用于受限网络环境")
FeatureRow(icon: "bell.fill", title: "更多功能", description: "等待你的发掘!")
}
}
@@ -197,7 +201,7 @@ struct AboutWindowView: View {
}
}
.padding(24)
.frame(width: 420, height: 500)
.frame(width: 420, height: 590) //
.alert("检测更新", isPresented: $showingUpdateAlert) {
Button("确定", role: .cancel) {
// ""

View File

@@ -53,6 +53,7 @@ struct PreferencesWindowView: View {
@State private var tempProxyUsername: String
@State private var tempProxyPassword: String
@State private var tempLaunchAtLogin: Bool
@State private var tempOptionClickAction: OptionClickAction
//
@State private var showingValidationError = false
@@ -101,6 +102,7 @@ struct PreferencesWindowView: View {
self._tempProxyUsername = State(initialValue: appSettings.proxyUsername)
self._tempProxyPassword = State(initialValue: appSettings.proxyPassword)
self._tempLaunchAtLogin = State(initialValue: appSettings.launchAtLogin)
self._tempOptionClickAction = State(initialValue: appSettings.optionClickAction)
}
var body: some View {
@@ -226,11 +228,12 @@ struct PreferencesWindowView: View {
}
}
// +
// + + Option+
private var generalSettingsView: some View {
VStack(spacing: 24) {
refreshSettingsView
launchSettingsView
optionClickSettingsView
}
}
@@ -282,6 +285,37 @@ struct PreferencesWindowView: View {
}
}
// Option+
private var optionClickSettingsView: some View {
SettingsGroupView(title: "Option+点击功能", icon: "cursorarrow.click.2") {
VStack(alignment: .leading, spacing: 12) {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text("按住Option+左键功能")
.font(.subheadline)
.foregroundColor(.primary)
Text("设置按住Option键点击币种时执行的操作")
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
// 使Picker
Picker("Option+点击操作", selection: $tempOptionClickAction) {
ForEach(OptionClickAction.allCases, id: \.self) { action in
Text(action.displayName).tag(action)
}
}
.pickerStyle(MenuPickerStyle())
.frame(width: 180)
.labelsHidden()
}
}
}
}
//
private var proxySettingsView: some View {
SettingsGroupView(title: "代理设置", icon: "network") {
@@ -681,6 +715,12 @@ struct PreferencesWindowView: View {
print("✅ [Preferences] 已设置开机自启动: \(tempLaunchAtLogin)")
}
// Option+
if tempOptionClickAction != appSettings.optionClickAction {
appSettings.saveOptionClickAction(tempOptionClickAction)
print("✅ [Preferences] 已保存Option+点击功能: \(tempOptionClickAction.displayName)")
}
//
let port = Int(tempProxyPort) ?? 3128
appSettings.saveProxySettings(

View File

@@ -36,6 +36,7 @@
- **价格复制功能**: 支持一键复制当前价格到剪贴板
- **配置持久化**: 用户设置自动保存,重启后保持配置
- **开机自启动**: 可选是否开机自动启动APP
- **代理支持**: 支持 HTTP/HTTPS 代理配置,支持代理认证
### 🎨 用户体验
- **中文界面**: 完整的中文用户界面
@@ -67,7 +68,7 @@
- **部署目标**: macOS 13.1
### 网络要求
- 需要稳定的互联网连接
- 需要稳定的互联网连接,国内用户建议使用科学上网工具 或 设置代理服务器。
- 访问币安 API (`https://api.binance.com`) 的网络权限
## 🚀 快速开始
@@ -124,24 +125,6 @@ xcodebuild -project "Bitcoin Monitoring.xcodeproj" -scheme "Bitcoin Monitoring"
- **自定义币种组件**: 专门的币种管理界面,支持添加、删除和切换自定义币种
- **图标缓存系统**: `CryptoIconGenerator` 实现图标生成和缓存机制,避免重复生成
### 并发处理
```swift
// 主线程 UI 更新
@MainActor
class BTCMenuBarApp: ObservableObject
// 异步网络请求
func fetchPrice() async throws -> Double
// Combine 响应式流
priceManager.$currentPrice
.receive(on: DispatchQueue.main)
.sink { [weak self] price in
self?.updateMenuBarTitle(price: price)
}
```
## 🔧 API 集成
### 币安 API 端点
@@ -161,7 +144,7 @@ GET https://api.binance.com/api/v3/ticker/price?symbol={SYMBOL}
- **网络异常处理**: 用户友好的错误提示和状态显示
- **代理配置支持**: 完整的 HTTP/HTTPS 代理配置和连接测试
## ⚙️ 配置管理
## ⚙️ 配置管理(持久化配置)
### UserDefaults 键值
- `BTCRefreshInterval`: 刷新间隔设置
@@ -171,9 +154,7 @@ GET https://api.binance.com/api/v3/ticker/price?symbol={SYMBOL}
- `SelectedCustomSymbolIndex`: 当前选中的自定义币种索引
- `UseCustomSymbol`: 是否使用自定义币种
- `ProxyEnabled/ProxyHost/ProxyPort/ProxyUsername/ProxyPassword`: 代理设置(包括认证)
### 配置持久化
所有用户设置通过 `AppSettings` 类的 `@Published` 属性自动保存到 UserDefaults应用重启后保持配置。使用 Combine 框架实现配置变化的实时响应。
- `OptionClickAction`: 选项点击操作设置
## 🔧 故障排除
@@ -184,10 +165,12 @@ GET https://api.binance.com/api/v3/ticker/price?symbol={SYMBOL}
**问题**: 双击应用图标无反应
**解决方案**:
```bash
# 指定该命令以启动APP
# 执行该命令以启动APP
sudo xattr -d com.apple.quarantine "/Applications/Bitcoin Monitoring.app"
# 或者在系统偏好设置中允许应用运行
# 或者
# 系统偏好设置中允许应用运行
系统设置 → 隐私与安全性 → 安全性 → 已阻止“Bitcoin Monitoring.app”以保护Mac → 仍要打开
```